start the django

This commit is contained in:
Ross
2025-12-09 10:01:04 +00:00
parent a230c9d94c
commit 4927697621
14 changed files with 511 additions and 7 deletions
Binary file not shown.
+10 -1
View File
@@ -38,6 +38,10 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
]
# Add local rota app
INSTALLED_APPS += [
"rota",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
@@ -54,7 +58,8 @@ ROOT_URLCONF = "djangorota.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
# look for project-level templates at djangorota/templates
"DIRS": [BASE_DIR / "djangorota" / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
@@ -115,3 +120,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "output"]
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
+2 -1
View File
@@ -16,8 +16,9 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import path
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("rota.urls")),
]
+33 -1
View File
@@ -1,3 +1,35 @@
from django.contrib import admin
from . import models
# Register your models here.
@admin.register(models.RotaSchedule)
class RotaScheduleAdmin(admin.ModelAdmin):
list_display = ("name", "start_date", "end_date", "created_at")
search_fields = ("name",)
@admin.register(models.Worker)
class WorkerAdmin(admin.ModelAdmin):
list_display = ("name", "email", "site", "grade", "fte", "active")
list_filter = ("site", "active")
search_fields = ("name", "email")
@admin.register(models.Assignment)
class AssignmentAdmin(admin.ModelAdmin):
list_display = ("worker", "rota", "role", "created_at")
list_select_related = ("worker", "rota")
@admin.register(models.Leave)
class LeaveAdmin(admin.ModelAdmin):
list_display = ("worker", "start_date", "end_date", "reason")
list_filter = ("start_date", "end_date")
search_fields = ("worker__name",)
@admin.register(models.RotaRun)
class RotaRunAdmin(admin.ModelAdmin):
list_display = ("rota", "status", "created_at", "started_at", "finished_at")
list_filter = ("status", "created_at")
readonly_fields = ("log", "result")
+21
View File
@@ -0,0 +1,21 @@
from django import forms
from .models import Worker, Leave, RotaSchedule
class WorkerForm(forms.ModelForm):
class Meta:
model = Worker
fields = ["name", "email", "site", "grade", "fte", "active"]
class LeaveForm(forms.ModelForm):
class Meta:
model = Leave
fields = ["start_date", "end_date", "reason"]
class RotaScheduleForm(forms.ModelForm):
class Meta:
model = RotaSchedule
fields = ["name", "start_date", "end_date", "description"]
+1
View File
@@ -0,0 +1 @@
# package
@@ -0,0 +1 @@
# package
@@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand, CommandError
from rota.models import RotaSchedule
from rota.services import run_rota_async
class Command(BaseCommand):
help = "Create a RotaRun for the given RotaSchedule id and start it in background."
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")
def handle(self, *args, **options):
rota_id = options["rota_id"]
use_external = options.get("external", False)
try:
rota = RotaSchedule.objects.get(pk=rota_id)
except RotaSchedule.DoesNotExist:
raise CommandError(f"RotaSchedule with id={rota_id} not found")
run = run_rota_async(rota.id, use_external_generator=use_external)
self.stdout.write(self.style.SUCCESS(f"Started RotaRun id={run.id} for rota={rota.name}"))
+164
View File
@@ -0,0 +1,164 @@
# Generated by Django 6.0 on 2025-12-09 09:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="RotaSchedule",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=200, unique=True)),
("start_date", models.DateField()),
("end_date", models.DateField()),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name="RotaRun",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("started_at", models.DateTimeField(blank=True, null=True)),
("finished_at", models.DateTimeField(blank=True, null=True)),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("running", "Running"),
("success", "Success"),
("failed", "Failed"),
],
default="pending",
max_length=20,
),
),
("result", models.JSONField(blank=True, null=True)),
("log", models.TextField(blank=True)),
(
"rota",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="runs",
to="rota.rotaschedule",
),
),
],
),
migrations.CreateModel(
name="Assignment",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("role", models.CharField(blank=True, max_length=100)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"rota",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="rota.rotaschedule",
),
),
],
),
migrations.CreateModel(
name="Worker",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=200)),
("email", models.EmailField(blank=True, max_length=254)),
("site", models.CharField(blank=True, max_length=100)),
("grade", models.IntegerField(blank=True, null=True)),
("fte", models.FloatField(default=1.0)),
("active", models.BooleanField(default=True)),
(
"rotas",
models.ManyToManyField(
related_name="workers",
through="rota.Assignment",
to="rota.rotaschedule",
),
),
],
),
migrations.CreateModel(
name="Leave",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("start_date", models.DateField()),
("end_date", models.DateField()),
("reason", models.CharField(blank=True, max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"worker",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="leaves",
to="rota.worker",
),
),
],
options={
"ordering": ("-start_date",),
},
),
migrations.AddField(
model_name="assignment",
name="worker",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="rota.worker"
),
),
migrations.AlterUniqueTogether(
name="assignment",
unique_together={("worker", "rota")},
),
]
+113 -1
View File
@@ -1,3 +1,115 @@
from django.db import models
from django.utils import timezone
# Create your models here.
class RotaSchedule(models.Model):
"""Represents a named rota (collection of shifts over a date range).
This is the top-level object that groups workers and runs.
"""
name = models.CharField(max_length=200, unique=True)
start_date = models.DateField()
end_date = models.DateField()
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Worker(models.Model):
"""Worker / staff member that can be assigned to one or more rotas."""
name = models.CharField(max_length=200)
email = models.EmailField(blank=True)
site = models.CharField(max_length=100, blank=True)
grade = models.IntegerField(null=True, blank=True)
fte = models.FloatField(default=1.0)
active = models.BooleanField(default=True)
rotas = models.ManyToManyField(RotaSchedule, through="Assignment", related_name="workers")
def __str__(self):
return self.name
class Assignment(models.Model):
"""Join model assigning a worker to a rota (can hold role / metadata)."""
worker = models.ForeignKey(Worker, on_delete=models.CASCADE)
rota = models.ForeignKey(RotaSchedule, on_delete=models.CASCADE)
role = models.CharField(max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("worker", "rota")
def __str__(self):
return f"{self.worker} -> {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()})"
+74
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = "rota"
urlpatterns = [
path("", views.index, name="index"),
path("rota/<int:rota_id>/", views.rota_detail, name="rota_detail"),
path("worker/add/", views.worker_create, name="worker_add"),
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
path("run/<int:run_id>/", views.rota_run_detail, name="rota_run_detail"),
]
+55 -2
View File
@@ -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.
+1 -1
View File
@@ -15,4 +15,4 @@ loguru
numpy
highspy
typer
django
django>=6.0,<7