mirror of
https://github.com/nottinghamtec/PyRIGS.git
synced 2026-10-05 03:55:37 +00:00
* build: move frontend assets to Bootstrap 5.3 and Tom Select * feat: migrate templates and scripts to Bootstrap 5, dropping jQuery Templates - Rename Bootstrap 4 classes and data attributes (data-bs-*, ms-/me-/ps-/pe-, text-start/end, form-group -> mb-3, input-group wrappers removed, badges, custom controls -> form-check/form-select, media -> d-flex, btn-close, ...) - Dark mode now uses Bootstrap 5.3 colour modes (data-bs-theme on <html>), set from <head> to avoid a flash of the light theme - Remove the Bootstrap 4 /bootstrap/ demo page - Contextual table classes (table-success etc.) still work as plain backgrounds for the rigboard key JavaScript - Remove jQuery: src.js/interaction.js and every inline template script are rewritten in plain JS on top of small helpers (helpers.js, bundled with Bootstrap into jpop.js so inline scripts can use them) - Replace bootstrap-select/ajax-bootstrap-select with Tom Select. The remote search now sends the `q` parameter and reads `text`, which is what the API actually uses (the old picker sent `term` and read `label`) - HTML fragments are fetched with an X-Requested-With header so views keep rendering their modal variants, and fetched scripts are re-executed - Remove the Bootstrap 4 plugin script includes * fix: stop rigboard header buttons stretching to full width under Bootstrap 5 * fix: restore dark-mode brand colours and contextual table colours under Bootstrap 5 * fix: brighten dark-mode links and make coloured card outlines visible * fix: calendar layout and grid borders, navbar search button width, guard missing modal links * fix: readable text in dark-mode Tom Select inputs and placeholders, restore form-control column widths * build: split the Dockerfile into cached stages with prod and dev targets * deploy: make compose.yml development-only with live reload, and document it * fix: keep badge text colour for links inside badges * fix: restore table-bordered grid lines in dark mode * fix: include Bootstrap's progress styles and keep progress labels readable
415 lines
15 KiB
Python
415 lines
15 KiB
Python
from RIGS.models import Profile, filter_by_pk
|
|
from reversion import revisions as reversion
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.db.models import Q
|
|
from django.urls import reverse
|
|
from django.utils.safestring import mark_safe
|
|
from versioning.versioning import RevisionMixin
|
|
from queryable_properties.properties import queryable_property
|
|
from queryable_properties.managers import QueryablePropertiesManager
|
|
|
|
|
|
class TraineeManager(models.Manager):
|
|
def get_queryset(self):
|
|
return super().get_queryset().filter(is_active=True, is_approved=True)
|
|
|
|
def search(self, query=None):
|
|
qs = self.get_queryset()
|
|
if query is not None:
|
|
or_lookup = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(initials__icontains=query)
|
|
or_lookup = filter_by_pk(or_lookup, query)
|
|
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
|
|
return qs
|
|
|
|
|
|
@reversion.register(for_concrete_model=False, fields=["is_supervisor"])
|
|
class Trainee(Profile, RevisionMixin):
|
|
class Meta:
|
|
proxy = True
|
|
|
|
objects = TraineeManager()
|
|
|
|
# FIXME use queryset
|
|
def started_levels(self):
|
|
return [
|
|
level
|
|
for level in TrainingLevel.objects.all()
|
|
if level.percentage_complete(self) > 0
|
|
and level.pk not in self.level_qualifications.values_list("level", flat=True)
|
|
]
|
|
|
|
@property
|
|
def confirmed_levels(self):
|
|
return self.level_qualifications.exclude(confirmed_on=None).select_related("level")
|
|
|
|
@property
|
|
def is_technician(self):
|
|
return (
|
|
self.confirmed_levels.filter(level__level=TrainingLevel.TECHNICIAN)
|
|
.exclude(level__department=TrainingLevel.HAULAGE)
|
|
.exclude(level__department__isnull=True)
|
|
.exists()
|
|
)
|
|
|
|
@property
|
|
def is_driver(self):
|
|
return self.confirmed_levels.filter(level__department=TrainingLevel.HAULAGE).exists()
|
|
|
|
def get_records_of_depth(self, depth):
|
|
return self.qualifications_obtained.filter(depth=depth).select_related("item", "trainee", "supervisor")
|
|
|
|
def is_user_qualified_in(self, item, required_depth):
|
|
return (
|
|
self.qualifications_obtained.values("item", "depth")
|
|
.filter(item=item)
|
|
.filter(depth__gte=required_depth)
|
|
.exists()
|
|
)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("trainee_detail", kwargs={"pk": self.pk})
|
|
|
|
@property
|
|
def display_id(self):
|
|
return str(self)
|
|
|
|
|
|
class TrainingCategory(models.Model):
|
|
reference_number = models.IntegerField(unique=True)
|
|
name = models.CharField(max_length=50)
|
|
training_level = models.ForeignKey(
|
|
"TrainingLevel",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
help_text="If this is set, any user with the selected level may pass out users within this category, regardless of other status",
|
|
)
|
|
|
|
def __str__(self):
|
|
return f"{self.reference_number}. {self.name}"
|
|
|
|
class Meta:
|
|
verbose_name_plural = "Training Categories"
|
|
ordering = ["reference_number"]
|
|
|
|
|
|
class TrainingItemManager(QueryablePropertiesManager):
|
|
def search(self, query=None):
|
|
qs = self.get_queryset()
|
|
if query is not None:
|
|
or_lookup = Q(name__icontains=query) | Q(description__icontains=query) | Q(display_id=query)
|
|
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
|
|
return qs
|
|
|
|
|
|
@reversion.register
|
|
class TrainingItem(models.Model):
|
|
reference_number = models.IntegerField()
|
|
category = models.ForeignKey("TrainingCategory", related_name="items", on_delete=models.CASCADE)
|
|
name = models.CharField(max_length=50)
|
|
description = models.TextField(blank=True)
|
|
active = models.BooleanField(default=True)
|
|
prerequisites = models.ManyToManyField("self", symmetrical=False, blank=True)
|
|
|
|
objects = TrainingItemManager()
|
|
|
|
@queryable_property
|
|
def display_id(self):
|
|
return f"{self.category.reference_number}.{self.reference_number}"
|
|
|
|
@property
|
|
def display_name(self):
|
|
return f"{self.display_id} | {self.name}"
|
|
|
|
@display_id.filter
|
|
@classmethod
|
|
def display_id(cls, lookup, value):
|
|
if "." in str(value):
|
|
try:
|
|
category_number, number = value.split(".", 2)
|
|
if category_number and number:
|
|
return Q(category__reference_number=int(category_number), reference_number=int(number))
|
|
except ValueError:
|
|
pass
|
|
return models.Q()
|
|
|
|
def __str__(self):
|
|
name = f"{self.display_id} {self.name}"
|
|
if not self.active:
|
|
name += " (inactive)"
|
|
return name
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("item_list")
|
|
|
|
def has_prereqs(self):
|
|
return self.prerequisites.all().exists()
|
|
|
|
def user_has_requirements(self, user):
|
|
# Always true if there are no prerequisites, otherwise get a set of prerequsite IDs and check if they are a subset of the set of qualification IDs
|
|
return not self.has_prereqs() or set(self.prerequisites.values_list("pk", flat=True)).issubset(
|
|
set(user.qualifications_obtained.values_list("item", flat=True))
|
|
)
|
|
|
|
@staticmethod
|
|
def user_has_qualification(item, user, depth):
|
|
return user.qualifications_obtained.only("item", "depth").filter(item=item, depth__gte=depth).exists()
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["reference_number", "active", "category"], name="unique_reference_number_active_category"
|
|
)
|
|
]
|
|
ordering = ["category__reference_number", "reference_number"]
|
|
|
|
|
|
class TrainingItemQualificationManager(QueryablePropertiesManager):
|
|
def search(self, query=None):
|
|
qs = self.get_queryset().select_related("item", "supervisor", "item__category")
|
|
if query is not None:
|
|
or_lookup = (
|
|
Q(item__name__icontains=query)
|
|
| Q(supervisor__first_name__icontains=query)
|
|
| Q(supervisor__last_name__icontains=query)
|
|
| Q(item__category__name__icontains=query)
|
|
| Q(item__display_id=query)
|
|
)
|
|
|
|
try:
|
|
or_lookup = Q(item__category__reference_number=int(query)) | or_lookup
|
|
except: # noqa
|
|
pass
|
|
|
|
qs = qs.filter(or_lookup).distinct()
|
|
return qs
|
|
|
|
|
|
@reversion.register
|
|
class TrainingItemQualification(models.Model, RevisionMixin):
|
|
STARTED = 0
|
|
COMPLETE = 1
|
|
PASSED_OUT = 2
|
|
CHOICES = (
|
|
(STARTED, "Training Started"),
|
|
(COMPLETE, "Training Complete"),
|
|
(PASSED_OUT, "Passed Out"),
|
|
)
|
|
item = models.ForeignKey("TrainingItem", on_delete=models.CASCADE)
|
|
depth = models.IntegerField(choices=CHOICES)
|
|
trainee = models.ForeignKey("Trainee", related_name="qualifications_obtained", on_delete=models.CASCADE)
|
|
date = models.DateField()
|
|
# TODO Remember that some training is external. Support for making an organisation the trainer?
|
|
supervisor = models.ForeignKey("Trainee", related_name="qualifications_granted", on_delete=models.CASCADE)
|
|
notes = models.TextField(blank=True)
|
|
# TODO Maximum depth - some things stop at Complete and you can't be passed out in them
|
|
|
|
objects = TrainingItemQualificationManager()
|
|
|
|
def clean(self):
|
|
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 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"
|
|
)
|
|
elif not self.supervisor.is_supervisor:
|
|
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):
|
|
errdict["item"] = "Missing prerequisites"
|
|
if errdict != {}: # If there was an error when validation
|
|
raise ValidationError(errdict)
|
|
|
|
def __str__(self):
|
|
return f"{self.get_depth_display()} in {self.item} on {self.date.strftime('%b %d %Y')}"
|
|
|
|
@property
|
|
def activity_feed_string(self):
|
|
return f"{self.trainee} {self.get_depth_display().lower()} in {self.item}"
|
|
|
|
@classmethod
|
|
def get_colour_from_depth(cls, depth):
|
|
if depth == 0:
|
|
return "warning"
|
|
if depth == 1:
|
|
return "success"
|
|
|
|
return "info"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("edit_qualification", kwargs={"pk": self.pk})
|
|
|
|
class Meta:
|
|
constraints = [models.UniqueConstraint(fields=["trainee", "item", "depth"], name="unique_trainee_item_depth")]
|
|
order_with_respect_to = "item"
|
|
|
|
|
|
# Levels
|
|
@reversion.register(follow=["requirements"])
|
|
class TrainingLevel(models.Model, RevisionMixin):
|
|
description = models.TextField(blank=True)
|
|
TA = 0
|
|
TECHNICIAN = 1
|
|
SUPERVISOR = 2
|
|
CHOICES = (
|
|
(TA, "Technical Assistant"),
|
|
(TECHNICIAN, "Technician"),
|
|
(SUPERVISOR, "Supervisor"),
|
|
)
|
|
SOUND = 0
|
|
LIGHTING = 1
|
|
POWER = 2
|
|
RIGGING = 3
|
|
HAULAGE = 4
|
|
DEPARTMENTS = (
|
|
(SOUND, "Sound"),
|
|
(LIGHTING, "Lighting"),
|
|
(POWER, "Power"),
|
|
(RIGGING, "Rigging"),
|
|
(HAULAGE, "Haulage"),
|
|
)
|
|
department = models.IntegerField(
|
|
choices=DEPARTMENTS, null=True, blank=True
|
|
) # N.B. Technical Assistant does not have a department
|
|
level = models.IntegerField(choices=CHOICES)
|
|
prerequisite_levels = models.ManyToManyField("self", related_name="prerequisites", symmetrical=False, blank=True)
|
|
icon = models.CharField(null=True, blank=True, max_length=20)
|
|
|
|
class Meta:
|
|
ordering = ["department", "level"]
|
|
|
|
@property
|
|
def department_colour(self):
|
|
if self.department == self.SOUND:
|
|
return "info"
|
|
if self.department == self.LIGHTING:
|
|
return "dark"
|
|
if self.department == self.POWER:
|
|
return "danger"
|
|
if self.department == self.RIGGING:
|
|
return "warning"
|
|
if self.department == self.HAULAGE:
|
|
return "light"
|
|
|
|
return "primary"
|
|
|
|
def get_requirements_of_depth(self, depth):
|
|
return self.requirements.filter(depth=depth)
|
|
|
|
@property
|
|
def is_common_competencies(self):
|
|
return self.department is None and self.level > 0
|
|
|
|
@property
|
|
def started_requirements(self):
|
|
return self.get_requirements_of_depth(TrainingItemQualification.STARTED)
|
|
|
|
@property
|
|
def complete_requirements(self):
|
|
return self.get_requirements_of_depth(TrainingItemQualification.COMPLETE)
|
|
|
|
@property
|
|
def passed_out_requirements(self):
|
|
return self.get_requirements_of_depth(TrainingItemQualification.PASSED_OUT)
|
|
|
|
def percentage_complete(self, user):
|
|
needed_qualifications = self.requirements.all().select_related("item")
|
|
relavant_qualifications = 0.0
|
|
# TODO Efficiency...
|
|
for req in needed_qualifications:
|
|
if user.is_user_qualified_in(req.item, req.depth):
|
|
relavant_qualifications += 1.0
|
|
|
|
if len(needed_qualifications) > 0:
|
|
return int(relavant_qualifications / float(len(needed_qualifications)) * 100)
|
|
|
|
return 0
|
|
|
|
def user_has_requirements(self, user):
|
|
has_required_items = all(
|
|
TrainingItem.user_has_qualification(req.item, user, req.depth) for req in self.requirements.all()
|
|
)
|
|
# Always true if there are no prerequisites, otherwise get a set of prerequsite IDs and check if they are a subset of the set of qualification IDs
|
|
has_required_levels = not self.prerequisite_levels.all().exists() or set(
|
|
self.prerequisite_levels.values_list("pk", flat=True)
|
|
).issubset(set(user.level_qualifications.values_list("level", flat=True)))
|
|
return has_required_items and has_required_levels
|
|
|
|
def __str__(self):
|
|
if self.department is None:
|
|
if self.level == self.TA:
|
|
return self.get_level_display()
|
|
else:
|
|
return f"{self.get_level_display()} Common Competencies"
|
|
else:
|
|
return f"{self.get_department_display()} {self.get_level_display()}"
|
|
|
|
@property
|
|
def activity_feed_string(self):
|
|
return str(self)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("level_detail", kwargs={"pk": self.pk})
|
|
|
|
@property
|
|
def get_icon(self):
|
|
if self.icon is not None:
|
|
icon = f"<span class='fas fa-{self.icon}'></span>"
|
|
else:
|
|
icon = "".join([w[0] for w in str(self).split()])
|
|
return mark_safe(
|
|
f"<span class='badge text-bg-{self.department_colour} rounded-pill' data-bs-toggle='tooltip' title='{str(self)}'>{icon}</span>"
|
|
)
|
|
|
|
|
|
@reversion.register
|
|
class TrainingLevelRequirement(models.Model, RevisionMixin):
|
|
level = models.ForeignKey("TrainingLevel", related_name="requirements", on_delete=models.CASCADE)
|
|
item = models.ForeignKey("TrainingItem", on_delete=models.CASCADE)
|
|
depth = models.IntegerField(choices=TrainingItemQualification.CHOICES)
|
|
|
|
reversion_hide = True
|
|
|
|
def __str__(self):
|
|
depth = TrainingItemQualification.CHOICES[self.depth][1]
|
|
return f"{depth} in {self.item}"
|
|
|
|
class Meta:
|
|
constraints = [models.UniqueConstraint(fields=["level", "item"], name="unique_level_item")]
|
|
|
|
|
|
@reversion.register
|
|
class TrainingLevelQualification(models.Model, RevisionMixin):
|
|
trainee = models.ForeignKey("Trainee", related_name="level_qualifications", on_delete=models.CASCADE)
|
|
level = models.ForeignKey("TrainingLevel", on_delete=models.CASCADE)
|
|
confirmed_on = models.DateTimeField(null=True)
|
|
confirmed_by = models.ForeignKey("Trainee", related_name="confirmer", on_delete=models.CASCADE, null=True)
|
|
|
|
@property
|
|
def get_icon(self):
|
|
return self.level.get_icon
|
|
|
|
def clean(self):
|
|
if self.level.level >= TrainingLevel.SUPERVISOR and self.level.department != TrainingLevel.HAULAGE:
|
|
self.trainee.is_supervisor = True
|
|
self.trainee.save()
|
|
|
|
def __str__(self):
|
|
if self.level.is_common_competencies:
|
|
return f"{self.trainee} is qualified in the {self.level}"
|
|
return f"{self.trainee} is qualified as a {self.level}"
|
|
|
|
@property
|
|
def activity_feed_string(self):
|
|
return str(self)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("trainee_detail", kwargs={"pk": self.trainee_id})
|
|
|
|
class Meta:
|
|
constraints = [models.UniqueConstraint(fields=["trainee", "level"], name="unique_trainee_level")]
|
|
ordering = ["-confirmed_on"]
|