diff --git a/evap/development/fixtures/test_data.json b/evap/development/fixtures/test_data.json index f7f516c288..c4e69700c2 100644 --- a/evap/development/fixtures/test_data.json +++ b/evap/development/fixtures/test_data.json @@ -764,6 +764,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -782,6 +783,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -800,6 +802,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -818,6 +821,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -836,6 +840,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -854,6 +859,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -872,6 +878,7 @@ "type": 4, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -890,6 +897,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -908,6 +916,7 @@ "type": 2, "is_private": true, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -926,6 +935,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -944,6 +954,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -962,6 +973,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -980,6 +992,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -998,6 +1011,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1016,6 +1030,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1034,6 +1049,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1052,6 +1068,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1070,6 +1087,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1088,6 +1106,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1106,6 +1125,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1124,6 +1144,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1142,6 +1163,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1160,6 +1182,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1178,6 +1201,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1196,6 +1220,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1214,6 +1239,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1232,6 +1258,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1250,6 +1277,7 @@ "type": 5, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 3 ], @@ -1268,6 +1296,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1286,6 +1315,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1304,6 +1334,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1322,6 +1353,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1340,6 +1372,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1358,6 +1391,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1376,6 +1410,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1394,6 +1429,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1412,6 +1448,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1430,6 +1467,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1448,6 +1486,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1466,6 +1505,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1484,6 +1524,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1502,6 +1543,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1520,6 +1562,7 @@ "type": 5, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1538,6 +1581,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1556,6 +1600,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1574,6 +1619,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1592,6 +1638,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1610,6 +1657,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1628,6 +1676,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1646,6 +1695,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1664,6 +1714,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1682,6 +1733,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1700,6 +1752,7 @@ "type": 4, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1718,6 +1771,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1736,6 +1790,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1754,6 +1809,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1772,6 +1828,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1790,6 +1847,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1808,6 +1866,7 @@ "type": 5, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1826,6 +1885,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1844,6 +1904,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1862,6 +1923,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1880,6 +1942,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1898,6 +1961,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1916,6 +1980,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -1934,6 +1999,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1952,6 +2018,7 @@ "type": 4, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1970,6 +2037,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -1988,6 +2056,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2006,6 +2075,7 @@ "type": 5, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2024,6 +2094,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2042,6 +2113,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2060,6 +2132,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2078,6 +2151,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2096,6 +2170,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2114,6 +2189,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2132,6 +2208,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2150,6 +2227,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2168,6 +2246,7 @@ "type": 5, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2186,6 +2265,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2204,6 +2284,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2222,6 +2303,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2240,6 +2322,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2258,6 +2341,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": true, + "cms_id": null, "programs": [ 1 ], @@ -2276,6 +2360,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2294,6 +2379,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2312,6 +2398,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2330,6 +2417,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2348,6 +2436,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2366,6 +2455,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2384,6 +2474,7 @@ "type": 4, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2402,6 +2493,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2420,6 +2512,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2438,6 +2531,7 @@ "type": 4, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2456,6 +2550,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2474,6 +2569,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2492,6 +2588,7 @@ "type": 5, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2510,6 +2607,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2528,6 +2626,7 @@ "type": 5, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2546,6 +2645,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2564,6 +2664,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2582,6 +2683,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2600,6 +2702,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2618,6 +2721,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2636,6 +2740,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2654,6 +2759,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2672,6 +2778,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2690,6 +2797,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2708,6 +2816,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2726,6 +2835,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2744,6 +2854,7 @@ "type": 5, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2762,6 +2873,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2780,6 +2892,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2798,6 +2911,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2816,6 +2930,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2834,6 +2949,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2852,6 +2968,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2870,6 +2987,7 @@ "type": 4, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2888,6 +3006,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2906,6 +3025,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2924,6 +3044,7 @@ "type": 4, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2942,6 +3063,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2960,6 +3082,7 @@ "type": 5, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -2978,6 +3101,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -2996,6 +3120,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -3014,6 +3139,7 @@ "type": 1, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -3032,6 +3158,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -3050,6 +3177,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -3068,6 +3196,7 @@ "type": 5, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -3086,6 +3215,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 3 ], @@ -3104,6 +3234,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -3122,6 +3253,7 @@ "type": 4, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -3140,6 +3272,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -3158,6 +3291,7 @@ "type": 3, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -3176,6 +3310,7 @@ "type": 2, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 1 ], @@ -3194,6 +3329,7 @@ "type": 4, "is_private": false, "gets_no_grade_documents": false, + "cms_id": null, "programs": [ 2 ], @@ -3221,6 +3357,7 @@ "vote_end_date": "2024-06-02", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 143, 137, @@ -3258,6 +3395,7 @@ "vote_end_date": "2022-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 270, 234, @@ -3297,6 +3435,7 @@ "vote_end_date": "2022-02-08", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 270, 234, @@ -3330,6 +3469,7 @@ "vote_end_date": "2022-03-01", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 310, 324, @@ -3374,6 +3514,7 @@ "vote_end_date": "2022-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 310, 240, @@ -3427,6 +3568,7 @@ "vote_end_date": "2022-03-18", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 235, 31, @@ -3468,6 +3610,7 @@ "vote_end_date": "2022-03-07", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 250, 223, @@ -3508,6 +3651,7 @@ "vote_end_date": "2022-03-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 254, 239, @@ -3537,6 +3681,7 @@ "vote_end_date": "2022-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 250, 247, @@ -3633,6 +3778,7 @@ "vote_end_date": "2022-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 235, 924, @@ -3673,6 +3819,7 @@ "vote_end_date": "2022-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 293, 254, @@ -3726,6 +3873,7 @@ "vote_end_date": "2024-05-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 21, 74, @@ -3772,6 +3920,7 @@ "vote_end_date": "2024-05-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 447, 472, @@ -3814,6 +3963,7 @@ "vote_end_date": "2022-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 247, 924, @@ -3853,6 +4003,7 @@ "vote_end_date": "2022-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 378, 393, @@ -3974,6 +4125,7 @@ "vote_end_date": "2022-02-09", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 337, 516, @@ -4113,6 +4265,7 @@ "vote_end_date": "2022-04-26", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 355, 438, @@ -4188,6 +4341,7 @@ "vote_end_date": "2022-04-04", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 416, 517, @@ -4225,6 +4379,7 @@ "vote_end_date": "2022-02-08", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 339, 355, @@ -4370,6 +4525,7 @@ "vote_end_date": "2022-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 339, 355, @@ -4523,6 +4679,7 @@ "vote_end_date": "2022-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 488, 543, @@ -4574,6 +4731,7 @@ "vote_end_date": "2022-01-23", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 55, 582, @@ -4609,6 +4767,7 @@ "vote_end_date": "2022-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 337, 516, @@ -4752,6 +4911,7 @@ "vote_end_date": "2022-07-29", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 337, 339, @@ -4878,6 +5038,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 337, 516, @@ -5017,6 +5178,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 378, 423, @@ -5182,6 +5344,7 @@ "vote_end_date": "2022-07-11", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 339, 355, @@ -5317,6 +5480,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 438, 368, @@ -5378,6 +5542,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 378, 426, @@ -5423,6 +5588,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 372, 344, @@ -5462,6 +5628,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 293, 558, @@ -5518,6 +5685,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 355, 519, @@ -5584,6 +5752,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 246, 322, @@ -5631,6 +5800,7 @@ "vote_end_date": "2022-07-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 222, 282, @@ -5684,6 +5854,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 250, 310, @@ -5747,6 +5918,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 640, 232, @@ -5782,6 +5954,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 240, 638, @@ -5825,6 +5998,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 270, 234, @@ -5880,6 +6054,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 461, 548, @@ -5919,6 +6094,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 74, 632, @@ -5946,6 +6122,7 @@ "vote_end_date": "2022-07-19", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 245, 260, @@ -5983,6 +6160,7 @@ "vote_end_date": "2022-07-19", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 512, 382, @@ -6014,6 +6192,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 237, 332, @@ -6050,6 +6229,7 @@ "vote_end_date": "2022-07-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 472, 811, @@ -6084,6 +6264,7 @@ "vote_end_date": "2022-08-24", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 293, 237, @@ -6128,6 +6309,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 337, 516, @@ -6248,6 +6430,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 500, 425 @@ -6276,6 +6459,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 461, 528, @@ -6322,6 +6506,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 519, 548, @@ -6354,6 +6539,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 459, 365, @@ -6464,6 +6650,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 477, 368, @@ -6498,6 +6685,7 @@ "vote_end_date": "2023-04-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 337, 365, @@ -6560,6 +6748,7 @@ "vote_end_date": "2023-02-17", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 560, 470, @@ -6613,6 +6802,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 541, 381, @@ -6657,6 +6847,7 @@ "vote_end_date": "2023-02-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 667, 337, @@ -6789,6 +6980,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 667, 732, @@ -6934,6 +7126,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 393, 755, @@ -6985,6 +7178,7 @@ "vote_end_date": "2099-09-18", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 293, 263, @@ -7017,6 +7211,7 @@ "vote_end_date": "2024-08-31", "allow_editors_to_edit": false, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 31, 477, @@ -7049,6 +7244,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 225, 754, @@ -7084,6 +7280,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 233, 270, @@ -7126,6 +7323,7 @@ "vote_end_date": "2024-05-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 270, 324, @@ -7157,6 +7355,7 @@ "vote_end_date": "2023-02-17", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 223, 625, @@ -7198,6 +7397,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 627, 295, @@ -7226,6 +7426,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 316, 336, @@ -7256,6 +7457,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 233, 293, @@ -7316,6 +7518,7 @@ "vote_end_date": "2023-02-28", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 310, 275, @@ -7355,6 +7558,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 293, 517, @@ -7415,6 +7619,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 443, 293, @@ -7520,6 +7725,7 @@ "vote_end_date": "2023-03-20", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 517, 324, @@ -7566,6 +7772,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 443, 270, @@ -7646,6 +7853,7 @@ "vote_end_date": "2099-12-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 639, 323, @@ -7675,6 +7883,7 @@ "vote_end_date": "2023-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 270, 446, @@ -7738,6 +7947,7 @@ "vote_end_date": "2023-03-03", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 293, 263, @@ -7809,6 +8019,7 @@ "vote_end_date": "2023-07-04", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 455, 459, @@ -7863,6 +8074,7 @@ "vote_end_date": "2023-09-22", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 337, 365, @@ -7899,6 +8111,7 @@ "vote_end_date": "2099-08-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 494, 253, @@ -7935,6 +8148,7 @@ "vote_end_date": "2023-07-21", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 732, 739, @@ -8080,6 +8294,7 @@ "vote_end_date": "2023-09-30", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 426, 372, @@ -8112,6 +8327,7 @@ "vote_end_date": "2023-07-21", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 426, 322, @@ -8161,6 +8377,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 337, 455, @@ -8204,6 +8421,7 @@ "vote_end_date": "2023-09-15", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 536, 305 @@ -8233,6 +8451,7 @@ "vote_end_date": "2023-08-23", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 443, 233, @@ -8295,6 +8514,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 355, 564, @@ -8329,6 +8549,7 @@ "vote_end_date": "2024-05-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 637, 625, @@ -8357,6 +8578,7 @@ "vote_end_date": "2023-07-18", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 667, 337, @@ -8515,6 +8737,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 443, 755, @@ -8561,6 +8784,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 667, 732, @@ -8697,6 +8921,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 694, 748, @@ -8760,6 +8985,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 459, 424, @@ -8798,6 +9024,7 @@ "vote_end_date": "2024-05-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 293, 393, @@ -8858,6 +9085,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 365, 429, @@ -8898,6 +9126,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 743, 666, @@ -8934,6 +9163,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 344, 484, @@ -8964,6 +9194,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 338, 324, @@ -8992,6 +9223,7 @@ "vote_end_date": "2023-07-29", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 625, 311, @@ -9028,6 +9260,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 496, 381, @@ -9065,6 +9298,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 467, 547, @@ -9098,6 +9332,7 @@ "vote_end_date": "2024-05-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 446, 427, @@ -9136,6 +9371,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 658 ], @@ -9161,6 +9397,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 459, 505, @@ -9195,6 +9432,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 755, 637, @@ -9242,6 +9480,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 473, 819 @@ -9268,6 +9507,7 @@ "vote_end_date": "2099-12-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 354, 452 @@ -9294,6 +9534,7 @@ "vote_end_date": "2023-07-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 293, 223, @@ -9332,6 +9573,7 @@ "vote_end_date": "2024-04-06", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 446, 754, @@ -9362,6 +9604,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 863, 461, @@ -9392,6 +9635,7 @@ "vote_end_date": "2024-04-06", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 338, 494, @@ -9423,6 +9667,7 @@ "vote_end_date": "2099-05-08", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 452, 505, @@ -9450,6 +9695,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 459, 684, @@ -9492,6 +9738,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 443, 560, @@ -9526,6 +9773,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 354, 449, @@ -9554,6 +9802,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 378, 368, @@ -9588,6 +9837,7 @@ "vote_end_date": "2024-02-16", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 378, 368, @@ -9624,6 +9874,7 @@ "vote_end_date": "2024-04-06", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 640, 536, @@ -9653,6 +9904,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 732, 739, @@ -9733,6 +9985,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 455, 523, @@ -9761,6 +10014,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 372, 328, @@ -9791,6 +10045,7 @@ "vote_end_date": "2024-04-06", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 426, 372, @@ -9825,6 +10080,7 @@ "vote_end_date": "2024-02-16", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 381, 536, @@ -9861,6 +10117,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 678, 734, @@ -9917,6 +10174,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 378, 434, @@ -9973,6 +10231,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 414, 421, @@ -10003,6 +10262,7 @@ "vote_end_date": "2099-12-31", "allow_editors_to_edit": false, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 459, 412, @@ -10040,6 +10300,7 @@ "vote_end_date": "2024-02-05", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 846, 851, @@ -10200,6 +10461,7 @@ "vote_end_date": "2099-12-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 528, 386, @@ -10232,6 +10494,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 503, 456, @@ -10261,6 +10524,7 @@ "vote_end_date": "2024-03-16", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 378, 379, @@ -10315,6 +10579,7 @@ "vote_end_date": "2024-02-07", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 667, 732, @@ -10449,6 +10714,7 @@ "vote_end_date": "2024-08-31", "allow_editors_to_edit": false, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 406, 773, @@ -10480,6 +10746,7 @@ "vote_end_date": "2099-12-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 640, 354, @@ -10510,6 +10777,7 @@ "vote_end_date": "2024-02-16", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 443, 434, @@ -10600,6 +10868,7 @@ "vote_end_date": "2024-02-12", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 519, 560, @@ -10651,6 +10920,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 732, 337, @@ -10784,6 +11054,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 739, 707, @@ -10826,6 +11097,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": false, + "cms_id": null, "participants": [ 519, 560, @@ -10895,6 +11167,7 @@ "vote_end_date": "2024-02-10", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 846, 851, @@ -11040,6 +11313,7 @@ "vote_end_date": "2024-04-13", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 732, 716, @@ -11122,6 +11396,7 @@ "vote_end_date": "2024-02-14", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 846, 851, @@ -11269,6 +11544,7 @@ "vote_end_date": "2024-08-31", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [ 729, 452, @@ -11300,6 +11576,7 @@ "vote_end_date": "2023-11-01", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": false, + "cms_id": null, "participants": [], "voters": [] } @@ -11323,6 +11600,7 @@ "vote_end_date": "2023-10-01", "allow_editors_to_edit": false, "wait_for_grade_upload_before_publishing": false, + "cms_id": null, "participants": [], "voters": [] } @@ -11346,6 +11624,7 @@ "vote_end_date": "2024-06-20", "allow_editors_to_edit": true, "wait_for_grade_upload_before_publishing": true, + "cms_id": null, "participants": [], "voters": [] } diff --git a/evap/evaluation/management/commands/tools.py b/evap/evaluation/management/commands/tools.py index 80af96a89d..c8d87c4334 100644 --- a/evap/evaluation/management/commands/tools.py +++ b/evap/evaluation/management/commands/tools.py @@ -36,7 +36,7 @@ def log_exceptions(cls): class NewClass(cls): def handle(self, *args, **options): try: - super().handle(args, options) + super().handle(*args, **options) except Exception: logger.exception("Management command '%s' failed. Traceback follows: ", sys.argv[1]) raise diff --git a/evap/evaluation/migrations/0148_course_cms_id_evaluation_cms_id.py b/evap/evaluation/migrations/0148_course_cms_id_evaluation_cms_id.py new file mode 100644 index 0000000000..276eed9fd1 --- /dev/null +++ b/evap/evaluation/migrations/0148_course_cms_id_evaluation_cms_id.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.4 on 2024-05-13 20:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("evaluation", "0147_unusable_password_default"), + ] + + operations = [ + migrations.AddField( + model_name="course", + name="cms_id", + field=models.CharField( + blank=True, max_length=255, null=True, unique=True, verbose_name="campus management system id" + ), + ), + migrations.AddField( + model_name="evaluation", + name="cms_id", + field=models.CharField( + blank=True, max_length=255, null=True, unique=True, verbose_name="campus management system id" + ), + ), + ] diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index 42cb7887eb..8fecb59ea4 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -334,6 +334,11 @@ class Course(LoggedModel): # grade publishers can set this to True, then the course will be handled as if final grades have already been uploaded gets_no_grade_documents = models.BooleanField(verbose_name=_("gets no grade documents"), default=False) + # unique reference for import from campus management system + cms_id = models.CharField( + verbose_name=_("campus management system id"), blank=True, null=True, unique=True, max_length=255 + ) + class Meta: unique_together = [ ["semester", "name_de"], @@ -471,6 +476,11 @@ class State(models.IntegerChoices): verbose_name=_("wait for grade upload before publishing"), default=True ) + # unique reference for import from campus management system + cms_id = models.CharField( + verbose_name=_("campus management system id"), blank=True, null=True, unique=True, max_length=255 + ) + @property def has_exam_evaluation(self): return self.course.evaluations.filter(name_de="Klausur", name_en="Exam").exists() diff --git a/evap/settings.py b/evap/settings.py index b921c36327..a2aad495f7 100644 --- a/evap/settings.py +++ b/evap/settings.py @@ -113,6 +113,12 @@ # Questionnaires automatically added to exam evaluations EXAM_QUESTIONNAIRE_IDS: list[int] = [] +# Emails of users that shouldn't be imported as responsibles during JSON import +NON_RESPONSIBLE_USERS: set[str] = set() + +# Study programs that shouldn't be imported during JSON import +IGNORE_PROGRAMS: set[str] = set() + ### Installation specific settings # People who get emails on errors. diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 01e8369397..6cb100f3d8 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -311,6 +311,7 @@ def __init__(self, data=None, *, instance: Course): "_voter_count", "voters", "votetimestamp", + "cms_id", } CONTRIBUTION_COPIED_FIELDS = { diff --git a/evap/staff/importers/json.py b/evap/staff/importers/json.py new file mode 100644 index 0000000000..fdf88ec9bf --- /dev/null +++ b/evap/staff/importers/json.py @@ -0,0 +1,501 @@ +import json +import logging +from dataclasses import dataclass, field +from datetime import date, datetime +from datetime import time as datetime_time +from datetime import timedelta +from typing import Any, NotRequired, TypedDict + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.db import transaction +from django.utils.timezone import now + +from evap.evaluation.models import Contribution, Course, CourseType, Evaluation, Program, Semester, UserProfile +from evap.evaluation.tools import clean_email +from evap.staff.tools import update_m2m_with_changes, update_or_create_with_changes, update_with_changes + +logger = logging.getLogger(__name__) + + +class ImportStudent(TypedDict): + gguid: str + email: str + name: str # last name + christianname: str # first name + + +class ImportLecturer(TypedDict): + gguid: str + email: str + name: str # last name + christianname: str # first name + titlefront: str # title + + +class ImportCourse(TypedDict): + cprid: str # name of the "course program" (name used by the CMS) -> mapped to a Program + scale: str # defines which grading system is used; we interpret any value as graded, ungraded if empty or missing + + +class ImportRelated(TypedDict): + """A related data object represented by its gguid.""" + + gguid: str + + +class ImportAppointment(TypedDict): + begin: str + end: str + + +class ImportEvent(TypedDict): + """An event can be a teaching course or exam course that we import together as a course with two evaluations.""" + + gguid: str + title: str + title_en: str + type: str # name of course type + isexam: bool # exam course? + courses: NotRequired[list[ImportCourse]] # programs + relatedevents: NotRequired[list[ImportRelated]] # related events are usually the respective teaching/exam course + appointments: NotRequired[list[ImportAppointment]] + lecturers: NotRequired[list[ImportRelated]] + students: NotRequired[list[ImportRelated]] + + +class ImportDict(TypedDict): + students: list[ImportStudent] + lecturers: list[ImportLecturer] + events: list[ImportEvent] + + +@dataclass +class NameChange: + old_last_name: str + old_first_name_given: str + new_last_name: str + new_first_name_given: str + email: str | None + + +@dataclass +class WarningMessage: + obj: str + message: str + + +@dataclass +class ImportStatistics: + name_changes: list[NameChange] = field(default_factory=list) + new_courses: list[Course] = field(default_factory=list) + new_evaluations: list[Evaluation] = field(default_factory=list) + updated_courses: list[Course] = field(default_factory=list) + updated_evaluations: list[Evaluation] = field(default_factory=list) + attempted_changes: list[Evaluation] = field(default_factory=list) + warnings: list[WarningMessage] = field(default_factory=list) + + @staticmethod + def _make_heading(heading: str, separator: str = "-") -> str: + return f"{heading}\n{separator * len(heading)}\n" + + @staticmethod + def _make_total(total: int) -> str: + return f"({total} in total)\n\n" + + @staticmethod + def _make_stats(heading: str, objects: list) -> str: + log = ImportStatistics._make_heading(heading) + log += ImportStatistics._make_total(len(objects)) + for obj in objects: + log += f"- {obj}\n" + log += "\n" + return log + + def get_log(self) -> str: + log = self._make_heading("JSON IMPORTER REPORT", "=") + log += "\n" + log += f"Import finished at {now()}\n\n" + + log += self._make_heading("Name Changes") + log += self._make_total(len(self.name_changes)) + for name_change in self.name_changes: + log += f"- {name_change.old_first_name_given} {name_change.old_last_name} → {name_change.new_first_name_given} {name_change.new_last_name} (email: {name_change.email})\n" + + log += self._make_stats("New Courses", self.new_courses) + log += self._make_stats("New Evaluations", self.new_evaluations) + log += self._make_stats("Updated Courses", self.updated_courses) + log += self._make_stats("Updated Evaluations", self.updated_evaluations) + log += self._make_stats("Attempted Changes", self.attempted_changes) + + log += self._make_heading("Warnings") + log += self._make_total(len(self.warnings)) + for warning in self.warnings: + log += f"- {warning.obj}: {warning.message}\n" + + return log + + def send_mail(self): + subject = "[EvaP] JSON importer log" + + managers = UserProfile.objects.filter(groups__name="Manager", email__isnull=False) + if not managers: + return + mail = EmailMultiAlternatives( + subject, + self.get_log(), + settings.SERVER_EMAIL, + [manager.email for manager in managers], + ) + mail.send() + + +class JSONImporter: + DATETIME_FORMAT = "%d.%m.%Y %H:%M:%S" + MIDNIGHT = datetime_time() + + def __init__(self, semester: Semester, default_course_end: date) -> None: + self.semester = semester + self.default_course_end = default_course_end + self.users_by_gguid: dict[str, UserProfile] = {} + self.course_type_cache: dict[str, CourseType] = { + import_name.strip().lower(): course_type + for course_type in CourseType.objects.all() + for import_name in course_type.import_names + } + self.program_cache: dict[str, Program] = { + import_name.strip().lower(): program + for program in Program.objects.all() + for import_name in program.import_names + } + self.courses_by_gguid: dict[str, Course] = {} + self.statistics = ImportStatistics() + + def _get_users_with_longest_title(self, user_profiles: list[UserProfile]) -> list[UserProfile]: + max_title_len = max((len(user.title) for user in user_profiles), default=0) + return [user for user in user_profiles if len(user.title) == max_title_len] + + def _remove_non_responsible_users(self, user_profiles: list[UserProfile]) -> list[UserProfile]: + return list(filter(lambda p: p.email not in settings.NON_RESPONSIBLE_USERS, user_profiles)) + + def _get_course_type(self, name: str) -> CourseType: + lookup = name.strip().lower() + if lookup in self.course_type_cache: + return self.course_type_cache[lookup] + + # It could happen that the importer needs a new course type + course_type, __ = CourseType.objects.get_or_create(name_de=name, defaults={"name_en": name}) + self.course_type_cache[name] = course_type + return course_type + + def _get_program(self, name: str) -> Program: + lookup = name.strip().lower() + if lookup in self.program_cache: + return self.program_cache[lookup] + + # It could happen that the importer needs a new program + program, __ = Program.objects.get_or_create(name_de=name, defaults={"name_en": name}) + self.program_cache[name] = program + return program + + def _get_user_profiles(self, data: list[ImportRelated]) -> list[UserProfile]: + # as we skip probably some user profiles during import, they might not exist + return [self.users_by_gguid[related["gguid"]] for related in data if related["gguid"] in self.users_by_gguid] + + def _create_name_change_from_changes(self, user_profile: UserProfile, changes: dict[str, tuple[Any, Any]]) -> None: + change = NameChange( + old_last_name=changes["last_name"][0] if changes.get("last_name") else user_profile.last_name, + old_first_name_given=( + changes["first_name_given"][0] if changes.get("first_name_given") else user_profile.first_name_given + ), + new_last_name=user_profile.last_name, + new_first_name_given=user_profile.first_name_given, + email=user_profile.email, + ) + self.statistics.name_changes.append(change) + + def _import_students(self, data: list[ImportStudent]) -> None: + for entry in data: + email = clean_email(entry["email"]) + if not email: + self.statistics.warnings.append( + WarningMessage(obj=f"Student {entry['christianname']} {entry['name']}", message="No email defined") + ) + else: + user_profile, __, changes = update_or_create_with_changes( + UserProfile, + email=email, + defaults={"last_name": entry["name"], "first_name_given": entry["christianname"]}, + ) + if changes: + self._create_name_change_from_changes(user_profile, changes) + + self.users_by_gguid[entry["gguid"]] = user_profile + + def _import_lecturers(self, data: list[ImportLecturer]) -> None: + for entry in data: + email = clean_email(entry["email"]) + if not email: + self.statistics.warnings.append( + WarningMessage( + obj=f"Contributor {entry['christianname']} {entry['name']}", message="No email defined" + ) + ) + else: + user_profile, __, changes = update_or_create_with_changes( + UserProfile, + email=email, + defaults={ + "last_name": entry["name"], + "first_name_given": entry["christianname"], + "title": entry["titlefront"], + }, + ) + if changes: + self._create_name_change_from_changes(user_profile, changes) + + self.users_by_gguid[entry["gguid"]] = user_profile + + def _import_course(self, data: ImportEvent, course_type: CourseType | None = None) -> Course: + course_type = self._get_course_type(data["type"]) if course_type is None else course_type + responsibles = self._get_user_profiles(data["lecturers"]) + responsibles = self._remove_non_responsible_users(responsibles) + responsibles = self._get_users_with_longest_title(responsibles) + if not data["title_en"]: + data["title_en"] = data["title"] + course, created, changes = update_or_create_with_changes( + Course, + semester=self.semester, + cms_id=data["gguid"], + defaults={"name_de": data["title"], "name_en": data["title_en"], "type": course_type}, + ) + changes |= update_m2m_with_changes(course, "responsibles", responsibles) + + if created: + self.statistics.new_courses.append(course) + elif changes: + self.statistics.updated_courses.append(course) + + self.courses_by_gguid[data["gguid"]] = course + + return course + + def _import_course_programs(self, course: Course, data: ImportEvent) -> None: + if "courses" not in data or not data["courses"]: + self.statistics.warnings.append( + WarningMessage( + obj=course.name, message="No 'courses' defined in import data, Programs can't be assigned" + ) + ) + else: + programs = [ + self._get_program(c["cprid"]) for c in data["courses"] if c["cprid"] not in settings.IGNORE_PROGRAMS + ] + course.programs.add(*programs) + + def _import_course_from_unused_exam(self, data: ImportEvent) -> Course | None: + prefix, sep, actual_title = data["title"].partition(":") + prefix = prefix.strip() + actual_title = actual_title.strip() + if not sep: + return None + + try: + course_type = CourseType.objects.get(import_names__contains=[prefix]) + except CourseType.DoesNotExist: + return None + + data["title"] = actual_title + if ":" in data["title_en"]: + data["title_en"] = data["title_en"].partition(":")[2].strip() + return self._import_course(data, course_type) + + # pylint: disable=too-many-locals + def _import_evaluation(self, course: Course, data: ImportEvent) -> Evaluation: # noqa: PLR0912 + if "appointments" not in data or not data["appointments"]: + self.statistics.warnings.append( + WarningMessage(obj=course.name, message="No dates defined, using default end date") + ) + course_end = datetime.combine(self.default_course_end, self.MIDNIGHT) + else: + course_end = max(datetime.strptime(app["end"], self.DATETIME_FORMAT) for app in data["appointments"]) + + assert isinstance(data["isexam"], bool) + if data["isexam"]: + # Set evaluation time frame of three days for exam evaluations: + evaluation_start_datetime = course_end.replace(hour=8, minute=0, second=0, microsecond=0) + timedelta( + days=1 + ) + evaluation_end_date = (course_end + timedelta(days=3)).date() + + name_de = data["title"].split(" - ")[-1] if " - " in data["title"] else "Prüfung" + name_en = data["title_en"].split(" - ")[-1] if " - " in data["title_en"] else "Exam" + + weight = 1 + + # Update previously created main evaluation + # If events are graded for any program, wait for grade upload before publishing + if "courses" not in data or not data["courses"]: + wait_for_grade_upload_before_publishing = True + else: + wait_for_grade_upload_before_publishing = any(grade["scale"] for grade in data["courses"]) + course.evaluations.all().update( + wait_for_grade_upload_before_publishing=wait_for_grade_upload_before_publishing + ) + else: + # Set evaluation time frame of two weeks for normal evaluations: + # Start datetime is at 8:00 am on the monday in the week before the event ends + evaluation_start_datetime = course_end.replace(hour=8, minute=0, second=0, microsecond=0) - timedelta( + weeks=1, days=course_end.weekday() + ) + # End date is on the sunday in the week the event ends + evaluation_end_date = (course_end + timedelta(days=6 - course_end.weekday())).date() + + name_de, name_en = "", "" + + weight = 9 + + # Might be overwritten when importing related exam evaluation + wait_for_grade_upload_before_publishing = True + + participants = self._get_user_profiles(data["students"]) if "students" in data else [] + + defaults = { + "name_de": name_de, + "name_en": name_en, + "vote_start_datetime": evaluation_start_datetime, + "vote_end_date": evaluation_end_date, + "wait_for_grade_upload_before_publishing": wait_for_grade_upload_before_publishing, + "weight": weight, + } + evaluation, created = Evaluation.objects.get_or_create( + course=course, + cms_id=data["gguid"], + defaults=defaults, + ) + if evaluation.state < Evaluation.State.APPROVED: + direct_changes = update_with_changes(evaluation, defaults) + + participant_changes = set(evaluation.participants.all()) != set(participants) + evaluation.participants.set(participants) + + any_lecturers_changed = False + if "lecturers" not in data: + self.statistics.warnings.append( + WarningMessage(obj=evaluation.full_name, message="No contributors defined") + ) + else: + for lecturer in data["lecturers"]: + __, lecturer_created = self._import_contribution(evaluation, lecturer) + any_lecturers_changed |= lecturer_created + + if not created and (direct_changes or participant_changes or any_lecturers_changed): + self.statistics.updated_evaluations.append(evaluation) + else: + self.statistics.attempted_changes.append(evaluation) + + if created: + self.statistics.new_evaluations.append(evaluation) + + return evaluation + + def _import_contribution(self, evaluation: Evaluation, data: ImportRelated) -> tuple[Contribution | None, bool]: + if data["gguid"] not in self.users_by_gguid: + return None, False + + user_profile = self.users_by_gguid[data["gguid"]] + + if user_profile.email in settings.NON_RESPONSIBLE_USERS: + return None, False + + contribution, created = Contribution.objects.update_or_create( + evaluation=evaluation, + contributor=user_profile, + ) + return contribution, created + + def _import_events(self, data: list[ImportEvent]) -> None: + # Divide in two lists so corresponding courses are imported before their exams + non_exam_events = (event for event in data if not event["isexam"]) + exam_events = (event for event in data if event["isexam"]) + + for event in non_exam_events: + course = self._import_course(event) + + self._import_evaluation(course, event) + + exam_events_without_related_non_exam_event = [] + courses_with_exams: dict[Course, list[Evaluation]] = {} + for event in exam_events: + if not event.get("relatedevents"): + exam_events_without_related_non_exam_event.append(event) + continue + + # Exam events have the non-exam event as a single entry in the relatedevents list + # We lookup the Course from this non-exam event (the main evaluation) to add the exam evaluation to the same Course + assert len(event["relatedevents"]) == 1 + course = self.courses_by_gguid[event["relatedevents"][0]["gguid"]] + + self._import_course_programs(course, event) + + evaluation = self._import_evaluation(course, event) + if course in courses_with_exams: + courses_with_exams[course].append(evaluation) + else: + courses_with_exams[course] = [evaluation] + + # Handle exam events that exist on their own without a related non-exam event + # They can be handled like non-exam events if they have a prefix existing in CourseType import names, + # this replaces the necessary CourseType information otherwise defined in non-exam events + for event in exam_events_without_related_non_exam_event: + course_from_unused_exam = self._import_course_from_unused_exam(event) + if not course_from_unused_exam: + self.statistics.warnings.append( + WarningMessage(obj=event["title"], message="No related event or matching prefix found") + ) + continue + event["isexam"] = False + self._import_course_programs(course_from_unused_exam, event) + self._import_evaluation(course_from_unused_exam, event) + + # Update vote end date of main evaluation to day before the exam date (course_end) + for course, exam_evaluations in courses_with_exams.items(): + if not course.evaluations.filter(name_de="", name_en="").exists(): + self.statistics.warnings.append( + WarningMessage( + obj=course.name, message="No main evaluation found to update vote end date to day before exam" + ) + ) + continue + main_evaluation = course.evaluations.get(name_de="", name_en="") + vote_start_date = main_evaluation.vote_start_datetime.date() + earliest_exam_date = min( + evaluation.vote_start_datetime for evaluation in exam_evaluations + ).date() - timedelta(days=1) + if earliest_exam_date <= vote_start_date: + self.statistics.warnings.append( + WarningMessage( + obj=course.name, + message=f"Exam date ({earliest_exam_date}) is on or before start date of main evaluation", + ) + ) + elif earliest_exam_date - vote_start_date < timedelta(days=4): + self.statistics.warnings.append( + WarningMessage( + obj=course.name, + message="Not automatically updating vote end date of main evaluation to day before exam because evaluation period would be less than 3 days", + ) + ) + else: + main_evaluation.vote_end_date = earliest_exam_date - timedelta(days=1) + main_evaluation.save() + + @transaction.atomic + def import_dict(self, data: ImportDict) -> None: + self._import_students(data["students"]) + self._import_lecturers(data["lecturers"]) + self._import_events(data["events"]) + self.statistics.send_mail() + + def import_json(self, data: str) -> None: + self.import_dict(json.loads(data)) diff --git a/evap/staff/management/commands/__init__.py b/evap/staff/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evap/staff/management/commands/json_import.py b/evap/staff/management/commands/json_import.py new file mode 100644 index 0000000000..cb0b6ca693 --- /dev/null +++ b/evap/staff/management/commands/json_import.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from django.core.management import CommandError +from django.core.management.base import BaseCommand + +from evap.evaluation.management.commands.tools import log_exceptions +from evap.evaluation.models import Semester +from evap.staff.importers.json import JSONImporter + + +@log_exceptions +class Command(BaseCommand): + help = "Import enrollments from JSON file." + + def add_arguments(self, parser): + # Positional arguments + parser.add_argument("semester", type=int) + parser.add_argument("file", type=str) + parser.add_argument("default_course_end", type=str) + + def handle(self, *args, **options): + try: + semester = Semester.objects.get(pk=options["semester"]) + except Semester.DoesNotExist as e: + raise CommandError("Semester does not exist.") from e + + with open(options["file"]) as file: + default_course_end = datetime.strptime(options["default_course_end"], "%d.%m.%Y") + JSONImporter(semester, default_course_end).import_json(file.read()) diff --git a/evap/staff/tests/test_json_importer.py b/evap/staff/tests/test_json_importer.py new file mode 100644 index 0000000000..9e82900c9b --- /dev/null +++ b/evap/staff/tests/test_json_importer.py @@ -0,0 +1,603 @@ +import json +import os +from datetime import date, datetime +from io import StringIO +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from django.core import mail +from django.core.management import CommandError, call_command +from django.test import TestCase, override_settings +from model_bakery import baker + +from evap.evaluation.models import ( + Contribution, + Course, + CourseType, + Evaluation, + Program, + Questionnaire, + Semester, + UserProfile, +) +from evap.evaluation.tests.tools import make_manager +from evap.staff.importers.json import ImportDict, JSONImporter, NameChange, WarningMessage + +EXAMPLE_DATA: ImportDict = { + "students": [ + {"gguid": "0x1", "email": "1@example.com", "name": "1", "christianname": "1"}, + {"gguid": "0x2", "email": "2@example.com", "name": "2", "christianname": "2"}, + ], + "lecturers": [ + {"gguid": "0x3", "email": "3@example.com", "name": "3", "christianname": "3", "titlefront": "Prof. Dr."}, + {"gguid": "0x4", "email": "4@example.com", "name": "4", "christianname": "4", "titlefront": "Dr."}, + {"gguid": "0x5", "email": "5@example.com", "name": "5", "christianname": "5", "titlefront": ""}, + {"gguid": "0x6", "email": "6@example.com", "name": "6", "christianname": "6", "titlefront": ""}, + ], + "events": [ + { + "gguid": "0x5", + "title": "Prozessorientierte Informationssysteme", + "title_en": "Process-oriented information systems", + "type": "Vorlesung", + "isexam": False, + "courses": [], + "appointments": [ + {"begin": "30.04.2024 10:15:00", "end": "30.04.2024 11:45:00"}, + {"begin": "15.07.2024 10:15:00", "end": "15.07.2024 11:45:00"}, + ], + "relatedevents": [{"gguid": "0x6"}], + "lecturers": [{"gguid": "0x3"}], + "students": [{"gguid": "0x1"}, {"gguid": "0x2"}], + }, + { + "gguid": "0x6", + "title": "Prozessorientierte Informationssysteme", + "title_en": "Process-oriented information systems", + "type": "Klausur", + "isexam": True, + "courses": [ + {"cprid": "BA-Inf", "scale": "GRADE_PARTICIPATION"}, + {"cprid": "MA-Inf", "scale": "GRADE_PARTICIPATION"}, + ], + "appointments": [{"begin": "29.07.2024 10:15:00", "end": "29.07.2024 11:45:00"}], + "relatedevents": [{"gguid": "0x5"}], + "lecturers": [{"gguid": "0x3"}, {"gguid": "0x4"}, {"gguid": "0x5"}], + "students": [{"gguid": "0x1"}, {"gguid": "0x2"}], + }, + { + "gguid": "0x7", + "title": "Bachelorprojekt: Prozessorientierte Informationssysteme", + "title_en": "Bachelor's Project: Process-oriented information systems", + "type": "Bachelorprojekt", + "isexam": True, + "courses": [ + {"cprid": "BA-Inf", "scale": "GRADE_PARTICIPATION"}, + ], + "lecturers": [{"gguid": "0x3"}], + "students": [{"gguid": "0x1"}, {"gguid": "0x2"}], + }, + ], +} +EXAMPLE_DATA_WITH_PREFIX = { + "students": EXAMPLE_DATA["students"], + "lecturers": EXAMPLE_DATA["lecturers"], + "events": [ + { + "gguid": "0x10", + "title": "BA-Projekt: Allerbestes Projekt", + "title_en": "BA Project: Best Project Ever", + "type": "Prüfung", + "isexam": True, + "courses": [{"cprid": "BA-Inf", "scale": "GRADE_TO_A_THIRD"}], + "lecturers": [{"gguid": "0x3"}], + "students": [{"gguid": "0x1"}, {"gguid": "0x2"}], + "appointments": [{"begin": "29.07.2024 10:15:00", "end": "29.07.2024 11:45:00"}], + } + ], +} +EXAMPLE_DATA_SPECIAL_CASES: ImportDict = { + "students": [ + {"gguid": "0x1", "email": "", "name": "1", "christianname": "1"}, + {"gguid": "0x2", "email": "2@example.com", "name": "2", "christianname": "2"}, + ], + "lecturers": [ + {"gguid": "0x3", "email": "", "name": "3", "christianname": "3", "titlefront": "Prof. Dr."}, + {"gguid": "0x4", "email": "4@example.com", "name": "4", "christianname": "4", "titlefront": "Prof. Dr."}, + {"gguid": "0x5", "email": "5@example.com", "name": "5", "christianname": "5", "titlefront": "Prof. Dr."}, + ], + "events": [ + { + "gguid": "0x7", + "title": "Terminlose Vorlesung", + "title_en": "", + "type": "Vorlesung", + "isexam": False, + "relatedevents": [{"gguid": "0x42"}, {"gguid": "0x43"}], + "lecturers": [{"gguid": "0x3"}], + "students": [{"gguid": "0x1"}, {"gguid": "0x2"}], + }, + { + "gguid": "0x8", + "title": "Klausurlose Vorlesung", + "title_en": "", + "type": "Vorlesung", + "isexam": False, + "lecturers": [{"gguid": "0x3"}], + "students": [{"gguid": "0x1"}, {"gguid": "0x2"}], + }, + { + "gguid": "0x9", + "title": "Vorlesung mit vielen Verantwortlichen", + "title_en": "", + "type": "Vorlesung", + "isexam": False, + "lecturers": [{"gguid": "0x4"}, {"gguid": "0x5"}], + "students": [], + "appointments": [{"begin": "29.07.2024 10:15:00", "end": "29.07.2024 11:45:00"}], + }, + { + "gguid": "0x42", + "title": "Die Antwort auf die endgültige Frage - Nach dem Leben", + "title_en": "The Answer to the Ultimate Question - Of Life", + "type": "Klausur", + "isexam": True, + "courses": [ + {"cprid": "BA-Inf", "scale": "GRADE_PARTICIPATION"}, + {"cprid": "Ignore", "scale": "GRADE_PARTICIPATION"}, + {"cprid": "P", "scale": "GRADE_PARTICIPATION"}, + ], + "appointments": [{"begin": "01.01.2025 01:01:01", "end": "31.12.2025 12:31:00"}], + "relatedevents": [{"gguid": "0x7"}], + "students": [{"gguid": "0x1"}, {"gguid": "0x2"}], + }, + { + "gguid": "0x43", + "title": "Die Antwort auf die endgültige Frage - Nach dem Universum", + "title_en": "The Answer to the Ultimate Question - Of the Universe", + "type": "Klausur", + "isexam": True, + "courses": [ + {"cprid": "Master Program", "scale": "GRADE_PARTICIPATION"}, + ], + "appointments": [{"begin": "01.01.2025 01:01:01", "end": "01.12.2025 12:31:00"}], + "relatedevents": [{"gguid": "0x7"}], + "lecturers": [{"gguid": "0x3"}], + }, + { + "gguid": "0x44", + "title": "Der ganze Rest", + "title_en": "Everything", + "type": "CT", + "isexam": False, + "appointments": [{"begin": "01.01.2025 01:01:01", "end": "31.12.2025 12:31:00"}], + "lecturers": [{"gguid": "0x3"}], + "students": [{"gguid": "0x1"}, {"gguid": "0x2"}], + }, + { + "gguid": "0x50", + "title": "Späte Vorlesung", + "title_en": "Late Lecture", + "type": "Vorlesung", + "isexam": False, + "appointments": [{"begin": "01.03.2025 08:00:00", "end": "20.03.2025 12:00:00"}], + "relatedevents": [{"gguid": "0x51"}], + "lecturers": [{"gguid": "0x3"}], + "students": [{"gguid": "0x1"}, {"gguid": "0x2"}], + }, + { + "gguid": "0x51", + "title": "Frühe Klausur", + "title_en": "Early Exam", + "type": "Klausur", + "isexam": True, + "courses": [ + {"cprid": "Master Program", "scale": "GRADE_PARTICIPATION"}, + ], + "appointments": [{"begin": "01.01.2025 08:00:00", "end": "01.01.2025 12:00:00"}], + "relatedevents": [{"gguid": "0x50"}], + "lecturers": [{"gguid": "0x3"}], + "students": [{"gguid": "0x1"}, {"gguid": "0x2"}], + }, + ], +} +EXAMPLE_JSON = json.dumps(EXAMPLE_DATA) + + +class TestImportUserProfiles(TestCase): + @classmethod + def setUpTestData(cls): + cls.students = EXAMPLE_DATA["students"] + cls.lecturers = EXAMPLE_DATA["lecturers"] + + cls.semester = baker.make(Semester) + + def test_import_students(self): + self.assertEqual(UserProfile.objects.count(), 0) + + importer = JSONImporter(self.semester, date(2000, 1, 1)) + importer._import_students(self.students) + + user_profiles = UserProfile.objects.all() + + for i, user_profile in enumerate(user_profiles.order_by("email")): + self.assertEqual(user_profile.email, self.students[i]["email"]) + self.assertEqual(user_profile.last_name, self.students[i]["name"]) + self.assertEqual(user_profile.first_name_given, self.students[i]["christianname"]) + + self.assertEqual(importer.statistics.name_changes, []) + + def test_import_existing_students(self): + user_profile = baker.make( + UserProfile, email=self.students[0]["email"], last_name="Doe", first_name_given="Jane" + ) + + importer = JSONImporter(self.semester, date(2000, 1, 1)) + importer._import_students(self.students) + + self.assertEqual(UserProfile.objects.count(), 2) + + user_profile.refresh_from_db() + + self.assertEqual(user_profile.email, self.students[0]["email"]) + self.assertEqual(user_profile.last_name, self.students[0]["name"]) + self.assertEqual(user_profile.first_name_given, self.students[0]["christianname"]) + + self.assertEqual( + importer.statistics.name_changes, + [ + NameChange( + old_last_name="Doe", + old_first_name_given="Jane", + new_last_name=self.students[0]["name"], + new_first_name_given=self.students[0]["christianname"], + email=self.students[0]["email"], + ) + ], + ) + + def test_import_lecturers(self): + self.assertEqual(UserProfile.objects.count(), 0) + + importer = JSONImporter(self.semester, date(2000, 1, 1)) + importer._import_lecturers(self.lecturers) + + user_profiles = UserProfile.objects.all() + + for i, user_profile in enumerate(user_profiles.order_by("email")): + self.assertEqual(user_profile.email, self.lecturers[i]["email"]) + self.assertEqual(user_profile.last_name, self.lecturers[i]["name"]) + self.assertEqual(user_profile.first_name_given, self.lecturers[i]["christianname"]) + self.assertEqual(user_profile.title, self.lecturers[i]["titlefront"]) + + self.assertEqual(importer.statistics.name_changes, []) + + def test_import_existing_lecturers(self): + user_profile = baker.make( + UserProfile, email=self.lecturers[0]["email"], last_name="Doe", first_name_given="Jane" + ) + + importer = JSONImporter(self.semester, date(2000, 1, 1)) + importer._import_lecturers(self.lecturers) + + self.assertEqual(UserProfile.objects.count(), 4) + + user_profile.refresh_from_db() + + self.assertEqual(user_profile.email, self.lecturers[0]["email"]) + self.assertEqual(user_profile.last_name, self.lecturers[0]["name"]) + self.assertEqual(user_profile.first_name_given, self.lecturers[0]["christianname"]) + self.assertEqual(user_profile.title, self.lecturers[0]["titlefront"]) + + self.assertEqual( + importer.statistics.name_changes, + [ + NameChange( + old_last_name="Doe", + old_first_name_given="Jane", + new_last_name=self.lecturers[0]["name"], + new_first_name_given=self.lecturers[0]["christianname"], + email=self.lecturers[0]["email"], + ) + ], + ) + + +class TestImportEvents(TestCase): + @classmethod + def setUpTestData(cls): + cls.semester = baker.make(Semester) + + def _import(self, data=None): + if not data: + data = EXAMPLE_DATA + data = json.dumps(data) + importer = JSONImporter(self.semester, date(2000, 1, 1)) + importer.import_json(data) + return importer + + def test_import_courses(self): + importer = self._import() + + course = Course.objects.get() + + self.assertEqual(course.semester, self.semester) + self.assertEqual(course.cms_id, EXAMPLE_DATA["events"][0]["gguid"]) + self.assertEqual(course.name_de, EXAMPLE_DATA["events"][0]["title"]) + self.assertEqual(course.name_en, EXAMPLE_DATA["events"][0]["title_en"]) + self.assertEqual(course.type.name_de, EXAMPLE_DATA["events"][0]["type"]) + self.assertEqual( + {d.name_de for d in course.programs.all()}, {d["cprid"] for d in EXAMPLE_DATA["events"][1]["courses"]} + ) + self.assertEqual( + set(course.responsibles.values_list("email", flat=True)), + {"3@example.com"}, + ) + + main_evaluation = Evaluation.objects.get(name_en="") + self.assertEqual(main_evaluation.course, course) + self.assertEqual(main_evaluation.name_de, "") + self.assertEqual(main_evaluation.name_en, "") + # [{"begin": "30.04.2024 10:15", "end": "15.07.2024 11:45"}] + self.assertEqual(main_evaluation.vote_start_datetime, datetime(2024, 7, 8, 8, 0)) + # exam is on 29.07.2024, so evaluation period should be until day before + self.assertEqual(main_evaluation.vote_end_date, date(2024, 7, 28)) + self.assertEqual( + set(main_evaluation.participants.values_list("email", flat=True)), + {"1@example.com", "2@example.com"}, + ) + + self.assertEqual(Contribution.objects.filter(evaluation=main_evaluation).count(), 2) + self.assertEqual( + set( + Contribution.objects.filter(evaluation=main_evaluation, contributor__isnull=False).values_list( + "contributor__email", flat=True + ) + ), + {"3@example.com"}, + ) + + exam_evaluation = Evaluation.objects.get(name_en="Exam") + self.assertEqual(exam_evaluation.course, course) + self.assertEqual(exam_evaluation.name_de, "Prüfung") + self.assertEqual(exam_evaluation.name_en, "Exam") + # [{"begin": "29.07.2024 10:15", "end": "29.07.2024 11:45"}] + self.assertEqual(exam_evaluation.vote_start_datetime, datetime(2024, 7, 30, 8, 0)) + self.assertEqual(exam_evaluation.vote_end_date, date(2024, 8, 1)) + self.assertEqual( + set(exam_evaluation.participants.values_list("email", flat=True)), + {"1@example.com", "2@example.com"}, + ) + self.assertTrue(exam_evaluation.wait_for_grade_upload_before_publishing) + + self.assertEqual(Contribution.objects.filter(evaluation=exam_evaluation).count(), 4) + self.assertEqual( + set( + Contribution.objects.filter(evaluation=exam_evaluation, contributor__isnull=False).values_list( + "contributor__email", flat=True + ) + ), + {"3@example.com", "4@example.com", "5@example.com"}, + ) + + self.assertEqual(len(importer.statistics.new_courses), 1) + self.assertEqual(len(importer.statistics.new_evaluations), 2) + + def test_import_courses_exam_with_prefix(self): + CourseType.objects.create(name_en="Foo", name_de="Foo", import_names=["nat"]) + course_type = CourseType.objects.create(name_en="Bar", name_de="Bar", import_names=["BA-Projekt"]) + + self._import(EXAMPLE_DATA_WITH_PREFIX) + + self.assertEqual(Course.objects.count(), 1) + self.assertEqual(Evaluation.objects.count(), 1) + + evaluation = Evaluation.objects.first() + self.assertEqual(evaluation.course.name_de, "Allerbestes Projekt") + self.assertEqual(evaluation.course.name_en, "Best Project Ever") + self.assertEqual(evaluation.name_de, "") + self.assertEqual(evaluation.name_en, "") + self.assertEqual(evaluation.course.type, course_type) + + self.assertEqual( + set(evaluation.participants.values_list("email", flat=True)), + {"1@example.com", "2@example.com"}, + ) + + self.assertEqual( + set( + Contribution.objects.filter(evaluation=evaluation, contributor__isnull=False).values_list( + "contributor__email", flat=True + ) + ), + {"3@example.com"}, + ) + + @override_settings(IGNORE_PROGRAMS=["Ignore"]) + def test_import_courses_special_cases(self): + course_type = CourseType.objects.create(name_en="Course Type", name_de="Kurstyp", import_names=["CT"]) + Program.objects.create(name_en="Program", name_de="Studiengang", import_names=["P"]) + importer = self._import(EXAMPLE_DATA_SPECIAL_CASES) + + self.assertEqual(Course.objects.count(), 5) + self.assertEqual(Evaluation.objects.count(), 8) + + evaluation = Evaluation.objects.first() + self.assertEqual(evaluation.course.name_de, "Terminlose Vorlesung") + + # evaluation has no English name, uses German + self.assertEqual(evaluation.course.name_en, "Terminlose Vorlesung") + + # evaluation has multiple exams, use correct date (first exam end: 01.12.2025) + self.assertEqual(evaluation.vote_start_datetime, datetime(1999, 12, 20, 8, 0)) + self.assertEqual(evaluation.vote_end_date, date(2025, 11, 30)) + + # evaluation_without_exam has no "appointments", uses default dates + evaluation_without_exam = Evaluation.objects.get(cms_id="0x8") + self.assertEqual(evaluation_without_exam.vote_start_datetime, datetime(1999, 12, 20, 8, 0)) + self.assertEqual(evaluation_without_exam.vote_end_date, date(2000, 1, 2)) + + # use import names and only import non-ignored programs + self.assertEqual({d.name_en for d in evaluation.course.programs.all()}, {"BA-Inf", "Master Program", "Program"}) + evaluation_everything = Evaluation.objects.get(cms_id="0x44") + self.assertEqual(evaluation_everything.course.type, course_type) + + # use second part of title after dash + evaluation_life = Evaluation.objects.get(cms_id="0x42") + self.assertEqual(evaluation_life.name_de, "Nach dem Leben") + self.assertEqual(evaluation_life.name_en, "Of Life") + evaluation_universe = Evaluation.objects.get(cms_id="0x43") + self.assertEqual(evaluation_universe.name_de, "Nach dem Universum") + self.assertEqual(evaluation_universe.name_en, "Of the Universe") + + # don't update evaluation period for late course + evaluation_late_lecture = Evaluation.objects.get(cms_id="0x50") + self.assertEqual(evaluation_late_lecture.vote_start_datetime, datetime(2025, 3, 10, 8, 0)) + self.assertEqual(evaluation_late_lecture.vote_end_date, date(2025, 3, 23)) + + # check warnings + self.assertCountEqual( + importer.statistics.warnings, + [ + WarningMessage( + obj="Contributor 3 3", + message="No email defined", + ), + WarningMessage( + obj="Student 1 1", + message="No email defined", + ), + WarningMessage( + obj=evaluation.course.name, + message="No dates defined, using default end date", + ), + WarningMessage( + obj=evaluation_life.full_name, + message="No contributors defined", + ), + WarningMessage( + obj=evaluation_without_exam.full_name, + message="No dates defined, using default end date", + ), + WarningMessage( + obj=evaluation_late_lecture.full_name, + message="Exam date (2025-01-01) is on or before start date of main evaluation", + ), + ], + ) + + # use first relatedevent, ignore other + self.assertCountEqual( + evaluation.course.evaluations.all(), + [ + evaluation, + evaluation_life, + evaluation_universe, + ], + ) + + # use weights + self.assertEqual(evaluation_everything.weight, 9) + self.assertEqual(evaluation_life.weight, 1) + + def test_import_ignore_non_responsible_users(self): + with override_settings(NON_RESPONSIBLE_USERS=["4@example.com"]): + self._import(EXAMPLE_DATA_SPECIAL_CASES) + evaluation = Evaluation.objects.get(cms_id="0x9") + self.assertEqual(set(evaluation.course.responsibles.values_list("email", flat=True)), {"5@example.com"}) + self.assertEqual( + set( + Contribution.objects.filter(evaluation=evaluation, contributor__isnull=False).values_list( + "contributor__email", flat=True + ) + ), + {"5@example.com"}, + ) + + with override_settings(NON_RESPONSIBLE_USERS=[]): + self._import(EXAMPLE_DATA_SPECIAL_CASES) + evaluation = Evaluation.objects.get(cms_id="0x9") + self.assertEqual( + set(evaluation.course.responsibles.values_list("email", flat=True)), {"4@example.com", "5@example.com"} + ) + self.assertEqual( + set( + Contribution.objects.filter(evaluation=evaluation, contributor__isnull=False).values_list( + "contributor__email", flat=True + ) + ), + {"4@example.com", "5@example.com"}, + ) + + def test_import_courses_evaluation_approved(self): + self._import() + + evaluation = Evaluation.objects.get(name_en="") + + evaluation.name_en = "Test" + evaluation.save() + + importer = self._import() + + evaluation = Evaluation.objects.get(pk=evaluation.pk) + + self.assertEqual(evaluation.name_en, "") + self.assertEqual(len(importer.statistics.attempted_changes), 0) + + evaluation.general_contribution.questionnaires.add( + baker.make(Questionnaire, type=Questionnaire.Type.CONTRIBUTOR) + ) + evaluation.manager_approve() + evaluation.name_en = "Test" + evaluation.save() + + importer = self._import() + + evaluation = Evaluation.objects.get(pk=evaluation.pk) + + self.assertEqual(evaluation.name_en, "Test") + + self.assertEqual(len(importer.statistics.attempted_changes), 1) + + def test_import_courses_update(self): + self._import() + + self.assertEqual(Course.objects.count(), 1) + course = Course.objects.all()[0] + course.name_de = "Doe" + course.name_en = "Jane" + course.save() + + importer = self._import() + + course.refresh_from_db() + + self.assertEqual(course.name_de, EXAMPLE_DATA["events"][0]["title"]) + self.assertEqual(course.name_en, EXAMPLE_DATA["events"][0]["title_en"]) + + self.assertEqual(len(importer.statistics.updated_courses), 1) + self.assertEqual(len(importer.statistics.new_courses), 0) + + def test_importer_log_email_sent(self): + manager = make_manager() + + self._import() + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, "[EvaP] JSON importer log") + self.assertEqual(mail.outbox[0].recipients(), [manager.email]) + + @patch("evap.staff.importers.json.JSONImporter.import_json") + def test_management_command(self, mock_import_json): + output = StringIO() + + with TemporaryDirectory() as temp_dir: + test_filename = os.path.join(temp_dir, "test.json") + with open(test_filename, "w", encoding="utf-8") as f: + f.write(EXAMPLE_JSON) + call_command("json_import", self.semester.id, test_filename, "01.01.2000", stdout=output) + + mock_import_json.assert_called_once_with(EXAMPLE_JSON) + + with self.assertRaises(CommandError): + call_command("json_import", self.semester.id + 42, test_filename, "01.01.2000", stdout=output) diff --git a/evap/staff/tools.py b/evap/staff/tools.py index eae4055f3d..d4f0ba72fc 100644 --- a/evap/staff/tools.py +++ b/evap/staff/tools.py @@ -1,14 +1,15 @@ -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from datetime import date, datetime, timedelta from enum import Enum from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeVar from django.conf import settings from django.contrib import messages from django.contrib.auth.models import Group from django.core.exceptions import SuspiciousOperation from django.db import transaction -from django.db.models import Count, Max +from django.db.models import Count, Max, Model from django.urls import reverse from django.utils.html import escape, format_html, format_html_join from django.utils.safestring import SafeString @@ -21,6 +22,9 @@ from evap.grades.models import GradeDocument from evap.results.tools import STATES_WITH_RESULTS_CACHING, cache_results +if TYPE_CHECKING: + from django.db.models.fields.related_descriptors import RelatedManager + class ImportType(Enum): USER = "user" @@ -412,3 +416,51 @@ def user_edit_link(user_id): reverse("staff:user_edit", kwargs={"user_id": user_id}), _("edit user"), ) + + +T = TypeVar("T", bound=Model) + + +def update_or_create_with_changes( + model: type[T], + defaults=None, + **kwargs, +) -> tuple[T, bool, dict[str, tuple[Any, Any]]]: + """Do update_or_create and track changed values.""" + + if not defaults: + defaults = {} + + obj, created = model._default_manager.get_or_create(**kwargs, defaults=defaults) + + if created: + return obj, True, {} + + changes = update_with_changes(obj, defaults) + + return obj, False, changes + + +def update_with_changes(obj: Model, defaults: dict[str, Any]) -> dict[str, tuple[Any, Any]]: + """Update a model instance and track changed values.""" + + changes = {} + for key, value in defaults.items(): + if getattr(obj, key) != value: + changes[key] = (getattr(obj, key), value) + setattr(obj, key, value) + + if changes: + obj.save() + + return changes + + +def update_m2m_with_changes(obj: Model, field: str, new_data: Sequence) -> dict[str, tuple[Any, Any]]: + """Update a m2m field of a model and track changed values.""" + manager: RelatedManager = getattr(obj, field) + old_data = manager.all() + if set(old_data) != set(new_data): + manager.set(new_data) + return {field: (old_data, new_data)} + return {}