import threading import traceback import io import contextlib from django.utils import timezone from .models import RotaRun, RotaSchedule def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_builder: bool = True, builder_solve: bool = True): """Background job that executes the scheduler and updates RotaRun. If `use_external_generator` is True, callers should implement a project level `generate_rota_for_schedule` function and import it here. By default a minimal placeholder result is recorded so the UI and workflow can be tested without wiring the full solver. """ run = RotaRun.objects.get(pk=run_id) try: run.mark_running() rota: RotaSchedule = run.rota # Placeholder: integrate your generator here. # Example integration point (not implemented in this scaffold): # from proc.integration import generate_rota_for_schedule # result = generate_rota_for_schedule(rota) if use_external_generator: # Try to import a project-level generator hook. This allows you to # call into existing code (e.g. your `rota.run2` logic) without # hard-coding it into the Django app. try: from project_rota_hook import generate_rota_for_schedule # type: ignore # Capture any stdout/stderr produced by the external generator out_buf = io.StringIO() err_buf = io.StringIO() with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf): result = generate_rota_for_schedule(rota) # Attach captured output to the result for debugging try: result = result or {} result["generator_stdout"] = out_buf.getvalue() result["generator_stderr"] = err_buf.getvalue() except Exception: pass # Also persist captured generator output into the run.log so # it's visible from the run detail page when executed in the # background thread. try: captured = "\n[GENERATOR STDOUT]\n" + (result.get("generator_stdout") or "") + "\n[GENERATOR STDERR]\n" + (result.get("generator_stderr") or "") run.log = (run.log or "") + captured run.save(update_fields=["log"]) except Exception: pass except Exception: tb = traceback.format_exc() run.mark_failed(message=tb) return else: # Minimal demo result for initial testing result = { "rota": rota.name, "worker_count": rota.workers.count(), "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: # Defensive: if no workers are assigned to the rota, skip the # builder step and return a clear message rather than raising an # exception from deep inside the builder. try: worker_count = rota.workers.count() except Exception: worker_count = 0 if worker_count == 0: result["builder_error"] = "No workers assigned to rota; add workers before running the builder." else: try: # Capture stdout/stderr from the builder so debugging info # is available in the RotaRun result and log. out_buf = io.StringIO() err_buf = io.StringIO() with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf): rb = rota.to_rota_builder() # If the rota has solver options saved in rota.options, prefer # those when invoking the builder. `solver_options` is expected # to be a dict of solver-specific options; `solver` may be a # string naming the solver plugin. opts = rota.options or {} solver_options = opts.get("solver_options") or {} solver_name = opts.get("solver") or None # build the model and optionally solve. If solver_name is # provided, pass it through; otherwise let builder default. if solver_name: rb.build_and_solve(options=solver_options, solve=builder_solve, solver=solver_name) else: rb.build_and_solve(options=solver_options, 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 result["builder_stdout"] = out_buf.getvalue() result["builder_stderr"] = err_buf.getvalue() # 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" # Append captured output to run.log for convenient viewing try: captured = "\n[BUILDER STDOUT]\n" + result.get("builder_stdout", "") + "\n[BUILDER STDERR]\n" + result.get("builder_stderr", "") run.log = (run.log or "") + captured # persist the appended log so it is visible to users # when the run finishes run.save(update_fields=["log"]) except Exception: pass 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) # Persist the log immediately so UI can show it even if # the outer flow later marks the run failed. try: run.save(update_fields=["log"]) except Exception: pass run.mark_finished(result=result) except Exception: tb = traceback.format_exc() # Ensure the traceback is persisted into the run log and result (if # available) before marking the run failed so the UI can display the # diagnostic information when viewing the run details. try: run.log = (run.log or "") + "\n" + tb # If we have a partially constructed `result`, attach it so the # failure page can show any captured stdout/stderr try: if 'result' in locals() and isinstance(result, dict): run.result = result run.save(update_fields=["log", "result"]) else: run.save(update_fields=["log"]) except Exception: # best-effort: ignore persistence errors here pass except Exception: # ignore log assignment errors and continue to mark failed pass try: run.mark_failed(message=tb) except Exception: # last resort: log to stdout print("Failed to update RotaRun with failure:") print(tb) def run_rota_async(rota_id: int, use_external_generator: bool = False, generate_builder: bool = True, builder_solve: bool = True) -> RotaRun: """Create a RotaRun and start background thread to run it. Returns the created RotaRun instance. """ 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, generate_builder, builder_solve), daemon=True, ) thread.start() return run