From 26f0adce3cf694bab5df78434814a92fbecaa906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 3 Jul 2026 17:02:23 +0200 Subject: [PATCH 1/5] feat(import_list_ministerialnych): migracja z long_running na django-liveops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Konwersja najbardziej rozbudowanego importu (filtrowana tabela wyników, statystyki, detal wiersza, tracking duplikatów) z wewnętrznego `src/long_running` na pakiet `django-liveops` (live UI: WebSocket + HTMX). Infra: - django-liveops>=0.1,<0.2 (PyPI) w zależnościach - INSTALLED_APPS: liveops + bpp_liveops; blok LIVEOPS (RUNNER=celery) - asgi: LiveOperationConsumer (drop-in nadzbiór NotificationsConsumer) - root urls: centralny namespace ^live/ (liveops:live/cancel/restart) Cienka warstwa bpp_liveops: - BppLiveOperation (abstract): reset_children() + readable_exception() - centralne generyczne widoki rozwiązujące model po UUID - gating grup przez braces (superuser-exempt, parytet z long_running) Konwersja aplikacji: - model: Operation+ASGINotificationMixin -> BppLiveOperation; perform() -> run(self, p); on_reset -> reset_children - core: send_progress -> p.track (dorzuca anulowanie); send_notification -> p.log - widoki: create -> CreateLiveOperationView; router/details/restart usunięte; tabela wyników jako owner-scoped ListView - szablony: host-page + fragment wyniku; usunięte include long_running - migracja 0009 (usuwa last_updated_on, dodaje pola liveops) Testy: 13 zastanych zaktualizowanych (FakeProgress) + 2 nowe integracyjne (run() -> finalizacja z result_context; render centralnej strony live). 15 passed. Baseline do odświeżenia przy scalaniu. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 3 + ...ps-import-list-ministerialnych.feature.rst | 5 + src/bpp_liveops/__init__.py | 0 src/bpp_liveops/apps.py | 6 + src/bpp_liveops/migrations/__init__.py | 0 src/bpp_liveops/models.py | 39 ++++ src/bpp_liveops/urls.py | 11 ++ src/bpp_liveops/views.py | 93 ++++++++++ src/django_bpp/asgi.py | 10 +- src/django_bpp/settings/base.py | 18 ++ src/django_bpp/urls.py | 7 + src/import_list_ministerialnych/core.py | 19 +- ...ortlistministerialnych_options_and_more.py | 79 +++++++++ src/import_list_ministerialnych/models.py | 26 ++- .../import_list_ministerialnych.html | 33 ++++ .../import_list_ministerialnych_result.html | 14 ++ .../importlistministerialnych_detail.html | 19 -- .../importlistministerialnych_list.html | 2 +- ...wierszimportulistyministerialnej_list.html | 1 - src/import_list_ministerialnych/tests.py | 167 +++++++++++++++--- src/import_list_ministerialnych/urls.py | 18 +- src/import_list_ministerialnych/views.py | 82 +++++---- uv.lock | 16 ++ 23 files changed, 562 insertions(+), 106 deletions(-) create mode 100644 src/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst create mode 100644 src/bpp_liveops/__init__.py create mode 100644 src/bpp_liveops/apps.py create mode 100644 src/bpp_liveops/migrations/__init__.py create mode 100644 src/bpp_liveops/models.py create mode 100644 src/bpp_liveops/urls.py create mode 100644 src/bpp_liveops/views.py create mode 100644 src/import_list_ministerialnych/migrations/0009_alter_importlistministerialnych_options_and_more.py create mode 100644 src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html create mode 100644 src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych_result.html delete mode 100644 src/import_list_ministerialnych/templates/import_list_ministerialnych/importlistministerialnych_detail.html diff --git a/pyproject.toml b/pyproject.toml index 4d1371eac..5fc38ab88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,6 +155,9 @@ dependencies = [ "django-first-run-wizard>=0.1.1", "uvicorn-worker>=0.4.0", "babel>=2.17", + # django-liveops: framework dla dlugo-dzialajacych operacji z live UI + # (WebSocket + HTMX). Nastepca wewnetrznego `src/long_running`. + "django-liveops>=0.1,<0.2", ] [project.optional-dependencies] diff --git a/src/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst b/src/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst new file mode 100644 index 000000000..552264f0d --- /dev/null +++ b/src/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst @@ -0,0 +1,5 @@ +Import list ministerialnych korzysta teraz z pakietu ``django-liveops`` +(podgląd postępu na żywo przez WebSocket + HTMX) zamiast wewnętrznej +aplikacji ``long_running``. Dodano cienką warstwę ``bpp_liveops`` +(``BppLiveOperation`` + centralne, generyczne widoki live/cancel/restart), +która pozostaje do wykorzystania przy migracji kolejnych importów. diff --git a/src/bpp_liveops/__init__.py b/src/bpp_liveops/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/bpp_liveops/apps.py b/src/bpp_liveops/apps.py new file mode 100644 index 000000000..bab8d3547 --- /dev/null +++ b/src/bpp_liveops/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BppLiveopsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bpp_liveops" diff --git a/src/bpp_liveops/migrations/__init__.py b/src/bpp_liveops/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/bpp_liveops/models.py b/src/bpp_liveops/models.py new file mode 100644 index 000000000..96124fa93 --- /dev/null +++ b/src/bpp_liveops/models.py @@ -0,0 +1,39 @@ +from liveops.models import LiveOperation + + +class BppLiveOperation(LiveOperation): + """Cienka warstwa BPP nad ``liveops.LiveOperation``. + + Dokłada konwencje wspólne dla operacji BPP, których standardowy liveops + nie ma (bo są specyficzne dla naszego wzorca importów z rekordami-dziećmi): + + - ``reset_children()`` — hook czyszczący rekordy potomne przy restarcie. + Odpowiednik ``on_reset()`` ze starego ``long_running.models.Operation``. + Standardowy ``liveops.views.RestartView`` resetuje tylko pola bazowe + operacji i NIE wie o rekordach-dzieciach — dlatego ``BppRestartView`` + woła ten hook przed ponownym zakolejkowaniem. + - ``readable_exception()`` — ostatnia niepusta linia tracebacku, do + pokazania w szablonach (jak w starym ``Operation``). + + Klasa jest abstrakcyjna — nie tworzy własnej tabeli. Konkretne operacje + (import list ministerialnych itd.) dziedziczą po niej i implementują + ``run(self, p)``. + """ + + class Meta(LiveOperation.Meta): + # Dziedziczymy po LiveOperation.Meta, żeby zachować ordering + # (["-created_on"]); samo `class Meta: abstract=True` by je zgubiło. + abstract = True + + def reset_children(self): + """Wyczyść rekordy potomne przy restarcie operacji. + + Domyślnie no-op. Podklasy z rekordami-dziećmi (np. wiersze importu) + nadpisują to, kasując swój ``*_set``. + """ + + def readable_exception(self): + if not self.traceback: + return None + lines = [line for line in self.traceback.split("\n") if line] + return lines[-1] if lines else None diff --git a/src/bpp_liveops/urls.py b/src/bpp_liveops/urls.py new file mode 100644 index 000000000..49a8c6dc6 --- /dev/null +++ b/src/bpp_liveops/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from bpp_liveops import views + +app_name = "liveops" + +urlpatterns = [ + path("/", views.BppLiveView.as_view(), name="live"), + path("/cancel/", views.BppCancelView.as_view(), name="cancel"), + path("/restart/", views.BppRestartView.as_view(), name="restart"), +] diff --git a/src/bpp_liveops/views.py b/src/bpp_liveops/views.py new file mode 100644 index 000000000..3850e939d --- /dev/null +++ b/src/bpp_liveops/views.py @@ -0,0 +1,93 @@ +"""Centralne, generyczne widoki live-operacji dla BPP. + +Standardowy liveops zakłada, że każda aplikacja rejestruje własne URL-e pod +namespace ``liveops`` (patrz ``liveops/urls.py`` — celowo puste). W BPP mamy +jednak WIELE modeli operacji, a ``get_absolute_url()`` każdej z nich robi +``reverse("liveops:live", pk=...)`` — czyli musi istnieć DOKŁADNIE JEDEN +namespace ``liveops``. + +Rozwiązanie: jeden centralny zestaw widoków (mount w root urlconf pod +``^live/``), które rozwiązują konkretny model operacji po UUID — tak jak robi +to ``liveops.consumers._find_operation``. UUID jest globalnie unikalny, więc +pk jednoznacznie wskazuje model. + +Widoki per-aplikacja (formularz tworzenia, filtrowana tabela wyników, detal +wiersza) zostają w swoich aplikacjach — tu trafia tylko to, co współdzielone. +""" + +from __future__ import annotations + +from braces.views import GroupRequiredMixin +from django.apps import apps +from django.core.exceptions import ValidationError +from django.http import Http404 +from liveops.models import LiveOperation +from liveops.views import CancelView, LiveOperationView, RestartView + +# Grupa wymagana do obsługi operacji live (jak w long_running). Gating przez +# braces GroupRequiredMixin — zwalnia superuserów (LIVEOPS bez REQUIRED_GROUP, +# patrz komentarz w settings). +GROUP_REQUIRED = "wprowadzanie danych" + + +def resolve_operation(pk, user): + """Znajdź instancję dowolnej konkretnej podklasy ``LiveOperation`` po UUID. + + Ograniczone do ``owner=user`` (zapobiega podglądaniu cudzych operacji — + tak jak ``RestrictToOwnerMixin`` w starym ``long_running``). Zwraca + ``None``, gdy nic nie pasuje. + """ + for model in apps.get_models(): + if model is LiveOperation or not issubclass(model, LiveOperation): + continue + try: + return model._default_manager.get(pk=pk, owner=user) + except model.DoesNotExist: + continue + except (ValueError, ValidationError): + # pk nie jest poprawnym UUID-em dla tego backendu — pomiń. + continue + return None + + +class ResolveOperationMixin: + """Ustawia ``self.model`` i zwraca konkretną instancję operacji po UUID. + + Nadpisuje ``get_object`` tak, by generyczny widok działał bez + zadeklarowanego ``model`` — model wynika z odnalezionej instancji. + """ + + def get_object(self, queryset=None): + operation = resolve_operation(self.kwargs["pk"], self.request.user) + if operation is None: + raise Http404("Nie znaleziono operacji.") + self.model = type(operation) + return operation + + +class BppLiveView(GroupRequiredMixin, ResolveOperationMixin, LiveOperationView): + """Strona-host live-operacji (postęp na żywo + wynik inline).""" + + group_required = GROUP_REQUIRED + + +class BppCancelView(GroupRequiredMixin, ResolveOperationMixin, CancelView): + """POST: ustaw ``cancel_requested`` i wróć na stronę operacji.""" + + group_required = GROUP_REQUIRED + + +class BppRestartView(GroupRequiredMixin, ResolveOperationMixin, RestartView): + """POST: restart operacji — najpierw czyścimy rekordy-dzieci. + + ``liveops.views.RestartView`` resetuje wyłącznie pola bazowe operacji; + nie wie o rekordach potomnych. Wołamy ``reset_children()`` PRZED + ``super().post()`` (który resetuje stan i ponownie kolejkuje). + """ + + group_required = GROUP_REQUIRED + + def post(self, request, *args, **kwargs): + operation = self.get_object() + operation.reset_children() + return super().post(request, *args, **kwargs) diff --git a/src/django_bpp/asgi.py b/src/django_bpp/asgi.py index 5a28357af..ca7e5f9b7 100644 --- a/src/django_bpp/asgi.py +++ b/src/django_bpp/asgi.py @@ -12,12 +12,16 @@ from channels.routing import ProtocolTypeRouter, URLRouter from channels.security.websocket import AllowedHostsOriginValidator -import channels_broadcast.routing import pbn_import.routing +# liveops.routing rejestruje LiveOperationConsumer na tej samej sciezce +# (/asgi/notifications/) co channels_broadcast.NotificationsConsumer — +# jest jego drop-in nadzbiorem (dorzuca snapshot-on-connect dla kanalow +# liveop.*). Zastepuje channels_broadcast.routing. +import liveops.routing + websocket_urlpatterns = ( - channels_broadcast.routing.websocket_urlpatterns - + pbn_import.routing.websocket_urlpatterns + liveops.routing.websocket_urlpatterns + pbn_import.routing.websocket_urlpatterns ) application = ProtocolTypeRouter( diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index e1e723ecc..572387192 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -383,6 +383,10 @@ def _elem_in_sys_argv(possible): "django.contrib.sitemaps", "django.contrib.postgres", "long_running", + # django-liveops + cienka warstwa BPP (BppLiveOperation + centralne + # widoki live/cancel/restart). Nastepca `long_running`. + "liveops", + "bpp_liveops", "import_pracownikow", "import_list_if", "password_policies", @@ -929,6 +933,20 @@ def can_login_as(request, target_user): }, } +# django-liveops. RUNNER="celery" — operacje wykonuje worker celery (tak jak +# stary `perform_generic_long_running_task`). BASE_TEMPLATE = bazowy szablon +# BPP (Foundation). THROTTLE_HZ — max liczba pushy % na sekunde. +# +# UWAGA: celowo NIE ustawiamy REQUIRED_GROUP. Gating grup robimy przez braces +# GroupRequiredMixin w bpp_liveops.views / widokach aplikacji, bo braces +# ZWALNIA superuserów, a liveopsowe REQUIRED_GROUP rzuca 403 nawet superuserowi +# — to byłaby regresja względem long_running. +LIVEOPS = { + "BASE_TEMPLATE": "base.html", + "RUNNER": "celery", + "THROTTLE_HZ": 10, +} + # Pozwól anonimowym użytkownikom łączyć się z WebSocketem notyfikacji # (/asgi/notifications/) i subskrybować globalny kanał "__all__". # diff --git a/src/django_bpp/urls.py b/src/django_bpp/urls.py index 42ed5202e..ac10b64e8 100644 --- a/src/django_bpp/urls.py +++ b/src/django_bpp/urls.py @@ -167,6 +167,13 @@ def protected_media_serve(request, path, document_root=None): ("import_list_ministerialnych.urls", "import_list_ministerialnych"), ), ), + # Centralny namespace "liveops": generyczne widoki live/cancel/restart + # rozwiazujace konkretny model operacji po UUID (patrz bpp_liveops.views). + # get_absolute_url() na kazdej operacji robi reverse("liveops:live"). + url( + r"^live/", + include(("bpp_liveops.urls", "liveops")), + ), url( r"^import_list_if/", include( diff --git a/src/import_list_ministerialnych/core.py b/src/import_list_ministerialnych/core.py index 77a3f9b81..3c5cd7a61 100644 --- a/src/import_list_ministerialnych/core.py +++ b/src/import_list_ministerialnych/core.py @@ -79,7 +79,7 @@ def detect_duplicates(data): def analyze_excel_file_import_list_ministerialnych( - fn, parent_model: ImportListMinisterialnych + fn, parent_model: ImportListMinisterialnych, p ): napraw_literowki_w_bazie() @@ -98,9 +98,9 @@ def analyze_excel_file_import_list_ministerialnych( dane_z_xls={}, rezultat=error_msg, ) - parent_model.send_notification(error_msg, "error") - # Mark the operation as finished with error but don't re-raise - # This will be handled by the task_perform method + p.log(error_msg) + # Podnosimy wyjątek — runner liveops złapie go i oznaczy operację + # jako zakończoną błędem (dawniej robił to task_perform). raise ValueError(error_msg) else: # Re-raise other ValueErrors @@ -113,7 +113,7 @@ def analyze_excel_file_import_list_ministerialnych( dane_z_xls={}, rezultat=error_msg, ) - parent_model.send_notification(error_msg, "error") + p.log(error_msg) raise total = len(data) @@ -124,9 +124,12 @@ def analyze_excel_file_import_list_ministerialnych( # Detect duplicates before processing duplicates = detect_duplicates(data) - for nr_wiersza, elem in enumerate(data, 3): - parent_model.send_progress(nr_wiersza * 100.0 / total) - + # p.track: aktualizuje pasek postępu (throttlowany) i sprawdza anulowanie + # (cancel_requested) przed każdym wierszem — zastępuje ręczny + # parent_model.send_progress(...). total liczone z len(data). + for nr_wiersza, elem in p.track( + list(enumerate(data, 3)), total=total, label="Import list ministerialnych" + ): tytul_zrodla = elem["Tytul_1"] or elem["Tytul_2"] # Extract mniswId from Excel column "Unikatowy Identyfikator Czasopisma" diff --git a/src/import_list_ministerialnych/migrations/0009_alter_importlistministerialnych_options_and_more.py b/src/import_list_ministerialnych/migrations/0009_alter_importlistministerialnych_options_and_more.py new file mode 100644 index 000000000..2f198129f --- /dev/null +++ b/src/import_list_ministerialnych/migrations/0009_alter_importlistministerialnych_options_and_more.py @@ -0,0 +1,79 @@ +# Generated by Django 5.2.15 on 2026-07-03 12:58 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('import_list_ministerialnych', '0008_remove_null_from_string_fields'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='importlistministerialnych', + options={'ordering': ['-created_on']}, + ), + migrations.RemoveField( + model_name='importlistministerialnych', + name='last_updated_on', + ), + migrations.AddField( + model_name='importlistministerialnych', + name='cancel_requested', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='importlistministerialnych', + name='cancelled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='importlistministerialnych', + name='current_stage', + field=models.IntegerField(default=-1), + ), + migrations.AddField( + model_name='importlistministerialnych', + name='language', + field=models.CharField(blank=True, default='', max_length=20), + ), + migrations.AddField( + model_name='importlistministerialnych', + name='log', + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name='importlistministerialnych', + name='log_seq', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='importlistministerialnych', + name='percent', + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.AddField( + model_name='importlistministerialnych', + name='result_context', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='importlistministerialnych', + name='stage_states', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='importlistministerialnych', + name='status_text', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AlterField( + model_name='importlistministerialnych', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/import_list_ministerialnych/models.py b/src/import_list_ministerialnych/models.py index b752d7a55..e1d8c1e9b 100644 --- a/src/import_list_ministerialnych/models.py +++ b/src/import_list_ministerialnych/models.py @@ -3,11 +3,10 @@ from bpp.fields import YearField from bpp.models import Zrodlo -from long_running.models import Operation -from long_running.notification_mixins import ASGINotificationMixin +from bpp_liveops.models import BppLiveOperation -class ImportListMinisterialnych(ASGINotificationMixin, Operation): +class ImportListMinisterialnych(BppLiveOperation): rok = YearField() plik = models.FileField(upload_to="protected/import_list_ministerialnych/") zapisz_zmiany_do_bazy = models.BooleanField(default=False) @@ -22,15 +21,30 @@ class ImportListMinisterialnych(ASGINotificationMixin, Operation): '"Electronics (Switzerland)" oraz "Electronics", gdy w bazie jest wyłącznie źródło "Electronics").', ) - def on_reset(self): + def reset_children(self): + # Wołane przez BppRestartView przed ponownym zakolejkowaniem — + # odpowiednik dawnego long_running on_reset(). self.wierszimportulistyministerialnej_set.all().delete() - def perform(self): + def run(self, p): + # Punkt wejścia liveops (dawniej Operation.perform()). `p` to obiekt + # Progress (Web/Text) — przekazujemy go do rdzenia, który raportuje + # postęp i loguje wyniki wierszy. Na końcu finalizujemy operację + # p.result(...) z podsumowaniem (trafia do result_context + szablonu + # wyniku). from import_list_ministerialnych.core import ( analyze_excel_file_import_list_ministerialnych, ) - analyze_excel_file_import_list_ministerialnych(self.plik.path, self) + analyze_excel_file_import_list_ministerialnych(self.plik.path, self, p) + + rows = self.get_details_set() + p.result( + { + "total": rows.count(), + "duplicates": rows.filter(is_duplicate=True).count(), + } + ) def get_details_set(self): return WierszImportuListyMinisterialnej.objects.filter(parent=self) diff --git a/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html b/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html new file mode 100644 index 000000000..7b90968b7 --- /dev/null +++ b/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% load static liveops %} + +{% block extratitle %} + Import list ministerialnych - {{ object.plik.name }} +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • import list ministerialnych
  • +
  • import {{ object.plik.name }}
  • +{% endblock %} + +{% block content %} +

    Import danych {{ object.plik.name }}

    + + {# Region live-operacji: kontener z data-liveop-channel/token + strefy #} + {# op-status/op-progress/op-log/op-stages/op-result (OOB-swap z workera). #} + {% live_operation object %} + +

    + + Zobacz pełne wyniki importu + +

    + + {# Kolejność ładowania jest istotna: htmx → klient channels_broadcast → #} + {# liveops.js (patchuje channelsBroadcast.addMessage i inicjuje socket). #} + + + +{% endblock %} diff --git a/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych_result.html b/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych_result.html new file mode 100644 index 000000000..01debf36c --- /dev/null +++ b/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych_result.html @@ -0,0 +1,14 @@ +{# Fragment wyniku (liveops result_template_name). Renderowany po p.result() #} +{# jako OOB-swap #op-result oraz przy wejściu na zakończoną operację. #} +{# Kontekst: operation, total, duplicates (z result_context). #} +
    +

    Import zakończony

    +

    + Przetworzono wierszy: {{ total }}{% if duplicates %}, + w tym wykryto duplikatów: {{ duplicates }}{% endif %}. +

    + + Zobacz pełne wyniki importu + +
    diff --git a/src/import_list_ministerialnych/templates/import_list_ministerialnych/importlistministerialnych_detail.html b/src/import_list_ministerialnych/templates/import_list_ministerialnych/importlistministerialnych_detail.html deleted file mode 100644 index 853c8bed5..000000000 --- a/src/import_list_ministerialnych/templates/import_list_ministerialnych/importlistministerialnych_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 list ministerialnych
  • -
  • import {{ object.plik.name }}
  • -{% endblock %} - - -{% block content %} -

    Import danych {{ object.plik.name }}

    - - {% include "long_running/operation_details.html" %} - -{% endblock %} diff --git a/src/import_list_ministerialnych/templates/import_list_ministerialnych/importlistministerialnych_list.html b/src/import_list_ministerialnych/templates/import_list_ministerialnych/importlistministerialnych_list.html index 9a9bb19d5..ea7f609d8 100644 --- a/src/import_list_ministerialnych/templates/import_list_ministerialnych/importlistministerialnych_list.html +++ b/src/import_list_ministerialnych/templates/import_list_ministerialnych/importlistministerialnych_list.html @@ -19,7 +19,7 @@

    Ostatnio importowane dane:
      {% for object in object_list %}
    • - plik {{ object.plik.name }}: + 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 %} diff --git a/src/import_list_ministerialnych/templates/import_list_ministerialnych/wierszimportulistyministerialnej_list.html b/src/import_list_ministerialnych/templates/import_list_ministerialnych/wierszimportulistyministerialnej_list.html index 306a65d00..b95cf89c6 100644 --- a/src/import_list_ministerialnych/templates/import_list_ministerialnych/wierszimportulistyministerialnej_list.html +++ b/src/import_list_ministerialnych/templates/import_list_ministerialnych/wierszimportulistyministerialnej_list.html @@ -55,7 +55,6 @@

      Zmiany nie zostały wprowadzone do bazy danych.

      Import został uruchomi zaznaczono opcję zapisywania zmian do bazy. {% endif %} - {% include "long_running/operation_details.html" %} {% if object.finished_successfully %} diff --git a/src/import_list_ministerialnych/tests.py b/src/import_list_ministerialnych/tests.py index 00db2ff90..13edc2fac 100644 --- a/src/import_list_ministerialnych/tests.py +++ b/src/import_list_ministerialnych/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from model_bakery import baker @@ -12,6 +12,33 @@ from pbn_api.models import Journal +class FakeProgress: + """Minimalny obiekt Progress do testów jednostkowych rdzenia importu. + + ``core.analyze_excel_file_import_list_ministerialnych`` używa tylko + ``track`` (iterowanie z postępem) i ``log``. Reszta metod jest dla + kompletności API liveops.Progress, gdyby rdzeń zaczął ich używać. + """ + + def track(self, iterable, total=None, label=None, unit="item"): + return iter(iterable) + + def log(self, line): + pass + + def percent(self, value): + pass + + def status(self, text, level="info"): + pass + + def result(self, context=None, **extra): + pass + + def check_cancelled(self): + pass + + @pytest.mark.django_db def test_import_list_ministerialnych_with_mnisw_id(): """Test import matching journals by mniswId when ISSN is missing""" @@ -44,7 +71,7 @@ def test_import_list_ministerialnych_with_mnisw_id(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = FakeProgress() # Mock Excel data with mniswId but no ISSN mock_data = [ @@ -66,7 +93,9 @@ def test_import_list_ministerialnych_with_mnisw_id(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - analyze_excel_file_import_list_ministerialnych("dummy.xlsx", parent_model) + analyze_excel_file_import_list_ministerialnych( + "dummy.xlsx", parent_model, p + ) # Check that the journal was matched and punktacja was set assert zrodlo.punktacja_zrodla_set.filter(rok=2023).exists() @@ -112,7 +141,7 @@ def test_import_list_ministerialnych_issn_priority(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = FakeProgress() # Mock Excel data with both ISSN and mniswId mock_data = [ @@ -133,7 +162,9 @@ def test_import_list_ministerialnych_issn_priority(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - analyze_excel_file_import_list_ministerialnych("dummy.xlsx", parent_model) + analyze_excel_file_import_list_ministerialnych( + "dummy.xlsx", parent_model, p + ) # Should match by ISSN (zrodlo2), not by mniswId (zrodlo1) wiersz = parent_model.wierszimportulistyministerialnej_set.first() @@ -154,7 +185,7 @@ def test_import_list_ministerialnych_no_mnisw_id(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = FakeProgress() # Mock Excel data without mniswId column mock_data = [ @@ -175,7 +206,9 @@ def test_import_list_ministerialnych_no_mnisw_id(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - analyze_excel_file_import_list_ministerialnych("dummy.xlsx", parent_model) + analyze_excel_file_import_list_ministerialnych( + "dummy.xlsx", parent_model, p + ) # Should still match by ISSN wiersz = parent_model.wierszimportulistyministerialnej_set.first() @@ -196,7 +229,7 @@ def test_import_list_ministerialnych_invalid_mnisw_id(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = FakeProgress() mock_data = [ { @@ -216,7 +249,9 @@ def test_import_list_ministerialnych_invalid_mnisw_id(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - analyze_excel_file_import_list_ministerialnych("dummy.xlsx", parent_model) + analyze_excel_file_import_list_ministerialnych( + "dummy.xlsx", parent_model, p + ) # Should fall back to e-issn matching wiersz = parent_model.wierszimportulistyministerialnej_set.first() @@ -251,7 +286,7 @@ def test_import_list_ministerialnych_column_name_with_space(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = FakeProgress() # Mock Excel data with space in column name (common Excel issue) mock_data = [ @@ -272,7 +307,9 @@ def test_import_list_ministerialnych_column_name_with_space(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - analyze_excel_file_import_list_ministerialnych("dummy.xlsx", parent_model) + analyze_excel_file_import_list_ministerialnych( + "dummy.xlsx", parent_model, p + ) # Should still match by mniswId despite space in column name wiersz = parent_model.wierszimportulistyministerialnej_set.first() @@ -355,7 +392,7 @@ def test_import_with_duplicate_detection(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = FakeProgress() # Mock data with duplicate ISSN mock_data = [ @@ -386,7 +423,9 @@ def test_import_with_duplicate_detection(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - analyze_excel_file_import_list_ministerialnych("dummy.xlsx", parent_model) + analyze_excel_file_import_list_ministerialnych( + "dummy.xlsx", parent_model, p + ) # Check that two rows were created assert parent_model.wierszimportulistyministerialnej_set.count() == 2 @@ -416,7 +455,7 @@ def test_import_with_multiple_duplicate_reasons(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = FakeProgress() mock_data = [ { @@ -446,7 +485,9 @@ def test_import_with_multiple_duplicate_reasons(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - analyze_excel_file_import_list_ministerialnych("dummy.xlsx", parent_model) + analyze_excel_file_import_list_ministerialnych( + "dummy.xlsx", parent_model, p + ) # Second row should have all three duplicate reasons second_row = parent_model.wierszimportulistyministerialnej_set.get(nr_wiersza=4) @@ -571,7 +612,7 @@ def test_import_with_nie_porownuj_po_tytulach_enabled(): ignoruj_zrodla_bez_odpowiednika=False, nie_porownuj_po_tytulach=True, # Enable the new option ) - parent_model.send_progress = Mock() + p = FakeProgress() # Mock Excel data with title "Electronics" but ISSN matching "Electronics (Switzerland)" mock_data = [ @@ -592,7 +633,9 @@ def test_import_with_nie_porownuj_po_tytulach_enabled(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - analyze_excel_file_import_list_ministerialnych("dummy.xlsx", parent_model) + analyze_excel_file_import_list_ministerialnych( + "dummy.xlsx", parent_model, p + ) # Should match by ISSN (zrodlo_electronics_switzerland), NOT by title (zrodlo_electronics) wiersz = parent_model.wierszimportulistyministerialnej_set.first() @@ -616,7 +659,7 @@ def test_import_with_nie_porownuj_po_tytulach_disabled(): ignoruj_zrodla_bez_odpowiednika=False, nie_porownuj_po_tytulach=False, # Default behavior - use title matching ) - parent_model.send_progress = Mock() + p = FakeProgress() # Mock Excel data with matching title but no ISSN mock_data = [ @@ -637,7 +680,9 @@ def test_import_with_nie_porownuj_po_tytulach_disabled(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - analyze_excel_file_import_list_ministerialnych("dummy.xlsx", parent_model) + analyze_excel_file_import_list_ministerialnych( + "dummy.xlsx", parent_model, p + ) # Should match by title since nie_porownuj_po_tytulach=False wiersz = parent_model.wierszimportulistyministerialnej_set.first() @@ -661,7 +706,7 @@ def test_import_title_not_matched_when_nie_porownuj_po_tytulach_enabled(): ignoruj_zrodla_bez_odpowiednika=False, nie_porownuj_po_tytulach=True, # Skip title matching ) - parent_model.send_progress = Mock() + p = FakeProgress() # Mock Excel data with matching title but no identifiers mock_data = [ @@ -682,7 +727,9 @@ def test_import_title_not_matched_when_nie_porownuj_po_tytulach_enabled(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - analyze_excel_file_import_list_ministerialnych("dummy.xlsx", parent_model) + analyze_excel_file_import_list_ministerialnych( + "dummy.xlsx", parent_model, p + ) # Should NOT match because title matching is disabled and no IDs are provided wiersz = parent_model.wierszimportulistyministerialnej_set.first() @@ -749,3 +796,81 @@ def test_import_results_view_statistics(admin_client, admin_user): assert response.context["duplicate_count"] == 4 assert response.context["identical_punkty_count"] == 3 assert response.context["identical_dyscypliny_count"] == 2 + + +@pytest.mark.django_db +def test_run_finalizes_operation_with_result_context(): + """Integracja liveops: run() → core → p.result() finalizuje operację. + + Używa prawdziwego liveops.TextProgress (nie FakeProgress), żeby + zweryfikować, że po przejściu run(): + - powstają wiersze importu, + - operacja jest oznaczona jako zakończona sukcesem, + - result_context zawiera podsumowanie (total/duplicates). + """ + import io + + from liveops.progress import TextProgress + + zrodlo = baker.make(Zrodlo, nazwa="Run Journal", issn="1234-5678") + + op = baker.make( + ImportListMinisterialnych, + rok=2023, + zapisz_zmiany_do_bazy=False, + importuj_punktacje=True, + importuj_dyscypliny=False, + ignoruj_zrodla_bez_odpowiednika=False, + ) + # run() czyta self.plik.path; wczytaj_plik jest patchowane, więc plik nie + # jest realnie otwierany — wystarczy nadać name, by .path nie rzucił. + op.plik.name = "protected/import_list_ministerialnych/dummy.xlsx" + + mock_data = [ + { + "Tytul_1": "Run Journal", + "Tytul_2": None, + "issn": "1234-5678", + "issn.1": None, + "e-issn": None, + "e-issn.1": None, + "Punkty": 100, + } + ] + + with patch( + "import_list_ministerialnych.core.wczytaj_plik_importu_dyscyplin_zrodel", + return_value=mock_data, + ): + with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): + op.run(TextProgress(op, io.StringIO())) + + op.refresh_from_db() + assert op.finished_on is not None + assert op.finished_successfully is True + assert op.result_context["total"] == 1 + assert op.result_context["duplicates"] == 0 + assert op.get_details_set().count() == 1 + wiersz = op.get_details_set().first() + assert wiersz.zrodlo == zrodlo + + +@pytest.mark.django_db +def test_central_live_view_renders_host_page(admin_client, admin_user): + """Centralny liveops:live rozwiązuje model po UUID i renderuje host-page. + + Waliduje warstwę bpp_liveops: ResolveOperationMixin znajduje konkretny + model po UUID, render host-template (import_list_ministerialnych.html) z + kontenerem live-operacji (data-liveop-channel/token dla liveops.js), a + superuser przechodzi bramkę grupy (braces, superuser-exempt). + """ + op = baker.make(ImportListMinisterialnych, owner=admin_user, rok=2023) + op.plik.name = "protected/import_list_ministerialnych/dummy.xlsx" + op.save(update_fields=["plik"]) + + response = admin_client.get(f"/live/{op.pk}/") + assert response.status_code == 200 + content = response.content.decode() + assert "data-liveop-channel" in content + assert "data-liveop-token" in content + assert str(op.pk) in content diff --git a/src/import_list_ministerialnych/urls.py b/src/import_list_ministerialnych/urls.py index 3343f5743..11d5e10c7 100644 --- a/src/import_list_ministerialnych/urls.py +++ b/src/import_list_ministerialnych/urls.py @@ -6,21 +6,9 @@ urlpatterns = [ path("", views.PokazImporty.as_view(), name="index"), path("nowy/", views.UtworzImportDyscyplinZrodel.as_view(), name="utworz-import"), - path( - "/", - views.ImportDyscyplinZrodelRouterView.as_view(), - name="importlistministerialnych-router", - ), - path( - "/details/", - views.ImportDyscyplinZrodelDetailsView.as_view(), - name="importlistministerialnych-details", - ), - path( - "/regen/", - views.RestartImportView.as_view(), - name="importlistministerialnych-restart", - ), + # Strona live (postęp + wynik) NIE jest już tutaj — obsługuje ją centralny + # namespace ``liveops`` (reverse "liveops:live"). Router/details/restart + # z long_running zostały usunięte. path( "/results/", views.ImportDyscyplinZrodelResultsView.as_view(), diff --git a/src/import_list_ministerialnych/views.py b/src/import_list_ministerialnych/views.py index 62a529949..a41cfec40 100644 --- a/src/import_list_ministerialnych/views.py +++ b/src/import_list_ministerialnych/views.py @@ -2,59 +2,77 @@ from braces.views import GroupRequiredMixin from django.db.models import Q -from django.views.generic import DetailView +from django.http import Http404 +from django.utils.functional import cached_property +from django.views.generic import DetailView, ListView +from liveops.views import CreateLiveOperationView from import_list_ministerialnych.forms import NowyImportForm from import_list_ministerialnych.models import ( ImportListMinisterialnych, WierszImportuListyMinisterialnej, ) -from long_running.views import ( - CreateLongRunningOperationView, - LongRunningDetailsView, - LongRunningOperationsView, - LongRunningResultsView, - LongRunningRouterView, - RestartLongRunningOperationView, -) +GROUP_REQUIRED = "wprowadzanie danych" + + +class PokazImporty(GroupRequiredMixin, ListView): + """Lista importów bieżącego użytkownika. -class BaseImportDyscyplinZrodelMixin(GroupRequiredMixin): - group_required = "wprowadzanie danych" + Dawniej long_running.LongRunningOperationsView. Teraz zwykły + owner-scoped ListView — strona live (postęp/wynik) jest osobno, pod + centralnym ``liveops:live`` (link przez ``object.get_absolute_url``). + """ + + group_required = GROUP_REQUIRED model = ImportListMinisterialnych + def get_queryset(self): + return self.model.objects.filter(owner=self.request.user).order_by( + "-created_on" + ) + -class PokazImporty(BaseImportDyscyplinZrodelMixin, LongRunningOperationsView): - pass +class UtworzImportDyscyplinZrodel(GroupRequiredMixin, CreateLiveOperationView): + """Formularz nowego importu. + ``CreateLiveOperationView`` (liveops) sam ustawia owner, zapisuje, + kolejkuje operację i przekierowuje na ``get_absolute_url()`` czyli + centralną stronę live. Gating grupy — braces GroupRequiredMixin + (superuser-exempt, jak w long_running). + """ -class UtworzImportDyscyplinZrodel( - BaseImportDyscyplinZrodelMixin, CreateLongRunningOperationView -): + group_required = GROUP_REQUIRED + model = ImportListMinisterialnych form_class = NowyImportForm -class ImportDyscyplinZrodelRouterView( - BaseImportDyscyplinZrodelMixin, LongRunningRouterView -): - redirect_prefix = "import_list_ministerialnych:ImportListMinisterialnych" +class ImportResultsBaseView(GroupRequiredMixin, ListView): + """Baza dla filtrowanej tabeli wyników importu. + + Zastępuje dawną long_running.LongRunningResultsView: właściciel-scoping + przez ``parent_object`` i queryset z ``get_details_set()``. + """ + group_required = GROUP_REQUIRED + paginate_by = 25 + model = ImportListMinisterialnych -class ImportDyscyplinZrodelDetailsView( - BaseImportDyscyplinZrodelMixin, LongRunningDetailsView -): - pass + @cached_property + def parent_object(self): + o = self.model.objects.get(pk=self.kwargs["pk"]) + if o.owner != self.request.user: + raise Http404 + return o + def get_queryset(self): + return self.parent_object.get_details_set() -class RestartImportView( - BaseImportDyscyplinZrodelMixin, RestartLongRunningOperationView -): - pass + def get_context_data(self, **kwargs): + return super().get_context_data(object=self.parent_object, **kwargs) -class ImportDyscyplinZrodelResultsView( - BaseImportDyscyplinZrodelMixin, LongRunningResultsView -): +class ImportDyscyplinZrodelResultsView(ImportResultsBaseView): def get_queryset(self): """Override to handle filtering parameters from URL""" queryset = super().get_queryset() @@ -144,7 +162,7 @@ def get_context_data(self, **kwargs): class WierszImportuListyMinisterialnejDetailView(GroupRequiredMixin, DetailView): - group_required = "wprowadzanie danych" + group_required = GROUP_REQUIRED model = WierszImportuListyMinisterialnej pk_url_kwarg = "row_pk" diff --git a/uv.lock b/uv.lock index b240d7388..d630ed270 100644 --- a/uv.lock +++ b/uv.lock @@ -409,6 +409,7 @@ dependencies = [ { name = "django-grappelli", marker = "platform_python_implementation != 'PyPy'" }, { name = "django-group-by", marker = "platform_python_implementation != 'PyPy'" }, { name = "django-import-export", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-liveops", marker = "platform_python_implementation != 'PyPy'" }, { name = "django-loginas", marker = "platform_python_implementation != 'PyPy'" }, { name = "django-messages-extends", marker = "platform_python_implementation != 'PyPy'" }, { name = "django-minify-html", marker = "platform_python_implementation != 'PyPy'" }, @@ -588,6 +589,7 @@ requires-dist = [ { name = "django-grappelli", specifier = ">=5.0.0,<6" }, { name = "django-group-by", specifier = "==0.3.1" }, { name = "django-import-export", git = "https://github.com/mpasternak/django-import-export.git?rev=d6ee0d39194fee31437affbdc6fee5ce549b4b8f" }, + { name = "django-liveops", specifier = ">=0.1,<0.2" }, { name = "django-loginas", specifier = "==0.3.14" }, { name = "django-messages-extends", specifier = "==0.6.3" }, { name = "django-microsoft-auth", marker = "extra == 'office365'", specifier = ">=3.0.1" }, @@ -1935,6 +1937,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/cf/b208767db5e56b5189829f753eec6a14ee75d074922dc2bd19220b22a34d/django_js_asset-3.1.2-py3-none-any.whl", hash = "sha256:b5ffe376aebbd73b7af886d675ac9f43ca63b39540190fa8409c9f8e79145f68", size = 5905, upload-time = "2025-03-04T15:22:51.152Z" }, ] +[[package]] +name = "django-liveops" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "channels", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django", marker = "platform_python_implementation != 'PyPy'" }, + { name = "django-channels-broadcast", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/d9/16839b0fc4ab30d3f2b87b92927d979871d48d14cd5ec06745406ca5a939/django_liveops-0.1.0.tar.gz", hash = "sha256:3a6597226302616d5238447b43d50c99a196fe4947a19a95e25363b11072883b", size = 268744, upload-time = "2026-07-03T12:36:45.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d6/bb30bdd07a536817ae30b6c68191bdcfe6c39624a9f75f80706a0d23842d/django_liveops-0.1.0-py3-none-any.whl", hash = "sha256:274c1b1fc41ea9aa1675e489dbb2db29eaad5f38cdc115959ca1008fb24e903e", size = 70257, upload-time = "2026-07-03T12:36:43.135Z" }, +] + [[package]] name = "django-loginas" version = "0.3.14" From a2144dc1ec621b835f1b36bfe98778eac7b84faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 3 Jul 2026 17:53:10 +0200 Subject: [PATCH 2/5] =?UTF-8?q?refactor(import=5Flist=5Fministerialnych):?= =?UTF-8?q?=20op=5Ftype=20routing=20(liveops=200.2),=20usu=C5=84=20bpp=5Fl?= =?UTF-8?q?iveops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit django-liveops 0.2.0 przeniósł generyczny routing live-operacji do samego pakietu (op_type = . w URL-u, rozwiązanie modelu jednym apps.get_model — bez skanu rejestru). Dzięki temu cała warstwa pośrednia bpp_liveops jest zbędna i znika. - pyproject: django-liveops >=0.2,<0.3 (z PyPI) - model: dziedziczy po liveops.LiveOperation wprost; reset_children -> hook on_restart (wołany przez RestartView pakietu) - root urls: include("liveops.urls") zamiast bpp_liveops.urls (koniec skanu) - settings: usunięto bpp_liveops z INSTALLED_APPS; REQUIRED_GROUP przywrócony (od 0.2 liveops zwalnia superuserów z bramki — brak regresji) - usunięto src/bpp_liveops/ w całości (-159 linii) get_absolute_url() -> /live/import_list_ministerialnych.importlistministerialnych// Testy: 15 passed na czystym 0.2.0 z PyPI. manage.py check czysto. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- ...ps-import-list-ministerialnych.feature.rst | 7 +- src/bpp_liveops/__init__.py | 0 src/bpp_liveops/apps.py | 6 -- src/bpp_liveops/migrations/__init__.py | 0 src/bpp_liveops/models.py | 39 -------- src/bpp_liveops/urls.py | 11 --- src/bpp_liveops/views.py | 93 ------------------- src/django_bpp/settings/base.py | 16 ++-- src/django_bpp/urls.py | 9 +- src/import_list_ministerialnych/models.py | 11 ++- src/import_list_ministerialnych/tests.py | 16 ++-- uv.lock | 8 +- 13 files changed, 37 insertions(+), 181 deletions(-) delete mode 100644 src/bpp_liveops/__init__.py delete mode 100644 src/bpp_liveops/apps.py delete mode 100644 src/bpp_liveops/migrations/__init__.py delete mode 100644 src/bpp_liveops/models.py delete mode 100644 src/bpp_liveops/urls.py delete mode 100644 src/bpp_liveops/views.py diff --git a/pyproject.toml b/pyproject.toml index 5fc38ab88..c9a6f69d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,7 +157,7 @@ dependencies = [ "babel>=2.17", # django-liveops: framework dla dlugo-dzialajacych operacji z live UI # (WebSocket + HTMX). Nastepca wewnetrznego `src/long_running`. - "django-liveops>=0.1,<0.2", + "django-liveops>=0.2,<0.3", ] [project.optional-dependencies] diff --git a/src/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst b/src/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst index 552264f0d..9cbc35253 100644 --- a/src/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst +++ b/src/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst @@ -1,5 +1,6 @@ Import list ministerialnych korzysta teraz z pakietu ``django-liveops`` (podgląd postępu na żywo przez WebSocket + HTMX) zamiast wewnętrznej -aplikacji ``long_running``. Dodano cienką warstwę ``bpp_liveops`` -(``BppLiveOperation`` + centralne, generyczne widoki live/cancel/restart), -która pozostaje do wykorzystania przy migracji kolejnych importów. +aplikacji ``long_running``. Routing live/cancel/restart jest generyczny +(``op_type`` = ``.``) i mieszka w samym pakiecie +liveops, więc konwersja kolejnych importów nie wymaga żadnej warstwy +pośredniej po stronie BPP. diff --git a/src/bpp_liveops/__init__.py b/src/bpp_liveops/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/bpp_liveops/apps.py b/src/bpp_liveops/apps.py deleted file mode 100644 index bab8d3547..000000000 --- a/src/bpp_liveops/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class BppLiveopsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "bpp_liveops" diff --git a/src/bpp_liveops/migrations/__init__.py b/src/bpp_liveops/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/bpp_liveops/models.py b/src/bpp_liveops/models.py deleted file mode 100644 index 96124fa93..000000000 --- a/src/bpp_liveops/models.py +++ /dev/null @@ -1,39 +0,0 @@ -from liveops.models import LiveOperation - - -class BppLiveOperation(LiveOperation): - """Cienka warstwa BPP nad ``liveops.LiveOperation``. - - Dokłada konwencje wspólne dla operacji BPP, których standardowy liveops - nie ma (bo są specyficzne dla naszego wzorca importów z rekordami-dziećmi): - - - ``reset_children()`` — hook czyszczący rekordy potomne przy restarcie. - Odpowiednik ``on_reset()`` ze starego ``long_running.models.Operation``. - Standardowy ``liveops.views.RestartView`` resetuje tylko pola bazowe - operacji i NIE wie o rekordach-dzieciach — dlatego ``BppRestartView`` - woła ten hook przed ponownym zakolejkowaniem. - - ``readable_exception()`` — ostatnia niepusta linia tracebacku, do - pokazania w szablonach (jak w starym ``Operation``). - - Klasa jest abstrakcyjna — nie tworzy własnej tabeli. Konkretne operacje - (import list ministerialnych itd.) dziedziczą po niej i implementują - ``run(self, p)``. - """ - - class Meta(LiveOperation.Meta): - # Dziedziczymy po LiveOperation.Meta, żeby zachować ordering - # (["-created_on"]); samo `class Meta: abstract=True` by je zgubiło. - abstract = True - - def reset_children(self): - """Wyczyść rekordy potomne przy restarcie operacji. - - Domyślnie no-op. Podklasy z rekordami-dziećmi (np. wiersze importu) - nadpisują to, kasując swój ``*_set``. - """ - - def readable_exception(self): - if not self.traceback: - return None - lines = [line for line in self.traceback.split("\n") if line] - return lines[-1] if lines else None diff --git a/src/bpp_liveops/urls.py b/src/bpp_liveops/urls.py deleted file mode 100644 index 49a8c6dc6..000000000 --- a/src/bpp_liveops/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path - -from bpp_liveops import views - -app_name = "liveops" - -urlpatterns = [ - path("/", views.BppLiveView.as_view(), name="live"), - path("/cancel/", views.BppCancelView.as_view(), name="cancel"), - path("/restart/", views.BppRestartView.as_view(), name="restart"), -] diff --git a/src/bpp_liveops/views.py b/src/bpp_liveops/views.py deleted file mode 100644 index 3850e939d..000000000 --- a/src/bpp_liveops/views.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Centralne, generyczne widoki live-operacji dla BPP. - -Standardowy liveops zakłada, że każda aplikacja rejestruje własne URL-e pod -namespace ``liveops`` (patrz ``liveops/urls.py`` — celowo puste). W BPP mamy -jednak WIELE modeli operacji, a ``get_absolute_url()`` każdej z nich robi -``reverse("liveops:live", pk=...)`` — czyli musi istnieć DOKŁADNIE JEDEN -namespace ``liveops``. - -Rozwiązanie: jeden centralny zestaw widoków (mount w root urlconf pod -``^live/``), które rozwiązują konkretny model operacji po UUID — tak jak robi -to ``liveops.consumers._find_operation``. UUID jest globalnie unikalny, więc -pk jednoznacznie wskazuje model. - -Widoki per-aplikacja (formularz tworzenia, filtrowana tabela wyników, detal -wiersza) zostają w swoich aplikacjach — tu trafia tylko to, co współdzielone. -""" - -from __future__ import annotations - -from braces.views import GroupRequiredMixin -from django.apps import apps -from django.core.exceptions import ValidationError -from django.http import Http404 -from liveops.models import LiveOperation -from liveops.views import CancelView, LiveOperationView, RestartView - -# Grupa wymagana do obsługi operacji live (jak w long_running). Gating przez -# braces GroupRequiredMixin — zwalnia superuserów (LIVEOPS bez REQUIRED_GROUP, -# patrz komentarz w settings). -GROUP_REQUIRED = "wprowadzanie danych" - - -def resolve_operation(pk, user): - """Znajdź instancję dowolnej konkretnej podklasy ``LiveOperation`` po UUID. - - Ograniczone do ``owner=user`` (zapobiega podglądaniu cudzych operacji — - tak jak ``RestrictToOwnerMixin`` w starym ``long_running``). Zwraca - ``None``, gdy nic nie pasuje. - """ - for model in apps.get_models(): - if model is LiveOperation or not issubclass(model, LiveOperation): - continue - try: - return model._default_manager.get(pk=pk, owner=user) - except model.DoesNotExist: - continue - except (ValueError, ValidationError): - # pk nie jest poprawnym UUID-em dla tego backendu — pomiń. - continue - return None - - -class ResolveOperationMixin: - """Ustawia ``self.model`` i zwraca konkretną instancję operacji po UUID. - - Nadpisuje ``get_object`` tak, by generyczny widok działał bez - zadeklarowanego ``model`` — model wynika z odnalezionej instancji. - """ - - def get_object(self, queryset=None): - operation = resolve_operation(self.kwargs["pk"], self.request.user) - if operation is None: - raise Http404("Nie znaleziono operacji.") - self.model = type(operation) - return operation - - -class BppLiveView(GroupRequiredMixin, ResolveOperationMixin, LiveOperationView): - """Strona-host live-operacji (postęp na żywo + wynik inline).""" - - group_required = GROUP_REQUIRED - - -class BppCancelView(GroupRequiredMixin, ResolveOperationMixin, CancelView): - """POST: ustaw ``cancel_requested`` i wróć na stronę operacji.""" - - group_required = GROUP_REQUIRED - - -class BppRestartView(GroupRequiredMixin, ResolveOperationMixin, RestartView): - """POST: restart operacji — najpierw czyścimy rekordy-dzieci. - - ``liveops.views.RestartView`` resetuje wyłącznie pola bazowe operacji; - nie wie o rekordach potomnych. Wołamy ``reset_children()`` PRZED - ``super().post()`` (który resetuje stan i ponownie kolejkuje). - """ - - group_required = GROUP_REQUIRED - - def post(self, request, *args, **kwargs): - operation = self.get_object() - operation.reset_children() - return super().post(request, *args, **kwargs) diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index 572387192..bd0a31d03 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -383,10 +383,10 @@ def _elem_in_sys_argv(possible): "django.contrib.sitemaps", "django.contrib.postgres", "long_running", - # django-liveops + cienka warstwa BPP (BppLiveOperation + centralne - # widoki live/cancel/restart). Nastepca `long_running`. + # django-liveops — nastepca `long_running`. Routing live/cancel/restart + # jest generyczny (op_type) i mieszka w samym pakiecie (liveops.urls), + # wiec nie potrzebujemy juz zadnej warstwy posredniej po stronie BPP. "liveops", - "bpp_liveops", "import_pracownikow", "import_list_if", "password_policies", @@ -935,16 +935,14 @@ def can_login_as(request, target_user): # django-liveops. RUNNER="celery" — operacje wykonuje worker celery (tak jak # stary `perform_generic_long_running_task`). BASE_TEMPLATE = bazowy szablon -# BPP (Foundation). THROTTLE_HZ — max liczba pushy % na sekunde. -# -# UWAGA: celowo NIE ustawiamy REQUIRED_GROUP. Gating grup robimy przez braces -# GroupRequiredMixin w bpp_liveops.views / widokach aplikacji, bo braces -# ZWALNIA superuserów, a liveopsowe REQUIRED_GROUP rzuca 403 nawet superuserowi -# — to byłaby regresja względem long_running. +# BPP (Foundation). THROTTLE_HZ — max liczba pushy % na sekunde. REQUIRED_GROUP +# bramkuje wszystkie operacje live; od 0.2.0 liveops ZWALNIA superuserów z tej +# bramki (parytet z braces/adminem), wiec mozna jej uzyc bez regresji. LIVEOPS = { "BASE_TEMPLATE": "base.html", "RUNNER": "celery", "THROTTLE_HZ": 10, + "REQUIRED_GROUP": "wprowadzanie danych", } # Pozwól anonimowym użytkownikom łączyć się z WebSocketem notyfikacji diff --git a/src/django_bpp/urls.py b/src/django_bpp/urls.py index ac10b64e8..a9d97954a 100644 --- a/src/django_bpp/urls.py +++ b/src/django_bpp/urls.py @@ -167,12 +167,13 @@ def protected_media_serve(request, path, document_root=None): ("import_list_ministerialnych.urls", "import_list_ministerialnych"), ), ), - # Centralny namespace "liveops": generyczne widoki live/cancel/restart - # rozwiazujace konkretny model operacji po UUID (patrz bpp_liveops.views). - # get_absolute_url() na kazdej operacji robi reverse("liveops:live"). + # Centralny, generyczny router live-operacji z samego pakietu liveops: + # live/cancel/restart rozwiazuja konkretny model po op_type + # (.) — jedno zapytanie, bez skanu. Kazda + # operacja robi reverse("liveops:live", op_type=..., pk=...). url( r"^live/", - include(("bpp_liveops.urls", "liveops")), + include("liveops.urls"), ), url( r"^import_list_if/", diff --git a/src/import_list_ministerialnych/models.py b/src/import_list_ministerialnych/models.py index e1d8c1e9b..037c4fb66 100644 --- a/src/import_list_ministerialnych/models.py +++ b/src/import_list_ministerialnych/models.py @@ -1,12 +1,12 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import models +from liveops.models import LiveOperation from bpp.fields import YearField from bpp.models import Zrodlo -from bpp_liveops.models import BppLiveOperation -class ImportListMinisterialnych(BppLiveOperation): +class ImportListMinisterialnych(LiveOperation): rok = YearField() plik = models.FileField(upload_to="protected/import_list_ministerialnych/") zapisz_zmiany_do_bazy = models.BooleanField(default=False) @@ -21,9 +21,10 @@ class ImportListMinisterialnych(BppLiveOperation): '"Electronics (Switzerland)" oraz "Electronics", gdy w bazie jest wyłącznie źródło "Electronics").', ) - def reset_children(self): - # Wołane przez BppRestartView przed ponownym zakolejkowaniem — - # odpowiednik dawnego long_running on_reset(). + def on_restart(self): + # Hook liveops: wołany przez RestartView przed resetem stanu i + # ponownym zakolejkowaniem — odpowiednik dawnego long_running + # on_reset(). Kasuje wiersze poprzedniego przebiegu. self.wierszimportulistyministerialnej_set.all().delete() def run(self, p): diff --git a/src/import_list_ministerialnych/tests.py b/src/import_list_ministerialnych/tests.py index 13edc2fac..48fb7b7b0 100644 --- a/src/import_list_ministerialnych/tests.py +++ b/src/import_list_ministerialnych/tests.py @@ -857,18 +857,22 @@ def test_run_finalizes_operation_with_result_context(): @pytest.mark.django_db def test_central_live_view_renders_host_page(admin_client, admin_user): - """Centralny liveops:live rozwiązuje model po UUID i renderuje host-page. + """Generyczny liveops:live (op_type) renderuje host-page. - Waliduje warstwę bpp_liveops: ResolveOperationMixin znajduje konkretny - model po UUID, render host-template (import_list_ministerialnych.html) z - kontenerem live-operacji (data-liveop-channel/token dla liveops.js), a - superuser przechodzi bramkę grupy (braces, superuser-exempt). + Waliduje integrację z django-liveops 0.2: get_absolute_url() zawiera + op_type (.), widok pakietu rozwiązuje konkretny + model jednym zapytaniem, renderuje host-template z kontenerem + live-operacji (data-liveop-channel/token dla liveops.js), a superuser + przechodzi bramkę REQUIRED_GROUP (zwolnienie superusera w 0.2). """ op = baker.make(ImportListMinisterialnych, owner=admin_user, rok=2023) op.plik.name = "protected/import_list_ministerialnych/dummy.xlsx" op.save(update_fields=["plik"]) - response = admin_client.get(f"/live/{op.pk}/") + url = op.get_absolute_url() + assert url == f"/live/import_list_ministerialnych.importlistministerialnych/{op.pk}/" + + response = admin_client.get(url) assert response.status_code == 200 content = response.content.decode() assert "data-liveop-channel" in content diff --git a/uv.lock b/uv.lock index d630ed270..71a729f71 100644 --- a/uv.lock +++ b/uv.lock @@ -589,7 +589,7 @@ requires-dist = [ { name = "django-grappelli", specifier = ">=5.0.0,<6" }, { name = "django-group-by", specifier = "==0.3.1" }, { name = "django-import-export", git = "https://github.com/mpasternak/django-import-export.git?rev=d6ee0d39194fee31437affbdc6fee5ce549b4b8f" }, - { name = "django-liveops", specifier = ">=0.1,<0.2" }, + { name = "django-liveops", specifier = ">=0.2,<0.3" }, { name = "django-loginas", specifier = "==0.3.14" }, { name = "django-messages-extends", specifier = "==0.6.3" }, { name = "django-microsoft-auth", marker = "extra == 'office365'", specifier = ">=3.0.1" }, @@ -1939,16 +1939,16 @@ wheels = [ [[package]] name = "django-liveops" -version = "0.1.0" +version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "channels", marker = "platform_python_implementation != 'PyPy'" }, { name = "django", marker = "platform_python_implementation != 'PyPy'" }, { name = "django-channels-broadcast", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/d9/16839b0fc4ab30d3f2b87b92927d979871d48d14cd5ec06745406ca5a939/django_liveops-0.1.0.tar.gz", hash = "sha256:3a6597226302616d5238447b43d50c99a196fe4947a19a95e25363b11072883b", size = 268744, upload-time = "2026-07-03T12:36:45.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/47/c7b5b0476c31bb98ee5877f0992e3c0cb4e33c4cc5a46c518953e996d42b/django_liveops-0.2.0.tar.gz", hash = "sha256:bdcd6e3e3960173d56bc7caec832aab11ba3c030063353c475fd9fd7953da77c", size = 271526, upload-time = "2026-07-03T15:49:02.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/d6/bb30bdd07a536817ae30b6c68191bdcfe6c39624a9f75f80706a0d23842d/django_liveops-0.1.0-py3-none-any.whl", hash = "sha256:274c1b1fc41ea9aa1675e489dbb2db29eaad5f38cdc115959ca1008fb24e903e", size = 70257, upload-time = "2026-07-03T12:36:43.135Z" }, + { url = "https://files.pythonhosted.org/packages/b8/06/6799e3a9246df99401125d4b1b7ff868773a1f48672424d75a9cc0ad7112/django_liveops-0.2.0-py3-none-any.whl", hash = "sha256:4e943f50f21769b70c849d8080ff1c0d6d83f05ec1d6d5c04c93d58881698833", size = 71758, upload-time = "2026-07-03T15:49:04.29Z" }, ] [[package]] From 8be2dea17175ee752fef49f4bec25b6dbe8ad61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 3 Jul 2026 20:10:00 +0200 Subject: [PATCH 3/5] deps: bump django-liveops to >=0.3,<0.4; use liveops.testing.MockProgress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit django-liveops 0.3.0 brings the CSRF fix (cancel/restart on the live page), htmx cancel/restart, get_success_url, and MockProgress. Bump the pin and: - swap the hand-rolled FakeProgress in tests for liveops.testing.MockProgress (records calls + finalizes the operation) — the finalization test drops TextProgress/io for MockProgress too; - simplify the host page: the "see full results" link now lives only in the result panel (shown on completion), not persistently above it. Tests: 15 passed on 0.3.0. manage.py check clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- .../import_list_ministerialnych.html | 9 +-- src/import_list_ministerialnych/tests.py | 60 +++++-------------- uv.lock | 8 +-- 4 files changed, 23 insertions(+), 56 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c9a6f69d8..4b5b63a22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,7 +157,7 @@ dependencies = [ "babel>=2.17", # django-liveops: framework dla dlugo-dzialajacych operacji z live UI # (WebSocket + HTMX). Nastepca wewnetrznego `src/long_running`. - "django-liveops>=0.2,<0.3", + "django-liveops>=0.3,<0.4", ] [project.optional-dependencies] diff --git a/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html b/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html index 7b90968b7..14c568cb2 100644 --- a/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html +++ b/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html @@ -16,15 +16,10 @@

      Import danych {{ object.plik.name }}

      {# Region live-operacji: kontener z data-liveop-channel/token + strefy #} {# op-status/op-progress/op-log/op-stages/op-result (OOB-swap z workera). #} + {# Przycisk "Zobacz pełne wyniki" pojawia się w #op-result (panel wyniku) #} + {# dopiero po zakończeniu importu — patrz _result.html. #} {% live_operation object %} -

      - - Zobacz pełne wyniki importu - -

      - {# Kolejność ładowania jest istotna: htmx → klient channels_broadcast → #} {# liveops.js (patchuje channelsBroadcast.addMessage i inicjuje socket). #} diff --git a/src/import_list_ministerialnych/tests.py b/src/import_list_ministerialnych/tests.py index 48fb7b7b0..ba0d9af28 100644 --- a/src/import_list_ministerialnych/tests.py +++ b/src/import_list_ministerialnych/tests.py @@ -1,6 +1,7 @@ from unittest.mock import patch import pytest +from liveops.testing import MockProgress from model_bakery import baker from bpp.models import Zrodlo @@ -12,33 +13,6 @@ from pbn_api.models import Journal -class FakeProgress: - """Minimalny obiekt Progress do testów jednostkowych rdzenia importu. - - ``core.analyze_excel_file_import_list_ministerialnych`` używa tylko - ``track`` (iterowanie z postępem) i ``log``. Reszta metod jest dla - kompletności API liveops.Progress, gdyby rdzeń zaczął ich używać. - """ - - def track(self, iterable, total=None, label=None, unit="item"): - return iter(iterable) - - def log(self, line): - pass - - def percent(self, value): - pass - - def status(self, text, level="info"): - pass - - def result(self, context=None, **extra): - pass - - def check_cancelled(self): - pass - - @pytest.mark.django_db def test_import_list_ministerialnych_with_mnisw_id(): """Test import matching journals by mniswId when ISSN is missing""" @@ -71,7 +45,7 @@ def test_import_list_ministerialnych_with_mnisw_id(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - p = FakeProgress() + p = MockProgress(parent_model) # Mock Excel data with mniswId but no ISSN mock_data = [ @@ -141,7 +115,7 @@ def test_import_list_ministerialnych_issn_priority(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - p = FakeProgress() + p = MockProgress(parent_model) # Mock Excel data with both ISSN and mniswId mock_data = [ @@ -185,7 +159,7 @@ def test_import_list_ministerialnych_no_mnisw_id(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - p = FakeProgress() + p = MockProgress(parent_model) # Mock Excel data without mniswId column mock_data = [ @@ -229,7 +203,7 @@ def test_import_list_ministerialnych_invalid_mnisw_id(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - p = FakeProgress() + p = MockProgress(parent_model) mock_data = [ { @@ -286,7 +260,7 @@ def test_import_list_ministerialnych_column_name_with_space(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - p = FakeProgress() + p = MockProgress(parent_model) # Mock Excel data with space in column name (common Excel issue) mock_data = [ @@ -392,7 +366,7 @@ def test_import_with_duplicate_detection(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - p = FakeProgress() + p = MockProgress(parent_model) # Mock data with duplicate ISSN mock_data = [ @@ -455,7 +429,7 @@ def test_import_with_multiple_duplicate_reasons(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - p = FakeProgress() + p = MockProgress(parent_model) mock_data = [ { @@ -612,7 +586,7 @@ def test_import_with_nie_porownuj_po_tytulach_enabled(): ignoruj_zrodla_bez_odpowiednika=False, nie_porownuj_po_tytulach=True, # Enable the new option ) - p = FakeProgress() + p = MockProgress(parent_model) # Mock Excel data with title "Electronics" but ISSN matching "Electronics (Switzerland)" mock_data = [ @@ -659,7 +633,7 @@ def test_import_with_nie_porownuj_po_tytulach_disabled(): ignoruj_zrodla_bez_odpowiednika=False, nie_porownuj_po_tytulach=False, # Default behavior - use title matching ) - p = FakeProgress() + p = MockProgress(parent_model) # Mock Excel data with matching title but no ISSN mock_data = [ @@ -706,7 +680,7 @@ def test_import_title_not_matched_when_nie_porownuj_po_tytulach_enabled(): ignoruj_zrodla_bez_odpowiednika=False, nie_porownuj_po_tytulach=True, # Skip title matching ) - p = FakeProgress() + p = MockProgress(parent_model) # Mock Excel data with matching title but no identifiers mock_data = [ @@ -802,16 +776,12 @@ def test_import_results_view_statistics(admin_client, admin_user): def test_run_finalizes_operation_with_result_context(): """Integracja liveops: run() → core → p.result() finalizuje operację. - Używa prawdziwego liveops.TextProgress (nie FakeProgress), żeby + Używa liveops.testing.MockProgress, żeby zweryfikować, że po przejściu run(): - powstają wiersze importu, - operacja jest oznaczona jako zakończona sukcesem, - result_context zawiera podsumowanie (total/duplicates). """ - import io - - from liveops.progress import TextProgress - zrodlo = baker.make(Zrodlo, nazwa="Run Journal", issn="1234-5678") op = baker.make( @@ -843,7 +813,7 @@ def test_run_finalizes_operation_with_result_context(): return_value=mock_data, ): with patch("import_list_ministerialnych.core.napraw_literowki_w_bazie"): - op.run(TextProgress(op, io.StringIO())) + op.run(MockProgress(op)) op.refresh_from_db() assert op.finished_on is not None @@ -870,7 +840,9 @@ def test_central_live_view_renders_host_page(admin_client, admin_user): op.save(update_fields=["plik"]) url = op.get_absolute_url() - assert url == f"/live/import_list_ministerialnych.importlistministerialnych/{op.pk}/" + assert ( + url == f"/live/import_list_ministerialnych.importlistministerialnych/{op.pk}/" + ) response = admin_client.get(url) assert response.status_code == 200 diff --git a/uv.lock b/uv.lock index 71a729f71..76255f5c0 100644 --- a/uv.lock +++ b/uv.lock @@ -589,7 +589,7 @@ requires-dist = [ { name = "django-grappelli", specifier = ">=5.0.0,<6" }, { name = "django-group-by", specifier = "==0.3.1" }, { name = "django-import-export", git = "https://github.com/mpasternak/django-import-export.git?rev=d6ee0d39194fee31437affbdc6fee5ce549b4b8f" }, - { name = "django-liveops", specifier = ">=0.2,<0.3" }, + { name = "django-liveops", specifier = ">=0.3,<0.4" }, { name = "django-loginas", specifier = "==0.3.14" }, { name = "django-messages-extends", specifier = "==0.6.3" }, { name = "django-microsoft-auth", marker = "extra == 'office365'", specifier = ">=3.0.1" }, @@ -1939,16 +1939,16 @@ wheels = [ [[package]] name = "django-liveops" -version = "0.2.0" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "channels", marker = "platform_python_implementation != 'PyPy'" }, { name = "django", marker = "platform_python_implementation != 'PyPy'" }, { name = "django-channels-broadcast", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/47/c7b5b0476c31bb98ee5877f0992e3c0cb4e33c4cc5a46c518953e996d42b/django_liveops-0.2.0.tar.gz", hash = "sha256:bdcd6e3e3960173d56bc7caec832aab11ba3c030063353c475fd9fd7953da77c", size = 271526, upload-time = "2026-07-03T15:49:02.907Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/90/86f39bce9afedb87ba3769d3a2c6edcf56c3aad64695e70eb9fb1928b4d0/django_liveops-0.3.0.tar.gz", hash = "sha256:2120f5bda23a2c57b9be752b964e5d014e80c0c7706ecb31e435d490b2abc75a", size = 276461, upload-time = "2026-07-03T18:00:20.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/06/6799e3a9246df99401125d4b1b7ff868773a1f48672424d75a9cc0ad7112/django_liveops-0.2.0-py3-none-any.whl", hash = "sha256:4e943f50f21769b70c849d8080ff1c0d6d83f05ec1d6d5c04c93d58881698833", size = 71758, upload-time = "2026-07-03T15:49:04.29Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/675043a056b2a868909cf001ccc31098d2308e444c38437ec9d6aa49d763/django_liveops-0.3.0-py3-none-any.whl", hash = "sha256:a727840f2de249b003b6b4fb2ddaa71d155dd2d27bfd209bff2991c18d14fc01", size = 76158, upload-time = "2026-07-03T18:00:16.774Z" }, ] [[package]] From 38ab659aef5cbda40522f042d361d8916406add5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 5 Jul 2026 10:36:59 +0200 Subject: [PATCH 4/5] =?UTF-8?q?lint(import=5Flist=5Fministerialnych):=20do?= =?UTF-8?q?mknij=20bramk=C4=99=20=E2=80=9ELint=20changed=20files"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - urls.py: import views przed app_name (E402) - core.py: raise ValueError(...) from e (B904); noqa C901 dla odziedziczonej złożoności detect_duplicates (12) i analyze_excel_file_import_list_ministerialnych (26) — refactor poza zakresem tego PR, konwencja jak w ewaluacja_optymalizacja Co-Authored-By: Claude Fable 5 --- src/import_list_ministerialnych/core.py | 6 +++--- src/import_list_ministerialnych/urls.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/import_list_ministerialnych/core.py b/src/import_list_ministerialnych/core.py index 3c5cd7a61..3f621edc3 100644 --- a/src/import_list_ministerialnych/core.py +++ b/src/import_list_ministerialnych/core.py @@ -7,7 +7,7 @@ ) -def detect_duplicates(data): +def detect_duplicates(data): # noqa: C901 """ Detect duplicate journals in the Excel data based on ISSN, E-ISSN, and mniswId. @@ -78,7 +78,7 @@ def detect_duplicates(data): return duplicates -def analyze_excel_file_import_list_ministerialnych( +def analyze_excel_file_import_list_ministerialnych( # noqa: C901 fn, parent_model: ImportListMinisterialnych, p ): napraw_literowki_w_bazie() @@ -101,7 +101,7 @@ def analyze_excel_file_import_list_ministerialnych( p.log(error_msg) # Podnosimy wyjątek — runner liveops złapie go i oznaczy operację # jako zakończoną błędem (dawniej robił to task_perform). - raise ValueError(error_msg) + raise ValueError(error_msg) from e else: # Re-raise other ValueErrors raise diff --git a/src/import_list_ministerialnych/urls.py b/src/import_list_ministerialnych/urls.py index 11d5e10c7..540e0b79b 100644 --- a/src/import_list_ministerialnych/urls.py +++ b/src/import_list_ministerialnych/urls.py @@ -1,8 +1,9 @@ from django.urls import path -app_name = "import_list_ministerialnych" from . import views +app_name = "import_list_ministerialnych" + urlpatterns = [ path("", views.PokazImporty.as_view(), name="index"), path("nowy/", views.UtworzImportDyscyplinZrodel.as_view(), name="utworz-import"), From bebcb797b45801cf9fa99d2872cad384e39a4df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 15 Jul 2026 19:23:51 +0200 Subject: [PATCH 5/5] fix(import_list_ministerialnych): wrapper CSRF dla liveops 0.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit django-liveops 0.4: przyciski Anuluj/Ponów na stronie live to gołe przyciski htmx poza formularzem. BPP ma CSRF_COOKIE_HTTPONLY=True, więc liveops.js nie odczyta tokenu z ciasteczka i POST kończy się 403. Owijam region live-operacji w
      , mirror rozwiązania z import_pracownikow (referencja migracji 0.4). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...import-list-ministerialnych-liveops-csrf.bugfix.rst | 4 ++++ .../import_list_ministerialnych.html | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 src/bpp/newsfragments/import-list-ministerialnych-liveops-csrf.bugfix.rst diff --git a/src/bpp/newsfragments/import-list-ministerialnych-liveops-csrf.bugfix.rst b/src/bpp/newsfragments/import-list-ministerialnych-liveops-csrf.bugfix.rst new file mode 100644 index 000000000..806dcb075 --- /dev/null +++ b/src/bpp/newsfragments/import-list-ministerialnych-liveops-csrf.bugfix.rst @@ -0,0 +1,4 @@ +Import list ministerialnych: przyciski „Anuluj"/„Ponów" na stronie live +dostają teraz token CSRF nagłówkiem ``X-CSRFToken`` (wrapper +``hx-headers`` wokół regionu live-operacji). Bez tego przy +``CSRF_COOKIE_HTTPONLY=True`` POST-y liveops kończyły się błędem 403. diff --git a/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html b/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html index 14c568cb2..956046531 100644 --- a/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html +++ b/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html @@ -18,7 +18,15 @@

      Import danych {{ object.plik.name }}

      {# op-status/op-progress/op-log/op-stages/op-result (OOB-swap z workera). #} {# Przycisk "Zobacz pełne wyniki" pojawia się w #op-result (panel wyniku) #} {# dopiero po zakończeniu importu — patrz _result.html. #} - {% live_operation object %} + {# 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. #} + {# Wrapper nie jest podmieniany przy OOB-swapie regionów op-*, więc token #} + {# dziedziczą też przyciski dorenderowane po odświeżeniu przez WebSocket. #} +
      + {% live_operation object %} +
      {# Kolejność ładowania jest istotna: htmx → klient channels_broadcast → #} {# liveops.js (patchuje channelsBroadcast.addMessage i inicjuje socket). #}