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
19 changed files with 1165 additions and 433 deletions

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.

View File

@ -1,5 +0,0 @@
#!/usr/bin/env python3
from memberPlumbing import doorUpdater
doorUpdater.main()

View File

@ -1,5 +0,0 @@
#!/usr/bin/env python3
from memberPlumbing import hidEvents
hidEvents.main()

View File

@ -1,5 +0,0 @@
#!/usr/bin/env python3
from memberPlumbing import sqlExport
sqlExport.main()

View File

@ -1,5 +0,0 @@
#!/usr/bin/env python3
from memberPlumbing import ucsAccounts
ucsAccounts.main()

View File

@ -34,6 +34,12 @@ DOOR_PASSWORD: ""
MEMBERSHIPWORKS_USERNAME: ""
MEMBERSHIPWORKS_PASSWORD: ""
# arguments for https://udm-rest-client.readthedocs.io/en/latest/udm_rest_client.html#udm_rest_client.udm.UDM
UCS:
url: ""
username: ""
password: ""
MEMBERSHIPWORKS_DB:
database: ""
user: ""

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,46 +151,44 @@ 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
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"""
"""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"""
ids = self.get_member_ids(folders)
# 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,20 +198,24 @@ 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):
"""Get the transactions between start_date and end_date
Dates can be datetime.date or datetime.datetime
Dates can be datetime.date or datetime.datetime
json gets a different version of the transactions list,
which contains a different set information
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

@ -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,13 +100,7 @@ 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"]
schedules = self.schedules + doorLevels
dm = DoorMember(
door,
@ -133,7 +121,6 @@ class MembershipworksMember(Member):
return (
super().__str__()
+ f"""OnHold? {self.onHold}
Limited Operations Access? {self.limitedOperations}
Former Member? {self.formerMember}
"""
)
@ -342,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

@ -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,13 +47,14 @@ 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]:
ml.magic_save()
else:
ml.delete_instance()
for flag, id in flags.items():
ml = MemberFlag(uid=member["Account ID"], flag_id=id)
if (type == "folders" and member["Account ID"] in folders[id]) or (
type != "folders" and member[flag]
):
ml.magic_save()
else:
ml.delete_instance()
print("Getting/Updating transactions...")
# Deduping these is hard, so just recreate the data every time
@ -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,106 +1,130 @@
#!/usr/bin/env python3
import asyncio
import random
import re
import string
import subprocess
from udm_rest_client.udm import UDM
from udm_rest_client.exceptions import NoObject, UdmError
from .config import Config
LDAP_BASE = "cn=users,dc=sawtooth,dc=claremontmakerspace,dc=org"
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
def makeGroups(members):
# From an API error message:
# A group name must start and end with a letter, number or underscore.
# In between additionally spaces, dashes and dots are allowed.
def sanitize_group_name(name):
sanitized_body = re.sub(r"[^0-9A-Za-z_ -.]", ".", name)
sanitized_start_end = re.sub("^[^0-9A-Za-z_]|[^0-9A-Za-z_]$", "_", sanitized_body)
return "MW_" + sanitized_start_end
# 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()).strip(".")
async def make_groups(group_mod, members):
existing_group_names = [g.props.name async for g in group_mod.search()]
groups = [
key.replace(":", ".").replace("?", "")
for key in members[0].keys()
if re.match(GROUPS_REGEX, key) is not None
sanitize_group_name(group_name)
for group_name in members[0].keys()
if re.match(GROUPS_REGEX, group_name) is not None
]
for group in groups:
subprocess.call(
[
"udm",
"groups/group",
"create",
"--position",
GROUP_BASE,
"--set",
"name=" + group,
]
)
for group_name in groups:
if group_name not in existing_group_names:
group = await group_mod.new()
group.props.name = group_name
await group.save()
def makeSets(props):
return sum([["--set", key + "=" + value] for key, value in props.items()], [])
def makeAppendGroups(member):
groups = [
key.replace(":", ".").replace("?", "")
for key, value in member.items()
if re.match(GROUPS_REGEX, key) is not None and value != ""
]
return sum(
[["--append", "groups=cn=" + group + "," + GROUP_BASE] for group in groups], []
)
def main():
async def _main():
config = Config()
members = config.membershipworks.get_members(
["Members", "CMS Staff"], "lvl,phn,eml,lbl,nam,end,_id"
)
makeGroups(members)
for member in members:
randomPass = "".join(
random.choice(string.ascii_letters + string.digits)
for x in range(0, RAND_PW_LEN)
)
username = member["Account Name"].lower().replace(" ", ".")
async with UDM(**config.UCS) as udm:
user_mod = udm.get("users/user")
group_mod = udm.get("groups/group")
props = {
"title": "", # Title
"firstname": member["First Name"],
"lastname": member["Last Name"], # (c)
"username": username, # (cmr)
"description": "", # Description
"password": randomPass, # (c) Password
# "mailPrimaryAddress": member["Email"], # Primary e-mail address
# "displayName": "", # Display name
# "birthday": "", # Birthdate
# "jpegPhoto": "", # Picture of the user (JPEG format)
"employeeNumber": member["Account ID"],
# "employeeType": "", # Employee type
"homedrive": "H:", # Windows home drive
"sambahome": "\\\\ucs\\" + username, # Windows home path
"profilepath": "%LOGONSERVER%\\%USERNAME%\\windows-profiles\\default", # Windows profile directory
"disabled": "1" if member["Account on Hold"] != "" else "0",
# "userexpiry": member["Renewal Date"],
"pwdChangeNextLogin": "1", # User has to change password on next login
# "sambaLogonHours": "", # Permitted times for Windows logins
"e-mail": member["Email"], # ([]) E-mail address
"phone": member["Phone"], # Telephone number
# "PasswordRecoveryMobile": member["Phone"], # Mobile phone number
"PasswordRecoveryEmail": member["Email"],
}
await make_groups(group_mod, members)
subprocess.call(
["udm", "users/user", "create", "--position", LDAP_BASE] + makeSets(props)
)
for member in members:
username = sanitize_user_name(member["Account Name"])
# remove props we don't want to reset
props.pop("password")
props.pop("pwdChangeNextLogin")
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 employeeNumber and rename users when needed
user = await user_mod.new()
subprocess.call(
["udm", "users/user", "modify", "--dn", "uid=" + username + "," + LDAP_BASE]
+ makeSets(props)
+ makeAppendGroups(member)
)
# set a random password and ensure it is changed at next login
user.props.password = "".join(
random.choice(string.ascii_letters + string.digits)
for x in range(0, RAND_PW_LEN)
)
user.props.pwdChangeNextLogin = True
user.props.update(
{
"title": "", # Title
"firstname": member["First Name"],
"lastname": member["Last Name"], # (c)
"username": username, # (cmr)
"description": "", # Description
# "password": "", # (c) Password
# "mailPrimaryAddress": member["Email"], # Primary e-mail address
# "displayName": "", # Display name
# "birthday": "", # Birthdate
# "jpegPhoto": "", # Picture of the user (JPEG format)
"employeeNumber": member["Account ID"],
# "employeeType": "", # Employee type
"homedrive": "H:", # Windows home drive
"sambahome": "\\\\ucs\\" + username, # Windows home path
"profilepath": "%LOGONSERVER%\\%USERNAME%\\windows-profiles\\default", # Windows profile directory
"disabled": member["Account on Hold"] != "",
# "userexpiry": member["Renewal Date"],
# "pwdChangeNextLogin": "1", # User has to change password on next login
# "sambaLogonHours": "", # Permitted times for Windows logins
"e-mail": [member["Email"]], # ([]) E-mail address
"phone": [member["Phone"]], # Telephone number
# "PasswordRecoveryMobile": member["Phone"], # Mobile phone number
"PasswordRecoveryEmail": member["Email"],
}
)
new_groups = [
"cn=" + sanitize_group_name(group) + "," + GROUP_BASE
for group, value in member.items()
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 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():
asyncio.run(_main())
if __name__ == "__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()

1110
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -17,15 +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"
mysqlclient = "^2.1.0"
udm-rest-client = "^1.0.6"
[tool.poetry.dev-dependencies]
black = "^19.10b0"
isort = "^4.3.21"
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

@ -7,4 +7,4 @@ User=adam
Type=oneshot
TimeoutStartSec=600
WorkingDirectory=/home/adam/memberPlumbing/
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run /home/adam/memberPlumbing/bin/doorUpdater
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run doorUpdater

View File

@ -9,4 +9,4 @@ User=adam
Type=oneshot
TimeoutStartSec=600
WorkingDirectory=/home/adam/memberPlumbing/
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run /home/adam/memberPlumbing/bin/hidEvents
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run hidEvents

View File

@ -9,4 +9,4 @@ User=adam
Type=oneshot
TimeoutStartSec=600
WorkingDirectory=/home/adam/memberPlumbing/
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run /home/adam/memberPlumbing/bin/sqlExport
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run sqlExport

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