Skip to content

Commit 82c815a

Browse files
Merge pull request #148 from cityofaustin/charlie/signal-pm
Signal PM Copier
2 parents b4a39b8 + bb37067 commit 82c815a

3 files changed

Lines changed: 221 additions & 0 deletions

File tree

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,39 @@ SECONDARY_SIGNALS = {
612612

613613
The package contains utilities for fetching and pushing data between Knack applications and PostgREST.
614614

615+
### Knack maintenance: Signal Preventative Maintenance Copier
616+
617+
Copies Preventative Maintenance (PM) records from primary signals to secondary signals.
618+
619+
#### Configuration
620+
621+
`app-name`,`view`, `scene`, and `object` of the preventative maintenance object is required. Along with the container of
622+
traffic signals and the `secondary_signals_field` that ties primary signals to secondary signals.
623+
624+
```python
625+
CONFIG = {
626+
"view_4284": {
627+
"description": "Preventative maintenance work orders",
628+
"scene": "scene_416",
629+
"object": "object_222",
630+
"signals_container": "view_197",
631+
"secondary_signals_field": "field_1329", # Knack field name of SECONDARY_SIGNALS in the signals object
632+
"signal_object_id": "object_12",
633+
}
634+
}
635+
```
636+
637+
638+
#### CLI arguments
639+
640+
- `--app-name, -a` (`str`, required): the name of the source Knack application
641+
- `--container, -c` (`str`, required): the view key of the PM container
642+
643+
## Utils (`/services/utils`)
644+
645+
The package contains utilities for fetching and pushing data between Knack applications and PostgREST.
646+
647+
615648
## Common Tasks
616649

617650
### Configuring a Knack container

services/config/knack.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,14 @@
239239
"scene": "scene_1545",
240240
"modified_date_field": "field_4535",
241241
"socrata_resource_id": "mudg-bik3",
242+
},
243+
"view_4284": {
244+
"description": "Preventative maintenance work orders that need to be copied",
245+
"scene": "scene_416",
246+
"object": "object_222",
247+
"signals_container": "view_197",
248+
"secondary_signals_field": "field_1329", # Knack field name of SECONDARY_SIGNALS in the signals object
249+
"signal_object_id": "object_12",
242250
}
243251
},
244252
"signs-markings": {

services/signal_pm_copier.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
#!/usr/bin/env python
2+
"""
3+
Copy traffic signal preventative maintenance (PM) records for the primary signal to secondary signal(s)
4+
"""
5+
import argparse
6+
from datetime import date
7+
import os
8+
9+
import knackpy
10+
11+
from config.knack import CONFIG
12+
import utils
13+
14+
APP_ID = os.getenv("KNACK_APP_ID")
15+
API_KEY = os.getenv("KNACK_API_KEY")
16+
17+
18+
def generate_field_name_lookup(record):
19+
lookup = {}
20+
for field in record.field_defs:
21+
lookup[field.key] = field.name
22+
return lookup
23+
24+
25+
def find_knack_field_name(field_name, field_map):
26+
"""
27+
Returns the knack field name for a given column name
28+
"""
29+
for field in field_map:
30+
if field_map[field] == field_name:
31+
return field
32+
raise Exception("Field name not found in field map.")
33+
34+
35+
def record_to_knack(record, object_id, method):
36+
if method == "update":
37+
assert "id" in record
38+
res = knackpy.api.record(
39+
app_id=APP_ID,
40+
api_key=API_KEY,
41+
obj=object_id,
42+
method=method,
43+
data=record,
44+
)
45+
return res
46+
47+
48+
def main(args):
49+
# Parse Arguments
50+
app_name = args.app_name
51+
container = args.container
52+
logger.info(args)
53+
54+
# Selecting correct config for the view
55+
config = CONFIG[app_name][container]
56+
app = knackpy.App(app_id=APP_ID, api_key=API_KEY)
57+
58+
# 1. Check for work orders to be copied.
59+
# Note that this view is already pre-filtered in Knack to PM records that need to be copied.
60+
pm_records = app.get(container)
61+
if not pm_records:
62+
logger.info("No PM records need to be copied, did nothing.")
63+
return 0
64+
pm_field_names = generate_field_name_lookup(pm_records[0])
65+
logger.info(f"{len(pm_records)} PM records to be copied.")
66+
67+
# 2. Download primary signal -> secondary signal relationships
68+
secondary_key = config["secondary_signals_field"]
69+
70+
# Filtering out traffic signals without secondary signals
71+
filters = {
72+
"match": "and",
73+
"rules": [
74+
{
75+
"field": secondary_key,
76+
"operator": "is not blank",
77+
"field_name": "SECONDARY_SIGNALS",
78+
}
79+
],
80+
}
81+
records = app.get(config["signals_container"], filters=filters)
82+
signal_field_names = generate_field_name_lookup(records[0])
83+
84+
# Creating a lookup dict of primary -> secondary signals
85+
signal_lookup = {}
86+
for signal_rec in records:
87+
if signal_rec.data[secondary_key]:
88+
signal_lookup[signal_rec.data["id"]] = signal_rec.data[
89+
f"{secondary_key}_raw"
90+
]
91+
92+
# 3. Do for each PM record:
93+
# - Set the PM record's COPIED_TO_SECONDARY to True
94+
# - For each secondary signal, copy the PM record and attach the appropriate signal
95+
# - Update the modified date of the secondary traffic signal, so the data is refreshed on the ODP.
96+
current_date = date.today().strftime("%Y-%m-%d")
97+
98+
# Grabbing some useful knack field names
99+
copied_field_name = find_knack_field_name("COPIED_TO_SECONDARY", pm_field_names)
100+
signal_field_name = find_knack_field_name("signal", pm_field_names)
101+
modified_date_field_name = find_knack_field_name(
102+
"MODIFIED_DATE", signal_field_names
103+
)
104+
105+
knack_todos = []
106+
for pm in pm_records:
107+
# Set the PM record's COPIED_TO_SECONDARY to True
108+
data = {"id": pm.data["id"], copied_field_name: True}
109+
knack_todos.append({"method": "update", "obj": config["object"], "data": data})
110+
pm.data[copied_field_name] = True
111+
112+
# For each secondary signal, copy the PM record and attach the appropriate signal
113+
primary_signal_id = pm.data[f"{signal_field_name}_raw"][0]["id"]
114+
secondary_signals = signal_lookup[primary_signal_id]
115+
for secondary in secondary_signals:
116+
new_rec = {}
117+
keys = pm.keys()
118+
keys.remove("id")
119+
# Copying PM record, we copy the existing value based on the field type.
120+
for key in keys:
121+
if pm.fields[key].field_def.type in ["multiple_choice"]:
122+
new_rec[key] = pm.data[f"{key}_raw"]
123+
elif pm.fields[key].field_def.type in ["connection"]:
124+
new_rec[key] = [entry["id"] for entry in pm.data[f"{key}_raw"]]
125+
elif pm.fields[key].field_def.type in [
126+
"auto_increment",
127+
"concatenation",
128+
]:
129+
continue
130+
else:
131+
new_rec[key] = pm.data[key]
132+
# Tagging the signal with the secondary signal's knack record ID
133+
new_rec[signal_field_name] = secondary["id"]
134+
knack_todos.append(
135+
{"method": "create", "obj": config["object"], "data": new_rec}
136+
)
137+
138+
# Update the modified date of the secondary traffic signal, so the data is refreshed on the ODP.
139+
data = {"id": secondary["id"], modified_date_field_name: current_date}
140+
knack_todos.append(
141+
{"method": "update", "obj": config["signal_object_id"], "data": data}
142+
)
143+
144+
# Sending updates/creates to knack
145+
logger.info(f"Updating/creating {len(knack_todos)} knack records")
146+
count = 0
147+
for knack_job in knack_todos:
148+
if count % 10 == 0:
149+
logger.info(f"Uploading record {count} of {len(knack_todos)}")
150+
res = record_to_knack(
151+
record=knack_job["data"],
152+
object_id=knack_job["obj"],
153+
method=knack_job["method"],
154+
)
155+
count += 1
156+
157+
158+
if __name__ == "__main__":
159+
# CLI arguments definition
160+
parser = argparse.ArgumentParser()
161+
162+
parser.add_argument(
163+
"-a",
164+
"--app-name",
165+
type=str,
166+
help="str: Name of the Knack App in knack.py config file",
167+
)
168+
169+
parser.add_argument(
170+
"-c",
171+
"--container",
172+
type=str,
173+
help="str: AKA API view that was created for downloading the location data",
174+
)
175+
176+
args = parser.parse_args()
177+
178+
logger = utils.logging.getLogger(__file__)
179+
180+
main(args)

0 commit comments

Comments
 (0)