diff --git a/src/bpp/newsfragments/import-list-if-liveops.feature.rst b/src/bpp/newsfragments/import-list-if-liveops.feature.rst
new file mode 100644
index 000000000..5d85fc51c
--- /dev/null
+++ b/src/bpp/newsfragments/import-list-if-liveops.feature.rst
@@ -0,0 +1,2 @@
+Import list IF przeniesiony z long_running na django-liveops: postęp
+importu na żywo przez WebSocket, spójny z pozostałymi importerami.
diff --git a/src/import_common/models.py b/src/import_common/models.py
index 79686c895..6583f0e56 100644
--- a/src/import_common/models.py
+++ b/src/import_common/models.py
@@ -1,62 +1,3 @@
-from math import ceil
-
-from django.db import models
-
-from import_common.exceptions import XLSParseError
-from import_common.util import XLSImportFile
-from long_running.models import Operation
-from long_running.notification_mixins import ASGINotificationMixin
-
-
-class ImportOperation(ASGINotificationMixin, Operation):
- plik_xls = models.FileField(upload_to="protected/import_common/")
-
- try_names = None
- banned_names = None
- min_points = None
-
- validation_form_class = None
-
- ignore_bad_rows = False
-
- def get_validation_form_class(self):
- return self.validation_form_class
-
- def get_xls_import_file(self):
- return XLSImportFile(
- self.plik_xls.path,
- try_names=self.try_names,
- banned_names=self.banned_names,
- min_points=self.min_points,
- )
-
- def perform(self):
- x = self.get_xls_import_file()
-
- total = x.count()
- five_percent = int(ceil(total / 50.0))
- form_class = self.get_validation_form_class()
-
- for no, elem in enumerate(x.data()):
- cleaned_data = None
- if form_class:
- form = form_class(elem)
- if not form.is_valid():
- if self.ignore_bad_rows:
- continue
- raise XLSParseError(elem, form, "wstępna weryfikacja danych")
-
- cleaned_data = form.cleaned_data
-
- self.import_single_row(xls_data=elem, cleaned_data=cleaned_data)
-
- if no % five_percent == 0:
- self.send_progress(no * 100.0 / total)
-
- class Meta:
- abstract = True
-
-
class ImportRowMixin:
def nr_arkusza(self):
return self.dane_z_xls.get("__xls_loc_sheet__")
diff --git a/src/import_common/util.py b/src/import_common/util.py
index 241b6a962..4a6a607b2 100644
--- a/src/import_common/util.py
+++ b/src/import_common/util.py
@@ -45,9 +45,11 @@ def sprawdz_bombe_dekompresji(sciezka, max_rozpakowany=MAX_ROZMIAR_PO_DEKOMPRESJ
f"potencjalna bomba dekompresyjna."
)
+
# openpyxl importujemy LOKALNIE (nie na poziomie modułu): ten moduł jest
-# importowany eager przez import_common.models (XLSImportFile) już przy
-# django.setup(), a openpyxl przez compat/numbers.py ciągnie całe numpy
+# importowany eager przez modele importerów (np. import_list_if.models →
+# XLSImportFile) już przy django.setup(), a openpyxl przez compat/numbers.py
+# ciągnie całe numpy
# (~tens MB RSS) do KAŻDEGO procesu — także web/ASGI, który nigdy nie czyta
# xlsx. Dzięki PEP 563 (future annotations) adnotacje typu ``openpyxl.*``
# w sygnaturach poniżej są tylko łańcuchami i nie wymagają openpyxl w
diff --git a/src/import_list_if/migrations/0005_liveops.py b/src/import_list_if/migrations/0005_liveops.py
new file mode 100644
index 000000000..0fcbdf213
--- /dev/null
+++ b/src/import_list_if/migrations/0005_liveops.py
@@ -0,0 +1,82 @@
+# Generated by Django 5.2.16 on 2026-07-16 18:57
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("import_list_if", "0004_alter_importlistif_plik_xls"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="importlistif",
+ options={"ordering": ["-created_on"]},
+ ),
+ migrations.RemoveField(
+ model_name="importlistif",
+ name="last_updated_on",
+ ),
+ migrations.AddField(
+ model_name="importlistif",
+ name="cancel_requested",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name="importlistif",
+ name="cancelled",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name="importlistif",
+ name="current_stage",
+ field=models.IntegerField(default=-1),
+ ),
+ migrations.AddField(
+ model_name="importlistif",
+ name="language",
+ field=models.CharField(blank=True, default="", max_length=20),
+ ),
+ migrations.AddField(
+ model_name="importlistif",
+ name="log",
+ field=models.JSONField(default=list),
+ ),
+ migrations.AddField(
+ model_name="importlistif",
+ name="log_seq",
+ field=models.PositiveIntegerField(default=0),
+ ),
+ migrations.AddField(
+ model_name="importlistif",
+ name="percent",
+ field=models.PositiveSmallIntegerField(default=0),
+ ),
+ migrations.AddField(
+ model_name="importlistif",
+ name="result_context",
+ field=models.JSONField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="importlistif",
+ name="stage_states",
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name="importlistif",
+ name="status_text",
+ field=models.CharField(blank=True, default="", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="importlistif",
+ 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_if/models.py b/src/import_list_if/models.py
index 436b81027..2678acde9 100644
--- a/src/import_list_if/models.py
+++ b/src/import_list_if/models.py
@@ -1,6 +1,7 @@
from django import forms
-from django.db import models
+from django.db import models, transaction
from django.db.models import JSONField
+from liveops.models import LiveOperation
from bpp.models import (
IF_DECIMAL_PLACES,
@@ -10,7 +11,9 @@
Zrodlo,
)
from import_common.core import matchuj_zrodlo
-from import_common.models import ImportOperation, ImportRowMixin
+from import_common.exceptions import XLSParseError
+from import_common.models import ImportRowMixin
+from import_common.util import XLSImportFile
class ImportListRowValidationForm(forms.Form):
@@ -20,14 +23,65 @@ class ImportListRowValidationForm(forms.Form):
)
-class ImportListIf(ImportOperation):
+class ImportListIf(LiveOperation):
rok = models.PositiveSmallIntegerField()
+ plik_xls = models.FileField(upload_to="protected/import_common/")
try_names = ["full_journal_title", "journal_impact_factor"]
banned_names = []
min_points = 2
validation_form_class = ImportListRowValidationForm
+ ignore_bad_rows = False
+
+ def get_validation_form_class(self):
+ return self.validation_form_class
+
+ def get_xls_import_file(self):
+ return XLSImportFile(
+ self.plik_xls.path,
+ try_names=self.try_names,
+ banned_names=self.banned_names,
+ min_points=self.min_points,
+ )
+
+ def run(self, p):
+ # Punkt wejścia liveops (dawniej ImportOperation.perform()). Owijamy
+ # ciało w transaction.atomic() dla parytetu z legacy task_perform:
+ # OperationCancelled / XLSParseError cofa (rollback) już zapisane
+ # wiersze → import jest all-or-nothing. p.result() jest POZA blokiem,
+ # więc panel wyniku dostaje policzone (zacommitowane) wiersze.
+ with transaction.atomic():
+ x = self.get_xls_import_file()
+ total = x.count()
+ form_class = self.get_validation_form_class()
+
+ for no, elem in enumerate(x.data()):
+ p.check_cancelled()
+
+ cleaned_data = None
+ if form_class:
+ form = form_class(elem)
+ if not form.is_valid():
+ if self.ignore_bad_rows:
+ continue
+ raise XLSParseError(elem, form, "wstępna weryfikacja danych")
+ cleaned_data = form.cleaned_data
+
+ self.import_single_row(xls_data=elem, cleaned_data=cleaned_data)
+
+ if total:
+ p.percent(int((no + 1) * 100 / total))
+
+ wiersze = self.get_details_set()
+ p.result(
+ {
+ "total": total,
+ "zintegrowano": wiersze.filter(zintegrowano=True).count(),
+ "niedopasowane": wiersze.filter(zrodlo__isnull=True).count(),
+ "rok": self.rok,
+ }
+ )
def import_single_row(self, xls_data, cleaned_data):
zrodlo = matchuj_zrodlo(cleaned_data.get("full_journal_title"))
@@ -57,11 +111,16 @@ def import_single_row(self, xls_data, cleaned_data):
res.save()
- def on_reset(self):
+ def on_restart(self):
+ # Hook liveops (RestartView) — odpowiednik dawnego on_reset(). Kasuje
+ # wiersze poprzedniego przebiegu, by restart zaczynał od czysta.
self.get_details_set().delete()
def get_details_set(self):
- return self.importlistifrow_set.all().select_related("zrodlo")
+ # order_by("id") = kolejność wstawiania = kolejność wierszy w pliku;
+ # deterministyczna paginacja tabeli wyników (ImportListIfRow nie ma
+ # Meta.ordering, więc bez tego paginator ostrzega o niestabilności).
+ return self.importlistifrow_set.all().select_related("zrodlo").order_by("id")
class ImportListIfRow(ImportRowMixin, models.Model):
diff --git a/src/import_list_if/templates/import_list_if/import_list_if.html b/src/import_list_if/templates/import_list_if/import_list_if.html
new file mode 100644
index 000000000..4bd35d089
--- /dev/null
+++ b/src/import_list_if/templates/import_list_if/import_list_if.html
@@ -0,0 +1,36 @@
+{% extends "base.html" %}
+{% load static liveops %}
+
+{% block extratitle %}
+ Import list IF - {{ object.plik_xls.name }}
+{% endblock %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+
import list IF
+ import {{ object.plik_xls.name }}
+{% endblock %}
+
+{% block content %}
+ Import list IF — przetwarzanie
+
+ {# Region live-operacji: kontener z data-liveop-channel/token + strefy #}
+ {# op-status/op-progress/op-log/op-result (OOB-swap z workera). Panel #}
+ {# wyniku (z linkiem do tabeli wyników) pojawia się w #op-result dopiero #}
+ {# po zakończeniu importu — patrz import_list_if_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_if/templates/import_list_if/import_list_if_result.html b/src/import_list_if/templates/import_list_if/import_list_if_result.html
new file mode 100644
index 000000000..55c673176
--- /dev/null
+++ b/src/import_list_if/templates/import_list_if/import_list_if_result.html
@@ -0,0 +1,20 @@
+{# 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 + klucze z result_context (total, zintegrowano, #}
+{# niedopasowane, rok). #}
+
+
+
+ Import zakończony
+
+
+ Przetworzono wierszy: {{ total }},
+ zintegrowano: {{ zintegrowano }},
+ niedopasowanych źródeł: {{ niedopasowane }}{% if rok %}
+ (rok {{ rok }}){% endif %}.
+
+
+ Zobacz pełne wyniki importu
+
+
diff --git a/src/import_list_if/templates/import_list_if/importlistif_detail.html b/src/import_list_if/templates/import_list_if/importlistif_detail.html
deleted file mode 100644
index b12b084d3..000000000
--- a/src/import_list_if/templates/import_list_if/importlistif_detail.html
+++ /dev/null
@@ -1,27 +0,0 @@
-{% extends "base.html" %}{% load render_table from django_tables2 %}
-
-{% block extratitle %}
- Import list IF - szczegóły {{ object.plik_xls.name }}
-{% endblock %}
-
-{% block breadcrumbs %}
- {{ block.super }}
- import list IF
- import {{ object.plik_xls.name }}
-{% endblock %}
-
-
-{% block content %}
- Import danych {{ object.plik_xls.name }}
-
-
-
-{% endblock %}
diff --git a/src/import_list_if/templates/import_list_if/importlistif_form.html b/src/import_list_if/templates/import_list_if/importlistif_form.html
index 581398387..168c32b5c 100644
--- a/src/import_list_if/templates/import_list_if/importlistif_form.html
+++ b/src/import_list_if/templates/import_list_if/importlistif_form.html
@@ -1,4 +1,4 @@
-{% extends "base.html" %}{% load render_table from django_tables2 %}
+{% extends "base.html" %}
{% block extratitle %}
Import list IF - nowy
diff --git a/src/import_list_if/templates/import_list_if/importlistif_list.html b/src/import_list_if/templates/import_list_if/importlistif_list.html
index 64cef804c..464a812e3 100644
--- a/src/import_list_if/templates/import_list_if/importlistif_list.html
+++ b/src/import_list_if/templates/import_list_if/importlistif_list.html
@@ -1,4 +1,4 @@
-{% extends "base.html" %}{% load render_table from django_tables2 %}
+{% extends "base.html" %}
{% block extratitle %}
Import list IF
@@ -6,7 +6,7 @@
{% block breadcrumbs %}
{{ block.super }}
- import list IF
+ import list IF
{% endblock %}
{% block content %}{% load static %}
@@ -24,7 +24,7 @@ Ostatnio importowane dane:
{% for object in object_list %}
-
- plik {{ object.plik_xls.name }}:
+ plik {{ object.plik_xls.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_if/templates/import_list_if/importlistifrow_list.html b/src/import_list_if/templates/import_list_if/importlistifrow_list.html
index fe13760ef..6fad5a8c3 100644
--- a/src/import_list_if/templates/import_list_if/importlistifrow_list.html
+++ b/src/import_list_if/templates/import_list_if/importlistifrow_list.html
@@ -1,4 +1,4 @@
-{% extends "base.html" %}{% load render_table from django_tables2 %}
+{% extends "base.html" %}
{% block extratitle %}
Import list IF - szczegóły {{ object.plik_xls.name }}
@@ -14,18 +14,6 @@
{% block content %}
Import danych {{ object.plik_xls.name }}
-
-
Lista modyfikacji do bazy danych, przeprowadzonych na podstawie pliku XLS, zajduje się poniżej:
{% include "pagination.html" %}
diff --git a/src/import_list_if/tests/test_anon_probe.py b/src/import_list_if/tests/test_anon_probe.py
new file mode 100644
index 000000000..b205cfdf0
--- /dev/null
+++ b/src/import_list_if/tests/test_anon_probe.py
@@ -0,0 +1,10 @@
+import pytest
+from django.urls import reverse
+
+
+@pytest.mark.django_db
+def test_anon_new_nie_daje_500(client):
+ """Anon GET na gejtowany liveops CreateView — 302→login, NIE 500."""
+ resp = client.get(reverse("import_list_if:new"))
+ assert resp.status_code != 500, "500-for-anon (handle_no_permission MRO bug)"
+ assert resp.status_code == 302
diff --git a/src/import_list_if/tests/test_models.py b/src/import_list_if/tests/test_models.py
index cbb3f3d7c..d57efc1dd 100644
--- a/src/import_list_if/tests/test_models.py
+++ b/src/import_list_if/tests/test_models.py
@@ -2,6 +2,8 @@
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
+from liveops.progress import OperationCancelled
+from liveops.testing import MockProgress
from bpp.models import Zrodlo
from import_list_if.models import ImportListIf
@@ -32,17 +34,35 @@ def import_list_if(admin_user, testdata_xlsx_path, zrodlo, rok):
return import_list_if_factory(admin_user, testdata_xlsx_path, rok)
-def test_ImportListIF_perform(import_list_if, rok):
- import_list_if.perform()
+def test_ImportListIF_run(import_list_if, rok):
+ import_list_if.run(MockProgress(import_list_if))
assert import_list_if.importlistifrow_set.count() == 4
assert Zrodlo.objects.get(nazwa="Gazeta").punktacja_zrodla_set.get(
rok=rok
).impact_factor == Decimal("70.67")
+ assert import_list_if.result_context["total"] == 4
-def test_ImportListIF_on_reset(import_list_if, rok):
- import_list_if.on_reset()
- assert True
+def test_ImportListIF_run_finalizuje_operacje(import_list_if):
+ import_list_if.run(MockProgress(import_list_if))
+ import_list_if.refresh_from_db()
+ assert import_list_if.finished_on is not None
+ assert import_list_if.finished_successfully is True
+
+
+def test_ImportListIF_run_anulowanie_cofa_wiersze(import_list_if):
+ # Owinięcie run() w transaction.atomic() sprawia, że OperationCancelled
+ # cofa (rollback) już zapisane wiersze — parytet z legacy task_perform.
+ with pytest.raises(OperationCancelled):
+ import_list_if.run(MockProgress(import_list_if, cancel_after=1))
+ assert import_list_if.importlistifrow_set.count() == 0
+
+
+def test_ImportListIF_on_restart(import_list_if, rok):
+ import_list_if.run(MockProgress(import_list_if))
+ assert import_list_if.importlistifrow_set.count() == 4
+ import_list_if.on_restart()
+ assert import_list_if.importlistifrow_set.count() == 0
def test_ImportListIF_get_details_set(import_list_if, rok):
diff --git a/src/import_list_if/tests/test_views.py b/src/import_list_if/tests/test_views.py
index 557239565..2c872d60c 100644
--- a/src/import_list_if/tests/test_views.py
+++ b/src/import_list_if/tests/test_views.py
@@ -1,4 +1,30 @@
+import os
+
+import pytest
+from django.contrib.auth.models import Group
+from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
+from liveops.testing import MockProgress
+
+from bpp.const import GR_WPROWADZANIE_DANYCH
+from import_list_if.models import ImportListIf
+
+
+@pytest.fixture
+def testdata_xlsx_path():
+ return os.path.join(os.path.dirname(__file__), "testdata1.xlsx")
+
+
+def _user_w_grupie(django_user_model, username):
+ user = django_user_model.objects.create_user(username=username, password="x")
+ grupa, _ = Group.objects.get_or_create(name=GR_WPROWADZANIE_DANYCH)
+ user.groups.add(grupa)
+ return user
+
+
+def _upload(path):
+ with open(path, "rb") as f:
+ return SimpleUploadedFile("testdata1.xlsx", f.read())
def test_ListaImportowView_link(admin_app):
@@ -11,3 +37,79 @@ def test_NowyImportView_link(admin_app):
page = admin_app.get(reverse("import_list_if:new"))
page = page.click("pobierz plik wzorcowy")
assert page.status_code == 200
+
+
+@pytest.mark.django_db
+def test_create_przekierowuje_na_live_i_zapisuje_wiersze(
+ client, django_user_model, testdata_xlsx_path
+):
+ user = _user_w_grupie(django_user_model, "creator")
+ client.force_login(user)
+
+ resp = client.post(
+ reverse("import_list_if:new"),
+ {"plik_xls": _upload(testdata_xlsx_path), "rok": 2020},
+ )
+
+ assert resp.status_code == 302
+ op = ImportListIf.objects.get(owner=user)
+ assert resp.url == op.get_absolute_url()
+ assert "/live/import_list_if.importlistif/" in resp.url
+ # RUNNER=eager (settings/test): run() wykonał się synchronicznie.
+ assert op.importlistifrow_set.count() == 4
+
+
+@pytest.mark.django_db
+def test_create_wymaga_grupy_bez_side_effectu(
+ client, django_user_model, testdata_xlsx_path
+):
+ user = django_user_model.objects.create_user(username="nogroup", password="x")
+ client.force_login(user)
+
+ resp = client.post(
+ reverse("import_list_if:new"),
+ {"plik_xls": _upload(testdata_xlsx_path), "rok": 2020},
+ )
+
+ # Braces GroupRequiredMixin (raise_exception=False) → redirect_to_login,
+ # a NIE 403. Asertujemy też brak side-effectu: import nie powstał.
+ assert resp.status_code == 302
+ assert "login" in resp.url
+ assert not ImportListIf.objects.filter(owner=user).exists()
+
+
+@pytest.mark.django_db
+def test_results_view_renderuje_wiersze_owner_scoped(
+ client, django_user_model, testdata_xlsx_path
+):
+ owner = _user_w_grupie(django_user_model, "owner")
+ op = ImportListIf(owner=owner, rok=2020)
+ op.plik_xls = _upload(testdata_xlsx_path)
+ op.save()
+ op.run(MockProgress(op))
+
+ url = reverse("import_list_if:importlistif-results", args=[op.pk])
+
+ client.force_login(owner)
+ resp = client.get(url)
+ assert resp.status_code == 200
+ assert op.importlistifrow_set.count() == 4
+
+ # Cudzy użytkownik (też w grupie) nie widzi importu → Http404.
+ inny = _user_w_grupie(django_user_model, "inny")
+ client.force_login(inny)
+ resp = client.get(url)
+ assert resp.status_code == 404
+
+
+@pytest.mark.django_db
+def test_results_view_nieistniejacy_pk_daje_404(client, django_user_model):
+ """Nieistniejący pk rodzica → 404 (nie 500). Parytet z get_object_or_404
+ we wzorcu import_pracownikow."""
+ from uuid import uuid4
+
+ owner = _user_w_grupie(django_user_model, "owner")
+ client.force_login(owner)
+ url = reverse("import_list_if:importlistif-results", args=[uuid4()])
+ resp = client.get(url)
+ assert resp.status_code == 404
diff --git a/src/import_list_if/urls.py b/src/import_list_if/urls.py
index 722823c14..53ac8d948 100644
--- a/src/import_list_if/urls.py
+++ b/src/import_list_if/urls.py
@@ -1,12 +1,9 @@
from django.urls import path
from import_list_if.views import (
- ImportListIfDetailsView,
ImportListIfResultsView,
- ImportListIfRouterView,
ListaImportowView,
NowyImportView,
- RestartImportView,
)
app_name = "import_list_if"
@@ -14,24 +11,12 @@
urlpatterns = [
path("", ListaImportowView.as_view(), name="index"),
path("new/", NowyImportView.as_view(), name="new"),
- path(
- "/",
- ImportListIfRouterView.as_view(),
- name="importlistif-router",
- ),
- path(
- "/detale/",
- ImportListIfDetailsView.as_view(),
- name="importlistif-details",
- ),
+ # Strona live (postęp + wynik) NIE jest tutaj — obsługuje ją centralny
+ # namespace ``liveops`` (reverse "liveops:live" przez get_absolute_url).
+ # Router/detale/regen z long_running zostały usunięte.
path(
"/rezultaty/",
ImportListIfResultsView.as_view(),
name="importlistif-results",
),
- path(
- "/regen/",
- RestartImportView.as_view(),
- name="restart",
- ),
]
diff --git a/src/import_list_if/views.py b/src/import_list_if/views.py
index fdb45f4ee..7905521da 100644
--- a/src/import_list_if/views.py
+++ b/src/import_list_if/views.py
@@ -1,41 +1,68 @@
from braces.views import GroupRequiredMixin
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+from django.utils.functional import cached_property
+from django.views.generic import ListView
+from liveops.views import CreateLiveOperationView
+from bpp.const import GR_WPROWADZANIE_DANYCH
from import_list_if.forms import NowyImportForm
from import_list_if.models import ImportListIf
-from long_running.views import (
- CreateLongRunningOperationView,
- LongRunningDetailsView,
- LongRunningOperationsView,
- LongRunningResultsView,
- LongRunningRouterView,
- RestartLongRunningOperationView,
-)
class BaseImportListIfMixin(GroupRequiredMixin):
- group_required = "wprowadzanie danych"
+ # Bramka grupy przez braces (superuser-exempt). liveops gejtuje grupą tylko
+ # przy LIVEOPS["REQUIRED_GROUP"], którego BPP nie ustawia — stąd braces.
+ group_required = GR_WPROWADZANIE_DANYCH
model = ImportListIf
-class ListaImportowView(BaseImportListIfMixin, LongRunningOperationsView):
- pass
+class ListaImportowView(BaseImportListIfMixin, ListView):
+ """Lista importów bieżącego użytkownika (owner-scoped).
+ Dawniej long_running.LongRunningOperationsView. Strona live (postęp/wynik)
+ jest osobno, pod centralnym ``liveops:live`` (link przez get_absolute_url).
+ """
-class NowyImportView(BaseImportListIfMixin, CreateLongRunningOperationView):
- form_class = NowyImportForm
+ def get_queryset(self):
+ return self.model.objects.filter(owner=self.request.user).order_by(
+ "-created_on"
+ )
+
+
+class NowyImportView(BaseImportListIfMixin, CreateLiveOperationView):
+ """Formularz nowego importu.
+ ``CreateLiveOperationView`` (liveops) ustawia owner, zapisuje, kolejkuje
+ operację i przekierowuje na ``get_absolute_url()`` (centralna strona live).
+ """
+
+ form_class = NowyImportForm
-class ImportListIfRouterView(BaseImportListIfMixin, LongRunningRouterView):
- redirect_prefix = "import_list_if:import_list_if"
+class ImportListIfResultsView(BaseImportListIfMixin, ListView):
+ """Tabela wyników importu (owner-scoped).
-class ImportListIfDetailsView(BaseImportListIfMixin, LongRunningDetailsView):
- pass
+ Zastępuje long_running.LongRunningResultsView. Queryset to wiersze z
+ ``get_details_set()`` (model ImportListIfRow → szablon
+ import_list_if/importlistifrow_list.html), a ``object`` w kontekście to
+ rodzic (ImportListIf) — szablon używa object.plik_xls.name i paginacji.
+ """
+ paginate_by = 25
-class ImportListIfResultsView(BaseImportListIfMixin, LongRunningResultsView):
- pass
+ @cached_property
+ def parent_object(self):
+ # Nieistniejący pk → 404 (nie 500); parytet z get_object_or_404 we
+ # wzorcu import_pracownikow. Owner-mismatch też 404 (nie ujawniamy
+ # istnienia cudzej operacji).
+ o = get_object_or_404(self.model, 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(BaseImportListIfMixin, RestartLongRunningOperationView):
- pass
+ def get_context_data(self, **kwargs):
+ return super().get_context_data(object=self.parent_object, **kwargs)