From 96bcc805164fda33bc18bd72947ebcf0c5d942a3 Mon Sep 17 00:00:00 2001 From: Adam Goldsmith Date: Wed, 8 Nov 2023 12:34:11 -0500 Subject: [PATCH] doorcontrol: Add task to periodically pull events from doors --- cmsmanage/django_q2_helper.py | 13 ++ cmsmanage/settings/base.py | 12 +- doorcontrol/apps.py | 5 + doorcontrol/hid/Credential.py | 41 +++++ doorcontrol/hid/DoorController.py | 239 +++++++++++++++++++++++++ doorcontrol/hid/__init__.py | 0 doorcontrol/migrations/0003_door_ip.py | 18 ++ doorcontrol/models.py | 29 +++ doorcontrol/tasks/__init__.py | 0 doorcontrol/tasks/scrapehidevents.py | 54 ++++++ pdm.lock | 65 ++++++- pyproject.toml | 1 + 12 files changed, 470 insertions(+), 7 deletions(-) create mode 100644 cmsmanage/django_q2_helper.py create mode 100644 doorcontrol/hid/Credential.py create mode 100644 doorcontrol/hid/DoorController.py create mode 100644 doorcontrol/hid/__init__.py create mode 100644 doorcontrol/migrations/0003_door_ip.py create mode 100644 doorcontrol/tasks/__init__.py create mode 100644 doorcontrol/tasks/scrapehidevents.py diff --git a/cmsmanage/django_q2_helper.py b/cmsmanage/django_q2_helper.py new file mode 100644 index 0000000..1a1659a --- /dev/null +++ b/cmsmanage/django_q2_helper.py @@ -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, + }, + ) diff --git a/cmsmanage/settings/base.py b/cmsmanage/settings/base.py index 143743a..9377fa2 100644 --- a/cmsmanage/settings/base.py +++ b/cmsmanage/settings/base.py @@ -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, + }, }, } diff --git a/doorcontrol/apps.py b/doorcontrol/apps.py index fec6b76..4ef5da9 100644 --- a/doorcontrol/apps.py +++ b/doorcontrol/apps.py @@ -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() diff --git a/doorcontrol/hid/Credential.py b/doorcontrol/hid/Credential.py new file mode 100644 index 0000000..90ab8f0 --- /dev/null +++ b/doorcontrol/hid/Credential.py @@ -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() diff --git a/doorcontrol/hid/DoorController.py b/doorcontrol/hid/DoorController.py new file mode 100644 index 0000000..6183592 --- /dev/null +++ b/doorcontrol/hid/DoorController.py @@ -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''): + 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: + + 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) diff --git a/doorcontrol/hid/__init__.py b/doorcontrol/hid/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/doorcontrol/migrations/0003_door_ip.py b/doorcontrol/migrations/0003_door_ip.py new file mode 100644 index 0000000..5b6be4e --- /dev/null +++ b/doorcontrol/migrations/0003_door_ip.py @@ -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, + ), + ] diff --git a/doorcontrol/models.py b/doorcontrol/models.py index adaa318..f57e8ed 100644 --- a/doorcontrol/models.py +++ b/doorcontrol/models.py @@ -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): """ diff --git a/doorcontrol/tasks/__init__.py b/doorcontrol/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/doorcontrol/tasks/scrapehidevents.py b/doorcontrol/tasks/scrapehidevents.py new file mode 100644 index 0000000..ab4aa3a --- /dev/null +++ b/doorcontrol/tasks/scrapehidevents.py @@ -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, + ) diff --git a/pdm.lock b/pdm.lock index 998d62f..65d65ae 100644 --- a/pdm.lock +++ b/pdm.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index fbe0129..371c45b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "semver~=3.0", "djangorestframework~=3.14", "django-q2~=1.5", + "lxml~=4.9", ] requires-python = ">=3.9"