{
"cells": [
{
"cell_type": "markdown",
"metadata": {
"ExecuteTime": {
"end_time": "2021-03-15T09:53:01.873561Z",
"start_time": "2021-03-15T09:53:01.869097Z"
}
},
"source": [
"# Imports"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.114125Z",
"start_time": "2021-04-05T16:55:08.016250Z"
}
},
"outputs": [],
"source": [
"import pandas as pd\n",
"import numpy as np\n",
"import json"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Load Data\n",
"\n",
"We will work with data from the Audit table. For sake of simplicity, the rows from the SQL table have been exported in an Excel file. Also, only a subset of the audit has been exported."
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.314873Z",
"start_time": "2021-04-05T16:55:10.117939Z"
}
},
"outputs": [],
"source": [
"# Load Audit Excel File\n",
"audit = pd.concat(pd.read_excel('AuditTableData.xlsx', sheet_name=None), ignore_index=True)\n",
"\n",
"# Set Datetime column\n",
"audit.CreatedOn = pd.to_datetime(audit.CreatedOn)"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.403917Z",
"start_time": "2021-04-05T16:55:10.321131Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Shape: (10, 15)\n"
]
},
{
"data": {
"text/html": [
"
\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" CallingUserId | \n",
" UserId | \n",
" CreatedOn | \n",
" TransactionId | \n",
" ChangeData | \n",
" Action | \n",
" Operation | \n",
" ObjectId | \n",
" AuditId | \n",
" AttributeMask | \n",
" ObjectTypeCode | \n",
" ObjectIdName | \n",
" UserAdditionalInfo | \n",
" RegardingObjectId | \n",
" RegardingObjectIdName | \n",
"
\n",
" \n",
" \n",
" \n",
" 0 | \n",
" NaN | \n",
" 52C11204-6A97-E611-80EF-005056955350 | \n",
" 2020-11-05 08:23:42 | \n",
" BC254F36-401F-EB11-814A-005056953107 | \n",
" NaN | \n",
" 1 | \n",
" 1 | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
" 65486436-401F-EB11-8140-005056956E2F | \n",
" ,10647,10059,10529,10106,10088,131,10239,10732... | \n",
" 2 | \n",
" NaN | \n",
" NaN | \n",
" NaN | \n",
" NaN | \n",
"
\n",
" \n",
" 1 | \n",
" NaN | \n",
" 52C11204-6A97-E611-80EF-005056955350 | \n",
" 2020-11-05 08:24:27 | \n",
" 6E6D4551-401F-EB11-814A-005056953107 | \n",
" drs_drs_xx_preferences_contact~drs_xx_preferen... | \n",
" 33 | \n",
" 2 | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
" 57554051-401F-EB11-8140-005056956E2F | \n",
" NaN | \n",
" 2 | \n",
" NaN | \n",
" NaN | \n",
" NaN | \n",
" NaN | \n",
"
\n",
" \n",
" 2 | \n",
" NaN | \n",
" 52C11204-6A97-E611-80EF-005056955350 | \n",
" 2020-11-05 08:25:00 | \n",
" 081BEC64-401F-EB11-814A-005056953107 | \n",
" drs_drs_xx_preferences_contact~drs_xx_preferen... | \n",
" 34 | \n",
" 2 | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
" 2401E764-401F-EB11-8140-005056956E2F | \n",
" NaN | \n",
" 2 | \n",
" NaN | \n",
" NaN | \n",
" NaN | \n",
" NaN | \n",
"
\n",
" \n",
" 3 | \n",
" NaN | \n",
" F048EF85-06BC-E811-8106-005056953107 | \n",
" 2020-11-18 07:36:45 | \n",
" EB74CDCE-7029-EB11-814C-005056953107 | \n",
" ~804170001 | \n",
" 2 | \n",
" 2 | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
" A1A9B9CE-7029-EB11-8143-005056956E2F | \n",
" ,10740,10205, | \n",
" 2 | \n",
" NaN | \n",
" NaN | \n",
" NaN | \n",
" NaN | \n",
"
\n",
" \n",
" 4 | \n",
" NaN | \n",
" F048EF85-06BC-E811-8106-005056953107 | \n",
" 2020-11-18 07:36:52 | \n",
" EC74CDCE-7029-EB11-814C-005056953107 | \n",
" ~~804170001 | \n",
" 2 | \n",
" 2 | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
" A2A9B9CE-7029-EB11-8143-005056956E2F | \n",
" ,10661,10662,10205, | \n",
" 2 | \n",
" NaN | \n",
" NaN | \n",
" NaN | \n",
" NaN | \n",
"
\n",
" \n",
"
\n",
"
"
],
"text/plain": [
" CallingUserId UserId CreatedOn \\\n",
"0 NaN 52C11204-6A97-E611-80EF-005056955350 2020-11-05 08:23:42 \n",
"1 NaN 52C11204-6A97-E611-80EF-005056955350 2020-11-05 08:24:27 \n",
"2 NaN 52C11204-6A97-E611-80EF-005056955350 2020-11-05 08:25:00 \n",
"3 NaN F048EF85-06BC-E811-8106-005056953107 2020-11-18 07:36:45 \n",
"4 NaN F048EF85-06BC-E811-8106-005056953107 2020-11-18 07:36:52 \n",
"\n",
" TransactionId \\\n",
"0 BC254F36-401F-EB11-814A-005056953107 \n",
"1 6E6D4551-401F-EB11-814A-005056953107 \n",
"2 081BEC64-401F-EB11-814A-005056953107 \n",
"3 EB74CDCE-7029-EB11-814C-005056953107 \n",
"4 EC74CDCE-7029-EB11-814C-005056953107 \n",
"\n",
" ChangeData Action Operation \\\n",
"0 NaN 1 1 \n",
"1 drs_drs_xx_preferences_contact~drs_xx_preferen... 33 2 \n",
"2 drs_drs_xx_preferences_contact~drs_xx_preferen... 34 2 \n",
"3 ~804170001 2 2 \n",
"4 ~~804170001 2 2 \n",
"\n",
" ObjectId AuditId \\\n",
"0 BB254F36-401F-EB11-814A-005056953107 65486436-401F-EB11-8140-005056956E2F \n",
"1 BB254F36-401F-EB11-814A-005056953107 57554051-401F-EB11-8140-005056956E2F \n",
"2 BB254F36-401F-EB11-814A-005056953107 2401E764-401F-EB11-8140-005056956E2F \n",
"3 BB254F36-401F-EB11-814A-005056953107 A1A9B9CE-7029-EB11-8143-005056956E2F \n",
"4 BB254F36-401F-EB11-814A-005056953107 A2A9B9CE-7029-EB11-8143-005056956E2F \n",
"\n",
" AttributeMask ObjectTypeCode \\\n",
"0 ,10647,10059,10529,10106,10088,131,10239,10732... 2 \n",
"1 NaN 2 \n",
"2 NaN 2 \n",
"3 ,10740,10205, 2 \n",
"4 ,10661,10662,10205, 2 \n",
"\n",
" ObjectIdName UserAdditionalInfo RegardingObjectId RegardingObjectIdName \n",
"0 NaN NaN NaN NaN \n",
"1 NaN NaN NaN NaN \n",
"2 NaN NaN NaN NaN \n",
"3 NaN NaN NaN NaN \n",
"4 NaN NaN NaN NaN "
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"# Print Data\n",
"print(\"Shape: \" + str(audit.shape))\n",
"audit.head(5)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Deal with the *AttributeMaskColumn* and *ChangeData* columns\n",
"\n",
"The first thing to do is to work with the *AttributeMasCustom* and the *ChangeData* columns. As explaine in Part I, these two columns contain the attributes and old values before the change.\n",
"\n",
"Therefore, we will start by creating a new column on the dataset, a column that will combine a field and its old value.\n",
"\n",
"> For the 'Create' event, there is no old value. Therefore the value \"OnCreation\" will be added\n"
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.411879Z",
"start_time": "2021-04-05T16:55:10.406919Z"
}
},
"outputs": [],
"source": [
"# Remove the first and last comma in the mask column\n",
"audit[\"AttributeMaskCustom\"] = audit.AttributeMask.str[1:-1]"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.428258Z",
"start_time": "2021-04-05T16:55:10.416223Z"
}
},
"outputs": [],
"source": [
"# Split the items to have \"Field###Value\"\n",
"audit[\"A\"] = audit[\"AttributeMaskCustom\"].str.split(',').fillna('')\n",
"audit[\"B\"] = audit[\"ChangeData\"].str.split('~').fillna(' ')"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.439301Z",
"start_time": "2021-04-05T16:55:10.431504Z"
}
},
"outputs": [],
"source": [
"# will create a new column with: Field###Old_Value!!!Date???\n",
"def mergelines(data,i):\n",
" v = \"\"\n",
" for j in range(i):\n",
" v_1 = data.A[j]\n",
" v_2 = data.B[j] if data.B[0] != \" \" else \"OnCreation\"\n",
" v_3 = str(data.CreatedOn)\n",
" v += v_1 + \"###\"+ v_2 + \"!!!\" + v_3 + \"???\"\n",
" return v[:-3]"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.477936Z",
"start_time": "2021-04-05T16:55:10.442843Z"
}
},
"outputs": [],
"source": [
"audit[\"values_merges\"] = audit.apply(lambda x : mergelines(x,len(x.A)),axis=1)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Merging line per field and per event\n",
"\n",
"Let's create a new dataframe with only two columns:\n",
"\n",
"* The first column will contains the reference to a field, it's old value, and the date and time when the change occured. \n",
"* The second column will contain the GUID of the AuditId, in order to retrieve which changes have been saved together."
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.519883Z",
"start_time": "2021-04-05T16:55:10.481849Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" v | \n",
" i | \n",
"
\n",
" \n",
" \n",
" \n",
" 0 | \n",
" 10647###OnCreation!!!2020-11-05 08:23:42 | \n",
" 65486436-401F-EB11-8140-005056956E2F | \n",
"
\n",
" \n",
" 1 | \n",
" 10059###OnCreation!!!2020-11-05 08:23:42 | \n",
" 65486436-401F-EB11-8140-005056956E2F | \n",
"
\n",
" \n",
" 2 | \n",
" 10529###OnCreation!!!2020-11-05 08:23:42 | \n",
" 65486436-401F-EB11-8140-005056956E2F | \n",
"
\n",
" \n",
" 3 | \n",
" 10106###OnCreation!!!2020-11-05 08:23:42 | \n",
" 65486436-401F-EB11-8140-005056956E2F | \n",
"
\n",
" \n",
" 4 | \n",
" 10088###OnCreation!!!2020-11-05 08:23:42 | \n",
" 65486436-401F-EB11-8140-005056956E2F | \n",
"
\n",
" \n",
" ... | \n",
" ... | \n",
" ... | \n",
"
\n",
" \n",
" 243 | \n",
" 10205###804170001!!!2021-03-25 15:55:12 | \n",
" 0639DB7A-828D-EB11-8149-005056956E2F | \n",
"
\n",
" \n",
" 244 | \n",
" 42###!!!2021-03-31 10:05:11 | \n",
" 86F7E593-0892-EB11-8149-005056956E2F | \n",
"
\n",
" \n",
" 245 | \n",
" 10205###804170001!!!2021-03-31 10:05:11 | \n",
" 86F7E593-0892-EB11-8149-005056956E2F | \n",
"
\n",
" \n",
" 246 | \n",
" 42###danny@gmail.com!!!2021-03-31 10:05:16 | \n",
" 87F7E593-0892-EB11-8149-005056956E2F | \n",
"
\n",
" \n",
" 247 | \n",
" 10205###804170001!!!2021-03-31 10:05:16 | \n",
" 87F7E593-0892-EB11-8149-005056956E2F | \n",
"
\n",
" \n",
"
\n",
"
248 rows × 2 columns
\n",
"
"
],
"text/plain": [
" v \\\n",
"0 10647###OnCreation!!!2020-11-05 08:23:42 \n",
"1 10059###OnCreation!!!2020-11-05 08:23:42 \n",
"2 10529###OnCreation!!!2020-11-05 08:23:42 \n",
"3 10106###OnCreation!!!2020-11-05 08:23:42 \n",
"4 10088###OnCreation!!!2020-11-05 08:23:42 \n",
".. ... \n",
"243 10205###804170001!!!2021-03-25 15:55:12 \n",
"244 42###!!!2021-03-31 10:05:11 \n",
"245 10205###804170001!!!2021-03-31 10:05:11 \n",
"246 42###danny@gmail.com!!!2021-03-31 10:05:16 \n",
"247 10205###804170001!!!2021-03-31 10:05:16 \n",
"\n",
" i \n",
"0 65486436-401F-EB11-8140-005056956E2F \n",
"1 65486436-401F-EB11-8140-005056956E2F \n",
"2 65486436-401F-EB11-8140-005056956E2F \n",
"3 65486436-401F-EB11-8140-005056956E2F \n",
"4 65486436-401F-EB11-8140-005056956E2F \n",
".. ... \n",
"243 0639DB7A-828D-EB11-8149-005056956E2F \n",
"244 86F7E593-0892-EB11-8149-005056956E2F \n",
"245 86F7E593-0892-EB11-8149-005056956E2F \n",
"246 87F7E593-0892-EB11-8149-005056956E2F \n",
"247 87F7E593-0892-EB11-8149-005056956E2F \n",
"\n",
"[248 rows x 2 columns]"
]
},
"execution_count": 8,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"new_cc = pd.concat([pd.Series(row['AuditId'], row['values_merges'].split('???')) for _, row in audit.iterrows()]).reset_index()\n",
"new_cc.columns = [\"v\",\"i\"]\n",
"new_cc"
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.538893Z",
"start_time": "2021-04-05T16:55:10.525225Z"
}
},
"outputs": [],
"source": [
"# Now, we can merge back this new information within the entire dataframe\n",
"all_data = new_cc.join(audit.set_index('AuditId'), on='i')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"For the moment, there are multiple fields per line. Now we make use of the '###' separator to spli each line and have only one field per line."
]
},