diff --git a/djangorota/rota/migrations/0003_rotarun_export_html.py b/djangorota/rota/migrations/0003_rotarun_export_html.py new file mode 100644 index 0000000..fb050f9 --- /dev/null +++ b/djangorota/rota/migrations/0003_rotarun_export_html.py @@ -0,0 +1,21 @@ +"""Add export_html field to RotaRun. + +Generated by assistant. +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("rota", "0002_rotaschedule_options_rotaschedule_shifts"), + ] + + operations = [ + migrations.AddField( + model_name="rotarun", + name="export_html", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/djangorota/rota/models.py b/djangorota/rota/models.py index 3f634e8..1f274d6 100644 --- a/djangorota/rota/models.py +++ b/djangorota/rota/models.py @@ -180,6 +180,10 @@ class RotaRun(models.Model): result = models.JSONField(null=True, blank=True) log = models.TextField(blank=True) + # Optional HTML export produced by a run (stored inline for convenience). + # May be large; keep nullable to avoid breaking existing runs. + export_html = models.TextField(null=True, blank=True) + def mark_running(self): self.status = self.STATUS_RUNNING self.started_at = timezone.now() diff --git a/djangorota/rota/services.py b/djangorota/rota/services.py index edc193f..38a791a 100644 --- a/djangorota/rota/services.py +++ b/djangorota/rota/services.py @@ -7,7 +7,7 @@ from django.utils import timezone from .models import RotaRun, RotaSchedule -def _run_rota_job(run_id: int, use_external_generator: bool = False): +def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_builder: bool = False, builder_solve: bool = False): """Background job that executes the scheduler and updates RotaRun. If `use_external_generator` is True, callers should implement a project @@ -47,6 +47,30 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False): "generated_at": timezone.now().isoformat(), } + # Optionally build the RotaBuilder and attach generated HTML to the + # result. This allows the web UI to present the generated HTML for a + # run without requiring a separate on-demand build. + if generate_builder: + try: + rb = rota.to_rota_builder() + # build the model and optionally solve + rb.build_and_solve(solve=builder_solve) + html = rb.get_worker_timetable_html(include_html_tag=True) + # store under result so it's accessible from UI + result["builder_html"] = html + # Persist the HTML on the RotaRun record for easy retrieval + try: + run.export_html = html + run.save(update_fields=["export_html"]) + except Exception: + # best-effort: if saving fails, include a log entry + run.log = (run.log or "") + "\nFailed to persist export_html" + except Exception as exc: + # include failure information in run.log and result + tb = traceback.format_exc() + run.log = (run.log or "") + "\n" + tb + result["builder_error"] = str(exc) + run.mark_finished(result=result) except Exception: @@ -59,7 +83,7 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False): print(tb) -def run_rota_async(rota_id: int, use_external_generator: bool = False) -> RotaRun: +def run_rota_async(rota_id: int, use_external_generator: bool = False, generate_builder: bool = False, builder_solve: bool = False) -> RotaRun: """Create a RotaRun and start background thread to run it. Returns the created RotaRun instance. @@ -68,7 +92,11 @@ def run_rota_async(rota_id: int, use_external_generator: bool = False) -> RotaRu rota = RotaSchedule.objects.get(pk=rota_id) run = RotaRun.objects.create(rota=rota) - thread = threading.Thread(target=_run_rota_job, args=(run.id, use_external_generator), daemon=True) + thread = threading.Thread( + target=_run_rota_job, + args=(run.id, use_external_generator, generate_builder, builder_solve), + daemon=True, + ) thread.start() return run diff --git a/djangorota/rota/urls.py b/djangorota/rota/urls.py index 5a731c3..d2938da 100644 --- a/djangorota/rota/urls.py +++ b/djangorota/rota/urls.py @@ -19,4 +19,5 @@ urlpatterns = [ path("run//export/html/", views.rota_run_export_html, name="rota_run_export_html"), path("run//export/download/", views.rota_run_export_download, name="rota_run_export_download"), path("rota//export/builder/", views.rota_export_builder, name="rota_export_builder"), + path("rota//runs/clear/", views.rota_runs_clear, name="rota_runs_clear"), ] diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index 0b44763..268df80 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -8,6 +8,7 @@ from .forms import ShiftForm from django.views.decorators.http import require_http_methods from django.template.loader import render_to_string from django.http import HttpResponse, HttpResponseBadRequest +from django.views.decorators.http import require_POST def index(request): @@ -19,7 +20,9 @@ def rota_detail(request, rota_id): rota = get_object_or_404(RotaSchedule, pk=rota_id) if request.method == "POST": # trigger a background run - run = run_rota_async(rota.id) + generate_builder = request.POST.get("generate_builder") == "1" + builder_solve = request.POST.get("builder_solve") == "1" + run = run_rota_async(rota.id, generate_builder=generate_builder, builder_solve=builder_solve) return redirect(reverse("rota:rota_run_detail", args=(run.id,))) return render(request, "rota/rota_detail.html", {"rota": rota}) @@ -253,6 +256,21 @@ def rota_export_builder(request, rota_id): return HttpResponse(html, content_type="text/html; charset=utf-8") +@require_POST +def rota_runs_clear(request, rota_id): + """Delete all RotaRun records for the given rota and redirect back. + + This is a POST-only endpoint; the template uses an inline form with a + JavaScript confirmation to avoid accidental removal. + """ + rota = get_object_or_404(RotaSchedule, pk=rota_id) + # Delete all runs for this rota + from .models import RotaRun + + RotaRun.objects.filter(rota=rota).delete() + return redirect("rota:rota_detail", rota_id=rota.id) + + @require_http_methods(["GET", "POST"]) def shift_add(request, rota_id): """HTMX endpoint to render a shift form or accept submission to append a shift to the rota."""