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/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/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..f933838e1 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: @@ -125,6 +147,12 @@ def create_report(self): # noqa: C901 (pre-existing complexity) total = kombinacje.count() for n, res in enumerate(kombinacje): + # Reaguj na „Anuluj": p.percent() nie sprawdza cancel_requested + # (robi to tylko p.track()), więc bez tego przycisk byłby no-op + # mid-run. OperationCancelled leci z bloku atomic w run() → + # rollback już-zapisanych wierszy (all-or-nothing). + p.check_cancelled() + if self.dziel_na_jednostki_i_wydzialy: autor_id, dyscyplina_id, jednostka_id = res else: @@ -161,8 +189,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/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 @@