From 492769762172e69018be342f2faefecc4979168b Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 9 Dec 2025 10:01:04 +0000 Subject: [PATCH] start the django --- djangorota/db.sqlite3 | Bin 0 -> 180224 bytes djangorota/djangorota/settings.py | 11 +- djangorota/djangorota/urls.py | 3 +- djangorota/rota/admin.py | 34 +++- djangorota/rota/forms.py | 21 +++ djangorota/rota/management/__init__.py | 1 + .../rota/management/commands/__init__.py | 1 + .../rota/management/commands/run_rota.py | 23 +++ djangorota/rota/migrations/0001_initial.py | 164 ++++++++++++++++++ djangorota/rota/models.py | 114 +++++++++++- djangorota/rota/services.py | 74 ++++++++ djangorota/rota/urls.py | 13 ++ djangorota/rota/views.py | 57 +++++- requirements.txt | 2 +- 14 files changed, 511 insertions(+), 7 deletions(-) create mode 100644 djangorota/db.sqlite3 create mode 100644 djangorota/rota/forms.py create mode 100644 djangorota/rota/management/__init__.py create mode 100644 djangorota/rota/management/commands/__init__.py create mode 100644 djangorota/rota/management/commands/run_rota.py create mode 100644 djangorota/rota/migrations/0001_initial.py create mode 100644 djangorota/rota/services.py create mode 100644 djangorota/rota/urls.py diff --git a/djangorota/db.sqlite3 b/djangorota/db.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..53a74f3df4b6730d564e8551cf713ee4e342e23f GIT binary patch literal 180224 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 {self.rota}" + + +class Leave(models.Model): + """Leave record for a worker. Stored as inclusive start/end dates.""" + + worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name="leaves") + start_date = models.DateField() + end_date = models.DateField() + reason = models.CharField(max_length=255, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ("-start_date",) + + def __str__(self): + return f"{self.worker}: {self.start_date} → {self.end_date}" + + +class RotaRun(models.Model): + """Represents an execution of the scheduler for a RotaSchedule. + + The run stores status and (optionally) JSON results. A background + runner or management command can update this record as the job progresses. + """ + + STATUS_PENDING = "pending" + STATUS_RUNNING = "running" + STATUS_SUCCESS = "success" + STATUS_FAILED = "failed" + + STATUS_CHOICES = [ + (STATUS_PENDING, "Pending"), + (STATUS_RUNNING, "Running"), + (STATUS_SUCCESS, "Success"), + (STATUS_FAILED, "Failed"), + ] + + rota = models.ForeignKey(RotaSchedule, on_delete=models.CASCADE, related_name="runs") + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING) + result = models.JSONField(null=True, blank=True) + log = models.TextField(blank=True) + + def mark_running(self): + self.status = self.STATUS_RUNNING + self.started_at = timezone.now() + self.save(update_fields=["status", "started_at"]) + + def mark_finished(self, result=None): + self.status = self.STATUS_SUCCESS + self.finished_at = timezone.now() + if result is not None: + self.result = result + self.save(update_fields=["status", "finished_at", "result"]) + + def mark_failed(self, message=""): + self.status = self.STATUS_FAILED + self.finished_at = timezone.now() + self.log = (self.log or "") + "\n" + message + self.save(update_fields=["status", "finished_at", "log"]) + + def __str__(self): + return f"RotaRun({self.rota.name} - {self.status} @ {self.created_at.isoformat()})" diff --git a/djangorota/rota/services.py b/djangorota/rota/services.py new file mode 100644 index 0000000..edc193f --- /dev/null +++ b/djangorota/rota/services.py @@ -0,0 +1,74 @@ +import threading +import traceback +from typing import Optional + +from django.utils import timezone + +from .models import RotaRun, RotaSchedule + + +def _run_rota_job(run_id: int, use_external_generator: bool = False): + """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 + + result = generate_rota_for_schedule(rota) + 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(), + } + + run.mark_finished(result=result) + + except Exception: + tb = traceback.format_exc() + 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) -> 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), daemon=True) + thread.start() + + return run diff --git a/djangorota/rota/urls.py b/djangorota/rota/urls.py new file mode 100644 index 0000000..b7e7821 --- /dev/null +++ b/djangorota/rota/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from . import views + +app_name = "rota" + +urlpatterns = [ + path("", views.index, name="index"), + path("rota//", views.rota_detail, name="rota_detail"), + path("worker/add/", views.worker_create, name="worker_add"), + path("worker//", views.worker_detail, name="worker_detail"), + path("run//", views.rota_run_detail, name="rota_run_detail"), +] diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index 91ea44a..a05463d 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -1,3 +1,56 @@ -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404, redirect +from django.urls import reverse + +from .models import RotaSchedule, Worker +from .forms import WorkerForm, LeaveForm, RotaScheduleForm +from .services import run_rota_async + + +def index(request): + rotas = RotaSchedule.objects.all().order_by("-created_at") + return render(request, "rota/index.html", {"rotas": rotas}) + + +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) + return redirect(reverse("rota_run_detail", args=(run.id,))) + + return render(request, "rota/rota_detail.html", {"rota": rota}) + + +def worker_create(request): + if request.method == "POST": + form = WorkerForm(request.POST) + if form.is_valid(): + worker = form.save() + return redirect("/") + else: + form = WorkerForm() + + return render(request, "rota/worker_form.html", {"form": form}) + + +def worker_detail(request, worker_id): + worker = get_object_or_404(Worker, pk=worker_id) + if request.method == "POST": + form = LeaveForm(request.POST) + if form.is_valid(): + leave = form.save(commit=False) + leave.worker = worker + leave.save() + return redirect("worker_detail", worker_id=worker.id) + else: + form = LeaveForm() + + return render(request, "rota/worker_detail.html", {"worker": worker, "form": form}) + + +def rota_run_detail(request, run_id): + from .models import RotaRun + + run = get_object_or_404(RotaRun, pk=run_id) + return render(request, "rota/rota_run_detail.html", {"run": run}) -# Create your views here. diff --git a/requirements.txt b/requirements.txt index 368ecf2..885289f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,4 @@ loguru numpy highspy typer -django \ No newline at end of file +django>=6.0,<7 \ No newline at end of file