
{
"cell_type": "code",
"execution_count": 10,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.576702Z",
"start_time": "2021-04-05T16:55:10.542883Z"
}
},
"outputs": [],
"source": [
"all_data[['v1','v1_1']] = all_data.v.str.split(\"###\",expand=True,)\n",
"all_data[['Old Value','v3']] = all_data.v1_1.str.split(\"!!!\",expand=True,)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"As a result we now have a table where for each line there is only one field"
]
},
{
"cell_type": "code",
"execution_count": 11,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:10.616586Z",
"start_time": "2021-04-05T16:55:10.579357Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" v1 | \n",
" v3 | \n",
" CreatedOn | \n",
" Old Value | \n",
"
\n",
" \n",
" \n",
" \n",
" 0 | \n",
" 10647 | \n",
" 2020-11-05 08:23:42 | \n",
" 2020-11-05 08:23:42 | \n",
" OnCreation | \n",
"
\n",
" \n",
" 1 | \n",
" 10059 | \n",
" 2020-11-05 08:23:42 | \n",
" 2020-11-05 08:23:42 | \n",
" OnCreation | \n",
"
\n",
" \n",
" 2 | \n",
" 10529 | \n",
" 2020-11-05 08:23:42 | \n",
" 2020-11-05 08:23:42 | \n",
" OnCreation | \n",
"
\n",
" \n",
" 3 | \n",
" 10106 | \n",
" 2020-11-05 08:23:42 | \n",
" 2020-11-05 08:23:42 | \n",
" OnCreation | \n",
"
\n",
" \n",
" 4 | \n",
" 10088 | \n",
" 2020-11-05 08:23:42 | \n",
" 2020-11-05 08:23:42 | \n",
" OnCreation | \n",
"
\n",
" \n",
" ... | \n",
" ... | \n",
" ... | \n",
" ... | \n",
" ... | \n",
"
\n",
" \n",
" 243 | \n",
" 10205 | \n",
" 2021-03-25 15:55:12 | \n",
" 2021-03-25 15:55:12 | \n",
" 804170001 | \n",
"
\n",
" \n",
" 244 | \n",
" 42 | \n",
" 2021-03-31 10:05:11 | \n",
" 2021-03-31 10:05:11 | \n",
" | \n",
"
\n",
" \n",
" 245 | \n",
" 10205 | \n",
" 2021-03-31 10:05:11 | \n",
" 2021-03-31 10:05:11 | \n",
" 804170001 | \n",
"
\n",
" \n",
" 246 | \n",
" 42 | \n",
" 2021-03-31 10:05:16 | \n",
" 2021-03-31 10:05:16 | \n",
" danny@gmail.com | \n",
"
\n",
" \n",
" 247 | \n",
" 10205 | \n",
" 2021-03-31 10:05:16 | \n",
" 2021-03-31 10:05:16 | \n",
" 804170001 | \n",
"
\n",
" \n",
"
\n",
"
248 rows × 4 columns
\n",
"
"
],
"text/plain": [
" v1 v3 CreatedOn Old Value\n",
"0 10647 2020-11-05 08:23:42 2020-11-05 08:23:42 OnCreation\n",
"1 10059 2020-11-05 08:23:42 2020-11-05 08:23:42 OnCreation\n",
"2 10529 2020-11-05 08:23:42 2020-11-05 08:23:42 OnCreation\n",
"3 10106 2020-11-05 08:23:42 2020-11-05 08:23:42 OnCreation\n",
"4 10088 2020-11-05 08:23:42 2020-11-05 08:23:42 OnCreation\n",
".. ... ... ... ...\n",
"243 10205 2021-03-25 15:55:12 2021-03-25 15:55:12 804170001\n",
"244 42 2021-03-31 10:05:11 2021-03-31 10:05:11 \n",
"245 10205 2021-03-31 10:05:11 2021-03-31 10:05:11 804170001\n",
"246 42 2021-03-31 10:05:16 2021-03-31 10:05:16 danny@gmail.com\n",
"247 10205 2021-03-31 10:05:16 2021-03-31 10:05:16 804170001\n",
"\n",
"[248 rows x 4 columns]"
]
},
"execution_count": 11,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"all_data[[\"v1\",\"v3\",\"CreatedOn\",\"Old Value\"]]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Fields mapping\n",
"\n",
"Inside the AttributeMask column, each integer corresponds to the ColumnNumber metadata property of attributes that have been updated. Those information are stored in the MetadataSchema.Attribute SQL table.\n",
"\n",
"Again, for sake of simplicity here, this data will not be retrieve directly in SQL, but in an Excel file."
]
},
{
"cell_type": "markdown",
"metadata": {
"ExecuteTime": {
"end_time": "2021-03-16T20:56:30.862703Z",
"start_time": "2021-03-16T20:56:30.851689Z"
}
},
"source": [
"```sql\n",
"SELECT ar.name,ar.ColumnNumber \n",
"FROM MetadataSchema.Attribute ar INNER JOIN \n",
" MetadataSchema.Entity en ON ar.EntityId = en.EntityId\n",
"WHERE en.ObjectTypeCode=2\n",
"``` "
]
},
{
"cell_type": "code",
"execution_count": 12,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.045240Z",
"start_time": "2021-04-05T16:55:10.622123Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" name | \n",
" ColumnNumber | \n",
"
\n",
" \n",
" \n",
" \n",
" 0 | \n",
" accountid | \n",
" 14 | \n",
"
\n",
" \n",
" 1 | \n",
" accountiddsc | \n",
" 86 | \n",
"
\n",
" \n",
" 2 | \n",
" accountidname | \n",
" 85 | \n",
"
\n",
" \n",
" 3 | \n",
" accountidyominame | \n",
" 219 | \n",
"
\n",
" \n",
" 4 | \n",
" accountrolecode | \n",
" 56 | \n",
"
\n",
" \n",
" 5 | \n",
" accountrolecode | \n",
" 56 | \n",
"
\n",
" \n",
" 6 | \n",
" accountrolecodename | \n",
" 158 | \n",
"
\n",
" \n",
" 7 | \n",
" address1_addressid | \n",
" 89 | \n",
"
\n",
" \n",
" 8 | \n",
" address1_addresstypecode | \n",
" 90 | \n",
"
\n",
" \n",
" 9 | \n",
" address1_addresstypecode | \n",
" 90 | \n",
"
\n",
" \n",
"
\n",
"
"
],
"text/plain": [
" name ColumnNumber\n",
"0 accountid 14\n",
"1 accountiddsc 86\n",
"2 accountidname 85\n",
"3 accountidyominame 219\n",
"4 accountrolecode 56\n",
"5 accountrolecode 56\n",
"6 accountrolecodename 158\n",
"7 address1_addressid 89\n",
"8 address1_addresstypecode 90\n",
"9 address1_addresstypecode 90"
]
},
"execution_count": 12,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"contact_attributeMask = pd.read_excel(\"Contact_AttributeMask.xlsx\")\n",
"contact_attributeMask.head(10)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Now we can map the AttribueMask ColumnNumber to their field's technical names."
]
},
{
"cell_type": "code",
"execution_count": 13,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.067903Z",
"start_time": "2021-04-05T16:55:11.049384Z"
}
},
"outputs": [],
"source": [
"attributeMaskeDict = pd.Series(contact_attributeMask.name.values,index=contact_attributeMask.ColumnNumber.apply(str)).to_dict()\n",
"all_data[\"Field\"] = all_data.v1.map(attributeMaskeDict)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Add the 'Event' column via 'Action' mapping\n",
"\n",
"Inside the Event column, each integer corresponds to the an unique event. It's an integer representing what kind of action has been performed on the record (e.g: Create, Update, Deactivate, Add Member, ...).\n",
"\n",
"Again, for sake of simplicity here, this data will not be retrieve directly in SQL, but in an Excel file."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"```sql\n",
"SELECT Value as Action, AttributeValue as ActionValue\n",
"FROM StringMap \n",
"WHERE AttributeName='action' and LangId = '1033'\n",
"``` "
]
},
{
"cell_type": "code",
"execution_count": 14,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.115707Z",
"start_time": "2021-04-05T16:55:11.070895Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" Action | \n",
" ActionValue | \n",
"
\n",
" \n",
" \n",
" \n",
" 0 | \n",
" Unknown | \n",
" 0 | \n",
"
\n",
" \n",
" 1 | \n",
" Create | \n",
" 1 | \n",
"
\n",
" \n",
" 2 | \n",
" Update | \n",
" 2 | \n",
"
\n",
" \n",
" 3 | \n",
" Delete | \n",
" 3 | \n",
"
\n",
" \n",
" 4 | \n",
" Activate | \n",
" 4 | \n",
"
\n",
" \n",
" 5 | \n",
" Deactivate | \n",
" 5 | \n",
"
\n",
" \n",
" 6 | \n",
" Cascade | \n",
" 11 | \n",
"
\n",
" \n",
" 7 | \n",
" Merge | \n",
" 12 | \n",
"
\n",
" \n",
" 8 | \n",
" Assign | \n",
" 13 | \n",
"
\n",
" \n",
" 9 | \n",
" Share | \n",
" 14 | \n",
"
\n",
" \n",
"
\n",
"
"
],
"text/plain": [
" Action ActionValue\n",
"0 Unknown 0\n",
"1 Create 1\n",
"2 Update 2\n",
"3 Delete 3\n",
"4 Activate 4\n",
"5 Deactivate 5\n",
"6 Cascade 11\n",
"7 Merge 12\n",
"8 Assign 13\n",
"9 Share 14"
]
},
"execution_count": 14,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"action_mapping = pd.read_excel(\"action_mapping.xlsx\")\n",
"action_mapping.head(10)"
]
},