Compare commits

..

45 Commits

Author SHA1 Message Date
e3176cd155 sqlExport: Use insert_many() to bulk insert transactions 2023-09-22 22:26:56 -04:00
d3266a7d7f Migrate from poetry to pdm, bumping dependency/Python versions 2023-09-22 22:14:39 -04:00
79678a8920 upcomingEvents: Use cleaner datetime format 2023-09-22 22:13:03 -04:00
8b845bab05 Apply black formatting 2023-09-22 22:13:03 -04:00
ef23433c8f sqlExport: Remove debug print 2023-09-22 22:13:03 -04:00
936effe5c7 sqlExport: Just insert transactions, instead of trying to upsert
This fixes an issue that seems to have only manifested on newer Python
versions (maybe >3.9, after update to Debian 12)
2023-09-22 22:12:08 -04:00
e71fd48975 upcomingEvents: Yield header/footer directly for better code readability 2023-05-25 23:08:18 -04:00
c0e43dd48e upcomingEvents: Improve error messages for events missing attributes 2023-05-25 23:05:06 -04:00
5478518d51 Corrected outdated class propsal form link (self hosted form - not using google docs anymore)
Updated language for tours to remove covid-19 language
Updated formatting for ongoing and full classes to add <hr> for consistency with other sections.
Updated Ongoing event header to make consistent (change classes to events)
2023-05-11 09:09:25 -04:00
9cf12b1bdd upcomingEvents: Use pyclip to copy the result to clipboard 2023-05-09 21:24:04 -04:00
981cb12aa6 Update dependencies 2023-05-09 21:24:04 -04:00
0a5c80e87c upcomingEvents: Split events into upcoming/full/ongoing sections 2023-05-09 21:24:04 -04:00
Steve Goldsmith
aa92b77150 Merge branch 'master' of https://git.claremontmakerspace.org/adam.goldsmith/memberPlumbing 2023-05-03 11:04:22 -04:00
5e2fd5d427 upcomingEvents: Ignore all hidden events, not just "[TEMPLATE FOR COPYING]" 2023-05-03 11:03:34 -04:00
f4f813c98f Add renovate.json 2023-03-02 05:18:20 +00:00
001a190947 sqlExport: Add Volunteer Email field 2023-02-02 21:33:24 -05:00
Steve Goldsmith
2732c788c4 Merge branch 'master' of https://git.claremontmakerspace.org/adam.goldsmith/memberPlumbing 2023-01-16 10:53:56 -05:00
Steve Goldsmith
a1330ae637 removed mask requirement language 2023-01-16 10:50:13 -05:00
66853e1156 ucsAccounts: Strip leading/trailing periods
This fixes a member whose name ended with "Jr."
2023-01-07 14:17:18 -05:00
363be0ba8c sqlExport: Get folder membership and apply into memberflags 2023-01-06 15:37:56 -05:00
d8b3958c87 upcomingEvents: Check for error on retrieving event list 2023-01-06 15:37:56 -05:00
68b4b10c51 Slightly simplify non-poetry invocation in README 2023-01-06 15:37:56 -05:00
f85c26a844 upcomingEvents: Check for error on retrieving event list 2022-10-28 17:36:17 -04:00
2570aa3620 Slightly simplify non-poetry invocation in README 2022-10-28 15:54:58 -04:00
88b2610513 Fix some typos in upcomingEvents templates 2022-10-28 15:45:46 -04:00
3f17cd9ec2 Add upcomingEvents script to README 2022-10-28 15:37:27 -04:00
5c53f7f88c Update README to reflect removal of bin/ directory 2022-10-28 15:35:50 -04:00
97c4dbc1ee Add upcomingEvents script to format MembershipWorks events to WordPress post 2022-10-28 15:27:26 -04:00
3595a24d85 MembershipWorks: Add methods to get event listing and events by eid/url 2022-10-28 15:27:03 -04:00
c1430e2f9a Sync all "Database .*" groups 2022-07-28 20:49:36 -04:00
a5a787e0f7 ucsAccounts: Change "Admin" group to "Database Admin" 2022-07-21 19:29:41 -04:00
6b7194c15a ucsAccounts: Correctly negate MW_ groups prefix check 2022-06-09 14:57:31 -04:00
855f9b652d Handle Byte Order Mark (BOM) in CSVs 2022-05-31 12:37:11 -04:00
69bcb71091 Get all types of transaction 2022-05-31 12:37:11 -04:00
63bd8efaf2 Bump dependencies 2022-05-31 12:37:05 -04:00
af6ecb2864 Add "Admin" to UCS group regex 2022-01-21 14:17:37 -05:00
ce2a0f4c1d doorUpdater: Remove "Staffed Hours" schedule for all members 2021-08-12 15:15:23 -04:00
995b6f9763 Also check for "Membership on hold" membership level, not just label 2021-07-01 12:43:01 -04:00
f94a27699c Remove limited operations check, but keep staffed hours temporarily
We are returning to normal operating hours, but have a grace period
during which we will keep the staffed hours as well
2021-07-01 12:43:01 -04:00
34539eb630 ucsAccounts: Print user info on error for debugging purposes 2020-11-11 13:21:08 -05:00
3849aca918 Update MembershipWorks module to v2 of the API, where available
still stuck using the v1 csv endpoint, haven't found an easy
alternative and it hasn't been changed yet for their v2
2020-10-27 21:05:21 -04:00
cfccc433dd Apply minor formatting changes from black 2020-10-27 21:04:44 -04:00
a2dd00f414 Add systemd units for ucsAccounts 2020-07-18 23:52:49 -04:00
5a39c5cae9 Remove bin/ scripts, replace with poetry scripts section 2020-07-18 23:52:49 -04:00
7a22f43ccf ucsAccounts: Rewrite using udm_rest_client, allowing remote operation
This allows running all of the updater scripts on net-svcs, instead of
running this one on UCS itself
2020-07-18 23:52:49 -04:00
27 changed files with 1482 additions and 2111 deletions

6
.gitignore vendored
View File

@ -1,7 +1,3 @@
/.coverage
/.mypy_cache/
/memberplumbing.egg-info/
/venv/
__pycache__/
/venv/
/config.yaml

View File

