diff --git a/src/bpp/newsfragments/import-polon-index-absencji.bugfix.rst b/src/bpp/newsfragments/import-polon-index-absencji.bugfix.rst new file mode 100644 index 000000000..33bcba81a --- /dev/null +++ b/src/bpp/newsfragments/import-polon-index-absencji.bugfix.rst @@ -0,0 +1,2 @@ +Strona „import absencji" pokazuje teraz listę importów absencji zamiast — +jak dotąd — listy importów POLON. diff --git a/src/bpp/newsfragments/import-polon-liveops.feature.rst b/src/bpp/newsfragments/import-polon-liveops.feature.rst new file mode 100644 index 000000000..5ae399e61 --- /dev/null +++ b/src/bpp/newsfragments/import-polon-liveops.feature.rst @@ -0,0 +1,5 @@ +Import z POLON i import absencji przeniesiono ze starego mechanizmu +``long_running`` na ``django-liveops``: postęp na żywo przez WebSocket + +HTMX, anulowanie i ponawianie operacji, wynik renderowany bez odświeżania +strony. Zachowano wielohostowe zawężanie importu POLON do uczelni oraz +domknięcie podglądu zapisem do bazy. diff --git a/src/import_polon/core/import_absencji.py b/src/import_polon/core/import_absencji.py index 1128341f4..0bb6f5d61 100644 --- a/src/import_polon/core/import_absencji.py +++ b/src/import_polon/core/import_absencji.py @@ -63,7 +63,7 @@ def _process_absence_record(autor, rok: int, ile_dni: int, zapisz: bool) -> str: return rezultat -def analyze_file_import_absencji(fn, parent_model: ImportPlikuAbsencji): +def analyze_file_import_absencji(fn, parent_model: ImportPlikuAbsencji, p): try: data = read_excel_or_csv_dataframe_guess_encoding(fn) except ValueError as e: @@ -79,7 +79,7 @@ def analyze_file_import_absencji(fn, parent_model: ImportPlikuAbsencji): nr_wiersza=0, rezultat=f"Błąd: {error_msg}", ) - parent_model.send_notification(error_msg, "error") + p.log(error_msg) raise ValueError(error_msg) from e except Exception as e: # Handle any other unexpected errors @@ -94,13 +94,17 @@ def analyze_file_import_absencji(fn, parent_model: ImportPlikuAbsencji): nr_wiersza=0, rezultat=error_msg, ) - parent_model.send_notification(error_msg, "error") + p.log(error_msg) raise # pandas.read_excel(fn, header=0).replace({numpy.nan: None}) records = data.to_dict("records") total = len(records) - for n_row, row in enumerate(records): + # ``p.track`` aktualizuje pasek postępu (throttlowany) i sprawdza anulowanie + # przed każdym wierszem — zastępuje ręczne ``send_progress``. + for n_row, row in p.track( + list(enumerate(records)), total=total, label="Import absencji" + ): autor = matchuj_autora( imiona=(row.get("IMIE", "") or "").strip(), nazwisko=(row.get("NAZWISKO", "") or "").strip(), @@ -126,5 +130,3 @@ def analyze_file_import_absencji(fn, parent_model: ImportPlikuAbsencji): ile_dni=ile_dni, rezultat=rezultat, ) - - parent_model.send_progress(n_row * 100.0 / total) diff --git a/src/import_polon/core/import_polon.py b/src/import_polon/core/import_polon.py index d95e18836..ccd411bb9 100644 --- a/src/import_polon/core/import_polon.py +++ b/src/import_polon/core/import_polon.py @@ -447,7 +447,7 @@ def _update_autor_orcid(autor, orcid, parent_model): return ops -def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): +def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon, p): try: data = read_excel_or_csv_dataframe_guess_encoding(fn) except ValueError as e: @@ -463,7 +463,7 @@ def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): nr_wiersza=0, rezultat=f"Błąd: {error_msg}", ) - parent_model.send_notification(error_msg, "error") + p.log(error_msg) raise ValueError(error_msg) from e except Exception as e: # Handle any other unexpected errors @@ -478,12 +478,16 @@ def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): nr_wiersza=0, rezultat=error_msg, ) - parent_model.send_notification(error_msg, "error") + p.log(error_msg) raise records = data.to_dict("records") total = len(records) - for n_row, row in enumerate(records): + # ``p.track`` aktualizuje pasek postępu (throttlowany) i sprawdza anulowanie + # przed każdym wierszem — zastępuje ręczne ``send_progress`` z każdej gałęzi. + for n_row, row in p.track( + list(enumerate(records)), total=total, label="Import POLON" + ): # Validate employment - skip if invalid (unless ignored) if not parent_model.ignoruj_miejsce_pracy: zatrudnienie = row.get("ZATRUDNIENIE", "") @@ -502,7 +506,6 @@ def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): zatrudnienie, parent_model.uczelnia ), ) - parent_model.send_progress(n_row * 100.0 / total) continue # Match author @@ -539,7 +542,6 @@ def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): f"modyfikować danych obcej uczelni." ), ) - parent_model.send_progress(n_row * 100.0 / total) continue # Skip unmatched authors if configured @@ -607,5 +609,3 @@ def analyze_file_import_polon(fn, parent_model: ImportPlikuPolon): subdyscyplina_naukowa=subdyscyplina_xlsx, rezultat=rezultat, ) - - parent_model.send_progress(n_row * 100.0 / total) diff --git a/src/import_polon/migrations/0017_liveops.py b/src/import_polon/migrations/0017_liveops.py new file mode 100644 index 000000000..1f2550fc9 --- /dev/null +++ b/src/import_polon/migrations/0017_liveops.py @@ -0,0 +1,149 @@ +# Generated by Django 5.2.16 on 2026-07-16 19:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("import_polon", "0016_importplikupolon_uczelnia"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name="importplikuabsencji", + options={"ordering": ["-created_on"]}, + ), + migrations.AlterModelOptions( + name="importplikupolon", + options={"ordering": ["-created_on"]}, + ), + migrations.RemoveField( + model_name="importplikuabsencji", + name="last_updated_on", + ), + migrations.RemoveField( + model_name="importplikupolon", + name="last_updated_on", + ), + migrations.AddField( + model_name="importplikuabsencji", + name="cancel_requested", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="cancelled", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="current_stage", + field=models.IntegerField(default=-1), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="language", + field=models.CharField(blank=True, default="", max_length=20), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="log", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="log_seq", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="percent", + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="result_context", + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="stage_states", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="importplikuabsencji", + name="status_text", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AddField( + model_name="importplikupolon", + name="cancel_requested", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="importplikupolon", + name="cancelled", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="importplikupolon", + name="current_stage", + field=models.IntegerField(default=-1), + ), + migrations.AddField( + model_name="importplikupolon", + name="language", + field=models.CharField(blank=True, default="", max_length=20), + ), + migrations.AddField( + model_name="importplikupolon", + name="log", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="importplikupolon", + name="log_seq", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="importplikupolon", + name="percent", + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.AddField( + model_name="importplikupolon", + name="result_context", + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name="importplikupolon", + name="stage_states", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="importplikupolon", + name="status_text", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="importplikuabsencji", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="importplikupolon", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/src/import_polon/models.py b/src/import_polon/models.py index e502016f8..bae9fc01c 100644 --- a/src/import_polon/models.py +++ b/src/import_polon/models.py @@ -1,24 +1,89 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.db.models import Count, Q +from liveops.models import LiveOperation from bpp.fields import YearField from bpp.models import Autor, Dyscyplina_Naukowa -from long_running.models import Operation -from long_running.notification_mixins import ASGINotificationMixin - -class ImportPlikuAbsencji(ASGINotificationMixin, Operation): +# Pola stanu operacji liveops zerowane przed (po)ponownym enqueue. Zwierciadło +# ``liveops.views.RestartView.post`` (liveops inline'uje ten reset, nie wystawia +# go jako metody modelu) — trzymamy JEDNĄ kopię, żeby ``ZapiszDoBazyMixin`` nie +# zdryfował. Bez tego ``cancel_requested=True`` po anulowanym runie od razu +# ubiłby nowy przebieg. +_POLA_LIVEOPS_RESET = ( + "finished_on", + "started_on", + "finished_successfully", + "cancelled", + "cancel_requested", + "traceback", + "result_context", + "current_stage", + "stage_states", + "log", + "percent", + "log_seq", +) + + +class _LiveopsResetMixin: + """Wspólny ``reset_liveops_state`` dla importów POLON/absencji.""" + + def reset_liveops_state(self): + """Zeruje pola stanu operacji liveops (jak ``RestartView.post``), tak by + kolejny ``enqueue()`` wystartował z czystym przebiegiem. NIE zapisuje + (caller składa ``update_fields``) i NIE woła ``enqueue``. Zwraca listę + ustawionych pól — do doklejenia w ``save(update_fields=)``.""" + self.finished_on = None + self.started_on = None + self.finished_successfully = False + self.cancelled = False + self.cancel_requested = False + self.traceback = None + self.result_context = None + self.current_stage = -1 + self.stage_states = {} + self.log = [] + self.percent = 0 + self.log_seq = 0 + return list(_POLA_LIVEOPS_RESET) + + +def _uruchom_import(parent, p, analyze): + """Wspólny ``run`` dla obu importów: woła rdzeń z liveops ``Progress`` i + finalizuje wynikiem (``total`` z wierszy-dzieci). + + ``liveops.runner._handle_error`` zapisuje traceback WYŁĄCZNIE do pola + ``traceback`` (bez śladu na konsoli workera i bez rollbara). Owijamy + właściwy przebieg, żeby błąd był WIDOCZNY: surowy traceback na stderr + (konsola celery/run-site) + rollbar (konwencja bg-tasków), po czym + re-raise — liveops i tak zapisze traceback do bazy i pokaże błąd w UI.""" + try: + analyze(parent.plik.path, parent, p) + except Exception: + import sys + import traceback as _traceback + + import rollbar + + _traceback.print_exc() + rollbar.report_exc_info(sys.exc_info()) + raise + p.result({"total": parent.get_details_set().count()}) + + +class ImportPlikuAbsencji(_LiveopsResetMixin, LiveOperation): plik = models.FileField(max_length=255, upload_to="protected/import_polon") zapisz_zmiany_do_bazy = models.BooleanField(default=False) - def on_reset(self): + def on_restart(self): self.wierszimportuplikuabsencji_set.all().delete() - def perform(self): + def run(self, p): from .core.import_absencji import analyze_file_import_absencji - analyze_file_import_absencji(self.plik.path, self) + _uruchom_import(self, p, analyze_file_import_absencji) def get_details_set(self): return self.wierszimportuplikuabsencji_set.all() @@ -40,7 +105,7 @@ class Meta: ordering = ("nr_wiersza",) -class ImportPlikuPolon(ASGINotificationMixin, Operation): +class ImportPlikuPolon(_LiveopsResetMixin, LiveOperation): rok = YearField() plik = models.FileField(max_length=255, upload_to="protected/import_polon") ukryj_niezmatchowanych_autorow = models.BooleanField(default=True) @@ -63,13 +128,13 @@ class ImportPlikuPolon(ASGINotificationMixin, Operation): help_text="Uczelnia, dla której wykonywany jest import (multi-hosted).", ) - def on_reset(self): + def on_restart(self): self.wierszimportuplikupolon_set.all().delete() - def perform(self): + def run(self, p): from import_polon.core import analyze_file_import_polon - analyze_file_import_polon(self.plik.path, self) + _uruchom_import(self, p, analyze_file_import_polon) def get_details_set(self): return WierszImportuPlikuPolon.objects.filter(parent=self) diff --git a/src/import_polon/templates/import_polon/import_pliku_absencji.html b/src/import_polon/templates/import_polon/import_pliku_absencji.html new file mode 100644 index 000000000..c832910ec --- /dev/null +++ b/src/import_polon/templates/import_polon/import_pliku_absencji.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% load static liveops %} + +{% block extratitle %} + Import absencji - {{ object.plik.name }} +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • import absencji
  • +
  • import {{ object.plik.name }}
  • +{% endblock %} + +{% block content %} +

    Import absencji — przetwarzanie

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

    Import absencji zakończony

    +

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

    + {% if not operation.zapisz_zmiany_do_bazy %} +

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

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

    Import z POLON — przetwarzanie

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

    Import z POLON zakończony

    +

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

    + {% if not operation.zapisz_zmiany_do_bazy %} +

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

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

    Import absencji {{ object.plik.name }}

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

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

    + + + {% if object_list.count == 0 %} + + {% endif %} + +{% endblock %} diff --git a/src/import_polon/templates/import_polon/importplikupolon_detail.html b/src/import_polon/templates/import_polon/importplikupolon_detail.html deleted file mode 100644 index 938fe07ba..000000000 --- a/src/import_polon/templates/import_polon/importplikupolon_detail.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %}{% load render_table from django_tables2 %} - -{% block extratitle %} - Import POLON - szczegóły {{ object.plik.name }} -{% endblock %} - -{% block breadcrumbs %} - {{ block.super }} -
  • import Polon
  • -
  • import {{ object.plik.name }}
  • -{% endblock %} - - -{% block content %} -

    Import danych {{ object.plik.name }}

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

    Ostatnio importowane dane: