Compare commits
9 Commits
a6c531c22f
...
35f812d2c6
Author | SHA1 | Date | |
---|---|---|---|
35f812d2c6 | |||
bb6577f3bb | |||
58de45134a | |||
250c96f1c2 | |||
b462e6b18f | |||
906f662419 | |||
67f13ce580 | |||
56d4601640 | |||
58d3787f9d |
@ -34,6 +34,7 @@ urlpatterns = [
|
|||||||
path("rentals/", include("rentals.urls")),
|
path("rentals/", include("rentals.urls")),
|
||||||
path("membershipworks/", include("membershipworks.urls")),
|
path("membershipworks/", include("membershipworks.urls")),
|
||||||
path("paperwork/", include("paperwork.urls")),
|
path("paperwork/", include("paperwork.urls")),
|
||||||
|
path("doorcontrol/", include("doorcontrol.urls")),
|
||||||
path("api/v1/", include((router.urls, "api"), namespace="v1")),
|
path("api/v1/", include((router.urls, "api"), namespace="v1")),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path(
|
path(
|
||||||
|
@ -3,11 +3,41 @@ from django.contrib import admin
|
|||||||
from .models import HIDEvent
|
from .models import HIDEvent
|
||||||
|
|
||||||
|
|
||||||
|
class IsRedFilter(admin.SimpleListFilter):
|
||||||
|
title = "Is Red"
|
||||||
|
parameter_name = "is_red"
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
("1", "Yes"),
|
||||||
|
("0", "No"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value() is None:
|
||||||
|
return queryset
|
||||||
|
else:
|
||||||
|
return queryset.filter(is_red=(self.value() == "1"))
|
||||||
|
|
||||||
|
|
||||||
@admin.register(HIDEvent)
|
@admin.register(HIDEvent)
|
||||||
class HIDEventAdmin(admin.ModelAdmin):
|
class HIDEventAdmin(admin.ModelAdmin):
|
||||||
search_fields = ["description", "forename", "surname", "cardholder_id"]
|
search_fields = ["forename", "surname", "cardholder_id"]
|
||||||
list_display = ["door_name", "timestamp", "event_type", "description", "is_red"]
|
list_display = ["door_name", "timestamp", "event_type", "description", "is_red"]
|
||||||
list_filter = ["door_name", "event_type"]
|
list_filter = [
|
||||||
|
"timestamp",
|
||||||
|
"door_name",
|
||||||
|
"event_type",
|
||||||
|
IsRedFilter,
|
||||||
|
]
|
||||||
|
readonly_fields = ["decoded_card_number"]
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).with_is_red().with_decoded_card_number()
|
||||||
|
|
||||||
|
@admin.display(boolean=True)
|
||||||
|
def is_red(self, obj):
|
||||||
|
return obj.is_red
|
||||||
|
|
||||||
def has_add_permission(self, request, obj=None):
|
def has_add_permission(self, request, obj=None):
|
||||||
return False
|
return False
|
||||||
|
@ -1,8 +1,74 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import ExpressionWrapper, F, Func, Q
|
||||||
|
from django.db.models.functions import Mod
|
||||||
|
|
||||||
|
|
||||||
|
class HIDEventQuerySet(models.QuerySet):
|
||||||
|
def with_is_red(self):
|
||||||
|
"""Based on `function isRedEvent` from /html/hid-global.js on a HID EDGE EVO Solo"""
|
||||||
|
return self.annotate(
|
||||||
|
is_red=ExpressionWrapper(
|
||||||
|
Q(
|
||||||
|
event_type__in=[
|
||||||
|
1022,
|
||||||
|
1023,
|
||||||
|
2024,
|
||||||
|
2029,
|
||||||
|
2036,
|
||||||
|
2042,
|
||||||
|
2043,
|
||||||
|
2046,
|
||||||
|
4041,
|
||||||
|
4042,
|
||||||
|
4043,
|
||||||
|
4044,
|
||||||
|
4045,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
output_field=models.BooleanField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def with_decoded_card_number(self):
|
||||||
|
# TODO: CONV and BIT_COUNT are MySQL/MariaDB specific
|
||||||
|
class Conv(Func):
|
||||||
|
function = "CONV"
|
||||||
|
arity = 3
|
||||||
|
# This is technically not true, but fine for my purposes
|
||||||
|
output_field = models.IntegerField()
|
||||||
|
|
||||||
|
class BitCount(Func):
|
||||||
|
function = "BIT_COUNT"
|
||||||
|
arity = 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
self.alias(card_number=Conv(F("raw_card_number"), 16, 10))
|
||||||
|
.alias(more_than_26_bits=F("card_number").bitrightshift(26))
|
||||||
|
.annotate(card_is_26_bit=Q(more_than_26_bits=0))
|
||||||
|
.alias(
|
||||||
|
parity_a=Mod(
|
||||||
|
BitCount(F("card_number").bitrightshift(1).bitand(0xFFF)), 2
|
||||||
|
),
|
||||||
|
parity_b=Mod(
|
||||||
|
BitCount(F("card_number").bitrightshift(13).bitand(0xFFF)), 2
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
card_is_valid_26_bit=~Q(parity_a=F("card_number").bitand(1))
|
||||||
|
& Q(parity_b=F("card_number").bitrightshift(25).bitand(1))
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
card_number_26_bit=F("card_number").bitrightshift(1).bitand(0xFFFF),
|
||||||
|
card_facility_code_26_bit=F("card_number")
|
||||||
|
.bitrightshift(17)
|
||||||
|
.bitand(0xFF),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HIDEvent(models.Model):
|
class HIDEvent(models.Model):
|
||||||
|
objects = HIDEventQuerySet.as_manager()
|
||||||
|
|
||||||
class EventType(models.IntegerChoices):
|
class EventType(models.IntegerChoices):
|
||||||
DENIED_ACCESS_CARD_NOT_FOUND = 1022, "Denied Access: Card Not Found"
|
DENIED_ACCESS_CARD_NOT_FOUND = 1022, "Denied Access: Card Not Found"
|
||||||
DENIED_ACCESS_ACCESS_PIN_NOT_FOUND = 1023, "Denied Access Access: PIN Not Found"
|
DENIED_ACCESS_ACCESS_PIN_NOT_FOUND = 1023, "Denied Access Access: PIN Not Found"
|
||||||
@ -89,28 +155,21 @@ class HIDEvent(models.Model):
|
|||||||
|
|
||||||
return event_types.get(self.event_type, f"Unknown Event Type {self.event_type}")
|
return event_types.get(self.event_type, f"Unknown Event Type {self.event_type}")
|
||||||
|
|
||||||
@admin.display(boolean=True)
|
|
||||||
def is_red(self):
|
|
||||||
"""Based on `function isRedEvent` from /html/hid-global.js on a HID EDGE EVO Solo"""
|
|
||||||
return self.event_type in [
|
|
||||||
1022,
|
|
||||||
1023,
|
|
||||||
2024,
|
|
||||||
2029,
|
|
||||||
2036,
|
|
||||||
2042,
|
|
||||||
2043,
|
|
||||||
2046,
|
|
||||||
4041,
|
|
||||||
4042,
|
|
||||||
4043,
|
|
||||||
4044,
|
|
||||||
4045,
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.door_name} {self.timestamp} - {self.description}"
|
return f"{self.door_name} {self.timestamp} - {self.description}"
|
||||||
|
|
||||||
|
def decoded_card_number(self) -> str:
|
||||||
|
"""Requires annotations from `with_decoded_card_number`"""
|
||||||
|
if self.raw_card_number is None:
|
||||||
|
return None
|
||||||
|
elif self.card_is_26_bit:
|
||||||
|
if self.card_is_valid_26_bit:
|
||||||
|
return f"{self.card_facility_code_26_bit} - {self.card_number_26_bit}"
|
||||||
|
else:
|
||||||
|
return "Invalid"
|
||||||
|
else:
|
||||||
|
return "Not 26 bit card"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(
|
models.UniqueConstraint(
|
||||||
|
94
doorcontrol/templates/doorcontrol/access_report.dj.html
Normal file
94
doorcontrol/templates/doorcontrol/access_report.dj.html
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{% extends "base.dj.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ selected_report }} | Door Controls | CMS{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
{% for report_name, report_url in report_types %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link{% if report_name == selected_report %} active{% endif %}"
|
||||||
|
href="{{ report_url }}?{{ query_params }}">
|
||||||
|
{{ report_name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<form method="get" class="form-floating">
|
||||||
|
<div class="row align-items-center row-cols-md-auto g-2 mb-2 mt-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="startDate"
|
||||||
|
name="timestamp__gte"
|
||||||
|
value="{{ timestamp__gte|date:'Y-m-d' }}">
|
||||||
|
<label for="startDate">Start Date</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="endDate"
|
||||||
|
name="timestamp__lte"
|
||||||
|
value="{{ timestamp__lte|date:'Y-m-d' }}">
|
||||||
|
<label for="endDate">End Date</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-2">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="number"
|
||||||
|
class="form-control"
|
||||||
|
id="itemsPerPage"
|
||||||
|
name="items_per_page"
|
||||||
|
value="{{ items_per_page }}"
|
||||||
|
min="10"
|
||||||
|
max="200"
|
||||||
|
step="10"
|
||||||
|
required>
|
||||||
|
<label for="itemsPerPage">Items Per Page</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
<a href="?" class="btn btn-warning">Reset</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<table class="table table-bordered table-striped table-hover mb-2">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for column in object_list.0.keys %}<th>{{ column|title }}</th>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for object in object_list %}
|
||||||
|
<tr>
|
||||||
|
{% for field in object.values %}<td>{{ field }}</td>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav aria-label="Page navigation">
|
||||||
|
<div class="text-center mb-2">
|
||||||
|
Showing {{ page_obj.object_list|length }} of {{ paginator.count }} results.
|
||||||
|
</div>
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?{{ query_params }}&page={{ page_obj.previous_page_number }}">Previous</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% for page_num in paginator_range %}
|
||||||
|
<li class="page-item {% if page_num == page_obj.number %} active {% elif page_num == paginator.ELLIPSIS %} disabled {% endif %}">
|
||||||
|
<a class="page-link" href="?{{ query_params }}&page={{ page_num }}">{{ page_num }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?{{ query_params }}&page={{ page_obj.next_page_number }}">Next</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
23
doorcontrol/urls.py
Normal file
23
doorcontrol/urls.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "doorcontrol"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"reports/access-per-<unit_time>",
|
||||||
|
views.AccessPerUnitTime.as_view(),
|
||||||
|
name="access-per-unit-time",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"reports/denied-access",
|
||||||
|
views.DeniedAccess.as_view(),
|
||||||
|
name="denied-access",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"reports/most-active-members",
|
||||||
|
views.MostActiveMembers.as_view(),
|
||||||
|
name="most-active-members",
|
||||||
|
),
|
||||||
|
]
|
@ -1,3 +1,203 @@
|
|||||||
from django.shortcuts import render
|
import datetime
|
||||||
|
|
||||||
# Create your views here.
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
|
from django.core.paginator import Page
|
||||||
|
from django.core.exceptions import BadRequest
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.db.models.functions import Trunc
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils import dateparse
|
||||||
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.utils.timezone import localtime
|
||||||
|
from django.views.generic.list import ListView
|
||||||
|
|
||||||
|
from .models import HIDEvent
|
||||||
|
|
||||||
|
|
||||||
|
REPORT_TYPES = []
|
||||||
|
|
||||||
|
|
||||||
|
def register_report(cls: "BaseAccessReport"):
|
||||||
|
REPORT_TYPES.extend(cls._report_types())
|
||||||
|
return cls
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAccessReport(PermissionRequiredMixin, ListView):
|
||||||
|
model = HIDEvent
|
||||||
|
permission_required = "doorcontrol.view_hidevent"
|
||||||
|
paginate_by = 20
|
||||||
|
context_object_name = "object_list"
|
||||||
|
template_name = "doorcontrol/access_report.dj.html"
|
||||||
|
|
||||||
|
_report_name = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _report_types(cls):
|
||||||
|
yield [
|
||||||
|
cls._report_name,
|
||||||
|
reverse_lazy("doorcontrol:" + slugify(cls._report_name)),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _selected_report(self):
|
||||||
|
return self._report_name
|
||||||
|
|
||||||
|
def _get_timestamp_range(self):
|
||||||
|
timestamp__gte = dateparse.parse_datetime(
|
||||||
|
self.request.GET.get("timestamp__gte") or "2019-01-01"
|
||||||
|
)
|
||||||
|
timestamp__lte = self.request.GET.get("timestamp__lte")
|
||||||
|
if timestamp__lte:
|
||||||
|
timestamp__lte = dateparse.parse_datetime(timestamp__lte)
|
||||||
|
else:
|
||||||
|
timestamp__lte = localtime()
|
||||||
|
|
||||||
|
return timestamp__gte, timestamp__lte
|
||||||
|
|
||||||
|
def get_paginate_by(self, queryset) -> int:
|
||||||
|
if "items_per_page" in self.request.GET:
|
||||||
|
return int(self.request.GET.get("items_per_page"))
|
||||||
|
return super().get_paginate_by(queryset)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return (
|
||||||
|
super().get_queryset().filter(timestamp__range=self._get_timestamp_range())
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["report_types"] = REPORT_TYPES
|
||||||
|
|
||||||
|
page: Page = context["page_obj"]
|
||||||
|
context["paginator_range"] = page.paginator.get_elided_page_range(page.number)
|
||||||
|
context["selected_report"] = self._selected_report()
|
||||||
|
context["items_per_page"] = self.get_paginate_by(None)
|
||||||
|
|
||||||
|
timestamp__gte, timestamp__lte = self._get_timestamp_range()
|
||||||
|
context["timestamp__gte"] = timestamp__gte
|
||||||
|
context["timestamp__lte"] = timestamp__lte
|
||||||
|
|
||||||
|
query_params = self.request.GET.copy()
|
||||||
|
if "page" in query_params:
|
||||||
|
query_params.pop("page")
|
||||||
|
context["query_params"] = query_params.urlencode()
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@register_report
|
||||||
|
class AccessPerUnitTime(BaseAccessReport):
|
||||||
|
UNIT_TIMES = ["day", "week", "month", "year"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _report_types(cls):
|
||||||
|
for unit_time in cls.UNIT_TIMES:
|
||||||
|
yield (
|
||||||
|
"Access per " + unit_time.title(),
|
||||||
|
reverse_lazy("doorcontrol:access-per-unit-time", args=[unit_time]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _selected_report(self) -> str:
|
||||||
|
return "Access per " + self.kwargs["unit_time"].title()
|
||||||
|
|
||||||
|
def _format_date(self, date: datetime.datetime) -> str:
|
||||||
|
unit_time = self.kwargs["unit_time"]
|
||||||
|
if unit_time == "day":
|
||||||
|
return date_format(date, "DATE_FORMAT")
|
||||||
|
elif unit_time == "week":
|
||||||
|
return (
|
||||||
|
date_format(date, "DATE_FORMAT")
|
||||||
|
+ " - "
|
||||||
|
+ date_format(date + datetime.timedelta(weeks=1), "DATE_FORMAT")
|
||||||
|
)
|
||||||
|
elif unit_time == "month":
|
||||||
|
return date_format(date, "N Y")
|
||||||
|
elif unit_time == "year":
|
||||||
|
return date_format(date, "Y")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
unit_time = self.kwargs["unit_time"]
|
||||||
|
if unit_time not in self.UNIT_TIMES:
|
||||||
|
raise BadRequest("unit time must be one of day, week, month, or year")
|
||||||
|
|
||||||
|
granted_event_types = [
|
||||||
|
t for t in HIDEvent.EventType if t.name.startswith("GRANTED_ACCESS")
|
||||||
|
]
|
||||||
|
events = (
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.filter(event_type__in=granted_event_types)
|
||||||
|
.values(unit_time=Trunc("timestamp", unit_time))
|
||||||
|
.annotate(
|
||||||
|
members=Count("cardholder_id", distinct=True),
|
||||||
|
access_count=Count("cardholder_id"),
|
||||||
|
)
|
||||||
|
.order_by("-unit_time")
|
||||||
|
.values("unit_time", "members", "access_count")
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
unit_time: self._format_date(event["unit_time"]),
|
||||||
|
"members": event["members"],
|
||||||
|
"access count": event["access_count"],
|
||||||
|
}
|
||||||
|
for event in events
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@register_report
|
||||||
|
class DeniedAccess(BaseAccessReport):
|
||||||
|
_report_name = "Denied Access"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
denied_event_types = [
|
||||||
|
t for t in HIDEvent.EventType if t.name.startswith("DENIED_ACCESS")
|
||||||
|
]
|
||||||
|
events = (
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.filter(event_type__in=denied_event_types)
|
||||||
|
.with_decoded_card_number()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"timestamp": event.timestamp,
|
||||||
|
"door name": event.door_name,
|
||||||
|
"event type": HIDEvent.EventType(event.event_type).label,
|
||||||
|
"name": " ".join(
|
||||||
|
(n for n in [event.forename, event.surname] if n is not None)
|
||||||
|
),
|
||||||
|
"raw card number": (
|
||||||
|
event.raw_card_number if event.raw_card_number is not None else ""
|
||||||
|
),
|
||||||
|
"card number": event.decoded_card_number() or "",
|
||||||
|
}
|
||||||
|
for event in events
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@register_report
|
||||||
|
class MostActiveMembers(BaseAccessReport):
|
||||||
|
_report_name = "Most Active Members"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
counts = (
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.values("cardholder_id", "forename", "surname")
|
||||||
|
.order_by()
|
||||||
|
.annotate(access_count=Count("cardholder_id"))
|
||||||
|
.order_by("-access_count")
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"cardholder id": count["cardholder_id"],
|
||||||
|
"name": " ".join(
|
||||||
|
(n for n in [count["forename"], count["surname"]] if n is not None)
|
||||||
|
),
|
||||||
|
"access count": count["access_count"],
|
||||||
|
}
|
||||||
|
for count in counts
|
||||||
|
]
|
||||||
|
@ -1,22 +1,52 @@
|
|||||||
|
from typing import Optional, Any, Type, cast
|
||||||
|
|
||||||
|
from django import forms
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.db.models import Value
|
from django.db.models import Value
|
||||||
|
from django.db.models.query import QuerySet
|
||||||
from django.db.models.functions import Now, Concat, LPad
|
from django.db.models.functions import Now, Concat, LPad
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
|
AbstractAudit,
|
||||||
CmsRedRiverVeteransScholarship,
|
CmsRedRiverVeteransScholarship,
|
||||||
Department,
|
Department,
|
||||||
CertificationDefinition,
|
CertificationDefinition,
|
||||||
Certification,
|
Certification,
|
||||||
|
CertificationAudit,
|
||||||
CertificationVersion,
|
CertificationVersion,
|
||||||
|
CertificationVersionAnnotated,
|
||||||
InstructorOrVendor,
|
InstructorOrVendor,
|
||||||
SpecialProgram,
|
SpecialProgram,
|
||||||
Waiver,
|
Waiver,
|
||||||
|
WaiverAudit,
|
||||||
)
|
)
|
||||||
from .forms import CertificationForm
|
from .forms import CertificationForm
|
||||||
from .certification_emails import all_certification_emails
|
from .certification_emails import all_certification_emails
|
||||||
|
|
||||||
|
|
||||||
|
class AlwaysChangedModelForm(forms.models.ModelForm):
|
||||||
|
"""By always returning true even unchanged inlines will get validated and saved."""
|
||||||
|
|
||||||
|
def has_changed(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractAuditInline(admin.TabularInline):
|
||||||
|
extra = 0
|
||||||
|
form = AlwaysChangedModelForm
|
||||||
|
|
||||||
|
def get_formset(
|
||||||
|
self, request: HttpRequest, obj: Optional[AbstractAudit] = None, **kwargs: Any
|
||||||
|
) -> Type[
|
||||||
|
"forms.models.BaseInlineFormSet[AbstractAudit, Any, forms.models.ModelForm[Any]]"
|
||||||
|
]:
|
||||||
|
formset = super().get_formset(request, obj, **kwargs)
|
||||||
|
formset.form.base_fields["user"].initial = request.user
|
||||||
|
return formset
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Department)
|
@admin.register(Department)
|
||||||
class DepartmentAdmin(admin.ModelAdmin):
|
class DepartmentAdmin(admin.ModelAdmin):
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
@ -40,19 +70,19 @@ class CertificationVersionInline(admin.TabularInline):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@admin.display(description="Latest", boolean=True)
|
@admin.display(description="Latest", boolean=True)
|
||||||
def is_latest(self, obj):
|
def is_latest(self, obj: CertificationVersionAnnotated) -> bool:
|
||||||
return obj.is_latest
|
return obj.is_latest
|
||||||
|
|
||||||
@admin.display(description="Current", boolean=True)
|
@admin.display(description="Current", boolean=True)
|
||||||
def is_current(self, obj):
|
def is_current(self, obj: CertificationVersionAnnotated) -> bool:
|
||||||
return obj.is_current
|
return obj.is_current
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CertificationDefinition)
|
@admin.register(CertificationDefinition)
|
||||||
class CertificationDefinitionAdmin(admin.ModelAdmin):
|
class CertificationDefinitionAdmin(admin.ModelAdmin):
|
||||||
search_fields = ["certification_name"]
|
search_fields = ["name"]
|
||||||
list_display = [
|
list_display = [
|
||||||
"certification_name",
|
"name",
|
||||||
"department",
|
"department",
|
||||||
"latest_semantic_version",
|
"latest_semantic_version",
|
||||||
]
|
]
|
||||||
@ -60,8 +90,12 @@ class CertificationDefinitionAdmin(admin.ModelAdmin):
|
|||||||
inlines = [CertificationVersionInline]
|
inlines = [CertificationVersionInline]
|
||||||
|
|
||||||
@admin.display(description="Latest Version")
|
@admin.display(description="Latest Version")
|
||||||
def latest_semantic_version(self, obj):
|
def latest_semantic_version(self, obj: CertificationDefinition) -> str:
|
||||||
return obj.latest_version().semantic_version()
|
return str(obj.latest_version().semantic_version())
|
||||||
|
|
||||||
|
|
||||||
|
class CertificationAuditInline(AbstractAuditInline):
|
||||||
|
model = CertificationAudit
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Certification)
|
@admin.register(Certification)
|
||||||
@ -69,22 +103,23 @@ class CertificationAdmin(admin.ModelAdmin):
|
|||||||
form = CertificationForm
|
form = CertificationForm
|
||||||
search_fields = [
|
search_fields = [
|
||||||
"name",
|
"name",
|
||||||
"certification_version__definition__certification_name",
|
"certification_version__definition__name",
|
||||||
"certification_version__definition__department__name",
|
"certification_version__definition__department__name",
|
||||||
]
|
]
|
||||||
autocomplete_fields = ["member"]
|
autocomplete_fields = ["member"]
|
||||||
exclude = ["shop_lead_notified"]
|
exclude = ["shop_lead_notified"]
|
||||||
|
inlines = [CertificationAuditInline]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request: HttpRequest) -> QuerySet[Certification]:
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.prefetch_related("certification_version__definition__department")
|
return qs.prefetch_related("certification_version__definition__department")
|
||||||
|
|
||||||
@admin.display(
|
@admin.display(
|
||||||
description="Certification Name",
|
description="Certification Name",
|
||||||
ordering="certification_version__definition__certification_name",
|
ordering="certification_version__definition__name",
|
||||||
)
|
)
|
||||||
def certification_name(self, obj):
|
def name(self, obj: Certification) -> str:
|
||||||
return obj.certification_version.definition.certification_name
|
return obj.certification_version.definition.name
|
||||||
|
|
||||||
@admin.display(
|
@admin.display(
|
||||||
description="Certification Version",
|
description="Certification Version",
|
||||||
@ -98,22 +133,26 @@ class CertificationAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def certification_semantic_version(self, obj):
|
def certification_semantic_version(self, obj: Certification) -> str:
|
||||||
return obj.certification_version.semantic_version()
|
return str(obj.certification_version.semantic_version())
|
||||||
|
|
||||||
@admin.display(description="Current", boolean=True)
|
@admin.display(description="Current", boolean=True)
|
||||||
def is_current(self, obj):
|
def is_current(self, obj: Certification) -> bool:
|
||||||
return obj.certification_version.is_current
|
return cast(CertificationVersionAnnotated, obj.certification_version).is_current
|
||||||
|
|
||||||
@admin.display(
|
@admin.display(
|
||||||
description="Department",
|
description="Department",
|
||||||
ordering="certification_version__definition__department",
|
ordering="certification_version__definition__department",
|
||||||
)
|
)
|
||||||
def certification_department(self, obj):
|
def certification_department(self, obj: Certification) -> Department:
|
||||||
return obj.certification_version.definition.department
|
return obj.certification_version.definition.department
|
||||||
|
|
||||||
|
@admin.display(description="Latest Audit")
|
||||||
|
def latest_audit(self, obj: Certification) -> CertificationAudit:
|
||||||
|
return obj.audits.latest()
|
||||||
|
|
||||||
list_display = [
|
list_display = [
|
||||||
"certification_name",
|
"name",
|
||||||
"name",
|
"name",
|
||||||
"certification_semantic_version",
|
"certification_semantic_version",
|
||||||
"is_current",
|
"is_current",
|
||||||
@ -121,14 +160,16 @@ class CertificationAdmin(admin.ModelAdmin):
|
|||||||
"date",
|
"date",
|
||||||
"shop_lead_notified",
|
"shop_lead_notified",
|
||||||
"certified_by",
|
"certified_by",
|
||||||
|
"latest_audit",
|
||||||
]
|
]
|
||||||
list_display_links = [
|
list_display_links = [
|
||||||
"certification_name",
|
"name",
|
||||||
"name",
|
"name",
|
||||||
]
|
]
|
||||||
list_filter = [
|
list_filter = [
|
||||||
"certification_version__definition__department",
|
"certification_version__definition__department",
|
||||||
("shop_lead_notified", admin.EmptyFieldListFilter),
|
("shop_lead_notified", admin.EmptyFieldListFilter),
|
||||||
|
("audits", admin.EmptyFieldListFilter),
|
||||||
]
|
]
|
||||||
|
|
||||||
actions = ["send_notifications"]
|
actions = ["send_notifications"]
|
||||||
@ -136,7 +177,9 @@ class CertificationAdmin(admin.ModelAdmin):
|
|||||||
@admin.action(
|
@admin.action(
|
||||||
description="Notify Shop Leads and Members of selected certifications"
|
description="Notify Shop Leads and Members of selected certifications"
|
||||||
)
|
)
|
||||||
def send_notifications(self, request, queryset):
|
def send_notifications(
|
||||||
|
self, request: HttpRequest, queryset: QuerySet[Certification]
|
||||||
|
) -> None:
|
||||||
try:
|
try:
|
||||||
emails = list(all_certification_emails(queryset))
|
emails = list(all_certification_emails(queryset))
|
||||||
|
|
||||||
@ -188,10 +231,13 @@ class SpecialProgramAdmin(admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WaiverAuditInline(AbstractAuditInline):
|
||||||
|
model = WaiverAudit
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Waiver)
|
@admin.register(Waiver)
|
||||||
class WaiverAdmin(admin.ModelAdmin):
|
class WaiverAdmin(admin.ModelAdmin):
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
|
|
||||||
list_display = [
|
list_display = [
|
||||||
"name",
|
"name",
|
||||||
"date",
|
"date",
|
||||||
@ -201,7 +247,17 @@ class WaiverAdmin(admin.ModelAdmin):
|
|||||||
"guardian_name",
|
"guardian_name",
|
||||||
"guardian_relation",
|
"guardian_relation",
|
||||||
"guardian_date",
|
"guardian_date",
|
||||||
|
"latest_audit",
|
||||||
]
|
]
|
||||||
|
list_filter = [
|
||||||
|
"waiver_version",
|
||||||
|
("audits", admin.EmptyFieldListFilter),
|
||||||
|
]
|
||||||
|
inlines = [WaiverAuditInline]
|
||||||
|
|
||||||
|
@admin.display(description="Latest Audit")
|
||||||
|
def latest_audit(self, obj: Waiver) -> WaiverAudit:
|
||||||
|
return obj.audits.latest()
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(CmsRedRiverVeteransScholarship)
|
admin.site.register(CmsRedRiverVeteransScholarship)
|
||||||
|
@ -104,7 +104,7 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
|||||||
class CertificationDefinitionSerializer(serializers.HyperlinkedModelSerializer):
|
class CertificationDefinitionSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CertificationDefinition
|
model = CertificationDefinition
|
||||||
fields = ["certification_name", "department"]
|
fields = ["name", "department"]
|
||||||
|
|
||||||
|
|
||||||
class CertificationDefinitionViewSet(viewsets.ModelViewSet):
|
class CertificationDefinitionViewSet(viewsets.ModelViewSet):
|
||||||
|
90
paperwork/migrations/0012_waiveraudit_certificationaudit.py
Normal file
90
paperwork/migrations/0012_waiveraudit_certificationaudit.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
# Generated by Django 4.2 on 2023-04-10 05:34
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("paperwork", "0011_alter_certificationversion_options"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="WaiverAudit",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("date", models.DateField(default=datetime.date.today, unique=True)),
|
||||||
|
("good", models.BooleanField(default=False)),
|
||||||
|
("notes", models.CharField(blank=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"waiver",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="audits",
|
||||||
|
to="paperwork.waiver",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["date"],
|
||||||
|
"get_latest_by": ["date"],
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CertificationAudit",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("date", models.DateField(default=datetime.date.today, unique=True)),
|
||||||
|
("good", models.BooleanField(default=False)),
|
||||||
|
("notes", models.CharField(blank=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"certification",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="audits",
|
||||||
|
to="paperwork.certification",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["date"],
|
||||||
|
"get_latest_by": ["date"],
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.2 on 2023-04-10 17:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("paperwork", "0012_waiveraudit_certificationaudit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certificationdefinition",
|
||||||
|
name="certification_name",
|
||||||
|
field=models.CharField(
|
||||||
|
db_column="Certification Name", default="", max_length=255
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,92 @@
|
|||||||
|
# Generated by Django 4.2 on 2023-04-10 18:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("membershipworks", "0002_alter_flag_options"),
|
||||||
|
("paperwork", "0013_alter_certificationdefinition_certification_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="certificationdefinition",
|
||||||
|
old_name="certification_identifier",
|
||||||
|
new_name="id",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certificationdefinition",
|
||||||
|
name="id",
|
||||||
|
field=models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certification",
|
||||||
|
name="certified_by",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certification",
|
||||||
|
name="date",
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certification",
|
||||||
|
name="member",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
db_constraint=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="membershipworks.member",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certification",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certification",
|
||||||
|
name="notes",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certification",
|
||||||
|
name="number",
|
||||||
|
field=models.AutoField(primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certification",
|
||||||
|
name="shop_lead_notified",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certificationdefinition",
|
||||||
|
name="certification_name",
|
||||||
|
field=models.CharField(max_length=255),
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="certificationdefinition",
|
||||||
|
old_name="certification_name",
|
||||||
|
new_name="name",
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="certificationdefinition",
|
||||||
|
options={"ordering": ("name", "department")},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="certificationversion",
|
||||||
|
name="definition",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="paperwork.certificationdefinition",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -1,13 +1,32 @@
|
|||||||
|
import datetime
|
||||||
import re
|
import re
|
||||||
|
from typing import TypedDict, TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from semver import VersionInfo
|
from semver import VersionInfo
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import OuterRef, Q, ExpressionWrapper, Subquery
|
from django.db.models import OuterRef, Q, ExpressionWrapper, Subquery
|
||||||
|
from django.conf import settings
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
|
from django_stubs_ext import WithAnnotations
|
||||||
|
|
||||||
from membershipworks.models import Member, Flag as MembershipWorksFlag
|
from membershipworks.models import Member, Flag as MembershipWorksFlag
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractAudit(models.Model):
|
||||||
|
date = models.DateField(default=datetime.date.today, unique=True)
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
||||||
|
good = models.BooleanField(default=False)
|
||||||
|
notes = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{'Good' if self.good else 'Bad'} audit at {self.date} by {self.user}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
ordering = ["date"]
|
||||||
|
get_latest_by = ["date"]
|
||||||
|
|
||||||
|
|
||||||
class CmsRedRiverVeteransScholarship(models.Model):
|
class CmsRedRiverVeteransScholarship(models.Model):
|
||||||
serial = models.AutoField(primary_key=True)
|
serial = models.AutoField(primary_key=True)
|
||||||
program_name = models.CharField(db_column="Program Name", max_length=255)
|
program_name = models.CharField(db_column="Program Name", max_length=255)
|
||||||
@ -40,7 +59,7 @@ class CmsRedRiverVeteransScholarship(models.Model):
|
|||||||
db_column="Program Status", max_length=16, blank=True, null=True
|
db_column="Program Status", max_length=16, blank=True, null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"{self.program_name} {self.member_name}"
|
return f"{self.program_name} {self.member_name}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -67,46 +86,46 @@ class Department(models.Model):
|
|||||||
)
|
)
|
||||||
list_reply_to_address = models.EmailField(max_length=254, blank=True)
|
list_reply_to_address = models.EmailField(max_length=254, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def list_name(self):
|
def list_name(self) -> Optional[str]:
|
||||||
if self.has_mailing_list:
|
if self.has_mailing_list:
|
||||||
return self.name.replace(" ", "_") + "-info"
|
return self.name.replace(" ", "_") + "-info"
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def list_address(self):
|
def list_address(self) -> Optional[str]:
|
||||||
if self.has_mailing_list:
|
if self.list_name:
|
||||||
return self.list_name + "@claremontmakerspace.org"
|
return self.list_name + "@claremontmakerspace.org"
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class CertificationDefinition(models.Model):
|
class CertificationDefinition(models.Model):
|
||||||
certification_identifier = models.AutoField(
|
name = models.CharField(max_length=255)
|
||||||
db_column="Certification Identifier", primary_key=True
|
|
||||||
)
|
|
||||||
certification_name = models.CharField(
|
|
||||||
db_column="Certification Name", max_length=255, blank=True, null=True
|
|
||||||
)
|
|
||||||
department = models.ForeignKey(Department, models.PROTECT)
|
department = models.ForeignKey(Department, models.PROTECT)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"{self.certification_name} <{self.department}>"
|
return f"{self.name} <{self.department}>"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "Certification Definitions"
|
db_table = "Certification Definitions"
|
||||||
ordering = ("certification_name", "department")
|
ordering = ("name", "department")
|
||||||
|
|
||||||
def latest_version(self) -> "CertificationVersion":
|
def latest_version(self) -> "CertificationVersion":
|
||||||
return self.certificationversion_set.latest()
|
return self.certificationversion_set.latest()
|
||||||
|
|
||||||
|
|
||||||
class CertificationVersionManager(models.Manager):
|
class CertificationVersionAnnotations(TypedDict):
|
||||||
def get_queryset(self):
|
is_latest: bool
|
||||||
|
is_current: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CertificationVersionManager(models.Manager["CertificationVersion"]):
|
||||||
|
def get_queryset(self) -> models.QuerySet["CertificationVersion"]:
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
latest = qs.filter(definition__pk=OuterRef("definition__pk")).reverse()
|
latest = qs.filter(definition__pk=OuterRef("definition__pk")).reverse()
|
||||||
return qs.annotate(
|
return qs.annotate(
|
||||||
@ -125,16 +144,14 @@ class CertificationVersionManager(models.Manager):
|
|||||||
class CertificationVersion(models.Model):
|
class CertificationVersion(models.Model):
|
||||||
objects = CertificationVersionManager()
|
objects = CertificationVersionManager()
|
||||||
|
|
||||||
definition = models.ForeignKey(
|
definition = models.ForeignKey(CertificationDefinition, on_delete=models.PROTECT)
|
||||||
CertificationDefinition, on_delete=models.PROTECT, db_column="Certification"
|
|
||||||
)
|
|
||||||
major = models.PositiveSmallIntegerField()
|
major = models.PositiveSmallIntegerField()
|
||||||
minor = models.PositiveSmallIntegerField()
|
minor = models.PositiveSmallIntegerField()
|
||||||
patch = models.PositiveSmallIntegerField()
|
patch = models.PositiveSmallIntegerField()
|
||||||
prerelease = models.CharField(max_length=255, blank=True)
|
prerelease = models.CharField(max_length=255, blank=True)
|
||||||
approval_date = models.DateField(blank=True, null=True)
|
approval_date = models.DateField(blank=True, null=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"{self.definition} [{self.semantic_version()}]"
|
return f"{self.definition} [{self.semantic_version()}]"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -165,31 +182,34 @@ class CertificationVersion(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
CertificationVersionAnnotated = WithAnnotations[
|
||||||
|
CertificationVersion, CertificationVersionAnnotations
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
CertificationVersionAnnotated = WithAnnotations[CertificationVersion]
|
||||||
|
|
||||||
|
|
||||||
class Certification(models.Model):
|
class Certification(models.Model):
|
||||||
number = models.AutoField(db_column="Number", primary_key=True)
|
number = models.AutoField(primary_key=True)
|
||||||
certification_version = models.ForeignKey(
|
certification_version = models.ForeignKey(
|
||||||
CertificationVersion, on_delete=models.PROTECT
|
CertificationVersion, on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
name = models.CharField(db_column="Name", max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
member = models.ForeignKey(
|
member = models.ForeignKey(
|
||||||
Member,
|
Member,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
to_field="uid",
|
to_field="uid",
|
||||||
db_column="uid",
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
db_constraint=False,
|
db_constraint=False,
|
||||||
)
|
)
|
||||||
certified_by = models.CharField(
|
certified_by = models.CharField(max_length=255, blank=True, null=True)
|
||||||
db_column="Certified_By", max_length=255, blank=True, null=True
|
date = models.DateField(blank=True, null=True)
|
||||||
)
|
shop_lead_notified = models.DateTimeField(blank=True, null=True)
|
||||||
date = models.DateField(db_column="Date", blank=True, null=True)
|
notes = models.CharField(max_length=255, blank=True, null=True)
|
||||||
shop_lead_notified = models.DateTimeField(
|
|
||||||
db_column="Shop Lead Notified", blank=True, null=True
|
|
||||||
)
|
|
||||||
notes = models.CharField(db_column="Notes", max_length=255, blank=True, null=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"{self.name} - {self.certification_version}"
|
return f"{self.name} - {self.certification_version}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -202,6 +222,12 @@ class Certification(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CertificationAudit(AbstractAudit):
|
||||||
|
certification = models.ForeignKey(
|
||||||
|
Certification, on_delete=models.CASCADE, related_name="audits"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InstructorOrVendor(models.Model):
|
class InstructorOrVendor(models.Model):
|
||||||
serial = models.AutoField(primary_key=True)
|
serial = models.AutoField(primary_key=True)
|
||||||
name = models.CharField(db_column="Name", max_length=255)
|
name = models.CharField(db_column="Name", max_length=255)
|
||||||
@ -214,7 +240,7 @@ class InstructorOrVendor(models.Model):
|
|||||||
db_column="email address", max_length=255, blank=True, null=True
|
db_column="email address", max_length=255, blank=True, null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -251,7 +277,7 @@ class SpecialProgram(models.Model):
|
|||||||
db_column="Program Status", max_length=16, blank=True, null=True
|
db_column="Program Status", max_length=16, blank=True, null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return self.program_name
|
return self.program_name
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -277,8 +303,12 @@ class Waiver(models.Model):
|
|||||||
)
|
)
|
||||||
guardian_date = models.DateField(db_column="Guardian Date", blank=True, null=True)
|
guardian_date = models.DateField(db_column="Guardian Date", blank=True, null=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"{self.name} {self.date}"
|
return f"{self.name} {self.date}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "Waivers"
|
db_table = "Waivers"
|
||||||
|
|
||||||
|
|
||||||
|
class WaiverAudit(AbstractAudit):
|
||||||
|
waiver = models.ForeignKey(Waiver, on_delete=models.CASCADE, related_name="audits")
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
{% for certification in certifications %}
|
{% for certification in certifications %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ certification.certification_version.definition.department.name }}</td>
|
<td>{{ certification.certification_version.definition.department.name }}</td>
|
||||||
<td>{{ certification.certification_version.definition.certification_name }}</td>
|
<td>{{ certification.certification_version.definition.name }}</td>
|
||||||
<td>{{ certification.member|default:certification.name }}</td>
|
<td>{{ certification.member|default:certification.name }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if not certification.certification_version.is_current %}[OUTDATED]{% endif %}
|
{% if not certification.certification_version.is_current %}[OUTDATED]{% endif %}
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% for certification in certifications %}
|
{% for certification in certifications %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ certification.certification_version.definition.certification_name }}</td>
|
<td>{{ certification.certification_version.definition.name }}</td>
|
||||||
<td>{{ certification.certification_version.semantic_version }}</td>
|
<td>{{ certification.certification_version.semantic_version }}</td>
|
||||||
<td>{{ certification.member }}</td>
|
<td>{{ certification.member }}</td>
|
||||||
<td>{{ certification.certification_version.definition.department }}</td>
|
<td>{{ certification.certification_version.definition.department }}</td>
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% for certification in certifications %}
|
{% for certification in certifications %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ certification.certification_version.definition.certification_name }}</td>
|
<td>{{ certification.certification_version.definition.name }}</td>
|
||||||
<td>{{ certification.certification_version.semantic_version }}</td>
|
<td>{{ certification.certification_version.semantic_version }}</td>
|
||||||
<td>{{ certification.member }}</td>
|
<td>{{ certification.member }}</td>
|
||||||
<td>{{ certification.certified_by }}</td>
|
<td>{{ certification.certified_by }}</td>
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% for certification in certifications %}
|
{% for certification in certifications %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ certification.certification_version.definition.certification_name }}</td>
|
<td>{{ certification.certification_version.definition.name }}</td>
|
||||||
<td>{{ certification.certification_version.semantic_version }}</td>
|
<td>{{ certification.certification_version.semantic_version }}</td>
|
||||||
<td>{{ certification.certification_version.definition.department }}</td>
|
<td>{{ certification.certification_version.definition.department }}</td>
|
||||||
<td>{{ certification.certified_by }}</td>
|
<td>{{ certification.certified_by }}</td>
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
{% if current or show_outdated %}
|
{% if current or show_outdated %}
|
||||||
<tr {% if not current %} class="table-warning"{% endif %}>
|
<tr {% if not current %} class="table-warning"{% endif %}>
|
||||||
<td {% if not current %} class="text-decoration-line-through"{% endif %}>
|
<td {% if not current %} class="text-decoration-line-through"{% endif %}>
|
||||||
{{ certification.certification_version.definition.certification_name }}
|
{{ certification.certification_version.definition.name }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ certification.certification_version.semantic_version }}
|
{{ certification.certification_version.semantic_version }}
|
||||||
|
Loading…
Reference in New Issue
Block a user