Compare commits

..

25 Commits

Author SHA1 Message Date
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
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
23 changed files with 1106 additions and 1898 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

@ -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()
@ -75,7 +82,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,76 @@
#!/usr/bin/env python3
from datetime import datetime
from .config import Config
from .MembershipWorks import MembershipWorks
def format_event(membershipworks: MembershipWorks, event):
event_details = membershipworks.get_event_by_eid(event["eid"])
url = (
"https://claremontmakerspace.org/events/#!event/register/"
+ event_details["url"]
)
if "lgo" in event_details:
img = f"""<img class="aligncenter" width="500" height="500" src="{event_details['lgo']['l']}">"""
else:
img = ""
# print(json.dumps(event_details))
return f"""<h2 style="text-align: center;">
<a href="{url}">
{img}
{event_details['ttl']}
</a>
</h2>
<div>{event_details['szp']} &mdash; {event_details['ezp']}</div>
<div>
{event_details['dtl']}
</div>
<a href="{url}">Register for this class now!</a>"""
def main():
config = Config()
membershipworks = config.membershipworks
events = membershipworks.get_events_list(datetime.now())
if "error" in events:
print("Error:", events["error"])
return
events_list = "\n<hr />\n\n".join(
format_event(membershipworks, event)
for event in events["evt"]
if event["ttl"] != "[TEMPLATE FOR COPYING]"
)
header = """<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>Please note: </strong>The Claremont MakerSpace currently requires masks for all visitors and members.
<strong>Instructors:</strong> Interested in teaching a class at CMS? Please fill out our <a href="https://docs.google.com/forms/d/e/1FAIpQLSdJyEVRJxzIczG784VkOm_DsNyv-VXRXYzlis8qlMdEOvHGpQ/viewform" target="_blank" rel="noreferrer noopener external" data-wpel-link="external">Class Proposal Form</a><a href="https://docs.google.com/forms/d/e/1FAIpQLSdJyEVRJxzIczG784VkOm_DsNyv-VXRXYzlis8qlMdEOvHGpQ/viewform" target="_blank" rel="noreferrer noopener external" data-wpel-link="external">.</a>
<strong>Tours:</strong> Want to see what the Claremont MakerSpace is all about? Tours are by appointment only due to COVID-19 restrictions. <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 />
"""
footer = """
<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>
"""
print(header, events_list, footer)
if __name__ == "__main__":
main()

1437
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -17,39 +17,23 @@ authors = ["Adam Goldsmith <adam@adamgoldsmith.name>"]
[tool.poetry.dependencies]
python = "^3.7"
requests = "^2.23.0"
"ruamel.yaml" = "^0.16.10"
"ruamel.yaml" = "^0.17.20"
bitstring = "^3.1.6"
lxml = "^4.5.0"
peewee = "^3.13.2"
mysqlclient = "^1.4.6"
udm-rest-client = "^0.4.0"
mysqlclient = "^2.1.0"
udm-rest-client = "^1.0.6"
[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
black = "^22.3.0"
isort = "^5.10.1"
[tool.poetry.scripts]
doorUpdater = 'memberPlumbing.doorUpdater:main'
hidEvents = 'memberPlumbing.hidEvents:main'
sqlExport = 'memberPlumbing.sqlExport:main'
ucsAccounts = 'memberPlumbing.ucsAccounts:main'
upcomingEvents = 'memberPlumbing.upcomingEvents:main'
[build-system]
requires = ["poetry>=0.12"]

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