From 77f903a9cba2bc228a7e7736cfb2598692aa6148 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 3 Oct 2026 17:46:59 +0100 Subject: [PATCH] Fix training items for Haulage Supervisors --- RIGS/models.py | 2 +- training/forms.py | 8 +-- ...category_training_level_supervisor_help.py | 19 +++++++ training/models.py | 34 +++++++++--- training/templates/session_log_form.html | 4 +- training/tests/test_unit.py | 54 +++++++++++++++++++ training/views.py | 9 ++-- 7 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 training/migrations/0011_trainingcategory_training_level_supervisor_help.py diff --git a/RIGS/models.py b/RIGS/models.py index f65d562e..cd88c585 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -51,7 +51,7 @@ class Profile(AbstractUser): def can_log_training(self): from training.models import Trainee - return self.is_supervisor or Trainee.objects.get(pk=self.pk).is_technician + return self.is_supervisor or Trainee.objects.get(pk=self.pk).can_log_training @classmethod def make_api_key(cls): diff --git a/training/forms.py b/training/forms.py index f81d4e1f..385ca06d 100644 --- a/training/forms.py +++ b/training/forms.py @@ -80,12 +80,12 @@ class SessionLogForm(forms.Form): 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 depth, _ in models.TrainingItemQualification.CHOICES: 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}") + self.add_error( + f"items_{depth}", f"You are not permitted to deliver this training in {item}" + ) return cleaned_data def clean_date(self): diff --git a/training/migrations/0011_trainingcategory_training_level_supervisor_help.py b/training/migrations/0011_trainingcategory_training_level_supervisor_help.py new file mode 100644 index 00000000..26ce06f8 --- /dev/null +++ b/training/migrations/0011_trainingcategory_training_level_supervisor_help.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.17 on 2026-10-03 16:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('training', '0010_trainingcategory_training_level_help'), + ] + + operations = [ + migrations.AlterField( + model_name='trainingcategory', + name='training_level', + field=models.ForeignKey(blank=True, help_text='If this is set, technicians must hold this level (e.g. Sound Technician) to deliver training in technician-trainable items in this category, and holders of a Supervisor level (e.g. Haulage Supervisor) may pass people out in it. If not set, any technician may deliver training in technician-trainable items.', null=True, on_delete=django.db.models.deletion.CASCADE, to='training.traininglevel'), + ), + ] diff --git a/training/models.py b/training/models.py index 18a42fa1..f9d881d2 100644 --- a/training/models.py +++ b/training/models.py @@ -56,12 +56,34 @@ class Trainee(Profile, RevisionMixin): def is_driver(self): return self.confirmed_levels.filter(level__department=TrainingLevel.HAULAGE).exists() + @property + def can_log_training(self): + """Whether this person may use the training logging pages at all""" + category_levels = TrainingCategory.objects.exclude(training_level=None).values("training_level") + return ( + self.is_supervisor or self.is_technician or self.confirmed_levels.filter(level__in=category_levels).exists() + ) + def can_deliver_training(self, item, depth): - """Technicians may deliver training up to Training Complete on flagged items that they have been passed out on. - If the item's category specifies a training level, they must also hold that level, otherwise any technician will do.""" - if depth == TrainingItemQualification.PASSED_OUT or not item.technician_can_train: - return False + """Whether this person may record training of the given depth in the item (i.e. be named as the supervisor) + + - Supervisors may deliver anything. + - Passing out requires holding a Supervisor level set on the item's category (e.g. Haulage Supervisor) + and being passed out in the item oneself. + - Started/Complete require the item to be technician trainable and the person to be passed out in it + themselves, and to hold the category's level, or to be a technician if the category has none.""" + if self.is_supervisor: + return True training_level = item.category.training_level + if depth == TrainingItemQualification.PASSED_OUT: + return ( + training_level is not None + and training_level.level == TrainingLevel.SUPERVISOR + and self.confirmed_levels.filter(level=training_level).exists() + and self.is_user_qualified_in(item, TrainingItemQualification.PASSED_OUT) + ) + if not item.technician_can_train: + return False if training_level is None: is_permitted = self.is_technician else: @@ -95,7 +117,7 @@ class TrainingCategory(models.Model): on_delete=models.CASCADE, null=True, blank=True, - help_text="If this is set, technicians must hold this level (e.g. Sound Technician) to deliver training in technician-trainable items in this category. If not set, any technician may.", + help_text="If this is set, technicians must hold this level (e.g. Sound Technician) to deliver training in technician-trainable items in this category, and holders of a Supervisor level (e.g. Haulage Supervisor) may pass people out in it. If not set, any technician may deliver training in technician-trainable items.", ) def __str__(self): @@ -227,7 +249,7 @@ 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 not self.supervisor.can_deliver_training(self.item, self.depth) and not self.supervisor.is_supervisor: + if not self.supervisor.can_deliver_training(self.item, self.depth): errdict["supervisor"] = "Selected supervisor must actually *be* a supervisor..." # Item requirements only apply to being passed out if self.depth == TrainingItemQualification.PASSED_OUT and not self.item.user_has_requirements(self.trainee): diff --git a/training/templates/session_log_form.html b/training/templates/session_log_form.html index e3af17a7..ff17cb5f 100644 --- a/training/templates/session_log_form.html +++ b/training/templates/session_log_form.html @@ -61,11 +61,11 @@
-

