mirror of
https://github.com/nottinghamtec/PyRIGS.git
synced 2026-10-12 07:35:54 +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
|
||||
|
||||
@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
|
||||
|
||||
@@ -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 '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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user