Compare commits

...

9 Commits

17 changed files with 780 additions and 85 deletions

View File

@ -34,6 +34,7 @@ urlpatterns = [
path("rentals/", include("rentals.urls")),
path("membershipworks/", include("membershipworks.urls")),
path("paperwork/", include("paperwork.urls")),
path("doorcontrol/", include("doorcontrol.urls")),
path("api/v1/", include((router.urls, "api"), namespace="v1")),
path("admin/", admin.site.urls),
path(

View File

@ -3,11 +3,41 @@ from django.contrib import admin
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)
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_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):
return False

View File

@ -1,8 +1,74 @@
from django.contrib import admin
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):
objects = HIDEventQuerySet.as_manager()
class EventType(models.IntegerChoices):
DENIED_ACCESS_CARD_NOT_FOUND = 1022, "Denied Access: Card 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}")
@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):
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:
constraints = [
models.UniqueConstraint(

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

View File

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

View File

@ -1,22 +1,52 @@
from typing import Optional, Any, Type, cast
from django import forms
from django.core import mail
from django.contrib import admin, messages
from django.db.models import Value
from django.db.models.query import QuerySet
from django.db.models.functions import Now, Concat, LPad
from django.http import HttpRequest
from .models import (
AbstractAudit,
CmsRedRiverVeteransScholarship,
Department,
CertificationDefinition,
Certification,
CertificationAudit,
CertificationVersion,
CertificationVersionAnnotated,
InstructorOrVendor,
SpecialProgram,
Waiver,
WaiverAudit,
)
from .forms import CertificationForm
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)
class DepartmentAdmin(admin.ModelAdmin):
search_fields = ["name"]
@ -40,19 +70,19 @@ class CertificationVersionInline(admin.TabularInline):
)
@admin.display(description="Latest", boolean=True)
def is_latest(self, obj):
def is_latest(self, obj: CertificationVersionAnnotated) -> bool:
return obj.is_latest
@admin.display(description="Current", boolean=True)
def is_current(self, obj):
def is_current(self, obj: CertificationVersionAnnotated) -> bool:
return obj.is_current
@admin.register(CertificationDefinition)
class CertificationDefinitionAdmin(admin.ModelAdmin):
search_fields = ["certification_name"]
search_fields = ["name"]
list_display = [
"certification_name",
"name",
"department",
"latest_semantic_version",
]
@ -60,8 +90,12 @@ class CertificationDefinitionAdmin(admin.ModelAdmin):
inlines = [CertificationVersionInline]
@admin.display(description="Latest Version")
def latest_semantic_version(self, obj):
return obj.latest_version().semantic_version()
def latest_semantic_version(self, obj: CertificationDefinition) -> str:
return str(obj.latest_version().semantic_version())
class CertificationAuditInline(AbstractAuditInline):
model = CertificationAudit
@admin.register(Certification)
@ -69,22 +103,23 @@ class CertificationAdmin(admin.ModelAdmin):
form = CertificationForm
search_fields = [
"name",
"certification_version__definition__certification_name",
"certification_version__definition__name",
"certification_version__definition__department__name",
]
autocomplete_fields = ["member"]
exclude = ["shop_lead_notified"]
inlines = [CertificationAuditInline]
def get_queryset(self, request):
def get_queryset(self, request: HttpRequest) -> QuerySet[Certification]:
qs = super().get_queryset(request)
return qs.prefetch_related("certification_version__definition__department")
@admin.display(
description="Certification Name",
ordering="certification_version__definition__certification_name",
ordering="certification_version__definition__name",
)
def certification_name(self, obj):
return obj.certification_version.definition.certification_name
def name(self, obj: Certification) -> str:
return obj.certification_version.definition.name
@admin.display(
description="Certification Version",
@ -98,22 +133,26 @@ class CertificationAdmin(admin.ModelAdmin):
)
),
)
def certification_semantic_version(self, obj):
return obj.certification_version.semantic_version()
def certification_semantic_version(self, obj: Certification) -> str:
return str(obj.certification_version.semantic_version())
@admin.display(description="Current", boolean=True)
def is_current(self, obj):
return obj.certification_version.is_current
def is_current(self, obj: Certification) -> bool:
return cast(CertificationVersionAnnotated, obj.certification_version).is_current
@admin.display(
description="Department",
ordering="certification_version__definition__department",
)
def certification_department(self, obj):
def certification_department(self, obj: Certification) -> 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 = [
"certification_name",
"name",
"name",
"certification_semantic_version",
"is_current",
@ -121,14 +160,16 @@ class CertificationAdmin(admin.ModelAdmin):
"date",
"shop_lead_notified",
"certified_by",
"latest_audit",
]
list_display_links = [
"certification_name",
"name",
"name",
]
list_filter = [
"certification_version__definition__department",
("shop_lead_notified", admin.EmptyFieldListFilter),
("audits", admin.EmptyFieldListFilter),
]
actions = ["send_notifications"]
@ -136,7 +177,9 @@ class CertificationAdmin(admin.ModelAdmin):
@admin.action(
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:
emails = list(all_certification_emails(queryset))
@ -188,10 +231,13 @@ class SpecialProgramAdmin(admin.ModelAdmin):
]
class WaiverAuditInline(AbstractAuditInline):
model = WaiverAudit
@admin.register(Waiver)
class WaiverAdmin(admin.ModelAdmin):
search_fields = ["name"]
list_display = [
"name",
"date",
@ -201,7 +247,17 @@ class WaiverAdmin(admin.ModelAdmin):
"guardian_name",
"guardian_relation",
"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)

View File

@ -104,7 +104,7 @@ class DepartmentViewSet(viewsets.ModelViewSet):
class CertificationDefinitionSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = CertificationDefinition
fields = ["certification_name", "department"]
fields = ["name", "department"]
class CertificationDefinitionViewSet(viewsets.ModelViewSet):

View 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,
},
),
]

