Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/bpp/newsfragments/import-polon-index-absencji.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Strona „import absencji" pokazuje teraz listę importów absencji zamiast —
jak dotąd — listy importów POLON.
5 changes: 5 additions & 0 deletions src/bpp/newsfragments/import-polon-liveops.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 8 additions & 6 deletions src/import_polon/core/import_absencji.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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(),
Expand All @@ -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)
16 changes: 8 additions & 8 deletions src/import_polon/core/import_polon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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", "")
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
149 changes: 149 additions & 0 deletions src/import_polon/migrations/0017_liveops.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
87 changes: 76 additions & 11 deletions src/import_polon/models.py
Original file line number Diff line number Diff line change
@@ -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()
Expand 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)
Expand All @@ -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)
Expand Down
Loading
Loading