doorcontrol: Add task to periodically pull events from doors
This commit is contained in:
parent
215e2946d1
commit
96bcc80516
13
cmsmanage/django_q2_helper.py
Normal file
13
cmsmanage/django_q2_helper.py
Normal file
@ -0,0 +1,13 @@
|
||||
import sys
|
||||
|
||||
from django_q.models import Schedule
|
||||
|
||||
|
||||
def ensure_scheduled(name: str, func, **kwargs):
|
||||
Schedule.objects.update_or_create(
|
||||
name=name,
|
||||
defaults={
|
||||
"func": f"{func.__module__}.{func.__qualname__}",
|
||||
**kwargs,
|
||||
},
|
||||
)
|
@ -120,9 +120,15 @@ REST_FRAMEWORK = {
|
||||
Q_CLUSTER = {
|
||||
"name": "cmsmanage",
|
||||
"orm": "default",
|
||||
"retry": 360,
|
||||
"timeout": 300,
|
||||
"retry": 60 * 6,
|
||||
"timeout": 60 * 5,
|
||||
"catch_up": False,
|
||||
"ALT_CLUSTERS": {
|
||||
"internal": {},
|
||||
"internal": {
|
||||
"retry": 60 * 60,
|
||||
"timeout": 60 * 59,
|
||||
"ack_failures": True,
|
||||
"max_attempts": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -5,3 +5,8 @@ class DoorControlConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "doorcontrol"
|
||||
verbose_name = "Door Control"
|
||||
|
||||
def ready(self):
|
||||
from .tasks.scrapehidevents import schedule_tasks
|
||||
|
||||
schedule_tasks()
|
||||
|
41
doorcontrol/hid/Credential.py
Normal file
41
doorcontrol/hid/Credential.py
Normal file
@ -0,0 +1,41 @@
|
||||
import bitstring
|
||||
|
||||
# Reference for H10301 card format:
|
||||
# https://www.hidglobal.com/sites/default/files/hid-understanding_card_data_formats-wp-en.pdf
|
||||
|
||||
|
||||
class Credential:
|
||||
def __init__(self, code=None, hex=None):
|
||||
if code is None and hex is None:
|
||||
raise TypeError("Must set either code or hex for a Credential")
|
||||
elif code is not None and hex 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 is not None:
|
||||
self.bits = bitstring.Bits(hex=hex)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Credential({self.code})"
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.bits == other.bits
|
||||
|
||||
def __hash__(self):
|
||||
return self.bits.int
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
facility = self.bits[7:15].uint
|
||||
code = self.bits[15:31].uint
|
||||
return (facility, code)
|
||||
|
||||
@property
|
||||
def hex(self):
|
||||
return self.bits.hex.upper()
|
239
doorcontrol/hid/DoorController.py
Normal file
239
doorcontrol/hid/DoorController.py
Normal file
@ -0,0 +1,239 @@
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
from lxml import etree
|
||||
from lxml.builder import ElementMaker
|
||||
|
||||
E_plain = ElementMaker(nsmap={"hid": "http://www.hidglobal.com/VertX"})
|
||||
E = ElementMaker(
|
||||
namespace="http://www.hidglobal.com/VertX",
|
||||
nsmap={"hid": "http://www.hidglobal.com/VertX"},
|
||||
)
|
||||
E_corp = ElementMaker(
|
||||
namespace="http://www.hidcorp.com/VertX", # stupid
|
||||
nsmap={"hid": "http://www.hidcorp.com/VertX"},
|
||||
)
|
||||
ROOT = E_plain.VertXMessage
|
||||
|
||||
fieldnames = "CardNumber,CardFormat,PinRequired,PinCode,ExtendedAccess,ExpiryDate,Forename,Initial,Surname,Email,Phone,Custom1,Custom2,Schedule1,Schedule2,Schedule3,Schedule4,Schedule5,Schedule6,Schedule7,Schedule8".split(
|
||||
","
|
||||
)
|
||||
|
||||
# TODO: where should this live?
|
||||
# it's fine, ssl certs are for losers anyway
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
class RemoteError(Exception):
|
||||
def __init__(self, r):
|
||||
super().__init__(f"Door Updating Error: {r.status_code} {r.reason}\n{r.text}")
|
||||
|
||||
|
||||
class DoorController:
|
||||
def __init__(self, ip, username, password):
|
||||
self.ip = ip
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
def doImport(self, params=None, files=None):
|
||||
"""Send a request to the door control import script"""
|
||||
r = requests.post(
|
||||
"https://" + self.ip + "/cgi-bin/import.cgi",
|
||||
params=params,
|
||||
files=files,
|
||||
auth=requests.auth.HTTPDigestAuth(self.username, self.password),
|
||||
timeout=60,
|
||||
verify=False,
|
||||
) # ignore insecure SSL
|
||||
xml = etree.XML(r.content)
|
||||
if (
|
||||
r.status_code != 200
|
||||
or len(xml.findall("{http://www.hidglobal.com/VertX}Error")) > 0
|
||||
):
|
||||
raise RemoteError(r)
|
||||
|
||||
def doCSVImport(self, csv):
|
||||
"""Do the CSV import procedure on a door control"""
|
||||
self.doImport({"task": "importInit"})
|
||||
self.doImport(
|
||||
{"task": "importCardsPeople", "name": "cardspeopleschedule.csv"},
|
||||
{"importCardsPeopleButton": ("cardspeopleschedule.csv", csv, "text/csv")},
|
||||
)
|
||||
self.doImport({"task": "importDone"})
|
||||
|
||||
def doXMLRequest(self, xml, prefix=b'<?xml version="1.0" encoding="UTF-8"?>'):
|
||||
if not isinstance(xml, bytes):
|
||||
xml = etree.tostring(xml)
|
||||
r = requests.get(
|
||||
"https://" + self.ip + "/cgi-bin/vertx_xml.cgi",
|
||||
params={"XML": prefix + xml},
|
||||
auth=requests.auth.HTTPDigestAuth(self.username, self.password),
|
||||
verify=False,
|
||||
)
|
||||
resp_xml = etree.XML(r.content)
|
||||
# probably meed to be more sane about this
|
||||
if r.status_code != 200 or len(resp_xml.findall("{*}Error")) > 0:
|
||||
raise RemoteError(r)
|
||||
return resp_xml
|
||||
|
||||
def get_scheduleMap(self):
|
||||
schedules = self.doXMLRequest(
|
||||
ROOT(E.Schedules({"action": "LR", "recordOffset": "0", "recordCount": "8"}))
|
||||
)
|
||||
return {
|
||||
fmt.attrib["scheduleName"]: fmt.attrib["scheduleID"] for fmt in schedules[0]
|
||||
}
|
||||
|
||||
def get_schedules(self):
|
||||
# TODO: might be able to do in one request
|
||||
schedules = self.doXMLRequest(ROOT(E.Schedules({"action": "LR"})))
|
||||
etree.dump(schedules)
|
||||
|
||||
data = self.doXMLRequest(
|
||||
ROOT(
|
||||
*[
|
||||
E.Schedules(
|
||||
{"action": "LR", "scheduleID": schedule.attrib["scheduleID"]}
|
||||
)
|
||||
for schedule in schedules[0]
|
||||
]
|
||||
)
|
||||
)
|
||||
return ROOT(E_corp.Schedules({"action": "AD"}, *[s[0] for s in data]))
|
||||
|
||||
def set_schedules(self, schedules):
|
||||
# clear all people
|
||||
outString = StringIO()
|
||||
writer = csv.DictWriter(outString, fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerow({})
|
||||
outString.seek(0)
|
||||
self.doCSVImport(outString)
|
||||
|
||||
# clear all schedules
|
||||
delXML = ROOT(
|
||||
*[
|
||||
E.Schedules({"action": "DD", "scheduleID": str(ii)})
|
||||
for ii in range(1, 8)
|
||||
]
|
||||
)
|
||||
try:
|
||||
self.doXMLRequest(delXML)
|
||||
except RemoteError:
|
||||
# don't care about failure to delete, they probably just didn't exist
|
||||
pass
|
||||
|
||||
# load new schedules
|
||||
self.doXMLRequest(schedules)
|
||||
|
||||
def get_cardFormats(self):
|
||||
cardFormats = self.doXMLRequest(
|
||||
ROOT(E.CardFormats({"action": "LR", "responseFormat": "expanded"}))
|
||||
)
|
||||
|
||||
return {
|
||||
fmt[0].attrib["value"]: fmt.attrib["formatID"]
|
||||
for fmt in cardFormats[0].findall("{*}CardFormat[{*}FixedField]")
|
||||
}
|
||||
|
||||
def set_cardFormat(self, formatName, templateID, facilityCode):
|
||||
# TODO: add ability to delete formats
|
||||
# delete example: <hid:CardFormats action="DD" formatID="7-1-244"/>
|
||||
|
||||
el = ROOT(
|
||||
E.CardFormats(
|
||||
{"action": "AD"},
|
||||
E.CardFormat(
|
||||
{"formatName": formatName, "templateID": str(templateID)},
|
||||
E.FixedField({"value": str(facilityCode)}),
|
||||
),
|
||||
)
|
||||
)
|
||||
return self.doXMLRequest(el)
|
||||
|
||||
def get_records(self, req, count, params={}, stopFunction=None):
|
||||
recordCount = 0
|
||||
moreRecords = True
|
||||
|
||||
# note: all the "+/-1" bits are to work around a bug where the
|
||||
# last returned entry is incomplete. There is probably a
|
||||
# better way to do this, but for now I just get the last entry
|
||||
# again in the next request. I suspect this probably ends
|
||||
# poorly if the numbers line up poorly (ie an exact multiple
|
||||
# of the returned record limit)
|
||||
while True:
|
||||
res = self.doXMLRequest(
|
||||
ROOT(
|
||||
req(
|
||||
{
|
||||
"action": "LR",
|
||||
"recordCount": str(count - recordCount + 1),
|
||||
"recordOffset": str(
|
||||
recordCount - 1 if recordCount > 0 else 0
|
||||
),
|
||||
**params,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
recordCount += int(res[0].get("recordCount")) - 1
|
||||
moreRecords = res[0].get("moreRecords") == "true"
|
||||
|
||||
if moreRecords and (stopFunction is None or stopFunction(list(res[0]))):
|
||||
yield list(res[0])[:-1]
|
||||
else:
|
||||
yield list(res[0])
|
||||
break
|
||||
|
||||
def get_cardholders(self):
|
||||
for page in self.get_records(
|
||||
E.Cardholders, 1000, {"responseFormat": "expanded"}
|
||||
):
|
||||
yield from page
|
||||
|
||||
def get_credentials(self):
|
||||
for page in self.get_records(E.Credentials, 1000):
|
||||
yield from page
|
||||
|
||||
def update_credential(self, rawCardNumber: str, cardholderID: str):
|
||||
return self.doXMLRequest(
|
||||
ROOT(
|
||||
E.Credentials(
|
||||
{
|
||||
"action": "UD",
|
||||
"rawCardNumber": rawCardNumber,
|
||||
"isCard": "true",
|
||||
},
|
||||
E.Credential({"cardholderID": cardholderID}),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def get_events(self, threshold):
|
||||
def event_newer_than_threshold(event):
|
||||
return datetime.fromisoformat(event.attrib["timestamp"]) > threshold
|
||||
|
||||
# These door controllers only store 5000 events max
|
||||
for page in self.get_records(
|
||||
E.EventMessages,
|
||||
5000,
|
||||
stopFunction=lambda events: event_newer_than_threshold(events[-1]),
|
||||
):
|
||||
events = [event for event in page if event_newer_than_threshold(event)]
|
||||
if events:
|
||||
yield events
|
||||
|
||||
def get_lock(self):
|
||||
el = ROOT(E.Doors({"action": "LR", "responseFormat": "status"}))
|
||||
xml = self.doXMLRequest(el)
|
||||
relayState = xml.find("./{*}Doors/{*}Door").attrib["relayState"]
|
||||
return "unlocked" if relayState == "set" else "locked"
|
||||
|
||||
def set_lock(self, lock=True):
|
||||
el = ROOT(
|
||||
E.Doors({"action": "CM", "command": "lockDoor" if lock else "unlockDoor"})
|
||||
)
|
||||
return self.doXMLRequest(el)
|
0
doorcontrol/hid/__init__.py
Normal file
0
doorcontrol/hid/__init__.py
Normal file
18
doorcontrol/migrations/0003_door_ip.py
Normal file
18
doorcontrol/migrations/0003_door_ip.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.5 on 2023-09-19 15:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("doorcontrol", "0002_door_remove_hidevent_door_name_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="door",
|
||||
name="ip",
|
||||
field=models.GenericIPAddressField(default="", protocol="IPv4"),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
@ -1,10 +1,14 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import ExpressionWrapper, F, Func, Q
|
||||
from django.db.models.functions import Mod
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Door(models.Model):
|
||||
name = models.CharField(max_length=64, unique=True)
|
||||
ip = models.GenericIPAddressField(protocol="IPv4")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -122,6 +126,31 @@ class HIDEvent(models.Model):
|
||||
max_length=8, blank=True, null=True, db_column="rawCardNumber"
|
||||
)
|
||||
|
||||
@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):
|
||||
"""
|
||||
|
0
doorcontrol/tasks/__init__.py
Normal file
0
doorcontrol/tasks/__init__.py
Normal file
54
doorcontrol/tasks/scrapehidevents.py
Normal file
54
doorcontrol/tasks/scrapehidevents.py
Normal file
@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from django_q.tasks import async_task
|
||||
from django_q.models import Schedule
|
||||
|
||||
from cmsmanage.django_q2_helper import ensure_scheduled
|
||||
from doorcontrol.models import Door, HIDEvent
|
||||
from doorcontrol.hid.DoorController import DoorController
|
||||
|
||||
|
||||
@transaction.atomic()
|
||||
def getMessages(door: Door):
|
||||
last_event = door.hidevent_set.order_by("timestamp").last()
|
||||
if last_event is not None:
|
||||
last_ts = timezone.make_naive(last_event.timestamp)
|
||||
else:
|
||||
last_ts = datetime(2010, 1, 1)
|
||||
|
||||
door_controller = DoorController(
|
||||
door.ip,
|
||||
settings.HID_DOOR_USERNAME,
|
||||
settings.HID_DOOR_PASSWORD,
|
||||
)
|
||||
|
||||
for events_page in door_controller.get_events(last_ts):
|
||||
print(f"Importing {len(events_page)} events for {door.name}")
|
||||
HIDEvent.objects.bulk_create(
|
||||
(HIDEvent.from_xml_attributes(door, event.attrib) for event in events_page),
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
|
||||
def q_getMessagesAllDoors():
|
||||
# TODO: this should probably use async_iter
|
||||
for door in Door.objects.all():
|
||||
async_task(
|
||||
getMessages,
|
||||
door,
|
||||
cluster="internal",
|
||||
group=f"Fetch HID Events - {door.name}",
|
||||
)
|
||||
|
||||
|
||||
def schedule_tasks():
|
||||
ensure_scheduled(
|
||||
"Fetch HID Events",
|
||||
q_getMessagesAllDoors,
|
||||
schedule_type=Schedule.MINUTES,
|
||||
minutes=15,
|
||||
)
|
65
pdm.lock
generated
65
pdm.lock
generated
@ -3,10 +3,9 @@
|
||||
|
||||
[metadata]
|
||||
groups = ["default", "debug", "lint", "server", "typing"]
|
||||
cross_platform = true
|
||||
static_urls = false
|
||||
lock_version = "4.3"
|
||||
content_hash = "sha256:c3404161808d837738b673f358588ae72e1b51f506b6f4e60d7ec8ef6b8e0e0c"
|
||||
strategy = ["cross_platform"]
|
||||
lock_version = "4.4"
|
||||
content_hash = "sha256:22f2fbe3d0a5c19621ffbd542f27ba8755345d4a427b541285c22d09e752f7c3"
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
@ -635,6 +634,64 @@ files = [
|
||||
{file = "json5-0.9.14.tar.gz", hash = "sha256:9ed66c3a6ca3510a976a9ef9b8c0787de24802724ab1860bc0153c7fdd589b02"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "4.9.3"
|
||||
requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*"
|
||||
summary = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
|
||||
files = [
|
||||
{file = "lxml-4.9.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:b86164d2cff4d3aaa1f04a14685cbc072efd0b4f99ca5708b2ad1b9b5988a991"},
|
||||
{file = "lxml-4.9.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:42871176e7896d5d45138f6d28751053c711ed4d48d8e30b498da155af39aebd"},
|
||||
{file = "lxml-4.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae8b9c6deb1e634ba4f1930eb67ef6e6bf6a44b6eb5ad605642b2d6d5ed9ce3c"},
|
||||
{file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:411007c0d88188d9f621b11d252cce90c4a2d1a49db6c068e3c16422f306eab8"},
|
||||
{file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:cd47b4a0d41d2afa3e58e5bf1f62069255aa2fd6ff5ee41604418ca925911d76"},
|
||||
{file = "lxml-4.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e2cb47860da1f7e9a5256254b74ae331687b9672dfa780eed355c4c9c3dbd23"},
|
||||
{file = "lxml-4.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1247694b26342a7bf47c02e513d32225ededd18045264d40758abeb3c838a51f"},
|
||||
{file = "lxml-4.9.3-cp310-cp310-win32.whl", hash = "sha256:cdb650fc86227eba20de1a29d4b2c1bfe139dc75a0669270033cb2ea3d391b85"},
|
||||
{file = "lxml-4.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:97047f0d25cd4bcae81f9ec9dc290ca3e15927c192df17331b53bebe0e3ff96d"},
|
||||
{file = "lxml-4.9.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:1f447ea5429b54f9582d4b955f5f1985f278ce5cf169f72eea8afd9502973dd5"},
|
||||
{file = "lxml-4.9.3-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:57d6ba0ca2b0c462f339640d22882acc711de224d769edf29962b09f77129cbf"},
|
||||
{file = "lxml-4.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:9767e79108424fb6c3edf8f81e6730666a50feb01a328f4a016464a5893f835a"},
|
||||
{file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:71c52db65e4b56b8ddc5bb89fb2e66c558ed9d1a74a45ceb7dcb20c191c3df2f"},
|
||||
{file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d73d8ecf8ecf10a3bd007f2192725a34bd62898e8da27eb9d32a58084f93962b"},
|
||||
{file = "lxml-4.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0a3d3487f07c1d7f150894c238299934a2a074ef590b583103a45002035be120"},
|
||||
{file = "lxml-4.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e28c51fa0ce5674be9f560c6761c1b441631901993f76700b1b30ca6c8378d6"},
|
||||
{file = "lxml-4.9.3-cp311-cp311-win32.whl", hash = "sha256:0bfd0767c5c1de2551a120673b72e5d4b628737cb05414f03c3277bf9bed3305"},
|
||||
{file = "lxml-4.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:25f32acefac14ef7bd53e4218fe93b804ef6f6b92ffdb4322bb6d49d94cad2bc"},
|
||||
{file = "lxml-4.9.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d3ff32724f98fbbbfa9f49d82852b159e9784d6094983d9a8b7f2ddaebb063d4"},
|
||||
{file = "lxml-4.9.3-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48d6ed886b343d11493129e019da91d4039826794a3e3027321c56d9e71505be"},
|
||||
{file = "lxml-4.9.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9a92d3faef50658dd2c5470af249985782bf754c4e18e15afb67d3ab06233f13"},
|
||||
{file = "lxml-4.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b4e4bc18382088514ebde9328da057775055940a1f2e18f6ad2d78aa0f3ec5b9"},
|
||||
{file = "lxml-4.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fc9b106a1bf918db68619fdcd6d5ad4f972fdd19c01d19bdb6bf63f3589a9ec5"},
|
||||
{file = "lxml-4.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:d37017287a7adb6ab77e1c5bee9bcf9660f90ff445042b790402a654d2ad81d8"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:9bb6ad405121241e99a86efff22d3ef469024ce22875a7ae045896ad23ba2340"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8ed74706b26ad100433da4b9d807eae371efaa266ffc3e9191ea436087a9d6a7"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fbf521479bcac1e25a663df882c46a641a9bff6b56dc8b0fafaebd2f66fb231b"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:303bf1edce6ced16bf67a18a1cf8339d0db79577eec5d9a6d4a80f0fb10aa2da"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:5515edd2a6d1a5a70bfcdee23b42ec33425e405c5b351478ab7dc9347228f96e"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:690dafd0b187ed38583a648076865d8c229661ed20e48f2335d68e2cf7dc829d"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b6420a005548ad52154c8ceab4a1290ff78d757f9e5cbc68f8c77089acd3c432"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bb3bb49c7a6ad9d981d734ef7c7193bc349ac338776a0360cc671eaee89bcf69"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d27be7405547d1f958b60837dc4c1007da90b8b23f54ba1f8b728c78fdb19d50"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-win32.whl", hash = "sha256:8df133a2ea5e74eef5e8fc6f19b9e085f758768a16e9877a60aec455ed2609b2"},
|
||||
{file = "lxml-4.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:4dd9a263e845a72eacb60d12401e37c616438ea2e5442885f65082c276dfb2b2"},
|
||||
{file = "lxml-4.9.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6689a3d7fd13dc687e9102a27e98ef33730ac4fe37795d5036d18b4d527abd35"},
|
||||
{file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f6bdac493b949141b733c5345b6ba8f87a226029cbabc7e9e121a413e49441e0"},
|
||||
{file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:05186a0f1346ae12553d66df1cfce6f251589fea3ad3da4f3ef4e34b2d58c6a3"},
|
||||
{file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2006f5c8d28dee289f7020f721354362fa304acbaaf9745751ac4006650254b"},
|
||||
{file = "lxml-4.9.3-pp38-pypy38_pp73-macosx_11_0_x86_64.whl", hash = "sha256:5c245b783db29c4e4fbbbfc9c5a78be496c9fea25517f90606aa1f6b2b3d5f7b"},
|
||||
{file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4fb960a632a49f2f089d522f70496640fdf1218f1243889da3822e0a9f5f3ba7"},
|
||||
{file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:50670615eaf97227d5dc60de2dc99fb134a7130d310d783314e7724bf163f75d"},
|
||||
{file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9719fe17307a9e814580af1f5c6e05ca593b12fb7e44fe62450a5384dbf61b4b"},
|
||||
{file = "lxml-4.9.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3331bece23c9ee066e0fb3f96c61322b9e0f54d775fccefff4c38ca488de283a"},
|
||||
{file = "lxml-4.9.3-pp39-pypy39_pp73-macosx_11_0_x86_64.whl", hash = "sha256:ed667f49b11360951e201453fc3967344d0d0263aa415e1619e85ae7fd17b4e0"},
|
||||
{file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8b77946fd508cbf0fccd8e400a7f71d4ac0e1595812e66025bac475a8e811694"},
|
||||
{file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e4da8ca0c0c0aea88fd46be8e44bd49716772358d648cce45fe387f7b92374a7"},
|
||||
{file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fe4bda6bd4340caa6e5cf95e73f8fea5c4bfc55763dd42f1b50a94c1b4a2fbd4"},
|
||||
{file = "lxml-4.9.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f3df3db1d336b9356dd3112eae5f5c2b8b377f3bc826848567f10bfddfee77e9"},
|
||||
{file = "lxml-4.9.3.tar.gz", hash = "sha256:48628bd53a426c9eb9bc066a923acaa0878d1e86129fd5359aee99285f4eed9c"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.4.1"
|
||||
|
@ -24,6 +24,7 @@ dependencies = [
|
||||
"semver~=3.0",
|
||||
"djangorestframework~=3.14",
|
||||
"django-q2~=1.5",
|
||||
"lxml~=4.9",
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user