doorcontrol: Move HID card number decoding out of database query

Not really needed, and hard to make portable
This commit is contained in:
Adam Goldsmith 2024-08-26 23:23:53 -04:00
parent 32a91315ef
commit cbe684d918
6 changed files with 66 additions and 91 deletions

View File

@ -44,9 +44,6 @@ class HIDEventAdmin(DjangoObjectActions, admin.ModelAdmin):
readonly_fields = ["decoded_card_number"]
changelist_actions = ("refresh_all_doors",)
def get_queryset(self, request):
return super().get_queryset(request).with_decoded_card_number()
@admin.display(boolean=True)
def _is_red(self, obj):
return obj.is_red

View File

@ -1,41 +1,62 @@
import dataclasses
from typing import Literal
import bitstring
# Reference for H10301 card format:
# https://www.hidglobal.com/sites/default/files/hid-understanding_card_data_formats-wp-en.pdf
class InvalidHexCode(Exception):
pass
class Not26Bit(InvalidHexCode):
def __init__(self) -> None:
super().__init__("Card number > 26 bits")
class InvalidParity(InvalidHexCode):
def __init__(self, even_odd: Literal["even", "odd"]) -> None:
super().__init__(f"Bad {even_odd} parity")
@dataclasses.dataclass
class Credential:
def __init__(self, code=None, hex_code=None):
if code is None and hex_code is None:
raise TypeError("Must set either code or hex for a Credential")
elif code is not None and hex_code is not None:
raise TypeError("Cannot set both code and hex for a Credential")
elif code is not None:
self.bits = bitstring.pack(
"0b000000, 0b0, uint:8=facility, uint:16=number, 0b0",
facility=code[0],
number=code[1],
)
self.bits[6] = self.bits[7:19].count(1) % 2 # even parity
self.bits[31] = not (self.bits[19:31].count(1) % 2) # odd parity
elif hex_code is not None:
self.bits = bitstring.Bits(hex=hex_code)
bits: bitstring.Bits
def __repr__(self):
return f"Credential({self.code})"
@classmethod
def from_code(cls, facility=int, card_number=int) -> "Credential":
bits = bitstring.pack(
"0b000000, 0b0, uint:8=facility, uint:16=card_number, 0b0",
facility=facility,
card_number=card_number,
)
bits[6] = bits[7:19].count(1) % 2 # even parity
bits[31] = bits[19:31].count(0) % 2 # odd parity
return cls(bits)
def __eq__(self, other):
return self.bits == other.bits
@classmethod
def from_hex(cls, hex_code: str) -> "Credential":
bits = bitstring.Bits(hex=hex_code)
def __hash__(self):
return self.bits.int
if bits[:6].any(1):
raise Not26Bit
if bits[6] != bits[7:19].count(1) % 2:
raise InvalidParity("even")
if bits[31] != (bits[19:31].count(0) % 2):
raise InvalidParity("odd")
return cls(bits)
@property
def code(self):
facility = self.bits[7:15].uint
code = self.bits[15:31].uint
return (facility, code)
def facility_code(self) -> int:
return self.bits[7:15].uint
@property
def hex(self):
def card_number(self) -> int:
return self.bits[15:31].uint
@property
def hex(self) -> str:
return self.bits.hex.upper()

View File

@ -3,14 +3,14 @@ from typing import Self
from django.conf import settings
from django.db import models
from django.db.models import F, Func, OuterRef, Q, Subquery
from django.db.models.functions import Mod
from django.db.models import OuterRef, Q, Subquery
from django.utils import timezone
from django.utils.functional import cached_property
from membershipworks.models import Flag as MembershipWorksFlag
from membershipworks.models import Member
from .hid.Credential import Credential, InvalidHexCode
from .hid.DoorController import DoorController
@ -102,42 +102,6 @@ class AttributeScheduleRule(AbstractScheduleRule):
class HIDEventQuerySet(models.QuerySet):
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),
)
)
def with_member_id(self):
return self.annotate(
member_id=Subquery(
@ -302,13 +266,10 @@ class HIDEvent(models.Model):
return event_types.get(self.event_type, f"Unknown Event Type {self.event_type}")
def decoded_card_number(self) -> str | None:
"""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"
try:
cred = Credential.from_hex(self.raw_card_number)
return f"{cred.facility_code} - {cred.card_number}"
except InvalidHexCode as e:
return f"Invalid: {e}"

View File

@ -20,6 +20,7 @@ class UnitTimeTable(tables.Table):
class DeniedAccessTable(tables.Table):
decoded_card_number = tables.Column(orderable=False)
name = tables.TemplateColumn(
"{{ record.forename|default:'' }} {{ record.surname|default:'' }}"
)

View File

@ -38,11 +38,9 @@ class DoorMember:
def from_membershipworks_member(cls, member: Member, door: Door):
if member.access_card_facility_code and member.access_card_number:
credentials = {
Credential(
code=(
member.access_card_facility_code,
member.access_card_number,
)
Credential.from_code(
member.access_card_facility_code,
member.access_card_number,
)
}
else:
@ -108,7 +106,7 @@ class DoorMember:
},
cardholderID=data.attrib["cardholderID"],
credentials={
Credential(hex_code=(c.attrib["rawCardNumber"]))
Credential.from_hex(c.attrib["rawCardNumber"])
for c in data.findall("{*}Credential")
},
schedules={r.attrib["scheduleName"] for r in data.findall("{*}Role")},
@ -172,9 +170,11 @@ class DoorMember:
xml_credentials = [
E.Credential(
{
"formatName": str(credential.code[0]),
"cardNumber": str(credential.code[1]),
"formatID": self.door.card_formats[str(credential.code[0])],
"formatName": str(credential.facility_code),
"cardNumber": str(credential.card_number),
"formatID": self.door.card_formats[
str(credential.facility_code)
],
"isCard": "true",
"cardholderID": self.cardholderID,
}
@ -222,7 +222,7 @@ def update_door(door: Door, dry_run: bool = False):
}
existing_door_credentials = {
Credential(hex_code=c.attrib["rawCardNumber"])
Credential.from_hex(c.attrib["rawCardNumber"])
for c in door.controller.get_credentials()
}

View File

@ -206,12 +206,7 @@ class DeniedAccess(BaseAccessReport):
denied_event_types = [
t for t in HIDEvent.EventType if t.name.startswith("DENIED_ACCESS")
]
return (
super()
.get_table_data()
.filter(event_type__in=denied_event_types)
.with_decoded_card_number()
)
return super().get_table_data().filter(event_type__in=denied_event_types)
@register_report