315 lines
11 KiB
Python
315 lines
11 KiB
Python
from datetime import datetime
|
|
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.utils import timezone
|
|
from django.utils.functional import cached_property
|
|
|
|
from membershipworks.models import Flag as MembershipWorksFlag
|
|
from membershipworks.models import Member
|
|
|
|
from .hid.DoorController import DoorController
|
|
|
|
|
|
class Door(models.Model):
|
|
name = models.CharField(max_length=64, unique=True)
|
|
ip = models.GenericIPAddressField(protocol="IPv4")
|
|
access_field = models.TextField(
|
|
max_length=128,
|
|
help_text="Membershipworks field that grants members access to this door",
|
|
)
|
|
|
|
@property
|
|
def controller(self) -> DoorController:
|
|
return DoorController(
|
|
self.ip,
|
|
settings.HID_DOOR_USERNAME,
|
|
settings.HID_DOOR_PASSWORD,
|
|
)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
@cached_property
|
|
def card_formats(self):
|
|
return self.controller.get_cardFormats()
|
|
|
|
@cached_property
|
|
def schedules_map(self):
|
|
return self.controller.get_scheduleMap()
|
|
|
|
|
|
class DoorCardholderMember(models.Model):
|
|
door = models.ForeignKey(Door, on_delete=models.CASCADE)
|
|
cardholder_id = models.IntegerField()
|
|
member = models.ForeignKey(Member, on_delete=models.CASCADE, db_constraint=False)
|
|
|
|
class Meta:
|
|
constraints = (
|
|
models.UniqueConstraint(
|
|
fields=("door", "cardholder_id"), name="unique_door_cardholder_id"
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=("door", "member"), name="unique_door_member"
|
|
),
|
|
)
|
|
|
|
def __str__(self):
|
|
return f"{self.door} [{self.cardholder_id}]: {self.member}"
|
|
|
|
|
|
class Schedule(models.Model):
|
|
name = models.CharField(max_length=255, unique=True)
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.name}"
|
|
|
|
|
|
class AbstractScheduleRule(models.Model):
|
|
schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE)
|
|
doors = models.ManyToManyField(Door)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class FlagScheduleRule(AbstractScheduleRule):
|
|
flag = models.ForeignKey(
|
|
MembershipWorksFlag,
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.PROTECT,
|
|
db_constraint=False,
|
|
)
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.schedule} [flag: {self.flag}]"
|
|
|
|
|
|
class AttributeScheduleRule(AbstractScheduleRule):
|
|
access_field = models.CharField(
|
|
max_length=128,
|
|
help_text=(
|
|
"Membershipworks field that grants members access to this door using this schedule."
|
|
),
|
|
)
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.schedule} [attribute: {self.access_field}]"
|
|
|
|
|
|
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(
|
|
DoorCardholderMember.objects.filter(
|
|
door=OuterRef("door"), cardholder_id=OuterRef("cardholder_id")
|
|
).values("member_id"),
|
|
)
|
|
)
|
|
|
|
|
|
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"
|
|
GRANTED_ACCESS = 2020, "Granted Access"
|
|
GRANTED_ACCESS_EXTENDED_TIME = 2021, "Granted Access: Extended Time"
|
|
DENIED_ACCESS_SCHEDULE = 2024, "Denied Access: Schedule"
|
|
DENIED_ACCESS_WRONG_PIN = 2029, "Denied Access: Wrong PIN"
|
|
DENIED_ACCESS_CARD_EXPIRED = 2036, "Denied Access: Card Expired"
|
|
DENIED_ACCESS_PIN_LOCKOUT = 2042, "Denied Access: PIN Lockout"
|
|
DENIED_ACCESS_UNASSIGNED_CARD = 2043, "Denied Access: Unassigned Card"
|
|
DENIED_ACCESS_UNASSIGNED_ACCESS_PIN = (
|
|
2044,
|
|
"Denied Access: Unassigned Access PIN",
|
|
)
|
|
DENIED_ACCESS_PIN_EXPIRED = 2046, "Denied Access: PIN Expired"
|
|
ALARM_ACKNOWLEDGED = 4034, "Alarm Acknowledged"
|
|
DOOR_LOCKED_SCHEDULED = 4035, "Door Locked: Scheduled"
|
|
DOOR_UNLOCKED_SCHEDULED = 4036, "Door Unlocked: Scheduled"
|
|
DOOR_FORCED_ALARM = 4041, "Door Forced Alarm"
|
|
DOOR_HELD_ALARM = 4042, "Door Held Alarm"
|
|
TAMPER_SWITCH_ALARM = 4043, "Tamper Switch Alarm"
|
|
AC_FAILURE = 4044, "AC Failure"
|
|
BATTERY_FAILURE = 4045, "Battery Failure"
|
|
REX_SWITCH_ALARM = 4051, "REX Switch Alarm"
|
|
TIME_SET_TO = 7020, "Time Set To"
|
|
GRANTED_ACCESS_MANUAL = 12031, "Granted Access: Manual"
|
|
DOOR_UNLOCKED = 12032, "Door Unlocked"
|
|
DOOR_LOCKED = 12033, "Door Locked"
|
|
|
|
@classmethod
|
|
def any_granted_access(cls) -> list[Self]:
|
|
return [t for t in cls if t.name.startswith("GRANTED_ACCESS")]
|
|
|
|
door = models.ForeignKey(Door, on_delete=models.CASCADE)
|
|
timestamp = models.DateTimeField()
|
|
event_type = models.IntegerField(db_column="eventType", choices=EventType.choices)
|
|
reader_address = models.IntegerField(db_column="readerAddress")
|
|
cardholder_id = models.IntegerField(blank=True, null=True, db_column="cardholderID")
|
|
command_status = models.BooleanField(
|
|
blank=True, null=True, db_column="commandStatus"
|
|
)
|
|
forename = models.TextField(blank=True, null=True)
|
|
surname = models.TextField(blank=True, null=True)
|
|
io_state = models.BooleanField(blank=True, null=True, db_column="ioState")
|
|
new_time = models.DateTimeField(blank=True, null=True, db_column="newTime")
|
|
old_time = models.DateTimeField(blank=True, null=True, db_column="oldTime")
|
|
raw_card_number = models.CharField(
|
|
max_length=8, blank=True, null=True, db_column="rawCardNumber"
|
|
)
|
|
|
|
# Based on `function isRedEvent` from /html/hid-global.js on a HID EDGE EVO Solo
|
|
is_red = models.GeneratedField(
|
|
expression=Q(
|
|
event_type__in=[
|
|
EventType.DENIED_ACCESS_CARD_NOT_FOUND,
|
|
EventType.DENIED_ACCESS_ACCESS_PIN_NOT_FOUND,
|
|
EventType.DENIED_ACCESS_SCHEDULE,
|
|
EventType.DENIED_ACCESS_WRONG_PIN,
|
|
EventType.DENIED_ACCESS_CARD_EXPIRED,
|
|
EventType.DENIED_ACCESS_PIN_LOCKOUT,
|
|
EventType.DENIED_ACCESS_UNASSIGNED_CARD,
|
|
EventType.DENIED_ACCESS_PIN_EXPIRED,
|
|
EventType.DOOR_FORCED_ALARM,
|
|
EventType.DOOR_HELD_ALARM,
|
|
EventType.TAMPER_SWITCH_ALARM,
|
|
EventType.AC_FAILURE,
|
|
EventType.BATTERY_FAILURE,
|
|
]
|
|
),
|
|
output_field=models.BooleanField(),
|
|
db_persist=False,
|
|
)
|
|
|
|
@classmethod
|
|
def from_xml_attributes(cls, door: Door, attrib: dict[str, str]):
|
|
field_lookup = {
|
|
field.column: field.attname for field in HIDEvent._meta.get_fields()
|
|
}
|
|
|
|
def attr_to_bool(str):
|
|
if str is None:
|
|
return None
|
|
else:
|
|
return str == "true"
|
|
|
|
return cls(
|
|
**{
|
|
**{field_lookup[k]: v for k, v in attrib.items()},
|
|
"door": door,
|
|
# fixups for specific attributes
|
|
"timestamp": timezone.make_aware(
|
|
datetime.fromisoformat(attrib["timestamp"])
|
|
),
|
|
"io_state": attr_to_bool(attrib.get("ioState")),
|
|
"command_status": attr_to_bool(attrib.get("commandStatus")),
|
|
}
|
|
)
|
|
|
|
@property
|
|
def description(self):
|
|
"""
|
|
Based on `Global.localeStrings` from /html/en_EN/en_EN.js
|
|
and `function eventDataHandler` from /html/modules/hid-dashboard.js
|
|
on a HID EDGE EVO Solo
|
|
"""
|
|
|
|
name = f"{self.forename} {self.surname}"
|
|
direction = "IN" if self.reader_address == 0 else "OUT"
|
|
|
|
event_types = {
|
|
1022: f"Denied Access, {direction} Card Not Found {self.raw_card_number}",
|
|
1023: f"Denied Access, {direction} Access PIN Not Found {self.raw_card_number}",
|
|
2020: f"Granted Access, {direction} {name}",
|
|
2021: f"Granted Access, {direction} Extended Time {name}",
|
|
2024: f"Denied Access, {direction} Schedule {name}",
|
|
2029: f"Denied Access, {direction} Wrong PIN {name}",
|
|
2036: f"Denied Access, {direction} Card Expired {name}",
|
|
2042: f"Denied Access, {direction} PIN Lockout {name}",
|
|
2043: f"Denied Access, {direction} Unassigned Card {self.raw_card_number}",
|
|
2044: f"Denied Access, {direction} Unassigned Access PIN {self.raw_card_number}",
|
|
2046: f"Denied Access - PIN Expired {name}",
|
|
4034: "Alarm Acknowledged",
|
|
4035: "Door Locked-Scheduled",
|
|
4036: "Door Unlocked-Scheduled",
|
|
4041: "Door Forced Alarm",
|
|
4042: "Door Held Alarm",
|
|
4043: "Tamper Switch Alarm",
|
|
4044: "AC Failure",
|
|
4045: "Battery Failure",
|
|
4051: "REX Switch Alarm",
|
|
7020: f"Time Set to: {self.new_time}",
|
|
12031: f"Granted Access, {direction} Manual",
|
|
12032: "Door Unlocked",
|
|
12033: "Door Locked",
|
|
}
|
|
|
|
return event_types.get(self.event_type, f"Unknown Event Type {self.event_type}")
|
|
|
|
def __str__(self):
|
|
return f"{self.door.name} {self.timestamp} - {self.description}"
|
|
|
|
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"
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["door", "timestamp", "event_type"], name="unique_hidevent"
|
|
)
|
|
]
|
|
db_table = "hidevent"
|
|
ordering = ("-timestamp",)
|