@ -4,7 +4,7 @@ This repo contains a set of scripts to sync data around for the Claremont MakerS
## Setup
This project uses [Poetry](https://python-poetry.org/) for dependency management. Typical usage is first running `poetry install` to create a virtualenv and install dependencies, then running `poetry run bin/<script>` to start a specific script.
This project uses [Poetry](https://python-poetry.org/) for dependency management. Typical usage is first running `poetry install` to create a virtualenv and install dependencies, then running `poetry run <script>` to start a specific script.
## Config
@ -12,7 +12,7 @@ Many of the scripts use data from a `config.yaml` in the current working directo
## Scripts
The primary entry points have scripts in [`bin`](./bin/). They assume that memberPlumbing is in `PYTHONPATH`, which can either be done with `poetry` or manually set.
The primary entry points have scripts entries (`tool.poetry.scripts`) in [`pyproject.toml`](./pyproject.toml). They assume that they are being run from a module, so must be run with `poetry run <script>` or `python -m memberPlumbing.<script>`.
### `doorUpdater`
@ -26,6 +26,10 @@ Retrieves member information from MembershipWorks and pushes it out to [UCS](htt
Retrieves account and transaction information from MembershipWorks, and pushes it to a MariaDB database for use in other projects. Schemas are defined with [peewee](peewee-orm.com) in [`memberPlumbing/mw_models.py`](./memberPlumbing/mw_models.py).
### `upcomingEvents`
Retrieves upcoming events from MembershipWorks and formats them for a WordPress post.
### `hidEvents`
Retrieves events from the HID Evo Solo door controllers, and pushes them to a SQL database.
@ -33,15 +37,3 @@ Retrieves events from the HID Evo Solo door controllers, and pushes them to a SQ
## Systemd
There are systemd units in the [`systemd`](./systemd/) folder, which can be used to run the various scripts regularly.
## SSL Certificates
The HID Evo Solo door controllers we use have a self-signed certificate, which is included in this repo as [`hidglobal.com.pem`](./hidglobal.com.pem).
If you need to use a different certificate, you can either download it with your browser or the following command (replacing `SERVER` by the address of the door):
```sh
openssl s_client -connect SERVER:443 </dev/null | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' > example.pem
```
Then set `doorControllerCA_BUNDLE` in `config.yaml` to the path to the created pem file. If your doors have different certificates, (which ours annoyingly don't), you will need to concatenate the certificates together into a single file.

View File

@ -6,8 +6,6 @@ doorControllers:
Wood Shop Rear: {ip: 172.18.51.15, access: Wood Shop}
Storage Closet: {ip: 172.18.51.16, access: Storage Closet}
doorControllerCA_BUNDLE: "hidglobal.com.pem"
# {member type: door schedule}
memberLevels:
CMS Staff: 7x24

View File

@ -1,24 +0,0 @@
-----BEGIN CERTIFICATE-----
MIID9TCCAt2gAwIBAgIJAPBotjnyfGu6MA0GCSqGSIb3DQEBCwUAMIGQMQswCQYD
VQQGEwJVUzERMA8GA1UECAwIQ29sb3JhZG8xFDASBgNVBAcMC1dlc3RtaW5zdGVy
MQwwCgYDVQQKDANISUQxDDAKBgNVBAsMA05BUzEWMBQGA1UEAwwNaGlkZ2xvYmFs
LmNvbTEkMCIGCSqGSIb3DQEJARYVc3VwcG9ydEBoaWRnbG9iYWwuY29tMB4XDTE5
MDcyMjEwMTQxMFoXDTI5MDcxOTEwMTQxMFowgZAxCzAJBgNVBAYTAlVTMREwDwYD
VQQIDAhDb2xvcmFkbzEUMBIGA1UEBwwLV2VzdG1pbnN0ZXIxDDAKBgNVBAoMA0hJ
RDEMMAoGA1UECwwDTkFTMRYwFAYDVQQDDA1oaWRnbG9iYWwuY29tMSQwIgYJKoZI
hvcNAQkBFhVzdXBwb3J0QGhpZGdsb2JhbC5jb20wggEiMA0GCSqGSIb3DQEBAQUA
A4IBDwAwggEKAoIBAQDSibuXB9Tn0EdwL2jDig26s/b1D9SX5B4xnZM+xZ4/mE6U
Meg5xbiTSMiWqtoSMVxG1WJDxogJWxCgZis2qk3AG89PBarg17pBmxPLYyCLricx
alyNvTJBxYgA/zKagPof6h6UqKOkhsW9qvulEmPe+TKk47pmlZXe+v+1A6PQDY5B
Y3MtqE23cnZ5nBTVanFAc1vbokMXUCCtRvE1Y/KhvuaJr2VjOSJ/KV3vcdTSCLGc
W9/n/Fv8udvI/eIkoPNpCUwngm8j3Aa7qN/OSg3SvVvBcl/Ykc08STSyZPMJGBaR
EuUcAraEBZbUDOCinDS488jKVHAXhrnvzzi7RMlhAgMBAAGjUDBOMB0GA1UdDgQW
BBSvOzxCjQi86ZUsuW0o4aa7GNAeSTAfBgNVHSMEGDAWgBSvOzxCjQi86ZUsuW0o
4aa7GNAeSTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQA/8FCv6x8v
PHm2Ya/hbZ/S3amdl7/E1illeApNRZodTGCn/rlVSGanCfWYzEY2naHiDC2ImhZq
NkHK9uvUtxaVQFq5VN6WQMo351J78LLfcoqpKOLGX3b9byFvrw7WporZx3C7yL1U
LS3oxI/pgavxy1KbOIw/yl+QgV50vlfvQ7sKZ1E5YOrgWLP5nJ9OeEKRdsASJyZS
Jjl0k/eGaZreSvAZPmx4kaePfbi7DNDA+mNhSFygwt6AakjjVoF2xUZ1F+qwBtER
GPxdZWldywUYsdBRG1PPvBsMo9ME46HpPdXRIjMge8P01fsaMr/6H86ojWg9uJmH
cCKtiouo08hL
-----END CERTIFICATE-----

View File

@ -2,8 +2,9 @@ import csv
from io import StringIO
import requests
import datetime
BASE_URL = "https://api.membershipworks.com/v1/"
BASE_URL = "https://api.membershipworks.com"
# extracted from `SF._is.crm` in https://cdn.membershipworks.com/all.js
CRM = {
@ -72,21 +73,30 @@ class MembershipWorksRemoteError(Exception):
class MembershipWorks:
def __init__(self):
self.sess = requests.Session()
self.org_info = None
self.auth_token = None
self.org_num = None
def login(self, username, password):
"""Authenticate against the membershipworks api"""
r = requests.post(
BASE_URL + "usr",
data={"_st": "all", "eml": username, "org": "10000", "pwd": password},
r = self.sess.post(
BASE_URL + "/v2/account/session",
data={"eml": username, "pwd": password},
headers={"X-Org": "10000"},
)
if r.status_code != 200 or "SF" not in r.json():
raise MembershipWorksRemoteError("login", r)
self.org_info = r.json()
self.auth_token = self.org_info["SF"]
self.org_num = self.org_info["org"]
self.sess.headers.update(
{
"X-Org": str(self.org_num),
"X-Role": "admin",
"Authorization": "Bearer " + self.auth_token,
}
)
def _inject_auth(self, kwargs):
# TODO: should probably be a decorator or something
@ -97,12 +107,12 @@ class MembershipWorks:
kwargs["params"] = {}
kwargs["params"]["SF"] = self.auth_token
def _get(self, *args, **kwargs):
def _get_v1(self, *args, **kwargs):
self._inject_auth(kwargs)
# TODO: should probably do some error handling in here
return requests.get(*args, **kwargs)
def _post(self, *args, **kwargs):
def _post_v1(self, *args, **kwargs):
self._inject_auth(kwargs)
# TODO: should probably do some error handling in here
return requests.post(*args, **kwargs)
@ -141,37 +151,35 @@ class MembershipWorks:
for dek in self.org_info["dek"]:
# TODO: there must be a better way. this is stupid
if dek["dek"] == 1:
ret["folders"][dek["lbl"]] = dek["_id"]
ret["folders"][dek["lbl"]] = dek["did"]
elif "cur" in dek:
ret["levels"][dek["lbl"]] = dek["_id"]
ret["levels"][dek["lbl"]] = dek["did"]
elif "mux" in dek:
ret["addons"][dek["lbl"]] = dek["_id"]
ret["addons"][dek["lbl"]] = dek["did"]
else:
ret["labels"][dek["lbl"]] = dek["_id"]
ret["labels"][dek["lbl"]] = dek["did"]
return ret
def get_member_ids(self, folders):
folder_map = self._parse_flags()["folders"]
r = self._get(
BASE_URL + "ylp",
params={
"lbl": ",".join([folder_map[f] for f in folders]),
"org": self.org_num,
"var": "_id,nam,ctc",
},
r = self.sess.get(
BASE_URL + "/v2/accounts",
params={"dek": ",".join([folder_map[f] for f in folders])},
)
if r.status_code != 200 or "usr" not in r.json():
raise MembershipWorksRemoteError("user listing", r)
# get list of member ID matching the search
return [user["uid"] for user in r.json()["usr"]]
# dedup with set() to work around people with alt uids
# TODO: figure out why people have alt uids
return set(user["uid"] for user in r.json()["usr"])
# TODO: has issues with aliasing header names:
# ex: "Personal Studio Space" Label vs Membership Addon/Field
def get_members(self, folders, columns):
""" Pull the members csv from the membershipworks api
"""Pull the members csv from the membershipworks api
folders: a list of the names of the folders to get
(see folder_map in this function for mapping to ids)
columns: which columns to get"""
@ -179,8 +187,8 @@ class MembershipWorks:
# get members CSV
# TODO: maybe can just use previous get instead? would return JSON
r = self._post(
BASE_URL + "csv",
r = self._post_v1(
BASE_URL + "/v1/csv",
data={
"_rt": "946702800", # unknown
"mux": "", # unknown
@ -190,6 +198,10 @@ class MembershipWorks:
)
if r.status_code != 200:
raise MembershipWorksRemoteError("csv generation", r)
if r.text[0] == "\ufeff":
r.encoding = r.encoding + "-sig"
return list(csv.DictReader(StringIO(r.text)))
def get_transactions(self, start_date, end_date, json=False):
@ -200,10 +212,10 @@ class MembershipWorks:
json gets a different version of the transactions list,
which contains a different set information
"""
r = self._get(
BASE_URL + "csv",
r = self._get_v1(
BASE_URL + "/v1/csv",
params={
"crm": "12,13,14,18,19", # transaction types, see CRM
"crm": ",".join(str(k) for k in CRM.keys()),
**({"txl": ""} if json else {}),
"sdp": start_date.strftime("%s"),
"edp": end_date.strftime("%s"),
@ -214,6 +226,9 @@ class MembershipWorks:
if json:
return r.json()
else:
if r.text[0] == "\ufeff":
r.encoding = r.encoding + "-sig"
return list(csv.DictReader(StringIO(r.text)))
def get_all_members(self):
@ -222,3 +237,29 @@ class MembershipWorks:
fields = self._all_fields()
members = self.get_members(folders, ",".join(fields.keys()))
return members
def get_events_list(self, start_date: datetime.datetime):
"""Retrive a list of events since start_date"""
r = self.sess.get(
BASE_URL + "/v2/events",
params={
"sdp": start_date.strftime("%s"),
},
)
return r.json()
def get_event_by_eid(self, eid: str):
"""Retrieve a specific event by its event id (eid)"""
r = self.sess.get(
BASE_URL + "/v2/event",
params={"eid": eid},
)
return r.json()
def get_event_by_url(self, url: str):
"""Retrieve a specific event by its url"""
r = self.sess.get(
BASE_URL + "/v2/event",
params={"url": url},
)
return r.json()

View File

@ -22,7 +22,6 @@ class Config:
self.DOOR_PASSWORD,
name=doorName,
access=doorData["access"],
cert=self.doorControllerCA_BUNDLE
)
for doorName, doorData in self.doorControllers.items()
}

View File

@ -72,15 +72,9 @@ class MembershipworksMember(Member):
else:
self.credentials = set()
self.onHold = data["Account on Hold"] != ""
self.limitedOperations = (
data[
"Access Permitted Using Membership Level Schedule During COVID-19 Limited Operations"
]
== "Y"
)
self.staffedLimitedOperations = (
data["Access Permitted During COVID-19 Staffed Period Only"] == "Y"
self.onHold = (
data["Account on Hold"] != ""
or data["CMS Membership on hold"] == "CMS Membership on hold"
)
self.formerMember = formerMember
@ -106,14 +100,8 @@ class MembershipworksMember(Member):
schedules = []
if door.name in self.doorAccess and not self.onHold and not self.formerMember:
# members should get their normal schedules
if self.limitedOperations or "CMS Staff" in self.levels:
schedules = self.schedules + doorLevels
# members should get only the staffed hours schedule
if self.staffedLimitedOperations:
schedules += ["Staffed Hours"]
dm = DoorMember(
door,
forename=self.forename,
@ -133,7 +121,6 @@ class MembershipworksMember(Member):
return (
super().__str__()
+ f"""OnHold? {self.onHold}
Limited Operations Access? {self.limitedOperations}
Former Member? {self.formerMember}
"""
)
@ -178,14 +165,48 @@ class DoorMember(Member):
"custom2": self.membershipWorksID,
}
def make_schedules(self, schedulesMap):
roles = [
E.Role(
{
"roleID": self.cardholderID,
"scheduleID": schedulesMap[schedule],
"resourceID": "0",
}
)
for schedule in self.schedules
]
return E.RoleSet(
{"action": "UD", "roleSetID": self.cardholderID}, E.Roles(*roles)
)
def make_credentials(self, newCredentials, cardFormats):
out = [
E.Credential(
{
"formatName": str(credential.code[0]),
"cardNumber": str(credential.code[1]),
"formatID": cardFormats[str(credential.code[0])],
"isCard": "true",
"cardholderID": self.cardholderID,
}
)
for credential in newCredentials
]
return E.Credentials({"action": "AD"}, *out)
def update_door(door, members):
cardFormats = door.get_cardFormats()
cardholders = {
member.membershipWorksID: member
for member in [
DoorMember.from_cardholder(ch, door) for ch in door.get_cardholders()
]
}
schedulesMap = door.get_scheduleMap()
allCredentials = set(
Credential(hex=c.attrib["rawCardNumber"]) for c in door.get_credentials()
)
@ -197,7 +218,9 @@ def update_door(door, members):
if member.membershipWorksID not in cardholders:
print("- Adding Member {member.forename} {member.surname}:")
print(f" - {member.attribs()}")
resp = door.add_cardholder(member.attribs)
resp = door.doXMLRequest(
ROOT(E.Cardholders({"action": "AD"}, E.Cardholder(member.attribs())))
)
member.cardholderID = resp.find("{*}Cardholders/{*}Cardholder").attrib[
"cardholderID"
]
@ -216,7 +239,14 @@ def update_door(door, members):
print(f"- Updating profile for {member.forename} {member.surname}")
print(f" - Old: {ch.attribs()}")
print(f" - New: {member.attribs()}")
door.update_cardholder(member.cardholderID, member.attribs)
door.doXMLRequest(
ROOT(
E.Cardholders(
{"action": "UD", "cardholderID": member.cardholderID},
E.CardHolder(member.attribs()),
)
)
)
if member.credentials != ch.credentials:
print(f"- Updating card for {member.forename} {member.surname}")
@ -234,7 +264,18 @@ def update_door(door, members):
# cards removed, and won't be reassigned to someone else
for card in (oldCards - newCards) - allNewCards:
door.assign_credential(card, None)
door.doXMLRequest(
ROOT(
E.Credentials(
{
"action": "UD",
"rawCardNumber": card.hex,
"isCard": "true",
},
E.Credential({"cardholderID": ""}),
)
)
)
if newCards - oldCards: # cards added
for card in newCards & allNewCards: # new card exists in another member
@ -250,11 +291,28 @@ def update_door(door, members):
# card existed in door, and needs to be reassigned
for card in newCards & allCredentials:
door.assign_credential(card, member.cardholderID)
door.doXMLRequest(
ROOT(
E.Credentials(
{
"action": "UD",
"rawCardNumber": card.hex,
"isCard": "true",
},
E.Credential({"cardholderID": member.cardholderID}),
)
)
)
# cards that never existed, and need to be created
if newCards - allCredentials:
door.add_credentials(newCards - allCredentials, member.cardholderID)
door.doXMLRequest(
ROOT(
member.make_credentials(
newCards - allCredentials, cardFormats
)
)
)
if member.schedules != ch.schedules:
print(
@ -262,7 +320,7 @@ def update_door(door, members):
+ f" {member.forename} {member.surname}:"
+ f" {ch.schedules} -> {member.schedules}"
)
door.set_cardholder_schedules(member.cardholderID, member.schedules)
door.doXMLRequest(ROOT(member.make_schedules(schedulesMap)))
# TODO: delete cardholders that are no longer members?
@ -271,7 +329,7 @@ def main():
config = Config()
membershipworks = config.membershipworks
membershipworks_attributes = (
"_id,nam,phn,eml,lvl,lbl,xws,xms,xsc,xas,xfd,xac,xcf,xeh,xse,xlo,xxc"
"_id,nam,phn,eml,lvl,lbl,xws,xms,xsc,xas,xfd,xac,xcf,xeh,xse"
)
memberData = membershipworks.get_members(

View File

@ -1,14 +1,11 @@
import bitstring
from typing import Tuple, Optional
# 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: Optional[Tuple[int, int]] = None, hex: Optional[str] = None
) -> None:
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:
@ -24,24 +21,21 @@ class Credential:
elif hex is not None:
self.bits = bitstring.Bits(hex=hex)
def __repr__(self) -> str:
def __repr__(self):
return f"Credential({self.code})"
def __eq__(self, other: object) -> bool:
if isinstance(other, Credential):
def __eq__(self, other):
return self.bits == other.bits
else:
return False
def __hash__(self) -> int:
def __hash__(self):
return self.bits.int
@property
def code(self) -> Tuple[int, int]:
def code(self):
facility = self.bits[7:15].uint
code = self.bits[15:31].uint
return (facility, code)
@property
def hex(self) -> str:
def hex(self):
return self.bits.hex.upper()

View File

@ -1,25 +1,11 @@
import csv
from datetime import datetime
from io import StringIO
from typing import (
IO,
Callable,
Iterable,
List,
Literal,
Mapping,
Optional,
Tuple,
Union,
)
import requests
import urllib3
from lxml import etree
from lxml.builder import ElementMaker
from requests import Session
from requests.adapters import HTTPAdapter
from .Credential import Credential
E_plain = ElementMaker(nsmap={"hid": "http://www.hidglobal.com/VertX"})
E = ElementMaker(
@ -36,65 +22,33 @@ fieldnames = "CardNumber,CardFormat,PinRequired,PinCode,ExtendedAccess,ExpiryDat
","
)
class HostNameIgnoringAdapter(HTTPAdapter):
def init_poolmanager(self, *args, **kwargs) -> None:
super().init_poolmanager(*args, **kwargs, assert_hostname=False)
# 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: requests.Response) -> None:
def __init__(self, r):
super().__init__(f"Door Updating Error: {r.status_code} {r.reason}\n{r.text}")
class DoorController:
def __init__(
self,
ip: str,
username: str,
password: str,
name: str = "",
access: str = "",
cert: Optional[str] = None,
) -> None:
def __init__(self, ip, username, password, name="", access=""):
self.ip = ip
self.username = username
self.password = password
self.name = name
self.access = access
self.session = Session()
if cert is not None:
self.session.mount("https://", HostNameIgnoringAdapter())
self.session.verify = cert
self._cardFormats: Optional[Mapping[str, str]] = None
self._scheduleMap: Optional[Mapping[str, str]] = None
# lazy evaluated, hopefully won't change for the lifetime of this object
@property
def cardFormats(self) -> Mapping[str, str]:
if not self._cardFormats:
self._cardFormats = self.get_cardFormats()
return self._cardFormats
@property
def scheduleMap(self) -> Mapping[str, str]:
if not self._scheduleMap:
self._scheduleMap = self.get_scheduleMap()
return self._scheduleMap
def doImport(
self,
params: Optional[Mapping[str, str]] = None,
files: Optional[Mapping[str, Tuple[str, Union[IO[str], str], str]]] = None,
) -> None:
def doImport(self, params=None, files=None):
"""Send a request to the door control import script"""
r = self.session.post(
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 (
@ -103,7 +57,7 @@ class DoorController:
):
raise RemoteError(r)
def doCSVImport(self, csv: Union[IO[str], str]) -> None:
def doCSVImport(self, csv):
"""Do the CSV import procedure on a door control"""
self.doImport({"task": "importInit"})
self.doImport(
@ -112,29 +66,22 @@ class DoorController:
)
self.doImport({"task": "importDone"})
def doXMLRequest(
self,
xml: Union[etree.Element, bytes],
prefix: bytes = b'<?xml version="1.0" encoding="UTF-8"?>',
) -> etree.XML:
def doXMLRequest(self, xml, prefix=b'<?xml version="1.0" encoding="UTF-8"?>'):
if not isinstance(xml, bytes):
xml = etree.tostring(xml)
r = self.session.get(
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,
)
if r.status_code != 200:
raise RemoteError(r)
# probably meed to be more sane about this
resp_xml = etree.XML(r.content)
if len(resp_xml.findall("{*}Error")) > 0:
# 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) -> Mapping[str, str]:
def get_scheduleMap(self):
schedules = self.doXMLRequest(
ROOT(E.Schedules({"action": "LR", "recordOffset": "0", "recordCount": "8"}))
)
@ -142,7 +89,7 @@ class DoorController:
fmt.attrib["scheduleName"]: fmt.attrib["scheduleID"] for fmt in schedules[0]
}
def get_schedules(self) -> etree.Element:
def get_schedules(self):
# TODO: might be able to do in one request
schedules = self.doXMLRequest(ROOT(E.Schedules({"action": "LR"})))
etree.dump(schedules)
@ -159,7 +106,7 @@ class DoorController:
)
return ROOT(E_corp.Schedules({"action": "AD"}, *[s[0] for s in data]))
def set_schedules(self, schedules: etree.Element) -> None:
def set_schedules(self, schedules):
# clear all people
outString = StringIO()
writer = csv.DictWriter(outString, fieldnames)
@ -184,27 +131,7 @@ class DoorController:
# load new schedules
self.doXMLRequest(schedules)
def set_cardholder_schedules(
self, cardholderID: str, schedules: Iterable[str]
) -> etree.XML:
roles = [
E.Role(
{
"roleID": cardholderID,
"scheduleID": self.scheduleMap[schedule],
"resourceID": "0",
}
)
for schedule in schedules
]
roleSet = E.RoleSet(
{"action": "UD", "roleSetID": cardholderID}, E.Roles(*roles)
)
return self.doXMLRequest(ROOT(roleSet))
def get_cardFormats(self) -> Mapping[str, str]:
def get_cardFormats(self):
cardFormats = self.doXMLRequest(
ROOT(E.CardFormats({"action": "LR", "responseFormat": "expanded"}))
)
@ -214,9 +141,7 @@ class DoorController:
for fmt in cardFormats[0].findall("{*}CardFormat[{*}FixedField]")
}
def set_cardFormat(
self, formatName: str, templateID: int, facilityCode: int
) -> etree.XML:
def set_cardFormat(self, formatName, templateID, facilityCode):
# TODO: add ability to delete formats
# delete example: <hid:CardFormats action="DD" formatID="7-1-244"/>
@ -231,14 +156,8 @@ class DoorController:
)
return self.doXMLRequest(el)
def get_records(
self,
req: ElementMaker,
count: int,
params: Mapping[str, str] = {},
stopFunction: Optional[Callable[[List[etree.Element]], bool]] = None,
) -> List[etree.Element]:
result: List[etree.Element] = []
def get_records(self, req, count, params={}, stopFunction=None):
result = []
recordCount = 0
moreRecords = True
@ -270,76 +189,17 @@ class DoorController:
return result
def get_cardholders(self) -> List[etree.Element]:
def get_cardholders(self):
return self.get_records(E.Cardholders, 1000, {"responseFormat": "expanded"})
def add_cardholder(self, attribs: Mapping[str, str]) -> etree.XML:
return self.doXMLRequest(
ROOT(E.Cardholders({"action": "AD"}, E.Cardholder(attribs)))
)
def update_cardholder(
self, cardholderID: str, attribs: Mapping[str, str]
) -> etree.XML:
return self.doXMLRequest(
ROOT(
E.Cardholders(
{"action": "UD", "cardholderID": cardholderID},
E.CardHolder(attribs),
)
)
)
def get_credentials(self) -> List[etree.Element]:
def get_credentials(self):
return self.get_records(E.Credentials, 1000)
def add_credentials(
self, credentials: Iterable[Credential], cardholderID: Optional[str] = None
) -> etree.XML:
"""Create new Credentials. If a cardholderID is provided, assign the
new credentials to that cardholder"""
creds = [
E.Credential(
{
"formatName": str(credential.code[0]),
"cardNumber": str(credential.code[1]),
"formatID": self.cardFormats[str(credential.code[0])],
"isCard": "true",
**({"cardholderID": cardholderID} if cardholderID else {}),
}
)
for credential in credentials
]
return self.doXMLRequest(ROOT(E.Credentials({"action": "AD"}, *creds)))
def assign_credential(
self, credential: Credential, cardholderID: Optional[str] = None
) -> etree.XML:
# empty string removes assignment
if cardholderID is None:
cardholderID = ""
return self.doXMLRequest(
ROOT(
E.Credentials(
{
"action": "UD",
"rawCardNumber": credential.hex,
"isCard": "true",
},
E.Credential({"cardholderID": cardholderID}),
)
)
)
def get_events(self, threshold: datetime) -> List[etree.Element]:
def event_newer_than_threshold(event: etree.Element) -> bool:
def get_events(self, threshold):
def event_newer_than_threshold(event):
return datetime.fromisoformat(event.attrib["timestamp"]) > threshold
def last_event_newer_than_threshold(
events: List[etree.Element],
) -> etree.Element:
def last_event_newer_than_threshold(events):
return (not events) or event_newer_than_threshold(events[-1])
return [
@ -350,13 +210,13 @@ class DoorController:
if event_newer_than_threshold(event)
]
def get_lock(self) -> Union[Literal["locked"], Literal["unlocked"]]:
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: bool = True) -> etree.XML:
def set_lock(self, lock=True):
el = ROOT(
E.Doors({"action": "CM", "command": "lockDoor" if lock else "unlockDoor"})
)

View File

@ -1,13 +0,0 @@
import pytest
from ..Credential import Credential
def test_code_to_hex() -> None:
cred = Credential(code=(123, 45678))
assert cred.hex == "02F764DD"
def test_hex_to_code() -> None:
cred = Credential(hex="02F764DD")
assert cred.code == (123, 45678)

View File

@ -97,7 +97,11 @@ def main():
config = Config()
database.init(
**config.HID_DB,
**{"charset": "utf8", "sql_mode": "PIPES_AS_CONCAT", "use_unicode": True,}
**{
"charset": "utf8",
"sql_mode": "PIPES_AS_CONCAT",
"use_unicode": True,
},
)
HIDEvent.create_table()
for door in config.doors.values():

View File

@ -75,6 +75,7 @@ class Member(BaseModel):
last_name = TextField(column_name="Last Name", null=True)
phone = TextField(column_name="Phone", null=True)
email = TextField(column_name="Email", null=True)
volunteer_email = TextField(column_name="Volunteer Email", null=True)
address_street = TextField(column_name="Address (Street)", null=True)
address_city = TextField(column_name="Address (City)", null=True)
address_state_province = TextField(

View File

@ -22,6 +22,12 @@ def do_import(config):
]
).on_conflict(action="update", preserve=[Flag.name, Flag.type]).execute()
print("Getting folder membership...")
folders = {
folder_id: membershipworks.get_member_ids([folder_name])
for folder_name, folder_id in membershipworks._parse_flags()["folders"].items()
}
print("Getting/Updating members...")
members = membershipworks.get_all_members()
for m in members:
@ -41,10 +47,11 @@ def do_import(config):
# update member's flags
for type, flags in membershipworks._parse_flags().items():
if type != "folders": # currently no way to retrieve this info
for flag, id in flags.items():
ml = MemberFlag(uid=member["Account ID"], flag_id=id)
if member[flag]:
if (type == "folders" and member["Account ID"] in folders[id]) or (
type != "folders" and member[flag]
):
ml.magic_save()
else:
ml.delete_instance()
@ -65,8 +72,12 @@ def do_import(config):
]
)
for transaction in transactions:
Transaction.from_csv_dict(transaction).magic_save()
Transaction.insert_many(
[
Transaction.from_csv_dict(transaction).__data__
for transaction in transactions
]
).execute()
# TODO: folders, levels, addons
@ -75,7 +86,11 @@ def main():
config = Config()
database.init(
**config.MEMBERSHIPWORKS_DB,
**{"charset": "utf8", "sql_mode": "PIPES_AS_CONCAT", "use_unicode": True,}
**{
"charset": "utf8",
"sql_mode": "PIPES_AS_CONCAT",
"use_unicode": True,
},
)
do_import(config)

View File

@ -1,688 +0,0 @@
{
"$schema": "http://json-schema.org/schema#",
"type": "object",
"properties": {
"nam": {
"type": "string"
},
"adr": {
"type": "object",
"properties": {
"zip": {
"type": "string"
},
"cit": {
"type": "string"
},
"sta": {
"type": "string"
},
"con": {
"type": "string"
},
"ad1": {
"type": "string"
},
"loc": {
"type": "array",
"items": {
"type": "number"
}
}
},
"required": [
"ad1",
"cit",
"con",
"loc",
"sta",
"zip"
]
},
"org": {
"type": "integer"
},
"cur": {
"type": "string"
},
"ctc": {
"type": "string"
},
"atg": {
"type": "string"
},
"uid": {
"type": "string"
},
"typ": {
"type": "string"
},
"eml": {
"type": "string"
},
"phn": {
"type": "string"
},
"end": {
"type": "integer"
},
"tpl": {
"type": "object",
"properties": {
"anm": {
"type": "array",
"items": {
"type": "object",
"properties": {
"lbl": {
"type": "string"
},
"box": {
"type": "array",
"items": {
"type": "object",
"properties": {
"dat": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "object",
"properties": {
"_id": {
"type": "string"
},
"typ": {
"type": "integer"
},
"plh": {
"type": "string"
},
"req": {
"type": "integer"
},
"rxp": {
"type": "string"
},
"err": {
"type": "string"
},
"lbl": {
"type": "string"
},
"dtl": {
"type": "string"
}
},
"required": [
"typ"
]
}
}
]
},
"ttl": {
"type": "string"
},
"dtl": {
"type": "string"
}
},
"required": [
"dat"
]
}
},
"dir": {
"type": "integer"
}
},
"required": [
"box",
"lbl"
]
}
},
"acc": {
"type": "array",
"items": {
"type": "object",
"properties": {
"lbl": {
"type": "string"
},
"box": {
"type": "array",
"items": {
"type": "object",
"properties": {
"dat": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "object",
"properties": {
"_id": {
"type": "string"
},
"typ": {
"type": "integer"
},
"plh": {
"type": "string"
},
"req": {
"type": "integer"
},
"rxp": {
"type": "string"
},
"err": {
"type": "string"
},
"lbl": {
"type": "string"
}
},
"required": [
"_id",
"lbl",
"typ"
]
}
}
]
},
"ttl": {
"type": "string"
}
},
"required": [
"dat"
]
}
}
},
"required": [
"box",
"lbl"
]
}
},
"adm": {
"type": "array",
"items": {
"type": "object",
"properties": {
"lbl": {
"type": "string"
},
"box": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ttl": {
"type": "string"
},
"dat": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "object",
"properties": {
"_id": {
"type": "string"
},
"typ": {
"type": "integer"
},
"plh": {
"type": "string"
},
"req": {
"type": "integer"
},
"rxp": {
"type": "string"
},
"err": {
"type": "string"
},
"lbl": {
"type": "string"
},
"def": {
"type": "string"
}
},
"required": [
"_id",
"typ"
]
}
}
]
},
"dtl": {
"type": "string"
}
},
"required": [
"dat"
]
}
}
},
"required": [
"box",
"lbl"
]
}
},
"dir": {
"type": "array",
"items": {
"type": "object",
"properties": {
"lbl": {
"type": "string"
},
"box": {
"type": "array",
"items": {
"type": "object",
"properties": {
"htm": {
"type": "string"
},
"dat": {
"type": "string"
}
}
}
}
},
"required": [
"box",
"lbl"
]
}
},
"crd": {
"type": "object",
"properties": {
"typ": {
"type": "integer"
},
"dtl": {
"type": "string"
}
},
"required": [
"dtl",
"typ"
]
},
"ylp": {
"type": "array",
"items": {
"type": "object",
"properties": {
"dat": {
"type": "string"
},
"ttl": {
"type": "string"
}
},
"required": [
"dat"
]
}
},
"evt": {
"type": "array",
"items": {
"type": "object",
"properties": {
"_id": {
"type": "string"
},
"typ": {
"type": "integer"
},
"req": {
"type": "integer"
},
"lbl": {
"type": "string"
},
"rxp": {
"type": "string"
},
"err": {
"type": "string"
}
},
"required": [
"_id",
"lbl",
"req",
"typ"
]
}
}
},
"required": [
"acc",
"adm",
"anm",
"crd",
"dir",
"evt",
"ylp"
]
},
"evg": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ttl": {
"type": "string"
},
"cal": {
"type": "integer"
},
"opt": {
"type": "integer"
}
},
"required": [
"cal",
"opt",
"ttl"
]
}
},
"dcc": {
"type": "array",
"items": {
"type": "object",
"properties": {
"lbl": {
"type": "string"
},
"amt": {
"type": "integer"
},
"typ": {
"type": "integer"
},
"_id": {
"type": "string"
},
"cnt": {
"type": "integer"
},
"cap": {
"type": "integer"
}
},
"required": [
"_id",
"amt",
"lbl",
"typ"
]
}
},
"dek": {
"type": "array",
"items": {
"type": "object",
"properties": {
"did": {
"type": "string"
},
"lbl": {
"type": "string"
},
"dir": {
"type": "integer"
},
"aon": {
"type": "integer"
},
"typ": {
"type": "integer"
},
"dek": {
"type": "integer"
},
"pub": {
"type": "integer"
},
"dtl": {
"type": "string"
},
"mux": {
"type": "integer"
},
"itv": {
"type": "string"
},
"lvl": {
"type": "array",
"items": {
"type": "string"
}
},
"_id": {
"type": "string"
},
"ctc": {
"type": "array",
"items": {
"type": "string"
}
},
"tnm": {
"type": "string"
},
"dnm": {
"type": "string"
},
"cur": {
"type": "string"
},
"_rt": {
"type": "integer"
},
"bil": {
"type": "array",
"items": {
"type": "object",
"properties": {
"bid": {
"type": "string"
},
"ttl": {
"type": "string"
},
"way": {
"type": "integer"
},
"amt": {
"type": "number"
},
"itv": {
"type": "string"
},
"mux": {
"type": "integer"
},
"pub": {
"type": "integer"
},
"dcc": {
"type": "object"
},
"dso": {
"type": "string"
}
},
"required": [
"amt",
"bid",
"itv",
"mux",
"ttl",
"way"
]
}
},
"cat": {
"type": "integer"
},
"put": {
"type": "array",
"items": {
"type": "string"
}
},
"upg": {
"type": "array",
"items": {
"type": "string"
}
},
"nyn": {
"type": "integer"
},
"prp": {
"type": "integer"
},
"psm": {
"type": "integer"
},
"exp": {
"type": "string"
},
"enr": {
"type": "array",
"items": {
"type": "object",
"properties": {
"day": {
"type": "integer"
},
"ttl": {
"type": "string"
},
"dtl": {
"type": "string"
}
},
"required": [
"day",
"dtl",
"ttl"
]
}
},
"wen": {
"type": "string"
},
"pur": {
"type": "array",
"items": {
"type": "string"
}
},
"puk": {
"type": "array",
"items": {
"type": "string"
}
},
"puu": {
"type": "array",
"items": {
"type": "string"
}
},
"pus": {
"type": "array",
"items": {
"type": "string"
}
},
"pup": {
"type": "array",
"items": {
"type": "string"
}
},
"puq": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"_id",
"dek",
"did",
"dir",
"lbl",
"pub",
"typ"
]
}
},
"SF": {
"type": "string"
},
"_fd": {
"type": "integer"
},
"_ts": {
"type": "integer"
},
"_re": {
"type": "integer"
}
},
"required": [
"SF",
"_fd",
"_ts",
"adr",
"atg",
"cur",
"dcc",
"dek",
"eml",
"end",
"evg",
"nam",
"org",
"tpl",
"typ",
"uid"
]
}

