Compare commits

..

No commits in common. "35f812d2c6d9bd24825978a85e09c7b789c897a6" and "a6c531c22fe58249f8c78847576b6aa96d713ff0" have entirely different histories.

17 changed files with 85 additions and 780 deletions

View File

@ -34,7 +34,6 @@ 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(

View File

@ -3,41 +3,11 @@ 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 = ["forename", "surname", "cardholder_id"] search_fields = ["description", "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 = [ list_filter = ["door_name", "event_type"]
"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

View File

@ -1,74 +1,8 @@
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"
@ -155,21 +89,28 @@ 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(

View File

@ -1,94 +0,0 @@
{% 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 %}

View File

@ -1,23 +0,0 @@
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,203 +1,3 @@
import datetime from django.shortcuts import render
from django.contrib.auth.mixins import PermissionRequiredMixin # Create your views here.
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,52 +1,22 @@
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"]
@ -70,19 +40,19 @@ class CertificationVersionInline(admin.TabularInline):
) )
@admin.display(description="Latest", boolean=True) @admin.display(description="Latest", boolean=True)
def is_latest(self, obj: CertificationVersionAnnotated) -> bool: def is_latest(self, obj):
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: CertificationVersionAnnotated) -> bool: def is_current(self, obj):
return obj.is_current return obj.is_current
@admin.register(CertificationDefinition) @admin.register(CertificationDefinition)
class CertificationDefinitionAdmin(admin.ModelAdmin): class CertificationDefinitionAdmin(admin.ModelAdmin):
search_fields = ["name"] search_fields = ["certification_name"]
list_display = [ list_display = [
"name", "certification_name",
"department", "department",
"latest_semantic_version", "latest_semantic_version",
] ]
@ -90,12 +60,8 @@ class CertificationDefinitionAdmin(admin.ModelAdmin):
inlines = [CertificationVersionInline] inlines = [CertificationVersionInline]
@admin.display(description="Latest Version") @admin.display(description="Latest Version")
def latest_semantic_version(self, obj: CertificationDefinition) -> str: def latest_semantic_version(self, obj):
return str(obj.latest_version().semantic_version()) return obj.latest_version().semantic_version()
class CertificationAuditInline(AbstractAuditInline):
model = CertificationAudit
@admin.register(Certification) @admin.register(Certification)
@ -103,23 +69,22 @@ class CertificationAdmin(admin.ModelAdmin):
form = CertificationForm form = CertificationForm
search_fields = [ search_fields = [
"name", "name",
"certification_version__definition__name", "certification_version__definition__certification_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: HttpRequest) -> QuerySet[Certification]: def get_queryset(self, request):
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__name", ordering="certification_version__definition__certification_name",
) )
def name(self, obj: Certification) -> str: def certification_name(self, obj):
return obj.certification_version.definition.name return obj.certification_version.definition.certification_name
@admin.display( @admin.display(
description="Certification Version", description="Certification Version",
@ -133,26 +98,22 @@ class CertificationAdmin(admin.ModelAdmin):
) )
), ),
) )
def certification_semantic_version(self, obj: Certification) -> str: def certification_semantic_version(self, obj):
return str(obj.certification_version.semantic_version()) return obj.certification_version.semantic_version()
@admin.display(description="Current", boolean=True) @admin.display(description="Current", boolean=True)
def is_current(self, obj: Certification) -> bool: def is_current(self, obj):
return cast(CertificationVersionAnnotated, obj.certification_version).is_current return 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: Certification) -> Department: def certification_department(self, obj):
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 = [
"name", "certification_name",
"name", "name",
"certification_semantic_version", "certification_semantic_version",
"is_current", "is_current",
@ -160,16 +121,14 @@ class CertificationAdmin(admin.ModelAdmin):
"date", "date",
"shop_lead_notified", "shop_lead_notified",
"certified_by", "certified_by",
"latest_audit",
] ]
list_display_links = [ list_display_links = [
"name", "certification_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"]
@ -177,9 +136,7 @@ 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( def send_notifications(self, request, queryset):
self, request: HttpRequest, queryset: QuerySet[Certification]
) -> None:
try: try:
emails = list(all_certification_emails(queryset)) emails = list(all_certification_emails(queryset))
@ -231,13 +188,10 @@ 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",
@ -247,17 +201,7 @@ 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)

View File

@ -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 = ["name", "department"] fields = ["certification_name", "department"]
class CertificationDefinitionViewSet(viewsets.ModelViewSet): class CertificationDefinitionViewSet(viewsets.ModelViewSet):

View File

@ -1,90 +0,0 @@
# 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

@ -1,20 +0,0 @@
# 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

@ -1,92 +0,0 @@
# 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,32 +1,13 @@
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)
@ -59,7 +40,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) -> str: def __str__(self):
return f"{self.program_name} {self.member_name}" return f"{self.program_name} {self.member_name}"
class Meta: class Meta:
@ -86,46 +67,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) -> str: def __str__(self):
return self.name return self.name
@property @property
def list_name(self) -> Optional[str]: def list_name(self):
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) -> Optional[str]: def list_address(self):
if self.list_name: if self.has_mailing_list:
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):
name = models.CharField(max_length=255) 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
)
department = models.ForeignKey(Department, models.PROTECT) department = models.ForeignKey(Department, models.PROTECT)
def __str__(self) -> str: def __str__(self):
return f"{self.name} <{self.department}>" return f"{self.certification_name} <{self.department}>"
class Meta: class Meta:
db_table = "Certification Definitions" db_table = "Certification Definitions"
ordering = ("name", "department") ordering = ("certification_name", "department")
def latest_version(self) -> "CertificationVersion": def latest_version(self) -> "CertificationVersion":
return self.certificationversion_set.latest() return self.certificationversion_set.latest()
class CertificationVersionAnnotations(TypedDict): class CertificationVersionManager(models.Manager):
is_latest: bool def get_queryset(self):
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(
@ -144,14 +125,16 @@ class CertificationVersionManager(models.Manager["CertificationVersion"]):
class CertificationVersion(models.Model): class CertificationVersion(models.Model):
objects = CertificationVersionManager() objects = CertificationVersionManager()
definition = models.ForeignKey(CertificationDefinition, on_delete=models.PROTECT) definition = models.ForeignKey(
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) -> str: def __str__(self):
return f"{self.definition} [{self.semantic_version()}]" return f"{self.definition} [{self.semantic_version()}]"
class Meta: class Meta:
@ -182,34 +165,31 @@ 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(primary_key=True) number = models.AutoField(db_column="Number", primary_key=True)
certification_version = models.ForeignKey( certification_version = models.ForeignKey(
CertificationVersion, on_delete=models.PROTECT CertificationVersion, on_delete=models.PROTECT
) )
name = models.CharField(max_length=255) name = models.CharField(db_column="Name", 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(max_length=255, blank=True, null=True) certified_by = models.CharField(
date = models.DateField(blank=True, null=True) db_column="Certified_By", max_length=255, blank=True, null=True
shop_lead_notified = models.DateTimeField(blank=True, null=True) )
notes = models.CharField(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)
def __str__(self) -> str: def __str__(self):
return f"{self.name} - {self.certification_version}" return f"{self.name} - {self.certification_version}"
class Meta: class Meta:
@ -222,12 +202,6 @@ 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)
@ -240,7 +214,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) -> str: def __str__(self):
return f"{self.name}" return f"{self.name}"
class Meta: class Meta:
@ -277,7 +251,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) -> str: def __str__(self):
return self.program_name return self.program_name
class Meta: class Meta:
@ -303,12 +277,8 @@ 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) -> str: def __str__(self):
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")

View File

@ -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.name }}</td> <td>{{ certification.certification_version.definition.certification_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 %}

View File

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

View File

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

View File

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

View File

@ -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.name }} {{ certification.certification_version.definition.certification_name }}
</td> </td>
<td> <td>
{{ certification.certification_version.semantic_version }} {{ certification.certification_version.semantic_version }}