diff --git a/djangorota/db.sqlite3 b/djangorota/db.sqlite3 new file mode 100644 index 0000000..53a74f3 Binary files /dev/null and b/djangorota/db.sqlite3 differ diff --git a/djangorota/djangorota/settings.py b/djangorota/djangorota/settings.py index 86716a0..62aafeb 100644 --- a/djangorota/djangorota/settings.py +++ b/djangorota/djangorota/settings.py @@ -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" diff --git a/djangorota/djangorota/urls.py b/djangorota/djangorota/urls.py index a17e8a9..62061e1 100644 --- a/djangorota/djangorota/urls.py +++ b/djangorota/djangorota/urls.py @@ -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")), ] diff --git a/djangorota/rota/admin.py b/djangorota/rota/admin.py index 8c38f3f..5322d70 100644 --- a/djangorota/rota/admin.py +++ b/djangorota/rota/admin.py @@ -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") diff --git a/djangorota/rota/forms.py b/djangorota/rota/forms.py new file mode 100644 index 0000000..b3a98f2 --- /dev/null +++ b/djangorota/rota/forms.py @@ -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"] diff --git a/djangorota/rota/management/__init__.py b/djangorota/rota/management/__init__.py new file mode 100644 index 0000000..5bb534f --- /dev/null +++ b/djangorota/rota/management/__init__.py @@ -0,0 +1 @@ +# package diff --git a/djangorota/rota/management/commands/__init__.py b/djangorota/rota/management/commands/__init__.py new file mode 100644 index 0000000..5bb534f --- /dev/null +++ b/djangorota/rota/management/commands/__init__.py @@ -0,0 +1 @@ +# package diff --git a/djangorota/rota/management/commands/run_rota.py b/djangorota/rota/management/commands/run_rota.py new file mode 100644 index 0000000..2de4965 --- /dev/null +++ b/djangorota/rota/management/commands/run_rota.py @@ -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}")) diff --git a/djangorota/rota/migrations/0001_initial.py b/djangorota/rota/migrations/0001_initial.py new file mode 100644 index 0000000..1f19a50 --- /dev/null +++ b/djangorota/rota/migrations/0001_initial.py @@ -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")}, + ), + ] diff --git a/djangorota/rota/models.py b/djangorota/rota/models.py index 71a8362..a5c0948 100644 --- a/djangorota/rota/models.py +++ b/djangorota/rota/models.py @@ -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()})" 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