View File

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

View File

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

View File

@ -1,13 +1,32 @@
import datetime
import re
from typing import TypedDict, TYPE_CHECKING, Optional
from semver import VersionInfo
from django.db import models
from django.db.models import OuterRef, Q, ExpressionWrapper, Subquery
from django.conf import settings
from django.core.validators import RegexValidator
from django_stubs_ext import WithAnnotations
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):
serial = models.AutoField(primary_key=True)
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
)
def __str__(self):
def __str__(self) -> str:
return f"{self.program_name} {self.member_name}"
class Meta:
@ -67,46 +86,46 @@ class Department(models.Model):
)
list_reply_to_address = models.EmailField(max_length=254, blank=True)
def __str__(self):
def __str__(self) -> str:
return self.name
@property
def list_name(self):
def list_name(self) -> Optional[str]:
if self.has_mailing_list:
return self.name.replace(" ", "_") + "-info"
else:
return None
@property
def list_address(self):
if self.has_mailing_list:
def list_address(self) -> Optional[str]:
if self.list_name:
return self.list_name + "@claremontmakerspace.org"
else:
return None
class CertificationDefinition(models.Model):
certification_identifier = models.AutoField(
db_column="Certification Identifier", primary_key=True
)
certification_name = models.CharField(
db_column="Certification Name", max_length=255, blank=True, null=True
)
name = models.CharField(max_length=255)
department = models.ForeignKey(Department, models.PROTECT)
def __str__(self):
return f"{self.certification_name} <{self.department}>"
def __str__(self) -> str:
return f"{self.name} <{self.department}>"
class Meta:
db_table = "Certification Definitions"
ordering = ("certification_name", "department")
ordering = ("name", "department")
def latest_version(self) -> "CertificationVersion":
return self.certificationversion_set.latest()
class CertificationVersionManager(models.Manager):
def get_queryset(self):
class CertificationVersionAnnotations(TypedDict):
is_latest: bool
is_current: bool
class CertificationVersionManager(models.Manager["CertificationVersion"]):
def get_queryset(self) -> models.QuerySet["CertificationVersion"]:
qs = super().get_queryset()
latest = qs.filter(definition__pk=OuterRef("definition__pk")).reverse()
return qs.annotate(
@ -125,16 +144,14 @@ class CertificationVersionManager(models.Manager):
class CertificationVersion(models.Model):
objects = CertificationVersionManager()
definition = models.ForeignKey(
CertificationDefinition, on_delete=models.PROTECT, db_column="Certification"
)
definition = models.ForeignKey(CertificationDefinition, on_delete=models.PROTECT)
major = models.PositiveSmallIntegerField()
minor = models.PositiveSmallIntegerField()
patch = models.PositiveSmallIntegerField()
prerelease = models.CharField(max_length=255, blank=True)
approval_date = models.DateField(blank=True, null=True)
def __str__(self):
def __str__(self) -> str:
return f"{self.definition} [{self.semantic_version()}]"
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):
number = models.AutoField(db_column="Number", primary_key=True)
number = models.AutoField(primary_key=True)
certification_version = models.ForeignKey(
CertificationVersion, on_delete=models.PROTECT
)
name = models.CharField(db_column="Name", max_length=255)
name = models.CharField(max_length=255)
member = models.ForeignKey(
Member,
on_delete=models.PROTECT,
to_field="uid",
db_column="uid",
blank=True,
null=True,
db_constraint=False,
)
certified_by = models.CharField(
db_column="Certified_By", max_length=255, blank=True, null=True
)
date = models.DateField(db_column="Date", 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)
certified_by = models.CharField(max_length=255, blank=True, null=True)
date = models.DateField(blank=True, null=True)
shop_lead_notified = models.DateTimeField(blank=True, null=True)
notes = models.CharField(max_length=255, blank=True, null=True)
def __str__(self):
def __str__(self) -> str:
return f"{self.name} - {self.certification_version}"
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):
serial = models.AutoField(primary_key=True)
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
)
def __str__(self):
def __str__(self) -> str:
return f"{self.name}"
class Meta:
@ -251,7 +277,7 @@ class SpecialProgram(models.Model):
db_column="Program Status", max_length=16, blank=True, null=True
)
def __str__(self):
def __str__(self) -> str:
return self.program_name
class Meta:
@ -277,8 +303,12 @@ class Waiver(models.Model):
)
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}"
class Meta:
db_table = "Waivers"
class WaiverAudit(AbstractAudit):
waiver = models.ForeignKey(Waiver, on_delete=models.CASCADE, related_name="audits")

View File

@ -48,7 +48,7 @@
{% for certification in certifications %}
<tr>
<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>
{% if not certification.certification_version.is_current %}[OUTDATED]{% endif %}

View File

@ -10,7 +10,7 @@
</tr>
{% for certification in certifications %}
<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.member }}</td>
<td>{{ certification.certification_version.definition.department }}</td>

View File

@ -14,7 +14,7 @@
</tr>
{% for certification in certifications %}
<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.member }}</td>
<td>{{ certification.certified_by }}</td>

View File

@ -12,7 +12,7 @@
</tr>
{% for certification in certifications %}
<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.definition.department }}</td>
<td>{{ certification.certified_by }}</td>

View File

@ -27,7 +27,7 @@
{% if current or show_outdated %}
<tr {% if not current %} class="table-warning"{% endif %}>
<td {% if not current %} class="text-decoration-line-through"{% endif %}>
{{ certification.certification_version.definition.certification_name }}
{{ certification.certification_version.definition.name }}
</td>
<td>
{{ certification.certification_version.semantic_version }}