
{
"cell_type": "code",
"execution_count": 15,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.132211Z",
"start_time": "2021-04-05T16:55:11.120218Z"
}
},
"outputs": [],
"source": [
"actionMaskDict = pd.Series(action_mapping.Action.values,index=action_mapping.ActionValue).to_dict()\n",
"all_data[\"Event\"] = all_data.Action.map(actionMaskDict)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Users mapping\n",
"\n",
"Similar to the fields mapping, we can map the name of the user that changed the data.\n",
"\n",
"To change relatively to the Excel file, the data is load via a JSON file that has been retrieved with the Web API via URL `/api/data/v8.2/systemusers?$select=fullname,systemuserid`"
]
},
{
"cell_type": "code",
"execution_count": 16,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.147479Z",
"start_time": "2021-04-05T16:55:11.136223Z"
}
},
"outputs": [],
"source": [
"# Opening JSON file \n",
"with open('systemusers.json', encoding=\"utf8\") as json_file: \n",
" systemusers = json.load(json_file) "
]
},
{
"cell_type": "code",
"execution_count": 17,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.162891Z",
"start_time": "2021-04-05T16:55:11.151783Z"
}
},
"outputs": [],
"source": [
"# From the JSON file, create a dictionary GUID -> Fullname\n",
"df = pd.DataFrame.from_dict(systemusers['value'])\n",
"df.index = df.systemuserid.str.upper()\n",
"users = df[\"fullname\"].to_dict()"
]
},
{
"cell_type": "code",
"execution_count": 18,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.178480Z",
"start_time": "2021-04-05T16:55:11.167922Z"
}
},
"outputs": [],
"source": [
"# Map it to the data\n",
"all_data[\"User\"] = all_data.UserId.map(users)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Add current values\n",
"\n",
"As explained in the Part I, a major drawback of the way Dynamics 365 deals with the audit is that it only stored the previous value, but not the new one.\n",
"\n",
"To complete our dataset, we will retrieve via the Web API all fields of a given contact and add it to the *AuditTable* rows. It will enable us to have the complete history of the audit."
]
},
{
"cell_type": "code",
"execution_count": 19,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.194714Z",
"start_time": "2021-04-05T16:55:11.181162Z"
}
},
"outputs": [],
"source": [
"# Opening JSON file \n",
"with open('contacts(BB254F36-401F-EB11-814A-005056953107).json', encoding=\"utf8\") as json_file: \n",
" contact = json.load(json_file) "
]
},
{
"cell_type": "code",
"execution_count": 20,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.210379Z",
"start_time": "2021-04-05T16:55:11.195711Z"
}
},
"outputs": [],
"source": [
"df_contact = pd.DataFrame(contact.items(), columns=['Field','Old Value'])\n",
"df_contact[\"User\"] = None\n",
"df_contact[\"Event\"] = \"CURRENT_VALUE\"\n",
"df_contact[\"ObjectId\"] = contact['contactid'].upper()\n",
"df_contact[\"CreatedOn\"] = pd.Timestamp.today()"
]
},