From 151b48d15baec0542b7cd8de3a6eea57a72283fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 16 Jul 2026 21:00:29 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(raport=5Fslotow):=20przenie=C5=9B=20mo?= =?UTF-8?q?del=20RaportSlotowUczelnia=20na=20django-liveops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faza 2 (cutover bazy): RaportSlotowUczelnia dziedziczy teraz po liveops.LiveOperation zamiast long_running.Report. - create_report() -> run(self, p); ciało owinięte w transaction.atomic() (liveops.task_run nie owija; long_running owijało — zachowujemy all-or-nothing generacji). Postęp przez p.percent() zamiast send_progress. - on_reset() -> on_restart(); dodano get_success_url() (Ścieżka A: po FINISHED_OK przejście na tabelę -results). Jawne host/result_template_name (kolizja class_to_snake z istniejącym szablonem wyników). - Scoping per-uczelnia bez zmian: filtr jednostka__uczelnia_id + przekazanie uczelnia_id/uczelnia do zbieraj_sloty/autorzy_zerowi. - Nowa migracja 0022_liveops (delta pól stanu liveops, usunięcie last_updated_on, owner related_name="+"). pk pozostaje UUID. - API v1 (druga powierzchnia, pękała razem z modelem): usunięto last_updated_on z serializera; create() enqueue przez transaction.on_commit(inst.enqueue) zamiast martwego perform_generic_long_running_task -> task_perform. - Testy modelu/guardy per-uczelnia: run(MockProgress(...)); nowy test_liveops_uczelnia; test create-enqueue w API. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../serializers/raport_slotow_uczelnia.py | 20 ++--- .../tests/test_raport_slotow_uczelnia.py | 44 ++++++++++ src/raport_slotow/migrations/0022_liveops.py | 79 ++++++++++++++++++ src/raport_slotow/models/uczelnia.py | 39 +++++++-- .../tests/test_per_uczelnia_uczelnia.py | 3 +- .../tests_models/test_liveops_uczelnia.py | 82 +++++++++++++++++++ .../tests/tests_models/test_uczelnia.py | 11 +-- 7 files changed, 254 insertions(+), 24 deletions(-) create mode 100644 src/raport_slotow/migrations/0022_liveops.py create mode 100644 src/raport_slotow/tests/tests_models/test_liveops_uczelnia.py diff --git a/src/api_v1/serializers/raport_slotow_uczelnia.py b/src/api_v1/serializers/raport_slotow_uczelnia.py index 2b14664ed..14129d216 100644 --- a/src/api_v1/serializers/raport_slotow_uczelnia.py +++ b/src/api_v1/serializers/raport_slotow_uczelnia.py @@ -2,14 +2,11 @@ from django.db import transaction from rest_framework import serializers -from long_running.tasks import perform_generic_long_running_task from raport_slotow.models.uczelnia import ( RaportSlotowUczelnia, RaportSlotowUczelniaWiersz, ) -from django.contrib.contenttypes.models import ContentType - # Serializers define the API representation. @@ -22,7 +19,6 @@ class Meta: model = RaportSlotowUczelnia read_only_fields = [ "created_on", - "last_updated_on", "started_on", "finished_on", "finished_successfully", @@ -31,11 +27,10 @@ class Meta: fields = [ "id", # - # Report + # LiveOperation # # "owner", "created_on", - "last_updated_on", "started_on", "finished_on", "finished_successfully", @@ -65,13 +60,18 @@ def validate(self, attrs): @transaction.atomic def create(self, validated_data): + # LOW-4: ścieżka API (druga obok widoku) NIE ustawia ``uczelnia`` — + # raport z API jest nie-zawężony (wszystkie uczelnie). Owner-scope i + # tak izoluje odczyt. To zachowanie OBECNE (przed migracją), świadomie + # zachowane — patrz PR / plan §8.1. validated_data["owner"] = self.context["request"].user inst = super().create(validated_data) - ct = ContentType.objects.get_for_model(inst) - transaction.on_commit( - lambda: perform_generic_long_running_task(ct.app_label, ct.model, inst.pk) - ) + # create() jest @transaction.atomic — enqueue MUSI iść przez + # on_commit, inaczej worker (celery) wystartowałby zanim wiersz się + # zacommituje. liveops.enqueue() nie ma retry-loopa, więc bez tego + # task nie znalazłby rekordu. + transaction.on_commit(inst.enqueue) return inst diff --git a/src/api_v1/tests/test_raport_slotow_uczelnia.py b/src/api_v1/tests/test_raport_slotow_uczelnia.py index f8d37c653..baca8040d 100644 --- a/src/api_v1/tests/test_raport_slotow_uczelnia.py +++ b/src/api_v1/tests/test_raport_slotow_uczelnia.py @@ -77,6 +77,50 @@ def test_raport_slotow_uczelnia_other_user_report_not_visible(): assert response.json()["count"] == 0 +@pytest.mark.django_db +def test_raport_slotow_uczelnia_create_enqueue(django_capture_on_commit_callbacks): + """POST create: serializer zapisuje raport (owner z requestu) i kolejkuje + przez ``transaction.on_commit(inst.enqueue)``. Pod runnerem ``eager`` + wykonanie callbacku on_commit odpala run() synchronicznie do stanu + terminalnego — dowód, że ścieżka create NIE woła już martwego + ``task_perform`` (regresja §4.5) i nie pęka na ``last_updated_on``. + """ + user, pw = _make_api_user("user_raport_create") + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=_basic_auth(user.username, pw)) + + url = reverse("api_v1:raport_slotow_uczelnia-list") + with django_capture_on_commit_callbacks(execute=True) as callbacks: + response = client.post( + url, + { + "od_roku": 2020, + "do_roku": 2020, + "akcja": RaportSlotowUczelnia.Akcje.SLOTY, + "slot": "1.0000", + "minimalny_pk": "0.00", + "dziel_na_jednostki_i_wydzialy": True, + "pokazuj_zerowych": False, + }, + format="json", + ) + + assert response.status_code == 201, response.content + # enqueue został zaplanowany przez on_commit (run() dokłada własne + # on_commit-pushe, więc callbacków jest ≥1 — kluczowe: enqueue jest wśród). + assert any( + getattr(cb, "__func__", None) is RaportSlotowUczelnia.enqueue + for cb in callbacks + ) + + report = RaportSlotowUczelnia.objects.get(owner=user) + # Pod eager runnerem run() dobiegł do stanu terminalnego (bez danych w + # cache generuje 0 wierszy, ale kończy sukcesem) — czyli enqueue zadziałał. + assert report.finished_on is not None + assert report.finished_successfully is True + + def _basic_auth(username, password): import base64 diff --git a/src/raport_slotow/migrations/0022_liveops.py b/src/raport_slotow/migrations/0022_liveops.py new file mode 100644 index 000000000..840db8d39 --- /dev/null +++ b/src/raport_slotow/migrations/0022_liveops.py @@ -0,0 +1,79 @@ +# 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 = [ + ('raport_slotow', '0021_merge_20260604_1952'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='raportslotowuczelnia', + options={'ordering': ['-created_on']}, + ), + migrations.RemoveField( + model_name='raportslotowuczelnia', + name='last_updated_on', + ), + migrations.AddField( + model_name='raportslotowuczelnia', + name='cancel_requested', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='raportslotowuczelnia', + name='cancelled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='raportslotowuczelnia', + name='current_stage', + field=models.IntegerField(default=-1), + ), + migrations.AddField( + model_name='raportslotowuczelnia', + name='language', + field=models.CharField(blank=True, default='', max_length=20), + ), + migrations.AddField( + model_name='raportslotowuczelnia', + name='log', + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name='raportslotowuczelnia', + name='log_seq', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='raportslotowuczelnia', + name='percent', + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.AddField( + model_name='raportslotowuczelnia', + name='result_context', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='raportslotowuczelnia', + name='stage_states', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='raportslotowuczelnia', + name='status_text', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AlterField( + model_name='raportslotowuczelnia', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/raport_slotow/models/uczelnia.py b/src/raport_slotow/models/uczelnia.py index 0030207e9..6bbb41ea0 100644 --- a/src/raport_slotow/models/uczelnia.py +++ b/src/raport_slotow/models/uczelnia.py @@ -16,19 +16,25 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator -from django.db import models +from django.db import models, transaction +from django.urls import reverse +from liveops.models import LiveOperation from bpp.core import zbieraj_sloty from bpp.fields import YearField from bpp.models import Autor, Cache_Punktacja_Autora_Query from bpp.models.uczelnia import do_roku_default from bpp.util import year_last_month -from long_running.models import Report -from long_running.notification_mixins import ASGINotificationMixin from raport_slotow.core import autorzy_zerowi -class RaportSlotowUczelnia(ASGINotificationMixin, Report): +class RaportSlotowUczelnia(LiveOperation): + # Kolizja nazw szablonów: class_to_snake("RaportSlotowUczelnia") = + # "raport_slotow_uczelnia", więc auto host-template pokryłby się z + # istniejącym szablonem tabeli wyników (raport_slotow_uczelnia.html). + # Dlatego host/result nazywamy JAWNIE. + host_template_name = "raport_slotow/raport_slotow_uczelnia_live.html" + result_template_name = "raport_slotow/raport_slotow_uczelnia_result.html" od_roku = YearField(default=year_last_month) # default = funkcja modułowa (stabilnie serializowalna) — patrz docstring # bpp.models.uczelnia.do_roku_default. Wcześniej bound-method managera @@ -70,9 +76,18 @@ class Akcje(models.TextChoices): czyli bez punktacji. """, ) - def on_reset(self): + def on_restart(self): self.raportslotowuczelniawiersz_set.all().delete() + def get_success_url(self): + # Ścieżka A: po zakończeniu (FINISHED_OK) liveops.js przenosi usera + # prosto na tabelę wyników (osobna strona ``-results``). Wynik Reportu + # to wiersze w bazie, nie panel inline — result-fragment jest tylko + # fallbackiem no-JS. + return reverse( + "raport_slotow:raportslotowuczelnia-results", kwargs={"pk": self.pk} + ) + def clean(self): if self.od_roku > self.do_roku: raise ValidationError( @@ -105,7 +120,14 @@ def clean(self): } ) - def create_report(self): # noqa: C901 (pre-existing complexity) + def run(self, p): # noqa: C901 (pre-existing complexity) + # Generacja raportu jest all-or-nothing: liveops.task_run NIE owija + # run() w transakcję (long_running owijało), więc robimy to sami — + # inaczej błąd w połowie zostawiłby częściowe wiersze. + with transaction.atomic(): + self._generuj(p) + + def _generuj(self, p): # noqa: C901 (pre-existing complexity) # lista wszystkich autorow z punktacja z okresu od-do roku lst = "autor_id", "dyscyplina_id" if self.dziel_na_jednostki_i_wydzialy: @@ -161,8 +183,9 @@ def create_report(self): # noqa: C901 (pre-existing complexity) avg=avg, ) - if not n % 10: - self.send_progress(n * 100.0 / total) + if total: + # Progress.percent ma wbudowany throttling — nie trzeba %10. + p.percent(int(n * 100 / total)) if self.pokazuj_zerowych: zerowi = autorzy_zerowi( diff --git a/src/raport_slotow/tests/test_per_uczelnia_uczelnia.py b/src/raport_slotow/tests/test_per_uczelnia_uczelnia.py index 649917464..bf66fd6c1 100644 --- a/src/raport_slotow/tests/test_per_uczelnia_uczelnia.py +++ b/src/raport_slotow/tests/test_per_uczelnia_uczelnia.py @@ -1,4 +1,5 @@ import pytest +from liveops.testing import MockProgress from model_bakery import baker @@ -17,7 +18,7 @@ def test_create_report_zawezony_po_uczelni( uczelnia=jednostka.uczelnia, akcja=RaportSlotowUczelnia.Akcje.WSZYSTKO, ) - raport.create_report() + raport.run(MockProgress(raport)) jednostki_w_raporcie = set( raport.raportslotowuczelniawiersz_set.values_list( diff --git a/src/raport_slotow/tests/tests_models/test_liveops_uczelnia.py b/src/raport_slotow/tests/tests_models/test_liveops_uczelnia.py new file mode 100644 index 000000000..2bd1f32cd --- /dev/null +++ b/src/raport_slotow/tests/tests_models/test_liveops_uczelnia.py @@ -0,0 +1,82 @@ +"""Faza 2 (cutover): RaportSlotowUczelnia jako liveops.LiveOperation. + +Sprawdza: typ bazy, nowe pola stanu, on_restart kasuje wiersze, run(p) +generuje wiersze i RESPEKTUJE scoping per-uczelnia (guard §8.1), oraz że +run() jest all-or-nothing (transaction.atomic). +""" + +import pytest +from liveops.models import LiveOperation +from liveops.testing import MockProgress +from model_bakery import baker + +from raport_slotow.models.uczelnia import ( + RaportSlotowUczelnia, + RaportSlotowUczelniaWiersz, +) + + +def test_jest_liveoperation(): + assert issubclass(RaportSlotowUczelnia, LiveOperation) + + +@pytest.mark.django_db +def test_ma_pola_stanu_liveops(): + r = baker.make(RaportSlotowUczelnia) + for pole in ( + "cancel_requested", + "cancelled", + "result_context", + "current_stage", + "stage_states", + "percent", + "log", + ): + assert hasattr(r, pole) + + +@pytest.mark.django_db +def test_on_restart_kasuje_wiersze(raport_slotow_uczelnia): + baker.make(RaportSlotowUczelniaWiersz, parent=raport_slotow_uczelnia) + assert raport_slotow_uczelnia.raportslotowuczelniawiersz_set.count() == 1 + + raport_slotow_uczelnia.on_restart() + + assert raport_slotow_uczelnia.raportslotowuczelniawiersz_set.count() == 0 + + +@pytest.mark.django_db +def test_run_generuje_wiersze(rekord_slotu, rok, raport_slotow_uczelnia): + raport_slotow_uczelnia.od_roku = rok + raport_slotow_uczelnia.do_roku = rok + raport_slotow_uczelnia.save() + + raport_slotow_uczelnia.run(MockProgress(raport_slotow_uczelnia)) + + assert RaportSlotowUczelniaWiersz.objects.count() == 1 + + +@pytest.mark.django_db +def test_run_respektuje_scoping_uczelni( + zwarte_dwie_uczelnie, jednostka, druga_uczelnia, rok +): + """run() z ustawioną ``uczelnia`` generuje wiersze TYLKO dla jej + jednostek (guard per-uczelnia §8.1 na poziomie run()).""" + zwarte_dwie_uczelnie.przelicz_punkty_dyscyplin() + + raport = baker.make( + RaportSlotowUczelnia, + od_roku=rok, + do_roku=rok, + uczelnia=jednostka.uczelnia, + akcja=RaportSlotowUczelnia.Akcje.WSZYSTKO, + ) + raport.run(MockProgress(raport)) + + uczelnie_w_raporcie = set( + raport.raportslotowuczelniawiersz_set.values_list( + "jednostka__uczelnia_id", flat=True + ) + ) + assert uczelnie_w_raporcie <= {jednostka.uczelnia_id} + assert druga_uczelnia.pk not in uczelnie_w_raporcie diff --git a/src/raport_slotow/tests/tests_models/test_uczelnia.py b/src/raport_slotow/tests/tests_models/test_uczelnia.py index eba725782..415ee5d7f 100644 --- a/src/raport_slotow/tests/tests_models/test_uczelnia.py +++ b/src/raport_slotow/tests/tests_models/test_uczelnia.py @@ -1,5 +1,6 @@ import pytest from django.core.exceptions import ValidationError +from liveops.testing import MockProgress from bpp.models import Autor_Dyscyplina, Autor_Jednostka from raport_slotow.models.uczelnia import ( @@ -24,7 +25,7 @@ def test_RaportSlotowUczelnia_create_report(rekord_slotu, rok, raport_slotow_ucz raport_slotow_uczelnia.save() assert RaportSlotowUczelniaWiersz.objects.count() == 0 - raport_slotow_uczelnia.create_report() + raport_slotow_uczelnia.run(MockProgress(raport_slotow_uczelnia)) assert RaportSlotowUczelniaWiersz.objects.count() == 1 @@ -38,7 +39,7 @@ def test_RaportSlotowUczelnia_create_report_wszystkie( raport_slotow_uczelnia.save() assert RaportSlotowUczelniaWiersz.objects.count() == 0 - raport_slotow_uczelnia.create_report() + raport_slotow_uczelnia.run(MockProgress(raport_slotow_uczelnia)) assert RaportSlotowUczelniaWiersz.objects.count() == 1 @@ -79,7 +80,7 @@ def test_RaportSlotowUczelnia_zerowi_autorzy( Autor_Jednostka.objects.create(autor=autor_jan_nowak, jednostka=jednostka) assert RaportSlotowUczelniaWiersz.objects.count() == 0 - raport_slotow_uczelnia.create_report() + raport_slotow_uczelnia.run(MockProgress(raport_slotow_uczelnia)) assert RaportSlotowUczelniaWiersz.objects.count() == expected_rows @@ -113,7 +114,7 @@ def test_RaportSlotowUczelnia_autor_niezerowy_jako_zerowy( ) assert RaportSlotowUczelniaWiersz.objects.count() == 0 - raport_slotow_uczelnia.create_report() + raport_slotow_uczelnia.run(MockProgress(raport_slotow_uczelnia)) assert RaportSlotowUczelniaWiersz.objects.count() == 2 @@ -142,6 +143,6 @@ def test_RaportSlotowUczelnia_autor_zerowy_w_jednym_roku_niezerowy_w_innym( ) assert RaportSlotowUczelniaWiersz.objects.count() == 0 - raport_slotow_uczelnia.create_report() + raport_slotow_uczelnia.run(MockProgress(raport_slotow_uczelnia)) assert RaportSlotowUczelniaWiersz.objects.count() == 1 From 4b17b68ff00abbaef5f0d97a731faf937a821435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 16 Jul 2026 21:13:07 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(raport=5Fslotow):=20przepnij=20widoki/?= =?UTF-8?q?URL/szablony=20raportu=20slot=C3=B3w=20na=20liveops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faza 3+4 (cutover UI + sprzątanie): - UtworzRaportSlotowUczelnia na CreateLiveOperationView; form_valid nadal ustawia uczelnia=uczelnia_dla_odczytu(request) PRZED save() (scoping per-uczelnia §8.1), owner + enqueue() bezpośrednio, redirect na live. - ListaRaportSlotowUczelnia: zwykły owner-scoped ListView (bez auto-delete starych operacji — housekeeping to nie zadanie list-view). - WygenerujPonownieRaportSlotowUczelnia: RestartView POST-only + owner-scope + bramka BaseRaportAuthMixin. Guard §8.4: reset/re-enqueue TYLKO gdy stan terminalny (chroni przed wyścigiem regen-w-trakcie-runu z workerem). - SzczegolyRaportSlotowUczelniaListaRekordow: odcięte od LongRunningResultsView; własne parent_object (owner-scope + superuser-exempt) i get_queryset; zachowany django_tables2 + eksport XLSX; kontekst nadal niesie object. - Usunięto widoki/URL-e router i details (rolę przejął centralny liveops:live); usunięto martwe szablony detail/wiersz_list. - Nowe szablony: host (raport_slotow_uczelnia_live.html) z wrapperem hx-headers X-CSRFToken (CSRF_COOKIE_HTTPONLY) + minimalny result-fragment. Link listy → get_absolute_url; regen w tabeli wyników → formularz POST. - _LiveopsNoPermissionCompatMixin: godzi braces AccessMixin (wymaga request) z liveops BaseLiveOperationMixin (Django-style bez request) — anonim dostaje 302 na login zamiast 500. - Testy widoków przepięte na nowy kontrakt (regen POST, lista bez kasowania, strona live, guard nie-skończonego, anon 302 bez efektu). Newsfragment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../raport-slotow-liveops.feature.rst | 4 + .../raport_slotow/raport_slotow_uczelnia.html | 13 +- .../raport_slotow_uczelnia_live.html | 36 ++++++ .../raport_slotow_uczelnia_result.html | 12 ++ .../raportslotowuczelnia_detail.html | 27 ---- .../raportslotowuczelnia_list.html | 2 +- .../raportslotowuczelniawiersz_list.html | 33 ----- .../tests/test_views/test_uczelnia.py | 89 ++++++++++--- src/raport_slotow/urls.py | 12 -- src/raport_slotow/views/uczelnia.py | 121 +++++++++++++----- 10 files changed, 226 insertions(+), 123 deletions(-) create mode 100644 src/bpp/newsfragments/raport-slotow-liveops.feature.rst create mode 100644 src/raport_slotow/templates/raport_slotow/raport_slotow_uczelnia_live.html create mode 100644 src/raport_slotow/templates/raport_slotow/raport_slotow_uczelnia_result.html delete mode 100644 src/raport_slotow/templates/raport_slotow/raportslotowuczelnia_detail.html delete mode 100644 src/raport_slotow/templates/raport_slotow/raportslotowuczelniawiersz_list.html diff --git a/src/bpp/newsfragments/raport-slotow-liveops.feature.rst b/src/bpp/newsfragments/raport-slotow-liveops.feature.rst new file mode 100644 index 000000000..4fb295137 --- /dev/null +++ b/src/bpp/newsfragments/raport-slotow-liveops.feature.rst @@ -0,0 +1,4 @@ +Raport slotów uczelni (``RaportSlotowUczelnia``) działa teraz na +django-liveops: pasek postępu na żywo przez WebSocket/HTMX, możliwość +anulowania, a po zakończeniu automatyczne przejście do tabeli wyników. +Zachowane zawężenie per-uczelnia oraz owner-scoping listy, wyników i API v1. diff --git a/src/raport_slotow/templates/raport_slotow/raport_slotow_uczelnia.html b/src/raport_slotow/templates/raport_slotow/raport_slotow_uczelnia.html index c49d10a7f..5a9300427 100644 --- a/src/raport_slotow/templates/raport_slotow/raport_slotow_uczelnia.html +++ b/src/raport_slotow/templates/raport_slotow/raport_slotow_uczelnia.html @@ -24,9 +24,16 @@

Raport slotów za {{ object.od_roku }} momencie {{ object.started_on }}, czyli {{ object.started_on|naturaltime }}.
Pobierz w formacie XLSX | - - Wygeneruj - ponownie + {# Regen jest akcją mutującą (POST-only liveops RestartView) — formularz #} + {# z tokenem CSRF zamiast linku GET. #} +
+ {% csrf_token %} + +