| 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 |
| 46 |
|
"Request Origination", |
| 47 |
|
] |
| 48 |
|
|
| 49 |
< |
# The new columns we add from the XML |
| 49 |
> |
# New columns derived from the XML |
| 50 |
|
DETAIL_COLUMNS = [ |
| 51 |
< |
"Field Name", |
| 52 |
< |
"Change Type", |
| 53 |
< |
"Old Value", |
| 54 |
< |
"New Value", |
| 55 |
< |
"Raw Details XML", |
| 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: |
| 81 |
|
if not field_change_entries: |
| 82 |
|
out_row = self._copy_base_columns(src_row) |
| 83 |
|
out_row.update({ |
| 84 |
< |
"Field Name": "(unknown)", |
| 85 |
< |
"Change Type": src_row.get("Action", ""), |
| 86 |
< |
"Old Value": "", |
| 87 |
< |
"New Value": "", |
| 88 |
< |
"Raw Details XML": details_str, |
| 84 |
> |
"Field Name": "", |
| 85 |
> |
"Field Type": "", |
| 86 |
> |
"unSetChoice": "", |
| 87 |
> |
"setChoice": "", |
| 88 |
> |
"oldValue": "", |
| 89 |
> |
"newValue": "", |
| 90 |
|
}) |
| 91 |
|
self.rows.append(out_row) |
| 92 |
|
continue |
| 96 |
|
out_row = self._copy_base_columns(src_row) |
| 97 |
|
out_row.update({ |
| 98 |
|
"Field Name": fc.get("field_name", ""), |
| 99 |
< |
"Change Type": fc.get("change_type", src_row.get("Action", "")), |
| 100 |
< |
"Old Value": fc.get("old_value", ""), |
| 101 |
< |
"New Value": fc.get("new_value", ""), |
| 102 |
< |
"Raw Details XML": details_str, |
| 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 |
|
|
| 110 |
|
|
| 111 |
|
def export_to_excel(self, excel_path: str) -> None: |
| 112 |
|
""" |
| 113 |
< |
Export flattened rows to Excel using win32com. |
| 113 |
> |
Export flattened rows to Excel using win32com (your preferred approach). |
| 114 |
|
""" |
| 115 |
|
from win32com.client import Dispatch |
| 116 |
|
|
| 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 field-change dicts: |
| 173 |
< |
{ "field_name": ..., "change_type": ..., "old_value": ..., "new_value": ... } |
| 174 |
< |
|
| 175 |
< |
Based on your schema: |
| 176 |
< |
- <auditElement> is the root. |
| 177 |
< |
- Each <field> element has attributes: id, type, name, formatstring. |
| 178 |
< |
- For choices: |
| 166 |
< |
<setChoice>...values added...</setChoice> |
| 167 |
< |
<unSetChoice>...values removed...</unSetChoice> |
| 168 |
< |
<unsetChoice>...values removed...</unsetChoice> |
| 169 |
< |
- For some fields: |
| 170 |
< |
<oldValue>...</oldValue> |
| 171 |
< |
<newValue>...</newValue> |
| 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: |
| 193 |
|
attrib = field_elem.attrib |
| 194 |
|
|
| 195 |
|
field_name = attrib.get("name", "") |
| 196 |
+ |
field_type = attrib.get("type", "") |
| 197 |
|
|
| 198 |
< |
# Gather choice changes |
| 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 Relativity uses both <unSetChoice> and <unsetChoice> (you saw both) |
| 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") |
| 214 |
|
if (child.text or "").strip() |
| 215 |
|
] |
| 216 |
|
|
| 217 |
< |
# Gather old/new value tags (for non-choice or special cases) |
| 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 "" |
| 214 |
< |
|
| 215 |
< |
# Decide what to treat as old/new |
| 216 |
< |
old_value = "" |
| 217 |
< |
new_value = "" |
| 218 |
< |
change_type = "Update" |
| 219 |
< |
|
| 220 |
< |
if old_val_text or new_val_text: |
| 221 |
< |
# Classic before/after |
| 222 |
< |
old_value = old_val_text |
| 223 |
< |
new_value = new_val_text |
| 224 |
< |
change_type = "Value Update" |
| 225 |
< |
elif set_choices or unset_choices: |
| 226 |
< |
# Choice changes |
| 227 |
< |
# Treat unset choices as old, set choices as new |
| 228 |
< |
if unset_choices: |
| 229 |
< |
old_value = "; ".join(unset_choices) |
| 230 |
< |
if set_choices: |
| 231 |
< |
new_value = "; ".join(set_choices) |
| 232 |
< |
|
| 233 |
< |
if set_choices and unset_choices: |
| 234 |
< |
change_type = "Choices Updated" |
| 235 |
< |
elif set_choices: |
| 236 |
< |
change_type = "Choices Set" |
| 237 |
< |
else: |
| 238 |
< |
change_type = "Choices Unset" |
| 239 |
< |
else: |
| 240 |
< |
# We saw a <field> but no obvious value info |
| 241 |
< |
# Keep field_name but leave values blank |
| 242 |
< |
change_type = "Field Touched" |
| 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 |
< |
"change_type": change_type, |
| 230 |
< |
"old_value": old_value, |
| 231 |
< |
"new_value": new_value, |
| 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 |
|
|
| 252 |
|
|
| 253 |
|
flattener.export_to_excel(output_excel) |
| 254 |
|
print(f"Excel report written to: {output_excel}") |
| 255 |
+ |
|