
{
"cell_type": "code",
"execution_count": 21,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.235830Z",
"start_time": "2021-04-05T16:55:11.214790Z"
}
},
"outputs": [],
"source": [
"all_data = all_data.append(df_contact)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# OptionSet Mappings\n",
"\n",
"In the AuditTable rows, option set are represented with the value of the option set, not with the name. In the code below we will map each option set value to it's label."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"```sql\n",
"SELECT Value as OptionName, AttributeValue as OptionValue, AttributeName as FieldName\n",
"FROM StringMap \n",
"WHERE ObjectTypeCode = 1\n",
"``` "
]
},
{
"cell_type": "code",
"execution_count": 22,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.388079Z",
"start_time": "2021-04-05T16:55:11.238189Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" OptionName | \n",
" OptionValue | \n",
" FieldName | \n",
"
\n",
" \n",
" \n",
" \n",
" 0 | \n",
" Analysé, sans autocertification | \n",
" 3 | \n",
" drs_statutautocertification | \n",
"
\n",
" \n",
" 1 | \n",
" Typologie à clarifier | \n",
" 804170002 | \n",
" drs_statutdyn365 | \n",
"
\n",
" \n",
" 2 | \n",
" Client existant à clarifier (Typologie) | \n",
" 7 | \n",
" drs_contexteprocessdyn365 | \n",
"
\n",
" \n",
" 3 | \n",
" Client existant à autocertifier (ADE) | \n",
" 8 | \n",
" drs_contexteprocessdyn365 | \n",
"
\n",
" \n",
" 4 | \n",
" Changement de circonstance à autocertifier (ADE) | \n",
" 9 | \n",
" drs_contexteprocessdyn365 | \n",
"
\n",
" \n",
" 5 | \n",
" Non | \n",
" 0 | \n",
" drs_technical_isemailsent | \n",
"
\n",
" \n",
" 6 | \n",
" Oui | \n",
" 1 | \n",
" drs_technical_isemailsent | \n",
"
\n",
" \n",
" 7 | \n",
" Non | \n",
" 0 | \n",
" drs_technical_isresidencefiscalemailsent | \n",
"
\n",
" \n",
" 8 | \n",
" Oui | \n",
" 1 | \n",
" drs_technical_isresidencefiscalemailsent | \n",
"
\n",
" \n",
" 9 | \n",
" Non | \n",
" 0 | \n",
" drs_technical_istaskcreatedafter67days | \n",
"
\n",
" \n",
"
\n",
"
"
],
"text/plain": [
" OptionName OptionValue \\\n",
"0 Analysé, sans autocertification 3 \n",
"1 Typologie à clarifier 804170002 \n",
"2 Client existant à clarifier (Typologie) 7 \n",
"3 Client existant à autocertifier (ADE) 8 \n",
"4 Changement de circonstance à autocertifier (ADE) 9 \n",
"5 Non 0 \n",
"6 Oui 1 \n",
"7 Non 0 \n",
"8 Oui 1 \n",
"9 Non 0 \n",
"\n",
" FieldName \n",
"0 drs_statutautocertification \n",
"1 drs_statutdyn365 \n",
"2 drs_contexteprocessdyn365 \n",
"3 drs_contexteprocessdyn365 \n",
"4 drs_contexteprocessdyn365 \n",
"5 drs_technical_isemailsent \n",
"6 drs_technical_isemailsent \n",
"7 drs_technical_isresidencefiscalemailsent \n",
"8 drs_technical_isresidencefiscalemailsent \n",
"9 drs_technical_istaskcreatedafter67days "
]
},
"execution_count": 22,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"optionset_mapping = pd.read_excel(\"Contact_OptionSetsMapping.xlsx\")\n",
"optionset_mapping.head(10)"
]
},
{
"cell_type": "code",
"execution_count": 23,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.481708Z",
"start_time": "2021-04-05T16:55:11.394951Z"
}
},
"outputs": [],
"source": [
"# Create an unique value merging the technical name of a field and its option set value.\n",
"optionset_mapping['MapValue'] = optionset_mapping.FieldName + \"###\" + optionset_mapping.OptionValue.astype(str)\n",
"\n",
"# Transform it into a Dictionary\n",
"optionSetMaskDict = pd.Series(optionset_mapping.OptionName.values,index=optionset_mapping.MapValue).to_dict()"
]
},
{
"cell_type": "code",
"execution_count": 24,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.500466Z",
"start_time": "2021-04-05T16:55:11.485290Z"
}
},
"outputs": [],
"source": [
"# Create the same unique value by merging field technical name and its option set value. \n",
"all_data['OptionSetMapping'] = all_data.Field.astype(str) + \"###\" + all_data['Old Value'].astype(str)\n",
"\n",
"# Mapping\n",
"all_data[\"OldValueOptionSetName\"] = all_data.OptionSetMapping.map(optionSetMaskDict)"
]
},
{
"cell_type": "code",
"execution_count": 25,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.515681Z",
"start_time": "2021-04-05T16:55:11.503460Z"
}
},
"outputs": [],
"source": [
"# If the field is an Option Set, display the label into 'Old Value' column\n",
"all_data['Old Value'] = np.where(all_data.OldValueOptionSetName.isnull(), all_data['Old Value'], all_data.OldValueOptionSetName)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Final data\n",
"\n",
"After all this transformations, the data looks like:"
]
},