diff --git a/pyproject.toml b/pyproject.toml index 304c26214..e4fe270d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,8 @@ dependencies = [ "uvicorn-worker>=0.4.0", "mozilla-django-oidc>=5.0.2,<6", "babel>=2.17", + # django-liveops: framework dla dlugo-dzialajacych operacji z live UI + # (WebSocket + HTMX). Nastepca wewnetrznego `src/long_running`. "django-liveops[celery]>=0.4.0,<0.5", "django-oauth-toolkit==3.3.0", "django-altcha>=1.0.0", 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/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst b/src/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst new file mode 100644 index 000000000..9cbc35253 --- /dev/null +++ b/src/bpp/newsfragments/liveops-import-list-ministerialnych.feature.rst @@ -0,0 +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``. 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/django_bpp/settings/base.py b/src/django_bpp/settings/base.py index ae32b5c23..0c417fe36 100644 --- a/src/django_bpp/settings/base.py +++ b/src/django_bpp/settings/base.py @@ -452,6 +452,9 @@ def _elem_in_sys_argv(possible): "compressor", "session_security", "channels_broadcast", + # 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", "integrator2", "nowe_raporty", @@ -1040,16 +1043,20 @@ def can_login_as(request, target_user): }, } -# django-liveops: długo-działające operacje (np. skan duplikatów źródeł) z -# live-progressem przez WebSocket + HTMX. RUNNER="celery" dispatchuje run() -# jako shared_task na tym samym workerze co reszta BPP (autodiscover). Live -# push wymaga Redis channel-layer (skonfigurowany wyżej). W testach RUNNER -# jest nadpisywany na "eager" (settings/test.py) — skan biegnie synchronicznie -# bez Redis/workera. +# django-liveops: długo-działające operacje (np. skan duplikatów źródeł, +# import list ministerialnych) z live-progressem przez WebSocket + HTMX. +# RUNNER="celery" dispatchuje run() jako shared_task na tym samym workerze co +# reszta BPP (autodiscover). Live push wymaga Redis channel-layer +# (skonfigurowany wyżej). W testach RUNNER jest nadpisywany na "eager" +# (settings/test.py) — operacja biegnie synchronicznie bez Redis/workera. +# 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/import_list_ministerialnych/core.py b/src/import_list_ministerialnych/core.py index 77a3f9b81..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,8 +78,8 @@ def detect_duplicates(data): return duplicates -def analyze_excel_file_import_list_ministerialnych( - fn, parent_model: ImportListMinisterialnych +def analyze_excel_file_import_list_ministerialnych( # noqa: C901 + fn, parent_model: ImportListMinisterialnych, p ): napraw_literowki_w_bazie() @@ -98,10 +98,10 @@ 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 - raise ValueError(error_msg) + 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) from e else: # Re-raise other ValueErrors raise @@ -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..037c4fb66 100644 --- a/src/import_list_ministerialnych/models.py +++ b/src/import_list_ministerialnych/models.py @@ -1,13 +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 long_running.models import Operation -from long_running.notification_mixins import ASGINotificationMixin -class ImportListMinisterialnych(ASGINotificationMixin, Operation): +class ImportListMinisterialnych(LiveOperation): rok = YearField() plik = models.FileField(upload_to="protected/import_list_ministerialnych/") zapisz_zmiany_do_bazy = models.BooleanField(default=False) @@ -22,15 +21,31 @@ class ImportListMinisterialnych(ASGINotificationMixin, Operation): '"Electronics (Switzerland)" oraz "Electronics", gdy w bazie jest wyłącznie źródło "Electronics").', ) - def on_reset(self): + 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 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..956046531 --- /dev/null +++ b/src/import_list_ministerialnych/templates/import_list_ministerialnych/import_list_ministerialnych.html @@ -0,0 +1,36 @@ +{% 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). #} + {# Przycisk "Zobacz pełne wyniki" pojawia się w #op-result (panel wyniku) #} + {# dopiero po zakończeniu importu — patrz _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. #} + {# 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). #} + + + +{% 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..ba0d9af28 100644 --- a/src/import_list_ministerialnych/tests.py +++ b/src/import_list_ministerialnych/tests.py @@ -1,6 +1,7 @@ -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest +from liveops.testing import MockProgress from model_bakery import baker from bpp.models import Zrodlo @@ -44,7 +45,7 @@ def test_import_list_ministerialnych_with_mnisw_id(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = MockProgress(parent_model) # Mock Excel data with mniswId but no ISSN mock_data = [ @@ -66,7 +67,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 +115,7 @@ def test_import_list_ministerialnych_issn_priority(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = MockProgress(parent_model) # Mock Excel data with both ISSN and mniswId mock_data = [ @@ -133,7 +136,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 +159,7 @@ def test_import_list_ministerialnych_no_mnisw_id(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = MockProgress(parent_model) # Mock Excel data without mniswId column mock_data = [ @@ -175,7 +180,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 +203,7 @@ def test_import_list_ministerialnych_invalid_mnisw_id(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = MockProgress(parent_model) mock_data = [ { @@ -216,7 +223,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 +260,7 @@ def test_import_list_ministerialnych_column_name_with_space(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = MockProgress(parent_model) # Mock Excel data with space in column name (common Excel issue) mock_data = [ @@ -272,7 +281,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 +366,7 @@ def test_import_with_duplicate_detection(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = MockProgress(parent_model) # Mock data with duplicate ISSN mock_data = [ @@ -386,7 +397,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 +429,7 @@ def test_import_with_multiple_duplicate_reasons(): importuj_dyscypliny=False, ignoruj_zrodla_bez_odpowiednika=False, ) - parent_model.send_progress = Mock() + p = MockProgress(parent_model) mock_data = [ { @@ -446,7 +459,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 +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 ) - parent_model.send_progress = Mock() + p = MockProgress(parent_model) # Mock Excel data with title "Electronics" but ISSN matching "Electronics (Switzerland)" mock_data = [ @@ -592,7 +607,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 +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 ) - parent_model.send_progress = Mock() + p = MockProgress(parent_model) # Mock Excel data with matching title but no ISSN mock_data = [ @@ -637,7 +654,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 +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 ) - parent_model.send_progress = Mock() + p = MockProgress(parent_model) # Mock Excel data with matching title but no identifiers mock_data = [ @@ -682,7 +701,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 +770,83 @@ 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 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). + """ + 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(MockProgress(op)) + + 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): + """Generyczny liveops:live (op_type) renderuje host-page. + + 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"]) + + 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 + 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..540e0b79b 100644 --- a/src/import_list_ministerialnych/urls.py +++ b/src/import_list_ministerialnych/urls.py @@ -1,26 +1,15 @@ 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"), - 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"