| 1 |
"""
|
| 2 |
|
| 3 |
RelativityAuditFlattenerXml
|
| 4 |
|
| 5 |
Created by
|
| 6 |
Emanuel Borges
|
| 7 |
12.08.2025
|
| 8 |
|
| 9 |
Using my XML inspector, this program will attempt to take a raw Relativity audit history report and generate a flatened spreadsheet report.
|
| 10 |
|
| 11 |
"""
|
| 12 |
|
| 13 |
|
| 14 |
import csv
|
| 15 |
import xml.etree.ElementTree as ET
|
| 16 |
from typing import Any, Dict, List
|
| 17 |
|
| 18 |
|
| 19 |
class RelativityAuditFlattenerXml:
|
| 20 |
"""
|
| 21 |
Reads a Relativity audit CSV and flattens XML 'Details' so that
|
| 22 |
each <field> modification becomes its own row, with the original
|
| 23 |
columns (Name through Request Origination) repeated.
|
| 24 |
|
| 25 |
XML schema (from your inspector):
|
| 26 |
- Root: <auditElement>
|
| 27 |
- Repeated per-field nodes: <field id="..." type="..." name="..." formatstring="...">
|
| 28 |
- Inside <field>:
|
| 29 |
<setChoice>...</setChoice>
|
| 30 |
<unSetChoice>...</unSetChoice>
|
| 31 |
<unsetChoice>...</unsetChoice>
|
| 32 |
<oldValue>...</oldValue>
|
| 33 |
<newValue>...</newValue>
|
| 34 |
"""
|
| 35 |
|
| 36 |
# The base columns we want to carry through from the CSV
|
| 37 |
BASE_COLUMNS = [
|
| 38 |
"Name",
|
| 39 |
"Action",
|
| 40 |
"User Name",
|
| 41 |
"Timestamp",
|
| 42 |
"ArtifactID",
|
| 43 |
"Execution Time",
|
| 44 |
"ID",
|
| 45 |
"Object Type",
|
| 46 |
"Request Origination",
|
| 47 |
]
|
| 48 |
|
| 49 |
# New columns derived from the XML
|
| 50 |
DETAIL_COLUMNS = [
|
| 51 |
"Field Name", # from <field name="...">
|
| 52 |
"Field Type", # from <field type="...">
|
| 53 |
"unSetChoice", # combined <unSetChoice> and <unsetChoice>
|
| 54 |
"setChoice", # <setChoice>
|
| 55 |
"oldValue", # <oldValue>
|
| 56 |
"newValue", # <newValue>
|
| 57 |
]
|
| 58 |
|
| 59 |
def __init__(self, csv_path: str) -> None:
|
| 60 |
self.csv_path = csv_path
|
| 61 |
self.rows: List[Dict[str, Any]] = []
|
| 62 |
|
| 63 |
# ------------------------------------------------------------
|
| 64 |
# Public API
|
| 65 |
# ------------------------------------------------------------
|
| 66 |
|
| 67 |
def load_and_flatten(self) -> None:
|
| 68 |
"""
|
| 69 |
Read the CSV, parse Details XML, and populate self.rows with
|
| 70 |
one row per <field> modification.
|
| 71 |
"""
|
| 72 |
with open(self.csv_path, newline="", encoding="utf-8-sig") as f:
|
| 73 |
reader = csv.DictReader(f)
|
| 74 |
|
| 75 |
for src_row in reader:
|
| 76 |
details_str = (src_row.get("Details") or "").strip()
|
| 77 |
field_change_entries = self._parse_details_xml(details_str)
|
| 78 |
|
| 79 |
# If we couldn't parse any field-level changes, still output
|
| 80 |
# a single row so the event isn't lost.
|
| 81 |
if not field_change_entries:
|
| 82 |
out_row = self._copy_base_columns(src_row)
|
| 83 |
out_row.update({
|
| 84 |
"Field Name": "",
|
| 85 |
"Field Type": "",
|
| 86 |
"unSetChoice": "",
|
| 87 |
"setChoice": "",
|
| 88 |
"oldValue": "",
|
| 89 |
"newValue": "",
|
| 90 |
})
|
| 91 |
self.rows.append(out_row)
|
| 92 |
continue
|
| 93 |
|
| 94 |
# One row per <field> node
|
| 95 |
for fc in field_change_entries:
|
| 96 |
out_row = self._copy_base_columns(src_row)
|
| 97 |
out_row.update({
|
| 98 |
"Field Name": fc.get("field_name", ""),
|
| 99 |
"Field Type": fc.get("field_type", ""),
|
| 100 |
"unSetChoice": fc.get("unset_choice", ""),
|
| 101 |
"setChoice": fc.get("set_choice", ""),
|
| 102 |
"oldValue": fc.get("old_value", ""),
|
| 103 |
"newValue": fc.get("new_value", ""),
|
| 104 |
})
|
| 105 |
self.rows.append(out_row)
|
| 106 |
|
| 107 |
def total_field_changes(self) -> int:
|
| 108 |
"""Total number of field-level rows."""
|
| 109 |
return len(self.rows)
|
| 110 |
|
| 111 |
def export_to_excel(self, excel_path: str) -> None:
|
| 112 |
"""
|
| 113 |
Export flattened rows to Excel using win32com (your preferred approach).
|
| 114 |
"""
|
| 115 |
from win32com.client import Dispatch
|
| 116 |
|
| 117 |
xlApp = Dispatch("Excel.Application")
|
| 118 |
xlApp.Visible = False # set True if you want to watch it build
|
| 119 |
|
| 120 |
wb = xlApp.Workbooks.Add()
|
| 121 |
ws = wb.Worksheets(1)
|
| 122 |
ws.Name = "Audit Changes"
|
| 123 |
|
| 124 |
headers = self.BASE_COLUMNS + self.DETAIL_COLUMNS
|
| 125 |
|
| 126 |
# Header row
|
| 127 |
for col_idx, header in enumerate(headers, start=1):
|
| 128 |
ws.Cells(1, col_idx).Value = header
|
| 129 |
|
| 130 |
# Data rows
|
| 131 |
for row_idx, row in enumerate(self.rows, start=2):
|
| 132 |
for col_idx, header in enumerate(headers, start=1):
|
| 133 |
ws.Cells(row_idx, col_idx).Value = row.get(header, "")
|
| 134 |
|
| 135 |
# Autofit and (optionally) make a table
|
| 136 |
last_row = len(self.rows) + 1
|
| 137 |
last_col = len(headers)
|
| 138 |
data_range = ws.Range(ws.Cells(1, 1), ws.Cells(last_row, last_col))
|
| 139 |
data_range.EntireColumn.AutoFit()
|
| 140 |
|
| 141 |
try:
|
| 142 |
table = ws.ListObjects.Add(
|
| 143 |
SourceType=0, # xlSrcRange
|
| 144 |
Source=data_range,
|
| 145 |
XlListObjectHasHeaders=1, # xlYes
|
| 146 |
)
|
| 147 |
table.Name = "AuditChangesTable"
|
| 148 |
table.TableStyle = "TableStyleLight9"
|
| 149 |
except Exception:
|
| 150 |
# If ListObjects/TableStyle fails, just ignore it
|
| 151 |
pass
|
| 152 |
|
| 153 |
wb.SaveAs(excel_path)
|
| 154 |
wb.Close(SaveChanges=True)
|
| 155 |
xlApp.Quit()
|
| 156 |
|
| 157 |
# ------------------------------------------------------------
|
| 158 |
# Internal helpers
|
| 159 |
# ------------------------------------------------------------
|
| 160 |
|
| 161 |
def _copy_base_columns(self, src_row: Dict[str, Any]) -> Dict[str, Any]:
|
| 162 |
"""
|
| 163 |
Copy only the base columns from the source CSV row.
|
| 164 |
"""
|
| 165 |
out: Dict[str, Any] = {}
|
| 166 |
for col in self.BASE_COLUMNS:
|
| 167 |
out[col] = src_row.get(col, "")
|
| 168 |
return out
|
| 169 |
|
| 170 |
def _parse_details_xml(self, xml_str: str) -> List[Dict[str, str]]:
|
| 171 |
"""
|
| 172 |
Parse the XML in Details and return a list of dicts with keys:
|
| 173 |
- field_name
|
| 174 |
- field_type
|
| 175 |
- unset_choice (combined unSetChoice/unsetChoice)
|
| 176 |
- set_choice
|
| 177 |
- old_value
|
| 178 |
- new_value
|
| 179 |
"""
|
| 180 |
xml_str = (xml_str or "").strip()
|
| 181 |
if not xml_str:
|
| 182 |
return []
|
| 183 |
|
| 184 |
try:
|
| 185 |
root = ET.fromstring(xml_str)
|
| 186 |
except ET.ParseError:
|
| 187 |
return []
|
| 188 |
|
| 189 |
results: List[Dict[str, str]] = []
|
| 190 |
|
| 191 |
# Find all <field> elements anywhere under the root
|
| 192 |
for field_elem in root.findall(".//field"):
|
| 193 |
attrib = field_elem.attrib
|
| 194 |
|
| 195 |
field_name = attrib.get("name", "")
|
| 196 |
field_type = attrib.get("type", "")
|
| 197 |
|
| 198 |
# Collect choice changes
|
| 199 |
set_choices = [
|
| 200 |
(child.text or "").strip()
|
| 201 |
for child in field_elem.findall("setChoice")
|
| 202 |
if (child.text or "").strip()
|
| 203 |
]
|
| 204 |
|
| 205 |
# Note: both <unSetChoice> and <unsetChoice> appear in your data
|
| 206 |
unset_choices = [
|
| 207 |
(child.text or "").strip()
|
| 208 |
for child in field_elem.findall("unSetChoice")
|
| 209 |
if (child.text or "").strip()
|
| 210 |
]
|
| 211 |
unset_choices += [
|
| 212 |
(child.text or "").strip()
|
| 213 |
for child in field_elem.findall("unsetChoice")
|
| 214 |
if (child.text or "").strip()
|
| 215 |
]
|
| 216 |
|
| 217 |
unset_choice_str = "; ".join(unset_choices) if unset_choices else ""
|
| 218 |
set_choice_str = "; ".join(set_choices) if set_choices else ""
|
| 219 |
|
| 220 |
# Gather old/new value tags (for non-choice cases)
|
| 221 |
old_val_node = field_elem.find("oldValue")
|
| 222 |
new_val_node = field_elem.find("newValue")
|
| 223 |
old_val_text = (old_val_node.text or "").strip() if (old_val_node is not None and old_val_node.text) else ""
|
| 224 |
new_val_text = (new_val_node.text or "").strip() if (new_val_node is not None and new_val_node.text) else ""
|
| 225 |
|
| 226 |
results.append(
|
| 227 |
{
|
| 228 |
"field_name": field_name,
|
| 229 |
"field_type": field_type,
|
| 230 |
"unset_choice": unset_choice_str,
|
| 231 |
"set_choice": set_choice_str,
|
| 232 |
"old_value": old_val_text,
|
| 233 |
"new_value": new_val_text,
|
| 234 |
}
|
| 235 |
)
|
| 236 |
|
| 237 |
return results
|
| 238 |
|
| 239 |
|
| 240 |
# ------------------------------------------------------------
|
| 241 |
# Example usage
|
| 242 |
# ------------------------------------------------------------
|
| 243 |
|
| 244 |
if __name__ == "__main__":
|
| 245 |
input_csv = r"C:\path\to\RelativityAuditReport.csv"
|
| 246 |
output_excel = r"C:\path\to\RelativityAuditReport_Flattened_XML.xlsx"
|
| 247 |
|
| 248 |
flattener = RelativityAuditFlattenerXml(input_csv)
|
| 249 |
flattener.load_and_flatten()
|
| 250 |
|
| 251 |
print(f"Total field-level changes: {flattener.total_field_changes()}")
|
| 252 |
|
| 253 |
flattener.export_to_excel(output_excel)
|
| 254 |
print(f"Excel report written to: {output_excel}")
|
| 255 |
|