
{
"cell_type": "code",
"execution_count": 26,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:11.565849Z",
"start_time": "2021-04-05T16:55:11.520695Z"
},
"scrolled": false
},
"outputs": [
{
"data": {
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" CreatedOn | \n",
" User | \n",
" Event | \n",
" Field | \n",
" Old Value | \n",
" ObjectId | \n",
"
\n",
" \n",
" \n",
" \n",
" 0 | \n",
" 2020-11-05 08:23:42.000000 | \n",
" Lloyd Sebag | \n",
" Create | \n",
" dada_webregistration | \n",
" OnCreation | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
"
\n",
" \n",
" 1 | \n",
" 2020-11-05 08:23:42.000000 | \n",
" Lloyd Sebag | \n",
" Create | \n",
" drs_donationaudernierdesvivants | \n",
" OnCreation | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
"
\n",
" \n",
" 2 | \n",
" 2020-11-05 08:23:42.000000 | \n",
" Lloyd Sebag | \n",
" Create | \n",
" drs_nepasanonymiser | \n",
" OnCreation | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
"
\n",
" \n",
" 3 | \n",
" 2020-11-05 08:23:42.000000 | \n",
" Lloyd Sebag | \n",
" Create | \n",
" drs_isepargnecadeau | \n",
" OnCreation | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
"
\n",
" \n",
" 4 | \n",
" 2020-11-05 08:23:42.000000 | \n",
" Lloyd Sebag | \n",
" Create | \n",
" drs_imprimercourrierinitial | \n",
" OnCreation | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
"
\n",
" \n",
" ... | \n",
" ... | \n",
" ... | \n",
" ... | \n",
" ... | \n",
" ... | \n",
" ... | \n",
"
\n",
" \n",
" 617 | \n",
" 2021-04-05 18:55:11.203439 | \n",
" None | \n",
" CURRENT_VALUE | \n",
" drs_xxcomplment | \n",
" None | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
"
\n",
" \n",
" 618 | \n",
" 2021-04-05 18:55:11.203439 | \n",
" None | \n",
" CURRENT_VALUE | \n",
" drs_dyn365statutcode | \n",
" None | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
"
\n",
" \n",
" 619 | \n",
" 2021-04-05 18:55:11.203439 | \n",
" None | \n",
" CURRENT_VALUE | \n",
" address1_latitude | \n",
" None | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
"
\n",
" \n",
" 620 | \n",
" 2021-04-05 18:55:11.203439 | \n",
" None | \n",
" CURRENT_VALUE | \n",
" drs_dyn365ventescumules | \n",
" None | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
"
\n",
" \n",
" 621 | \n",
" 2021-04-05 18:55:11.203439 | \n",
" None | \n",
" CURRENT_VALUE | \n",
" drs_origin | \n",
" None | \n",
" BB254F36-401F-EB11-814A-005056953107 | \n",
"
\n",
" \n",
"
\n",
"
870 rows × 6 columns
\n",
"
"
],
"text/plain": [
" CreatedOn User Event \\\n",
"0 2020-11-05 08:23:42.000000 Lloyd Sebag Create \n",
"1 2020-11-05 08:23:42.000000 Lloyd Sebag Create \n",
"2 2020-11-05 08:23:42.000000 Lloyd Sebag Create \n",
"3 2020-11-05 08:23:42.000000 Lloyd Sebag Create \n",
"4 2020-11-05 08:23:42.000000 Lloyd Sebag Create \n",
".. ... ... ... \n",
"617 2021-04-05 18:55:11.203439 None CURRENT_VALUE \n",
"618 2021-04-05 18:55:11.203439 None CURRENT_VALUE \n",
"619 2021-04-05 18:55:11.203439 None CURRENT_VALUE \n",
"620 2021-04-05 18:55:11.203439 None CURRENT_VALUE \n",
"621 2021-04-05 18:55:11.203439 None CURRENT_VALUE \n",
"\n",
" Field Old Value \\\n",
"0 dada_webregistration OnCreation \n",
"1 drs_donationaudernierdesvivants OnCreation \n",
"2 drs_nepasanonymiser OnCreation \n",
"3 drs_isepargnecadeau OnCreation \n",
"4 drs_imprimercourrierinitial OnCreation \n",
".. ... ... \n",
"617 drs_xxcomplment None \n",
"618 drs_dyn365statutcode None \n",
"619 address1_latitude None \n",
"620 drs_dyn365ventescumules None \n",
"621 drs_origin None \n",
"\n",
" ObjectId \n",
"0 BB254F36-401F-EB11-814A-005056953107 \n",
"1 BB254F36-401F-EB11-814A-005056953107 \n",
"2 BB254F36-401F-EB11-814A-005056953107 \n",
"3 BB254F36-401F-EB11-814A-005056953107 \n",
"4 BB254F36-401F-EB11-814A-005056953107 \n",
".. ... \n",
"617 BB254F36-401F-EB11-814A-005056953107 \n",
"618 BB254F36-401F-EB11-814A-005056953107 \n",
"619 BB254F36-401F-EB11-814A-005056953107 \n",
"620 BB254F36-401F-EB11-814A-005056953107 \n",
"621 BB254F36-401F-EB11-814A-005056953107 \n",
"\n",
"[870 rows x 6 columns]"
]
},
"execution_count": 26,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"all_data[[\"CreatedOn\",\"User\",\"Event\",\"Field\",\"Old Value\",\"ObjectId\"]]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"As you can see, each rows contains the event, the data, the user who performed the action, which field is concerned by the change and the previous field value."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Add Display Name\n",
"\n",
"One final data mapping we can do is to use the display name of every field instead of the technical name."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"```sql\n",
"SELECT ar.name,ar.ColumnNumber,ll.Label\n",
"FROM MetadataSchema.Attribute ar INNER JOIN \n",
" MetadataSchema.Entity en ON ar.EntityId = en.EntityId INNER JOIN\n",
" LocalizedLabelView ll ON ll.ObjectId = ar.AttributeId\n",
"WHERE en.ObjectTypeCode=2 and ll.Label != '' and LanguageId = 1036\n",
"``` "
]
},