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
This commit is contained in:
Joe Banks
2026-10-03 16:10:13 +01:00
committed by GitHub
parent abfc394088
commit 435e6688e1
11 changed files with 191 additions and 8 deletions

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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)

View File

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

View File

@@ -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"

View File

@@ -30,7 +30,7 @@
<a class="dropdown-item" href="{% url 'item_list' %}"><span class="fas fa-sitemap"></span> Item List</a>
</div>
</li>
{% if request.user.is_supervisor %}
{% if request.user.can_log_training %}
<li class="nav-item"><a class="nav-link" href="{% url 'session_log' %}"><span class="fas fa-users"></span> Log Session</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{% url 'training_activity_table' %}"><span class="fas fa-random"></span> Recent Changes</a></li>

View File

@@ -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

View File

@@ -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/<int:pk>/add_qualification/",
is_supervisor()(views.AddQualification.as_view()),
is_supervisor_or_technician()(views.AddQualification.as_view()),
name="add_qualification",
),
path(
"trainee/edit_qualification/<int:pk>/",
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/<int:pk>/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"),
]

View File

@@ -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: