Skip to content

Commit 5a54bb6

Browse files
committed
Multi-workflow engine, push/pull diffs, clone SubWorkflowDefinition
- Workflow engine: create_workflow_tasks spawns tasks for ALL workflows on a form (parallel tracks). handle_approval/handle_rejection derive workflow from task.workflow_stage.workflow. Submission only finalizes when all tracks complete (_try_finalize_all_tracks). - Push flow redesigned to mirror pull: standalone 3-step page (pick remote → select forms with diff status → push selected). Removed push_forms_to_remote admin action. - Pull flow: added Status column showing New/Identical/N changes with expandable diff details per form. - Clone: both admin and form builder now copy SubWorkflowDefinition. - Bump version to 0.26.0
1 parent 9eac3eb commit 5a54bb6

7 files changed

Lines changed: 310 additions & 90 deletions

File tree

django_forms_workflows/admin.py

Lines changed: 146 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ class FormDefinitionAdmin(admin.ModelAdmin):
307307
inlines = [FormFieldInline, WorkflowDefinitionInline]
308308
filter_horizontal = ("submit_groups", "view_groups", "admin_groups")
309309
change_form_template = "admin/django_forms_workflows/formdef_change_form.html"
310-
actions = ["clone_forms", "diff_forms", "export_as_json", "push_forms_to_remote"]
310+
actions = ["clone_forms", "diff_forms", "export_as_json"]
311311

312312
fieldsets = (
313313
(
@@ -550,6 +550,20 @@ def clone_forms(self, request, queryset):
550550
group=sag.group,
551551
position=sag.position,
552552
)
553+
# Clone SubWorkflowDefinition if present
554+
try:
555+
swc = wf.sub_workflow_config
556+
SubWorkflowDefinition.objects.create(
557+
parent_workflow=cloned_wf,
558+
sub_workflow=swc.sub_workflow,
559+
count_field=swc.count_field,
560+
section_label=swc.section_label,
561+
label_template=swc.label_template,
562+
trigger=swc.trigger,
563+
data_prefix=swc.data_prefix,
564+
)
565+
except SubWorkflowDefinition.DoesNotExist:
566+
pass
553567

554568
cloned_count += 1
555569
except Exception as e:
@@ -654,21 +668,19 @@ def sync_import_admin_view(self, request):
654668

655669
# ── Push/Pull admin actions & views ───────────────────────────────────────
656670

657-
@admin.action(description="Push selected forms to a remote instance")
658-
def push_forms_to_remote(self, request, queryset):
659-
"""Admin action: redirect to the push view with selected form PKs."""
660-
pks = ",".join(str(pk) for pk in queryset.values_list("pk", flat=True))
661-
push_url = reverse("admin:formdefinition_sync_push")
662-
return HttpResponseRedirect(f"{push_url}?pks={pks}")
663-
664671
def sync_pull_admin_view(self, request):
665672
"""Multi-step admin page for pulling form definitions from a remote instance.
666673
667674
Step 0 (GET) – show remote picker (configured remotes + manual URL/token)
668675
Step 1 (POST) – fetch available forms from the remote, show checkbox list
669676
Step 2 (POST) – import selected forms, show results
670677
"""
671-
from .sync_api import fetch_remote_payload, get_sync_remotes, import_payload
678+
from .sync_api import (
679+
build_export_payload,
680+
fetch_remote_payload,
681+
get_sync_remotes,
682+
import_payload,
683+
)
672684

673685
context = dict(self.admin_site.each_context(request))
674686
context["title"] = "Pull Forms from Remote"
@@ -719,11 +731,46 @@ def sync_pull_admin_view(self, request):
719731
)
720732

721733
remote_forms = payload.get("forms", [])
734+
735+
# Build per-form diffs: remote vs local
736+
import json
737+
738+
from .diff_views import _build_summary
739+
740+
form_diffs = []
741+
for rf in remote_forms:
742+
slug = rf.get("form", {}).get("slug", "")
743+
local_fd = FormDefinition.objects.filter(slug=slug).first()
744+
if local_fd:
745+
local_payload = build_export_payload(
746+
FormDefinition.objects.filter(pk=local_fd.pk)
747+
)
748+
local_data = (
749+
local_payload["forms"][0]
750+
if local_payload.get("forms")
751+
else {}
752+
)
753+
local_json = json.dumps(local_data, indent=2, default=str)
754+
remote_json = json.dumps(rf, indent=2, default=str)
755+
summary = _build_summary([local_data, rf])
756+
form_diffs.append(
757+
{
758+
"slug": slug,
759+
"local_json": local_json,
760+
"remote_json": remote_json,
761+
"summary": summary[0] if summary else None,
762+
"is_new": False,
763+
}
764+
)
765+
else:
766+
form_diffs.append({"slug": slug, "is_new": True})
767+
722768
context["step"] = 1
723769
context["remote_url"] = remote_url
724770
context["remote_token"] = remote_token
725771
context["remote_name"] = remote_name
726772
context["remote_forms"] = remote_forms
773+
context["form_diffs"] = form_diffs
727774
return render(
728775
request, "admin/django_forms_workflows/sync_pull.html", context
729776
)
@@ -776,39 +823,31 @@ def sync_pull_admin_view(self, request):
776823
return render(request, "admin/django_forms_workflows/sync_pull.html", context)
777824