View File

@ -1,179 +0,0 @@
import datetime
from unittest.mock import patch
import pytest
import responses
from ..MembershipWorks import BASE_URL, MembershipWorks, MembershipWorksRemoteError
@responses.activate
def test_login():
membershipworks = MembershipWorks()
responses.add(
responses.POST, BASE_URL + "usr", json={"SF": "test1", "org": 10000}, status=200
)
membershipworks.login("test1@example.com", "test2")
assert membershipworks.auth_token == "test1"
assert membershipworks.org_num == 10000
@responses.activate
def test_login_fail():
membershipworks = MembershipWorks()
responses.add(
responses.POST, BASE_URL + "usr", json={"error": "Login/password not accepted"},
)
with pytest.raises(MembershipWorksRemoteError) as e:
membershipworks.login("test1@example.com", "test2")
assert (
e.exception.args[0]
== "Error when attempting login: 200 OK\n"
+ '{"error":"Login/password not accepted"}'
)
@pytest.fixture
def membershipworks():
mw = MembershipWorks()
with responses.RequestsMock() as rsps:
rsps.add(
responses.POST,
BASE_URL + "usr",
json={
"SF": "test1",
"org": 10000,
"dek": [{"_id": "12345", "lbl": "test", "dek": 1}],
},
)
mw.login("test1@example.com", "test2")
return mw
@responses.activate
def test_get_member_ids(membershipworks: MembershipWorks):
responses.add(
responses.GET,
BASE_URL + "ylp",
json={
"typ": "a",
"usr": [{"uid": "asdf", "nam": "test", "rnk": 12.345}],
"_re": 631,
},
)
ids = membershipworks.get_member_ids(["test"])
assert ids == ["asdf"]
@responses.activate
@patch("memberPlumbing.MembershipWorks.MembershipWorks.get_member_ids")
def test_get_members(mockIDs, membershipworks: MembershipWorks):
mockIDs.return_value = ["asdf"]
responses.add(
responses.POST,
BASE_URL + "csv",
body=""""Account Name","First Name","Last Name","Account ID","Parent Account ID"
"bob whatever", "bob", "whatever", "aaaaaaaaaaaaaaaaaaaaaaaa", ""
"george whatever", "george", "whatever", "bbbbbbbbbbbbbbbbbbbbbbbb", ""
""",
)
members = membershipworks.get_members(["test"], "_id,nam")
assert members == [
{
"Account Name": "bob whatever",
"First Name": ' "bob"',
"Last Name": ' "whatever"',
"Account ID": ' "aaaaaaaaaaaaaaaaaaaaaaaa"',
"Parent Account ID": ' ""',
},
{
"Account Name": "george whatever",
"First Name": ' "george"',
"Last Name": ' "whatever"',
"Account ID": ' "bbbbbbbbbbbbbbbbbbbbbbbb"',
"Parent Account ID": ' ""',
},
]
@responses.activate
def test_get_transactions_csv(membershipworks: MembershipWorks):
responses.add(
responses.GET,
BASE_URL + "csv",
body=""""Date","Name","Contact Person","Full Address","Street","City","State/Province","Postal Code","Country","Phone","Email","Membership Sub-Total","Event Sub-Total","Donation Sub-Total","Cart Sub-Total","Other Sub-Total","Handling","Total Tax","Transaction Total","Transaction Fee","Total Payment Due","Transaction Type","For","Items","Payment ID","Account ID","Discount Code","Note"
"Jan 01, 2020","whatever","","PO Box 0, fakeplace NH, US","PO Box 0","fakeplace","NH","","US","000-000-0000","example@example.com",1,0,0,0,0,0,0,1,0.32,0,"Membership","","CMS Membership on hold - Membership on hold","ch_aaaaaaaaaaaaaaaaaaaaaaaa","aaaaaaaaaaaaaaaaaaaaaaaa","",""
""",
)
transactions = membershipworks.get_transactions(
datetime.datetime(2000, 1, 1), datetime.datetime(2021, 1, 1)
)
assert transactions == [
{
"Date": "Jan 01, 2020",
"Name": "whatever",
"Contact Person": "",
"Full Address": "PO Box 0, fakeplace NH, US",
"Street": "PO Box 0",
"City": "fakeplace",
"State/Province": "NH",
"Postal Code": "",
"Country": "US",
"Phone": "000-000-0000",
"Email": "example@example.com",
"Membership Sub-Total": "1",
"Event Sub-Total": "0",
"Donation Sub-Total": "0",
"Cart Sub-Total": "0",
"Other Sub-Total": "0",
"Handling": "0",
"Total Tax": "0",
"Transaction Total": "1",
"Transaction Fee": "0.32",
"Total Payment Due": "0",
"Transaction Type": "Membership",
"For": "",
"Items": "CMS Membership on hold - Membership on hold",
"Payment ID": "ch_aaaaaaaaaaaaaaaaaaaaaaaa",
"Account ID": "aaaaaaaaaaaaaaaaaaaaaaaa",
"Discount Code": "",
"Note": "",
}
]
@responses.activate
def test_get_transactions_json(membershipworks: MembershipWorks):
data_json = [
{
"cur": "usd",
"sid": "ch_aaaaaaaaaaaaaaaaaaaaaaaa",
"typ": 12,
"_dp": 1585100000,
"sum": 1,
"fee": 0.32,
"uid": "aaaaaaaaaaaaaaaaaaaaaaaa",
"nam": "whatever",
}
]
responses.add(responses.GET, BASE_URL + "csv", json=data_json)
transactions = membershipworks.get_transactions(
datetime.datetime(2000, 1, 1), datetime.datetime(2021, 1, 1), json=True
)
assert "&txl=" in responses.calls[0].request.url # json flag
assert transactions == data_json

