Plan: migracja importera POLON z long_running na django-liveops (v3)¶
Status: PLAN (do wykonania). Wersja 3 — poprawiona po dwóch rundach adversarialnego review (defekty zweryfikowane w kodzie tego worktree; patrz „Zmiany względem v2" i „Zmiany względem v1" na końcu). Ten dokument NIE zmienia kodu.
Streszczenie¶
Importer POLON (ImportPlikuPolon) oraz importer absencji
(ImportPlikuAbsencji) nadal dziedziczą po long_running.models.Operation
+ ASGINotificationMixin (postęp przez channels_broadcast,
?extraChannels=[pk]). Migrujemy je na liveops.LiveOperation (kanały
liveop.<pk>, podpisany subscription_token, snapshot-on-connect,
throttlowany progress, runner celery). Zadanie jest niepilne: świeżo scalona
łatka (long_running/authorizers.py + CHANNELS_BROADCAST_SUBSCRIPTION_AUTHORIZER)
przywróciła live-progress na STAREJ ścieżce — to modernizacja spójnościowa, nie
naprawa awarii.
Kontekst infrastrukturalny — CO JUŻ JEST (nie ruszać)¶
Infrastruktura liveops jest już wpięta na dev (i w tym worktree). Migracja
POLON to wyłącznie zmiany w aplikacji import_polon — bez plumbingu
ASGI/URL/settings:
pyproject.toml:160—django-liveops[celery]>=0.4.0,<0.5(zainstalowane).src/django_bpp/settings/base.py:455—"liveops"wINSTALLED_APPS.src/django_bpp/settings/base.py:1049—LIVEOPS = {BASE_TEMPLATE, RUNNER: "celery", THROTTLE_HZ: 10}. BrakREQUIRED_GROUP(świadomie — bramkuje globalnie WSZYSTKIE liveopsy, w tym deduplikator).src/django_bpp/settings/test.py:73—LIVEOPS = {**LIVEOPS, "RUNNER": "eager"}— w testach dispatch jest SYNCHRONICZNY (bez Redis/workera).src/django_bpp/asgi.py:13,23—liveops.routing(LiveOperationConsumer= nadzbiórNotificationsConsumer+ snapshot).src/django_bpp/urls.py:286—path("live/", include("liveops.urls"))(centralnyliveops:live/:cancel/:restartpoop_type).src/django_bpp/settings/base.py:1596—CSRF_COOKIE_HTTPONLY = True→liveops.jsNIE odczyta tokenu z ciasteczka; host-template MUSI wstrzyknąćX-CSRFToken(patrz Faza 2, wzórimport_pracownikow.html:27).src/django_bpp/settings/base.py:1077—CHANNELS_BROADCAST_SUBSCRIPTION_AUTHORIZER = "long_running.authorizers.authorize_operation_channel"— STARA ścieżka. Zostaje (inne importery nadal używająextraChannels); POLON „wypadnie" z autoryzatora sam, przestając być podklasąOperation.
Referencje (co JEST i czego NIE MA na tym branchu)¶
Zweryfikowane w kodzie tego worktree — rzeczywiście zmigrowane są TRZY
importery: import_pracownikow, import_punktacji_zrodel,
deduplikator_zrodel.
- KANONICZNY wzór =
import_pracownikow(najbliższy POLON-owi: dry-run→ zapis, dzieci, uczelnia, grupa „wprowadzanie danych"): - migracja:
src/import_pracownikow/migrations/0010_liveops.py— zawiera DOKŁADNIE naszą deltę:AlterModelOptions ordering=["-created_on"],RemoveField last_updated_on,AddField×10 (cancel_requested, cancelled, current_stage, language, log, log_seq, percent, result_context, stage_states, status_text),AlterField owner related_name="+". - model:
src/import_pracownikow/models.py:72,251-345—run(self,p)z obudową rollbar+re-raise,on_restart(),reset_liveops_state(). - host template:
src/import_pracownikow/templates/import_pracownikow/ import_pracownikow.html(z wrapperem CSRF, kolejnością skryptów). - widoki:
src/import_pracownikow/views.py:118-174(ListView + Create),1386-1455(_PkOwnerRestartMixin(GroupRequiredMixin, RestartView)). - testy bramki:
src/import_pracownikow/tests/test_auth_gate.py. import_list_ministerialnych— NIE scalony na dev. Zweryfikowane:src/import_list_ministerialnych/models.py:10to nadalASGINotificationMixin, Operation; migracje kończą się na0008_*;feat/liveops-import-list-ministerialnychNIE jest przodkiemdev(git merge-base --is-ancestor … → NOT-ancestor). Jego diff (origin/feat/liveops-import-list-ministerialnych) można oglądać jako ilustrację, ale NIE traktować jako „w drzewie". (Patrz też „Ryzyka" — konflikt baseline przy scalaniu tych dwóch gałęzi.)
Stan obecny (mapa legacy POLON)¶
Modele — src/import_polon/models.py¶
ImportPlikuPolon(ASGINotificationMixin, Operation)— pola:rok,plik,ukryj_niezmatchowanych_autorow,zapisz_zmiany_do_bazy,ignoruj_miejsce_pracy,uczelnia(FKbpp.Uczelnia).perform()→analyze_file_import_polon(self.plik.path, self);on_reset()kasuje dzieci;get_details_set();autorzy_niezmatchowani()(multi-hosted).ImportPlikuAbsencji(ASGINotificationMixin, Operation)—plik,zapisz_zmiany_do_bazy;perform()→analyze_file_import_absencji;on_reset().- Dzieci:
WierszImportuPlikuPolon,WierszImportuPlikuAbsencji(FKparent). ImportPolonOverride— słownik.
Operation (src/long_running/models.py:17) daje: id (UUID pk), owner
(FK, bez related_name), created_on, last_updated_on (auto_now),
started_on, finished_on, finished_successfully, traceback,
get_state, mark_*, mark_reset, task_perform, get_url,
get_absolute_url→get_url("router"). Brak cancel_requested.
Ścieżka postępu (legacy)¶
ASGINotificationMixin: send_progress(pct) (3× w core/import_polon.py:
505,542,611; 1× w core/import_absencji.py:130), send_notification (2× w
każdym rdzeniu), send_processing_finished. Widoki subskrybują
extraChannels=[pk]. Autoryzacja: authorize_operation_channel.
Widoki — views/plik_polon.py + plik_absencji.py¶
PokazImporty(BaseImportPlikuPolonMixin, LongRunningOperationsView)— zawężenie multi-hosted (Q(uczelnia==current)|Q(uczelnia__isnull=True)), per-uczelnia trimmax_previous_ops, order_by-last_updated_on.UtworzImportPlikuPolon(…, CreateLongRunningOperationView)—form_validustawiauczelnia = Uczelnia.get_for_request(request).ImportPolonRouterView/…DetailsView/RestartImportView.ImportPolonResultsView(…, LongRunningResultsView)— filtry + kontekstunmatched_autor_dyscyplina.ZapiszDoBazyMixin(RestrictToOwnerMixin, LongRunningTaskCallerMixin, DetailView)(plik_polon.py:21-48) — owner-scope zRestrictToOwnerMixin(import zlong_running.views); POST (@transaction.atomic+select_for_update) → guard → flip flagi →self.object.mark_reset()(linia 46) →self.task_on_commit(pk)(linia 47) →HttpResponseRedirect("..")(linia 48).views/__init__.py— star-importuje oba moduły (from .plik_absencji import *from .plik_polon import *).
URL-e — src/import_polon/urls.py¶
index, utworz-import, importplikupolon-router|details|restart|
zapisz-do-bazy|results; lustrzane importplikuabsencji-*; index-absencji
(linia 35 — mapuje na views.PokazImporty, czyli listę POLON, nie
absencji), utworz-import-absencji.
Szablony¶
importplikupolon_detail.html,importplikuabsencji_detail.html— includelong_running/operation_details.html. Do usunięcia.importplikupolon_list.html— link…importplikupolon-router+ label{{ object.uczelnia }}.wierszimportuplikupolon_list.html/…absencji_list.html— strona wyników; includeoperation_details.html+ przycisk „zapisz do bazy".potwierdz_zapis_do_bazy.html— formaction="."(POST OK) + link Anulujhref=".."(→ router URL, do naprawy w Fazie 2).
Testy¶
test_multi_uczelnia_scoping.py— Finding 1-4; woła rdzeń (2×) iPokazImporty().get_queryset().test_zapisz_do_bazy.py— 11 testów (grep -c '^def test'= 11); liczą callbackion_commitpotask_on_commit(_reimport_scheduled). Wymaga przepisania — patrz Faza 2.- Call-site’y rdzenia bez
p:test_import_polon_core.py(6×: linie 33,45,112,160,206,241),test_import_polon_override.py(4×),test_import_polon_ignoruj.py(3×),test_import_absencji_core.py(2×),test_multi_uczelnia_scoping.py(2×).
Docelowy stan (liveops)¶
API liveops (zweryfikowane, wersja 0.4)¶
LiveOperation (liveops/models.py): pk UUID; owner (related_name="+");
created_on/started_on/finished_on; finished_successfully,
cancel_requested, cancelled, traceback, result_context, language,
status_text, percent, log, log_seq, current_stage, stage_states;
klasowe stages, override host_template_name/result_template_name.
Kontrakt: run(self,p), on_restart(). Kanał liveop.<pk>, op_type_key()
= <app_label>.<model_name>. get_absolute_url() → liveops:live.
enqueue() przy RUNNER="celery" → _celery_task.delay(...) bez
transaction.on_commit.
Progress (liveops/progress.py): status, percent (throttle),
track(iterable, total, label) (throttle + check_cancelled per item), log,
stage(name), result(ctx, **extra), error(msg). check_cancelled()
(linia 154) woła refresh_from_db(fields=["cancel_requested"]) — istotne dla
Fazy 1 (legacy Operation nie ma tego pola). MockProgress
(liveops/testing.py) — nagrywa, finalizuje, bez throttle, check_cancelled
no-op.
Runner (liveops/runner.py): task_run ustawia started_on, woła run(p),
auto-finalizuje, łapie OperationCancelled/wyjątki (zapis traceback).
Widoki (liveops/views.py): CreateLiveOperationView, LiveOperationListView,
generyczne LiveOperationView/CancelView/RestartView (owner-scope, bramka
REQUIRED_GROUP = no-op gdy nieustawione). Consumer robi send_snapshot()
na connect.
Migracja modelu¶
Oba modele przechodzą z Operation na LiveOperation. NIE modyfikujemy
migracji 0001-0016 — nowa 0017_*, POWIELAJĄCA na dwa modele deltę z
import_pracownikow/migrations/0010_liveops.py:
AlterModelOptions:ordering["-last_updated_on"]→["-created_on"].RemoveField last_updated_on(jedyny konsument to sortowanie listy —PokazImportyprzechodzi na-created_on; zweryfikowano grepem: żaden kod nie czytauser.importplikupolon_set/importplikuabsencji_setani.last_updated_onPOLON-a pozaplik_polon.py:69).AddField×10 (pola liveops z defaultami).AlterField owner→related_name="+"(tylko stan migracji, bez SQL).
Pola wspólne (id, created_on, started_on, finished_on,
finished_successfully, traceback) mają identyczne definicje → brak operacji.
pk zostaje UUID → URL-e <uuid:pk> bez zmian. Po zmianie modeli uruchomić
uv run python src/manage.py makemigrations --check. Baseline: NIE odświeżać
w tym branchu (dopiero przy scalaniu).
Fazy (przyrostowe, TDD) — 4 fazy¶
Atomowość:
ImportPlikuPoloniImportPlikuAbsencjidzielą klasę bazową, jedną migrację, wspólny plik widoków ORAZ wspólnyZapiszDoBazyMixin. Cutover bazy modelu USUWA z użyciamark_reset/task_perform/get_url, więc cała warstwa widoków +ZapiszDoBazyMixin+ jego testy muszą przejść w TYM SAMYM commicie/PR co model — inaczejtest_zapisz_do_bazy.py(11 testów) jest czerwony między fazami. Dlatego Faza 2 to JEDEN zielony cutover obejmujący również dry-run→zapisz.
Faza 1 — rdzeń przyjmuje Progress (stary model działa dalej)¶
core/import_polon.py::analyze_file_import_polon(fn, parent_model, p)— dodać wymaganyp.send_progress(...)→ pętlafor … in p.track(list(enumerate(records)), total=total, label="Import POLON");send_notification(err,"error")→p.log(err)(potemraise).core/import_absencji.py::analyze_file_import_absencji(fn, parent_model, p)— analogicznie.- Most zgodności = LOKALNA podklasa
ProgressFORWARDUJĄCA do legacy API, NIETextProgressi NIE pusty no-op. LegacyOperationnie ma polacancel_requested, aProgress.trackwołacheck_cancelled()→refresh_from_db(fields=["cancel_requested"])(liveops/progress.py:154) → w realnej ścieżce celery byłbyValueError(unit-testy zMockProgressby to przegapiły) — dlategocheck_cancellednadpisujemy na no-op (fix awarii zostaje). Ale pozostałych emiterów NIE zerujemy: skoro Fazy 1 i 2 mogą trafić osobno, czysty no-op wygasiłby pasek postępu na starej stronie details między nimi (zielone testy, regresja behawioralna). Most FORWARDUJE do legacy:_emit_percent(v) → parent_model.send_progress(v),log(line) → parent_model.send_notification(line, "info")(istatus/erroranalogicznie). Cała klasa oraz linia wperform()znikają w Fazie 2. - Zaktualizować test-call-site’y rdzenia na
MockProgress(model):test_import_polon_core.py(6×),test_import_polon_override.py(4×),test_import_polon_ignoruj.py(3×),test_import_absencji_core.py(2×),test_multi_uczelnia_scoping.py(2×).
Gate Fazy 1 (zielone): uv run pytest
src/import_polon/tests/test_import_polon_core.py
src/import_polon/tests/test_import_polon_override.py
src/import_polon/tests/test_import_polon_ignoruj.py
src/import_polon/tests/test_import_absencji_core.py
src/import_polon/tests/test_multi_uczelnia_scoping.py.
Faza 2 — atomowy cutover (model + widoki + URL + szablony + zapisz-do-bazy)¶
Model (models.py) — wzór import_pracownikow/models.py:72,251-345:
- ImportPlikuPolon(LiveOperation), ImportPlikuAbsencji(LiveOperation) —
usunąć importy Operation/ASGINotificationMixin + lokalny most z Fazy 1.
- perform()→run(self, p): woła rdzeń z p, finalizuje
p.result({"total": self.get_details_set().count()}); obudowa
try/except: traceback.print_exc(); rollbar.report_exc_info(); raise.
- on_reset()→on_restart().
- Dodać reset_liveops_state() + _POLA_LIVEOPS_RESET
(kopia import_pracownikow/models.py:306-345).
- Nazwy szablonów: class_to_snake("ImportPlikuPolon") = "import_pliku_polon"
(nie importplikupolon!). Ustawić jawne host_template_name /
result_template_name albo utworzyć pliki pod auto-nazwą — byle spójnie.
- Migracja 0017_* (patrz „Migracja modelu"); makemigrations --check.
Widoki (views/plik_polon.py, plik_absencji.py):
- Usunąć importy long_running.views.
- PokazImporty(GroupRequiredMixin, ListView) — model=ImportPlikuPolon,
group_required="wprowadzanie danych"; get_queryset = owner-scope +
order_by("-created_on") + zachować zawężenie multi-hosted + per-uczelnia
trim (wzór ListView import_pracownikow/views.py:118-133). Analogiczny
PokazImportyAbsencji; przy okazji naprawić index-absencji (patrz Faza 4
— bugfix + newsfragment).
- UtworzImportPlikuPolon(GroupRequiredMixin, CreateLiveOperationView) —
form_valid ustawia TYLKO form.instance.uczelnia =
Uczelnia.get_for_request(request) i woła return super().form_valid(form);
reszta (owner + form.save() + enqueue() + redirect na get_absolute_url)
robi bazowe CreateLiveOperationView.form_valid (liveops/views.py:101-105).
Uwaga o cytacie: import_pracownikow.NowyImportView.form_valid
(views.py:160-174) świadomie NIE wywołuje super()/enqueue() — przekierowuje
na krok mapowania — więc to NIE jest wzór na enqueue; wzorem enqueue jest
bazowa metoda liveops. UtworzImportPlikuAbsencji bez uczelnia — może użyć
gołego CreateLiveOperationView.
- ImportPolonResultsView(GroupRequiredMixin, ListView) — owner-scope przez
parent_object (get_object_or_404(..., owner=request.user)). Ostrożnie z
wzorem import_pracownikow/views.py:650-667: on podaje w kontekst
parent_object= (NIE object=) i NIE ma paginate_by. POLON-owy
wierszimportuplikupolon_list.html renderuje {% url … object.pk %} (~176)
i stronicowanie (~339,394), więc widok MUSI mieć:
- paginate_by = 25 (inaczej cicha utrata paginacji — tysiące wierszy na
jednej stronie; testy tego NIE łapią),
- get_context_data(object=self.parent_object, …) (inaczej NoReverseMatch
na object.pk — testy łapią),
- zachowany dotychczasowy filter_form + kontekst unmatched_autor_dyscyplina
(unmatched_count), które obecny ImportPolonResultsView już buduje.
Absencji analogicznie (object=self.parent_object + paginate_by=25 pod
wierszimportuplikuabsencji_list.html).
- Usunąć ImportPolonRouterView, ImportPolonDetailsView + absencyjne
odpowiedniki. RestartImportView (POLON + absencji) NIE usuwać ślepo —
zastąpić gejtowanym wariantem opartym na liveops RestartView (patrz
„Restart" niżej).
- ZapiszDoBazyMixin — przepisać (owner-scope BEZ long_running):
- dziedziczenie: LoginRequiredMixin + DetailView/SingleObjectMixin;
owner-scope przez własny get_queryset = self.model.objects.filter(
owner=self.request.user) (zastępuje RestrictToOwnerMixin z
long_running.views) — po to, by Faza 4 „brak importów long_running"
przeszła.
- GET bez zmian (potwierdz_zapis_do_bazy.html).
- POST (@transaction.atomic + select_for_update) — guard bez zmian
(finished_successfully and not zapisz_zmiany_do_bazy), ale zamiast
mark_reset() + task_on_commit:
self.object.zapisz_zmiany_do_bazy = True
self.object.on_restart() # kasuje wiersze-dzieci
pola = self.object.reset_liveops_state()
self.object.save(update_fields=["zapisz_zmiany_do_bazy", *pola])
transaction.on_commit(self.object.enqueue) # patrz Ryzyko #2
HttpResponseRedirect("..") (obecnie linia 48) → HttpResponseRedirect(
self.object.get_absolute_url()) (stary „.." = router URL kasowany w tej
fazie → 404).
- ZapiszDoBazyImportView/ZapiszDoBazyImportAbsencjiView zostają z
GroupRequiredMixin (bramka grupy).
URL-e (urls.py): usunąć *-router|details (POLON + absencji); zostawić
*-restart (teraz celuje w gejtowany _PkOwnerRestartMixin, POST); zostawić
index, utworz-import, *-results, *-zapisz-do-bazy, index-absencji,
utworz-import-absencji.
views/__init__.py: nadal star-import obu modułów (bez zmiany struktury), ale
zweryfikować, że nic z zewnątrz nie importuje po imieniu usuniętych klas
(router/details/restart) — gwiazdka podąża za zawartością modułów, więc
usunięcie klas wystarcza; sprawdzić importy w innych appach/urls.
Szablony:
- Host import_polon/import_pliku_polon.html (+ absencji) — wzór DOSŁOWNY
import_pracownikow/templates/import_pracownikow/import_pracownikow.html:
{% extends "base.html" %}, {% load static liveops %}, breadcrumbs, i
OBOWIĄZKOWO wrapper CSRF (bo CSRF_COOKIE_HTTPONLY=True):
<div hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% live_operation object %}
</div>
<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>
import_polon/import_pliku_polon_result.html (+ absencji) — panel
„zakończono" + link {% url "import_polon:importplikupolon-results"
operation.pk %}. Liczniki z operation.get_details_set(), NIE tylko z
result_context (stare importy mają result_context=NULL — patrz Ryzyko #3).
- importplikupolon_list.html: link → {{ object.get_absolute_url }},
zachować {{ object.uczelnia }}.
- NOWY import_polon/importplikuabsencji_list.html — dziś index-absencji
pożycza importplikupolon_list.html (bo mapuje na PokazImporty); po
wydzieleniu PokazImportyAbsencji(ListView) nie ma szablonu listy absencji →
TemplateDoesNotExist. Utworzyć osobny szablon: link wiersza →
{{ object.get_absolute_url }}, przycisk „utwórz nowy import" →
{% url "import_polon:utworz-import-absencji" %} (bez pola uczelnia).
- wierszimportuplikupolon_list.html / …absencji_list.html: usunąć
{% include "long_running/operation_details.html" %} + toggle JS. Zachować
przycisk „zapisz do bazy".
- potwierdz_zapis_do_bazy.html: link Anuluj href=".." →
href="{{ object.get_absolute_url }}" (stary „.." = router URL → 404).
- Usunąć importplikupolon_detail.html, importplikuabsencji_detail.html.
Restart — gejtowany grupą (precedens #508 F4). Centralny liveops:restart
gejtuje tylko gdy LIVEOPS["REQUIRED_GROUP"] ustawione — w BPP NIE jest
(liveops/views.py:50-59 → no-op), a legacy RestartImportView siedział za
grupą „wprowadzanie danych" (plik_polon.py:104). Restart z
zapisz_zmiany_do_bazy=True PONOWNIE zapisuje do bazy, więc nie zostawiamy go
owner-only. Dodać (wzór import_pracownikow/views.py:1386-1404) mały
_PkOwnerRestartMixin(GroupRequiredMixin, RestartView) (owner-scope +
group_required="wprowadzanie danych") i gejtowane widoki restartu POLON +
absencji, wpięte pod własne URL-e restartu (przywrócić *-restart), a przycisk
restartu na host-page kierować na nie (a nie na centralny liveops:restart).
Dodać test bramki (wzór import_pracownikow/tests/test_auth_gate.py): user bez
grupy → POST restart nie zmienia stanu; z grupą → przechodzi.
Testy Fazy 2 (nowe + przepisane, wszystko zielone na koniec):
- test_run_finalizuje_polon / …_absencji — op.run(MockProgress(op)) na
fixture-pliku: finished_successfully, result_context, wiersze utworzone.
- test_liveops_live_view_renderuje_host — op.get_absolute_url() ==
"/live/import_polon.importplikupolon/<pk>/"; GET (admin) 200; treść zawiera
data-liveop-channel, data-liveop-token ORAZ X-CSRFToken (wrapper).
- Przepisany test_zapisz_do_bazy.py (11 testów) — patrz „Strategia testów".
- test_restart_bez_grupy_nie_zmienia_stanu / …_z_grupa_przechodzi (wzór
import_pracownikow/tests/test_auth_gate.py) — bramka grupy na restarcie.
Asercja przez EFEKT UBOCZNY, NIE po kodzie 403: braces
GroupRequiredMixin ma domyślnie raise_exception=False (żaden widok BPP nie
ustawia inaczej), więc zalogowany user bez grupy dostaje 302 →
redirect_to_login, a nie 403. Sprawdzać: operacja NIE została zresetowana /
re-run (stan bez zmian, wiersze-dzieci nietknięte) — ewentualnie dodatkowo
status_code == 302; NIGDY nie asertować 403 (strażnik #508 F4 też idzie po
efekcie ubocznym, nie po kodzie).
- Zielone bez zmian merytorycznych: całe test_multi_uczelnia_scoping.py
(get_queryset po -created_on; membership bez zmian),
test_widok_tworzacy_przypina_uczelnie (POST create — enqueue() w
form_valid jest SYNCHRONICZNE przy eager, więc import realnie biegnie;
sprawdzić że run() domyka na fixture-danych).
Faza 3 — polish live UI (opcjonalnie)¶
stages+with p.stage(...)(stepper; wzórimport_pracownikow/models.py:270-284). Niewymagane funkcjonalnie.get_success_url()— domyślnie None (zostaje na stronie live z wynikiem).
Faza 4 — sprzątanie + newsfragmenty¶
- Grep:
import_polonnie importujelong_runninganiASGINotificationMixin; brak martwych URL-name. - NIE usuwać
long_running, autoryzatora,CHANNELS_BROADCAST_*. admin.py/apps.py— bez zmian (admin.pyrejestruje tylkoImportPolonOverride; nie dotyka Operation/liveops) — executor nie musi ich ruszać.- DWA newsfragmenty w
src/bpp/newsfragments/: <slug>.feature.rst— „Import POLON i absencji na django-liveops (live-progress WebSocket + HTMX)".<slug2>.bugfix.rst— naprawaindex-absencji: dziś strona „absencje" (urls.py:35) pokazuje listę importów POLON zamiast absencji; Faza 2 przepinaindex-absencjinaPokazImportyAbsencji. To osobna, user-widoczna poprawka → osobny fragment.
Zachowanie do utrzymania (checklista + strażnicy)¶
| Zachowanie | Gdzie | Test-strażnik |
|---|---|---|
| Multi-hosted: raport niezmatchowanych | ImportPlikuPolon.autorzy_niezmatchowani (bez zmian) |
test_multi_uczelnia_scoping.py::test_autorzy_niezmatchowani_* |
| Multi-hosted: walidacja ZATRUDNIENIE | core/import_polon.py::validate_zatrudnienie_... |
..::test_walidacja_zatrudnienia_zawezona_do_uczelni_importu |
| Multi-hosted: guard mutacji obcej uczelni | core/import_polon.py::autor_z_innej_uczelni |
..::test_import_nie_modyfikuje_autora_innej_uczelni |
Lista zawężona do uczelni + uczelnia=None wstecz |
PokazImporty.get_queryset (ListView) |
..::test_lista_importow_zawezona_*, _bez_rozstrzygnietej_* |
Create przypina uczelnia |
UtworzImportPlikuPolon.form_valid |
..::test_widok_tworzacy_przypina_uczelnie_z_requestu |
| Label uczelni na liście | importplikupolon_list.html |
(wizualny) |
| Dry-run → zapisz (flip + reset + re-run) | ZapiszDoBazyMixin.post |
test_zapisz_do_bazy.py (przepisany) |
| Guardy zapisu (zapisany/błąd/w-trakcie/404) | ZapiszDoBazyMixin.post |
test_zapisz_do_bazy.py::*guard*, *other_user_404 |
| Reset kasuje wiersze-dzieci | on_restart() |
..::test_zapisz_do_bazy_polon_post_deletes_child_rows |
| Przycisk „zapisz" tylko dla dry-run | wierszimportuplikupolon_list.html + results |
..::test_results_page_shows_button, _hides_button |
| Routing details/results/restart | liveops:live/restart + *-results |
nowy test_liveops_live_view_* |
Bramka grupy: LIVEOPS nie ma REQUIRED_GROUP, więc centralne
liveops:live/:cancel są tylko owner-scoped. Grupa „wprowadzanie danych"
nadal bramkuje create/list/results/zapisz (GroupRequiredMixin) ORAZ restart
(gejtowany _PkOwnerRestartMixin, patrz Ryzyko #5 — restart jest destrukcyjny).
Utworzyć import może tylko członek grupy, a live/wynik widzi tylko właściciel →
brak regresji. Nie ustawiać REQUIRED_GROUP globalnie (zbramkowałoby
deduplikator i inne liveopsy).
Strategia testów¶
transaction.on_commitw testach pytest-django NIE odpala się (test biegnie w rollbackowanej transakcji), chyba że użyjeszdjango_capture_on_commit_callbacks(execute=True). Wcześniejsze (v1) stwierdzenie „eager + on_commit → import biegnie w teście" było BŁĘDNE dla ścieżki zapisz-do-bazy (tam enqueue jest won_commit).- Ścieżka create (
enqueue()wołane wprost wform_valid, pozaon_commit) JEST synchroniczna przy eager —test_widok_tworzacy_*realnie uruchamia import. - Ścieżka zapisz-do-bazy (
transaction.on_commit(enqueue)):- guard-testy (stan bez zmian) →
django_capture_on_commit_callbacks()BEZexecutei asercja, że lista callbacków enqueue jest pusta / że stan nie ruszył; - „re-run faktycznie przetworzył wiersze" →
…(execute=True)żeby wymusić enqueue (przy eager wykona się synchronicznie po commicie); - flip-flag + reset stanu (
zapisz_zmiany_do_bazy=True,started_on/finished_onNone, dzieci skasowane) sprawdzać PRZED wykonaniem enqueue (capture bez execute) — inaczej re-run znów je ustawi.
- guard-testy (stan bez zmian) →
- Faza 1: rdzeń zielony z
MockProgress(gate: core+override+ignoruj+absencji+ multi_uczelnia). - Faza 2: nowe
test_run_finalizuje_*,test_liveops_live_view_*; regresja:test_multi_uczelnia_scoping.py,test_widok_tworzacy_*; przepisanytest_zapisz_do_bazy.py. - Zielone bez zmian merytorycznych (poza
p):test_update_rodzaj_autora.py,test_import_polon_validation.py,test_utils.py. - Komendy:
uv run pytest src/import_polon/tests/ -q(Docker daemon dla testcontainers; RUNNER=eager zsettings/test.py).
Ryzyka i pytania otwarte¶
- Migracja 0017 (oba modele naraz).
owner related_name="+"(AlterField bez SQL) — zgrepowano, brak użyć reverse accessora.last_updated_onznika (jedyny konsument: sortowanie listy →-created_on).makemigrations --checkpo zmianie. Baseline dopiero przy scalaniu. - Celery enqueue w bloku
@transaction.atomic(zapisz-do-bazy).enqueue()przyRUNNER="celery"woła.delay()bezon_commit— w bloku atomic worker mógłby czytać stan sprzed commitu. Rozwiązanie:transaction.on_commit(self.object.enqueue). Konsekwencja: przepisanietest_zapisz_do_bazy.py(patrz „Strategia testów"). - Snapshot/result dla STARYCH importów (
result_context=NULL). Result- template musi liczyć zoperation.get_details_set(). Nazewnictwo:class_to_snake("ImportPlikuPolon")="import_pliku_polon"— pliki host/result pod tą nazwą albo jawnehost_template_name/result_template_name. - CSRF.
CSRF_COOKIE_HTTPONLY=True(base.py:1596) → bez wrapperahx-headersprzyciski Anuluj/Ponów na host-page dają 403. Wrapper obowiązkowy (wzórimport_pracownikow.html:27). - Bramka grupy na restarcie — GEJTUJEMY (nie owner-only). Centralny
liveops:restartgejtuje tylko przy ustawionymREQUIRED_GROUP(liveops/views.py:50-59), którego BPP nie ustawia → owner-only. Ale restart zzapisz_zmiany_do_bazy=TruePONOWNIE zapisuje do bazy, a legacyRestartImportViewbył za grupą „wprowadzanie danych" (plik_polon.py:104). Projekt uznał to już za dziurę przyimport_pracownikow(#508 F4:_PkOwnerRestartMixin(GroupRequiredMixin, RestartView),views.py:1386+test_auth_gate.py). Decyzja: powielić ten wzór dla POLON + absencji (własny gejtowany widok restartu pod*-restart, przycisk host-page celuje w niego) zamiast akceptować owner-only.cancel(nie- destrukcyjny) może zostać owner-only na centralnymliveops:cancel. notify_list_changed/ auto-refresh listy. WłasnaPokazImportynie ma liveops-owego JS list-live → lista nie odświeży się sama (jak dziś). Bez regresji.- Token TTL 24h — POLON mieści się; snapshot-on-connect pokrywa reconnect. Niskie ryzyko.
- Faza 1 most zgodności. Musi być LOKALNY no-op
Progress(nieTextProgress) — inaczejcheck_cancelled → refresh_from_db(["cancel_ requested"])na legacyOperationrzuciValueErrorw realnej ścieżce celery (unit-testy zMockProgressby to przegapiły). - Koordynacja z gałęzią
feat/liveops-import-list-ministerialnych. Jest otwarta i też migruje importer na liveops. Przy scalaniu OBU: (a) potencjalny konflikt baseline (baseline-sql/baseline.sql— jeden wielki plik, odświeżać raz, przy merge); (b) obie dodają migracje/newsfragmenty — niezależne katalogi, więc same fragmenty nie kolidują, ale baseline tak. Uzgodnić kolejność scalania. index-absencji(urls.py:35). Dziś mapuje na listę POLON — user- widoczny bug; Faza 2 naprawia (osobny newsfragment bugfix).
Czego NIE robić¶
- NIE modyfikować migracji
import_polon/0001-0016— tylko0017. - NIE odświeżać baseline w tym branchu.
- NIE usuwać
long_running,long_running/authorizers.py,CHANNELS_BROADCAST_SUBSCRIPTION_AUTHORIZER. - NIE ustawiać
LIVEOPS["REQUIRED_GROUP"]. - NIE ruszać wspólnych plików
long_running/*. - NIE cytować
import_list_ministerialnychjako „w drzewie" — nie jest scalony; wzór kanoniczny toimport_pracownikow.
Zmiany względem v2 (v3, dla recenzenta)¶
- Results views (MAJOR): jawny wymóg
paginate_by = 25+get_context_data(object=self.parent_object, …)dla POLON i absencji — ślepe skopiowanieimport_pracownikow(parent_object=, brak paginacji) dałobyNoReverseMatchnaobject.pk(~176) i cichą utratę stronicowania. - Bugfix
index-absencji(MAJOR): dodano wymóg utworzenia NOWEGOimportplikuabsencji_list.html— bez niego wydzielonyPokazImportyAbsencjidajeTemplateDoesNotExist. - Restart gejtowany (MINOR 5): Ryzyko #5 zmienione z „owner-only OK" na
„GEJTUJEMY grupą" (
_PkOwnerRestartMixin+ auth-gate test; precedens #508 F4);*-restartURL zostaje; zaktualizowano sekcje Widoki/URL/Zachowanie/Testy. - Faza 1 most (MINOR 3): nie czysty no-op — forwarduje do legacy
send_progress/send_notification(żeby nie wygasić paska między fazami);check_cancellednadal no-op (fix awarii zostaje). - Liczba testów (MINOR 4):
test_zapisz_do_bazy.py= 11, nie 13 (poprawione wszędzie). - Cytat create (NIT 6):
UtworzImportPlikuPolon.form_valid= ustawuczelnia+super().form_valid(); enqueue robi bazoweCreateLiveOperationView.form_valid(liveops/views.py:101-105) — a nieNowyImportView(:160-174), które celowo NIE enqueue’uje.
Zmiany względem v1 (dla recenzenta)¶
- Referencje:
import_list_ministerialnychprzeniesiony do „NIE scalony" (zweryfikowane:models.py:10= Operation, migracje do 0008, nie-przodek dev); wzór kanoniczny →import_pracownikow(migracja0010_liveops.py, hostimport_pracownikow.html). - Fazy: dry-run→zapisz (
ZapiszDoBazyMixin+ przepisanietest_zapisz_do_bazy.py) wciągnięte do atomowej Fazy 2 (było osobną fazą, co dawało 13 czerwonych testów między fazami). Fazy: 5 → 4. - Redirecty: dopisany fix
HttpResponseRedirect("..")→get_absolute_url()(POST zapisu) i link Anuluj wpotwierdz_zapis_do_bazy.html. - CSRF: dodany obowiązkowy wrapper
hx-headers(CSRF_COOKIE_HTTPONLY). - Faza 1 most:
TextProgress→ LOKALNY no-op Progress (polecancel_requested). - Strategia testów: skorygowany błąd o
on_commitw testach (django_capture_on_commit_callbacks(execute=True)); poprawiona liczba call-site’ów rdzenia (6, nie 3) i rozszerzony gate Fazy 1. - ZapiszDoBazyMixin owner-scope: dopisane, czym zastąpić
RestrictToOwnerMixinzlong_running. - Gapy: drugi newsfragment (bugfix
index-absencji), nota oviews/__init__.py, nota żeadmin.py/apps.pybez zmian, koordynacja z otwartą gałęzią liveops-list-ministerialnych.