
{
"cell_type": "code",
"execution_count": 27,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:12.118141Z",
"start_time": "2021-04-05T16:55:11.570088Z"
}
},
"outputs": [],
"source": [
"contact_DisplayName = pd.read_excel(\"Contact_AttributeMask_WithDisplayName.xlsx\")"
]
},
{
"cell_type": "code",
"execution_count": 28,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:12.144865Z",
"start_time": "2021-04-05T16:55:12.123288Z"
}
},
"outputs": [],
"source": [
"# Mapping\n",
"displayNameMaskeDict = pd.Series(contact_DisplayName.Label.values,index=contact_DisplayName.ColumnNumber.apply(str)).to_dict()\n",
"all_data[\"Field Display Name\"] = all_data.v1.map(displayNameMaskeDict)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Retrieving the new value\n",
"\n",
"Now that we have all our data ready, we can make the final step : add the 'New Value' column !"
]
},
{
"cell_type": "code",
"execution_count": 29,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:12.168658Z",
"start_time": "2021-04-05T16:55:12.150267Z"
}
},
"outputs": [],
"source": [
"# Use only the columns of interest\n",
"final_data = all_data[[\"CreatedOn\",\"User\",\"Event\",\"Field Display Name\",\"Old Value\",\"ObjectId\"]].sort_values('CreatedOn',ascending=False)\n",
"\n",
"# Create the 'New Value' columns thanks to the shift method !\n",
"final_data[\"New Value\"] = final_data.groupby(['ObjectId','Field Display Name'])['Old Value'].shift()"
]
},
{
"cell_type": "code",
"execution_count": 30,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:12.185196Z",
"start_time": "2021-04-05T16:55:12.173571Z"
},
"scrolled": false
},
"outputs": [],
"source": [
"# Remove the current value rows, since we only use it for the 'New Value' column of last change per field\n",
"final_data = final_data[final_data['Event'] != \"CURRENT_VALUE\"]"
]
},
{
"cell_type": "code",
"execution_count": 31,
"metadata": {
"ExecuteTime": {
"end_time": "2021-04-05T16:55:12.218668Z",
"start_time": "2021-04-05T16:55:12.189739Z"
},
"scrolled": false
},
"outputs": [
{
"data": {
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" CreatedOn | \n",
" User | \n",
" Event | \n",
" Field Display Name | \n",
" Old Value | \n",
" New Value | \n",
"
\n",
" \n",
" \n",
" \n",
" 247 | \n",
" 2021-03-31 10:05:16 | \n",
" Danny Rodrigues Alves | \n",
" Update | \n",
" Statut MEC | \n",
" Non auto-déclaré | \n",
" Non auto-déclaré | \n",
"
\n",
" \n",
" 246 | \n",
" 2021-03-31 10:05:16 | \n",
" Danny Rodrigues Alves | \n",
" Update | \n",
" Email DadaMail | \n",
" danny@gmail.com | \n",
" danny@hotmail.com | \n",
"
\n",
" \n",
" 245 | \n",
" 2021-03-31 10:05:11 | \n",
" Danny Rodrigues Alves | \n",
" Update | \n",
" Statut MEC | \n",
" Non auto-déclaré | \n",
" Non auto-déclaré | \n",
"
\n",
" \n",
" 244 | \n",
" 2021-03-31 10:05:11 | \n",
" Danny Rodrigues Alves | \n",
" Update | \n",
" Email DadaMail | \n",
" | \n",
" danny@gmail.com | \n",
"
\n",
" \n",
" 243 | \n",
" 2021-03-25 15:55:12 | \n",
" Danny Rodrigues Alves | \n",
" Update | \n",
" Statut MEC | \n",
" Non auto-déclaré | \n",
" Non auto-déclaré | \n",
"
\n",
" \n",
"
\n",
"
"
],
"text/plain": [
" CreatedOn User Event Field Display Name \\\n",
"247 2021-03-31 10:05:16 Danny Rodrigues Alves Update Statut MEC \n",
"246 2021-03-31 10:05:16 Danny Rodrigues Alves Update Email DadaMail \n",
"245 2021-03-31 10:05:11 Danny Rodrigues Alves Update Statut MEC \n",
"244 2021-03-31 10:05:11 Danny Rodrigues Alves Update Email DadaMail \n",
"243 2021-03-25 15:55:12 Danny Rodrigues Alves Update Statut MEC \n",
"\n",
" Old Value New Value \n",
"247 Non auto-déclaré Non auto-déclaré \n",
"246 danny@gmail.com danny@hotmail.com \n",
"245 Non auto-déclaré Non auto-déclaré \n",
"244 danny@gmail.com \n",
"243 Non auto-déclaré Non auto-déclaré "
]
},
"execution_count": 31,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"final_data.head(5)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"If we compare it to the Audit from Dynamics 365, we can see that it's identical !\n",
"\n",
"![AuditFromDynamics.png](AuditFromDynamics.png)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.5"
}
},
"nbformat": 4,
"nbformat_minor": 4
}