View File

@ -1,31 +0,0 @@
import json
import os
import jsonschema
import requests
import pytest
from ..common import MEMBERSHIPWORKS_PASSWORD, MEMBERSHIPWORKS_USERNAME
from ..MembershipWorks import BASE_URL
schemas_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "json-schemas")
def test_login():
r = requests.post(
BASE_URL + "usr",
data={
"_st": "all",
"eml": MEMBERSHIPWORKS_USERNAME,
"org": "10000",
"pwd": MEMBERSHIPWORKS_PASSWORD,
},
)
with open(os.path.join(schemas_dir, "usr.schema")) as f:
schema = json.load(f)
usr = r.json()
jsonschema.validate(usr, schema)

View File

@ -5,13 +5,15 @@ import re
import string
from udm_rest_client.udm import UDM
from udm_rest_client.exceptions import NoObject
from udm_rest_client.exceptions import NoObject, UdmError
from .config import Config
USER_BASE = "cn=users,dc=sawtooth,dc=claremontmakerspace,dc=org"
GROUP_BASE = "cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org"
GROUPS_REGEX = "|".join(["Certified: .*", "Access .*\?", "CMS .*", "Volunteer: .*"])
GROUPS_REGEX = "|".join(
["Certified: .*", "Access .*\?", "CMS .*", "Volunteer: .*", "Database .*"]
)
RAND_PW_LEN = 20
@ -27,7 +29,7 @@ def sanitize_group_name(name):
# From an API error message: "Username must only contain numbers, letters and dots!"
def sanitize_user_name(name):
return re.sub(r"[^0-9a-z.]", ".", name.lower())
return re.sub(r"[^0-9a-z.]", ".", name.lower()).strip(".")
async def make_groups(group_mod, members):
@ -52,7 +54,6 @@ async def _main():
["Members", "CMS Staff"], "lvl,phn,eml,lbl,nam,end,_id"
)
# TODO: security!
async with UDM(**config.UCS) as udm:
user_mod = udm.get("users/user")
group_mod = udm.get("groups/group")
@ -65,7 +66,7 @@ async def _main():
try: # try to get an existing user to update
user = await user_mod.get(f"uid={username},{USER_BASE}")
except NoObject: # create a new user
# TODO: search by employeeID and rename users when needed
# TODO: search by employeeNumber and rename users when needed
user = await user_mod.new()
# set a random password and ensure it is changed at next login
@ -109,10 +110,17 @@ async def _main():
if re.match(GROUPS_REGEX, group) is not None and value != ""
]
# groups not from this script
other_old_groups = [g for g in user.props.groups if g[3:].startswith("MW_")]
other_old_groups = [
g for g in user.props.groups if not g[3:].startswith("MW_")
]
user.props.groups = other_old_groups + new_groups
try:
await user.save()
except UdmError:
print("Failed to save user", username)
print(user.props)
raise
def main():

