Skip to content
Merged
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
20 changes: 10 additions & 10 deletions src/api_v1/serializers/raport_slotow_uczelnia.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand All @@ -22,7 +19,6 @@ class Meta:
model = RaportSlotowUczelnia
read_only_fields = [
"created_on",
"last_updated_on",
"started_on",
"finished_on",
"finished_successfully",
Expand All @@ -31,11 +27,10 @@ class Meta:
fields = [
"id",
#
# Report
# LiveOperation
#
# "owner",
"created_on",
"last_updated_on",
"started_on",
"finished_on",
"finished_successfully",
Expand Down Expand Up @@ -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

Expand Down
44 changes: 44 additions & 0 deletions src/api_v1/tests/test_raport_slotow_uczelnia.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/bpp/newsfragments/raport-slotow-liveops.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 79 additions & 0 deletions src/raport_slotow/migrations/0022_liveops.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
45 changes: 37 additions & 8 deletions src/raport_slotow/models/uczelnia.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ <h1>Raport slotów za {{ object.od_roku }}
momencie {{ object.started_on }}, czyli {{ object.started_on|naturaltime }}.
<br/><a href="?_export=xlsx"><i class="fi-page-export"></i> Pobierz w formacie XLSX</a> |

<a title="Generuje raport od zera na podstawie aktualnych danych" href="../regen">
<i class="fi-refresh"></i> Wygeneruj
ponownie</a>
{# Regen jest akcją mutującą (POST-only liveops RestartView) — formularz #}
{# z tokenem CSRF zamiast linku GET. #}
<form method="post" action="../regen/" style="display:inline">
{% csrf_token %}
<button type="submit" class="link-button"
title="Generuje raport od zera na podstawie aktualnych danych"
style="background:none;border:none;padding:0;color:inherit;cursor:pointer">
<i class="fi-refresh"></i> Wygeneruj ponownie
</button>
</form>
</div>

<style>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% load static liveops %}

{% block extratitle %}
Raport slotów - uczelnia - generowanie
{% endblock %}

{% block breadcrumbs %}
{{ block.super }}
<li>ewaluacja</li>
<li><a href="{% url "raport_slotow:lista-raport-slotow-uczelnia" %}">Raport slotów - uczelnia</a></li>
<li class="current">generowanie raportu</li>
{% endblock %}

{% block content %}
<h1>Raport slotów - uczelnia — generowanie</h1>

{# Region live-operacji: kontener z data-liveop-channel/token + strefy #}
{# op-status/op-progress/op-log/op-result (OOB-swap z workera). Po #}
{# zakończeniu liveops.js przenosi usera na tabelę wyników (Ścieżka A, #}
{# get_success_url). Panel wyniku w #op-result to fallback no-JS. #}
{# 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 #}
{# → POST bez tokenu = 403. Wrapper nie jest podmieniany przy OOB-swapie #}
{# regionów op-*, więc token dziedziczą też przyciski dorenderowane po WS. #}
<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,12 @@
{# Fragment wyniku (liveops result_template_name). Renderowany po zakończeniu #}
{# generacji jako OOB-swap #op-result oraz przy wejściu na zakończoną #}
{# operację. Dla Reportu realny „wynik" to osobna tabela (-results): #}
{# get_success_url() przenosi tam usera z JS. Ten panel to fallback no-JS. #}
<div class="panel callout success">
<h3><span class="fi-check"></span> Raport gotowy</h3>
<p>Raport slotów został wygenerowany. Przechodzę do wyników…</p>
<a href="{% url 'raport_slotow:raportslotowuczelnia-results' operation.pk %}"
class="button primary">
<span class="fi-list"></span> Zobacz wyniki
</a>
</div>

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ <h1>Ostatnio wygenerowane raporty:</h1>
<ul>
{% for object in object_list %}
<li>
<a href="{% url "raport_slotow:raportslotowuczelnia-router" object.pk %}">
<a href="{{ object.get_absolute_url }}">
od {{ object.od_roku }} do {{ object.do_roku }}, slot = {{ object.slot }}, min pk = {{ object.minimalny_pk }}, zamówiono {{ object.created_on }}, ukończono {{ object.finished_on }}
</a>
</li>
Expand Down
Loading