You may deliver up to Training Complete in these items, as you are a technician who is passed out in them. Passing people out is for supervisors only.

+

You may deliver training in these items. Passing people out is only possible where marked.

{% for category, items in deliverable_items %}
{{ category }}
    - {% for item in items %}
  • {{ item.display_name }}
  • {% endfor %} + {% for item in items %}
  • {{ item.display_name }}{% if item.can_pass_out %} (including passing out){% endif %}
  • {% endfor %}
{% empty %}

You can't currently deliver training in any items. An item must be marked as technician-trainable, you must be passed out in it, and you must hold any technician level its category requires.

diff --git a/training/tests/test_unit.py b/training/tests/test_unit.py index 05f71d39..618deea1 100644 --- a/training/tests/test_unit.py +++ b/training/tests/test_unit.py @@ -215,3 +215,57 @@ def test_plain_trainee_cannot_log_session(client, trainee): def test_supervisor_does_not_see_deliverable_items(admin_client): response = admin_client.get(reverse("session_log")) assertNotContains(response, "What training items can I deliver training in?") + + +def _haulage_setup(trainee, supervisor): + haul_level = models.TrainingLevel.objects.create( + level=models.TrainingLevel.SUPERVISOR, department=models.TrainingLevel.HAULAGE, description="x" + ) + models.TrainingLevelQualification.objects.create( + trainee=models.Trainee.objects.get(pk=trainee.pk), level=haul_level, confirmed_on=timezone.now() + ) + category = models.TrainingCategory.objects.create(reference_number=4, name="Haulage", training_level=haul_level) + return models.TrainingItem.objects.create(category=category, reference_number=1, name="Tail lifts") + + +def test_haulage_supervisor_can_pass_out_without_supervisor_flag(client, trainee, supervisor, admin_user): + item = _haulage_setup(trainee, supervisor) + tech = models.Trainee.objects.get(pk=trainee.pk) + assert not tech.is_supervisor + assert tech.can_log_training + # Must be passed out in the item oneself + assert not tech.can_deliver_training(item, models.TrainingItemQualification.PASSED_OUT) + models.TrainingItemQualification.objects.create( + item=item, + depth=models.TrainingItemQualification.PASSED_OUT, + trainee=tech, + supervisor=models.Trainee.objects.get(pk=supervisor.pk), + date=datetime.date.today(), + ) + assert tech.can_deliver_training(item, models.TrainingItemQualification.PASSED_OUT) + client.force_login(trainee) + url = reverse("session_log") + data = { + "trainees": [admin_user.pk], + "items_2": [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.PASSED_OUT + ).exists() + + +def test_technician_level_does_not_allow_pass_out(trainee, supervisor): + item = _technician_setup(trainee, supervisor) + assert not models.Trainee.objects.get(pk=trainee.pk).can_deliver_training( + item, models.TrainingItemQualification.PASSED_OUT + ) + + +def test_flagged_supervisor_can_deliver_anything(trainee, supervisor): + item = _technician_setup(trainee, supervisor) + sup = models.Trainee.objects.get(pk=supervisor.pk) + for depth, _ in models.TrainingItemQualification.CHOICES: + assert sup.can_deliver_training(item, depth) diff --git a/training/views.py b/training/views.py index 1794bb23..4944b708 100644 --- a/training/views.py +++ b/training/views.py @@ -299,12 +299,15 @@ class SessionLog(generic.FormView): def get_deliverable_items(self): """Active items the current user may deliver training in, grouped by category, using the same test as form validation""" trainee = models.Trainee.objects.get(pk=self.request.user.pk) - items = models.TrainingItem.objects.filter(active=True, technician_can_train=True).select_related( - "category", "category__training_level" + items = ( + models.TrainingItem.objects.filter(active=True) + .filter(Q(technician_can_train=True) | Q(category__training_level__level=models.TrainingLevel.SUPERVISOR)) + .select_related("category", "category__training_level") ) grouped = {} for item in items: - if trainee.can_deliver_training(item, models.TrainingItemQualification.COMPLETE): + item.can_pass_out = trainee.can_deliver_training(item, models.TrainingItemQualification.PASSED_OUT) + if item.can_pass_out or trainee.can_deliver_training(item, models.TrainingItemQualification.COMPLETE): grouped.setdefault(item.category, []).append(item) return list(grouped.items())