From b5486b6277ca44663c7f36e83556dd3a6398a992 Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Mon, 6 Jul 2026 12:19:38 -0400 Subject: [PATCH 01/10] Wire PHOLD config into admin and configuration page --- net_maestro/core/admin/__init__.py | 2 + .../core/admin/phold_simulation_config.py | 13 +++++ net_maestro/core/constants.py | 9 ++++ net_maestro/core/models/__init__.py | 2 + .../core/models/phold_simulation_config.py | 49 +++++++++++++++++++ .../net_maestro/partials/simulation.html | 20 +++++++- net_maestro/core/views.py | 29 +++++++++-- 7 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 net_maestro/core/admin/phold_simulation_config.py create mode 100644 net_maestro/core/models/phold_simulation_config.py diff --git a/net_maestro/core/admin/__init__.py b/net_maestro/core/admin/__init__.py index 65c5eb3..38138ba 100644 --- a/net_maestro/core/admin/__init__.py +++ b/net_maestro/core/admin/__init__.py @@ -4,6 +4,7 @@ from .event_record import EventRecordAdmin from .model_file import ModelFileAdmin from .model_record import ModelRecordAdmin +from .phold_simulation_config import PHOLDSimulationConfigAdmin from .phold_simulation_lp_record import PHOLDSimulationLpRecordAdmin from .run import RunAdmin from .simulation_file import SimulationFileAdmin @@ -16,6 +17,7 @@ "EventRecordAdmin", "ModelFileAdmin", "ModelRecordAdmin", + "PHOLDSimulationConfigAdmin", "PHOLDSimulationLpRecordAdmin", "RunAdmin", "SimulationFileAdmin", diff --git a/net_maestro/core/admin/phold_simulation_config.py b/net_maestro/core/admin/phold_simulation_config.py new file mode 100644 index 0000000..c5bea5f --- /dev/null +++ b/net_maestro/core/admin/phold_simulation_config.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from django.contrib import admin + +from net_maestro.core.models import PHOLDSimulationConfig + + +@admin.register(PHOLDSimulationConfig) +class PHOLDSimulationConfigAdmin(admin.ModelAdmin): + list_select_related = ["run"] + list_display = ["id", "run", "synch", "nlp", "remote", "mean", "lookahead"] + list_filter = ["synch", "stagger"] + search_fields = ["run__name"] diff --git a/net_maestro/core/constants.py b/net_maestro/core/constants.py index 11070cd..9e9dc96 100644 --- a/net_maestro/core/constants.py +++ b/net_maestro/core/constants.py @@ -24,3 +24,12 @@ class ModelType(models.TextChoices): class TrafficType(models.TextChoices): UNIFORM = "uniform" + + +class SynchProtocol(models.IntegerChoices): + SEQUENTIAL = 1, "Sequential" + CONSERVATIVE = 2, "Conservative" + OPTIMISTIC = 3, "Optimistic" + OPTIMISTIC_DEBUG = 4, "Optimistic Debug" + OPTIMISTIC_REALTIME = 5, "Optimistic Realtime" + REVERSE_HANDLER_CHECK = 6, "Reverse Handler Check" diff --git a/net_maestro/core/models/__init__.py b/net_maestro/core/models/__init__.py index c58d1a7..4ad40b8 100644 --- a/net_maestro/core/models/__init__.py +++ b/net_maestro/core/models/__init__.py @@ -4,6 +4,7 @@ from .event_record import EventRecord from .model_file import ModelFile from .model_record import ModelRecord +from .phold_simulation_config import PHOLDSimulationConfig from .run import Run from .simulation_base_record import SimulationBaseRecord from .simulation_file import SimulationFile @@ -16,6 +17,7 @@ "EventRecord", "ModelFile", "ModelRecord", + "PHOLDSimulationConfig", "PHOLDSimulationLpRecord", "Run", "SimulationBaseRecord", diff --git a/net_maestro/core/models/phold_simulation_config.py b/net_maestro/core/models/phold_simulation_config.py new file mode 100644 index 0000000..8f662fa --- /dev/null +++ b/net_maestro/core/models/phold_simulation_config.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + +from net_maestro.core.constants import SynchProtocol +from net_maestro.core.models.run import Run + + +class PHOLDSimulationConfig(models.Model): + """PHOLD simulation configuration parameters submitted for a Run.""" + + run = models.OneToOneField(Run, on_delete=models.CASCADE, related_name="phold_config") + + synch = models.IntegerField( + choices=SynchProtocol, + default=SynchProtocol.OPTIMISTIC, + verbose_name="Synchronization protocol", + ) + avl_size = models.IntegerField( + default=18, + validators=[MinValueValidator(10), MaxValueValidator(24)], + verbose_name="AVL tree size", + ) + nlp = models.IntegerField( + default=8, validators=[MinValueValidator(1)], verbose_name="LPs per processor" + ) + remote = models.FloatField( + default=0.25, + validators=[MinValueValidator(0.0), MaxValueValidator(1.0)], + verbose_name="Remote event rate", + ) + mean = models.FloatField( + default=1.0, validators=[MinValueValidator(0.1)], verbose_name="Mean timestamp" + ) + mult = models.FloatField( + default=1.4, validators=[MinValueValidator(1.0)], verbose_name="Memory multiplier" + ) + lookahead = models.FloatField(default=1.0, validators=[MinValueValidator(0.1)]) + start_events = models.IntegerField( + default=1, validators=[MinValueValidator(1)], verbose_name="Start events per LP" + ) + memory = models.IntegerField( + default=100, validators=[MinValueValidator(0)], verbose_name="Additional memory buffers" + ) + stagger = models.BooleanField(default=False) + + def __str__(self) -> str: + return f"PHOLD config for Run {self.run_id}" diff --git a/net_maestro/core/templates/net_maestro/partials/simulation.html b/net_maestro/core/templates/net_maestro/partials/simulation.html index 3334d6a..cd800a2 100644 --- a/net_maestro/core/templates/net_maestro/partials/simulation.html +++ b/net_maestro/core/templates/net_maestro/partials/simulation.html @@ -5,7 +5,6 @@ Currently demo-only with hard-coded default values. TODO: Replace with real Django form when simulation models exist. TODO: Add support for other simulation models (Ping Pong, Custom CODES). - TODO: Implement save action to persist simulation configuration. {% endcomment %}
@@ -36,7 +35,24 @@

New Simulation

> Cancel - + +
diff --git a/net_maestro/core/views.py b/net_maestro/core/views.py index b1cde6c..9d9ac52 100644 --- a/net_maestro/core/views.py +++ b/net_maestro/core/views.py @@ -16,7 +16,7 @@ from .constants import RunStatus from .forms import PHOLDSimulationForm -from .models import Run +from .models import PHOLDSimulationConfig, Run from .tasks import run_phold_simulation @@ -298,20 +298,23 @@ def simulation_config(request: HttpRequest) -> HttpResponse: """Render the simulation configuration form and handle submission. GET: Display the form with PHOLD parameters. - POST: Create a Run and trigger the simulation task. + POST: Create a Run and its configuration. Only triggers a task when "Save and Run" + was used; "Save" persists the configuration without running it. """ if request.method == "POST": form = PHOLDSimulationForm(request.POST) if form.is_valid(): + should_run = request.POST.get("action") == "save_and_run" + # Create Run with PENDING status run = Run.objects.create( name=form.cleaned_data["run_identifier"], status=RunStatus.PENDING, ) - # Trigger Celery task to run PHOLD simulation - run_phold_simulation.delay( - run_id=run.id, + # Persist the submitted simulation configuration for this run + PHOLDSimulationConfig.objects.create( + run=run, synch=int(form.cleaned_data["synch"]), avl_size=form.cleaned_data["avl_size"], nlp=form.cleaned_data["nlp"], @@ -324,6 +327,22 @@ def simulation_config(request: HttpRequest) -> HttpResponse: stagger=bool(int(form.cleaned_data["stagger"])), ) + if should_run: + # Trigger Celery task to run PHOLD simulation + run_phold_simulation.delay( + run_id=run.id, + synch=int(form.cleaned_data["synch"]), + avl_size=form.cleaned_data["avl_size"], + nlp=form.cleaned_data["nlp"], + remote=form.cleaned_data["remote"], + mean=form.cleaned_data["mean"], + mult=form.cleaned_data["mult"], + lookahead=form.cleaned_data["lookahead"], + start_events=form.cleaned_data["start_events"], + memory=form.cleaned_data["memory"], + stagger=bool(int(form.cleaned_data["stagger"])), + ) + # Redirect to analysis page return redirect("analysis-partial") else: From b4c2f30145f8e2f97d886f9f72dbd6bcdd711bfd Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Mon, 6 Jul 2026 12:33:00 -0400 Subject: [PATCH 02/10] Split new simulation form and add saved simulations page Creates the dedicated new_simulation.html partial, the saved simulations listing, the accompanying tests, and routes to render both screens. --- .../{simulation.html => new_simulation.html} | 5 +- .../partials/saved_simulations.html | 121 +++++++++++++++++ .../core/tests/test_simulation_views.py | 91 ++++++++++++- net_maestro/core/views.py | 125 +++++++++++++++++- net_maestro/urls.py | 7 +- 5 files changed, 335 insertions(+), 14 deletions(-) rename net_maestro/core/templates/net_maestro/partials/{simulation.html => new_simulation.html} (97%) create mode 100644 net_maestro/core/templates/net_maestro/partials/saved_simulations.html diff --git a/net_maestro/core/templates/net_maestro/partials/simulation.html b/net_maestro/core/templates/net_maestro/partials/new_simulation.html similarity index 97% rename from net_maestro/core/templates/net_maestro/partials/simulation.html rename to net_maestro/core/templates/net_maestro/partials/new_simulation.html index cd800a2..8022f5b 100644 --- a/net_maestro/core/templates/net_maestro/partials/simulation.html +++ b/net_maestro/core/templates/net_maestro/partials/new_simulation.html @@ -3,7 +3,6 @@ {% comment %} Simulation configuration form for PHOLD model. Currently demo-only with hard-coded default values. - TODO: Replace with real Django form when simulation models exist. TODO: Add support for other simulation models (Ping Pong, Custom CODES). {% endcomment %}
@@ -26,8 +25,8 @@

New Simulation

+
+ + +
+ {% for config in configs %} +
+
+
+
+ +
+

{{ config.run.name }}

+

{{ config.run.created }}

+
+
+
+ {% if config.run.status == 'completed' %} +
Completed
+ {% elif config.run.status == 'running' %} +
Running
+ {% elif config.run.status == 'failed' %} +
Failed
+ {% elif config.run.status == 'cancelled' %} +
Cancelled
+ {% else %} +
Pending
+ {% endif %} +
PHOLD
+
+
+ +
+ +
View details
+
+
+
+
Synchronization Protocol
+
{{ config.get_synch_display }}
+
+
+
AVL Tree Size
+
{{ config.avl_size }}
+
+
+
LPs per Processor
+
{{ config.nlp }}
+
+
+
Remote Event Rate
+
{{ config.remote }}
+
+
+
Mean Timestamp
+
{{ config.mean }}
+
+
+
Memory Multiplier
+
{{ config.mult }}
+
+
+
Lookahead
+
{{ config.lookahead }}
+
+
+
Start Events per LP
+
{{ config.start_events }}
+
+
+
Additional Memory Buffers
+
{{ config.memory }}
+
+
+
Stagger Events
+
{{ config.stagger|yesno:"Yes,No" }}
+
+
+
+
+
+
+ {% empty %} +
+
+ +

No saved simulations yet

+

+ Configure a PHOLD simulation to save it here for future reference or re-run. +