View File

@ -0,0 +1,162 @@
#!/usr/bin/env python3
from datetime import datetime
import sys
import pyclip
from .config import Config
TIME_FMT = "%l:%M%P"
DATETIME_FMT = "%a %b %e %Y, " + TIME_FMT
def format_datetime_range(start_ts: int, end_ts: int):
start = datetime.fromtimestamp(start_ts)
end = datetime.fromtimestamp(end_ts)
start_str = start.strftime(DATETIME_FMT)
if start.date() == end.date():
end_str = end.strftime(TIME_FMT)
else:
# TODO: this probably implies multiple instances. Should read
# RRULE or similar from the event notes
end_str = end.strftime(DATETIME_FMT)
return f"{start_str} &mdash; {end_str}"
def format_event(event_details, truncate: bool):
try:
url = (
"https://claremontmakerspace.org/events/#!event/register/"
+ event_details["url"]
)
if "lgo" in event_details:
img = f"""<img class="alignleft" width="400" src="{event_details['lgo']['l']}">"""
else:
img = ""
# print(json.dumps(event_details))
out = f"""<h2 style="text-align: center;">
<a href="{url}">{img}{event_details['ttl']}</a>
</h2>
<div><i>{format_datetime_range(event_details['sdp'], event_details['edp'])}</i></div>
"""
if not truncate:
out += f"""
<div>
{event_details['dtl']}
</div>
<a href="{url}">Register for this class now!</a>"""
return out
except KeyError as e:
print(
f"Event '{event_details.get('ttl')}' missing required property: '{e.args[0]}'"
)
raise
def format_section(title: str, blurb: str, events, truncate: bool):
# skip empty sections
if not events:
return ""
events_list = "\n<hr />\n\n".join(format_event(event, truncate) for event in events)
return f"""<h1>{title}</h1>
<h4><i>{blurb}</i></h4>
{events_list}
"""
def generate_post():
config = Config()
now = datetime.now()
membershipworks = config.membershipworks
events = membershipworks.get_events_list(now)
if "error" in events:
print("Error:", events["error"])
return
ongoing_events = []
full_events = []
upcoming_events = []
for event in events["evt"]:
try:
# ignore hidden events
if event["cal"] == 0:
continue
event_details = membershipworks.get_event_by_eid(event["eid"])
# registration has already ended
if (
"erd" in event_details
and datetime.fromtimestamp(event_details["erd"]) < now
):
ongoing_events.append(event_details)
# class is full
elif event_details["cnt"] >= event_details["cap"]:
full_events.append(event_details)
else:
upcoming_events.append(event_details)
except KeyError as e:
print(
f"Event '{event.get('ttl')}' missing required property: '{e.args[0]}'"
)
raise
# header
yield """<p><img class="aligncenter size-medium wp-image-2319" src="https://claremontmakerspace.org/wp-content/uploads/2019/03/CMS-Logo-b-y-g-300x168.png" alt="" width="300" height="168" /></a></p>
<p>Greetings Upper Valley Makers:</p>
<p>We have an exciting list of upcoming classes at the Claremont MakerSpace that we think might interest you.</p>
<strong>For most classes and events, CMS MEMBERSHIP IS NOT REQUIRED.</strong> That said, members receive a discount on registration and there are some classes/events that are for members only (this will be clearly noted in the event description).
<strong>Class policies</strong> (liability waiver, withdrawal, cancellation, etc.) can be found <a href="https://claremontmakerspace.org/class-policies/" data-wpel-link="internal">here</a>.
<strong>Instructors:</strong> Interested in teaching a class at CMS? Please fill out our <a href="https://claremontmakerspace.org/cms-class-proposal-form/" data-wpel-link="internal">Class Proposal Form</a>.
<strong>Tours:</strong> Want to see what the Claremont MakerSpace is all about? Tours are by appointment only. <a href="https://tickets.claremontmakerspace.org/open.php" target="_blank" rel="noreferrer noopener external" data-wpel-link="external">Contact Us</a> to schedule your tour where you can learn about all the awesome tools that the CMS offers access to, as well as how membership, classes, and studio spaces work.
<hr />
"""
yield format_section(
"Upcoming Events",
"Events that are currently open for registration.",
upcoming_events,
truncate=False,
)
yield format_section(
"<hr />Just Missed",
"These classes are currently full at time of writing. If you are interested, please check the event's page; spots occasionally open up. Keep an eye on this newsletter to see when these classes are offered again.",
full_events,
truncate=True,
)
yield format_section(
"<hr />Ongoing Events",
"These events are ongoing. Registration is currently closed, but these events may be offered again in the future.",
ongoing_events,
truncate=True,
)
# footer
yield """<div style="clear: both;">
<hr />
<div>Happy Makin!</div>
<div>We are grateful for all of the public support that our 501(c)(3), non-profit organization receives. If youd like to make a donation,please visit the <a href="https://claremontmakerspace.org/support/"><strong>Support Us page</strong></a> of our website.</div>
</div>
"""
def main():
result = "\n".join(generate_post())
print(result)
pyclip.copy(result)
print("Copied to clipboard!", file=sys.stderr)
if __name__ == "__main__":
main()

1007
pdm.lock Normal file

File diff suppressed because it is too large Load Diff

2
pdm.toml Normal file
View File

@ -0,0 +1,2 @@
[strategy]
save = "compatible"

852
poetry.lock generated
View File

