From da88cef30675e93f528f9e888094fc2b67d56ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 16 Jul 2026 21:03:09 +0200 Subject: [PATCH 1/4] =?UTF-8?q?refactor(import=5Fpolon):=20Faza=201=20?= =?UTF-8?q?=E2=80=94=20rdze=C5=84=20importu=20przyjmuje=20liveops=20Progre?= =?UTF-8?q?ss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rdzeń analyze_file_import_polon / analyze_file_import_absencji dostaje wymagany parametr ``p`` (liveops.Progress): pętle przechodzą na ``p.track(...)`` (throttlowany pasek + check_cancelled per wiersz), a błędy wczytania pliku raportują ``p.log(...)`` zamiast ręcznych ``send_progress`` / ``send_notification``. Model wciąż jest legacy ``Operation``, więc ``perform()`` buduje LOKALNY ``_LegacyProgressBridge`` forwardujący emisje do starego API ``ASGINotificationMixin`` (pasek postępu na dotychczasowej stronie details działa dalej między Fazą 1 a 2). ``check_cancelled`` jest no-op — legacy Operation nie ma pola ``cancel_requested``, więc realny track w celery nie rzuci ValueError. Call-site'y testów rdzenia przechodzą na ``MockProgress(model)``. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/import_polon/core/import_absencji.py | 14 ++++---- src/import_polon/core/import_polon.py | 16 ++++----- src/import_polon/models.py | 34 +++++++++++++++++-- .../tests/test_import_absencji_core.py | 5 +-- .../tests/test_import_polon_core.py | 13 +++---- .../tests/test_import_polon_ignoruj.py | 7 ++-- .../tests/test_import_polon_override.py | 9 ++--- .../tests/test_multi_uczelnia_scoping.py | 5 +-- 8 files changed, 70 insertions(+), 33 deletions(-) diff --git a/src/import_polon/core/import_absencji.py b/src/import_polon/core/import_absencji.py index 1128341f4..0bb6f5d61 100644 --- a/src/import_polon/core/import_absencji.py +++ b/src/import_polon/core/import_absencji.py @@ -63,7 +63,7 @@ def _process_absence_record(autor, rok: int, ile_dni: int, zapisz: bool) -> str: return rezultat -def analyze_file_import_absencji(fn, parent_model: ImportPlikuAbsencji): +def analyze_file_import_absencji(fn, parent_model: ImportPlikuAbsencji, p): try: data = read_excel_or_csv_dataframe_guess_encoding(fn) except ValueError as e: @@ -79,7 +79,7 @@ def analyze_file_import_absencji(fn, parent_model: ImportPlikuAbsencji): nr_wiersza=0, rezultat=f"Błąd: {error_msg}", ) - parent_model.send_notification(error_msg, "error") + p.log(error_msg) raise ValueError(error_msg) from e except Exception as e: # Handle any other unexpected errors @@ -94,13 +94,17 @@ def analyze_file_import_absencji(fn, parent_model: ImportPlikuAbsencji): nr_wiersza=0, rezultat=error_msg, ) - parent_model.send_notification(error_msg, "error") + p.log(error_msg) raise # pandas.read_excel(fn, header=0).replace({numpy.nan: None}) records = data.to_dict("records") total = len(records) - for n_row, row in enumerate(records): + # ``p.track`` aktualizuje pasek postępu (throttlowany) i sprawdza anulowanie + # przed każdym wierszem — zastępuje ręczne ``send_progress``. + for n_row, row in p.track( + list(enumerate(records)), total=total, label="Import absencji" + ): autor = matchuj_autora( imiona=(row.get("IMIE", "") or "").strip(), nazwisko=(row.get("NAZWISKO", "") or "").strip(), @@ -126,5 +130,3 @@ def analyze_file_import_absencji(fn, parent_model: ImportPlikuAbsencji): ile_dni=ile_dni, rezultat=rezultat, ) - - parent_model.send_progress(n_row * 100.0 / total) diff --git a/src/import_polon/core/import_polon.py b/src/import_polon/core/import_polon.py index d95e18836..ccd411bb9 100644 --- a/src/import_polon/core/import_polon.py +++ b/src/import_polon/core/import_polon.py @@ -447,7 +447,7 @@ def _update_autor_orcid(autor, orcid, parent_model): return ops -def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): +def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon, p): try: data = read_excel_or_csv_dataframe_guess_encoding(fn) except ValueError as e: @@ -463,7 +463,7 @@ def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): nr_wiersza=0, rezultat=f"Błąd: {error_msg}", ) - parent_model.send_notification(error_msg, "error") + p.log(error_msg) raise ValueError(error_msg) from e except Exception as e: # Handle any other unexpected errors @@ -478,12 +478,16 @@ def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): nr_wiersza=0, rezultat=error_msg, ) - parent_model.send_notification(error_msg, "error") + p.log(error_msg) raise records = data.to_dict("records") total = len(records) - for n_row, row in enumerate(records): + # ``p.track`` aktualizuje pasek postępu (throttlowany) i sprawdza anulowanie + # przed każdym wierszem — zastępuje ręczne ``send_progress`` z każdej gałęzi. + for n_row, row in p.track( + list(enumerate(records)), total=total, label="Import POLON" + ): # Validate employment - skip if invalid (unless ignored) if not parent_model.ignoruj_miejsce_pracy: zatrudnienie = row.get("ZATRUDNIENIE", "") @@ -502,7 +506,6 @@ def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): zatrudnienie, parent_model.uczelnia ), ) - parent_model.send_progress(n_row * 100.0 / total) continue # Match author @@ -539,7 +542,6 @@ def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): f"modyfikować danych obcej uczelni." ), ) - parent_model.send_progress(n_row * 100.0 / total) continue # Skip unmatched authors if configured @@ -607,5 +609,3 @@ def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): subdyscyplina_naukowa=subdyscyplina_xlsx, rezultat=rezultat, ) - - parent_model.send_progress(n_row * 100.0 / total) diff --git a/src/import_polon/models.py b/src/import_polon/models.py index e502016f8..180dea6a2 100644 --- a/src/import_polon/models.py +++ b/src/import_polon/models.py @@ -1,6 +1,7 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.db.models import Count, Q +from liveops.progress import Progress from bpp.fields import YearField from bpp.models import Autor, Dyscyplina_Naukowa @@ -8,6 +9,35 @@ from long_running.notification_mixins import ASGINotificationMixin +class _LegacyProgressBridge(Progress): + """Most zgodności Fazy 1: rdzeń importu przyjmuje już ``Progress`` (liveops), + ale model nadal jest legacy ``Operation`` (bez pola ``cancel_requested``). + + FORWARDUJE emisje do starego API ``ASGINotificationMixin`` — pasek postępu + na dotychczasowej stronie details działa dalej między Fazą 1 a 2. NIE jest + to czysty no-op ani ``TextProgress``: ``check_cancelled`` MUSI być no-op, bo + ``Progress.track`` wołałby ``refresh_from_db(fields=["cancel_requested"])``, + a legacy ``Operation`` tego pola nie ma → ``ValueError`` w realnej ścieżce + celery. Cała klasa znika w Fazie 2 (model przechodzi na ``LiveOperation``). + """ + + def _emit_percent(self, value: int) -> None: + self._operation.send_progress(value) + + def status(self, text: str, level: str = "info") -> None: + self._operation.send_notification(text, level) + + def log(self, line: str) -> None: + self._operation.send_notification(line, "info") + + def error(self, message: str) -> None: + self._operation.send_notification(message, "error") + + def check_cancelled(self) -> None: + # Legacy Operation nie ma pola cancel_requested — no-op (patrz docstring). + pass + + class ImportPlikuAbsencji(ASGINotificationMixin, Operation): plik = models.FileField(max_length=255, upload_to="protected/import_polon") zapisz_zmiany_do_bazy = models.BooleanField(default=False) @@ -18,7 +48,7 @@ def on_reset(self): def perform(self): from .core.import_absencji import analyze_file_import_absencji - analyze_file_import_absencji(self.plik.path, self) + analyze_file_import_absencji(self.plik.path, self, _LegacyProgressBridge(self)) def get_details_set(self): return self.wierszimportuplikuabsencji_set.all() @@ -69,7 +99,7 @@ def on_reset(self): def perform(self): from import_polon.core import analyze_file_import_polon - analyze_file_import_polon(self.plik.path, self) + analyze_file_import_polon(self.plik.path, self, _LegacyProgressBridge(self)) def get_details_set(self): return WierszImportuPlikuPolon.objects.filter(parent=self) diff --git a/src/import_polon/tests/test_import_absencji_core.py b/src/import_polon/tests/test_import_absencji_core.py index f4ceb55a9..bc3aa73b4 100644 --- a/src/import_polon/tests/test_import_absencji_core.py +++ b/src/import_polon/tests/test_import_absencji_core.py @@ -1,4 +1,5 @@ import pytest +from liveops.testing import MockProgress from model_bakery import baker from bpp.models import Autor @@ -19,7 +20,7 @@ def test_analyze_excel_file_import_absencji(fn_test_import_absencji): ) adam_nowak_2: Autor = baker.make(Autor, nazwisko="Nowak", imiona="Adam") - analyze_file_import_absencji(fn_test_import_absencji, ipa) + analyze_file_import_absencji(fn_test_import_absencji, ipa, MockProgress(ipa)) assert jan_kowalski.autor_absencja_set.get(rok=2017).ile_dni == 141 assert jan_kowalski.autor_absencja_set.get(rok=2018).ile_dni == 352 @@ -32,7 +33,7 @@ def test_analyze_excel_file_import_absencji(fn_test_import_absencji): def test_analyze_excel_file_import_absencji_zly_plik(fn_test_import_polon): ipa = baker.make(ImportPlikuAbsencji, zapisz_zmiany_do_bazy=True) - analyze_file_import_absencji(fn_test_import_polon, ipa) + analyze_file_import_absencji(fn_test_import_polon, ipa, MockProgress(ipa)) @pytest.mark.django_db diff --git a/src/import_polon/tests/test_import_polon_core.py b/src/import_polon/tests/test_import_polon_core.py index df22e4a04..7bfb757f3 100644 --- a/src/import_polon/tests/test_import_polon_core.py +++ b/src/import_polon/tests/test_import_polon_core.py @@ -11,6 +11,7 @@ from denorm import denorms from django.core.files import File from django.db import transaction +from liveops.testing import MockProgress from model_bakery import baker from bpp.models import ( @@ -30,7 +31,7 @@ def test_analyze_excel_file_import_polon_zly_plik(fn_test_import_absencji): ipp: ImportPlikuPolon = baker.make( ImportPlikuPolon, zapisz_zmiany_do_bazy=True, rok=ROK ) - analyze_file_import_polon(fn_test_import_absencji, ipp) + analyze_file_import_polon(fn_test_import_absencji, ipp, MockProgress(ipp)) @pytest.mark.django_db @@ -42,7 +43,7 @@ def test_analyze_excel_file_import_polon_plik_bez_dyscyplin( ImportPlikuPolon, zapisz_zmiany_do_bazy=True, rok=ROK ) baker.make(Autor, nazwisko="Kowalski", imiona="Aleksander Bolesław") - analyze_file_import_polon(fn_test_import_polon_bledny, ipp) + analyze_file_import_polon(fn_test_import_polon_bledny, ipp, MockProgress(ipp)) def test_analyze_excel_file_import_polon( @@ -109,7 +110,7 @@ def test_analyze_excel_file_import_polon( rodzaj_autora=rodzaj_autora_z, ) - analyze_file_import_polon(fn_test_import_polon, ipp) + analyze_file_import_polon(fn_test_import_polon, ipp, MockProgress(ipp)) assert ( artur_dyscyplinazn.autor_dyscyplina_set.get(rok=ROK).rodzaj_autora @@ -157,7 +158,7 @@ def test_analyze_file_import_polon_with_invalid_zatrudnienie(tmp_path, uczelnia) import_model.plik.save("test_invalid_zatrudnienie.xlsx", File(f)) # Run import - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) # Check results - both records should be ignored results = WierszImportuPlikuPolon.objects.filter(parent=import_model) @@ -203,7 +204,7 @@ def test_analyze_file_import_polon_with_valid_zatrudnienie(tmp_path, uczelnia): import_model.plik.save("test_valid_zatrudnienie.xlsx", File(f)) # Run import - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) # Check results - record should be processed (not ignored) results = WierszImportuPlikuPolon.objects.filter(parent=import_model) @@ -238,7 +239,7 @@ def test_analyze_excel_file_import_polon_badawczy_type( ) # Run import - analyze_file_import_polon(fn_test_import_polon, ipp) + analyze_file_import_polon(fn_test_import_polon, ipp, MockProgress(ipp)) # Check if author was classified as type 'B' based on GRUPA_STANOWISK try: diff --git a/src/import_polon/tests/test_import_polon_ignoruj.py b/src/import_polon/tests/test_import_polon_ignoruj.py index 0905cda6a..5ee90c0a4 100644 --- a/src/import_polon/tests/test_import_polon_ignoruj.py +++ b/src/import_polon/tests/test_import_polon_ignoruj.py @@ -8,6 +8,7 @@ import pandas as pd import pytest from django.core.files import File +from liveops.testing import MockProgress from model_bakery import baker from bpp.models import Autor @@ -46,7 +47,7 @@ def test_analyze_file_import_polon_ignoruj_miejsce_pracy_true(tmp_path, uczelnia import_model.plik.save("test_ignoruj_miejsce_pracy.xlsx", File(f)) # Run import - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) # Check results - records should NOT be ignored due to ZATRUDNIENIE results = WierszImportuPlikuPolon.objects.filter(parent=import_model) @@ -88,7 +89,7 @@ def test_analyze_file_import_polon_ignoruj_miejsce_pracy_false_default( import_model.plik.save("test_ignoruj_miejsce_pracy_false.xlsx", File(f)) # Run import - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) # Check results - record should be ignored results = WierszImportuPlikuPolon.objects.filter(parent=import_model) @@ -142,7 +143,7 @@ def test_analyze_file_import_polon_ignoruj_miejsce_pracy_processes_author( import_model.plik.save("test_ignoruj_autor.xlsx", File(f)) # Run import - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) # Check that author was matched and processed results = WierszImportuPlikuPolon.objects.filter(parent=import_model) diff --git a/src/import_polon/tests/test_import_polon_override.py b/src/import_polon/tests/test_import_polon_override.py index a48eaeb93..a16384123 100644 --- a/src/import_polon/tests/test_import_polon_override.py +++ b/src/import_polon/tests/test_import_polon_override.py @@ -8,6 +8,7 @@ import pandas as pd import pytest from django.core.files import File +from liveops.testing import MockProgress from model_bakery import baker from bpp.models import Autor @@ -59,7 +60,7 @@ def test_import_polon_override_badawczy_true( import_model.plik.save("test_override_true.xlsx", File(f)) # Run import - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) # Check if author was classified as type 'B' due to override ad = autor.autor_dyscyplina_set.get(rok=2023) @@ -110,7 +111,7 @@ def test_import_polon_override_badawczy_false( import_model.plik.save("test_override_false.xlsx", File(f)) # Run import - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) # Check if author was classified as type 'Z' (not B) due to wymuszenie ad = autor.autor_dyscyplina_set.get(rok=2023) @@ -157,7 +158,7 @@ def test_import_polon_no_override_uses_default_logic( import_model.plik.save("test_no_override.xlsx", File(f)) # Run import - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) # Check if author was classified as type 'B' using default logic ad = autor.autor_dyscyplina_set.get(rok=2023) @@ -207,7 +208,7 @@ def test_import_polon_override_case_insensitive( import_model.plik.save("test_override_case.xlsx", File(f)) # Run import - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) # Check if author was classified as type 'B' despite different case ad = autor.autor_dyscyplina_set.get(rok=2023) diff --git a/src/import_polon/tests/test_multi_uczelnia_scoping.py b/src/import_polon/tests/test_multi_uczelnia_scoping.py index 26fca98a9..e8048a20c 100644 --- a/src/import_polon/tests/test_multi_uczelnia_scoping.py +++ b/src/import_polon/tests/test_multi_uczelnia_scoping.py @@ -17,6 +17,7 @@ import pandas as pd import pytest from django.utils import timezone +from liveops.testing import MockProgress from model_bakery import baker from bpp.models import Autor, Jednostka, Uczelnia, Wydzial @@ -189,7 +190,7 @@ def test_walidacja_zatrudnienia_zawezona_do_uczelni_importu(tmp_path): zapisz_zmiany_do_bazy=False, ukryj_niezmatchowanych_autorow=False, ) - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) wiersze = {w.dane_z_xls["NAZWISKO"]: w for w in import_model.get_details_set()} assert "REKORD ZIGNOROWANY" not in wiersze["Iksinski"].rezultat, ( @@ -236,7 +237,7 @@ def test_import_nie_modyfikuje_autora_innej_uczelni(tmp_path): ignoruj_miejsce_pracy=True, ukryj_niezmatchowanych_autorow=False, ) - analyze_file_import_polon(str(test_file), import_model) + analyze_file_import_polon(str(test_file), import_model, MockProgress(import_model)) wiersze = {w.dane_z_xls["NAZWISKO"]: w for w in import_model.get_details_set()} assert "innej uczelni" in wiersze["Igrekowa"].rezultat.lower(), ( From 10a95dda81f9b0d22e836437ff08f0b6b40147c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 16 Jul 2026 21:12:51 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(import=5Fpolon):=20Faza=202=20?= =?UTF-8?q?=E2=80=94=20atomowy=20cutover=20na=20django-liveops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oba importery (POLON + absencji) przechodzą z long_running (Operation + ASGINotificationMixin) na liveops.LiveOperation: kanały liveop., podpisany subscription_token, snapshot-on-connect, throttlowany progress, runner celery. Cutover jest atomowy — model, widoki, URL-e, szablony oraz domknięcie dry-run→„zapisz do bazy" muszą przejść w jednym zielonym commicie (wspólna klasa bazowa, jedna migracja, wspólny ZapiszDoBazyMixin, jeden urls.py). Model (0017_liveops): AlterModelOptions ordering=-created_on, usunięcie last_updated_on, +10 pól liveops, owner related_name="+". run(self, p) woła rdzeń z Progress i finalizuje p.result({"total": ...}); on_restart() kasuje wiersze-dzieci; reset_liveops_state() zeruje stan przed re-enqueue. Widoki: PokazImporty/PokazImportyAbsencji = owner-scoped ListView (zachowane zawężenie multi-hosted + per-uczelnia trim dla POLON); UtworzImport* = CreateLiveOperationView (POLON przypina uczelnię z requestu); *ResultsView = ListView z paginate_by=25 + object=parent_object (inaczej NoReverseMatch / cicha utrata paginacji), zachowany filter_form + raport niezmatchowanych; ZapiszDoBazyMixin owner-scope bez long_running, POST: flip flagi + on_restart + reset + transaction.on_commit(enqueue), redirect na get_absolute_url; restart gejtowany grupą (_PkOwnerRestartMixin(GroupRequiredMixin, RestartView), precedens #508 F4). Szablony: nowe host/result (import_pliku_polon[_result].html + absencji) z OBOWIĄZKOWYM wrapperem CSRF (CSRF_COOKIE_HTTPONLY=True); liczniki z operation.get_details_set() (stare importy mają result_context=NULL); nowy importplikuabsencji_list.html (bugfix index-absencji); linki na get_absolute_url; usunięte *_detail.html + include operation_details. Testy: przepisany test_zapisz_do_bazy.py (11) na liveops enqueue; nowe test_liveops.py (run finalizuje, strona live + wrapper CSRF, restart gejtowany grupą — asercja po efekcie ubocznym, 302→login nie 403). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/import_polon/migrations/0017_liveops.py | 149 ++++++++++++++++++ src/import_polon/models.py | 117 +++++++++----- .../import_polon/import_pliku_absencji.html | 26 +++ .../import_pliku_absencji_result.html | 23 +++ .../import_polon/import_pliku_polon.html | 34 ++++ .../import_pliku_polon_result.html | 24 +++ .../importplikuabsencji_detail.html | 19 --- .../importplikuabsencji_list.html | 36 +++++ .../import_polon/importplikupolon_detail.html | 19 --- .../import_polon/importplikupolon_list.html | 2 +- .../import_polon/potwierdz_zapis_do_bazy.html | 2 +- .../wierszimportuplikuabsencji_list.html | 2 - .../wierszimportuplikupolon_list.html | 26 --- src/import_polon/tests/test_liveops.py | 140 ++++++++++++++++ src/import_polon/tests/test_zapisz_do_bazy.py | 6 +- src/import_polon/urls.py | 22 +-- src/import_polon/views/plik_absencji.py | 69 +++++--- src/import_polon/views/plik_polon.py | 121 +++++++++----- 18 files changed, 641 insertions(+), 196 deletions(-) create mode 100644 src/import_polon/migrations/0017_liveops.py create mode 100644 src/import_polon/templates/import_polon/import_pliku_absencji.html create mode 100644 src/import_polon/templates/import_polon/import_pliku_absencji_result.html create mode 100644 src/import_polon/templates/import_polon/import_pliku_polon.html create mode 100644 src/import_polon/templates/import_polon/import_pliku_polon_result.html delete mode 100644 src/import_polon/templates/import_polon/importplikuabsencji_detail.html create mode 100644 src/import_polon/templates/import_polon/importplikuabsencji_list.html delete mode 100644 src/import_polon/templates/import_polon/importplikupolon_detail.html create mode 100644 src/import_polon/tests/test_liveops.py diff --git a/src/import_polon/migrations/0017_liveops.py b/src/import_polon/migrations/0017_liveops.py new file mode 100644 index 000000000..1f2550fc9 --- /dev/null +++ b/src/import_polon/migrations/0017_liveops.py @@ -0,0 +1,149 @@ +# Generated by Django 5.2.16 on 2026-07-16 19:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("import_polon", "0016_importplikupolon_uczelnia"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name="importplikuabsencji", + options={"ordering": ["-created_on"]}, + ), + migrations.AlterModelOptions( + name="importplikupolon", + options={"ordering": ["-created_on"]}, + ), + migrations.RemoveField( + model_name="importplikuabsencji", + name="last_updated_on", + ), + migrations.RemoveField( + model_name="importplikupolon", + name="last_updated_on", + ), + migrations.AddField( + model_name="importplikuabsencji", + name="cancel_requested", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="cancelled", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="current_stage", + field=models.IntegerField(default=-1), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="language", + field=models.CharField(blank=True, default="", max_length=20), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="log", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="log_seq", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="percent", + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="result_context", + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="stage_states", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="status_text", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AddField( + model_name="importplikupolon", + name="cancel_requested", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="importplikupolon", + name="cancelled", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="importplikupolon", + name="current_stage", + field=models.IntegerField(default=-1), + ), + migrations.AddField( + model_name="importplikupolon", + name="language", + field=models.CharField(blank=True, default="", max_length=20), + ), + migrations.AddField( + model_name="importplikupolon", + name="log", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="importplikupolon", + name="log_seq", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="importplikupolon", + name="percent", + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.AddField( + model_name="importplikupolon", + name="result_context", + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name="importplikupolon", + name="stage_states", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="importplikupolon", + name="status_text", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="importplikuabsencji", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="importplikupolon", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/src/import_polon/models.py b/src/import_polon/models.py index 180dea6a2..bae9fc01c 100644 --- a/src/import_polon/models.py +++ b/src/import_polon/models.py @@ -1,54 +1,89 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.db.models import Count, Q -from liveops.progress import Progress +from liveops.models import LiveOperation from bpp.fields import YearField from bpp.models import Autor, Dyscyplina_Naukowa -from long_running.models import Operation -from long_running.notification_mixins import ASGINotificationMixin - -class _LegacyProgressBridge(Progress): - """Most zgodności Fazy 1: rdzeń importu przyjmuje już ``Progress`` (liveops), - ale model nadal jest legacy ``Operation`` (bez pola ``cancel_requested``). - - FORWARDUJE emisje do starego API ``ASGINotificationMixin`` — pasek postępu - na dotychczasowej stronie details działa dalej między Fazą 1 a 2. NIE jest - to czysty no-op ani ``TextProgress``: ``check_cancelled`` MUSI być no-op, bo - ``Progress.track`` wołałby ``refresh_from_db(fields=["cancel_requested"])``, - a legacy ``Operation`` tego pola nie ma → ``ValueError`` w realnej ścieżce - celery. Cała klasa znika w Fazie 2 (model przechodzi na ``LiveOperation``). - """ - - def _emit_percent(self, value: int) -> None: - self._operation.send_progress(value) - - def status(self, text: str, level: str = "info") -> None: - self._operation.send_notification(text, level) - - def log(self, line: str) -> None: - self._operation.send_notification(line, "info") - - def error(self, message: str) -> None: - self._operation.send_notification(message, "error") - - def check_cancelled(self) -> None: - # Legacy Operation nie ma pola cancel_requested — no-op (patrz docstring). - pass - - -class ImportPlikuAbsencji(ASGINotificationMixin, Operation): +# Pola stanu operacji liveops zerowane przed (po)ponownym enqueue. Zwierciadło +# ``liveops.views.RestartView.post`` (liveops inline'uje ten reset, nie wystawia +# go jako metody modelu) — trzymamy JEDNĄ kopię, żeby ``ZapiszDoBazyMixin`` nie +# zdryfował. Bez tego ``cancel_requested=True`` po anulowanym runie od razu +# ubiłby nowy przebieg. +_POLA_LIVEOPS_RESET = ( + "finished_on", + "started_on", + "finished_successfully", + "cancelled", + "cancel_requested", + "traceback", + "result_context", + "current_stage", + "stage_states", + "log", + "percent", + "log_seq", +) + + +class _LiveopsResetMixin: + """Wspólny ``reset_liveops_state`` dla importów POLON/absencji.""" + + def reset_liveops_state(self): + """Zeruje pola stanu operacji liveops (jak ``RestartView.post``), tak by + kolejny ``enqueue()`` wystartował z czystym przebiegiem. NIE zapisuje + (caller składa ``update_fields``) i NIE woła ``enqueue``. Zwraca listę + ustawionych pól — do doklejenia w ``save(update_fields=)``.""" + self.finished_on = None + self.started_on = None + self.finished_successfully = False + self.cancelled = False + self.cancel_requested = False + self.traceback = None + self.result_context = None + self.current_stage = -1 + self.stage_states = {} + self.log = [] + self.percent = 0 + self.log_seq = 0 + return list(_POLA_LIVEOPS_RESET) + + +def _uruchom_import(parent, p, analyze): + """Wspólny ``run`` dla obu importów: woła rdzeń z liveops ``Progress`` i + finalizuje wynikiem (``total`` z wierszy-dzieci). + + ``liveops.runner._handle_error`` zapisuje traceback WYŁĄCZNIE do pola + ``traceback`` (bez śladu na konsoli workera i bez rollbara). Owijamy + właściwy przebieg, żeby błąd był WIDOCZNY: surowy traceback na stderr + (konsola celery/run-site) + rollbar (konwencja bg-tasków), po czym + re-raise — liveops i tak zapisze traceback do bazy i pokaże błąd w UI.""" + try: + analyze(parent.plik.path, parent, p) + except Exception: + import sys + import traceback as _traceback + + import rollbar + + _traceback.print_exc() + rollbar.report_exc_info(sys.exc_info()) + raise + p.result({"total": parent.get_details_set().count()}) + + +class ImportPlikuAbsencji(_LiveopsResetMixin, LiveOperation): plik = models.FileField(max_length=255, upload_to="protected/import_polon") zapisz_zmiany_do_bazy = models.BooleanField(default=False) - def on_reset(self): + def on_restart(self): self.wierszimportuplikuabsencji_set.all().delete() - def perform(self): + def run(self, p): from .core.import_absencji import analyze_file_import_absencji - analyze_file_import_absencji(self.plik.path, self, _LegacyProgressBridge(self)) + _uruchom_import(self, p, analyze_file_import_absencji) def get_details_set(self): return self.wierszimportuplikuabsencji_set.all() @@ -70,7 +105,7 @@ class Meta: ordering = ("nr_wiersza",) -class ImportPlikuPolon(ASGINotificationMixin, Operation): +class ImportPlikuPolon(_LiveopsResetMixin, LiveOperation): rok = YearField() plik = models.FileField(max_length=255, upload_to="protected/import_polon") ukryj_niezmatchowanych_autorow = models.BooleanField(default=True) @@ -93,13 +128,13 @@ class ImportPlikuPolon(ASGINotificationMixin, Operation): help_text="Uczelnia, dla której wykonywany jest import (multi-hosted).", ) - def on_reset(self): + def on_restart(self): self.wierszimportuplikupolon_set.all().delete() - def perform(self): + def run(self, p): from import_polon.core import analyze_file_import_polon - analyze_file_import_polon(self.plik.path, self, _LegacyProgressBridge(self)) + _uruchom_import(self, p, analyze_file_import_polon) def get_details_set(self): return WierszImportuPlikuPolon.objects.filter(parent=self) diff --git a/src/import_polon/templates/import_polon/import_pliku_absencji.html b/src/import_polon/templates/import_polon/import_pliku_absencji.html new file mode 100644 index 000000000..c832910ec --- /dev/null +++ b/src/import_polon/templates/import_polon/import_pliku_absencji.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% load static liveops %} + +{% block extratitle %} + Import absencji - {{ object.plik.name }} +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • import absencji
  • +
  • import {{ object.plik.name }}
  • +{% endblock %} + +{% block content %} +

    Import absencji — przetwarzanie

    + + {# Region live-operacji + wrapper CSRF (CSRF_COOKIE_HTTPONLY=True) — patrz #} + {# import_pliku_polon.html po komentarz szczegółowy. #} +
    + {% live_operation object %} +
    + + + + +{% endblock %} diff --git a/src/import_polon/templates/import_polon/import_pliku_absencji_result.html b/src/import_polon/templates/import_polon/import_pliku_absencji_result.html new file mode 100644 index 000000000..42f45613f --- /dev/null +++ b/src/import_polon/templates/import_polon/import_pliku_absencji_result.html @@ -0,0 +1,23 @@ +{# Fragment wyniku importu absencji (liveops result_template_name). #} +{# Liczniki z operation.get_details_set() — stare importy mają #} +{# result_context=NULL. #} +
    +

    Import absencji zakończony

    +

    Przetworzono wierszy: {{ operation.get_details_set.count }}.

    + {% if not operation.zapisz_zmiany_do_bazy %} +

    Import uruchomiono w trybie podglądu — zmiany NIE + zostały zapisane do bazy danych.

    + {% endif %} +
    + + Zobacz wyniki + + {% if not operation.zapisz_zmiany_do_bazy %} + + Zapisz ten import do bazy danych + + {% endif %} +
    +
    diff --git a/src/import_polon/templates/import_polon/import_pliku_polon.html b/src/import_polon/templates/import_polon/import_pliku_polon.html new file mode 100644 index 000000000..8bc91f7b0 --- /dev/null +++ b/src/import_polon/templates/import_polon/import_pliku_polon.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% load static liveops %} + +{% block extratitle %} + Import z POLON - {{ object.plik.name }} +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • import POLON
  • +
  • import {{ object.plik.name }}
  • +{% endblock %} + +{% block content %} +

    Import z POLON — przetwarzanie

    + + {# Region live-operacji: kontener z data-liveop-channel/token + strefy #} + {# op-status/op-progress/op-log/op-result (OOB-swap z workera). Panel #} + {# wyniku (z linkiem do szczegółów) pojawia się w #op-result dopiero po #} + {# zakończeniu importu — patrz import_pliku_polon_result.html. #} + {# hx-headers wstrzykuje token CSRF nagłówkiem X-CSRFToken: przyciski #} + {# Anuluj/Ponów liveops to gołe przyciski htmx poza formularzem, a BPP ma #} + {# CSRF_COOKIE_HTTPONLY=True, więc liveops.js nie odczyta tokenu z ciasteczka #} + {# (getCookie zwraca pusty łańcuch) → POST bez tokenu = 403 CSRF token missing. #} +
    + {% live_operation object %} +
    + + {# Kolejność ładowania jest istotna: htmx → klient channels_broadcast → #} + {# liveops.js (patchuje channelsBroadcast.addMessage i inicjuje socket). #} + + + +{% endblock %} diff --git a/src/import_polon/templates/import_polon/import_pliku_polon_result.html b/src/import_polon/templates/import_polon/import_pliku_polon_result.html new file mode 100644 index 000000000..681088746 --- /dev/null +++ b/src/import_polon/templates/import_polon/import_pliku_polon_result.html @@ -0,0 +1,24 @@ +{# Fragment wyniku (liveops result_template_name). Renderowany po p.result() #} +{# jako OOB-swap #op-result oraz przy wejściu na zakończoną operację. #} +{# Liczniki liczymy z operation.get_details_set() — stare importy mają #} +{# result_context=NULL, więc nie polegamy wyłącznie na kontekście wyniku. #} +
    +

    Import z POLON zakończony

    +

    Przetworzono wierszy: {{ operation.get_details_set.count }}.

    + {% if not operation.zapisz_zmiany_do_bazy %} +

    Import uruchomiono w trybie podglądu — zmiany NIE + zostały zapisane do bazy danych.

    + {% endif %} +
    + + Zobacz wyniki + + {% if not operation.zapisz_zmiany_do_bazy %} + + Zapisz ten import do bazy danych + + {% endif %} +
    +
    diff --git a/src/import_polon/templates/import_polon/importplikuabsencji_detail.html b/src/import_polon/templates/import_polon/importplikuabsencji_detail.html deleted file mode 100644 index 6457baf7a..000000000 --- a/src/import_polon/templates/import_polon/importplikuabsencji_detail.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %}{% load render_table from django_tables2 %} - -{% block extratitle %} - Import absencji - szczegóły {{ object.plik.name }} -{% endblock %} - -{% block breadcrumbs %} - {{ block.super }} -
  • import absencji
  • -
  • import {{ object.plik.name }}
  • -{% endblock %} - - -{% block content %} -

    Import absencji {{ object.plik.name }}

    - - {% include "long_running/operation_details.html" %} - -{% endblock %} diff --git a/src/import_polon/templates/import_polon/importplikuabsencji_list.html b/src/import_polon/templates/import_polon/importplikuabsencji_list.html new file mode 100644 index 000000000..d5a72ad12 --- /dev/null +++ b/src/import_polon/templates/import_polon/importplikuabsencji_list.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block extratitle %} + Import absencji +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • import absencji
  • +{% endblock %} + +{% block content %} +

    Ostatnio importowane absencje: + + + utwórz nowy import + +

    +
      + {% for object in object_list %} +
    • + plik {{ object.plik.name }}: + import utworzono {{ object.created_on }}{% if object.finished_on %}, ukończono {{ object.finished_on }} + {% if object.finished_successfully %}, zakończono pomyślnie{% else %}, zakończono z błędem{% endif %} + {% endif %} +
    • + {% endfor %} +
    + + {% if object_list.count == 0 %} + + {% endif %} + +{% endblock %} diff --git a/src/import_polon/templates/import_polon/importplikupolon_detail.html b/src/import_polon/templates/import_polon/importplikupolon_detail.html deleted file mode 100644 index 938fe07ba..000000000 --- a/src/import_polon/templates/import_polon/importplikupolon_detail.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %}{% load render_table from django_tables2 %} - -{% block extratitle %} - Import POLON - szczegóły {{ object.plik.name }} -{% endblock %} - -{% block breadcrumbs %} - {{ block.super }} -
  • import Polon
  • -
  • import {{ object.plik.name }}
  • -{% endblock %} - - -{% block content %} -

    Import danych {{ object.plik.name }}

    - - {% include "long_running/operation_details.html" %} - -{% endblock %} diff --git a/src/import_polon/templates/import_polon/importplikupolon_list.html b/src/import_polon/templates/import_polon/importplikupolon_list.html index e274adca5..ecfadfaf0 100644 --- a/src/import_polon/templates/import_polon/importplikupolon_list.html +++ b/src/import_polon/templates/import_polon/importplikupolon_list.html @@ -19,7 +19,7 @@

    Ostatnio importowane dane:
      {% for object in object_list %}
    • - plik {{ object.plik.name }}{% if object.uczelnia %} {{ object.uczelnia }}{% endif %}: + plik {{ object.plik.name }}{% if object.uczelnia %} {{ object.uczelnia }}{% endif %}: import utworzono {{ object.created_on }}{% if object.finished_on %}, ukończono {{ object.finished_on }} {% if object.finished_successfully %}, zakończono pomyślnie{% else %}, zakończono z błędem{% endif %} {% endif %} diff --git a/src/import_polon/templates/import_polon/potwierdz_zapis_do_bazy.html b/src/import_polon/templates/import_polon/potwierdz_zapis_do_bazy.html index e3bab8b27..b60e24927 100644 --- a/src/import_polon/templates/import_polon/potwierdz_zapis_do_bazy.html +++ b/src/import_polon/templates/import_polon/potwierdz_zapis_do_bazy.html @@ -20,6 +20,6 @@

      Zapisać import do bazy danych?

      - Anuluj + Anuluj {% endblock %} diff --git a/src/import_polon/templates/import_polon/wierszimportuplikuabsencji_list.html b/src/import_polon/templates/import_polon/wierszimportuplikuabsencji_list.html index dc0579b07..57828933d 100644 --- a/src/import_polon/templates/import_polon/wierszimportuplikuabsencji_list.html +++ b/src/import_polon/templates/import_polon/wierszimportuplikuabsencji_list.html @@ -33,8 +33,6 @@

      Zmiany nie zostały wprowadzone do bazy danych.

      Import został uruchomi {% endif %} - {% include "long_running/operation_details.html" %} - {% if object.finished_successfully %} {% include "pagination.html" %} diff --git a/src/import_polon/templates/import_polon/wierszimportuplikupolon_list.html b/src/import_polon/templates/import_polon/wierszimportuplikupolon_list.html index d7644f079..2bcb79247 100644 --- a/src/import_polon/templates/import_polon/wierszimportuplikupolon_list.html +++ b/src/import_polon/templates/import_polon/wierszimportuplikupolon_list.html @@ -182,32 +182,6 @@

      Zmiany nie zostały wprowadzone do bazy danych.

      Import został uruchomi {% endif %} - - - - {% if object.finished_successfully and unmatched_count > 0 %}
      Autorzy z przypisaniami dyscyplin w BPP, którzy nie byli w pliku Excel ({{ unmatched_count }})
      diff --git a/src/import_polon/tests/test_liveops.py b/src/import_polon/tests/test_liveops.py new file mode 100644 index 000000000..c5e548296 --- /dev/null +++ b/src/import_polon/tests/test_liveops.py @@ -0,0 +1,140 @@ +"""Testy migracji importów POLON/absencji na django-liveops. + +Pokrywają: finalizację ``run(self, p)`` (MockProgress), renderowanie strony +live (host template + wrapper CSRF) oraz gejtowany grupą restart. +""" + +import pytest +from django.contrib.auth.models import Group +from django.core.files import File +from django.urls import reverse +from liveops.testing import MockProgress +from model_bakery import baker + +from import_polon.models import ( + ImportPlikuAbsencji, + ImportPlikuPolon, + WierszImportuPlikuPolon, +) + +GRUPA = "wprowadzanie danych" + + +# --- run() finalizuje operację ---------------------------------------------- + + +@pytest.mark.django_db +def test_run_finalizuje_polon(admin_user, fn_test_import_polon): + imp = baker.make( + ImportPlikuPolon, owner=admin_user, rok=2023, zapisz_zmiany_do_bazy=False + ) + with open(fn_test_import_polon, "rb") as f: + imp.plik.save("test_import_polon.xlsx", File(f)) + + imp.run(MockProgress(imp)) + + imp.refresh_from_db() + assert imp.finished_successfully is True + n = imp.get_details_set().count() + assert n > 0, "run() musi utworzyć wiersze-dzieci" + assert imp.result_context == {"total": n} + + +@pytest.mark.django_db +def test_run_finalizuje_absencji(admin_user, fn_test_import_absencji): + imp = baker.make(ImportPlikuAbsencji, owner=admin_user, zapisz_zmiany_do_bazy=False) + with open(fn_test_import_absencji, "rb") as f: + imp.plik.save("test_import_absencji.xlsx", File(f)) + + imp.run(MockProgress(imp)) + + imp.refresh_from_db() + assert imp.finished_successfully is True + n = imp.get_details_set().count() + assert n > 0 + assert imp.result_context == {"total": n} + + +# --- strona live (host template + wrapper CSRF) ----------------------------- + + +@pytest.mark.django_db +def test_liveops_live_view_renderuje_host(admin_client, admin_user): + imp = baker.make(ImportPlikuPolon, owner=admin_user, rok=2023) + + assert imp.get_absolute_url() == (f"/live/import_polon.importplikupolon/{imp.pk}/") + + response = admin_client.get(imp.get_absolute_url()) + + assert response.status_code == 200 + content = response.content.decode() + # kontener liveops (WS binding) + assert "data-liveop-channel" in content + assert "data-liveop-token" in content + # wrapper CSRF (CSRF_COOKIE_HTTPONLY=True → X-CSRFToken przez hx-headers) + assert "X-CSRFToken" in content + + +@pytest.mark.django_db +def test_liveops_live_view_absencji_url(admin_user): + imp = baker.make(ImportPlikuAbsencji, owner=admin_user) + assert imp.get_absolute_url() == ( + f"/live/import_polon.importplikuabsencji/{imp.pk}/" + ) + + +# --- restart gejtowany grupą (precedens #508 F4) ---------------------------- + + +def _polon_z_wierszami(owner): + """Zakończony pomyślnie import POLON z dwoma wierszami-dziećmi (bez pliku — + ponowny run i tak by padł, ale nas interesuje wyłącznie efekt uboczny + bramki: czy wiersze-dzieci zostały skasowane / operacja zresetowana).""" + from django.utils import timezone + + imp = baker.make( + ImportPlikuPolon, + owner=owner, + rok=2023, + zapisz_zmiany_do_bazy=False, + started_on=timezone.now(), + finished_on=timezone.now(), + finished_successfully=True, + ) + baker.make(WierszImportuPlikuPolon, parent=imp, nr_wiersza=1) + baker.make(WierszImportuPlikuPolon, parent=imp, nr_wiersza=2) + return imp + + +@pytest.mark.django_db +def test_restart_bez_grupy_nie_zmienia_stanu(client, django_user_model): + u = django_user_model.objects.create_user(username="plain-restart", password="x") + client.force_login(u) + imp = _polon_z_wierszami(u) + url = reverse("import_polon:importplikupolon-restart", kwargs={"pk": imp.pk}) + + resp = client.post(url) + + # GroupRequiredMixin ma raise_exception=False → 302 (redirect_to_login), + # NIE 403. Kluczowe: brak efektu ubocznego. + assert resp.status_code == 302 + imp.refresh_from_db() + assert imp.finished_successfully is True, "operacja NIE mogła zostać zresetowana" + assert imp.wierszimportuplikupolon_set.count() == 2, "wiersze-dzieci nietknięte" + + +@pytest.mark.django_db +def test_restart_z_grupa_przechodzi(client, django_user_model): + u = django_user_model.objects.create_user(username="entry-restart", password="x") + u.groups.add(Group.objects.get_or_create(name=GRUPA)[0]) + client.force_login(u) + imp = _polon_z_wierszami(u) + url = reverse("import_polon:importplikupolon-restart", kwargs={"pk": imp.pk}) + + client.post(url) + + imp.refresh_from_db() + # Bramka przeszła: on_restart() skasował wiersze-dzieci, a reset zdjął + # znacznik pomyślnego zakończenia (ponowny run bez pliku kończy się błędem). + assert imp.wierszimportuplikupolon_set.count() == 0 + assert imp.finished_successfully is False diff --git a/src/import_polon/tests/test_zapisz_do_bazy.py b/src/import_polon/tests/test_zapisz_do_bazy.py index cfee61eda..3806acf91 100644 --- a/src/import_polon/tests/test_zapisz_do_bazy.py +++ b/src/import_polon/tests/test_zapisz_do_bazy.py @@ -20,10 +20,10 @@ def _reimport_scheduled(callbacks): """Ile spośród callbacków ``on_commit`` to nasze ponowne uruchomienie importu. Middleware audytu (django-easy-audit) rejestruje własne callbacki, więc nie - można liczyć wszystkich — filtrujemy po tym, który pochodzi z - ``LongRunningTaskCallerMixin.task_on_commit``. + można liczyć wszystkich — filtrujemy po ``LiveOperation.enqueue``, który + ``ZapiszDoBazyMixin.post`` odracza przez ``transaction.on_commit``. """ - return sum("task_on_commit" in cb.__qualname__ for cb in callbacks) + return sum("enqueue" in cb.__qualname__ for cb in callbacks) def _finished_dry_run(model, owner, **extra): diff --git a/src/import_polon/urls.py b/src/import_polon/urls.py index 15644b556..5c0549a33 100644 --- a/src/import_polon/urls.py +++ b/src/import_polon/urls.py @@ -7,16 +7,6 @@ urlpatterns = [ path("dane/", views.PokazImporty.as_view(), name="index"), path("dane/nowy/", views.UtworzImportPlikuPolon.as_view(), name="utworz-import"), - path( - "dane//", - views.ImportPolonRouterView.as_view(), - name="importplikupolon-router", - ), - path( - "dane//details/", - views.ImportPolonDetailsView.as_view(), - name="importplikupolon-details", - ), path( "dane//regen/", views.RestartImportView.as_view(), @@ -32,22 +22,12 @@ views.ImportPolonResultsView.as_view(), name="importplikupolon-results", ), - path("absencje/", views.PokazImporty.as_view(), name="index-absencji"), + path("absencje/", views.PokazImportyAbsencji.as_view(), name="index-absencji"), path( "absencje/nowy/", views.UtworzImportPlikuAbsencji.as_view(), name="utworz-import-absencji", ), - path( - "absencje//", - views.ImportAbsencjiRouterView.as_view(), - name="importplikuabsencji-router", - ), - path( - "absencje//details/", - views.ImportAbsencjiDetailsView.as_view(), - name="importplikuabsencji-details", - ), path( "absencje//regen/", views.RestartImportAbsencjiView.as_view(), diff --git a/src/import_polon/views/plik_absencji.py b/src/import_polon/views/plik_absencji.py index 1aabe03cc..b0a6b0717 100644 --- a/src/import_polon/views/plik_absencji.py +++ b/src/import_polon/views/plik_absencji.py @@ -1,13 +1,14 @@ +from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property +from django.views.generic import ListView +from liveops.views import CreateLiveOperationView + from import_polon.forms import NowyImportAbsencjiForm from import_polon.models import ImportPlikuAbsencji -from import_polon.views.plik_polon import BaseImportPlikuPolonMixin, ZapiszDoBazyMixin -from long_running.views import ( - CreateLongRunningOperationView, - LongRunningDetailsView, - LongRunningOperationsView, - LongRunningResultsView, - LongRunningRouterView, - RestartLongRunningOperationView, +from import_polon.views.plik_polon import ( + BaseImportPlikuPolonMixin, + ZapiszDoBazyMixin, + _PkOwnerRestartMixin, ) @@ -15,33 +16,51 @@ class BaseImportPlikuAbsencjiMixin(BaseImportPlikuPolonMixin): model = ImportPlikuAbsencji -class PokazImportPlikuAbsencji(BaseImportPlikuAbsencjiMixin, LongRunningOperationsView): - pass - +class PokazImportyAbsencji(BaseImportPlikuAbsencjiMixin, ListView): + """Lista importów absencji bieżącego użytkownika (owner-scoped). -class UtworzImportPlikuAbsencji( - BaseImportPlikuAbsencjiMixin, CreateLongRunningOperationView -): - form_class = NowyImportAbsencjiForm + Absencje NIE mają pola ``uczelnia`` (import globalny, nie per-uczelnia), + więc bez zawężenia multi-hosted. Bugfix: dawniej ``index-absencji`` + mapował na ``PokazImporty`` (listę POLON) — teraz ma własny widok + + szablon ``importplikuabsencji_list.html``. + """ + max_previous_ops = 10 + template_name = "import_polon/importplikuabsencji_list.html" -class ImportAbsencjiRouterView(BaseImportPlikuAbsencjiMixin, LongRunningRouterView): - redirect_prefix = "import_polon:importplikabsencji" + def get_queryset(self): + qset = self.model.objects.filter(owner=self.request.user).order_by( + "-created_on" + ) + for elem in qset[self.max_previous_ops :]: + elem.delete() + return qset -class ImportAbsencjiDetailsView(BaseImportPlikuAbsencjiMixin, LongRunningDetailsView): - pass +class UtworzImportPlikuAbsencji(BaseImportPlikuAbsencjiMixin, CreateLiveOperationView): + form_class = NowyImportAbsencjiForm -class RestartImportAbsencjiView( - BaseImportPlikuAbsencjiMixin, RestartLongRunningOperationView -): - pass +class RestartImportAbsencjiView(_PkOwnerRestartMixin): + model = ImportPlikuAbsencji class ZapiszDoBazyImportAbsencjiView(BaseImportPlikuAbsencjiMixin, ZapiszDoBazyMixin): pass -class ImportAbsencjiResultsView(BaseImportPlikuAbsencjiMixin, LongRunningResultsView): - pass +class ImportAbsencjiResultsView(BaseImportPlikuAbsencjiMixin, ListView): + paginate_by = 25 + + @cached_property + def parent_object(self): + return get_object_or_404( + ImportPlikuAbsencji, pk=self.kwargs["pk"], owner=self.request.user + ) + + def get_queryset(self): + return self.parent_object.get_details_set() + + def get_context_data(self, **kwargs): + # object=self.parent_object OBOWIĄZKOWE (szablon woła {% url ... object.pk %}). + return super().get_context_data(object=self.parent_object, **kwargs) diff --git a/src/import_polon/views/plik_polon.py b/src/import_polon/views/plik_polon.py index 8e64a899b..ed1129e05 100644 --- a/src/import_polon/views/plik_polon.py +++ b/src/import_polon/views/plik_polon.py @@ -1,36 +1,37 @@ from braces.views import GroupRequiredMixin +from django.contrib.auth.mixins import LoginRequiredMixin from django.db import transaction from django.db.models import Q from django.http import HttpResponseRedirect -from django.views.generic import DetailView +from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property +from django.views.generic import DetailView, ListView +from liveops.views import CreateLiveOperationView, RestartView from import_polon.forms import NowyImportForm, WierszImportuPlikuPolonFilterForm from import_polon.models import ImportPlikuPolon -from long_running.views import ( - CreateLongRunningOperationView, - LongRunningDetailsView, - LongRunningOperationsView, - LongRunningResultsView, - LongRunningRouterView, - LongRunningTaskCallerMixin, - RestartLongRunningOperationView, - RestrictToOwnerMixin, -) - - -class ZapiszDoBazyMixin(RestrictToOwnerMixin, LongRunningTaskCallerMixin, DetailView): + + +class ZapiszDoBazyMixin(LoginRequiredMixin, DetailView): """Domknięcie dry-runa zapisem do bazy. Gdy import uruchomiono bez zapisu (``zapisz_zmiany_do_bazy=False``), ta akcja przestawia flagę na ``True`` i resetuje obiekt do ponownego uruchomienia — „taki reset, ALE tym razem modyfikuj bazę". GET pokazuje stronę potwierdzenia; dopiero POST modyfikuje bazę (prefetch/robot nie wywoła - zapisu). ``mark_reset`` czyści stan i kasuje wiersze-dzieci; ponowne - ``perform()`` — tym razem z zapisem — kolejkuje ``task_on_commit``. + zapisu). ``on_restart()`` kasuje wiersze-dzieci, ``reset_liveops_state()`` + czyści stan operacji; ponowny ``enqueue()`` — tym razem z zapisem. + + Owner-scope własnym ``get_queryset`` (bez ``long_running`` — Faza 4 wymaga + braku importów z tamtego pakietu). Bramka grupy dochodzi w podklasach + (``GroupRequiredMixin`` przez ``BaseImportPlikuPolonMixin``). """ template_name = "import_polon/potwierdz_zapis_do_bazy.html" + def get_queryset(self): + return self.model.objects.filter(owner=self.request.user) + @transaction.atomic def post(self, *args, **kwargs): # select_for_update: blokuje wiersz na czas transakcji, więc dwa @@ -41,11 +42,18 @@ def post(self, *args, **kwargs): # użytkownik musiał zobaczyć podgląd. Odsiewa import w trakcie, zakończony # błędem oraz już zapisany do bazy (ochrona przed podwójnym zapisem). if self.object.finished_successfully and not self.object.zapisz_zmiany_do_bazy: - # Flaga trafi do bazy przez pełny save() w mark_reset() poniżej. self.object.zapisz_zmiany_do_bazy = True - self.object.mark_reset() - self.task_on_commit(pk=self.object.pk) - return HttpResponseRedirect("..") + self.object.on_restart() # kasuje wiersze-dzieci poprzedniego runu + pola = self.object.reset_liveops_state() + self.object.save(update_fields=["zapisz_zmiany_do_bazy", *pola]) + # ``enqueue()`` przy RUNNER="celery" woła .delay() BEZ on_commit — + # w bloku atomic worker mógłby czytać stan sprzed commitu. Odraczamy + # do on_commit, żeby ponowny przebieg widział flagę + wyczyszczony + # stan już utrwalone w bazie. + transaction.on_commit(self.object.enqueue) + # Stary „.." (router URL) kasowany w tej fazie → 404. Kierujemy na + # centralną stronę live liveops. + return HttpResponseRedirect(self.object.get_absolute_url()) class BaseImportPlikuPolonMixin(GroupRequiredMixin): @@ -53,7 +61,16 @@ class BaseImportPlikuPolonMixin(GroupRequiredMixin): model = ImportPlikuPolon -class PokazImporty(BaseImportPlikuPolonMixin, LongRunningOperationsView): +class PokazImporty(BaseImportPlikuPolonMixin, ListView): + """Lista importów bieżącego użytkownika (owner-scoped, zawężona do uczelni). + + Dawniej ``long_running.LongRunningOperationsView``; teraz zwykły ListView. + Strona live (postęp/wynik) jest osobno, pod centralnym ``liveops:live`` + (link przez ``object.get_absolute_url``). + """ + + max_previous_ops = 10 + def get_queryset(self): # Multi-hosted: importy pokazuj tylko na stronie uczelni, z której # zostały zaczęte (uczelnia z requestu: domena→Site→Uczelnia). Stare @@ -66,7 +83,9 @@ def get_queryset(self): # importy uczelni Y właściciela. from bpp.models import Uczelnia - qset = RestrictToOwnerMixin.get_queryset(self).order_by("-last_updated_on") + qset = self.model.objects.filter(owner=self.request.user).order_by( + "-created_on" + ) uczelnia = Uczelnia.objects.get_for_request(self.request) if uczelnia is not None: @@ -78,7 +97,7 @@ def get_queryset(self): return qset -class UtworzImportPlikuPolon(BaseImportPlikuPolonMixin, CreateLongRunningOperationView): +class UtworzImportPlikuPolon(BaseImportPlikuPolonMixin, CreateLiveOperationView): form_class = NowyImportForm def form_valid(self, form): @@ -86,32 +105,59 @@ def form_valid(self, form): # Uczelnia). Zawęża późniejszą walidację ZATRUDNIENIE, dopasowanie # autora i raport niezmatchowanych do tej jednej uczelni. Gdy nie da # się rozstrzygnąć (single-host bez domeny / pusta baza) → None → - # zachowanie wsteczne bez zawężenia. + # zachowanie wsteczne bez zawężenia. Owner + save + enqueue + redirect + # na get_absolute_url robi bazowe CreateLiveOperationView.form_valid. from bpp.models import Uczelnia form.instance.uczelnia = Uczelnia.objects.get_for_request(self.request) return super().form_valid(form) -class ImportPolonRouterView(BaseImportPlikuPolonMixin, LongRunningRouterView): - redirect_prefix = "import_polon:importplikuabsencji" +class _PkOwnerRestartMixin(GroupRequiredMixin, RestartView): + """Gejtowany restart importu (POLON/absencji). + URL restartu ma tylko ``pk`` (bez ``op_type``), więc nadpisujemy + ``OpTypeObjectMixin.get_object`` i rozwiązujemy konkretny model wprost, + owner-scoped. -class ImportPolonDetailsView(BaseImportPlikuPolonMixin, LongRunningDetailsView): - pass + Bramka grupy (precedens #508 F4): liveops ``BaseLiveOperationMixin`` gejtuje + tylko gdy ``LIVEOPS["REQUIRED_GROUP"]`` ustawione — w BPP NIE jest, więc + tamta bramka to no-op. Dokładamy braces ``GroupRequiredMixin`` (konwencja + projektu, jak reszta widoków importu). Restart z ``zapisz_zmiany_do_bazy= + True`` PONOWNIE zapisuje do bazy (akcja destrukcyjna) — nie zostawiamy go + owner-only. + """ + model = ImportPlikuPolon + group_required = "wprowadzanie danych" -class RestartImportView(BaseImportPlikuPolonMixin, RestartLongRunningOperationView): - pass + def get_object(self, queryset=None): + return get_object_or_404( + self.model, pk=self.kwargs["pk"], owner=self.request.user + ) + + +class RestartImportView(_PkOwnerRestartMixin): + model = ImportPlikuPolon class ZapiszDoBazyImportView(BaseImportPlikuPolonMixin, ZapiszDoBazyMixin): pass -class ImportPolonResultsView(BaseImportPlikuPolonMixin, LongRunningResultsView): +class ImportPolonResultsView(BaseImportPlikuPolonMixin, ListView): + # paginate_by OBOWIĄZKOWE: szablon renderuje stronicowanie, a bez tego + # tysiące wierszy trafiłoby na jedną stronę (cicha utrata paginacji). + paginate_by = 25 + + @cached_property + def parent_object(self): + return get_object_or_404( + ImportPlikuPolon, pk=self.kwargs["pk"], owner=self.request.user + ) + def get_queryset(self): - queryset = super().get_queryset() + queryset = self.parent_object.get_details_set() # Get filter parameters autor_wiersz = self.request.GET.get("autor_wiersz", "").strip() @@ -155,12 +201,12 @@ def get_queryset(self): return queryset def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + # object=self.parent_object OBOWIĄZKOWE: szablon woła {% url ... object.pk %} + # (bez tego NoReverseMatch). + context = super().get_context_data(object=self.parent_object, **kwargs) # Get base queryset for form choices - base_queryset = ImportPlikuPolon.objects.get( - pk=self.kwargs["pk"] - ).get_details_set() + base_queryset = self.parent_object.get_details_set() # Create filter form with current GET parameters and queryset for choices filter_form = WierszImportuPlikuPolonFilterForm( @@ -182,8 +228,7 @@ def get_context_data(self, **kwargs): # realną jednostkę (obecnie lub historycznie; jednostki obce pomijane — # patrz ImportPlikuPolon.autorzy_niezmatchowani) — bez tego raport # wyciekałby autorów innych uczelni współistniejących w bazie. - import_object = ImportPlikuPolon.objects.get(pk=self.kwargs["pk"]) - unmatched_autor_dyscyplina = import_object.autorzy_niezmatchowani() + unmatched_autor_dyscyplina = self.parent_object.autorzy_niezmatchowani() context["unmatched_autor_dyscyplina"] = unmatched_autor_dyscyplina context["unmatched_count"] = unmatched_autor_dyscyplina.count() From 90c6413f93d43cc6c6b407c823cd917d4d84f546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 16 Jul 2026 21:13:28 +0200 Subject: [PATCH 3/4] =?UTF-8?q?docs(import=5Fpolon):=20newsfragmenty=20?= =?UTF-8?q?=E2=80=94=20liveops=20+=20bugfix=20index-absencji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bpp/newsfragments/import-polon-index-absencji.bugfix.rst | 2 ++ src/bpp/newsfragments/import-polon-liveops.feature.rst | 5 +++++ 2 files changed, 7 insertions(+) create mode 100644 src/bpp/newsfragments/import-polon-index-absencji.bugfix.rst create mode 100644 src/bpp/newsfragments/import-polon-liveops.feature.rst diff --git a/src/bpp/newsfragments/import-polon-index-absencji.bugfix.rst b/src/bpp/newsfragments/import-polon-index-absencji.bugfix.rst new file mode 100644 index 000000000..33bcba81a --- /dev/null +++ b/src/bpp/newsfragments/import-polon-index-absencji.bugfix.rst @@ -0,0 +1,2 @@ +Strona „import absencji" pokazuje teraz listę importów absencji zamiast — +jak dotąd — listy importów POLON. diff --git a/src/bpp/newsfragments/import-polon-liveops.feature.rst b/src/bpp/newsfragments/import-polon-liveops.feature.rst new file mode 100644 index 000000000..5ae399e61 --- /dev/null +++ b/src/bpp/newsfragments/import-polon-liveops.feature.rst @@ -0,0 +1,5 @@ +Import z POLON i import absencji przeniesiono ze starego mechanizmu +``long_running`` na ``django-liveops``: postęp na żywo przez WebSocket + +HTMX, anulowanie i ponawianie operacji, wynik renderowany bez odświeżania +strony. Zachowano wielohostowe zawężanie importu POLON do uczelni oraz +domknięcie podglądu zapisem do bazy. From 7c7a017772b7250adb11a9edd0b44ddbb93d5732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 16 Jul 2026 21:30:44 +0200 Subject: [PATCH 4/4] =?UTF-8?q?test(import=5Fpolon):=20regresja=20anon?= =?UTF-8?q?=E2=86=92302=20(nie=20500)=20na=20gejtowanych=20widokach=20live?= =?UTF-8?q?ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review PR #617: dodane testy potwierdzające, że anonim na utworz-import (CreateLiveOperationView) i restart (_PkOwnerRestartMixin/RestartView) dostaje 302→login, nie 500. braces GroupRequiredMixin.dispatch jest najbardziej z lewej w MRO i short-circuituje anona przed liveops BaseLiveOperationMixin (który woła bez-argumentowe handle_no_permission) — POLON nie ma kolizji, którą raport_slotow rozwiązuje compat-mixinem. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/import_polon/tests/test_anon_probe.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/import_polon/tests/test_anon_probe.py diff --git a/src/import_polon/tests/test_anon_probe.py b/src/import_polon/tests/test_anon_probe.py new file mode 100644 index 000000000..c2292a26e --- /dev/null +++ b/src/import_polon/tests/test_anon_probe.py @@ -0,0 +1,21 @@ +import uuid + +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +def test_anon_utworz_nie_daje_500(client): + """Anon GET na gejtowany liveops CreateView — 302→login, NIE 500.""" + resp = client.get(reverse("import_polon:utworz-import")) + assert resp.status_code != 500, "500-for-anon (handle_no_permission MRO bug)" + assert resp.status_code == 302 + + +@pytest.mark.django_db +def test_anon_restart_nie_daje_500(client): + """Anon POST na gejtowany liveops RestartView — 302→login, NIE 500.""" + url = reverse("import_polon:importplikupolon-restart", args=[uuid.uuid4()]) + resp = client.post(url) + assert resp.status_code != 500, "500-for-anon (handle_no_permission MRO bug)" + assert resp.status_code == 302