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 pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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`` = ``<app_label>.<model_name>``) i mieszka w samym pakiecie
liveops, więc konwersja kolejnych importów nie wymaga żadnej warstwy
pośredniej po stronie BPP.
19 changes: 13 additions & 6 deletions src/django_bpp/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
25 changes: 14 additions & 11 deletions src/import_list_ministerialnych/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
27 changes: 21 additions & 6 deletions src/import_list_ministerialnych/models.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% load static liveops %}

{% block extratitle %}
Import list ministerialnych - {{ object.plik.name }}
{% endblock %}

{% block breadcrumbs %}
{{ block.super }}
<li><a href="{% url "import_list_ministerialnych:index" %}">import list ministerialnych</a></li>
<li class="current">import {{ object.plik.name }}</li>
{% endblock %}

{% block content %}
<h1>Import danych {{ object.plik.name }}</h1>

{# 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. #}
<div hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% live_operation object %}
</div>

{# Kolejność ładowania jest istotna: htmx → klient channels_broadcast → #}
{# liveops.js (patchuje channelsBroadcast.addMessage i inicjuje socket). #}
<script src="{% static 'liveops/vendor/htmx.min.js' %}"></script>
<script src="{% static 'channels_broadcast/js/notifications.js' %}"></script>
<script src="{% static 'liveops/liveops.js' %}"></script>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -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). #}
<div class="panel callout success">
<h3><span class="fi-check"></span> Import zakończony</h3>
<p>
Przetworzono wierszy: <strong>{{ total }}</strong>{% if duplicates %},
w tym wykryto duplikatów: <strong>{{ duplicates }}</strong>{% endif %}.
</p>
<a href="{% url "import_list_ministerialnych:importlistministerialnych-results" operation.pk %}"
class="button">
<span class="fi-list"></span> Zobacz pełne wyniki importu
</a>
</div>

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ <h1>Ostatnio importowane dane:
<ul>
{% for object in object_list %}
<li>
<a href="{% url "import_list_ministerialnych:importlistministerialnych-router" object.pk %}">plik {{ object.plik.name }}</a>:
<a href="{{ object.get_absolute_url }}">plik {{ object.plik.name }}</a>:
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 %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ <h2>Zmiany nie zostały wprowadzone do bazy danych.</h2> Import został uruchomi
zaznaczono opcję zapisywania zmian do bazy.
</div>
{% endif %}
{% include "long_running/operation_details.html" %}

<a id="filtry"></a>
{% if object.finished_successfully %}
Expand Down
Loading
Loading