Fix training items for Haulage Supervisors

This commit is contained in:
Joe Banks
2026-10-03 17:46:59 +01:00
parent 3ec872d7ed
commit 77f903a9cb
7 changed files with 114 additions and 16 deletions

View File

@@ -51,7 +51,7 @@ class Profile(AbstractUser):
def can_log_training(self): def can_log_training(self):
from training.models import Trainee 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 @classmethod
def make_api_key(cls): def make_api_key(cls):

View File

@@ -80,12 +80,12 @@ class SessionLogForm(forms.Form):
if supervisor.pk != self.user.pk: if supervisor.pk != self.user.pk:
self.add_error("supervisor", "You may only log sessions that you delivered yourself") self.add_error("supervisor", "You may only log sessions that you delivered yourself")
else: else:
if cleaned_data.get("items_2"): for depth, _ in models.TrainingItemQualification.CHOICES:
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}", []): for item in cleaned_data.get(f"items_{depth}", []):
if not supervisor.can_deliver_training(item, 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 return cleaned_data
def clean_date(self): def clean_date(self):

View File

@@ -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'),
),
]

View File

@@ -56,12 +56,34 @@ class Trainee(Profile, RevisionMixin):
def is_driver(self): def is_driver(self):
return self.confirmed_levels.filter(level__department=TrainingLevel.HAULAGE).exists() 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): 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. """Whether this person may record training of the given depth in the item (i.e. be named as the supervisor)
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: - Supervisors may deliver anything.
return False - 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 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: if training_level is None:
is_permitted = self.is_technician is_permitted = self.is_technician
else: else:
@@ -95,7 +117,7 @@ class TrainingCategory(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True, null=True,
blank=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): def __str__(self):
@@ -227,7 +249,7 @@ class TrainingItemQualification(models.Model, RevisionMixin):
errdict = {} errdict = {}
# Validate supervisor can train in this item # Validate supervisor can train in this item
if hasattr(self, "supervisor"): # This will be false if form validation fails 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..." errdict["supervisor"] = "Selected supervisor must actually *be* a supervisor..."
# Item requirements only apply to being passed out # Item requirements only apply to being passed out
if self.depth == TrainingItemQualification.PASSED_OUT and not self.item.user_has_requirements(self.trainee): if self.depth == TrainingItemQualification.PASSED_OUT and not self.item.user_has_requirements(self.trainee):

View File

@@ -61,11 +61,11 @@
</button> </button>
<div class="collapse mt-3" id="deliverable_items"> <div class="collapse mt-3" id="deliverable_items">
<div class="card card-body"> <div class="card card-body">
<p class="text-muted">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.</p> <p class="text-muted">You may deliver training in these items. Passing people out is only possible where marked.</p>
{% for category, items in deliverable_items %} {% for category, items in deliverable_items %}
<h5>{{ category }}</h5> <h5>{{ category }}</h5>
<ul> <ul>
{% for item in items %}<li>{{ item.display_name }}</li>{% endfor %} {% for item in items %}<li>{{ item.display_name }}{% if item.can_pass_out %} <span class="text-muted">(including passing out)</span>{% endif %}</li>{% endfor %}
</ul> </ul>
{% empty %} {% empty %}
<p class="mb-0">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.</p> <p class="mb-0">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.</p>

View File

@@ -215,3 +215,57 @@ def test_plain_trainee_cannot_log_session(client, trainee):
def test_supervisor_does_not_see_deliverable_items(admin_client): def test_supervisor_does_not_see_deliverable_items(admin_client):
response = admin_client.get(reverse("session_log")) response = admin_client.get(reverse("session_log"))
assertNotContains(response, "What training items can I deliver training in?") 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)

View File

@@ -299,12 +299,15 @@ class SessionLog(generic.FormView):
def get_deliverable_items(self): def get_deliverable_items(self):
"""Active items the current user may deliver training in, grouped by category, using the same test as form validation""" """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) trainee = models.Trainee.objects.get(pk=self.request.user.pk)
items = models.TrainingItem.objects.filter(active=True, technician_can_train=True).select_related( items = (
"category", "category__training_level" 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 = {} grouped = {}
for item in items: 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) grouped.setdefault(item.category, []).append(item)
return list(grouped.items()) return list(grouped.items())