778825
def sync_push_admin_view(self, request):
779-
"""Admin page for pushing local form definitions to a remote instance.
826+
"""Multi-step admin page for pushing form definitions to a remote.
780827
781-
Arrives with ``?pks=1,2,3`` (selected from the changelist action) or
782-
without PKs (push all forms).
783-
784-
Step 0 (GET) – show forms to be pushed + remote picker
785-
Step 1 (POST) – execute push, show results
828+
Mirrors the pull flow:
829+
Step 0 (GET) – show remote picker (configured remotes + manual URL/token)
830+
Step 1 (POST) – show all local forms with checkboxes + diff status
831+
Step 2 (POST) – execute push for selected forms, show results
786832
"""
787-
from .sync_api import get_sync_remotes, push_to_remote
833+
from .sync_api import (
834+
build_export_payload,
835+
fetch_remote_payload,
836+
get_sync_remotes,
837+
push_to_remote,
838+
)
788839

789840
context = dict(self.admin_site.each_context(request))
790841
context["title"] = "Push Forms to Remote"
791842
context["opts"] = self.model._meta
792843
context["remotes"] = get_sync_remotes()
793844
context["step"] = 0
794845

795-
# Resolve form queryset from ?pks= query param or POST field
796-
pks_raw = request.GET.get("pks") or request.POST.get("pks", "")
797-
if pks_raw:
798-
try:
799-
pk_list = [int(p) for p in pks_raw.split(",") if p.strip().isdigit()]
800-
except ValueError:
801-
pk_list = []
802-
queryset = self.model.objects.filter(pk__in=pk_list)
803-
else:
804-
queryset = self.model.objects.all()
805-
806-
context["forms_to_push"] = queryset
807-
context["pks"] = pks_raw
808-
809846
if request.method == "POST":
810-
step = request.POST.get("step", "push")
811-
if step == "push":
847+
step = request.POST.get("step", "1")
848+
849+
# ── Step 1: show local forms with diff status ────────────────
850+
if step == "1":
812851
remote_idx = request.POST.get("remote_idx", "")
813852
manual_url = request.POST.get("manual_url", "").strip()
814853
manual_token = request.POST.get("manual_token", "").strip()
@@ -836,24 +875,95 @@ def sync_push_admin_view(self, request):
836875
"Please select a configured remote or enter a URL and token."
837876
)
838877
return render(
839-
request, "admin/django_forms_workflows/sync_push.html", context
878+
request,
879+
"admin/django_forms_workflows/sync_push.html",
880+
context,
840881
)
841882

883+
# Build per-form diffs: local vs remote
884+
885+
from .diff_views import _build_summary
886+
887+
local_qs = self.model.objects.all()
888+
local_payload = build_export_payload(local_qs)
889+
local_forms = local_payload.get("forms", [])
890+
891+
try:
892+
remote_payload = fetch_remote_payload(remote_url, remote_token)
893+
remote_forms_list = remote_payload.get("forms", [])
894+
except Exception:
895+
remote_forms_list = []
896+
897+
remote_by_slug = {
898+
f.get("form", {}).get("slug"): f for f in remote_forms_list
899+
}
900+
form_diffs = []
901+
for lf in local_forms:
902+
slug = lf.get("form", {}).get("slug", "")
903+
name = lf.get("form", {}).get("name", slug)
904+
rf = remote_by_slug.get(slug)
905+
if rf:
906+
summary = _build_summary([rf, lf])
907+
form_diffs.append(
908+
{
909+
"slug": slug,
910+
"name": name,
911+
"summary": summary[0] if summary else None,
912+
"is_new": False,
913+
}
914+
)
915+
else:
916+
form_diffs.append({"slug": slug, "name": name, "is_new": True})
917+
918+
context["step"] = 1
919+
context["remote_url"] = remote_url
920+
context["remote_token"] = remote_token
921+
context["remote_name"] = remote_name
922+
context["conflict"] = conflict
923+
context["local_forms"] = local_forms
924+
context["form_diffs"] = form_diffs
925+
return render(
926+
request,
927+
"admin/django_forms_workflows/sync_push.html",
928+
context,
929+
)
930+
931+
# ── Step 2: push selected forms ──────────────────────────────
932+
if step == "2":
933+
remote_url = request.POST.get("remote_url", "").strip()
934+
remote_token = request.POST.get("remote_token", "").strip()
935+
remote_name = request.POST.get("remote_name", remote_url)
936+
conflict = request.POST.get("conflict", "update")
937+
selected_slugs = request.POST.getlist("slugs")
938+
939+
if not selected_slugs:
940+
context["error"] = "No forms selected."
941+
return render(
942+
request,
943+
"admin/django_forms_workflows/sync_push.html",
944+
context,
945+
)
946+
947+
queryset = self.model.objects.filter(slug__in=selected_slugs)
842948
try:
843949
result = push_to_remote(
844950
remote_url, remote_token, queryset, conflict=conflict
845951
)
846952
except Exception as exc:
847953
context["error"] = f"Push failed: {exc}"
848954
return render(
849-
request, "admin/django_forms_workflows/sync_push.html", context
955+
request,
956+
"admin/django_forms_workflows/sync_push.html",
957+
context,
850958
)
851959

852-
context["step"] = 1
960+
context["step"] = 2
853961
context["remote_name"] = remote_name
854962
context["push_result"] = result
855963
return render(
856-
request, "admin/django_forms_workflows/sync_push.html", context
964+
request,
965+
"admin/django_forms_workflows/sync_push.html",
966+
context,
857967
)
858968

859969
return render(request, "admin/django_forms_workflows/sync_push.html", context)

django_forms_workflows/form_builder_views.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
FormTemplate,
2222
PrefillSource,
2323
StageApprovalGroup,
24+
SubWorkflowDefinition,
2425
WorkflowDefinition,
2526
WorkflowStage,
2627
)
@@ -194,6 +195,20 @@ def form_builder_clone(request, form_id):
194195
group=sag.group,
195196
position=sag.position,
196197
)
198+
# Clone SubWorkflowDefinition if present
199+
try:
200+
swc = wf.sub_workflow_config
201+
SubWorkflowDefinition.objects.create(
202+
parent_workflow=cloned_wf,
203+
sub_workflow=swc.sub_workflow,
204+
count_field=swc.count_field,
205+
section_label=swc.section_label,
206+
label_template=swc.label_template,
207+
trigger=swc.trigger,
208+
data_prefix=swc.data_prefix,
209+
)
210+
except SubWorkflowDefinition.DoesNotExist:
211+
pass
197212

198213
return JsonResponse(
199214
{

django_forms_workflows/templates/admin/django_forms_workflows/formdefinition/change_list.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@
4444
</a>
4545
</li>
4646
<li>
47-
<a href="{% url 'admin:formdefinition_sync_push' %}" class="sync-btn sync-btn-push" title="Push all forms to another instance">
48-
↑ Push All to Remote
47+
<a href="{% url 'admin:formdefinition_sync_push' %}" class="sync-btn sync-btn-push" title="Push forms to another instance">
48+
↑ Push to Remote
4949
</a>
5050
</li>
5151
<li>

django_forms_workflows/templates/admin/django_forms_workflows/sync_pull.html

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,34 @@ <h2>Import Results</h2>
7575
<th>Slug</th>
7676
<th>Name</th>
7777
<th>Category</th>
78+
<th>Status</th>
7879
</tr>
7980
</thead>
8081
<tbody>
8182
{% for form in remote_forms %}
83+
{% with slug=form.form.slug %}
8284
<tr>
83-
<td><input type="checkbox" name="slugs" value="{{ form.form.slug }}" checked></td>
84-
<td><code>{{ form.form.slug }}</code></td>
85+
<td><input type="checkbox" name="slugs" value="{{ slug }}" checked></td>
86+
<td><code>{{ slug }}</code></td>
8587
<td>{{ form.form.name }}</td>
8688
<td>{{ form.category.name|default:"—" }}</td>
89+
<td>
90+
{% for d in form_diffs %}{% if d.slug == slug %}{% if d.is_new %}<span style="color:#1a7f37;font-weight:600;">New</span>{% elif d.summary.identical %}<span style="color:#6e7781;">Identical</span>{% else %}<a href="#" onclick="document.getElementById('diff-{{slug}}').style.display=document.getElementById('diff-{{slug}}').style.display==='none'?'':'none';return false;" style="font-weight:600;">{{ d.summary.diffs|length }} change{{ d.summary.diffs|length|pluralize }}</a>{% endif %}{% endif %}{% endfor %}
91+
</td>
8792
</tr>
93+
{% for d in form_diffs %}{% if d.slug == slug and not d.is_new and not d.summary.identical %}
94+
<tr id="diff-{{ slug }}" style="display:none;">
95+
<td colspan="5" style="padding:8px 16px;background:#f6f8fa;">
96+
{% for item in d.summary.diffs %}
97+
<div style="padding:3px 0;font-size:13px;">
98+
{% if "added" in item|lower %}<span style="background:#1a7f37;color:#fff;padding:1px 6px;border-radius:10px;font-size:11px;font-weight:600;margin-right:6px;">+</span>{% elif "removed" in item|lower %}<span style="background:#cf222e;color:#fff;padding:1px 6px;border-radius:10px;font-size:11px;font-weight:600;margin-right:6px;"></span>{% else %}<span style="background:#9a6700;color:#fff;padding:1px 6px;border-radius:10px;font-size:11px;font-weight:600;margin-right:6px;">Δ</span>{% endif %}
99+
{{ item }}
100+
</div>
101+
{% endfor %}
102+
</td>
103+
</tr>
104+
{% endif %}{% endfor %}
105+
{% endwith %}
88106
{% endfor %}
89107
</tbody>
90108
</table>

0 commit comments

Comments
 (0)