start the django
This commit is contained in:
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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}"))
|
||||
@@ -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
@@ -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()})"
|
||||
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
@@ -15,4 +15,4 @@ loguru
|
||||
numpy
|
||||
highspy
|
||||
typer
|
||||
django
|
||||
django>=6.0,<7
|
||||
Reference in New Issue
Block a user