Plan migracji import_list_if na django-liveops 0.4¶
Status: PLAN (do wykonania). Ten dokument NIE zmienia kodu — opisuje
uporządkowaną, TDD-ową ścieżkę przeniesienia aplikacji import_list_if
z legacy stacku long_running na django-liveops 0.4, wzorowaną 1:1 na
już-scalonym, najbliższym kształtem import_punktacji_zrodel.
Worktree: ~/Programowanie/bpp-import-list-if-liveops
(gałąź worktree-import-list-if-liveops, od dev).
Zmiany po recenzji (execute-with-fixes). Decyzje architektoniczne (RETIRE
ImportOperation, zostawImportRowMixin, delta0005, cutover) — potwierdzone poprawne. Uściślenia po adversarialnej recenzji, każde zweryfikowane w kodzie: - PRIMARY reference =import_punktacji_zrodel(nieimport_pracownikow). Ta apka jest już na liveops 0.4 (models.py:9ImportPunktacjiZrodel(LiveOperation)) i to najbliższy 1:1 kształtimport_list_if: jeden XLS →index/new/results,run(self,p),on_restart,ResultsView(GroupRequiredMixin, ListView)zpaginate_by=25iobject=self.parent_object(views.py:45-86), auto-nazwane szablony (import_punktacji_zrodel_result.html).import_pracownikowpozostaje referencją dla wrapperahx-headersi group-gated restart mixinu. - Bramka grupy = 302 redirect_to_login, NIE 403 (bracesAccessMixin.raise_exception=Falsedomyślnie,_access.py:27,81→redirect_to_login). Testy asertują side-effect (stan/utworzenie), nie status. (Uwaga: 403 z CSRF to co innego i zostaje.) - Post-Faza-1 nie jest deployowalny (aplikacja runtime-broken do Fazy 2); Faza 0 kończy RED (merge z Fazą 1 lubxfail). Doprecyzowane niżej. - Atomic ↔ cancel: owinięcierun()wtransaction.atomic()sprawia, żeOperationCancelledcofa wiersze → test anulowania asertujepytest.raises(OperationCancelled)+ 0 wierszy.WebProgress.result/errori tak defer push przeztransaction.on_commit(progress.py:263-289), więc owinięcie jest oficjalnie wspierane. Świadome odejście od wzorca punktacji (ta NIE owija — pisze przyrostowo); owijamy dla parytetu z legacytask_perform. - App-level restart URL (regen/) — DROP (precedens punktacji: brakregenw urls; „Ponów" idzie na centralnyliveops:restart). - Poprawione line-refy, literał grupy →bpp.const.GR_WPROWADZANIE_DANYCH(const.py:20), martwy{% load render_table %}do usunięcia, zanotowany (już naprawiony na dev, commit 7eed62a2a) latentny bug CSRF.
1. Streszczenie¶
import_list_if to najprostszy z importerów BPP: jeden upload XLS
(plik_xls + rok), synchroniczne przetworzenie wierszy (dopasowanie
źródła + zapis Punktacja_Zrodla.impact_factor dla danego roku), lista
importów i tabela wyników. Dziś operacja dziedziczy po abstrakcyjnym
import_common.models.ImportOperation (ASGINotificationMixin, Operation
z long_running), a widoki to rodzina LongRunning*View.
Migracja: ImportListIf ma dziedziczyć wprost po
liveops.models.LiveOperation, a logika ImportOperation (pole
plik_xls, get_xls_import_file, validation_form_class /
get_validation_form_class, pętla perform) zostaje wchłonięta do
ImportListIf i przepisana na run(self, p). Abstrakcyjny
ImportOperation znika (jedyny użytkownik). ImportRowMixin z tego
samego pliku zostaje — używa go też import_pracownikow.
Skala zmiany jest mała (model ~75 linii, 6 widoków, 4 szablony, 1 nowa migracja delta). Rekomendacja: cutover model+widoki w jednym kroku (bez mostka kompatybilności z §Fazy) — aplikacja jest jednofazowa, nie ma osobnego etapu „emisja progresu" do zainscenizowania.
Referencje (źródła prawdy, już na 0.4):
- PRIMARY — src/import_punktacji_zrodel/ (najbliższy 1:1 kształt):
models.py:9 ImportPunktacjiZrodel(LiveOperation), run(self,p)
(:29), on_restart (:52), get_details_set; views.py:45-86
ResultsView(GroupRequiredMixin, ListView) z paginate_by=25 +
object=self.parent_object; urls.py (index/new/results/zatwierdz, brak
routera/detali/regen); import_punktacji_zrodel_result.html.
- SECONDARY — src/import_pracownikow/ (dla dwóch wzorców):
templates/import_pracownikow/import_pracownikow.html:27 wrapper
hx-headers (CSRF), views.py:1386 group-gated _PkOwnerRestartMixin.
Reszta (models.py:72, migrations/0010_liveops.py) — pomocniczo.
2. Stan obecny (legacy wiring)¶
Model — dziedziczenie abstrakcyjne (kluczowy fakt strukturalny)¶
src/import_list_if/models.py:23—class ImportListIf(ImportOperation); dokładarok,try_names,banned_names,min_points,validation_form_class = ImportListRowValidationForm,import_single_row(),on_reset(),get_details_set().src/import_common/models.py:11—class ImportOperation( ASGINotificationMixin, Operation),class Meta: abstract = True. Dostarcza:plik_xls = FileField(upload_to="protected/import_common/"),get_validation_form_class(),get_xls_import_file()(budujeXLSImportFile),perform()(pętla po wierszach XLS z walidacją formularza iself.send_progress(...)co 2%),ignore_bad_rows = False.src/import_common/models.py:60—class ImportRowMixin(nr_arkusza,nr_wiersza) — współdzielony:import_pracownikow/models.py:720(ImportPracownikowRow(ImportRowMixin, models.Model)).src/long_running/models.py:17—Operation(NullNotificationMixin, models.Model):idUUID,ownerFK (bezrelated_name),created_on,last_updated_on(auto_now),started_on,finished_on,finished_successfully,traceback;Meta.ordering = ["-last_updated_on"];task_perform()(long_running/models.py:99) owijaperform()wtransaction.atomic()(:108).
POTWIERDZONE (grep repo-wide): ImportOperation importuje/dziedziczy
wyłącznie import_list_if (grep -rn ImportOperation src → tylko
import_common/models.py definicja + import_list_if/models.py:13,23).
Pozostałe importery na legacy stacku (import_list_ministerialnych,
import_polon, importer_autorow_pbn, raport_slotow) używają long_running
bezpośrednio, nie przez ImportOperation. Dlatego retire ImportOperation
jest czysty. Natomiast long_running jako aplikacja zostaje (7+ innych
użytkowników — grep -rln long_running src).
UWAGA (korekta po recenzji): import_punktacji_zrodel NIE jest już na
long_running — jest już zmigrowany na liveops 0.4
(src/import_punktacji_zrodel/models.py:9 ImportPunktacjiZrodel(LiveOperation);
urls.py bez routera/detali; auto-nazwane szablony). To najbliższy 1:1
kształt import_list_if i główna referencja tego planu (patrz §3, §nota
u góry). import_pracownikow to referencja dla wrappera hx-headers i
group-gated restart mixinu.
Widoki (src/import_list_if/views.py)¶
Wszystkie w BaseImportListIfMixin(GroupRequiredMixin),
group_required = "wprowadzanie danych", model = ImportListIf:
ListaImportowView(LongRunningOperationsView)— owner-scoped ListView.NowyImportView(CreateLongRunningOperationView),form_class = NowyImportForm.ImportListIfRouterView(LongRunningRouterView),redirect_prefix = "import_list_if:import_list_if"— routuje po stanie na-details/-results.ImportListIfDetailsView(LongRunningDetailsView)— strona z paskiem postępu.ImportListIfResultsView(LongRunningResultsView)—paginate_by = 25(dziedziczone zlong_running/views.py:89).RestartImportView(RestartLongRunningOperationView)— restart, group-gated przez mixin.
URL-e (src/import_list_if/urls.py)¶
index, new, importlistif-router (<uuid:pk>/), importlistif-details,
importlistif-results, restart (<uuid:pk>/regen/). Wszystkie <uuid:pk>.
Szablony (src/import_list_if/templates/import_list_if/)¶
importlistif_list.html— lista; linkimport_list_if:importlistif-router object.pk; UWAGA: breadcrumb i nagłówek błędnie linkująimport_pracownikow:index(„import list IF") — bug do naprawienia przy okazji.importlistif_form.html— formularz (CreateViewdefault:<app>/<model>_form.html).importlistif_detail.html—{% include "long_running/operation_details.html" %}.importlistifrow_list.html— tabela wyników;{% include "pagination.html" %}- iteruje
object_list; używaobject.plik_xls.name,object.pk(kontekstobject= rodzic!),{% include "long_running/operation_details.html" %}.
Testy¶
tests/test_models.py—test_ImportListIF_performwołaimport_list_if.perform()i sprawdza 4 wiersze +impact_factor==70.67;test_ImportListIF_on_reset,test_ImportListIF_get_details_set.tests/test_views.py— webtest:index/newGET + klik „pobierz plik wzorcowy".
Odniesienia zewnętrzne (muszą przeżyć)¶
src/django_bpp/templates/top_bar.html:190→{% url "import_list_if:index" %}.src/bpp/templates/browse/uczelnia.html:706→link: "/import_list_if/".src/django_bpp/urls.py:183mount^import_list_if/.src/django_bpp/urls.py:286— centralnypath("live/", include("liveops.urls"))już zamontowany.admin.pypusty (brak rejestracji ModelAdmin — zero pracy).
3. Docelowy stan (liveops)¶
Model ImportListIf(LiveOperation)¶
Pola liveops (.venv/.../liveops/models.py:27): id 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; Meta.abstract, ordering = ["-created_on"].
Wchłonięte z ImportOperation: plik_xls, try_names/banned_names/
min_points, validation_form_class + get_validation_form_class(),
get_xls_import_file(), ignore_bad_rows.
Kontrakt liveops, który realizujemy:
run(self, p)— logika operacji (dawneperform()), progres/anulowanie przez obiektp(liveops.progress.Progress:p.percent(int),p.track(iterable),p.log(str),p.stage(name)[ctx-manager],p.check_cancelled(),p.result(ctx),p.error(msg)).on_restart(self)— hookRestartView(kasuje wiersze; dawneon_reset).get_absolute_url()— z bazy: reverseliveops:live(op_type=<app>.<model>, pk). Zastępuje router+details.- template naming (auto,
liveops/naming.py):class_to_snake("ImportListIf") = "import_list_if"→ hostimport_list_if/import_list_if.html, wynikimport_list_if/import_list_if_result.html. ZWERYFIKOWANO uruchamiającclass_to_snake.
Widoki (mapowanie legacy → liveops)¶
legacy LongRunning*View |
zamiennik liveops |
|---|---|
LongRunningOperationsView (lista) |
zwykły owner-scoped ListView (wzór import_punktacji_zrodel/views.py:14), własny template_name, order_by("-created_on") |
CreateLongRunningOperationView |
liveops.views.CreateLiveOperationView (liveops/views.py:98) — ustawia owner, save(), enqueue(), redirect na get_absolute_url(); group-gate przez braces (wzór import_punktacji_zrodel/views.py:31) |
LongRunningRouterView |
usunięty — get_absolute_url() → liveops:live robi to samo |
LongRunningDetailsView |
usunięty — strona live (LiveOperationView, centralny URL) renderuje host template import_list_if.html |
LongRunningResultsView |
app-specific owner-scoped ListView near-copy-paste z import_punktacji_zrodel/views.py:45-86 — parent_object (cached_property, owner→Http404), get_details_set(), paginate_by=25, object=self.parent_object w kontekście |
RestartLongRunningOperationView |
usunięty jako app-level URL (patrz §URL-e docelowe). „Ponów" na stronie live idzie na centralny liveops:restart (owner-scoped). Gdyby jednak zostawić app-level restart: _PkOwnerRestartMixin-style (braces GroupRequiredMixin + liveops.views.RestartView, wzór import_pracownikow/views.py:1386) |
Bramka grupy: liveops.BaseLiveOperationMixin gejtuje tylko gdy
LIVEOPS["REQUIRED_GROUP"] ustawione — w BPP LIVEOPS
(settings/base.py:1049) tego nie ma, więc dokładamy braces
GroupRequiredMixin (jak import_pracownikow) na Create/List/Results/Restart,
inaczej dowolny zalogowany user odpaliłby import.
URL-e docelowe¶
index (""), new (new/), importlistif-results
(<uuid:pk>/rezultaty/). Usuwamy importlistif-router,
importlistif-details oraz restart (<uuid:pk>/regen/). Strona live
idzie przez centralny liveops:live (live/<op_type>/<uuid:pk>/).
Decyzja o app-level restart (regen/) — DROP. Po usunięciu include
operation_details.html nic już nie linkuje <uuid:pk>/regen/. Przycisk
„Ponów" na stronie live POST-uje na centralny liveops:restart
(owner-scoped, ale NIE group-gated — liveops gejtuje tylko przy
REQUIRED_GROUP, którego BPP nie ustawia). Ekspozycja ~zerowa: user bez
grupy „wprowadzanie danych" i tak nie stworzy importu (create jest
group-gated), więc nie ma własnej operacji do restartu. To dokładnie
precedens import_punktacji_zrodel (jego urls.py nie ma regen) i
import_pracownikow. Jeśli w trakcie realizacji okaże się, że potrzebny jest
jawny app-level restart (np. z listy importów), przywróć go jako
_PkOwnerRestartMixin (group-gated) — ale domyślnie NIE dodajemy.
Szablony docelowe¶
- Nowy
import_list_if/import_list_if.html(host live) —extends base.html,{% load static liveops %}, wrapperhx-headersz CSRF (patrz §Gotchas) wokół{% live_operation object %}, plus 3 skrypty (htmx.min.js,channels_broadcast/js/notifications.js,liveops/liveops.js) — kopiaimport_pracownikow/import_pracownikow.html. - Nowy
import_list_if/import_list_if_result.html(fragment wyniku) — panel „import zakończony" + link doimportlistif-results, kontekst zresult_context(np.total,zintegrowano, ...). Wzór:import_punktacji_zrodel/import_punktacji_zrodel_result.html(near-copy-paste). importlistif_list.html— link zamień naobject.get_absolute_url(liveops:live); napraw breadcrumbimport_pracownikow:index→import_list_if:index; usuń auto-redirect JS jak przeszkadza.importlistifrow_list.html— usuń{% include "long_running/ operation_details.html" %}; reszta działa, o ile results view podaobject=parent_objectipaginate_by(patrz §Gotchas).- Usuń
importlistif_detail.html(zastąpiony host template). importlistif_form.html— bez zmian (CreateViewnadal go szuka jakoimport_list_if/importlistif_form.html).
4. Decyzja o ImportOperation: RETIRE (uzasadnienie)¶
Decyzja: skasować abstrakcyjny ImportOperation, a jego zawartość
wchłonąć do ImportListIf(LiveOperation). NIE migrujemy bazy
abstrakcyjnej na LiveOperation.
Uzasadnienie:
- Jedyny użytkownik.
grep -rn ImportOperation src= definicja +import_list_if. Utrzymywanie warstwy abstrakcji dla jednej podklasy to martwy koszt; po migracjiimport_pracownikow(który nigdy nie dziedziczył poImportOperation) baza ta straciła rację bytu. LiveOperationjuż jest bazą abstrakcyjną. Wstawienie pośredniegoImportOperation(LiveOperation)dodałoby drugi poziom MRO bez żadnego drugiego konsumenta — antywzorzec „abstrakcja spekulacyjna".- Zawartość jest trywialna do inline'u (~30 linii:
plik_xls,get_xls_import_file,get_validation_form_class, pętla walidacji).import_pracownikowsam trzyma swójplik_xlsi logikę — spójność ze wzorcem referencyjnym. ImportRowMixinNIE jest częścią tej decyzji — to osobna klasa w tym samym pliku, współdzielona zimport_pracownikow/models.py:720. Zostaje wimport_common/models.py. Kasujemy z tego pliku tylko klasęImportOperationoraz nieużywane po niej importy (ceil,XLSParseError,XLSImportFile,Operation,ASGINotificationMixin) — o ileImportRowMixinich nie potrzebuje (nie potrzebuje).
Konsekwencja czyszcząca: po przeniesieniu import_common/models.py zostaje
praktycznie z samym ImportRowMixin. To OK — plik pozostaje (import ścieżki
from import_common.models import ImportRowMixin używany przez
import_pracownikow).
5. Migracja modelu (nowa migracja delta)¶
Nowa migracja src/import_list_if/migrations/0005_liveops.py, mirror
import_pracownikow/migrations/0010_liveops.py, bez stan i bez
RunPython (import_list_if nie ma pól performed/integrated do
przemapowania).
dependencies:("import_list_if", "0004_alter_importlistif_plik_xls")migrations.swappable_dependency(settings.AUTH_USER_MODEL).operations:AlterModelOptions("importlistif", options={"ordering": ["-created_on"]})(dziśoptions={}po0002; nowa baza wnosi-created_on).RemoveField("importlistif", "last_updated_on").AddField×10 (identyczne definicje jak w 0010):cancel_requestedBooleanField(default=False),cancelledBooleanField(default=False),current_stageIntegerField(default=-1),languageCharField(blank=True, default="", max_length=20),logJSONField(default=list),log_seqPositiveIntegerField(default=0),percentPositiveSmallIntegerField(default=0),result_contextJSONField(blank=True, null=True),stage_statesJSONField(default=dict),status_textCharField(blank=True, default="", max_length=255).AlterField("importlistif", "owner", ForeignKey(on_delete=CASCADE, related_name="+", to=AUTH_USER_MODEL)).
Pola niezmienne (już kolumny w tabeli po spłaszczeniu dziedziczenia): id,
created_on, started_on, finished_on, finished_successfully,
traceback, plik_xls, rok — redeklaracja z bazy LiveOperation nie
generuje operacji. Zmiana bases w CreateModel NIE jest emitowana przez
makemigrations (bases nie wpływają na schemat) — nie potrzeba AlterModelBases.
PK / FK / related_name¶
- PK zostaje UUID —
LiveOperation.id = UUIDField(default=uuid4)jest identyczne z obecnymOperation.id; URL-e<uuid:pk>bez zmian. ImportListIfRow.parent(FK doImportListIf,models.py:68) — bez zmian (nadal wskazuje ten sam model,on_delete=CASCADE,related_namedomyślnyimportlistifrow_set). Brak migracji naRow.owner→related_name="+"(jak liveops) — pojedynczaAlterField.
Data concerns¶
- Brak migracji danych. Istniejące ukończone importy:
finished_on+finished_successfully→LiveOperation.get_state()dajeFINISHED_OKpoprawnie;cancelled=False(default). Utratalast_updated_on(sortowanie) nieistotna — sortujemy po-created_on. - Baseline: migracja zmienia schemat → przy scalaniu do
devtrzebamake baseline-update(delta). NIE robić w tym worktree/branchu (reguła: baseline raz, przy merge; równoległe branche = nierozwiązywalny konflikt na jednym pliku). Odnotować w PR. makemigrations --check --dry-runmusi być czysty po napisaniu migracji (walidacja zgodności model↔migracja).
6. Fazy (uporządkowane, każda niezależnie zielona, TDD)¶
Rekomendacja: cutover w jednej gałęzi, ale w 3 commitach-fazach. Bez
mostka kompatybilności — aplikacja jednofazowa, split emisji nic nie daje.
(Mostek — patrz §Gotchas — trzymamy jako awaryjny fallback, gdyby ktoś chciał
pośredni zielony stan między modelem a widokami; wtedy MUSI to być lokalny
Progress forwardujący do legacy send_progress/send_notification z
check_cancelled = no-op, NIGDY TextProgress na legacy modelu.)
Faza 0 — testy charakteryzujące (kończy RED — commituj RAZEM z Fazą 1)¶
WAŻNE: Faza 0 z definicji kończy się czerwono (produkcja jeszcze nie
ma run()). Nie jest to „niezależnie zielona" faza — albo scal jej commit
z Fazą 1 (jeden zielony commit model+testy), albo oznacz nowe testy
@pytest.mark.xfail(reason="run() dopiero w Fazie 1", strict=True) i zdejmij
xfail w Fazie 1. Nie zostawiaj czerwonego commitu jako osobnego kroku.
- Przepisz
test_models.py: zamiastimport_list_if.perform()wołajimport_list_if.run(p)zliveops.testing.MockProgress(import_list_if)(.venv/.../liveops/testing.py:43klasa,__init__zcancel_afterna:46; docstringtest_my_import_runna :27). Asercje bez zmian (4 wiersze,impact_factor == Decimal("70.67")).on_reset→on_restart,get_details_set— analogicznie. - Test anulowania musi być zgodny z decyzją o atomicity (patrz §9 ryzyko 1):
jeśli
run()owinięte wtransaction.atomic(),p.check_cancelled()rzucaliveops.progress.OperationCancelled, które cofa zapisane wiersze → asercja:with pytest.raises(OperationCancelled): op.run(MockProgress(op, cancel_after=1))orazop.importlistifrow_set.count() == 0. (Bez atomic byłby 1 zapisany wiersz — nie tego chcemy.)MockProgress.check_cancelledrzucaOperationCancelledpocancel_afterwywołaniach.
Faza 1 — model + migracja (zielona po sobie)¶
import_list_if/models.py:class ImportListIf(LiveOperation); wchłońplik_xls,get_xls_import_file,get_validation_form_class,ignore_bad_rows; przepiszperform()→run(self, p)(owinięte wtransaction.atomic()dla parytetu z legacytask_perform— patrz §9):(importy przenieś dodef run(self, p): from django.db import transaction with transaction.atomic(): x = self.get_xls_import_file() total = x.count() form_class = self.get_validation_form_class() for no, elem in enumerate(x.data()): p.check_cancelled() # OperationCancelled → rollback wierszy cleaned = None if form_class: form = form_class(elem) if not form.is_valid(): if self.ignore_bad_rows: continue raise XLSParseError( elem, form, "wstępna weryfikacja danych" ) cleaned = form.cleaned_data self.import_single_row(xls_data=elem, cleaned_data=cleaned) if total: p.percent(int((no + 1) * 100 / total)) p.result({"total": total}) # WebProgress.result deferuje push on_commitimport_list_if/models.py:ceilniepotrzebny;XLSParseErrorzimport_common.exceptions,XLSImportFilezimport_common.util.)on_reset→on_restart(kasujeget_details_set().delete()).p.result(...)poza blokiem atomic (i tak defer przezon_commit—progress.py:263-289).import_common/models.py: usuńclass ImportOperation+ osierocone importy; zostawImportRowMixin.- Napisz
0005_liveops.py(§5). - Zielone:
uv run pytest src/import_list_if/tests/test_models.py uv run python src/manage.py makemigrations --check --dry-run(brak driftu).
Post-Faza-1 NIE jest deployowalny. Testy modelu są zielone, ale aplikacja jest runtime-broken do końca Fazy 2: stare
views.pynadal importujeLongRunning*Viewi woła metody, których model już nie ma (RouterView.get→object.get_url()/get_state()int-vs-str; create →task_performAttributeError; restart →mark_resetAttributeError). To OK w obrębie jednej gałęzi (cutover), ale nie mergować/deployować między Fazą 1 a 2 — dopiero po Fazie 2 apka działa.
Faza 2 — widoki + URL-e + szablony (domyka cutover — dopiero tu apka działa)¶
views.py: przepisz na liveops (tabela §3).BaseImportListIfMixin(bracesGroupRequiredMixin,group_required = GR_WPROWADZANIE_DANYCHzbpp.const— nie literał"wprowadzanie danych") zostaje jako miks na Create/List/Results. Restart app-level: nie dodajemy (§3).urls.py: usuńimportlistif-router,importlistif-detailsorazrestart; zostawindex,new,importlistif-results.- Szablony: nowy
import_list_if.html(host, hx-headers + live_operation), nowyimport_list_if_result.html, naprawimportlistif_list.html(link + breadcrumb), oczyśćimportlistifrow_list.html, skasujimportlistif_detail.html. Usuń martwy{% load render_table from django_tables2 %}z każdego szablonu, który go trzyma bez użycia (importlistif_list.html,importlistif_form.html,importlistifrow_list.html,importlistif_detail.html— ten ostatni i tak kasujemy). test_views.py: istniejące GET-yindex/newprzechodzą; dodaj:- create POST → redirect na
liveops:live(RUNNER=eager wsettings/test.pywykona import synchronicznie); wiersze zapisane; - results view renderuje wiersze (owner-scoped; cudzy import →
Http404); - group-gating (create): zalogowany user bez grupy
„wprowadzanie danych" → 302 redirect_to_login (braces
raise_exception=Falsedomyślnie — NIE 403) i brak side-effectu (import nie powstał). Asertuj po side-effekcie +status_code == 302/"/login" in resp.url, wzoremimport_pracownikow/tests/test_auth_gate.py(który asertuje przez niezmieniony stan operacji). Superuser i user w grupie → przechodzą. - Zielone:
uv run pytest src/import_list_if/.
Faza 3 — sprzątanie + newsfragment¶
grep -rn "ImportOperation\|long_running" src/import_list_if src/import_common= czysto (pozaImportRowMixin). Potwierdź, żelong_runningnie jest usuwany globalnie (dalej używany przez 7+ apek).- Newsfragment
src/bpp/newsfragments/<slug>.feature.rst(PL, zwięźle) — np. „Import list IF przeniesiony na django-liveops (live progress przez WebSocket)". - Zielone:
make tests-without-playwright(albo pełnemake tests).
7. Zachowanie do utrzymania (+ testy strażnicze)¶
| Zachowanie | Strażnik |
|---|---|
Dopasowanie źródła + zapis Punktacja_Zrodla.impact_factor per rok; flaga zintegrowano |
test_models.py::test_ImportListIF_run (dawne _perform) |
| Reset kasuje wiersze podglądu | test_ImportListIF_on_restart (dawne _on_reset) |
get_details_set() (select_related zrodlo) |
test_ImportListIF_get_details_set |
| Anulowanie przerywa pętlę i cofa wiersze (atomic) | nowy test MockProgress(cancel_after=1) → pytest.raises(OperationCancelled) + count()==0 |
| Owner-scoping list/results (brak wycieku cudzych importów) | nowy test widoku (dwóch userów; cudzy → Http404) |
Group-gating create (GR_WPROWADZANIE_DANYCH) |
nowy test widoku: user bez grupy → 302 redirect_to_login + brak side-effectu (NIE 403); superuser/w-grupie → OK |
| Linki „pobierz plik wzorcowy" na index/new | istniejące test_views.py (zostają) |
Paginacja tabeli wyników + kontekst object=rodzic |
test renderu importlistif-results |
| Redirect po create na stronę live | test create POST → liveops:live |
8. Strategia testów¶
- Jednostkowe modelu:
liveops.testing.MockProgress(nagrywapercent/log/result, wspieracancel_after) — bez Redis/WebSocket.@pytest.mark.django_db,baker.make/istniejące fixtury (zrodlo,rok,admin_user), pliktestdata1.xlsx. - Widoki: webtest (
admin_app) jak dziś +django_webtest/clientdla POST-ów.settings/test.py:73maLIVEOPS = {**LIVEOPS, "RUNNER": "eager"}— create/restart uruchamiająrun()synchronicznie, więc po POST można od razu asertować wiersze i stanFINISHED_OK. - Bramka grupy: utwórz usera w grupie
GR_WPROWADZANIE_DANYCHvs bez; user bez grupy → 302 redirect_to_login (bracesraise_exception=False), asertuj też brak side-effectu. Superuser exempt (semantyka braces). - Lokalnie (reguła projektu): odpal
uv run pytest src/import_list_if/po każdej fazie; przed PRmake tests-without-playwright. Wymaga Dockera (testcontainers PG/Redis) — jeśli brak, powiedz wprost. makemigrations --checkjako część CI/lokalnej walidacji Fazy 1.
9. Ryzyka i pytania otwarte¶
Top 3 ryzyka:
- Brak zewnętrznej transakcji wokół
run(). Legacytask_perform(long_running/models.py:99) owijałperform()wtransaction.atomic()(:108);liveops.runner._task_runnie owijaoperation.run(progress)(runner.py:143). Częściowy import przy błędzie w połowie pliku zostawiłby część wierszy zapisanych + stanFINISHED_ERROR. Mitygacja (rekomendowana): owinąć ciałorun()wtransaction.atomic()— przywraca dawne all-or-nothing. Jest to oficjalnie wspierane przez liveops:WebProgress.result/errordeferują push terminalny przeztransaction.on_commit(progress.py:263-289, konkretnie:302/:332), więc push nie odpala się dopóki transakcja się nie zacommituje. Świadome odejście od wzorca PRIMARY:import_punktacji_zrodelNIE owija (pisze wiersze przyrostowo wanalyze_jcr_file); my owijamy dla parytetu z legacytask_perform(dawne zachowanie import_list_if było atomowe). Konsekwencja dla testu anulowania:OperationCancelledcofa wiersze → asercjaraises + count()==0(§Faza 0). Uwaga naenqueue()gotcha §Gotchas (pozarun()). - Nazwy szablonów. Host MUSI być
import_list_if/import_list_if.html, wynikimport_list_if/import_list_if_result.html(auto zclass_to_snake). Staryimportlistif_detail.htmlNIE jest już rozpoznawany przez liveops — łatwo zostawić martwy plik i dostać gołyliveops/operation.html. - CSRF +
hx-headers. Bez wrappera<div hx-headers=...>przyciski Anuluj/Ponów dają 403 (BPPCSRF_COOKIE_HTTPONLY=True,settings/base.py:1596) — patrz §Gotchas.
Pozostałe ryzyka: results view bez paginate_by/object łamie
importlistifrow_list.html (§Gotchas); baseline drift jeśli ktoś odświeży
baseline w tym branchu; run() importuje XLSParseError/XLSImportFile —
pilnować, by przeniesione importy nie zostawiły cyklu.
Pytania otwarte:
- Czy
import_list_ifpotrzebuje własnegoresult_contextbogatszego niż{"total": ...}(liczba zintegrowanych, niedopasowanych źródeł)? Wpływa na treśćimport_list_if_result.html. Domyślnie: minimalny panel + link. - Czy zachować auto-redirect z pustej listy na
./new(importlistif_list.html:36)? Można zostawić. - Restart: app-level
regen/zdjęty (§3) — „Ponów" idzie na centralnyliveops:restart, któryRestartView.postrobi pełny reset stanu sam (nie potrzebareset_liveops_statejak w wielofazowymimport_pracownikow/models.py:326). Otwarte tylko: czy UX wymaga jawnego restartu z listy importów (wtedy przywróć group-gated_PkOwnerRestartMixin).
10. Czego NIE robić¶
- NIE modyfikować istniejących migracji
import_list_if/migrations/ 0001..0004— tylko dodać0005_liveops.py. - NIE kasować
ImportRowMixinzimport_common/models.py(używaimport_pracownikow). - NIE usuwać aplikacji
long_runningani jej zINSTALLED_APPS(settings/base.py:407) — 7+ innych importerów na niej stoi. - NIE odświeżać baseline (
make baseline-update/rebuild-baseline) w tym branchu — robi się to raz, przy scalaniu dodev. - NIE montować nowych URL-i live w
import_list_if/urls.py— centralny routerliveops:livejuż jest (django_bpp/urls.py:286). - NIE używać
TextProgressna legacy modelu (brak polacancel_requested→check_cancelled().refresh_from_dbwywali) — dotyczy tylko awaryjnego mostka §Gotchas. - NIE dodawać
LIVEOPS["REQUIRED_GROUP"]globalnie zamiast bracesGroupRequiredMixin— reszta liveopsów w BPP polega na braku tej bramki. - NIE wołać
enqueue()wewnątrztransaction.atomic()beztransaction.on_commit(§Gotchas).
Załącznik — wbudowane gotchas (żeby wykonawca nie odkrywał od nowa)¶
- CSRF (
CSRF_COOKIE_HTTPONLY=True,settings/base.py:1596): host template MUSI owinąć{% live_operation object %}w<div hx-headers='{"X-CSRFToken":"{{ csrf_token }}"}'>— inaczej POST Anuluj/Ponów = 403 (to prawdziwe 403 z CSRF, nie mylić z 302 bramki grupy). Wzór:import_pracownikow/import_pracownikow.html:27. NIE kopiować zimport_punktacji_zrodel/import_punktacji_zrodel.html:21— ten host template nie miał wrappera (latentny bug CSRF; analogiczniededuplikator_zrodel). Oba naprawione nadev(commit 7eed62a2a) — po rebase nadevmożna wzorować się i na punktacji, ale weź wrapper świadomie. - Results view:
importlistifrow_list.htmlużywaobject.plik_xls.name/object.pki{% include "pagination.html" %}→ results view MUSI ustawićpaginate_by = 25i podaćobject=self.parent_objectw kontekście (opróczobject_list). Near-copy-paste zimport_punktacji_zrodel/views.py:45-86(ResultsView.get_context_datawołasuper().get_context_data( object=self.parent_object, ...)). - Bramka grupy = 302, nie 403. Braces
GroupRequiredMixin(AccessMixin.raise_exception=False,_access.py:27,81) przy braku grupy robiredirect_to_login→ 302, nie 403. Testy asertują przez side-effect (op nie powstał/nie zresetowany), nie po statusie. Chcieć 403 = ustawićraise_exception=True(zmiana zachowania — NIE rekomendowane). - Restart NIE jest group-gated (świadomie). App-level
regen/zdjęty; centralnyliveops:restartjest owner-scoped, ale liveops gejtuje grupą tylko przyREQUIRED_GROUP(BPP nie ustawia). Ekspozycja ~zero: create jest group-gated, więc user bez grupy nie ma własnego importu do restartu. Precedensimport_punktacji_zrodel/import_pracownikow. enqueue()→ celery.delay()bezon_commit. Bezpośredni enqueue w create jest OK (brakATOMIC_REQUESTS). Enqueue wewnątrz@transaction.atomicwymagatransaction.on_commit(...).- Nazwy szablonów:
class_to_snake("ImportListIf") = "import_list_if"(ZWERYFIKOWANO) → hostimport_list_if/import_list_if.html, wynik..._result.html. Zgadza się z app_label — brak potrzeby jawnychhost_template_name/result_template_name. - Mostek Fazy 1 (tylko jeśli split emisji ≠ cutover): lokalny
ProgressFORWARDUJĄCY do legacysend_progress/send_notification, zcheck_cancellednadpisanym na no-op (legacyOperationnie macancel_requested→refresh_from_dbby się wywalił). NIGDYTextProgressna legacy modelu. Dlaimport_list_ifrekomendacja: pomiń mostek, rób cutover. - Istniejące migracje nietykalne; nie odświeżać baseline (odnotować przy merge); newsfragment wymagany.