+ + New Simulation + +
+
+ {% endfor %} +
+
+
diff --git a/net_maestro/core/tests/test_simulation_views.py b/net_maestro/core/tests/test_simulation_views.py index f8da00a..5f90eb0 100644 --- a/net_maestro/core/tests/test_simulation_views.py +++ b/net_maestro/core/tests/test_simulation_views.py @@ -35,7 +35,7 @@ def authenticated_client(self, client: Client) -> Client: def test_get_simulation_form(self, authenticated_client: Client) -> None: """Test GET request returns simulation form.""" - response = authenticated_client.get(reverse("simulation-config")) + response = authenticated_client.get(reverse("new-simulation-config")) assert response.status_code == 200 assert "form" in response.context @@ -44,12 +44,12 @@ def test_get_simulation_form(self, authenticated_client: Client) -> None: def test_get_simulation_form_htmx(self, authenticated_client: Client) -> None: """Test HTMX GET request returns partial template.""" response = authenticated_client.get( - reverse("simulation-config"), + reverse("new-simulation-config"), HTTP_HX_REQUEST="true", ) assert response.status_code == 200 - assert "net_maestro/partials/simulation.html" in [t.name for t in response.templates] + assert "net_maestro/partials/new_simulation.html" in [t.name for t in response.templates] @mock.patch("net_maestro.core.views.run_phold_simulation") def test_submit_simulation_form( @@ -70,7 +70,7 @@ def test_submit_simulation_form( "stagger": "0", } - response = authenticated_client.post(reverse("simulation-config"), data=form_data) + response = authenticated_client.post(reverse("new-simulation-config"), data=form_data) # Check redirect assert response.status_code == 302 @@ -95,6 +95,40 @@ def test_submit_simulation_form( stagger=False, ) + @mock.patch("net_maestro.core.views.run_phold_simulation") + def test_submit_simulation_form_save_only( + self, mock_task: mock.Mock, authenticated_client: Client + ) -> None: + """Test "Save" submission persists the run/config without triggering the task.""" + form_data = { + "action": "save", + "run_identifier": "Saved Simulation", + "synch": "1", + "avl_size": "18", + "nlp": "8", + "remote": "0.25", + "mean": "1.0", + "mult": "1.4", + "lookahead": "1.0", + "start_events": "1", + "memory": "100", + "stagger": "0", + } + + response = authenticated_client.post(reverse("new-simulation-config"), data=form_data) + + # Check redirect: "Save" returns to the saved simulations list + assert response.status_code == 302 + assert response.url == reverse("simulation-config") # type: ignore[attr-defined] + + # Verify run and configuration were created with PENDING status + run = Run.objects.get(name="Saved Simulation") + assert run.status == RunStatus.PENDING + assert PHOLDSimulationConfig.objects.filter(run=run).exists() + + # Verify the task was NOT triggered + mock_task.delay.assert_not_called() + def test_submit_invalid_form(self, authenticated_client: Client) -> None: """Test invalid form submission returns errors.""" form_data = { @@ -111,7 +145,7 @@ def test_submit_invalid_form(self, authenticated_client: Client) -> None: "stagger": "0", } - response = authenticated_client.post(reverse("simulation-config"), data=form_data) + response = authenticated_client.post(reverse("new-simulation-config"), data=form_data) # Should not redirect assert response.status_code == 200 @@ -124,8 +158,51 @@ def test_submit_invalid_form(self, authenticated_client: Client) -> None: def test_unauthenticated_access(self, client: Client) -> None: """Test unauthenticated access returns the form (public page).""" - response = client.get(reverse("simulation-config")) + response = client.get(reverse("new-simulation-config")) - # simulation-config is publicly accessible + # new-simulation-config is publicly accessible assert response.status_code == 200 assert "form" in response.context + + +@pytest.mark.django_db +class TestSavedSimulationsView: + """Test the saved simulations list view.""" + + @pytest.fixture + def authenticated_client(self, client: Client) -> Client: + """Create an authenticated client.""" + User.objects.create_user(username="testuser", password="testpass") + client.login(username="testuser", password="testpass") + return client + + def test_list_empty(self, authenticated_client: Client) -> None: + """Test the list view renders with no saved configurations.""" + response = authenticated_client.get(reverse("simulation-config")) + + assert response.status_code == 200 + assert list(response.context["configs"]) == [] + assert "net_maestro/index.html" in [t.name for t in response.templates] + + def test_list_htmx(self, authenticated_client: Client) -> None: + """Test HTMX GET request returns the partial template.""" + response = authenticated_client.get( + reverse("simulation-config"), + HTTP_HX_REQUEST="true", + ) + + assert response.status_code == 200 + assert "net_maestro/partials/saved_simulations.html" in [t.name for t in response.templates] + + def test_list_shows_saved_configs(self, authenticated_client: Client) -> None: + """Test saved configurations are listed, most recently created first.""" + older_run = Run.objects.create(name="Older Run", status=RunStatus.PENDING) + PHOLDSimulationConfig.objects.create(run=older_run) + newer_run = Run.objects.create(name="Newer Run", status=RunStatus.PENDING) + PHOLDSimulationConfig.objects.create(run=newer_run) + + response = authenticated_client.get(reverse("simulation-config")) + + assert response.status_code == 200 + configs = list(response.context["configs"]) + assert [config.run_id for config in configs] == [newer_run.id, older_run.id] diff --git a/net_maestro/core/views.py b/net_maestro/core/views.py index 9d9ac52..cd6f281 100644 --- a/net_maestro/core/views.py +++ b/net_maestro/core/views.py @@ -294,6 +294,47 @@ def custom_component_delete(_request: HttpRequest, component_id: int) -> HttpRes return _custom_component_not_implemented(f"delete for component {component_id}") +def _phold_form_initial_from_config(config: PHOLDSimulationConfig) -> dict[str, object]: + return { + "run_identifier": config.run.name, + "synch": config.synch, + "avl_size": config.avl_size, + "nlp": config.nlp, + "remote": config.remote, + "mean": config.mean, + "mult": config.mult, + "lookahead": config.lookahead, + "start_events": config.start_events, + "memory": config.memory, + "stagger": int(config.stagger), + } + + +def _phold_form_data_from_config(config: PHOLDSimulationConfig) -> dict[str, str]: + initial = _phold_form_initial_from_config(config) + return {key: str(value) for key, value in initial.items()} + + +def _phold_form_from_config(config: PHOLDSimulationConfig) -> PHOLDSimulationForm: + return PHOLDSimulationForm(_phold_form_data_from_config(config)) + + +def _run_phold_from_form(run: Run, form: PHOLDSimulationForm) -> None: + run_phold_simulation.delay( + run_id=run.id, + synch=int(form.cleaned_data["synch"]), + avl_size=form.cleaned_data["avl_size"], + nlp=form.cleaned_data["nlp"], + remote=form.cleaned_data["remote"], + mean=form.cleaned_data["mean"], + mult=form.cleaned_data["mult"], + lookahead=form.cleaned_data["lookahead"], + start_events=form.cleaned_data["start_events"], + memory=form.cleaned_data["memory"], + stagger=bool(int(form.cleaned_data["stagger"])), + ) + + def simulation_config(request: HttpRequest) -> HttpResponse: """Render the simulation configuration form and handle submission. @@ -342,14 +383,92 @@ def simulation_config(request: HttpRequest) -> HttpResponse: memory=form.cleaned_data["memory"], stagger=bool(int(form.cleaned_data["stagger"])), ) + # Redirect to the analysis page so the user can watch the run + return redirect("analysis-partial") - # Redirect to analysis page - return redirect("analysis-partial") + # Redirect to the saved simulations list + return redirect("simulation-config") else: form = PHOLDSimulationForm() context: dict[str, object] = {"form": form} - partial_template = "net_maestro/partials/simulation.html" + partial_template = "net_maestro/partials/new_simulation.html" + if request.headers.get("HX-Request"): + return render(request, partial_template, context) + context.update({"active_page": "simulation", "partial_template": partial_template}) + return render(request, "net_maestro/index.html", context) + + +def edit_simulation_config(request: HttpRequest, run_id: int) -> HttpResponse: + config = get_object_or_404(PHOLDSimulationConfig.objects.select_related("run"), run_id=run_id) + + if request.method == "POST": + form = PHOLDSimulationForm(request.POST) + if form.is_valid(): + should_run = request.POST.get("action") == "save_and_run" + run_status = RunStatus.PENDING if should_run else RunStatus.SAVED + run = Run.objects.create( + name=form.cleaned_data["run_identifier"], + status=run_status, + ) + + PHOLDSimulationConfig.objects.create( + run=run, + synch=int(form.cleaned_data["synch"]), + avl_size=form.cleaned_data["avl_size"], + nlp=form.cleaned_data["nlp"], + remote=form.cleaned_data["remote"], + mean=form.cleaned_data["mean"], + mult=form.cleaned_data["mult"], + lookahead=form.cleaned_data["lookahead"], + start_events=form.cleaned_data["start_events"], + memory=form.cleaned_data["memory"], + stagger=bool(int(form.cleaned_data["stagger"])), + ) + + if should_run: + _run_phold_from_form(run, form) + return redirect("analysis-partial") + + return redirect("simulation-config") + else: + form = PHOLDSimulationForm(initial=_phold_form_initial_from_config(config)) + + context: dict[str, object] = { + "form": form, + "form_action": request.path, + "page_heading": "Edit Simulation", + "breadcrumb_label": "Edit Simulation", + } + partial_template = "net_maestro/partials/new_simulation.html" + if request.headers.get("HX-Request"): + return render(request, partial_template, context) + context.update({"active_page": "simulation", "partial_template": partial_template}) + return render(request, "net_maestro/index.html", context) + + +@require_POST +def run_saved_simulation(request: HttpRequest, run_id: int) -> HttpResponse: + run = get_object_or_404(Run.objects.select_related("phold_config"), pk=run_id) + config = run.phold_config + + form = _phold_form_from_config(config) + if not form.is_valid(): + return redirect("simulation-config") + run.status = RunStatus.PENDING + run.save(update_fields=["status"]) + _run_phold_from_form(run, form) + return redirect("analysis-partial") + + +def saved_simulations(request: HttpRequest) -> HttpResponse: + """Render the list of saved PHOLD simulation configurations. + + GET: Display all saved simulation configurations, most recently created first. + """ + configs = PHOLDSimulationConfig.objects.select_related("run").order_by("-run__created") + context: dict[str, object] = {"configs": configs} + partial_template = "net_maestro/partials/saved_simulations.html" if request.headers.get("HX-Request"): return render(request, partial_template, context) context.update({"active_page": "simulation", "partial_template": partial_template}) diff --git a/net_maestro/urls.py b/net_maestro/urls.py index b35b673..ba5e245 100644 --- a/net_maestro/urls.py +++ b/net_maestro/urls.py @@ -75,9 +75,14 @@ ), path( "simulation/config", - views.simulation_config, + views.saved_simulations, name="simulation-config", ), + path( + "simulation/new-config", + views.simulation_config, + name="new-simulation-config", + ), ] if settings.DEBUG: From ca99f523095c191b044c0e829ea0ab67f289343a Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Mon, 6 Jul 2026 12:46:17 -0400 Subject: [PATCH 03/10] Track SAVED runs separately from pending runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SAVED RunStatus, default updates, and filtering tweaks so saved-only configs don’t show in the analysis sidebar unless requested. --- net_maestro/core/constants.py | 1 + net_maestro/core/models/run.py | 2 +- net_maestro/core/views.py | 14 +++++++++----- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/net_maestro/core/constants.py b/net_maestro/core/constants.py index 9e9dc96..8b263ce 100644 --- a/net_maestro/core/constants.py +++ b/net_maestro/core/constants.py @@ -4,6 +4,7 @@ class RunStatus(models.TextChoices): + SAVED = "saved" PENDING = "pending" RUNNING = "running" COMPLETED = "completed" diff --git a/net_maestro/core/models/run.py b/net_maestro/core/models/run.py index df991af..c1114f8 100644 --- a/net_maestro/core/models/run.py +++ b/net_maestro/core/models/run.py @@ -9,7 +9,7 @@ class Run(models.Model): created = models.DateTimeField(auto_now_add=True) name = models.CharField(max_length=200) description = models.TextField(blank=True, default="") - status = models.CharField(max_length=20, choices=RunStatus, default=RunStatus.PENDING) + status = models.CharField(max_length=20, choices=RunStatus, default=RunStatus.SAVED) def __str__(self): return f"Run {self.id}: {self.name} ({self.status})" diff --git a/net_maestro/core/views.py b/net_maestro/core/views.py index cd6f281..1a66448 100644 --- a/net_maestro/core/views.py +++ b/net_maestro/core/views.py @@ -180,9 +180,11 @@ def _filtered_runs(request: HttpRequest) -> dict[str, object]: """Return runs queryset filtered by ?status= and ?q= params.""" statuses = request.GET.getlist("status") search_query = request.GET.get("q", "").strip() - runs = Run.objects.all() - if statuses: - runs = runs.filter(status__in=statuses) + runs = ( + Run.objects.filter(status__in=statuses) + if statuses + else Run.objects.exclude(status=RunStatus.SAVED) + ) if search_query: runs = runs.filter(name__icontains=search_query) return { @@ -347,10 +349,12 @@ def simulation_config(request: HttpRequest) -> HttpResponse: if form.is_valid(): should_run = request.POST.get("action") == "save_and_run" - # Create Run with PENDING status + run_status = RunStatus.PENDING if should_run else RunStatus.SAVED + + # Create Run with appropriate status run = Run.objects.create( name=form.cleaned_data["run_identifier"], - status=RunStatus.PENDING, + status=run_status, ) # Persist the submitted simulation configuration for this run From 0715f8d7711083f7e7280121723eb1a3d39d42a6 Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Mon, 6 Jul 2026 15:46:29 -0400 Subject: [PATCH 04/10] Allow running/editing saved simulations from cards Introduces new edit/run views, URL wiring, and template adjustments so saved runs can be re-used. --- .../net_maestro/partials/new_simulation.html | 6 +-- .../partials/saved_simulations.html | 39 ++++++++++++------- net_maestro/core/views.py | 25 +++++------- net_maestro/urls.py | 10 +++++ 4 files changed, 47 insertions(+), 33 deletions(-) diff --git a/net_maestro/core/templates/net_maestro/partials/new_simulation.html b/net_maestro/core/templates/net_maestro/partials/new_simulation.html index 8022f5b..d685425 100644 --- a/net_maestro/core/templates/net_maestro/partials/new_simulation.html +++ b/net_maestro/core/templates/net_maestro/partials/new_simulation.html @@ -19,9 +19,9 @@ Simulations - New Simulation + {{ breadcrumb_label|default:"New Simulation" }} -

New Simulation

+

{{ page_heading|default:"New Simulation" }}

-
+ {% csrf_token %} -
+
{% for config in configs %}
@@ -29,19 +29,30 @@

{{ config.run.name }}

{{ config.run.created }}

-
- {% if config.run.status == 'completed' %} -
Completed
- {% elif config.run.status == 'running' %} -
Running
- {% elif config.run.status == 'failed' %} -
Failed
- {% elif config.run.status == 'cancelled' %} -
Cancelled
- {% else %} -
Pending
- {% endif %} +
PHOLD
+ + {% csrf_token %} + + + + + Edit +
@@ -96,7 +107,7 @@

{{ config.run.name }}

{% empty %} -
+

No saved simulations yet

diff --git a/net_maestro/core/views.py b/net_maestro/core/views.py index 1a66448..8ecdbf2 100644 --- a/net_maestro/core/views.py +++ b/net_maestro/core/views.py @@ -9,7 +9,8 @@ from typing import TYPE_CHECKING from django.http import HttpResponse -from django.shortcuts import redirect, render +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_POST if TYPE_CHECKING: from django.http import HttpRequest @@ -373,20 +374,7 @@ def simulation_config(request: HttpRequest) -> HttpResponse: ) if should_run: - # Trigger Celery task to run PHOLD simulation - run_phold_simulation.delay( - run_id=run.id, - synch=int(form.cleaned_data["synch"]), - avl_size=form.cleaned_data["avl_size"], - nlp=form.cleaned_data["nlp"], - remote=form.cleaned_data["remote"], - mean=form.cleaned_data["mean"], - mult=form.cleaned_data["mult"], - lookahead=form.cleaned_data["lookahead"], - start_events=form.cleaned_data["start_events"], - memory=form.cleaned_data["memory"], - stagger=bool(int(form.cleaned_data["stagger"])), - ) + _run_phold_from_form(run, form) # Redirect to the analysis page so the user can watch the run return redirect("analysis-partial") @@ -395,7 +383,12 @@ def simulation_config(request: HttpRequest) -> HttpResponse: else: form = PHOLDSimulationForm() - context: dict[str, object] = {"form": form} + context: dict[str, object] = { + "form": form, + "form_action": request.path, + "page_heading": "New Simulation", + "breadcrumb_label": "New Simulation", + } partial_template = "net_maestro/partials/new_simulation.html" if request.headers.get("HX-Request"): return render(request, partial_template, context) diff --git a/net_maestro/urls.py b/net_maestro/urls.py index ba5e245..43d37ef 100644 --- a/net_maestro/urls.py +++ b/net_maestro/urls.py @@ -83,6 +83,16 @@ views.simulation_config, name="new-simulation-config", ), + path( + "simulation/edit-config/", + views.edit_simulation_config, + name="edit-simulation-config", + ), + path( + "simulation/run/", + views.run_saved_simulation, + name="run-saved-simulation", + ), ] if settings.DEBUG: From f96234cf99725ab2298dfb54b0c45052418eed4e Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Mon, 6 Jul 2026 12:19:54 -0400 Subject: [PATCH 05/10] Expand simulation view tests for saved configs --- .../core/templates/net_maestro/index.html | 9 + .../core/tests/test_simulation_views.py | 205 +++++++++++++++++- net_maestro/core/views.py | 76 ++++--- 3 files changed, 241 insertions(+), 49 deletions(-) diff --git a/net_maestro/core/templates/net_maestro/index.html b/net_maestro/core/templates/net_maestro/index.html index 1fd4fe3..26e4a48 100644 --- a/net_maestro/core/templates/net_maestro/index.html +++ b/net_maestro/core/templates/net_maestro/index.html @@ -39,6 +39,15 @@ {% include 'net_maestro/shared/sidebar.html' %}
+ {% if messages %} +
+ {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} {% if partial_template %} {% include partial_template %} {% else %} diff --git a/net_maestro/core/tests/test_simulation_views.py b/net_maestro/core/tests/test_simulation_views.py index 5f90eb0..67a9716 100644 --- a/net_maestro/core/tests/test_simulation_views.py +++ b/net_maestro/core/tests/test_simulation_views.py @@ -8,15 +8,17 @@ from __future__ import annotations +from datetime import timedelta from typing import TYPE_CHECKING from unittest import mock from django.contrib.auth.models import User from django.urls import reverse +from django.utils import timezone import pytest from net_maestro.core.constants import RunStatus -from net_maestro.core.models import Run +from net_maestro.core.models import PHOLDSimulationConfig, Run if TYPE_CHECKING: from django.test import Client @@ -52,11 +54,12 @@ def test_get_simulation_form_htmx(self, authenticated_client: Client) -> None: assert "net_maestro/partials/new_simulation.html" in [t.name for t in response.templates] @mock.patch("net_maestro.core.views.run_phold_simulation") - def test_submit_simulation_form( + def test_submit_simulation_form_save_and_run( self, mock_task: mock.Mock, authenticated_client: Client ) -> None: - """Test successful form submission creates run with PENDING status.""" + """Test "Save and Run" submission creates run/config and triggers the task.""" form_data = { + "action": "save_and_run", "run_identifier": "Test Simulation", "synch": "1", "avl_size": "18", @@ -72,14 +75,19 @@ def test_submit_simulation_form( response = authenticated_client.post(reverse("new-simulation-config"), data=form_data) - # Check redirect + # Save & Run redirects to analysis so users can monitor the run immediately. assert response.status_code == 302 - assert response.url == reverse("analysis-partial") # type: ignore[attr-defined] + assert response.headers["Location"] == reverse("analysis-partial") # Verify run was created with PENDING status run = Run.objects.get(name="Test Simulation") assert run.status == RunStatus.PENDING + # Verify the configuration was persisted + config = PHOLDSimulationConfig.objects.get(run=run) + assert config.synch == 1 + assert config.avl_size == 18 + # Verify task was called with correct arguments mock_task.delay.assert_called_once_with( run_id=run.id, @@ -99,7 +107,7 @@ def test_submit_simulation_form( def test_submit_simulation_form_save_only( self, mock_task: mock.Mock, authenticated_client: Client ) -> None: - """Test "Save" submission persists the run/config without triggering the task.""" + """Save-only submissions stay on the saved simulations page and skip the task.""" form_data = { "action": "save", "run_identifier": "Saved Simulation", @@ -117,13 +125,13 @@ def test_submit_simulation_form_save_only( response = authenticated_client.post(reverse("new-simulation-config"), data=form_data) - # Check redirect: "Save" returns to the saved simulations list + # Check redirect assert response.status_code == 302 - assert response.url == reverse("simulation-config") # type: ignore[attr-defined] + assert response.headers["Location"] == reverse("simulation-config") - # Verify run and configuration were created with PENDING status + # Verify run and configuration were created with SAVED status run = Run.objects.get(name="Saved Simulation") - assert run.status == RunStatus.PENDING + assert run.status == RunStatus.SAVED assert PHOLDSimulationConfig.objects.filter(run=run).exists() # Verify the task was NOT triggered @@ -197,8 +205,12 @@ def test_list_htmx(self, authenticated_client: Client) -> None: def test_list_shows_saved_configs(self, authenticated_client: Client) -> None: """Test saved configurations are listed, most recently created first.""" older_run = Run.objects.create(name="Older Run", status=RunStatus.PENDING) + older_run.created = timezone.now() - timedelta(days=1) + older_run.save(update_fields=["created"]) PHOLDSimulationConfig.objects.create(run=older_run) newer_run = Run.objects.create(name="Newer Run", status=RunStatus.PENDING) + newer_run.created = timezone.now() + newer_run.save(update_fields=["created"]) PHOLDSimulationConfig.objects.create(run=newer_run) response = authenticated_client.get(reverse("simulation-config")) @@ -206,3 +218,176 @@ def test_list_shows_saved_configs(self, authenticated_client: Client) -> None: assert response.status_code == 200 configs = list(response.context["configs"]) assert [config.run_id for config in configs] == [newer_run.id, older_run.id] + + +@pytest.mark.django_db +class TestEditSimulationView: + @pytest.fixture + def authenticated_client(self, client: Client) -> Client: + User.objects.create_user(username="testuser", password="testpass") + client.login(username="testuser", password="testpass") + return client + + def _create_config(self) -> PHOLDSimulationConfig: + run = Run.objects.create(name="Original Run", status=RunStatus.SAVED) + return PHOLDSimulationConfig.objects.create( + run=run, + synch=3, + avl_size=18, + nlp=8, + remote=0.25, + mean=1.0, + mult=1.4, + lookahead=1.0, + start_events=1, + memory=100, + stagger=False, + ) + + def test_edit_get_prefills_form(self, authenticated_client: Client) -> None: + config = self._create_config() + + response = authenticated_client.get(reverse("edit-simulation-config", args=[config.run.id])) + + assert response.status_code == 200 + form = response.context["form"] + assert form["run_identifier"].value() == "Original Run" + assert form["avl_size"].value() == 18 + + @mock.patch("net_maestro.core.views.run_phold_simulation") + def test_edit_save_updates_config( + self, mock_task: mock.Mock, authenticated_client: Client + ) -> None: + config = self._create_config() + form_data = { + "action": "save", + "run_identifier": "Updated Run", + "synch": "2", + "avl_size": "20", + "nlp": "10", + "remote": "0.5", + "mean": "2.0", + "mult": "1.8", + "lookahead": "1.2", + "start_events": "3", + "memory": "200", + "stagger": "1", + } + + response = authenticated_client.post( + reverse("edit-simulation-config", args=[config.run.id]), + data=form_data, + ) + + assert response.status_code == 302 + assert response.headers["Location"] == reverse("simulation-config") + + config.refresh_from_db() + new_run = Run.objects.get(name="Updated Run") + assert new_run.status == RunStatus.SAVED + new_config = new_run.phold_config + assert new_config.avl_size == 20 + assert new_config.stagger is True + # Original config remains unchanged + config.refresh_from_db() + assert config.run.name == "Original Run" + assert config.avl_size == 18 + mock_task.delay.assert_not_called() + + def test_edit_clone_shows_both_runs_in_saved_list(self, authenticated_client: Client) -> None: + config = self._create_config() + form_data = { + "action": "save", + "run_identifier": "Cloned Run", + "synch": "2", + "avl_size": "20", + "nlp": "9", + "remote": "0.4", + "mean": "1.5", + "mult": "1.7", + "lookahead": "1.1", + "start_events": "2", + "memory": "150", + "stagger": "0", + } + + response = authenticated_client.post( + reverse("edit-simulation-config", args=[config.run.id]), + data=form_data, + ) + + assert response.status_code == 302 + + list_response = authenticated_client.get(reverse("simulation-config")) + assert list_response.status_code == 200 + run_names = [cfg.run.name for cfg in list_response.context["configs"]] + assert run_names == ["Cloned Run", "Original Run"] + + @mock.patch("net_maestro.core.views.run_phold_simulation") + def test_edit_save_and_run_triggers_task( + self, mock_task: mock.Mock, authenticated_client: Client + ) -> None: + config = self._create_config() + form_data = { + "action": "save_and_run", + "run_identifier": "Run Again", + "synch": "3", + "avl_size": "18", + "nlp": "8", + "remote": "0.25", + "mean": "1.0", + "mult": "1.4", + "lookahead": "1.0", + "start_events": "1", + "memory": "100", + "stagger": "0", + } + + response = authenticated_client.post( + reverse("edit-simulation-config", args=[config.run.id]), + data=form_data, + ) + + assert response.status_code == 302 + assert response.headers["Location"] == reverse("analysis-partial") + + new_run = Run.objects.get(name="Run Again") + assert new_run.status == RunStatus.PENDING + mock_task.delay.assert_called_once() + + @mock.patch("net_maestro.core.views.run_phold_simulation") + def test_run_saved_simulation_shortcut( + self, mock_task: mock.Mock, authenticated_client: Client + ) -> None: + config = self._create_config() + + response = authenticated_client.post(reverse("run-saved-simulation", args=[config.run.id])) + + assert response.status_code == 302 + assert response.headers["Location"] == reverse("analysis-partial") + + config.run.refresh_from_db() + assert config.run.status == RunStatus.PENDING + mock_task.delay.assert_called_once() + + @mock.patch("net_maestro.core.views.run_phold_simulation") + def test_run_saved_simulation_invalid_config_shows_error( + self, mock_task: mock.Mock, authenticated_client: Client + ) -> None: + """An invalid saved config surfaces an error message instead of failing silently.""" + config = self._create_config() + config.avl_size = 5 # Below the form/model MinValueValidator(10) + config.save(update_fields=["avl_size"]) + + response = authenticated_client.post( + reverse("run-saved-simulation", args=[config.run.id]), follow=True + ) + + assert response.status_code == 200 + assert response.redirect_chain[-1] == (reverse("simulation-config"), 302) + messages = [str(m) for m in response.context["messages"]] + assert any("Unable to run" in message for message in messages) + + config.run.refresh_from_db() + assert config.run.status == RunStatus.SAVED + mock_task.delay.assert_not_called() diff --git a/net_maestro/core/views.py b/net_maestro/core/views.py index 8ecdbf2..0b3164c 100644 --- a/net_maestro/core/views.py +++ b/net_maestro/core/views.py @@ -6,8 +6,11 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING +from django.contrib import messages +from django.db import transaction from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_POST @@ -20,6 +23,8 @@ from .models import PHOLDSimulationConfig, Run from .tasks import run_phold_simulation +logger = logging.getLogger(__name__) + def _custom_component_context() -> dict[str, object]: """Return custom component context for the configuration page. @@ -338,6 +343,30 @@ def _run_phold_from_form(run: Run, form: PHOLDSimulationForm) -> None: ) +def _create_run_and_config(form: PHOLDSimulationForm, run_status: RunStatus) -> Run: + """Create a Run and its PHOLDSimulationConfig atomically from validated form data.""" + with transaction.atomic(): + run = Run.objects.create( + name=form.cleaned_data["run_identifier"], + status=run_status, + ) + + PHOLDSimulationConfig.objects.create( + run=run, + synch=int(form.cleaned_data["synch"]), + avl_size=form.cleaned_data["avl_size"], + nlp=form.cleaned_data["nlp"], + remote=form.cleaned_data["remote"], + mean=form.cleaned_data["mean"], + mult=form.cleaned_data["mult"], + lookahead=form.cleaned_data["lookahead"], + start_events=form.cleaned_data["start_events"], + memory=form.cleaned_data["memory"], + stagger=bool(int(form.cleaned_data["stagger"])), + ) + return run + + def simulation_config(request: HttpRequest) -> HttpResponse: """Render the simulation configuration form and handle submission. @@ -351,27 +380,7 @@ def simulation_config(request: HttpRequest) -> HttpResponse: should_run = request.POST.get("action") == "save_and_run" run_status = RunStatus.PENDING if should_run else RunStatus.SAVED - - # Create Run with appropriate status - run = Run.objects.create( - name=form.cleaned_data["run_identifier"], - status=run_status, - ) - - # Persist the submitted simulation configuration for this run - PHOLDSimulationConfig.objects.create( - run=run, - synch=int(form.cleaned_data["synch"]), - avl_size=form.cleaned_data["avl_size"], - nlp=form.cleaned_data["nlp"], - remote=form.cleaned_data["remote"], - mean=form.cleaned_data["mean"], - mult=form.cleaned_data["mult"], - lookahead=form.cleaned_data["lookahead"], - start_events=form.cleaned_data["start_events"], - memory=form.cleaned_data["memory"], - stagger=bool(int(form.cleaned_data["stagger"])), - ) + run = _create_run_and_config(form, run_status) if should_run: _run_phold_from_form(run, form) @@ -404,24 +413,7 @@ def edit_simulation_config(request: HttpRequest, run_id: int) -> HttpResponse: if form.is_valid(): should_run = request.POST.get("action") == "save_and_run" run_status = RunStatus.PENDING if should_run else RunStatus.SAVED - run = Run.objects.create( - name=form.cleaned_data["run_identifier"], - status=run_status, - ) - - PHOLDSimulationConfig.objects.create( - run=run, - synch=int(form.cleaned_data["synch"]), - avl_size=form.cleaned_data["avl_size"], - nlp=form.cleaned_data["nlp"], - remote=form.cleaned_data["remote"], - mean=form.cleaned_data["mean"], - mult=form.cleaned_data["mult"], - lookahead=form.cleaned_data["lookahead"], - start_events=form.cleaned_data["start_events"], - memory=form.cleaned_data["memory"], - stagger=bool(int(form.cleaned_data["stagger"])), - ) + run = _create_run_and_config(form, run_status) if should_run: _run_phold_from_form(run, form) @@ -451,6 +443,12 @@ def run_saved_simulation(request: HttpRequest, run_id: int) -> HttpResponse: form = _phold_form_from_config(config) if not form.is_valid(): + logger.warning( + "Saved config for run %s failed re-validation and could not be run: %s", + run_id, + form.errors.as_text(), + ) + messages.error(request, f'Unable to run "{run.name}": saved configuration is invalid.') return redirect("simulation-config") run.status = RunStatus.PENDING run.save(update_fields=["status"]) From 072c6fda833063e4946c019ab5949b59e6b5d8fc Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Tue, 7 Jul 2026 10:10:08 -0400 Subject: [PATCH 06/10] Add PHOLD config migration --- ..._alter_run_status_pholdsimulationconfig.py | 134 ++++++++++++++++++ .../core/models/phold_simulation_config.py | 7 +- 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 net_maestro/core/migrations/0010_alter_run_status_pholdsimulationconfig.py diff --git a/net_maestro/core/migrations/0010_alter_run_status_pholdsimulationconfig.py b/net_maestro/core/migrations/0010_alter_run_status_pholdsimulationconfig.py new file mode 100644 index 0000000..d063e07 --- /dev/null +++ b/net_maestro/core/migrations/0010_alter_run_status_pholdsimulationconfig.py @@ -0,0 +1,134 @@ +# Generated by Django 6.0.6 on 2026-07-07 14:07 +from __future__ import annotations + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0009_pholdsimulationlprecord"), + ] + + operations = [ + migrations.AlterField( + model_name="run", + name="status", + field=models.CharField( + choices=[ + ("saved", "Saved"), + ("pending", "Pending"), + ("running", "Running"), + ("completed", "Completed"), + ("failed", "Failed"), + ("cancelled", "Cancelled"), + ], + default="saved", + max_length=20, + ), + ), + migrations.CreateModel( + name="PHOLDSimulationConfig", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "synch", + models.IntegerField( + choices=[ + (1, "Sequential"), + (2, "Conservative"), + (3, "Optimistic"), + (4, "Optimistic Debug"), + (5, "Optimistic Realtime"), + (6, "Reverse Handler Check"), + ], + default=3, + verbose_name="Synchronization protocol", + ), + ), + ( + "avl_size", + models.IntegerField( + default=18, + validators=[ + django.core.validators.MinValueValidator(10), + django.core.validators.MaxValueValidator(24), + ], + verbose_name="AVL tree size", + ), + ), + ( + "nlp", + models.IntegerField( + default=8, + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="LPs per processor", + ), + ), + ( + "remote", + models.FloatField( + default=0.25, + validators=[ + django.core.validators.MinValueValidator(0.0), + django.core.validators.MaxValueValidator(1.0), + ], + verbose_name="Remote event rate", + ), + ), + ( + "mean", + models.FloatField( + default=1.0, + validators=[django.core.validators.MinValueValidator(0.1)], + verbose_name="Mean timestamp", + ), + ), + ( + "mult", + models.FloatField( + default=1.4, + validators=[django.core.validators.MinValueValidator(1.0)], + verbose_name="Memory multiplier", + ), + ), + ( + "lookahead", + models.FloatField( + default=1.0, validators=[django.core.validators.MinValueValidator(0.1)] + ), + ), + ( + "start_events", + models.IntegerField( + default=1, + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="Start events per LP", + ), + ), + ( + "memory", + models.IntegerField( + default=100, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Additional memory buffers", + ), + ), + ("stagger", models.BooleanField(default=False)), + ( + "run", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="phold_config", + to="core.run", + ), + ), + ], + ), + ] diff --git a/net_maestro/core/models/phold_simulation_config.py b/net_maestro/core/models/phold_simulation_config.py index 8f662fa..00609ac 100644 --- a/net_maestro/core/models/phold_simulation_config.py +++ b/net_maestro/core/models/phold_simulation_config.py @@ -8,7 +8,12 @@ class PHOLDSimulationConfig(models.Model): - """PHOLD simulation configuration parameters submitted for a Run.""" + """PHOLD simulation configuration parameters submitted for a Run. + + NOTE: This model currently handles the single PHOLD scenario. As we support more + simulation types, this should be generalized so we can store various parameter sets + without duplicating schema for each model. + """ run = models.OneToOneField(Run, on_delete=models.CASCADE, related_name="phold_config") From 322b42307c8735445d7af08227296fb3279e8b0f Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Tue, 7 Jul 2026 16:31:43 -0400 Subject: [PATCH 07/10] Convert PHOLDSimulationForm to a ModelForm - convert to a forms.ModelForm bound to PHOLDSimulationConfig so validators live in a single place - ModelForm._post_clean() now runs full_clean() automatically - run_identifier and stagger remain explicitly declared since they don't map directly to a model field/widget - build the config via form.save(commit=False) instead of manually listing every field --- net_maestro/core/forms.py | 143 ++++++++++++++++++-------------------- net_maestro/core/views.py | 88 ++++++++--------------- 2 files changed, 96 insertions(+), 135 deletions(-) diff --git a/net_maestro/core/forms.py b/net_maestro/core/forms.py index d5e0190..0d26132 100644 --- a/net_maestro/core/forms.py +++ b/net_maestro/core/forms.py @@ -2,14 +2,22 @@ from __future__ import annotations +from typing import Any + from django import forms -from django.core.validators import MaxValueValidator, MinValueValidator +from .models import PHOLDSimulationConfig + + +class PHOLDSimulationForm(forms.ModelForm): + """Form for PHOLD simulation parameters. -class PHOLDSimulationForm(forms.Form): - """Form for PHOLD simulation parameters.""" + A ModelForm bound to PHOLDSimulationConfig so field validators are not duplicated + between the model and a plain Form. + """ - # Simulation Model section + # run_identifier maps to Run.name, not a PHOLDSimulationConfig field, + # so it must be declared explicitly. run_identifier = forms.CharField( label="Run Identifier", max_length=200, @@ -17,81 +25,62 @@ class PHOLDSimulationForm(forms.Form): widget=forms.TextInput(attrs={"class": "input input-bordered w-full"}), ) - # Engine Parameters - synch = forms.ChoiceField( - label="Synchronization Protocol", - choices=[ - (1, "Sequential"), - (2, "Conservative"), - (3, "Optimistic"), - (4, "Optimistic Debug"), - (5, "Optimistic Realtime"), - (6, "Reverse Handler Check"), - ], - initial=3, - widget=forms.Select(attrs={"class": "select select-bordered w-full"}), - ) - - avl_size = forms.IntegerField( - label="AVL Tree Size", - initial=18, - validators=[MinValueValidator(10), MaxValueValidator(24)], - widget=forms.NumberInput(attrs={"class": "input input-bordered w-full"}), - ) - - # Model Parameters - nlp = forms.IntegerField( - label="LPs per Processor", - initial=8, - validators=[MinValueValidator(1)], - widget=forms.NumberInput(attrs={"class": "input input-bordered w-full"}), - ) - - remote = forms.FloatField( - label="Remote Event Rate", - initial=0.25, - validators=[MinValueValidator(0.0), MaxValueValidator(1.0)], - widget=forms.NumberInput(attrs={"class": "input input-bordered w-full", "step": "0.01"}), - ) - - mean = forms.FloatField( - label="Mean Timestamp", - initial=1.0, - validators=[MinValueValidator(0.1)], - widget=forms.NumberInput(attrs={"class": "input input-bordered w-full", "step": "0.1"}), - ) - - mult = forms.FloatField( - label="Memory Multiplier", - initial=1.4, - validators=[MinValueValidator(1.0)], - widget=forms.NumberInput(attrs={"class": "input input-bordered w-full", "step": "0.1"}), - ) - - lookahead = forms.FloatField( - label="Lookahead", - initial=1.0, - validators=[MinValueValidator(0.1)], - widget=forms.NumberInput(attrs={"class": "input input-bordered w-full", "step": "0.1"}), - ) - - start_events = forms.IntegerField( - label="Start Events per LP", - initial=1, - validators=[MinValueValidator(1)], - widget=forms.NumberInput(attrs={"class": "input input-bordered w-full"}), - ) - - memory = forms.IntegerField( - label="Additional Memory Buffers", - initial=100, - validators=[MinValueValidator(0)], - widget=forms.NumberInput(attrs={"class": "input input-bordered w-full"}), - ) - - stagger = forms.ChoiceField( + # Rendered as a Select (Yes/No) rather than the default checkbox widget. + stagger = forms.TypedChoiceField( label="Stagger Events", choices=[(0, "No"), (1, "Yes")], + coerce=lambda value: str(value) == "1", initial=0, widget=forms.Select(attrs={"class": "select select-bordered w-full"}), ) + + class Meta: + model = PHOLDSimulationConfig + fields = [ + "synch", + "avl_size", + "nlp", + "remote", + "mean", + "mult", + "lookahead", + "start_events", + "memory", + "stagger", + ] + labels = { + "synch": "Synchronization Protocol", + "avl_size": "AVL Tree Size", + "nlp": "LPs per Processor", + "remote": "Remote Event Rate", + "mean": "Mean Timestamp", + "mult": "Memory Multiplier", + "start_events": "Start Events per LP", + "memory": "Additional Memory Buffers", + } + widgets = { + "synch": forms.Select(attrs={"class": "select select-bordered w-full"}), + "avl_size": forms.NumberInput(attrs={"class": "input input-bordered w-full"}), + "nlp": forms.NumberInput(attrs={"class": "input input-bordered w-full"}), + "remote": forms.NumberInput( + attrs={"class": "input input-bordered w-full", "step": "0.01"} + ), + "mean": forms.NumberInput( + attrs={"class": "input input-bordered w-full", "step": "0.1"} + ), + "mult": forms.NumberInput( + attrs={"class": "input input-bordered w-full", "step": "0.1"} + ), + "lookahead": forms.NumberInput( + attrs={"class": "input input-bordered w-full", "step": "0.1"} + ), + "start_events": forms.NumberInput(attrs={"class": "input input-bordered w-full"}), + "memory": forms.NumberInput(attrs={"class": "input input-bordered w-full"}), + } + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + # model_to_dict() surfaces the model's boolean value for stagger, + # but the widget's choices are keyed by 0/1. + if "stagger" in self.initial: + self.initial["stagger"] = int(bool(self.initial["stagger"])) diff --git a/net_maestro/core/views.py b/net_maestro/core/views.py index 0b3164c..c05d58a 100644 --- a/net_maestro/core/views.py +++ b/net_maestro/core/views.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING from django.contrib import messages +from django.core.exceptions import ValidationError from django.db import transaction from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render @@ -303,68 +304,38 @@ def custom_component_delete(_request: HttpRequest, component_id: int) -> HttpRes def _phold_form_initial_from_config(config: PHOLDSimulationConfig) -> dict[str, object]: - return { - "run_identifier": config.run.name, - "synch": config.synch, - "avl_size": config.avl_size, - "nlp": config.nlp, - "remote": config.remote, - "mean": config.mean, - "mult": config.mult, - "lookahead": config.lookahead, - "start_events": config.start_events, - "memory": config.memory, - "stagger": int(config.stagger), - } - - -def _phold_form_data_from_config(config: PHOLDSimulationConfig) -> dict[str, str]: - initial = _phold_form_initial_from_config(config) - return {key: str(value) for key, value in initial.items()} + return {"run_identifier": config.run.name} -def _phold_form_from_config(config: PHOLDSimulationConfig) -> PHOLDSimulationForm: - return PHOLDSimulationForm(_phold_form_data_from_config(config)) - - -def _run_phold_from_form(run: Run, form: PHOLDSimulationForm) -> None: +def _run_phold(run: Run, config: PHOLDSimulationConfig) -> None: run_phold_simulation.delay( run_id=run.id, - synch=int(form.cleaned_data["synch"]), - avl_size=form.cleaned_data["avl_size"], - nlp=form.cleaned_data["nlp"], - remote=form.cleaned_data["remote"], - mean=form.cleaned_data["mean"], - mult=form.cleaned_data["mult"], - lookahead=form.cleaned_data["lookahead"], - start_events=form.cleaned_data["start_events"], - memory=form.cleaned_data["memory"], - stagger=bool(int(form.cleaned_data["stagger"])), + synch=config.synch, + avl_size=config.avl_size, + nlp=config.nlp, + remote=config.remote, + mean=config.mean, + mult=config.mult, + lookahead=config.lookahead, + start_events=config.start_events, + memory=config.memory, + stagger=config.stagger, ) -def _create_run_and_config(form: PHOLDSimulationForm, run_status: RunStatus) -> Run: +def _create_run_and_config( + form: PHOLDSimulationForm, run_status: RunStatus +) -> tuple[Run, PHOLDSimulationConfig]: """Create a Run and its PHOLDSimulationConfig atomically from validated form data.""" with transaction.atomic(): run = Run.objects.create( name=form.cleaned_data["run_identifier"], status=run_status, ) - - PHOLDSimulationConfig.objects.create( - run=run, - synch=int(form.cleaned_data["synch"]), - avl_size=form.cleaned_data["avl_size"], - nlp=form.cleaned_data["nlp"], - remote=form.cleaned_data["remote"], - mean=form.cleaned_data["mean"], - mult=form.cleaned_data["mult"], - lookahead=form.cleaned_data["lookahead"], - start_events=form.cleaned_data["start_events"], - memory=form.cleaned_data["memory"], - stagger=bool(int(form.cleaned_data["stagger"])), - ) - return run + config = form.save(commit=False) + config.run = run + config.save() + return run, config def simulation_config(request: HttpRequest) -> HttpResponse: @@ -380,10 +351,10 @@ def simulation_config(request: HttpRequest) -> HttpResponse: should_run = request.POST.get("action") == "save_and_run" run_status = RunStatus.PENDING if should_run else RunStatus.SAVED - run = _create_run_and_config(form, run_status) + run, config = _create_run_and_config(form, run_status) if should_run: - _run_phold_from_form(run, form) + _run_phold(run, config) # Redirect to the analysis page so the user can watch the run return redirect("analysis-partial") @@ -413,15 +384,15 @@ def edit_simulation_config(request: HttpRequest, run_id: int) -> HttpResponse: if form.is_valid(): should_run = request.POST.get("action") == "save_and_run" run_status = RunStatus.PENDING if should_run else RunStatus.SAVED - run = _create_run_and_config(form, run_status) + run, new_config = _create_run_and_config(form, run_status) if should_run: - _run_phold_from_form(run, form) + _run_phold(run, new_config) return redirect("analysis-partial") return redirect("simulation-config") else: - form = PHOLDSimulationForm(initial=_phold_form_initial_from_config(config)) + form = PHOLDSimulationForm(instance=config, initial=_phold_form_initial_from_config(config)) context: dict[str, object] = { "form": form, @@ -441,18 +412,19 @@ def run_saved_simulation(request: HttpRequest, run_id: int) -> HttpResponse: run = get_object_or_404(Run.objects.select_related("phold_config"), pk=run_id) config = run.phold_config - form = _phold_form_from_config(config) - if not form.is_valid(): + try: + config.full_clean() + except ValidationError as exc: logger.warning( "Saved config for run %s failed re-validation and could not be run: %s", run_id, - form.errors.as_text(), + exc.message_dict, ) messages.error(request, f'Unable to run "{run.name}": saved configuration is invalid.') return redirect("simulation-config") run.status = RunStatus.PENDING run.save(update_fields=["status"]) - _run_phold_from_form(run, form) + _run_phold(run, config) return redirect("analysis-partial") From 42a447e83a18ffc314de7ddc8990e54c92698d37 Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Tue, 7 Jul 2026 16:41:55 -0400 Subject: [PATCH 08/10] Add some clarifying comments --- net_maestro/core/models/run.py | 2 ++ .../templates/net_maestro/partials/saved_simulations.html | 4 ++++ net_maestro/core/views.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/net_maestro/core/models/run.py b/net_maestro/core/models/run.py index c1114f8..861ec07 100644 --- a/net_maestro/core/models/run.py +++ b/net_maestro/core/models/run.py @@ -9,6 +9,8 @@ class Run(models.Model): created = models.DateTimeField(auto_now_add=True) name = models.CharField(max_length=200) description = models.TextField(blank=True, default="") + # Defaults to SAVED because a Run only becomes PENDING once a simulation is actually + # queued to execute. Immediately queued simulations must pass an explicit status. status = models.CharField(max_length=20, choices=RunStatus, default=RunStatus.SAVED) def __str__(self): diff --git a/net_maestro/core/templates/net_maestro/partials/saved_simulations.html b/net_maestro/core/templates/net_maestro/partials/saved_simulations.html index 33214e9..978d4ee 100644 --- a/net_maestro/core/templates/net_maestro/partials/saved_simulations.html +++ b/net_maestro/core/templates/net_maestro/partials/saved_simulations.html @@ -30,6 +30,10 @@

{{ config.run.name }}

+ {% comment %} + This list only supports PHOLDSimulationConfig today. + Update to render the actual model type once saved simulations support other models. + {% endcomment %}
PHOLD
HttpResponse: if form.is_valid(): should_run = request.POST.get("action") == "save_and_run" run_status = RunStatus.PENDING if should_run else RunStatus.SAVED + # NOTE: The cloned run/config currently has no explicit linkage to the source run. + # Consider associating them (e.g., self-referential FK or audit metadata) so users + # can trace edit history or re-clone without guesswork. run, new_config = _create_run_and_config(form, run_status) if should_run: From 44630040d224d0287a4152ffc3cff0ed0ccb73ec Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Thu, 9 Jul 2026 10:32:37 -0400 Subject: [PATCH 09/10] clone-on-edit UX, saved-run filtering, TODOs - Rename "Edit" to "Clone" throughout the saved simulations UI since editing a saved config actually creates a new Run/config rather than mutating the original. - Exclude RunStatus.SAVED from the status filter choices on the analysis page - Document the clone-on-edit rationale and the missing source-run linkage as TODOs. - Add TODOs for future pagination on the saved simulations list and replacing the redirect+messages error flow with an inline toast/HTMX notification. --- ..._alter_run_status_pholdsimulationconfig.py | 4 +- .../core/models/phold_simulation_config.py | 2 +- .../partials/saved_simulations.html | 4 +- .../core/tests/test_simulation_views.py | 2 +- net_maestro/core/views.py | 51 +++++++++++++++---- 5 files changed, 46 insertions(+), 17 deletions(-) diff --git a/net_maestro/core/migrations/0010_alter_run_status_pholdsimulationconfig.py b/net_maestro/core/migrations/0010_alter_run_status_pholdsimulationconfig.py index d063e07..f3a4b95 100644 --- a/net_maestro/core/migrations/0010_alter_run_status_pholdsimulationconfig.py +++ b/net_maestro/core/migrations/0010_alter_run_status_pholdsimulationconfig.py @@ -123,9 +123,9 @@ class Migration(migrations.Migration): ("stagger", models.BooleanField(default=False)), ( "run", - models.OneToOneField( + models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="phold_config", + related_name="phold_configs", to="core.run", ), ), diff --git a/net_maestro/core/models/phold_simulation_config.py b/net_maestro/core/models/phold_simulation_config.py index 00609ac..c4af989 100644 --- a/net_maestro/core/models/phold_simulation_config.py +++ b/net_maestro/core/models/phold_simulation_config.py @@ -15,7 +15,7 @@ class PHOLDSimulationConfig(models.Model): without duplicating schema for each model. """ - run = models.OneToOneField(Run, on_delete=models.CASCADE, related_name="phold_config") + run = models.ForeignKey(Run, on_delete=models.CASCADE, related_name="phold_configs") synch = models.IntegerField( choices=SynchProtocol, diff --git a/net_maestro/core/templates/net_maestro/partials/saved_simulations.html b/net_maestro/core/templates/net_maestro/partials/saved_simulations.html index 978d4ee..3b260f1 100644 --- a/net_maestro/core/templates/net_maestro/partials/saved_simulations.html +++ b/net_maestro/core/templates/net_maestro/partials/saved_simulations.html @@ -54,8 +54,8 @@

{{ config.run.name }}

hx-push-url="true" class="btn btn-xs btn-ghost" > - - Edit + + Clone
diff --git a/net_maestro/core/tests/test_simulation_views.py b/net_maestro/core/tests/test_simulation_views.py index 67a9716..21e1626 100644 --- a/net_maestro/core/tests/test_simulation_views.py +++ b/net_maestro/core/tests/test_simulation_views.py @@ -285,7 +285,7 @@ def test_edit_save_updates_config( config.refresh_from_db() new_run = Run.objects.get(name="Updated Run") assert new_run.status == RunStatus.SAVED - new_config = new_run.phold_config + new_config = PHOLDSimulationConfig.objects.get(run=new_run) assert new_config.avl_size == 20 assert new_config.stagger is True # Original config remains unchanged diff --git a/net_maestro/core/views.py b/net_maestro/core/views.py index e717300..10d320f 100644 --- a/net_maestro/core/views.py +++ b/net_maestro/core/views.py @@ -12,8 +12,8 @@ from django.contrib import messages from django.core.exceptions import ValidationError from django.db import transaction -from django.http import HttpResponse -from django.shortcuts import get_object_or_404, redirect, render +from django.http import Http404, HttpResponse +from django.shortcuts import redirect, render from django.views.decorators.http import require_POST if TYPE_CHECKING: @@ -196,7 +196,10 @@ def _filtered_runs(request: HttpRequest) -> dict[str, object]: runs = runs.filter(name__icontains=search_query) return { "runs": runs, - "status_choices": RunStatus.choices, + # SAVED runs have no simulation data to analyze; exclude them from filter options. + "status_choices": [ + (value, label) for value, label in RunStatus.choices if value != RunStatus.SAVED + ], "selected_statuses": statuses, "search_query": search_query, } @@ -307,6 +310,23 @@ def _phold_form_initial_from_config(config: PHOLDSimulationConfig) -> dict[str, return {"run_identifier": config.run.name} +def _get_latest_phold_config_or_404(run_id: int) -> PHOLDSimulationConfig: + """Return the most recently created PHOLDSimulationConfig for a Run. + + TODO: Revisit this "most recent wins" choice once ensembles need to disambiguate + between multiple configs. + """ + config = ( + PHOLDSimulationConfig.objects.select_related("run") + .filter(run_id=run_id) + .order_by("-id") + .first() + ) + if config is None: + raise Http404(f"No PHOLDSimulationConfig found for run {run_id}") + return config + + def _run_phold(run: Run, config: PHOLDSimulationConfig) -> None: run_phold_simulation.delay( run_id=run.id, @@ -377,16 +397,21 @@ def simulation_config(request: HttpRequest) -> HttpResponse: def edit_simulation_config(request: HttpRequest, run_id: int) -> HttpResponse: - config = get_object_or_404(PHOLDSimulationConfig.objects.select_related("run"), run_id=run_id) + config = _get_latest_phold_config_or_404(run_id) if request.method == "POST": form = PHOLDSimulationForm(request.POST) if form.is_valid(): should_run = request.POST.get("action") == "save_and_run" run_status = RunStatus.PENDING if should_run else RunStatus.SAVED - # NOTE: The cloned run/config currently has no explicit linkage to the source run. - # Consider associating them (e.g., self-referential FK or audit metadata) so users - # can trace edit history or re-clone without guesswork. + # TODO: "Editing" a saved config intentionally clones it into a new Run/config + # rather than mutating the original. This is a deliberate safeguard against one + # person's edits overwriting another person's saved work. + # Revisit once per-user ownership exists and in-place edits are safe to allow. + # + # TODO: The cloned run/config currently has no explicit linkage to the source run. + # Consider associating them so users can trace edit history or re-clone without + # guesswork. run, new_config = _create_run_and_config(form, run_status) if should_run: @@ -400,8 +425,8 @@ def edit_simulation_config(request: HttpRequest, run_id: int) -> HttpResponse: context: dict[str, object] = { "form": form, "form_action": request.path, - "page_heading": "Edit Simulation", - "breadcrumb_label": "Edit Simulation", + "page_heading": "Clone Simulation", + "breadcrumb_label": "Clone Simulation", } partial_template = "net_maestro/partials/new_simulation.html" if request.headers.get("HX-Request"): @@ -412,8 +437,8 @@ def edit_simulation_config(request: HttpRequest, run_id: int) -> HttpResponse: @require_POST def run_saved_simulation(request: HttpRequest, run_id: int) -> HttpResponse: - run = get_object_or_404(Run.objects.select_related("phold_config"), pk=run_id) - config = run.phold_config + config = _get_latest_phold_config_or_404(run_id) + run = config.run try: config.full_clean() @@ -423,6 +448,8 @@ def run_saved_simulation(request: HttpRequest, run_id: int) -> HttpResponse: run_id, exc.message_dict, ) + # TODO: Consider surfacing errors via a toast notification or an HTMX response instead of + # a redirect so it's clear to the user what happened and why. messages.error(request, f'Unable to run "{run.name}": saved configuration is invalid.') return redirect("simulation-config") run.status = RunStatus.PENDING @@ -436,6 +463,8 @@ def saved_simulations(request: HttpRequest) -> HttpResponse: GET: Display all saved simulation configurations, most recently created first. """ + # TODO: This list is unbounded and will likely grow over time. Consider pagination once + # the number of saved configs makes this a real usability concern. configs = PHOLDSimulationConfig.objects.select_related("run").order_by("-run__created") context: dict[str, object] = {"configs": configs} partial_template = "net_maestro/partials/saved_simulations.html" From 9adcf724b73593ab24b96012e5373da03c57e342 Mon Sep 17 00:00:00 2001 From: Brianna Major Date: Tue, 14 Jul 2026 17:18:14 -0500 Subject: [PATCH 10/10] Use shared client fixture for simulation view tests --- .../core/tests/test_simulation_views.py | 89 ++++++------------- 1 file changed, 29 insertions(+), 60 deletions(-) diff --git a/net_maestro/core/tests/test_simulation_views.py b/net_maestro/core/tests/test_simulation_views.py index 21e1626..5ca124d 100644 --- a/net_maestro/core/tests/test_simulation_views.py +++ b/net_maestro/core/tests/test_simulation_views.py @@ -12,7 +12,6 @@ from typing import TYPE_CHECKING from unittest import mock -from django.contrib.auth.models import User from django.urls import reverse from django.utils import timezone import pytest @@ -28,24 +27,17 @@ class TestSimulationView: """Test the PHOLD simulation view.""" - @pytest.fixture - def authenticated_client(self, client: Client) -> Client: - """Create an authenticated client.""" - User.objects.create_user(username="testuser", password="testpass") - client.login(username="testuser", password="testpass") - return client - - def test_get_simulation_form(self, authenticated_client: Client) -> None: + def test_get_simulation_form(self, client: Client) -> None: """Test GET request returns simulation form.""" - response = authenticated_client.get(reverse("new-simulation-config")) + response = client.get(reverse("new-simulation-config")) assert response.status_code == 200 assert "form" in response.context assert "net_maestro/index.html" in [t.name for t in response.templates] - def test_get_simulation_form_htmx(self, authenticated_client: Client) -> None: + def test_get_simulation_form_htmx(self, client: Client) -> None: """Test HTMX GET request returns partial template.""" - response = authenticated_client.get( + response = client.get( reverse("new-simulation-config"), HTTP_HX_REQUEST="true", ) @@ -55,7 +47,7 @@ def test_get_simulation_form_htmx(self, authenticated_client: Client) -> None: @mock.patch("net_maestro.core.views.run_phold_simulation") def test_submit_simulation_form_save_and_run( - self, mock_task: mock.Mock, authenticated_client: Client + self, mock_task: mock.Mock, client: Client ) -> None: """Test "Save and Run" submission creates run/config and triggers the task.""" form_data = { @@ -73,7 +65,7 @@ def test_submit_simulation_form_save_and_run( "stagger": "0", } - response = authenticated_client.post(reverse("new-simulation-config"), data=form_data) + response = client.post(reverse("new-simulation-config"), data=form_data) # Save & Run redirects to analysis so users can monitor the run immediately. assert response.status_code == 302 @@ -104,9 +96,7 @@ def test_submit_simulation_form_save_and_run( ) @mock.patch("net_maestro.core.views.run_phold_simulation") - def test_submit_simulation_form_save_only( - self, mock_task: mock.Mock, authenticated_client: Client - ) -> None: + def test_submit_simulation_form_save_only(self, mock_task: mock.Mock, client: Client) -> None: """Save-only submissions stay on the saved simulations page and skip the task.""" form_data = { "action": "save", @@ -123,7 +113,7 @@ def test_submit_simulation_form_save_only( "stagger": "0", } - response = authenticated_client.post(reverse("new-simulation-config"), data=form_data) + response = client.post(reverse("new-simulation-config"), data=form_data) # Check redirect assert response.status_code == 302 @@ -137,7 +127,7 @@ def test_submit_simulation_form_save_only( # Verify the task was NOT triggered mock_task.delay.assert_not_called() - def test_submit_invalid_form(self, authenticated_client: Client) -> None: + def test_submit_invalid_form(self, client: Client) -> None: """Test invalid form submission returns errors.""" form_data = { "run_identifier": "", # Required field @@ -153,7 +143,7 @@ def test_submit_invalid_form(self, authenticated_client: Client) -> None: "stagger": "0", } - response = authenticated_client.post(reverse("new-simulation-config"), data=form_data) + response = client.post(reverse("new-simulation-config"), data=form_data) # Should not redirect assert response.status_code == 200 @@ -177,24 +167,17 @@ def test_unauthenticated_access(self, client: Client) -> None: class TestSavedSimulationsView: """Test the saved simulations list view.""" - @pytest.fixture - def authenticated_client(self, client: Client) -> Client: - """Create an authenticated client.""" - User.objects.create_user(username="testuser", password="testpass") - client.login(username="testuser", password="testpass") - return client - - def test_list_empty(self, authenticated_client: Client) -> None: + def test_list_empty(self, client: Client) -> None: """Test the list view renders with no saved configurations.""" - response = authenticated_client.get(reverse("simulation-config")) + response = client.get(reverse("simulation-config")) assert response.status_code == 200 assert list(response.context["configs"]) == [] assert "net_maestro/index.html" in [t.name for t in response.templates] - def test_list_htmx(self, authenticated_client: Client) -> None: + def test_list_htmx(self, client: Client) -> None: """Test HTMX GET request returns the partial template.""" - response = authenticated_client.get( + response = client.get( reverse("simulation-config"), HTTP_HX_REQUEST="true", ) @@ -202,7 +185,7 @@ def test_list_htmx(self, authenticated_client: Client) -> None: assert response.status_code == 200 assert "net_maestro/partials/saved_simulations.html" in [t.name for t in response.templates] - def test_list_shows_saved_configs(self, authenticated_client: Client) -> None: + def test_list_shows_saved_configs(self, client: Client) -> None: """Test saved configurations are listed, most recently created first.""" older_run = Run.objects.create(name="Older Run", status=RunStatus.PENDING) older_run.created = timezone.now() - timedelta(days=1) @@ -213,7 +196,7 @@ def test_list_shows_saved_configs(self, authenticated_client: Client) -> None: newer_run.save(update_fields=["created"]) PHOLDSimulationConfig.objects.create(run=newer_run) - response = authenticated_client.get(reverse("simulation-config")) + response = client.get(reverse("simulation-config")) assert response.status_code == 200 configs = list(response.context["configs"]) @@ -222,12 +205,6 @@ def test_list_shows_saved_configs(self, authenticated_client: Client) -> None: @pytest.mark.django_db class TestEditSimulationView: - @pytest.fixture - def authenticated_client(self, client: Client) -> Client: - User.objects.create_user(username="testuser", password="testpass") - client.login(username="testuser", password="testpass") - return client - def _create_config(self) -> PHOLDSimulationConfig: run = Run.objects.create(name="Original Run", status=RunStatus.SAVED) return PHOLDSimulationConfig.objects.create( @@ -244,10 +221,10 @@ def _create_config(self) -> PHOLDSimulationConfig: stagger=False, ) - def test_edit_get_prefills_form(self, authenticated_client: Client) -> None: + def test_edit_get_prefills_form(self, client: Client) -> None: config = self._create_config() - response = authenticated_client.get(reverse("edit-simulation-config", args=[config.run.id])) + response = client.get(reverse("edit-simulation-config", args=[config.run.id])) assert response.status_code == 200 form = response.context["form"] @@ -255,9 +232,7 @@ def test_edit_get_prefills_form(self, authenticated_client: Client) -> None: assert form["avl_size"].value() == 18 @mock.patch("net_maestro.core.views.run_phold_simulation") - def test_edit_save_updates_config( - self, mock_task: mock.Mock, authenticated_client: Client - ) -> None: + def test_edit_save_updates_config(self, mock_task: mock.Mock, client: Client) -> None: config = self._create_config() form_data = { "action": "save", @@ -274,7 +249,7 @@ def test_edit_save_updates_config( "stagger": "1", } - response = authenticated_client.post( + response = client.post( reverse("edit-simulation-config", args=[config.run.id]), data=form_data, ) @@ -294,7 +269,7 @@ def test_edit_save_updates_config( assert config.avl_size == 18 mock_task.delay.assert_not_called() - def test_edit_clone_shows_both_runs_in_saved_list(self, authenticated_client: Client) -> None: + def test_edit_clone_shows_both_runs_in_saved_list(self, client: Client) -> None: config = self._create_config() form_data = { "action": "save", @@ -311,22 +286,20 @@ def test_edit_clone_shows_both_runs_in_saved_list(self, authenticated_client: Cl "stagger": "0", } - response = authenticated_client.post( + response = client.post( reverse("edit-simulation-config", args=[config.run.id]), data=form_data, ) assert response.status_code == 302 - list_response = authenticated_client.get(reverse("simulation-config")) + list_response = client.get(reverse("simulation-config")) assert list_response.status_code == 200 run_names = [cfg.run.name for cfg in list_response.context["configs"]] assert run_names == ["Cloned Run", "Original Run"] @mock.patch("net_maestro.core.views.run_phold_simulation") - def test_edit_save_and_run_triggers_task( - self, mock_task: mock.Mock, authenticated_client: Client - ) -> None: + def test_edit_save_and_run_triggers_task(self, mock_task: mock.Mock, client: Client) -> None: config = self._create_config() form_data = { "action": "save_and_run", @@ -343,7 +316,7 @@ def test_edit_save_and_run_triggers_task( "stagger": "0", } - response = authenticated_client.post( + response = client.post( reverse("edit-simulation-config", args=[config.run.id]), data=form_data, ) @@ -356,12 +329,10 @@ def test_edit_save_and_run_triggers_task( mock_task.delay.assert_called_once() @mock.patch("net_maestro.core.views.run_phold_simulation") - def test_run_saved_simulation_shortcut( - self, mock_task: mock.Mock, authenticated_client: Client - ) -> None: + def test_run_saved_simulation_shortcut(self, mock_task: mock.Mock, client: Client) -> None: config = self._create_config() - response = authenticated_client.post(reverse("run-saved-simulation", args=[config.run.id])) + response = client.post(reverse("run-saved-simulation", args=[config.run.id])) assert response.status_code == 302 assert response.headers["Location"] == reverse("analysis-partial") @@ -372,16 +343,14 @@ def test_run_saved_simulation_shortcut( @mock.patch("net_maestro.core.views.run_phold_simulation") def test_run_saved_simulation_invalid_config_shows_error( - self, mock_task: mock.Mock, authenticated_client: Client + self, mock_task: mock.Mock, client: Client ) -> None: """An invalid saved config surfaces an error message instead of failing silently.""" config = self._create_config() config.avl_size = 5 # Below the form/model MinValueValidator(10) config.save(update_fields=["avl_size"]) - response = authenticated_client.post( - reverse("run-saved-simulation", args=[config.run.id]), follow=True - ) + response = client.post(reverse("run-saved-simulation", args=[config.run.id]), follow=True) assert response.status_code == 200 assert response.redirect_chain[-1] == (reverse("simulation-config"), 302)