@ -1,852 +0,0 @@
[[package]]
category = "main"
description = "Async http client/server framework (asyncio)"
name = "aiohttp"
optional = false
python-versions = ">=3.5.3"
version = "3.6.2"
[package.dependencies]
async-timeout = ">=3.0,<4.0"
attrs = ">=17.3.0"
chardet = ">=2.0,<4.0"
multidict = ">=4.5,<5.0"
yarl = ">=1.0,<2.0"
[package.extras]
speedups = ["aiodns", "brotlipy", "cchardet"]
[[package]]
category = "dev"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
name = "appdirs"
optional = false
python-versions = "*"
version = "1.4.4"
[[package]]
category = "main"
description = "Python decorator for async properties."
name = "async-property"
optional = false
python-versions = "*"
version = "0.2.1"
[[package]]
category = "main"
description = "Timeout context manager for asyncio programs"
name = "async-timeout"
optional = false
python-versions = ">=3.5.3"
version = "3.0.1"
[[package]]
category = "dev"
description = "Atomic file writes."
marker = "sys_platform == \"win32\""
name = "atomicwrites"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "1.4.0"
[[package]]
category = "main"
description = "Classes Without Boilerplate"
name = "attrs"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "19.3.0"
[package.extras]
azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
docs = ["sphinx", "zope.interface"]
tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
[[package]]
category = "main"
description = "Simple construction, analysis and modification of binary data."
name = "bitstring"
optional = false
python-versions = "*"
version = "3.1.7"
[[package]]
category = "dev"
description = "The uncompromising code formatter."
name = "black"
optional = false
python-versions = ">=3.6"
version = "19.10b0"
[package.dependencies]
appdirs = "*"
attrs = ">=18.1.0"
click = ">=6.5"
pathspec = ">=0.6,<1"
regex = "*"
toml = ">=0.9.4"
typed-ast = ">=1.4.0"
[package.extras]
d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
[[package]]
category = "main"
description = "Python package for providing Mozilla's CA Bundle."
name = "certifi"
optional = false
python-versions = "*"
version = "2020.6.20"
[[package]]
category = "main"
description = "Universal encoding detector for Python 2 and 3"
name = "chardet"
optional = false
python-versions = "*"
version = "3.0.4"
[[package]]
category = "main"
description = "Composable command line interface toolkit"
name = "click"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "7.1.2"
[[package]]
category = "dev"
description = "Cross-platform colored terminal text."
marker = "sys_platform == \"win32\""
name = "colorama"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.4.3"
[[package]]
category = "dev"
description = "Code coverage measurement for Python"
name = "coverage"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
version = "5.2"
[package.dependencies]
[package.dependencies.toml]
optional = true
version = "*"
[package.extras]
toml = ["toml"]
[[package]]
category = "main"
description = "A Python library for the Docker Engine API."
name = "docker"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "4.2.2"
[package.dependencies]
requests = ">=2.14.2,<2.18.0 || >2.18.0"
six = ">=1.4.0"
websocket-client = ">=0.32.0"
[package.dependencies.pypiwin32]
python = ">=3.6"
version = "223"
[package.extras]
ssh = ["paramiko (>=2.4.2)"]
tls = ["pyOpenSSL (>=17.5.0)", "cryptography (>=1.3.4)", "idna (>=2.0.0)"]
[[package]]
category = "main"
description = "Internationalized Domain Names in Applications (IDNA)"
name = "idna"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "2.10"
[[package]]
category = "dev"
description = "Read metadata from Python packages"
marker = "python_version < \"3.8\""
name = "importlib-metadata"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
version = "1.7.0"
[package.dependencies]
zipp = ">=0.5"
[package.extras]
docs = ["sphinx", "rst.linker"]
testing = ["packaging", "pep517", "importlib-resources (>=1.3)"]
[[package]]
category = "dev"
description = "A Python utility / library to sort Python imports."
name = "isort"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "4.3.21"
[package.extras]
pipfile = ["pipreqs", "requirementslib"]
pyproject = ["toml"]
requirements = ["pipreqs", "pip-api"]
xdg_home = ["appdirs (>=1.4.0)"]
[[package]]
category = "main"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
name = "lxml"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*"
version = "4.5.2"
[package.extras]
cssselect = ["cssselect (>=0.7)"]
html5 = ["html5lib"]
htmlsoup = ["beautifulsoup4"]
source = ["Cython (>=0.29.7)"]
[[package]]
category = "dev"
description = "More routines for operating on iterables, beyond itertools"
name = "more-itertools"
optional = false
python-versions = ">=3.5"
version = "8.4.0"
[[package]]
category = "main"
description = "multidict implementation"
name = "multidict"
optional = false
python-versions = ">=3.5"
version = "4.7.6"
[[package]]
category = "main"
description = "Python interface to MySQL"
name = "mysqlclient"
optional = false
python-versions = "*"
version = "1.4.6"
[[package]]
category = "dev"
description = "Core utilities for Python packages"
name = "packaging"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "20.4"
[package.dependencies]
pyparsing = ">=2.0.2"
six = "*"
[[package]]
category = "dev"
description = "Utility library for gitignore style pattern matching of file paths."
name = "pathspec"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.8.0"
[[package]]
category = "main"
description = "a little orm"
name = "peewee"
optional = false
python-versions = "*"
version = "3.13.3"
[[package]]
category = "dev"
description = "plugin and hook calling mechanisms for python"
name = "pluggy"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "0.13.1"
[package.dependencies]
[package.dependencies.importlib-metadata]
python = "<3.8"
version = ">=0.12"
[package.extras]
dev = ["pre-commit", "tox"]
[[package]]
category = "dev"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
name = "py"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "1.9.0"
[[package]]
category = "dev"
description = "Python parsing module"
name = "pyparsing"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
version = "2.4.7"
[[package]]
category = "main"
description = ""
marker = "sys_platform == \"win32\" and python_version >= \"3.6\""
name = "pypiwin32"
optional = false
python-versions = "*"
version = "223"
[package.dependencies]
pywin32 = ">=223"
[[package]]
category = "dev"
description = "pytest: simple powerful testing with Python"
name = "pytest"
optional = false
python-versions = ">=3.5"
version = "5.4.3"
[package.dependencies]
atomicwrites = ">=1.0"
attrs = ">=17.4.0"
colorama = "*"
more-itertools = ">=4.0.0"
packaging = "*"
pluggy = ">=0.12,<1.0"
py = ">=1.5.0"
wcwidth = "*"
[package.dependencies.importlib-metadata]
python = "<3.8"
version = ">=0.12"
[package.extras]
checkqa-mypy = ["mypy (v0.761)"]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
[[package]]
category = "main"
description = "Python for Window Extensions"
marker = "sys_platform == \"win32\" and python_version >= \"3.6\""
name = "pywin32"
optional = false
python-versions = "*"
version = "228"
[[package]]
category = "dev"
description = "Alternative regular expression module, to replace re."
name = "regex"
optional = false
python-versions = "*"
version = "2020.7.14"
[[package]]
category = "main"
description = "Python HTTP for Humans."
name = "requests"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "2.24.0"
[package.dependencies]
certifi = ">=2017.4.17"
chardet = ">=3.0.2,<4"
idna = ">=2.5,<3"
urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26"
[package.extras]
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"]
[[package]]
category = "main"
description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order"
name = "ruamel.yaml"
optional = false
python-versions = "*"
version = "0.16.10"
[package.dependencies]
[package.dependencies."ruamel.yaml.clib"]
python = "<3.9"
version = ">=0.1.2"
[package.extras]
docs = ["ryd"]
jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"]
[[package]]
category = "main"
description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml"
marker = "platform_python_implementation == \"CPython\" and python_version < \"3.9\""
name = "ruamel.yaml.clib"
optional = false
python-versions = "*"
version = "0.2.0"
[[package]]
category = "main"
description = "Python 2 and 3 compatibility utilities"
name = "six"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
version = "1.15.0"
[[package]]
category = "dev"
description = "tasks runner for python projects"
name = "taskipy"
optional = false
python-versions = ">=3.6,<4.0"
version = "1.2.1"
[package.dependencies]
toml = ">=0.10.0,<0.11.0"
[[package]]
category = "dev"
description = "Python Library for Tom's Obvious, Minimal Language"
name = "toml"
optional = false
python-versions = "*"
version = "0.10.1"
[[package]]
category = "dev"
description = "a fork of Python 2 and 3 ast modules with type comment support"
name = "typed-ast"
optional = false
python-versions = "*"
version = "1.4.1"
[[package]]
category = "main"
description = "Python library to interact with the Univention UDM REST API. Implements the simple Python UDM API."
name = "udm-rest-client"
optional = false
python-versions = ">=3.6"
version = "0.4.0"
[package.dependencies]
aiohttp = "*"
async-property = "*"
click = "*"
docker = "*"
requests = "*"
[[package]]
category = "main"
description = "HTTP library with thread-safe connection pooling, file post, and more."
name = "urllib3"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
version = "1.25.9"
[package.extras]
brotli = ["brotlipy (>=0.6.0)"]
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"]
socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
[[package]]
category = "dev"
description = "Measures the displayed width of unicode strings in a terminal"
name = "wcwidth"
optional = false
python-versions = "*"
version = "0.2.5"
[[package]]
category = "main"
description = "WebSocket client for Python. hybi13 is supported."
name = "websocket-client"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "0.57.0"
[package.dependencies]
six = "*"
[[package]]
category = "main"
description = "Yet another URL library"
name = "yarl"
optional = false
python-versions = ">=3.5"
version = "1.4.2"
[package.dependencies]
idna = ">=2.0"
multidict = ">=4.0"
[[package]]
category = "dev"
description = "Backport of pathlib-compatible object wrapper for zip files"
marker = "python_version < \"3.8\""
name = "zipp"
optional = false
python-versions = ">=3.6"
version = "3.1.0"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
testing = ["jaraco.itertools", "func-timeout"]
[metadata]
content-hash = "ccab831ceb4e390d9ffa4d7768046fa520130a2888438f575721116de4db5314"
python-versions = "^3.7"
[metadata.files]
aiohttp = [
{file = "aiohttp-3.6.2-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e"},
{file = "aiohttp-3.6.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec"},
{file = "aiohttp-3.6.2-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48"},
{file = "aiohttp-3.6.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59"},
{file = "aiohttp-3.6.2-cp36-cp36m-win32.whl", hash = "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a"},
{file = "aiohttp-3.6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17"},
{file = "aiohttp-3.6.2-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a"},
{file = "aiohttp-3.6.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd"},
{file = "aiohttp-3.6.2-cp37-cp37m-win32.whl", hash = "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"},
{file = "aiohttp-3.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654"},
{file = "aiohttp-3.6.2-py3-none-any.whl", hash = "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4"},
{file = "aiohttp-3.6.2.tar.gz", hash = "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326"},
]
appdirs = [
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
]
async-property = [
{file = "async_property-0.2.1-py2.py3-none-any.whl", hash = "sha256:f1f105009a6216ed9a13031aa13632754ed8a5c2e329fb8f9f2082d83529eacd"},
{file = "async_property-0.2.1.tar.gz", hash = "sha256:53826fd45a67d7d6cca3d7abbc0e8ba951f7c7618c830021fbd3675979b0b67d"},
]
async-timeout = [
{file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"},
{file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"},
]
atomicwrites = [
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
]
attrs = [
{file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
{file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
]
bitstring = [
{file = "bitstring-3.1.7.tar.gz", hash = "sha256:fdf3eb72b229d2864fb507f8f42b1b2c57af7ce5fec035972f9566de440a864a"},
]
black = [
{file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
{file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
]
certifi = [
{file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"},
{file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"},
]
chardet = [
{file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
{file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
]
click = [
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
]
colorama = [
{file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
{file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
]
coverage = [
{file = "coverage-5.2-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:d9ad0a988ae20face62520785ec3595a5e64f35a21762a57d115dae0b8fb894a"},
{file = "coverage-5.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:4bb385a747e6ae8a65290b3df60d6c8a692a5599dc66c9fa3520e667886f2e10"},
{file = "coverage-5.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:9702e2cb1c6dec01fb8e1a64c015817c0800a6eca287552c47a5ee0ebddccf62"},
{file = "coverage-5.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:42fa45a29f1059eda4d3c7b509589cc0343cd6bbf083d6118216830cd1a51613"},
{file = "coverage-5.2-cp27-cp27m-win32.whl", hash = "sha256:41d88736c42f4a22c494c32cc48a05828236e37c991bd9760f8923415e3169e4"},
{file = "coverage-5.2-cp27-cp27m-win_amd64.whl", hash = "sha256:bbb387811f7a18bdc61a2ea3d102be0c7e239b0db9c83be7bfa50f095db5b92a"},
{file = "coverage-5.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:3740b796015b889e46c260ff18b84683fa2e30f0f75a171fb10d2bf9fb91fc70"},
{file = "coverage-5.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ebf2431b2d457ae5217f3a1179533c456f3272ded16f8ed0b32961a6d90e38ee"},
{file = "coverage-5.2-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:d54d7ea74cc00482a2410d63bf10aa34ebe1c49ac50779652106c867f9986d6b"},
{file = "coverage-5.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:87bdc8135b8ee739840eee19b184804e5d57f518578ffc797f5afa2c3c297913"},
{file = "coverage-5.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c"},
{file = "coverage-5.2-cp35-cp35m-win32.whl", hash = "sha256:509294f3e76d3f26b35083973fbc952e01e1727656d979b11182f273f08aa80b"},
{file = "coverage-5.2-cp35-cp35m-win_amd64.whl", hash = "sha256:ca63dae130a2e788f2b249200f01d7fa240f24da0596501d387a50e57aa7075e"},
{file = "coverage-5.2-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:5c74c5b6045969b07c9fb36b665c9cac84d6c174a809fc1b21bdc06c7836d9a0"},
{file = "coverage-5.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c32aa13cc3fe86b0f744dfe35a7f879ee33ac0a560684fef0f3e1580352b818f"},
{file = "coverage-5.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1e58fca3d9ec1a423f1b7f2aa34af4f733cbfa9020c8fe39ca451b6071237405"},
{file = "coverage-5.2-cp36-cp36m-win32.whl", hash = "sha256:3b2c34690f613525672697910894b60d15800ac7e779fbd0fccf532486c1ba40"},
{file = "coverage-5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a4d511012beb967a39580ba7d2549edf1e6865a33e5fe51e4dce550522b3ac0e"},
{file = "coverage-5.2-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:32ecee61a43be509b91a526819717d5e5650e009a8d5eda8631a59c721d5f3b6"},
{file = "coverage-5.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6f91b4492c5cde83bfe462f5b2b997cdf96a138f7c58b1140f05de5751623cf1"},
{file = "coverage-5.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bfcc811883699ed49afc58b1ed9f80428a18eb9166422bce3c31a53dba00fd1d"},
{file = "coverage-5.2-cp37-cp37m-win32.whl", hash = "sha256:60a3d36297b65c7f78329b80120f72947140f45b5c7a017ea730f9112b40f2ec"},
{file = "coverage-5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:12eaccd86d9a373aea59869bc9cfa0ab6ba8b1477752110cb4c10d165474f703"},
{file = "coverage-5.2-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:d82db1b9a92cb5c67661ca6616bdca6ff931deceebb98eecbd328812dab52032"},
{file = "coverage-5.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:214eb2110217f2636a9329bc766507ab71a3a06a8ea30cdeebb47c24dce5972d"},
{file = "coverage-5.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8a3decd12e7934d0254939e2bf434bf04a5890c5bf91a982685021786a08087e"},
{file = "coverage-5.2-cp38-cp38-win32.whl", hash = "sha256:1dcebae667b73fd4aa69237e6afb39abc2f27520f2358590c1b13dd90e32abe7"},
{file = "coverage-5.2-cp38-cp38-win_amd64.whl", hash = "sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b"},
{file = "coverage-5.2-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:7403675df5e27745571aba1c957c7da2dacb537c21e14007ec3a417bf31f7f3d"},
{file = "coverage-5.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:0fc4e0d91350d6f43ef6a61f64a48e917637e1dcfcba4b4b7d543c628ef82c2d"},
{file = "coverage-5.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:25fe74b5b2f1b4abb11e103bb7984daca8f8292683957d0738cd692f6a7cc64c"},
{file = "coverage-5.2-cp39-cp39-win32.whl", hash = "sha256:d67599521dff98ec8c34cd9652cbcfe16ed076a2209625fca9dc7419b6370e5c"},
{file = "coverage-5.2-cp39-cp39-win_amd64.whl", hash = "sha256:10f2a618a6e75adf64329f828a6a5b40244c1c50f5ef4ce4109e904e69c71bd2"},
{file = "coverage-5.2.tar.gz", hash = "sha256:1874bdc943654ba46d28f179c1846f5710eda3aeb265ff029e0ac2b52daae404"},
]
docker = [
{file = "docker-4.2.2-py2.py3-none-any.whl", hash = "sha256:03a46400c4080cb6f7aa997f881ddd84fef855499ece219d75fbdb53289c17ab"},
{file = "docker-4.2.2.tar.gz", hash = "sha256:26eebadce7e298f55b76a88c4f8802476c5eaddbdbe38dbc6cce8781c47c9b54"},
]
idna = [
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
]
importlib-metadata = [
{file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"},
{file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"},
]
isort = [
{file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
{file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
]
lxml = [
{file = "lxml-4.5.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20"},
{file = "lxml-4.5.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef"},
{file = "lxml-4.5.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5"},
{file = "lxml-4.5.2-cp27-cp27m-win32.whl", hash = "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a"},
{file = "lxml-4.5.2-cp27-cp27m-win_amd64.whl", hash = "sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f"},
{file = "lxml-4.5.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6"},
{file = "lxml-4.5.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443"},
{file = "lxml-4.5.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0"},
{file = "lxml-4.5.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1"},
{file = "lxml-4.5.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:c9d317efde4bafbc1561509bfa8a23c5cab66c44d49ab5b63ff690f5159b2304"},
{file = "lxml-4.5.2-cp35-cp35m-win32.whl", hash = "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88"},
{file = "lxml-4.5.2-cp35-cp35m-win_amd64.whl", hash = "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730"},
{file = "lxml-4.5.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1"},
{file = "lxml-4.5.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe"},
{file = "lxml-4.5.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258"},
{file = "lxml-4.5.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:1fa21263c3aba2b76fd7c45713d4428dbcc7644d73dcf0650e9d344e433741b3"},
{file = "lxml-4.5.2-cp36-cp36m-win32.whl", hash = "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae"},
{file = "lxml-4.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481"},
{file = "lxml-4.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba"},
{file = "lxml-4.5.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd"},
{file = "lxml-4.5.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed"},
{file = "lxml-4.5.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cfd7c5dd3c35c19cec59c63df9571c67c6d6e5c92e0fe63517920e97f61106d1"},
{file = "lxml-4.5.2-cp37-cp37m-win32.whl", hash = "sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e"},
{file = "lxml-4.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a"},
{file = "lxml-4.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d"},
{file = "lxml-4.5.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7"},
{file = "lxml-4.5.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843"},
{file = "lxml-4.5.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8f0ec6b9b3832e0bd1d57af41f9238ea7709bbd7271f639024f2fc9d3bb01293"},
{file = "lxml-4.5.2-cp38-cp38-win32.whl", hash = "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f"},
{file = "lxml-4.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee"},
{file = "lxml-4.5.2.tar.gz", hash = "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6"},
]
more-itertools = [
{file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"},
{file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"},
]
multidict = [
{file = "multidict-4.7.6-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000"},
{file = "multidict-4.7.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a"},
{file = "multidict-4.7.6-cp35-cp35m-win32.whl", hash = "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5"},
{file = "multidict-4.7.6-cp35-cp35m-win_amd64.whl", hash = "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3"},
{file = "multidict-4.7.6-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87"},
{file = "multidict-4.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2"},
{file = "multidict-4.7.6-cp36-cp36m-win32.whl", hash = "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7"},
{file = "multidict-4.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463"},
{file = "multidict-4.7.6-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d"},
{file = "multidict-4.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255"},
{file = "multidict-4.7.6-cp37-cp37m-win32.whl", hash = "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507"},
{file = "multidict-4.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c"},
{file = "multidict-4.7.6-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b"},
{file = "multidict-4.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7"},
{file = "multidict-4.7.6-cp38-cp38-win32.whl", hash = "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d"},
{file = "multidict-4.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19"},
{file = "multidict-4.7.6.tar.gz", hash = "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430"},
]
mysqlclient = [
{file = "mysqlclient-1.4.6-cp36-cp36m-win_amd64.whl", hash = "sha256:4c82187dd6ab3607150fbb1fa5ef4643118f3da122b8ba31c3149ddd9cf0cb39"},
{file = "mysqlclient-1.4.6-cp37-cp37m-win_amd64.whl", hash = "sha256:9e6080a7aee4cc6a06b58b59239f20f1d259c1d2fddf68ddeed242d2311c7087"},
{file = "mysqlclient-1.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:f646f8d17d02be0872291f258cce3813497bc7888cd4712a577fd1e719b2f213"},
{file = "mysqlclient-1.4.6.tar.gz", hash = "sha256:f3fdaa9a38752a3b214a6fe79d7cae3653731a53e577821f9187e67cbecb2e16"},
]
packaging = [
{file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"},
{file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"},
]
pathspec = [
{file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"},
{file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"},
]
peewee = [
{file = "peewee-3.13.3.tar.gz", hash = "sha256:1269a9736865512bd4056298003aab190957afe07d2616cf22eaf56cb6398369"},
]
pluggy = [
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
]
py = [
{file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"},
{file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"},
]
pyparsing = [
{file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
]
pypiwin32 = [
{file = "pypiwin32-223-py3-none-any.whl", hash = "sha256:67adf399debc1d5d14dffc1ab5acacb800da569754fafdc576b2a039485aa775"},
{file = "pypiwin32-223.tar.gz", hash = "sha256:71be40c1fbd28594214ecaecb58e7aa8b708eabfa0125c8a109ebd51edbd776a"},
]
pytest = [
{file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"},
{file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"},
]
pywin32 = [
{file = "pywin32-228-cp27-cp27m-win32.whl", hash = "sha256:37dc9935f6a383cc744315ae0c2882ba1768d9b06700a70f35dc1ce73cd4ba9c"},
{file = "pywin32-228-cp27-cp27m-win_amd64.whl", hash = "sha256:11cb6610efc2f078c9e6d8f5d0f957620c333f4b23466931a247fb945ed35e89"},
{file = "pywin32-228-cp35-cp35m-win32.whl", hash = "sha256:1f45db18af5d36195447b2cffacd182fe2d296849ba0aecdab24d3852fbf3f80"},
{file = "pywin32-228-cp35-cp35m-win_amd64.whl", hash = "sha256:6e38c44097a834a4707c1b63efa9c2435f5a42afabff634a17f563bc478dfcc8"},
{file = "pywin32-228-cp36-cp36m-win32.whl", hash = "sha256:ec16d44b49b5f34e99eb97cf270806fdc560dff6f84d281eb2fcb89a014a56a9"},
{file = "pywin32-228-cp36-cp36m-win_amd64.whl", hash = "sha256:a60d795c6590a5b6baeacd16c583d91cce8038f959bd80c53bd9a68f40130f2d"},
{file = "pywin32-228-cp37-cp37m-win32.whl", hash = "sha256:af40887b6fc200eafe4d7742c48417529a8702dcc1a60bf89eee152d1d11209f"},
{file = "pywin32-228-cp37-cp37m-win_amd64.whl", hash = "sha256:00eaf43dbd05ba6a9b0080c77e161e0b7a601f9a3f660727a952e40140537de7"},
{file = "pywin32-228-cp38-cp38-win32.whl", hash = "sha256:fa6ba028909cfc64ce9e24bcf22f588b14871980d9787f1e2002c99af8f1850c"},
{file = "pywin32-228-cp38-cp38-win_amd64.whl", hash = "sha256:9b3466083f8271e1a5eb0329f4e0d61925d46b40b195a33413e0905dccb285e8"},
{file = "pywin32-228-cp39-cp39-win32.whl", hash = "sha256:ed74b72d8059a6606f64842e7917aeee99159ebd6b8d6261c518d002837be298"},
{file = "pywin32-228-cp39-cp39-win_amd64.whl", hash = "sha256:8319bafdcd90b7202c50d6014efdfe4fde9311b3ff15fd6f893a45c0868de203"},
]
regex = [
{file = "regex-2020.7.14-cp27-cp27m-win32.whl", hash = "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"},
{file = "regex-2020.7.14-cp27-cp27m-win_amd64.whl", hash = "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88"},
{file = "regex-2020.7.14-cp36-cp36m-win32.whl", hash = "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4"},
{file = "regex-2020.7.14-cp36-cp36m-win_amd64.whl", hash = "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89"},
{file = "regex-2020.7.14-cp37-cp37m-win32.whl", hash = "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6"},
{file = "regex-2020.7.14-cp37-cp37m-win_amd64.whl", hash = "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204"},
{file = "regex-2020.7.14-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99"},
{file = "regex-2020.7.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e"},
{file = "regex-2020.7.14-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e"},
{file = "regex-2020.7.14-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a"},
{file = "regex-2020.7.14-cp38-cp38-win32.whl", hash = "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341"},
{file = "regex-2020.7.14-cp38-cp38-win_amd64.whl", hash = "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840"},
{file = "regex-2020.7.14.tar.gz", hash = "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb"},
]
requests = [
{file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"},
{file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"},
]
"ruamel.yaml" = [
{file = "ruamel.yaml-0.16.10-py2.py3-none-any.whl", hash = "sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b"},
{file = "ruamel.yaml-0.16.10.tar.gz", hash = "sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954"},
]
"ruamel.yaml.clib" = [
{file = "ruamel.yaml.clib-0.2.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9c6d040d0396c28d3eaaa6cb20152cb3b2f15adf35a0304f4f40a3cf9f1d2448"},
{file = "ruamel.yaml.clib-0.2.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d55386129291b96483edcb93b381470f7cd69f97585829b048a3d758d31210a"},
{file = "ruamel.yaml.clib-0.2.0-cp27-cp27m-win32.whl", hash = "sha256:8073c8b92b06b572e4057b583c3d01674ceaf32167801fe545a087d7a1e8bf52"},
{file = "ruamel.yaml.clib-0.2.0-cp27-cp27m-win_amd64.whl", hash = "sha256:615b0396a7fad02d1f9a0dcf9f01202bf9caefee6265198f252c865f4227fcc6"},
{file = "ruamel.yaml.clib-0.2.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:a0ff786d2a7dbe55f9544b3f6ebbcc495d7e730df92a08434604f6f470b899c5"},
{file = "ruamel.yaml.clib-0.2.0-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:ea4362548ee0cbc266949d8a441238d9ad3600ca9910c3fe4e82ee3a50706973"},
{file = "ruamel.yaml.clib-0.2.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:77556a7aa190be9a2bd83b7ee075d3df5f3c5016d395613671487e79b082d784"},
{file = "ruamel.yaml.clib-0.2.0-cp35-cp35m-win32.whl", hash = "sha256:392b7c371312abf27fb549ec2d5e0092f7ef6e6c9f767bfb13e83cb903aca0fd"},
{file = "ruamel.yaml.clib-0.2.0-cp35-cp35m-win_amd64.whl", hash = "sha256:ed5b3698a2bb241b7f5cbbe277eaa7fe48b07a58784fba4f75224fd066d253ad"},
{file = "ruamel.yaml.clib-0.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7aee724e1ff424757b5bd8f6c5bbdb033a570b2b4683b17ace4dbe61a99a657b"},
{file = "ruamel.yaml.clib-0.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d0d3ac228c9bbab08134b4004d748cf9f8743504875b3603b3afbb97e3472947"},
{file = "ruamel.yaml.clib-0.2.0-cp36-cp36m-win32.whl", hash = "sha256:f9dcc1ae73f36e8059589b601e8e4776b9976effd76c21ad6a855a74318efd6e"},
{file = "ruamel.yaml.clib-0.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:1e77424825caba5553bbade750cec2277ef130647d685c2b38f68bc03453bac6"},
{file = "ruamel.yaml.clib-0.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d10e9dd744cf85c219bf747c75194b624cc7a94f0c80ead624b06bfa9f61d3bc"},
{file = "ruamel.yaml.clib-0.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:550168c02d8de52ee58c3d8a8193d5a8a9491a5e7b2462d27ac5bf63717574c9"},
{file = "ruamel.yaml.clib-0.2.0-cp37-cp37m-win32.whl", hash = "sha256:57933a6986a3036257ad7bf283529e7c19c2810ff24c86f4a0cfeb49d2099919"},
{file = "ruamel.yaml.clib-0.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:b1b7fcee6aedcdc7e62c3a73f238b3d080c7ba6650cd808bce8d7761ec484070"},
{file = "ruamel.yaml.clib-0.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:be018933c2f4ee7de55e7bd7d0d801b3dfb09d21dad0cce8a97995fd3e44be30"},
{file = "ruamel.yaml.clib-0.2.0.tar.gz", hash = "sha256:b66832ea8077d9b3f6e311c4a53d06273db5dc2db6e8a908550f3c14d67e718c"},
]
six = [
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
]
taskipy = [
{file = "taskipy-1.2.1-py3-none-any.whl", hash = "sha256:99bdaf5b19791c2345806847147e0fc2d28e1ac9446058def5a8b6b3fc9f23e2"},
{file = "taskipy-1.2.1.tar.gz", hash = "sha256:5eb2c3b1606c896c7fa799848e71e8883b880759224958d07ba760e5db263175"},
]
toml = [
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
{file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
]
typed-ast = [
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
{file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
{file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
{file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
{file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
{file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
{file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
{file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
{file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
{file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
{file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
{file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
{file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
{file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
]
udm-rest-client = [
{file = "udm-rest-client-0.4.0.tar.gz", hash = "sha256:3402a2320b6f9da8a2e5c307f90f90e40e83df9942e39a0e642c1367bd014b3e"},
{file = "udm_rest_client-0.4.0-py2.py3-none-any.whl", hash = "sha256:048f15050189d96070965a527fd185a9b72230b3c5f714fb1127c95c614dd7bf"},
]
urllib3 = [
{file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"},
{file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"},
]
wcwidth = [
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
]
websocket-client = [
{file = "websocket_client-0.57.0-py2.py3-none-any.whl", hash = "sha256:0fc45c961324d79c781bab301359d5a1b00b13ad1b10415a4780229ef71a5549"},
{file = "websocket_client-0.57.0.tar.gz", hash = "sha256:d735b91d6d1692a6a181f2a8c9e0238e5f6373356f561bb9dc4c7af36f452010"},
]
yarl = [
{file = "yarl-1.4.2-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b"},
{file = "yarl-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1"},
{file = "yarl-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080"},
{file = "yarl-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a"},
{file = "yarl-1.4.2-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f"},
{file = "yarl-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea"},
{file = "yarl-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb"},
{file = "yarl-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70"},
{file = "yarl-1.4.2-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d"},
{file = "yarl-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce"},
{file = "yarl-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"},
{file = "yarl-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce"},
{file = "yarl-1.4.2-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b"},
{file = "yarl-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae"},
{file = "yarl-1.4.2-cp38-cp38-win32.whl", hash = "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462"},
{file = "yarl-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6"},
{file = "yarl-1.4.2.tar.gz", hash = "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b"},
]
zipp = [
{file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"},
{file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"},
]

View File

@ -1,4 +1,48 @@
[project]
name = "memberPlumbing"
version = "0.1.0"
description = ""
authors = [
{name = "Adam Goldsmith", email = "adam@adamgoldsmith.name"},
]
requires-python = ">=3.11,<4.0"
dependencies = [
"requests~=2.31",
"ruamel-yaml~=0.17",
"bitstring~=4.1",
"lxml~=4.9",
"peewee~=3.16",
"mysqlclient~=2.1",
"udm-rest-client~=1.2",
"pyclip~=0.7",
"recurrent~=0.4",
]
[tool.pdm]
[tool.pdm.dev-dependencies]
dev = [
"black~=23.3",
"isort~=5.11",
]
[tool.pdm.build]
includes = [
"memberPlumbing",
]
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[project.scripts]
doorUpdater = "memberPlumbing.doorUpdater:main"
hidEvents = "memberPlumbing.hidEvents:main"
sqlExport = "memberPlumbing.sqlExport:main"
ucsAccounts = "memberPlumbing.ucsAccounts:main"
upcomingEvents = "memberPlumbing.upcomingEvents:main"
[tool.black]
line-length = 88
[tool.isort]
multi_line_output = 3
@ -6,51 +50,3 @@ include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
line_length = 88
[tool.poetry]
name = "memberPlumbing"
packages = [{ include = "memberPlumbing" }]
version = "0.1.0"
description = ""
authors = ["Adam Goldsmith <adam@adamgoldsmith.name>"]
[tool.poetry.dependencies]
python = "^3.7"
requests = "^2.23.0"
"ruamel.yaml" = "^0.16.10"
bitstring = "^3.1.6"
lxml = "^4.5.0"
peewee = "^3.13.2"
mysqlclient = "^1.4.6"
udm-rest-client = "^0.4.0"
[tool.poetry.dev-dependencies]
black = "^19.10b0"
isort = "^4.3.21"
taskipy = "^1.2.1"
pytest = "^5.4.2"
coverage = {extras = ["toml"], version = "^5.1"}
[tool.taskipy.tasks]
test = "pytest"
mypy = "mypy --strict memberPlumbing/hid/ stubs"
cov = "coverage run && coverage html"
[tool.coverage.run]
branch = true
source = ["memberPlumbing"]
omit = ["*/tests/*"]
command_line = "-m pytest"
[tool.coverage.report]
skip_empty = true
[tool.poetry.scripts]
doorUpdater = 'memberPlumbing.doorUpdater:main'
hidEvents = 'memberPlumbing.hidEvents:main'
sqlExport = 'memberPlumbing.sqlExport:main'
ucsAccounts = 'memberPlumbing.ucsAccounts:main'
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

6
renovate.json Normal file
View File

@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
]
}

View File

@ -0,0 +1,10 @@
[Unit]
Description=Update UCS Accounts
OnFailure=status-email-admin@%n.service
[Service]
User=adam
Type=oneshot
TimeoutStartSec=600
WorkingDirectory=/home/adam/memberPlumbing/
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run ucsAccounts

View File

@ -0,0 +1,9 @@
[Unit]
Description=Hourly UCS Accounts update
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target