mirror of
https://github.com/nottinghamtec/PyRIGS.git
synced 2026-10-11 23:25:46 +00:00
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:
@@ -47,6 +47,12 @@ class Profile(AbstractUser):
|
|||||||
|
|
||||||
reversion_hide = True
|
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
|
@classmethod
|
||||||
def make_api_key(cls):
|
def make_api_key(cls):
|
||||||
size = 20
|
size = 20
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<a class="list-group-item list-group-item-action" href="{% url 'trainee_list' %}"><span class="fas fa-users"></span><span class="align-middle"> Trainee List</span></a>
|
<a class="list-group-item list-group-item-action" href="{% url 'trainee_list' %}"><span class="fas fa-users"></span><span class="align-middle"> Trainee List</span></a>
|
||||||
<a class="list-group-item list-group-item-action" href="{% url 'level_list' %}"><span class="fas fa-layer-group"></span> <span class="align-middle">Level List</span></a>
|
<a class="list-group-item list-group-item-action" href="{% url 'level_list' %}"><span class="fas fa-layer-group"></span> <span class="align-middle">Level List</span></a>
|
||||||
<a class="list-group-item list-group-item-action" href="{% url 'item_list' %}"><span class="fas fa-sitemap"></span> <span class="align-middle">Item List</span></a>
|
<a class="list-group-item list-group-item-action" href="{% url 'item_list' %}"><span class="fas fa-sitemap"></span> <span class="align-middle">Item List</span></a>
|
||||||
{% if request.user.is_supervisor %}
|
{% if request.user.can_log_training %}
|
||||||
<a class="list-group-item list-group-item-action" href="{% url 'session_log' %}"><span class="fas fa-users"></span> <span class="align-middle">Log Session</span></a>
|
<a class="list-group-item list-group-item-action" href="{% url 'session_log' %}"><span class="fas fa-users"></span> <span class="align-middle">Log Session</span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from reversion.admin import VersionAdmin
|
|||||||
|
|
||||||
|
|
||||||
admin.site.register(models.TrainingCategory, VersionAdmin)
|
admin.site.register(models.TrainingCategory, VersionAdmin)
|
||||||
admin.site.register(models.TrainingItem, VersionAdmin)
|
|
||||||
admin.site.register(models.TrainingLevel, VersionAdmin)
|
admin.site.register(models.TrainingLevel, VersionAdmin)
|
||||||
admin.site.register(models.TrainingLevelQualification, VersionAdmin)
|
admin.site.register(models.TrainingLevelQualification, VersionAdmin)
|
||||||
admin.site.register(models.TrainingLevelRequirement, VersionAdmin)
|
admin.site.register(models.TrainingLevelRequirement, VersionAdmin)
|
||||||
@@ -13,3 +12,9 @@ admin.site.register(models.TrainingLevelRequirement, VersionAdmin)
|
|||||||
@admin.register(models.TrainingItemQualification)
|
@admin.register(models.TrainingItemQualification)
|
||||||
class TrainingItemQualificationAdmin(VersionAdmin):
|
class TrainingItemQualificationAdmin(VersionAdmin):
|
||||||
list_display = ["__str__", "trainee"]
|
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"]
|
||||||
|
|||||||
@@ -3,3 +3,7 @@ from PyRIGS.decorators import user_passes_test_with_403
|
|||||||
|
|
||||||
def is_supervisor(login_url=None, oembed_view=None):
|
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)
|
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)
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ class QualificationForm(forms.ModelForm):
|
|||||||
supervisor = self.cleaned_data.get("supervisor")
|
supervisor = self.cleaned_data.get("supervisor")
|
||||||
if supervisor.pk == self.cleaned_data.get("trainee").pk:
|
if supervisor.pk == self.cleaned_data.get("trainee").pk:
|
||||||
raise forms.ValidationError("One may not supervise oneself...")
|
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
|
return supervisor
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.user = kwargs.pop("user", None)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["date"].widget.format = "%Y-%m-%d"
|
self.fields["date"].widget.format = "%Y-%m-%d"
|
||||||
|
|
||||||
@@ -63,6 +66,28 @@ class SessionLogForm(forms.Form):
|
|||||||
|
|
||||||
related_models = {"supervisor": models.Trainee}
|
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):
|
def clean_date(self):
|
||||||
return QualificationForm.clean_date(self)
|
return QualificationForm.clean_date(self)
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -56,6 +56,18 @@ 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()
|
||||||
|
|
||||||
|
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):
|
def get_records_of_depth(self, depth):
|
||||||
return self.qualifications_obtained.filter(depth=depth).select_related("item", "trainee", "supervisor")
|
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)
|
name = models.CharField(max_length=50)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
active = models.BooleanField(default=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)
|
prerequisites = models.ManyToManyField("self", symmetrical=False, blank=True)
|
||||||
|
|
||||||
objects = TrainingItemManager()
|
objects = TrainingItemManager()
|
||||||
@@ -210,7 +226,9 @@ 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 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):
|
if not self.supervisor.level_qualifications.filter(level=self.item.category.training_level):
|
||||||
errdict["supervisor"] = (
|
errdict["supervisor"] = (
|
||||||
"Selected supervising person is missing requisite training level to train in this department"
|
"Selected supervising person is missing requisite training level to train in this department"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<a class="dropdown-item" href="{% url 'item_list' %}"><span class="fas fa-sitemap"></span> Item List</a>
|
<a class="dropdown-item" href="{% url 'item_list' %}"><span class="fas fa-sitemap"></span> Item List</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</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>
|
<li class="nav-item"><a class="nav-link" href="{% url 'session_log' %}"><span class="fas fa-users"></span> Log Session</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'training_activity_table' %}"><span class="fas fa-random"></span> Recent Changes</a></li>
|
<li class="nav-item"><a class="nav-link" href="{% url 'training_activity_table' %}"><span class="fas fa-random"></span> Recent Changes</a></li>
|
||||||
|
|||||||
@@ -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()})
|
response = admin_client.get(url, {"q": trainee.get_full_name()})
|
||||||
assertContains(response, trainee.get_full_name())
|
assertContains(response, trainee.get_full_name())
|
||||||
assertNotContains(response, supervisor.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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
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 training import views, models
|
||||||
from versioning.views import VersionHistory
|
from versioning.views import VersionHistory
|
||||||
@@ -22,12 +22,12 @@ urlpatterns = [
|
|||||||
), # Not picked up automatically because proxy model (I think)
|
), # Not picked up automatically because proxy model (I think)
|
||||||
path(
|
path(
|
||||||
"trainee/<int:pk>/add_qualification/",
|
"trainee/<int:pk>/add_qualification/",
|
||||||
is_supervisor()(views.AddQualification.as_view()),
|
is_supervisor_or_technician()(views.AddQualification.as_view()),
|
||||||
name="add_qualification",
|
name="add_qualification",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"trainee/edit_qualification/<int:pk>/",
|
"trainee/edit_qualification/<int:pk>/",
|
||||||
is_supervisor()(views.EditQualification.as_view()),
|
is_supervisor_or_technician()(views.EditQualification.as_view()),
|
||||||
name="edit_qualification",
|
name="edit_qualification",
|
||||||
),
|
),
|
||||||
path("levels/", login_required(views.LevelList.as_view()), name="level_list"),
|
path("levels/", login_required(views.LevelList.as_view()), name="level_list"),
|
||||||
@@ -47,5 +47,5 @@ urlpatterns = [
|
|||||||
name="confirm_level",
|
name="confirm_level",
|
||||||
),
|
),
|
||||||
path("trainee/<int:pk>/item_record", login_required(views.TraineeItemDetail.as_view()), name="trainee_item_detail"),
|
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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class AddQualification(generic.CreateView, ModalURLMixin):
|
|||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
kwargs["pk"] = self.kwargs["pk"]
|
kwargs["pk"] = self.kwargs["pk"]
|
||||||
|
kwargs["user"] = self.request.user
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +164,17 @@ class EditQualification(generic.UpdateView, ModalURLMixin):
|
|||||||
model = models.TrainingItemQualification
|
model = models.TrainingItemQualification
|
||||||
form_class = forms.QualificationForm
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["depths"] = models.TrainingItemQualification.CHOICES
|
context["depths"] = models.TrainingItemQualification.CHOICES
|
||||||
@@ -251,6 +263,11 @@ class SessionLog(generic.FormView):
|
|||||||
form_class = forms.SessionLogForm
|
form_class = forms.SessionLogForm
|
||||||
success_url = reverse_lazy("trainee_list")
|
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):
|
def form_valid(self, form, *args, **kwargs):
|
||||||
for trainee in form.cleaned_data.get("trainees", []):
|
for trainee in form.cleaned_data.get("trainees", []):
|
||||||
for depth in models.TrainingItemQualification.CHOICES:
|
for depth in models.TrainingItemQualification.CHOICES:
|
||||||
|
|||||||
Reference in New Issue
Block a user