Skip to content
This repository was archived by the owner on Nov 1, 2024. It is now read-only.

Commit 8ad0af1

Browse files
authored
Merge pull request #32 from SafetyCulture/INTG-501
INTG-501 Update CSV format to exactly match the Tableau format
2 parents b356302 + 24b361d commit 8ad0af1

File tree

40 files changed

+1176
-632
lines changed

40 files changed

+1176
-632
lines changed

tools/exporter/ReadMe.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ python exporter.py --format json
5959
python csvExporter.py path/to/audit_file.json
6060
```
6161
* Basic example of [CSV Export Format](https://github.com/SafetyCulture/safetyculture-sdk-python/blob/master/tools/exporter/tests/csv_test_files/unit_test_single_question_yes___no___na_answered_no_expected_output.csv)
62+
* [Explanation and details of CSV format](https://support.safetyculture.com/integrations/safetyculture-csv-exporter-tool/#format)
6263

6364
#### Bulk CSV Export
6465
* Each Audit is the same format as the single Audit CSV export
@@ -70,10 +71,10 @@ To export Multiple Audits to Bulk CSV file:
7071
python exporter.py --format csv
7172
```
7273

73-
#### CSV values whose format does not match JSON properties
74+
#### The format of the following CSV values do not match the format used by the SafetyCulture API Audit JSON
7475
##### Date/Time field
7576
* JSON: `2017-03-03T03:45:58.090Z`
76-
* CSV: Date Value: `03 March 2017` and Time Value: `03:45AM7`
77+
* CSV: `03 March 2017 03:45 AM`
7778
##### Checkbox field
7879
* JSON: `1` or `0`
7980
* CSV: `True` or `False`

tools/exporter/csvExporter.py

Lines changed: 124 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,43 @@
66
from datetime import datetime
77

88
CSV_HEADER_ROW = [
9-
'Item Type',
9+
'ItemType',
1010
'Label',
1111
'Response',
1212
'Comment',
13-
'Media Hypertext Reference',
14-
'Location Coordinates',
15-
'Item Score',
16-
'Item Max Score',
17-
'Item Score Percentage',
13+
'MediaHypertextReference',
14+
'Latitude',
15+
'Longitude',
16+
'ItemScore',
17+
'ItemMaxScore',
18+
'ItemScorePercentage',
1819
'Mandatory',
19-
'Failed Response',
20+
'FailedResponse',
2021
'Inactive',
21-
'Item ID',
22-
'Response ID',
23-
'Parent ID',
24-
'Audit Owner',
25-
'Audit Author',
26-
'Audit Name',
27-
'Audit Score',
28-
'Audit Max Score',
29-
'Audit Score Percentage',
30-
'Audit Duration (seconds)',
31-
'Date Started',
32-
'Time Started',
33-
'Date Completed',
34-
'Time Completed',
35-
'Audit ID',
36-
'Template ID',
37-
'Template Name',
38-
'Template Author'
22+
'ItemID',
23+
'ResponseID',
24+
'ParentID',
25+
'AuditOwner',
26+
'AuditAuthor',
27+
'AuditName',
28+
'AuditScore',
29+
'AuditMaxScore',
30+
'AuditScorePercentage',
31+
'AuditDuration',
32+
'DateStarted',
33+
'DateCompleted',
34+
'DateModified',
35+
'AuditID',
36+
'TemplateID',
37+
'TemplateName',
38+
'TemplateAuthor',
39+
'ItemCategory',
40+
'DocumentNo',
41+
'ConductedOn',
42+
'PreparedBy',
43+
'Location',
44+
'Personnel',
45+
'ClientSite'
3946
]
4047

4148
# audit item empty response
@@ -119,6 +126,16 @@
119126
'b5c92352-e11b-11e1-9b23-0800200c9a66': 'N/A'
120127
}
121128

129+
# maps header fields to their static IDs
130+
header_field_id = {
131+
'DocumentNo': 'f3245d46-ea77-11e1-aff1-0800200c9a66',
132+
'ConductedOn': 'f3245d42-ea77-11e1-aff1-0800200c9a66',
133+
'PreparedBy': 'f3245d43-ea77-11e1-aff1-0800200c9a66',
134+
'Location': 'f3245d44-ea77-11e1-aff1-0800200c9a66',
135+
'Personnel': 'f3245d45-ea77-11e1-aff1-0800200c9a66',
136+
'ClientSite': 'f3245d41-ea77-11e1-aff1-0800200c9a66'
137+
}
138+
122139

123140
def get_json_property(obj, *args):
124141
"""
@@ -156,8 +173,10 @@ def __init__(self, audit_json, export_inactive_items=True):
156173
"""
157174
self.audit_json = audit_json
158175
self.export_inactive_items = export_inactive_items
176+
self.map_items()
159177
self.audit_table = self.convert_audit_to_table()
160178

179+
161180
def audit_id(self):
162181
"""
163182
:return: The audit ID
@@ -170,6 +189,35 @@ def audit_items(self):
170189
"""
171190
return self.audit_json['header_items'] + self.audit_json['items']
172191

192+
def map_items(self):
193+
"""
194+
Creates a dictionary which maps each item to it's parent ID, Label, and Type.
195+
This tree can then be traversed recursively to find the Category or Section of a given item.
196+
"""
197+
self.item_category = EMPTY_RESPONSE
198+
self.item_map = {}
199+
for item in self.audit_items():
200+
if item.get('item_id'):
201+
self.item_map[item['item_id']] = {
202+
'parent_id': item.get('parent_id') or EMPTY_RESPONSE,
203+
'label': item.get('label') or EMPTY_RESPONSE,
204+
'type': item.get('type') or EMPTY_RESPONSE
205+
}
206+
207+
def get_item_category(self, item_id):
208+
"""
209+
Recursively traverses the item Map, following parent IDs until it gets to a Section or Category.
210+
When a Section or Category is found, the item Category is set to the label of that Section or Category.
211+
:param item_id: item ID to find Category for
212+
:return: Category or Section label
213+
"""
214+
if not item_id:
215+
return EMPTY_RESPONSE
216+
elif self.item_map[item_id]['type'] == 'section' or self.item_map[item_id]['type'] == 'category':
217+
return self.item_map[item_id]['label'] or EMPTY_RESPONSE
218+
else:
219+
return self.get_item_category(self.item_map[item_id]['parent_id'])
220+
173221
def audit_custom_response_id_to_label_map(self):
174222
"""
175223
:return: dictionary mapping custom response_id's to their label
@@ -188,6 +236,7 @@ def common_audit_data(self):
188236
audit_data_property = self.audit_json['audit_data']
189237
template_data_property = self.audit_json['template_data']
190238
audit_date_completed = audit_data_property['date_completed']
239+
header_data = self.audit_json['header_items']
191240
audit_data_as_list = list()
192241
audit_data_as_list.append(audit_data_property['authorship']['owner'])
193242
audit_data_as_list.append(audit_data_property['authorship']['author'])
@@ -196,41 +245,54 @@ def common_audit_data(self):
196245
audit_data_as_list.append(audit_data_property['total_score'])
197246
audit_data_as_list.append(audit_data_property[SCORE_PERCENTAGE])
198247
audit_data_as_list.append(audit_data_property['duration'])
199-
audit_data_as_list.append(self.format_date(audit_data_property['date_started']))
200-
audit_data_as_list.append(self.format_time(audit_data_property['date_started']))
201-
audit_data_as_list.append(self.format_date(audit_date_completed))
202-
audit_data_as_list.append(self.format_time(audit_date_completed))
248+
audit_data_as_list.append(self.format_date_time(audit_data_property['date_started']))
249+
audit_data_as_list.append(self.format_date_time(audit_date_completed))
250+
audit_data_as_list.append(self.format_date_time(audit_data_property['date_modified']))
203251
audit_data_as_list.append(self.audit_id())
204252
audit_data_as_list.append(self.audit_json['template_id'])
205253
audit_data_as_list.append(template_data_property['metadata']['name'])
206254
audit_data_as_list.append(template_data_property['authorship']['author'])
255+
audit_data_as_list.append(self.item_category)
256+
audit_data_as_list.append(self.get_header_item(header_data, 'DocumentNo'))
257+
audit_data_as_list.append(self.get_header_item(header_data, 'ConductedOn'))
258+
audit_data_as_list.append(self.get_header_item(header_data, 'PreparedBy'))
259+
audit_data_as_list.append(self.get_header_item(header_data, 'Location'))
260+
audit_data_as_list.append(self.get_header_item(header_data, 'Personnel'))
261+
audit_data_as_list.append(self.get_header_item(header_data, 'ClientSite'))
207262
return audit_data_as_list
208263

209-
@staticmethod
210-
def format_date(date):
211-
"""
212-
:param date: date in the format: 2017-03-03T03:45:58.090Z
213-
:return: date in the format: '03 March 2017',
214-
"""
215-
if date:
216-
date_object = datetime.strptime(date, '%Y-%m-%dT%H:%M:%S.%fZ')
217-
formatted_date = date_object.strftime('%d %B %Y')
218-
return formatted_date
219-
else:
220-
return ''
264+
def get_header_item(self, header_data, header_item_type):
265+
"""
266+
267+
:param header_data:
268+
:param header_item_type:
269+
:return:
270+
"""
271+
for item in header_data:
272+
if item.get('item_id') == header_field_id.get(header_item_type):
273+
if 'responses' not in item.keys():
274+
return EMPTY_RESPONSE
275+
if 'text' in item['responses'].keys():
276+
return get_json_property(item, 'responses', 'text')
277+
if 'datetime' in item['responses'].keys():
278+
return get_json_property(item, 'responses', 'datetime')
279+
if 'location_text' in item['responses'].keys():
280+
return get_json_property(item, 'responses', 'location_text')
281+
return EMPTY_RESPONSE
221282

222283
@staticmethod
223-
def format_time(date):
284+
def format_date_time(date):
224285
"""
225286
:param date: date in the format: 2017-03-03T03:45:58.090Z
226-
:return: time in the format '03:45AM'
287+
:return: date and time in the format: '03 March 2017 03:45 AM',
227288
"""
228289
if date:
229290
date_object = datetime.strptime(date, '%Y-%m-%dT%H:%M:%S.%fZ')
230-
formatted_time = date_object.strftime('%I:%M%p')
231-
return formatted_time
291+
formatted_date = date_object.strftime('%d %B %Y')
292+
formatted_time = date_object.strftime('%I:%M %p')
293+
return formatted_date + ' ' + formatted_time
232294
else:
233-
return ''
295+
return EMPTY_RESPONSE
234296

235297
def convert_audit_to_table(self):
236298
"""
@@ -240,6 +302,10 @@ def convert_audit_to_table(self):
240302
"""
241303
self.audit_table = []
242304
for item in self.audit_items():
305+
if item.get('parent_id'):
306+
self.item_category = self.get_item_category(item['parent_id'])
307+
else:
308+
self.item_category = EMPTY_RESPONSE
243309
row_array = self.item_properties_as_list(item) + self.common_audit_data()
244310
if get_json_property(item, INACTIVE) and not self.export_inactive_items:
245311
continue
@@ -323,8 +389,7 @@ def get_item_response(self, item):
323389
elif item_type == 'smartfield':
324390
response = get_json_property(item, 'evaluation')
325391
elif item_type == 'datetime':
326-
response = self.format_date(get_json_property(item, RESPONSES, 'datetime'))
327-
response = response + ' at ' + self.format_time(get_json_property(item, RESPONSES, 'datetime'))
392+
response = self.format_date_time(get_json_property(item, RESPONSES, 'datetime'))
328393
elif item_type == 'text' or item_type == 'textsingle':
329394
response = get_json_property(item, RESPONSES, 'text')
330395
elif item_type == INFORMATION and get_json_property(item, 'options', TYPE) == 'link':
@@ -454,29 +519,33 @@ def get_item_location_coordinates(self, item):
454519
item_type = get_json_property(item, TYPE)
455520
if item_type == 'address':
456521
location_coordinates = get_json_property(item, 'responses', 'location', 'geometry', 'coordinates')
457-
if isinstance(location_coordinates, list):
458-
return str(location_coordinates).strip('[]')
459-
return EMPTY_RESPONSE
522+
if isinstance(location_coordinates, list) and len(location_coordinates):
523+
return str(location_coordinates).strip('[]').split(',')
524+
return [EMPTY_RESPONSE, EMPTY_RESPONSE]
460525

461526
def item_properties_as_list(self, item):
462527
"""
463528
Returns selected properties of the audit item JSON as a list
464529
:param item: single item in JSON format
465530
:return: array of item data, in format that CSV writer can handle
466531
"""
532+
location_coordinates = self.get_item_location_coordinates(item)
533+
latitude = location_coordinates[1]
534+
longitude = location_coordinates[0]
467535
return [
468536
self.get_item_type(item),
469537
self.get_item_label(item),
470538
self.get_item_response(item),
471539
get_json_property(item, RESPONSES, 'text') if item.get(TYPE) not in ['text', 'textsingle'] else EMPTY_RESPONSE,
472540
self.get_item_media(item),
473-
self.get_item_location_coordinates(item),
541+
latitude,
542+
longitude,
474543
self.get_item_score(item),
475544
self.get_item_max_score(item),
476545
self.get_item_score_percentage(item),
477-
get_json_property(item, 'options', 'is_mandatory'),
478-
get_json_property(item, RESPONSES, FAILED),
479-
get_json_property(item, INACTIVE),
546+
get_json_property(item, 'options', 'is_mandatory') or False,
547+
get_json_property(item, RESPONSES, FAILED) or False,
548+
get_json_property(item, INACTIVE) or False,
480549
get_json_property(item, ID),
481550
self.get_item_response_id(item),
482551
get_json_property(item, PARENT_ID)

tools/exporter/exporter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,7 @@ def get_media_from_audit(logger, audit_json):
625625
# This condition checks for media attached to signature and drawing type fields.
626626
if 'responses' in item.keys() and 'image' in item['responses'].keys():
627627
media_id_list.append(item['responses']['image']['media_id'])
628-
# This condition checks for media attached to information type fields.
628+
# This condition checks for media attached to information type fields.
629629
if 'options' in item.keys() and 'media' in item['options'].keys():
630630
media_id_list.append(item['options']['media']['media_id'])
631631
logger.info("Discovered {0} media files associated with {1}.".format(len(media_id_list), audit_json['audit_id']))

0 commit comments

Comments
 (0)