diff --git a/djangorota/djangorota/settings.py b/djangorota/djangorota/settings.py index cba5d94..5bc07de 100644 --- a/djangorota/djangorota/settings.py +++ b/djangorota/djangorota/settings.py @@ -122,7 +122,12 @@ USE_TZ = True # https://docs.djangoproject.com/en/6.0/howto/static-files/ STATIC_URL = "static/" -STATICFILES_DIRS = [BASE_DIR / "output"] +# Project-level output directory (app-level) plus repo-level `output` for legacy layout +STATICFILES_DIRS = [ + BASE_DIR / "output", + # also look for output/ at the repository root (BASE_DIR.parent) + BASE_DIR.parent / "output", +] # Default primary key field type DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/djangorota/rota/management/commands/run_rota.py b/djangorota/rota/management/commands/run_rota.py index 2de4965..adbb92e 100644 --- a/djangorota/rota/management/commands/run_rota.py +++ b/djangorota/rota/management/commands/run_rota.py @@ -10,6 +10,8 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("rota_id", type=int) parser.add_argument("--external", action="store_true", help="Use external generator hook if available") + parser.add_argument("--no-export", action="store_true", help="Do not generate HTML export for this run") + parser.add_argument("--no-solve", action="store_true", help="Do not run solver when generating export") def handle(self, *args, **options): rota_id = options["rota_id"] @@ -19,5 +21,12 @@ class Command(BaseCommand): except RotaSchedule.DoesNotExist: raise CommandError(f"RotaSchedule with id={rota_id} not found") - run = run_rota_async(rota.id, use_external_generator=use_external) + no_export = options.get("no_export", False) + no_solve = options.get("no_solve", False) + run = run_rota_async( + rota.id, + use_external_generator=use_external, + generate_builder=not no_export, + builder_solve=not no_solve, + ) self.stdout.write(self.style.SUCCESS(f"Started RotaRun id={run.id} for rota={rota.name}")) diff --git a/djangorota/rota/services.py b/djangorota/rota/services.py index 38a791a..3753c8d 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, generate_builder: bool = False, builder_solve: bool = False): +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 @@ -83,7 +83,7 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu print(tb) -def run_rota_async(rota_id: int, use_external_generator: bool = False, generate_builder: bool = False, builder_solve: bool = False) -> RotaRun: +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. diff --git a/djangorota/rota/urls.py b/djangorota/rota/urls.py index d2938da..59f3ebc 100644 --- a/djangorota/rota/urls.py +++ b/djangorota/rota/urls.py @@ -20,4 +20,5 @@ urlpatterns = [ 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"), + path("run//export/regenerate/", views.rota_run_export_regenerate, name="rota_run_export_regenerate"), ] diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index 268df80..be43ec2 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -9,6 +9,7 @@ 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 +from django.shortcuts import HttpResponseRedirect def index(request): @@ -213,6 +214,11 @@ def rota_run_export_html(request, run_id): from .models import RotaRun run = get_object_or_404(RotaRun, pk=run_id) + # If the export HTML was persisted with the run, return it directly. + if getattr(run, "export_html", None): + return HttpResponse(run.export_html, content_type="text/html; charset=utf-8") + + # Fallback: render the export template which shows run.result. return render(request, "rota/rota_run_export.html", {"run": run}) @@ -220,13 +226,46 @@ def rota_run_export_download(request, run_id): from .models import RotaRun run = get_object_or_404(RotaRun, pk=run_id) - html = render_to_string("rota/rota_run_export.html", {"run": run}, request=request) + # Prefer persisted export HTML if available + if getattr(run, "export_html", None): + html = run.export_html + else: + html = render_to_string("rota/rota_run_export.html", {"run": run}, request=request) + response = HttpResponse(html, content_type="text/html; charset=utf-8") filename = f"rota_run_{run.id}_export.html" response["Content-Disposition"] = f'attachment; filename="{filename}"' return response +@require_POST +def rota_run_export_regenerate(request, run_id): + """Regenerate the HTML export for a completed run and persist it on the RotaRun. + + This is a POST endpoint — regenerating may be slow if the solver is invoked. + """ + from .models import RotaRun + + run = get_object_or_404(RotaRun, pk=run_id) + rota = run.rota + solve = request.POST.get("solve") == "1" + + try: + rb = rota.to_rota_builder() + rb.build_and_solve(solve=solve) + html = rb.get_worker_timetable_html(include_html_tag=True) + run.export_html = html + run.save(update_fields=["export_html"]) + except Exception as exc: + tb = traceback.format_exc() + run.log = (run.log or "") + "\n" + tb + run.save(update_fields=["log"]) + return HttpResponse(f"Failed to regenerate export: {exc}", status=500) + + # Redirect to the HTML view so user sees the generated page + return HttpResponseRedirect(reverse("rota:rota_run_export_html", args=(run.id,))) + + def rota_export_builder(request, rota_id): """Build a `RotaBuilder` for the given `RotaSchedule` and return its HTML export. diff --git a/rota_generator/shifts.py b/rota_generator/shifts.py index b4fcc2b..6e9aab1 100644 --- a/rota_generator/shifts.py +++ b/rota_generator/shifts.py @@ -4279,7 +4279,7 @@ class RotaBuilder(object): return "\n".join(timetable) def get_worker_timetable_html( - self, include_html_tag=False, table_name="rota-table" + self, include_html_tag=False, table_name="rota-table", html_static_prefix=None ): model = self.model @@ -4718,13 +4718,43 @@ class RotaBuilder(object): """ if include_html_tag: + # html_static_prefix controls how CSS/JS are referenced: + # - If html_static_prefix is None: try to use Django STATIC_URL when running inside Django, otherwise use relative links + # - If html_static_prefix is a falsey value (e.g. empty string or False): use relative links ("timetable.css") + # - If html_static_prefix is a string: use it as the prefix (ensuring trailing slash) + static_prefix = None + if html_static_prefix is not None: + static_prefix = html_static_prefix + + if static_prefix is None: + # Attempt to detect Django settings; if not present, fall back to relative links + try: + from django.conf import settings + + static_prefix = getattr(settings, "STATIC_URL", "") + except Exception: + static_prefix = "" + + # If a prefix is provided and not empty, ensure trailing slash + if isinstance(static_prefix, str) and static_prefix: + if not static_prefix.endswith("/"): + static_prefix = static_prefix + "/" + + if static_prefix: + css_href = f"{static_prefix}timetable.css" + js_src = f"{static_prefix}timetable.js" + else: + # Relative links for standalone usage + css_href = "timetable.css" + js_src = "timetable.js" + html = f""" - + - + {html}"""