From 435e6688e18624a90a76a11ac4ecdcafd957edbe Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 3 Oct 2026 16:10:13 +0100 Subject: [PATCH] Allow technicians to record training sessions (#664) * Allow technicians to record training sessions Add a per-item technician_can_train flag. Technicians in an item's department who are passed out on a flagged item may log Training Started/Complete for it. * Run linter --- RIGS/models.py | 6 ++ templates/index.html | 2 +- training/admin.py | 7 +- training/decorators.py | 4 + training/forms.py | 25 ++++++ .../0009_trainingitem_technician_can_train.py | 18 ++++ training/models.py | 20 ++++- training/templates/base_training.html | 2 +- training/tests/test_unit.py | 90 +++++++++++++++++++ training/urls.py | 8 +- training/views.py | 17 ++++ 11 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 training/migrations/0009_trainingitem_technician_can_train.py diff --git a/RIGS/models.py b/RIGS/models.py index 2b564800..f65d562e 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -47,6 +47,12 @@ class Profile(AbstractUser): reversion_hide = True + @property + def can_log_training(self): + from training.models import Trainee + + return self.is_supervisor or Trainee.objects.get(pk=self.pk).is_technician + @classmethod def make_api_key(cls): size = 20 diff --git a/templates/index.html b/templates/index.html index 4c011db5..5d5fff0b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -56,7 +56,7 @@ Trainee List Level List Item List - {% if request.user.is_supervisor %} + {% if request.user.can_log_training %} Log Session {% endif %} diff --git a/training/admin.py b/training/admin.py index 6493031a..9b4774ef 100644 --- a/training/admin.py +++ b/training/admin.py @@ -4,7 +4,6 @@ from reversion.admin import VersionAdmin admin.site.register(models.TrainingCategory, VersionAdmin) -admin.site.register(models.TrainingItem, VersionAdmin) admin.site.register(models.TrainingLevel, VersionAdmin) admin.site.register(models.TrainingLevelQualification, VersionAdmin) admin.site.register(models.TrainingLevelRequirement, VersionAdmin) @@ -13,3 +12,9 @@ admin.site.register(models.TrainingLevelRequirement, VersionAdmin) @admin.register(models.TrainingItemQualification) class TrainingItemQualificationAdmin(VersionAdmin): list_display = ["__str__", "trainee"] + + +@admin.register(models.TrainingItem) +class TrainingItemAdmin(VersionAdmin): + list_display = ["__str__", "category", "active", "technician_can_train"] + list_filter = ["category", "active", "technician_can_train"] diff --git a/training/decorators.py b/training/decorators.py index 13c66394..20fc66ee 100644 --- a/training/decorators.py +++ b/training/decorators.py @@ -3,3 +3,7 @@ from PyRIGS.decorators import user_passes_test_with_403 def is_supervisor(login_url=None, oembed_view=None): return user_passes_test_with_403(lambda u: hasattr(u, "is_supervisor") and u.is_supervisor) + + +def is_supervisor_or_technician(login_url=None, oembed_view=None): + return user_passes_test_with_403(lambda u: u.is_authenticated and u.can_log_training) diff --git a/training/forms.py b/training/forms.py index 771d1c2a..f81d4e1f 100644 --- a/training/forms.py +++ b/training/forms.py @@ -22,9 +22,12 @@ class QualificationForm(forms.ModelForm): supervisor = self.cleaned_data.get("supervisor") if supervisor.pk == self.cleaned_data.get("trainee").pk: raise forms.ValidationError("One may not supervise oneself...") + if self.user is not None and not self.user.is_supervisor and supervisor.pk != self.user.pk: + raise forms.ValidationError("You may only record training that you delivered yourself") return supervisor def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user", None) super().__init__(*args, **kwargs) self.fields["date"].widget.format = "%Y-%m-%d" @@ -63,6 +66,28 @@ class SessionLogForm(forms.Form): related_models = {"supervisor": models.Trainee} + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user", None) + super().__init__(*args, **kwargs) + if self.user is not None and not self.user.is_supervisor: + self.fields["supervisor"].initial = self.user.pk + + def clean(self): + cleaned_data = super().clean() + supervisor = cleaned_data.get("supervisor") + # Supervisors may log anything (as before); technicians only what they are permitted to deliver + if self.user is not None and not self.user.is_supervisor and supervisor is not None: + if supervisor.pk != self.user.pk: + self.add_error("supervisor", "You may only log sessions that you delivered yourself") + else: + if cleaned_data.get("items_2"): + self.add_error("items_2", "Technicians may not pass people out") + for depth in (models.TrainingItemQualification.STARTED, models.TrainingItemQualification.COMPLETE): + for item in cleaned_data.get(f"items_{depth}", []): + if not supervisor.can_deliver_training(item, depth): + self.add_error(f"items_{depth}", f"You are not permitted to deliver training in {item}") + return cleaned_data + def clean_date(self): return QualificationForm.clean_date(self) diff --git a/training/migrations/0009_trainingitem_technician_can_train.py b/training/migrations/0009_trainingitem_technician_can_train.py new file mode 100644 index 00000000..9f1327af --- /dev/null +++ b/training/migrations/0009_trainingitem_technician_can_train.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-10-03 14:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('training', '0008_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='trainingitem', + name='technician_can_train', + field=models.BooleanField(default=False, help_text="Technicians in this item's department who are passed out on it may deliver training up to Training Complete"), + ), + ] diff --git a/training/models.py b/training/models.py index a7ec3f0f..7e8224d7 100644 --- a/training/models.py +++ b/training/models.py @@ -56,6 +56,18 @@ class Trainee(Profile, RevisionMixin): def is_driver(self): return self.confirmed_levels.filter(level__department=TrainingLevel.HAULAGE).exists() + def can_deliver_training(self, item, depth): + """Technicians may deliver training up to Training Complete on flagged items in their own department, + provided they have been passed out on the item themselves.""" + if depth == TrainingItemQualification.PASSED_OUT or not item.technician_can_train: + return False + training_level = item.category.training_level + if training_level is None or training_level.department is None: + return False + return self.confirmed_levels.filter( + level__level=TrainingLevel.TECHNICIAN, level__department=training_level.department + ).exists() and self.is_user_qualified_in(item, TrainingItemQualification.PASSED_OUT) + def get_records_of_depth(self, depth): return self.qualifications_obtained.filter(depth=depth).select_related("item", "trainee", "supervisor") @@ -109,6 +121,10 @@ class TrainingItem(models.Model): name = models.CharField(max_length=50) description = models.TextField(blank=True) active = models.BooleanField(default=True) + technician_can_train = models.BooleanField( + default=False, + help_text="Technicians in this item's department who are passed out on it may deliver training up to Training Complete", + ) prerequisites = models.ManyToManyField("self", symmetrical=False, blank=True) objects = TrainingItemManager() @@ -210,7 +226,9 @@ class TrainingItemQualification(models.Model, RevisionMixin): errdict = {} # Validate supervisor can train in this item if hasattr(self, "supervisor"): # This will be false if form validation fails - if self.item.category.training_level: + if self.supervisor.can_deliver_training(self.item, self.depth): + pass + elif self.item.category.training_level: if not self.supervisor.level_qualifications.filter(level=self.item.category.training_level): errdict["supervisor"] = ( "Selected supervising person is missing requisite training level to train in this department" diff --git a/training/templates/base_training.html b/training/templates/base_training.html index 6a19ca85..7b90ba7f 100644 --- a/training/templates/base_training.html +++ b/training/templates/base_training.html @@ -30,7 +30,7 @@ Item List - {% if request.user.is_supervisor %} + {% if request.user.can_log_training %} {% endif %} diff --git a/training/tests/test_unit.py b/training/tests/test_unit.py index 8bb916dd..c7d379a8 100644 --- a/training/tests/test_unit.py +++ b/training/tests/test_unit.py @@ -95,3 +95,93 @@ def test_trainee_list_search(admin_client, admin_user, trainee, supervisor): response = admin_client.get(url, {"q": trainee.get_full_name()}) assertContains(response, trainee.get_full_name()) assertNotContains(response, supervisor.get_full_name()) + + +def _make_technician(trainee, department=models.TrainingLevel.SOUND): + tech_level = models.TrainingLevel.objects.create( + level=models.TrainingLevel.TECHNICIAN, department=department, description="x" + ) + models.TrainingLevelQualification.objects.create( + trainee=models.Trainee.objects.get(pk=trainee.pk), level=tech_level, confirmed_on=timezone.now() + ) + return tech_level + + +def _technician_setup(trainee, supervisor, flag=True, department=models.TrainingLevel.SOUND): + tech_level = _make_technician(trainee, department) + category = models.TrainingCategory.objects.create(reference_number=6, name="Sound", training_level=tech_level) + item = models.TrainingItem.objects.create( + category=category, reference_number=1, name="FOH", technician_can_train=flag + ) + today = datetime.date.today() + models.TrainingItemQualification.objects.create( + item=item, + depth=models.TrainingItemQualification.PASSED_OUT, + trainee=models.Trainee.objects.get(pk=trainee.pk), + supervisor=models.Trainee.objects.get(pk=supervisor.pk), + date=today, + ) + return item + + +def test_technician_can_deliver_training(trainee, supervisor): + item = _technician_setup(trainee, supervisor) + tech = models.Trainee.objects.get(pk=trainee.pk) + assert tech.can_deliver_training(item, models.TrainingItemQualification.COMPLETE) + assert not tech.can_deliver_training(item, models.TrainingItemQualification.PASSED_OUT) + + +def test_technician_cannot_train_unflagged_item(trainee, supervisor): + item = _technician_setup(trainee, supervisor, flag=False) + assert not models.Trainee.objects.get(pk=trainee.pk).can_deliver_training( + item, models.TrainingItemQualification.COMPLETE + ) + + +def test_technician_cannot_train_without_passout(trainee, supervisor): + item = _technician_setup(trainee, supervisor) + models.TrainingItemQualification.objects.filter(item=item).delete() + assert not models.Trainee.objects.get(pk=trainee.pk).can_deliver_training( + item, models.TrainingItemQualification.COMPLETE + ) + + +def test_technician_cannot_train_other_department(trainee, supervisor): + item = _technician_setup(trainee, supervisor, department=models.TrainingLevel.LIGHTING) + other = models.TrainingLevel.objects.create( + level=models.TrainingLevel.TECHNICIAN, department=models.TrainingLevel.SOUND, description="x" + ) + item.category.training_level = other + item.category.save() + assert not models.Trainee.objects.get(pk=trainee.pk).can_deliver_training( + item, models.TrainingItemQualification.COMPLETE + ) + + +def test_technician_session_log(client, trainee, supervisor, admin_user): + item = _technician_setup(trainee, supervisor) + client.force_login(trainee) + url = reverse("session_log") + assert client.get(url).status_code == 200 + data = { + "trainees": [admin_user.pk], + "items_1": [item.pk], + "supervisor": trainee.pk, + "date": datetime.date.today().strftime("%Y-%m-%d"), + } + assert client.post(url, data).status_code == 302 + assert models.TrainingItemQualification.objects.filter( + trainee=admin_user.pk, item=item, depth=models.TrainingItemQualification.COMPLETE + ).exists() + # Passing out is not allowed + response = client.post(url, {**data, "items_1": [], "items_2": [item.pk]}) + assert response.status_code == 200 + assert "items_2" in response.context["form"].errors + # Nor is naming someone else as the supervisor + response = client.post(url, {**data, "supervisor": supervisor.pk}) + assert "supervisor" in response.context["form"].errors + + +def test_plain_trainee_cannot_log_session(client, trainee): + client.force_login(trainee) + assert client.get(reverse("session_log")).status_code == 403 diff --git a/training/urls.py b/training/urls.py index 59943003..05f7d503 100644 --- a/training/urls.py +++ b/training/urls.py @@ -1,7 +1,7 @@ from django.urls import path from django.contrib.auth.decorators import login_required -from training.decorators import is_supervisor +from training.decorators import is_supervisor, is_supervisor_or_technician from training import views, models from versioning.views import VersionHistory @@ -22,12 +22,12 @@ urlpatterns = [ ), # Not picked up automatically because proxy model (I think) path( "trainee//add_qualification/", - is_supervisor()(views.AddQualification.as_view()), + is_supervisor_or_technician()(views.AddQualification.as_view()), name="add_qualification", ), path( "trainee/edit_qualification//", - is_supervisor()(views.EditQualification.as_view()), + is_supervisor_or_technician()(views.EditQualification.as_view()), name="edit_qualification", ), path("levels/", login_required(views.LevelList.as_view()), name="level_list"), @@ -47,5 +47,5 @@ urlpatterns = [ name="confirm_level", ), path("trainee//item_record", login_required(views.TraineeItemDetail.as_view()), name="trainee_item_detail"), - path("session_log", is_supervisor()(views.SessionLog.as_view()), name="session_log"), + path("session_log", is_supervisor_or_technician()(views.SessionLog.as_view()), name="session_log"), ] diff --git a/training/views.py b/training/views.py index d72b00e5..218c69a5 100644 --- a/training/views.py +++ b/training/views.py @@ -155,6 +155,7 @@ class AddQualification(generic.CreateView, ModalURLMixin): def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["pk"] = self.kwargs["pk"] + kwargs["user"] = self.request.user return kwargs @@ -163,6 +164,17 @@ class EditQualification(generic.UpdateView, ModalURLMixin): model = models.TrainingItemQualification form_class = forms.QualificationForm + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + + def get_queryset(self): + queryset = super().get_queryset() + if not self.request.user.is_supervisor: # Technicians may only edit records they delivered + queryset = queryset.filter(supervisor=self.request.user.pk) + return queryset + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["depths"] = models.TrainingItemQualification.CHOICES @@ -251,6 +263,11 @@ class SessionLog(generic.FormView): form_class = forms.SessionLogForm success_url = reverse_lazy("trainee_list") + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + def form_valid(self, form, *args, **kwargs): for trainee in form.cleaned_data.get("trainees", []): for depth in models.TrainingItemQualification.CHOICES: