[{"data":1,"prerenderedAt":2146},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-training-compliance-matrix":3,"surround:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-training-compliance-matrix":2111,"ancestors:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-training-compliance-matrix":2120,"children:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-training-compliance-matrix":2125,"related:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-training-compliance-matrix":2126},{"id":4,"title":5,"body":6,"dateModified":2077,"datePublished":2077,"description":2078,"extension":2079,"faq":2080,"keyword":2089,"kind":2090,"meta":2091,"navigation":109,"path":2100,"seo":2101,"slug":2104,"stem":2105,"topics":2106,"type":2109,"__hash__":2110},"docs\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-training-compliance-matrix\u002Findex.md","Build a Training Compliance Matrix",{"type":7,"value":8,"toc":2065},"minimark",[9,24,29,58,77,81,280,287,291,662,681,835,839,1100,1109,1417,1421,1835,1838,1842,1872,1943,1947,1982,1986,1989,1993,2002,2012,2018,2028,2032,2061],[10,11,12,13,17,18,23],"p",{},"Health and safety, data protection, first aid, forklift licences, food hygiene: most organisations have a list of training that certain people must hold and renew. The question auditors and managers ask is simple — is everyone who needs it trained, and is anyone about to lapse? — but answering it from a learning system export is fiddly. The export lists every course completion ever recorded; what matters is the ",[14,15,16],"em",{},"latest"," completion per person and course, whether that course is required for the person's role at all, and whether it has expired given that each course has its own validity period. The usual result is a matrix kept by hand: people down the side, courses across the top, coloured cells. This page builds that matrix with pandas from three tables — people, role requirements and completions — classifies every cell, measures compliance by department and writes a colour-coded workbook with an action list. It belongs to ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002F","Building Finance and Operations Reports",".",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[37,38,39],"code",{"__ignoreMap":35},[40,41,44,48,52,55],"span",{"class":42,"line":43},"line",1,[40,45,47],{"class":46},"sMTad","pip",[40,49,51],{"class":50},"srMev"," install",[40,53,54],{"class":50}," pandas",[40,56,57],{"class":50}," openpyxl\n",[10,59,60,61,64,65,68,69,72,73,76],{},"Four small tables: ",[14,62,63],{},"people"," with department and role; ",[14,66,67],{},"requirements"," listing which courses each role must hold; ",[14,70,71],{},"courses"," with how many months each certificate stays valid; and ",[14,74,75],{},"completions",", the learning system's export of every completion recorded. The report date is 5 October 2026.",[25,78,80],{"id":79},"step-1-who-needs-what","Step 1 — Who needs what",[30,82,86],{"className":83,"code":84,"language":85,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\ntoday = pd.Timestamp(\"2026-10-05\")\npeople = pd.read_excel(\"people.xlsx\")\nrequired = pd.read_excel(\"requirements.xlsx\")\ncourses = pd.read_excel(\"courses.xlsx\")\ndone = pd.read_excel(\"completions.xlsx\", parse_dates=[\"Completed\"])\n\nneeds = people.merge(required, on=\"Role\", how=\"left\", validate=\"many_to_many\")\nprint(needs.groupby(\"Name\")[\"Course\"].apply(\", \".join).to_string())\n","python",[37,87,88,104,111,129,145,160,175,206,211,252],{"__ignoreMap":35},[40,89,90,94,98,101],{"class":42,"line":43},[40,91,93],{"class":92},"s-kum","import",[40,95,97],{"class":96},"skGVy"," pandas ",[40,99,100],{"class":92},"as",[40,102,103],{"class":96}," pd\n",[40,105,107],{"class":42,"line":106},2,[40,108,110],{"emptyLinePlaceholder":109},true,"\n",[40,112,114,117,120,123,126],{"class":42,"line":113},3,[40,115,116],{"class":96},"today ",[40,118,119],{"class":92},"=",[40,121,122],{"class":96}," pd.Timestamp(",[40,124,125],{"class":50},"\"2026-10-05\"",[40,127,128],{"class":96},")\n",[40,130,132,135,137,140,143],{"class":42,"line":131},4,[40,133,134],{"class":96},"people ",[40,136,119],{"class":92},[40,138,139],{"class":96}," pd.read_excel(",[40,141,142],{"class":50},"\"people.xlsx\"",[40,144,128],{"class":96},[40,146,148,151,153,155,158],{"class":42,"line":147},5,[40,149,150],{"class":96},"required ",[40,152,119],{"class":92},[40,154,139],{"class":96},[40,156,157],{"class":50},"\"requirements.xlsx\"",[40,159,128],{"class":96},[40,161,163,166,168,170,173],{"class":42,"line":162},6,[40,164,165],{"class":96},"courses ",[40,167,119],{"class":92},[40,169,139],{"class":96},[40,171,172],{"class":50},"\"courses.xlsx\"",[40,174,128],{"class":96},[40,176,178,181,183,185,188,191,195,197,200,203],{"class":42,"line":177},7,[40,179,180],{"class":96},"done ",[40,182,119],{"class":92},[40,184,139],{"class":96},[40,186,187],{"class":50},"\"completions.xlsx\"",[40,189,190],{"class":96},", ",[40,192,194],{"class":193},"sa561","parse_dates",[40,196,119],{"class":92},[40,198,199],{"class":96},"[",[40,201,202],{"class":50},"\"Completed\"",[40,204,205],{"class":96},"])\n",[40,207,209],{"class":42,"line":208},8,[40,210,110],{"emptyLinePlaceholder":109},[40,212,214,217,219,222,225,227,230,232,235,237,240,242,245,247,250],{"class":42,"line":213},9,[40,215,216],{"class":96},"needs ",[40,218,119],{"class":92},[40,220,221],{"class":96}," people.merge(required, ",[40,223,224],{"class":193},"on",[40,226,119],{"class":92},[40,228,229],{"class":50},"\"Role\"",[40,231,190],{"class":96},[40,233,234],{"class":193},"how",[40,236,119],{"class":92},[40,238,239],{"class":50},"\"left\"",[40,241,190],{"class":96},[40,243,244],{"class":193},"validate",[40,246,119],{"class":92},[40,248,249],{"class":50},"\"many_to_many\"",[40,251,128],{"class":96},[40,253,255,259,262,265,268,271,274,277],{"class":42,"line":254},10,[40,256,258],{"class":257},"sP0c6","print",[40,260,261],{"class":96},"(needs.groupby(",[40,263,264],{"class":50},"\"Name\"",[40,266,267],{"class":96},")[",[40,269,270],{"class":50},"\"Course\"",[40,272,273],{"class":96},"].apply(",[40,275,276],{"class":50},"\", \"",[40,278,279],{"class":96},".join).to_string())\n",[10,281,282,283,286],{},"Requirements belong to roles, not to people: a forklift operator needs a forklift licence, manual handling and fire safety, whoever holds the job. Joining people to the requirements by role produces one row per person per course they ",[14,284,285],{},"must"," hold — eighteen rows for the seven people here, since each role needs two or three courses. That table is the backbone of the report, and getting it from a role mapping rather than a hand-kept list means that when someone changes role, or a role gains a requirement, the matrix follows without anyone editing it.",[25,288,290],{"id":289},"step-2-latest-completion-expiry-and-status","Step 2 — Latest completion, expiry and status",[30,292,294],{"className":83,"code":293,"language":85,"meta":35,"style":35},"latest = done.sort_values(\"Completed\").groupby([\"Employee\", \"Course\"], as_index=False).last()\ncells = needs.merge(latest, on=[\"Employee\", \"Course\"], how=\"left\").merge(courses, on=\"Course\", how=\"left\")\ncells[\"Expires\"] = [c + pd.DateOffset(months=int(m)) if pd.notna(c) else pd.NaT\n                    for c, m in zip(cells[\"Completed\"], cells[\"Valid (months)\"])]\n\ndef status(row):\n    if pd.isna(row[\"Completed\"]):\n        return \"Missing\"\n    if row[\"Expires\"] \u003C today:\n        return \"Expired\"\n    if row[\"Expires\"] \u003C today + pd.DateOffset(days=60):\n        return \"Expiring\"\n    return \"Valid\"\n\ncells[\"Status\"] = cells.apply(status, axis=1)\nprint(cells[[\"Name\", \"Course\", \"Completed\", \"Expires\", \"Status\"]]\n      .assign(Completed=cells[\"Completed\"].dt.date, Expires=cells[\"Expires\"].dt.date).to_string(index=False))\n",[37,295,296,332,381,426,454,458,470,483,491,508,515,546,554,563,568,593,622],{"__ignoreMap":35},[40,297,298,301,303,306,308,311,314,316,318,321,324,326,329],{"class":42,"line":43},[40,299,300],{"class":96},"latest ",[40,302,119],{"class":92},[40,304,305],{"class":96}," done.sort_values(",[40,307,202],{"class":50},[40,309,310],{"class":96},").groupby([",[40,312,313],{"class":50},"\"Employee\"",[40,315,190],{"class":96},[40,317,270],{"class":50},[40,319,320],{"class":96},"], ",[40,322,323],{"class":193},"as_index",[40,325,119],{"class":92},[40,327,328],{"class":257},"False",[40,330,331],{"class":96},").last()\n",[40,333,334,337,339,342,344,346,348,350,352,354,356,358,360,362,365,367,369,371,373,375,377,379],{"class":42,"line":106},[40,335,336],{"class":96},"cells ",[40,338,119],{"class":92},[40,340,341],{"class":96}," needs.merge(latest, ",[40,343,224],{"class":193},[40,345,119],{"class":92},[40,347,199],{"class":96},[40,349,313],{"class":50},[40,351,190],{"class":96},[40,353,270],{"class":50},[40,355,320],{"class":96},[40,357,234],{"class":193},[40,359,119],{"class":92},[40,361,239],{"class":50},[40,363,364],{"class":96},").merge(courses, ",[40,366,224],{"class":193},[40,368,119],{"class":92},[40,370,270],{"class":50},[40,372,190],{"class":96},[40,374,234],{"class":193},[40,376,119],{"class":92},[40,378,239],{"class":50},[40,380,128],{"class":96},[40,382,383,386,389,392,394,397,400,403,406,408,411,414,417,420,423],{"class":42,"line":113},[40,384,385],{"class":96},"cells[",[40,387,388],{"class":50},"\"Expires\"",[40,390,391],{"class":96},"] ",[40,393,119],{"class":92},[40,395,396],{"class":96}," [c ",[40,398,399],{"class":92},"+",[40,401,402],{"class":96}," pd.DateOffset(",[40,404,405],{"class":193},"months",[40,407,119],{"class":92},[40,409,410],{"class":257},"int",[40,412,413],{"class":96},"(m)) ",[40,415,416],{"class":92},"if",[40,418,419],{"class":96}," pd.notna(c) ",[40,421,422],{"class":92},"else",[40,424,425],{"class":96}," pd.NaT\n",[40,427,428,431,434,437,440,443,445,448,451],{"class":42,"line":131},[40,429,430],{"class":92},"                    for",[40,432,433],{"class":96}," c, m ",[40,435,436],{"class":92},"in",[40,438,439],{"class":257}," zip",[40,441,442],{"class":96},"(cells[",[40,444,202],{"class":50},[40,446,447],{"class":96},"], cells[",[40,449,450],{"class":50},"\"Valid (months)\"",[40,452,453],{"class":96},"])]\n",[40,455,456],{"class":42,"line":147},[40,457,110],{"emptyLinePlaceholder":109},[40,459,460,463,467],{"class":42,"line":162},[40,461,462],{"class":92},"def",[40,464,466],{"class":465},"s_Opv"," status",[40,468,469],{"class":96},"(row):\n",[40,471,472,475,478,480],{"class":42,"line":177},[40,473,474],{"class":92},"    if",[40,476,477],{"class":96}," pd.isna(row[",[40,479,202],{"class":50},[40,481,482],{"class":96},"]):\n",[40,484,485,488],{"class":42,"line":208},[40,486,487],{"class":92},"        return",[40,489,490],{"class":50}," \"Missing\"\n",[40,492,493,495,498,500,502,505],{"class":42,"line":213},[40,494,474],{"class":92},[40,496,497],{"class":96}," row[",[40,499,388],{"class":50},[40,501,391],{"class":96},[40,503,504],{"class":92},"\u003C",[40,506,507],{"class":96}," today:\n",[40,509,510,512],{"class":42,"line":254},[40,511,487],{"class":92},[40,513,514],{"class":50}," \"Expired\"\n",[40,516,518,520,522,524,526,528,531,533,535,538,540,543],{"class":42,"line":517},11,[40,519,474],{"class":92},[40,521,497],{"class":96},[40,523,388],{"class":50},[40,525,391],{"class":96},[40,527,504],{"class":92},[40,529,530],{"class":96}," today ",[40,532,399],{"class":92},[40,534,402],{"class":96},[40,536,537],{"class":193},"days",[40,539,119],{"class":92},[40,541,542],{"class":257},"60",[40,544,545],{"class":96},"):\n",[40,547,549,551],{"class":42,"line":548},12,[40,550,487],{"class":92},[40,552,553],{"class":50}," \"Expiring\"\n",[40,555,557,560],{"class":42,"line":556},13,[40,558,559],{"class":92},"    return",[40,561,562],{"class":50}," \"Valid\"\n",[40,564,566],{"class":42,"line":565},14,[40,567,110],{"emptyLinePlaceholder":109},[40,569,571,573,576,578,580,583,586,588,591],{"class":42,"line":570},15,[40,572,385],{"class":96},[40,574,575],{"class":50},"\"Status\"",[40,577,391],{"class":96},[40,579,119],{"class":92},[40,581,582],{"class":96}," cells.apply(status, ",[40,584,585],{"class":193},"axis",[40,587,119],{"class":92},[40,589,590],{"class":257},"1",[40,592,128],{"class":96},[40,594,596,598,601,603,605,607,609,611,613,615,617,619],{"class":42,"line":595},16,[40,597,258],{"class":257},[40,599,600],{"class":96},"(cells[[",[40,602,264],{"class":50},[40,604,190],{"class":96},[40,606,270],{"class":50},[40,608,190],{"class":96},[40,610,202],{"class":50},[40,612,190],{"class":96},[40,614,388],{"class":50},[40,616,190],{"class":96},[40,618,575],{"class":50},[40,620,621],{"class":96},"]]\n",[40,623,625,628,631,633,635,637,640,643,645,647,649,652,655,657,659],{"class":42,"line":624},17,[40,626,627],{"class":96},"      .assign(",[40,629,630],{"class":193},"Completed",[40,632,119],{"class":92},[40,634,385],{"class":96},[40,636,202],{"class":50},[40,638,639],{"class":96},"].dt.date, ",[40,641,642],{"class":193},"Expires",[40,644,119],{"class":92},[40,646,385],{"class":96},[40,648,388],{"class":50},[40,650,651],{"class":96},"].dt.date).to_string(",[40,653,654],{"class":193},"index",[40,656,119],{"class":92},[40,658,328],{"class":257},[40,660,661],{"class":96},"))\n",[10,663,664,665,668,669,672,673,676,677,680],{},"The completions export often contains several records for the same person and course — Asha has done fire safety twice — and only the most recent counts. Sorting by date and taking the last record per person and course keeps exactly that. Each course's validity period then gives an expiry date, using calendar months so a certificate valid for twelve months from 20 October expires on 20 October the next year. Every required course ends up in one of four states: ",[14,666,667],{},"missing"," if there is no record at all, ",[14,670,671],{},"expired"," if the expiry date has passed, ",[14,674,675],{},"expiring"," if it falls within the next sixty days, and ",[14,678,679],{},"valid"," otherwise. Sixty days is a common warning window because it leaves time to book a course; it is one number in the code if a different notice period suits, and some organisations use ninety days for courses with long waiting lists.",[682,683,691,692,691,696,691,700,691,708,691,718,691,725,691,729,691,734,691,740,691,745,691,750,691,755,691,758,691,761,691,765,691,768,691,772,691,777,691,779,691,782,691,786,691,789,691,794,691,800,691,803,691,806,691,809,691,813,691,817,691,820,691,825,691,831],"svg",{"viewBox":684,"role":685,"ariaLabelledBy":686,"xmlns":689,"style":690},"4 0 752 144","img",[687,688],"train-status-t","train-status-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[693,694,695],"title",{"id":687},"How each cell gets its status",[697,698,699],"desc",{"id":688},"For each person and required course, the latest completion is found. No completion means missing. Otherwise the course's validity period gives an expiry date — past it is expired, within sixty days is expiring, and later is valid.",[701,702],"rect",{"x":703,"y":704,"width":705,"height":706,"fill":707},"4","0","752","144","#ffffff",[701,709],{"x":710,"y":711,"width":712,"height":713,"rx":714,"fill":715,"stroke":716,"style":717},"20.0","16.0","115.2","111.5","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[719,720,724],"text",{"x":721,"y":722,"style":723},"77.6","59.0","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Required",[719,726,728],{"x":721,"y":727,"style":723},"76.5","course",[719,730,733],{"x":721,"y":731,"style":732},"96.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","from the role",[42,735],{"x1":736,"y1":737,"x2":738,"y2":737,"stroke":739,"style":717},"140.2","71.8","162.2","var(--brand,#5b5cf0)",[741,742],"polygon",{"points":743,"fill":744},"166.2,71.8 157.2,76.8 157.2,66.8","#5b5cf0",[701,746],{"x":747,"y":711,"width":712,"height":713,"rx":714,"fill":748,"stroke":749,"style":717},"171.2","#fce9e9","var(--danger,#dc2626)",[719,751,754],{"x":752,"y":722,"style":753},"228.8","font-size:13.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Latest",[719,756,757],{"x":752,"y":727,"style":753},"completion?",[719,759,760],{"x":752,"y":731,"style":732},"none → Missing",[42,762],{"x1":763,"y1":737,"x2":764,"y2":737,"stroke":739,"style":717},"291.4","313.4",[741,766],{"points":767,"fill":744},"317.4,71.8 308.4,76.8 308.4,66.8",[701,769],{"x":770,"y":711,"width":712,"height":713,"rx":714,"fill":771,"stroke":739,"style":717},"322.4","#f0f4ff",[719,773,776],{"x":774,"y":722,"style":775},"380.0","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","+ validity",[719,778,405],{"x":774,"y":727,"style":775},[719,780,781],{"x":774,"y":731,"style":732},"expiry date",[42,783],{"x1":784,"y1":737,"x2":785,"y2":737,"stroke":739,"style":717},"442.6","464.6",[741,787],{"points":788,"fill":744},"468.6,71.8 459.6,76.8 459.6,66.8",[701,790],{"x":791,"y":711,"width":712,"height":713,"rx":714,"fill":792,"stroke":793,"style":717},"473.6","#fdefd8","var(--gold,#b4740a)",[719,795,799],{"x":796,"y":797,"style":798},"531.2","41.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Expired or",[719,801,802],{"x":796,"y":722,"style":798},"within 60",[719,804,805],{"x":796,"y":727,"style":798},"days?",[719,807,808],{"x":796,"y":731,"style":732},"Expired \u002F",[719,810,812],{"x":796,"y":811,"style":732},"113.5","Expiring",[42,814],{"x1":815,"y1":737,"x2":816,"y2":737,"stroke":739,"style":717},"593.8","615.8",[741,818],{"points":819,"fill":744},"619.8,71.8 610.8,76.8 610.8,66.8",[701,821],{"x":822,"y":711,"width":712,"height":713,"rx":714,"fill":823,"stroke":824,"style":717},"624.8","#d9f4f1","var(--teal,#0f9488)",[719,826,830],{"x":827,"y":828,"style":829},"682.4","67.8","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Otherwise",[719,832,834],{"x":827,"y":833,"style":732},"87.2","Valid",[25,836,838],{"id":837},"step-3-the-matrix-and-compliance-by-department","Step 3 — The matrix and compliance by department",[30,840,842],{"className":83,"code":841,"language":85,"meta":35,"style":35},"matrix = cells.pivot_table(index=[\"Department\", \"Name\"], columns=\"Course\", values=\"Status\", aggfunc=\"first\")\nmatrix = matrix.reindex(columns=courses[\"Course\"]).fillna(\"—\")         # \"—\" = not required for the role\nprint(matrix.to_string())\n\ncells[\"Compliant\"] = cells[\"Status\"].isin([\"Valid\", \"Expiring\"])\nby_dept = cells.groupby(\"Department\").agg(required=(\"Course\", \"size\"), compliant=(\"Compliant\", \"sum\"))\nby_dept[\"Compliance\"] = (by_dept[\"compliant\"] \u002F by_dept[\"required\"]).round(3)\nprint(by_dept.to_string())\nprint(\"overall:\", f\"{cells['Compliant'].mean():.0%}\")\n",[37,843,844,897,928,935,939,968,1017,1054,1061],{"__ignoreMap":35},[40,845,846,849,851,854,856,858,860,863,865,867,869,872,874,876,878,881,883,885,887,890,892,895],{"class":42,"line":43},[40,847,848],{"class":96},"matrix ",[40,850,119],{"class":92},[40,852,853],{"class":96}," cells.pivot_table(",[40,855,654],{"class":193},[40,857,119],{"class":92},[40,859,199],{"class":96},[40,861,862],{"class":50},"\"Department\"",[40,864,190],{"class":96},[40,866,264],{"class":50},[40,868,320],{"class":96},[40,870,871],{"class":193},"columns",[40,873,119],{"class":92},[40,875,270],{"class":50},[40,877,190],{"class":96},[40,879,880],{"class":193},"values",[40,882,119],{"class":92},[40,884,575],{"class":50},[40,886,190],{"class":96},[40,888,889],{"class":193},"aggfunc",[40,891,119],{"class":92},[40,893,894],{"class":50},"\"first\"",[40,896,128],{"class":96},[40,898,899,901,903,906,908,910,913,915,918,921,924],{"class":42,"line":106},[40,900,848],{"class":96},[40,902,119],{"class":92},[40,904,905],{"class":96}," matrix.reindex(",[40,907,871],{"class":193},[40,909,119],{"class":92},[40,911,912],{"class":96},"courses[",[40,914,270],{"class":50},[40,916,917],{"class":96},"]).fillna(",[40,919,920],{"class":50},"\"—\"",[40,922,923],{"class":96},")         ",[40,925,927],{"class":926},"s-wDw","# \"—\" = not required for the role\n",[40,929,930,932],{"class":42,"line":113},[40,931,258],{"class":257},[40,933,934],{"class":96},"(matrix.to_string())\n",[40,936,937],{"class":42,"line":131},[40,938,110],{"emptyLinePlaceholder":109},[40,940,941,943,946,948,950,953,955,958,961,963,966],{"class":42,"line":147},[40,942,385],{"class":96},[40,944,945],{"class":50},"\"Compliant\"",[40,947,391],{"class":96},[40,949,119],{"class":92},[40,951,952],{"class":96}," cells[",[40,954,575],{"class":50},[40,956,957],{"class":96},"].isin([",[40,959,960],{"class":50},"\"Valid\"",[40,962,190],{"class":96},[40,964,965],{"class":50},"\"Expiring\"",[40,967,205],{"class":96},[40,969,970,973,975,978,980,983,986,988,991,993,995,998,1001,1004,1006,1008,1010,1012,1015],{"class":42,"line":162},[40,971,972],{"class":96},"by_dept ",[40,974,119],{"class":92},[40,976,977],{"class":96}," cells.groupby(",[40,979,862],{"class":50},[40,981,982],{"class":96},").agg(",[40,984,985],{"class":193},"required",[40,987,119],{"class":92},[40,989,990],{"class":96},"(",[40,992,270],{"class":50},[40,994,190],{"class":96},[40,996,997],{"class":50},"\"size\"",[40,999,1000],{"class":96},"), ",[40,1002,1003],{"class":193},"compliant",[40,1005,119],{"class":92},[40,1007,990],{"class":96},[40,1009,945],{"class":50},[40,1011,190],{"class":96},[40,1013,1014],{"class":50},"\"sum\"",[40,1016,661],{"class":96},[40,1018,1019,1022,1025,1027,1029,1032,1035,1037,1040,1043,1046,1049,1052],{"class":42,"line":177},[40,1020,1021],{"class":96},"by_dept[",[40,1023,1024],{"class":50},"\"Compliance\"",[40,1026,391],{"class":96},[40,1028,119],{"class":92},[40,1030,1031],{"class":96}," (by_dept[",[40,1033,1034],{"class":50},"\"compliant\"",[40,1036,391],{"class":96},[40,1038,1039],{"class":92},"\u002F",[40,1041,1042],{"class":96}," by_dept[",[40,1044,1045],{"class":50},"\"required\"",[40,1047,1048],{"class":96},"]).round(",[40,1050,1051],{"class":257},"3",[40,1053,128],{"class":96},[40,1055,1056,1058],{"class":42,"line":208},[40,1057,258],{"class":257},[40,1059,1060],{"class":96},"(by_dept.to_string())\n",[40,1062,1063,1065,1067,1070,1072,1075,1078,1082,1084,1087,1090,1093,1096,1098],{"class":42,"line":213},[40,1064,258],{"class":257},[40,1066,990],{"class":96},[40,1068,1069],{"class":50},"\"overall:\"",[40,1071,190],{"class":96},[40,1073,1074],{"class":92},"f",[40,1076,1077],{"class":50},"\"",[40,1079,1081],{"class":1080},"sSjpA","{",[40,1083,385],{"class":96},[40,1085,1086],{"class":50},"'Compliant'",[40,1088,1089],{"class":96},"].mean()",[40,1091,1092],{"class":92},":.0%",[40,1094,1095],{"class":1080},"}",[40,1097,1077],{"class":50},[40,1099,128],{"class":96},[10,1101,1102,1103,1105,1106,1108],{},"Pivoting the statuses gives the familiar matrix: one row per person, grouped by department, and one column per course. Courses a person's role does not require stay empty in the pivot and are shown as a dash, which is different from ",[14,1104,667],{}," — the forklift licence is not missing for the office administrators, it simply does not apply to them. A certificate held but not required, such as Dan's first aid, does not appear at all, because the matrix is driven by requirements. Compliance is the share of required course-person pairs that are currently valid, counting ",[14,1107,675],{}," as still compliant — they are valid today, they just need booking. The department view shows where the gaps are. The office is weakest at 50%: Eva's data protection certificate expired in September and she has no fire safety record at all. The warehouse is at 75%, with Ben's forklift licence and Chloe's manual handling both expired — the licence matters most, because Ben should not be driving a forklift until it is renewed. The kitchen is at 83%, but only because Femi's first aid is still valid for another two weeks; Grace has never done it. Overall, 72% of required certificates are current.",[682,1110,691,1116,691,1119,691,1122,691,1127,691,1133,691,1137,691,1143,691,1147,691,1151,691,1155,691,1159,691,1163,691,1167,691,1171,691,1175,691,1179,691,1183,691,1187,691,1190,691,1193,691,1197,691,1199,691,1202,691,1204,691,1207,691,1209,691,1212,691,1214,691,1217,691,1219,691,1222,691,1225,691,1229,691,1231,691,1235,691,1237,691,1239,691,1241,691,1243,691,1245,691,1248,691,1250,691,1253,691,1255,691,1257,691,1259,691,1262,691,1264,691,1267,691,1269,691,1273,691,1275,691,1277,691,1279,691,1281,691,1283,691,1285,691,1287,691,1289,691,1292,691,1295,691,1297,691,1300,691,1302,691,1304,691,1306,691,1308,691,1310,691,1312,691,1314,691,1316,691,1318,691,1320,691,1323,691,1327,691,1329,691,1332,691,1334,691,1336,691,1338,691,1340,691,1342,691,1345,691,1347,691,1349,691,1351,691,1353,691,1356,691,1360,691,1362,691,1365,691,1367,691,1369,691,1371,691,1373,691,1375,691,1377,691,1379,691,1381,691,1383,691,1385,691,1388,691,1392,691,1394,691,1397,691,1399,691,1401,691,1403,691,1405,691,1407,691,1409,691,1411,691,1413,691,1415],{"viewBox":1111,"role":685,"ariaLabelledBy":1112,"xmlns":689,"style":1115},"40 0 679 252",[1113,1114],"train-matrix-t","train-matrix-d","width:100%;max-width:679px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[693,1117,1118],{"id":1113},"The training matrix (extract)",[697,1120,1121],{"id":1114},"In the warehouse Asha's fire safety is expiring, Ben's forklift licence has expired and Chloe's manual handling has expired. In the office Eva has no fire safety record and her data protection has expired. In the kitchen Femi's first aid is expiring and Grace has no first aid record. Courses a role does not require are shown as a dash.",[701,1123],{"x":1124,"y":704,"width":1125,"height":1126,"fill":707},"40","679","252",[701,1128],{"x":1129,"y":711,"width":1130,"height":1131,"rx":704,"fill":715,"stroke":716,"style":1132},"56.7","32.0","24.0","stroke-width:1px",[701,1134],{"x":1135,"y":711,"width":1136,"height":1131,"rx":704,"fill":715,"stroke":716,"style":1132},"88.7","89.6",[719,1138,1142],{"x":1139,"y":1140,"style":1141},"133.5","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[701,1144],{"x":1145,"y":711,"width":1146,"height":1131,"rx":704,"fill":715,"stroke":716,"style":1132},"178.3","115.5",[719,1148,1150],{"x":1149,"y":1140,"style":1141},"236.1","B",[701,1152],{"x":1153,"y":711,"width":1154,"height":1131,"rx":704,"fill":715,"stroke":716,"style":1132},"293.8","126.1",[719,1156,1158],{"x":1157,"y":1140,"style":1141},"356.9","C",[701,1160],{"x":1161,"y":711,"width":1162,"height":1131,"rx":704,"fill":715,"stroke":716,"style":1132},"419.9","89.5",[719,1164,1166],{"x":1165,"y":1140,"style":1141},"464.7","D",[701,1168],{"x":1169,"y":711,"width":1170,"height":1131,"rx":704,"fill":715,"stroke":716,"style":1132},"509.4","119.3",[719,1172,1174],{"x":1173,"y":1140,"style":1141},"569.1","E",[701,1176],{"x":1177,"y":711,"width":1178,"height":1131,"rx":704,"fill":715,"stroke":716,"style":1132},"628.7","74.7",[719,1180,1182],{"x":1181,"y":1140,"style":1141},"666.0","F",[701,1184],{"x":1129,"y":1185,"width":1130,"height":1186,"rx":704,"fill":715,"stroke":716,"style":1132},"40.0","28.0",[719,1188,590],{"x":1189,"y":722,"style":1141},"72.7",[701,1191],{"x":1135,"y":1185,"width":1136,"height":1186,"rx":704,"fill":771,"stroke":739,"style":1192},"stroke-width:1.5px",[719,1194,1196],{"x":1139,"y":722,"style":1195},"font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Person",[701,1198],{"x":1145,"y":1185,"width":1146,"height":1186,"rx":704,"fill":771,"stroke":739,"style":1192},[719,1200,1201],{"x":1149,"y":722,"style":1195},"Forklift licence",[701,1203],{"x":1153,"y":1185,"width":1154,"height":1186,"rx":704,"fill":771,"stroke":739,"style":1192},[719,1205,1206],{"x":1157,"y":722,"style":1195},"Manual handling",[701,1208],{"x":1161,"y":1185,"width":1162,"height":1186,"rx":704,"fill":771,"stroke":739,"style":1192},[719,1210,1211],{"x":1165,"y":722,"style":1195},"Fire safety",[701,1213],{"x":1169,"y":1185,"width":1170,"height":1186,"rx":704,"fill":771,"stroke":739,"style":1192},[719,1215,1216],{"x":1173,"y":722,"style":1195},"Data protection",[701,1218],{"x":1177,"y":1185,"width":1178,"height":1186,"rx":704,"fill":771,"stroke":739,"style":1192},[719,1220,1221],{"x":1181,"y":722,"style":1195},"First aid",[701,1223],{"x":1129,"y":1224,"width":1130,"height":1186,"rx":704,"fill":715,"stroke":716,"style":1132},"68.0",[719,1226,1228],{"x":1189,"y":1227,"style":1141},"87.0","2",[701,1230],{"x":1135,"y":1224,"width":1136,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1232,1234],{"x":1139,"y":1227,"style":1233},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Asha Patel",[701,1236],{"x":1145,"y":1224,"width":1146,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1238,834],{"x":1149,"y":1227,"style":1233},[701,1240],{"x":1153,"y":1224,"width":1154,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1242,834],{"x":1157,"y":1227,"style":1233},[701,1244],{"x":1161,"y":1224,"width":1162,"height":1186,"rx":704,"fill":792,"stroke":793,"style":1192},[719,1246,812],{"x":1165,"y":1227,"style":1247},"font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle",[701,1249],{"x":1169,"y":1224,"width":1170,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1251,1252],{"x":1173,"y":1227,"style":1233},"—",[701,1254],{"x":1177,"y":1224,"width":1178,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1256,1252],{"x":1181,"y":1227,"style":1233},[701,1258],{"x":1129,"y":731,"width":1130,"height":1186,"rx":704,"fill":715,"stroke":716,"style":1132},[719,1260,1051],{"x":1189,"y":1261,"style":1141},"115.0",[701,1263],{"x":1135,"y":731,"width":1136,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1265,1266],{"x":1139,"y":1261,"style":1233},"Ben Ortiz",[701,1268],{"x":1145,"y":731,"width":1146,"height":1186,"rx":704,"fill":748,"stroke":749,"style":1192},[719,1270,1272],{"x":1149,"y":1261,"style":1271},"font-size:12px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Expired",[701,1274],{"x":1153,"y":731,"width":1154,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1276,834],{"x":1157,"y":1261,"style":1233},[701,1278],{"x":1161,"y":731,"width":1162,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1280,834],{"x":1165,"y":1261,"style":1233},[701,1282],{"x":1169,"y":731,"width":1170,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1284,1252],{"x":1173,"y":1261,"style":1233},[701,1286],{"x":1177,"y":731,"width":1178,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1288,1252],{"x":1181,"y":1261,"style":1233},[701,1290],{"x":1129,"y":1291,"width":1130,"height":1186,"rx":704,"fill":715,"stroke":716,"style":1132},"124.0",[719,1293,703],{"x":1189,"y":1294,"style":1141},"143.0",[701,1296],{"x":1135,"y":1291,"width":1136,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1298,1299],{"x":1139,"y":1294,"style":1233},"Chloe Kim",[701,1301],{"x":1145,"y":1291,"width":1146,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1303,1252],{"x":1149,"y":1294,"style":1233},[701,1305],{"x":1153,"y":1291,"width":1154,"height":1186,"rx":704,"fill":748,"stroke":749,"style":1192},[719,1307,1272],{"x":1157,"y":1294,"style":1271},[701,1309],{"x":1161,"y":1291,"width":1162,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1311,834],{"x":1165,"y":1294,"style":1233},[701,1313],{"x":1169,"y":1291,"width":1170,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1315,1252],{"x":1173,"y":1294,"style":1233},[701,1317],{"x":1177,"y":1291,"width":1178,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1319,1252],{"x":1181,"y":1294,"style":1233},[701,1321],{"x":1129,"y":1322,"width":1130,"height":1186,"rx":704,"fill":715,"stroke":716,"style":1132},"152.0",[719,1324,1326],{"x":1189,"y":1325,"style":1141},"171.0","5",[701,1328],{"x":1135,"y":1322,"width":1136,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1330,1331],{"x":1139,"y":1325,"style":1233},"Eva Novak",[701,1333],{"x":1145,"y":1322,"width":1146,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1335,1252],{"x":1149,"y":1325,"style":1233},[701,1337],{"x":1153,"y":1322,"width":1154,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1339,1252],{"x":1157,"y":1325,"style":1233},[701,1341],{"x":1161,"y":1322,"width":1162,"height":1186,"rx":704,"fill":748,"stroke":749,"style":1192},[719,1343,1344],{"x":1165,"y":1325,"style":1271},"Missing",[701,1346],{"x":1169,"y":1322,"width":1170,"height":1186,"rx":704,"fill":748,"stroke":749,"style":1192},[719,1348,1272],{"x":1173,"y":1325,"style":1271},[701,1350],{"x":1177,"y":1322,"width":1178,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1352,1252],{"x":1181,"y":1325,"style":1233},[701,1354],{"x":1129,"y":1355,"width":1130,"height":1186,"rx":704,"fill":715,"stroke":716,"style":1132},"180.0",[719,1357,1359],{"x":1189,"y":1358,"style":1141},"199.0","6",[701,1361],{"x":1135,"y":1355,"width":1136,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1363,1364],{"x":1139,"y":1358,"style":1233},"Femi Ade",[701,1366],{"x":1145,"y":1355,"width":1146,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1368,1252],{"x":1149,"y":1358,"style":1233},[701,1370],{"x":1153,"y":1355,"width":1154,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1372,1252],{"x":1157,"y":1358,"style":1233},[701,1374],{"x":1161,"y":1355,"width":1162,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1376,834],{"x":1165,"y":1358,"style":1233},[701,1378],{"x":1169,"y":1355,"width":1170,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1380,1252],{"x":1173,"y":1358,"style":1233},[701,1382],{"x":1177,"y":1355,"width":1178,"height":1186,"rx":704,"fill":792,"stroke":793,"style":1192},[719,1384,812],{"x":1181,"y":1358,"style":1247},[701,1386],{"x":1129,"y":1387,"width":1130,"height":1186,"rx":704,"fill":715,"stroke":716,"style":1132},"208.0",[719,1389,1391],{"x":1189,"y":1390,"style":1141},"227.0","7",[701,1393],{"x":1135,"y":1387,"width":1136,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1395,1396],{"x":1139,"y":1390,"style":1233},"Grace Liu",[701,1398],{"x":1145,"y":1387,"width":1146,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1400,1252],{"x":1149,"y":1390,"style":1233},[701,1402],{"x":1153,"y":1387,"width":1154,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1404,1252],{"x":1157,"y":1390,"style":1233},[701,1406],{"x":1161,"y":1387,"width":1162,"height":1186,"rx":704,"fill":792,"stroke":793,"style":1192},[719,1408,812],{"x":1165,"y":1390,"style":1247},[701,1410],{"x":1169,"y":1387,"width":1170,"height":1186,"rx":704,"fill":707,"stroke":716,"style":1132},[719,1412,1252],{"x":1173,"y":1390,"style":1233},[701,1414],{"x":1177,"y":1387,"width":1178,"height":1186,"rx":704,"fill":748,"stroke":749,"style":1192},[719,1416,1344],{"x":1181,"y":1390,"style":1271},[25,1418,1420],{"id":1419},"step-4-the-workbook-and-the-action-list","Step 4 — The workbook and the action list",[30,1422,1424],{"className":83,"code":1423,"language":85,"meta":35,"style":35},"from openpyxl import load_workbook\nfrom openpyxl.formatting.rule import CellIsRule\nfrom openpyxl.styles import PatternFill\n\nactions = (cells[cells[\"Status\"] != \"Valid\"]\n           .assign(Due=lambda d: d[\"Expires\"].fillna(today).dt.date)\n           .sort_values([\"Status\", \"Due\"])[[\"Department\", \"Name\", \"Course\", \"Status\", \"Due\"]])\npath = \"training_matrix_2026-10.xlsx\"\nwith pd.ExcelWriter(path) as xw:\n    matrix.to_excel(xw, sheet_name=\"Matrix\")\n    by_dept.reset_index().to_excel(xw, sheet_name=\"By department\", index=False)\n    actions.to_excel(xw, sheet_name=\"Actions\", index=False)\n\nwb = load_workbook(path)\nws = wb[\"Matrix\"]\narea = f\"C2:{ws.cell(1, ws.max_column).column_letter}{ws.max_row}\"\nfor text, colour in [(\"Valid\", \"C6EFCE\"), (\"Expiring\", \"FFEB9C\"), (\"Expired\", \"FFC7CE\"), (\"Missing\", \"F4B084\")]:\n    ws.conditional_formatting.add(area, CellIsRule(operator=\"equal\", formula=[f'\"{text}\"'], fill=PatternFill(\"solid\", bgColor=colour)))\nws.freeze_panes = \"C2\"\nwb.save(path)\nprint(actions.to_string(index=False))\n",[37,1425,1426,1439,1451,1463,1467,1490,1509,1545,1555,1568,1583,1605,1627,1631,1641,1655,1689,1742,1802,1813,1819],{"__ignoreMap":35},[40,1427,1428,1431,1434,1436],{"class":42,"line":43},[40,1429,1430],{"class":92},"from",[40,1432,1433],{"class":96}," openpyxl ",[40,1435,93],{"class":92},[40,1437,1438],{"class":96}," load_workbook\n",[40,1440,1441,1443,1446,1448],{"class":42,"line":106},[40,1442,1430],{"class":92},[40,1444,1445],{"class":96}," openpyxl.formatting.rule ",[40,1447,93],{"class":92},[40,1449,1450],{"class":96}," CellIsRule\n",[40,1452,1453,1455,1458,1460],{"class":42,"line":113},[40,1454,1430],{"class":92},[40,1456,1457],{"class":96}," openpyxl.styles ",[40,1459,93],{"class":92},[40,1461,1462],{"class":96}," PatternFill\n",[40,1464,1465],{"class":42,"line":131},[40,1466,110],{"emptyLinePlaceholder":109},[40,1468,1469,1472,1474,1477,1479,1481,1484,1487],{"class":42,"line":147},[40,1470,1471],{"class":96},"actions ",[40,1473,119],{"class":92},[40,1475,1476],{"class":96}," (cells[cells[",[40,1478,575],{"class":50},[40,1480,391],{"class":96},[40,1482,1483],{"class":92},"!=",[40,1485,1486],{"class":50}," \"Valid\"",[40,1488,1489],{"class":96},"]\n",[40,1491,1492,1495,1498,1501,1504,1506],{"class":42,"line":162},[40,1493,1494],{"class":96},"           .assign(",[40,1496,1497],{"class":193},"Due",[40,1499,1500],{"class":92},"=lambda",[40,1502,1503],{"class":96}," d: d[",[40,1505,388],{"class":50},[40,1507,1508],{"class":96},"].fillna(today).dt.date)\n",[40,1510,1511,1514,1516,1518,1521,1524,1526,1528,1530,1532,1534,1536,1538,1540,1542],{"class":42,"line":177},[40,1512,1513],{"class":96},"           .sort_values([",[40,1515,575],{"class":50},[40,1517,190],{"class":96},[40,1519,1520],{"class":50},"\"Due\"",[40,1522,1523],{"class":96},"])[[",[40,1525,862],{"class":50},[40,1527,190],{"class":96},[40,1529,264],{"class":50},[40,1531,190],{"class":96},[40,1533,270],{"class":50},[40,1535,190],{"class":96},[40,1537,575],{"class":50},[40,1539,190],{"class":96},[40,1541,1520],{"class":50},[40,1543,1544],{"class":96},"]])\n",[40,1546,1547,1550,1552],{"class":42,"line":208},[40,1548,1549],{"class":96},"path ",[40,1551,119],{"class":92},[40,1553,1554],{"class":50}," \"training_matrix_2026-10.xlsx\"\n",[40,1556,1557,1560,1563,1565],{"class":42,"line":213},[40,1558,1559],{"class":92},"with",[40,1561,1562],{"class":96}," pd.ExcelWriter(path) ",[40,1564,100],{"class":92},[40,1566,1567],{"class":96}," xw:\n",[40,1569,1570,1573,1576,1578,1581],{"class":42,"line":254},[40,1571,1572],{"class":96},"    matrix.to_excel(xw, ",[40,1574,1575],{"class":193},"sheet_name",[40,1577,119],{"class":92},[40,1579,1580],{"class":50},"\"Matrix\"",[40,1582,128],{"class":96},[40,1584,1585,1588,1590,1592,1595,1597,1599,1601,1603],{"class":42,"line":517},[40,1586,1587],{"class":96},"    by_dept.reset_index().to_excel(xw, ",[40,1589,1575],{"class":193},[40,1591,119],{"class":92},[40,1593,1594],{"class":50},"\"By department\"",[40,1596,190],{"class":96},[40,1598,654],{"class":193},[40,1600,119],{"class":92},[40,1602,328],{"class":257},[40,1604,128],{"class":96},[40,1606,1607,1610,1612,1614,1617,1619,1621,1623,1625],{"class":42,"line":548},[40,1608,1609],{"class":96},"    actions.to_excel(xw, ",[40,1611,1575],{"class":193},[40,1613,119],{"class":92},[40,1615,1616],{"class":50},"\"Actions\"",[40,1618,190],{"class":96},[40,1620,654],{"class":193},[40,1622,119],{"class":92},[40,1624,328],{"class":257},[40,1626,128],{"class":96},[40,1628,1629],{"class":42,"line":556},[40,1630,110],{"emptyLinePlaceholder":109},[40,1632,1633,1636,1638],{"class":42,"line":565},[40,1634,1635],{"class":96},"wb ",[40,1637,119],{"class":92},[40,1639,1640],{"class":96}," load_workbook(path)\n",[40,1642,1643,1646,1648,1651,1653],{"class":42,"line":570},[40,1644,1645],{"class":96},"ws ",[40,1647,119],{"class":92},[40,1649,1650],{"class":96}," wb[",[40,1652,1580],{"class":50},[40,1654,1489],{"class":96},[40,1656,1657,1660,1662,1665,1668,1670,1673,1675,1678,1681,1684,1686],{"class":42,"line":595},[40,1658,1659],{"class":96},"area ",[40,1661,119],{"class":92},[40,1663,1664],{"class":92}," f",[40,1666,1667],{"class":50},"\"C2:",[40,1669,1081],{"class":1080},[40,1671,1672],{"class":96},"ws.cell(",[40,1674,590],{"class":257},[40,1676,1677],{"class":96},", ws.max_column).column_letter",[40,1679,1680],{"class":1080},"}{",[40,1682,1683],{"class":96},"ws.max_row",[40,1685,1095],{"class":1080},[40,1687,1688],{"class":50},"\"\n",[40,1690,1691,1694,1697,1699,1702,1704,1706,1709,1712,1714,1716,1719,1721,1724,1726,1729,1731,1734,1736,1739],{"class":42,"line":624},[40,1692,1693],{"class":92},"for",[40,1695,1696],{"class":96}," text, colour ",[40,1698,436],{"class":92},[40,1700,1701],{"class":96}," [(",[40,1703,960],{"class":50},[40,1705,190],{"class":96},[40,1707,1708],{"class":50},"\"C6EFCE\"",[40,1710,1711],{"class":96},"), (",[40,1713,965],{"class":50},[40,1715,190],{"class":96},[40,1717,1718],{"class":50},"\"FFEB9C\"",[40,1720,1711],{"class":96},[40,1722,1723],{"class":50},"\"Expired\"",[40,1725,190],{"class":96},[40,1727,1728],{"class":50},"\"FFC7CE\"",[40,1730,1711],{"class":96},[40,1732,1733],{"class":50},"\"Missing\"",[40,1735,190],{"class":96},[40,1737,1738],{"class":50},"\"F4B084\"",[40,1740,1741],{"class":96},")]:\n",[40,1743,1745,1748,1751,1753,1756,1758,1761,1763,1765,1767,1770,1772,1774,1776,1779,1781,1784,1786,1789,1792,1794,1797,1799],{"class":42,"line":1744},18,[40,1746,1747],{"class":96},"    ws.conditional_formatting.add(area, CellIsRule(",[40,1749,1750],{"class":193},"operator",[40,1752,119],{"class":92},[40,1754,1755],{"class":50},"\"equal\"",[40,1757,190],{"class":96},[40,1759,1760],{"class":193},"formula",[40,1762,119],{"class":92},[40,1764,199],{"class":96},[40,1766,1074],{"class":92},[40,1768,1769],{"class":50},"'\"",[40,1771,1081],{"class":1080},[40,1773,719],{"class":96},[40,1775,1095],{"class":1080},[40,1777,1778],{"class":50},"\"'",[40,1780,320],{"class":96},[40,1782,1783],{"class":193},"fill",[40,1785,119],{"class":92},[40,1787,1788],{"class":96},"PatternFill(",[40,1790,1791],{"class":50},"\"solid\"",[40,1793,190],{"class":96},[40,1795,1796],{"class":193},"bgColor",[40,1798,119],{"class":92},[40,1800,1801],{"class":96},"colour)))\n",[40,1803,1805,1808,1810],{"class":42,"line":1804},19,[40,1806,1807],{"class":96},"ws.freeze_panes ",[40,1809,119],{"class":92},[40,1811,1812],{"class":50}," \"C2\"\n",[40,1814,1816],{"class":42,"line":1815},20,[40,1817,1818],{"class":96},"wb.save(path)\n",[40,1820,1822,1824,1827,1829,1831,1833],{"class":42,"line":1821},21,[40,1823,258],{"class":257},[40,1825,1826],{"class":96},"(actions.to_string(",[40,1828,654],{"class":193},[40,1830,119],{"class":92},[40,1832,328],{"class":257},[40,1834,661],{"class":96},[10,1836,1837],{},"The matrix sheet colours each status with conditional formatting rather than fixed fills, so if someone corrects a status by hand the colour follows. The department sheet gives the compliance percentages for a management summary, and the action list is what the training coordinator works from: every course that is missing, expired or about to expire, with the date it lapsed or will lapse, sorted so the expired items come first. In this run that is three expired certificates, five expiring in the next two months — four of them in October and November — and two that have never been completed, ten actions in all for seven people. Run monthly, with the action list emailed to line managers, the matrix replaces the hand-coloured spreadsheet completely; the only manual inputs left are the role requirements and validity periods, which change rarely.",[25,1839,1841],{"id":1840},"keeping-the-inputs-honest","Keeping the inputs honest",[10,1843,1844,1845,1847,1848,1851,1852,1854,1855,190,1858,190,1861,1864,1865,1867,1868,1871],{},"The matrix is only as reliable as the joins behind it, and three things commonly break them. First, people must be matched on an employee ID, never on names: the HR system says ",[14,1846,1234],{},", the learning system ",[14,1849,1850],{},"A. Patel",", and a name join silently turns her valid certificates into ",[14,1853,667],{},". Second, course names drift — ",[14,1856,1857],{},"Fire Safety",[14,1859,1860],{},"Fire safety awareness",[14,1862,1863],{},"Fire Safety (refresher)"," — so map every variant in the export to one canonical course name before matching, and list any course in the completions that is not in the course table, rather than dropping it. Third, new starters need a grace period: someone who joined last week cannot have done their induction training yet, so a ",[14,1866,667],{}," certificate for a person whose start date is within, say, thirty days is better shown as ",[14,1869,1870],{},"due"," than counted against the department. Each of these is a few lines of pandas, and together they decide whether managers trust the report.",[682,1873,691,1878,691,1881,691,1884,691,1887,691,1891,691,1897,691,1902,691,1905,691,1909,691,1912,691,1916,691,1920,691,1922,691,1925,691,1928,691,1933,691,1937,691,1940],{"viewBox":1874,"role":685,"ariaLabelledBy":1875,"xmlns":689,"style":690},"4 0 752 147",[1876,1877],"train-counts-t","train-counts-d",[693,1879,1880],{"id":1876},"What counts towards compliance",[697,1882,1883],{"id":1877},"Valid and expiring certificates count as compliant because they are in date today. Expired and missing certificates are not compliant. Courses the role does not require are excluded from the count altogether.",[701,1885],{"x":703,"y":704,"width":705,"height":1886,"fill":707},"147",[701,1888],{"x":710,"y":711,"width":1889,"height":1261,"rx":1890,"fill":823,"stroke":824,"style":717},"228.0","14",[719,1892,1896],{"x":1893,"y":1894,"style":1895},"134.0","44.0","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Compliant",[42,1898],{"x1":1899,"y1":1900,"x2":1901,"y2":1900,"stroke":824,"style":1132},"36.0","57.0","232.0",[719,1903,834],{"x":1893,"y":1904,"style":1233},"77.0",[719,1906,1908],{"x":1893,"y":1907,"style":1233},"103.0","Expiring — book a refresher",[701,1910],{"x":1911,"y":711,"width":1889,"height":1261,"rx":1890,"fill":748,"stroke":749,"style":717},"266.0",[719,1913,1915],{"x":774,"y":1894,"style":1914},"font-size:14px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Not compliant",[42,1917],{"x1":1918,"y1":1900,"x2":1919,"y2":1900,"stroke":749,"style":1132},"282.0","478.0",[719,1921,1272],{"x":774,"y":1904,"style":1233},[719,1923,1924],{"x":774,"y":1907,"style":1233},"Missing — never completed",[701,1926],{"x":1927,"y":711,"width":1889,"height":1261,"rx":1890,"fill":715,"stroke":716,"style":717},"512.0",[719,1929,1932],{"x":1930,"y":1894,"style":1931},"626.0","font-size:14px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Not counted",[42,1934],{"x1":1935,"y1":1900,"x2":1936,"y2":1900,"stroke":716,"style":1132},"528.0","724.0",[719,1938,1939],{"x":1930,"y":1904,"style":1233},"not required for the role",[719,1941,1942],{"x":1930,"y":1907,"style":1233},"shown as a dash",[25,1944,1946],{"id":1945},"common-pitfalls","Common pitfalls",[1948,1949,1950,1958,1964,1970,1976],"ul",{},[1951,1952,1953,1957],"li",{},[1954,1955,1956],"strong",{},"Counting every completion."," Keep only the latest record per person and course.",[1951,1959,1960,1963],{},[1954,1961,1962],{},"Requirements kept per person."," Derive them from roles so changes flow through.",[1951,1965,1966,1969],{},[1954,1967,1968],{},"Not required shown as missing."," Distinguish the two; a dash is not a gap.",[1951,1971,1972,1975],{},[1954,1973,1974],{},"Expiry by days instead of months."," Use calendar months for certificates valid for N months.",[1951,1977,1978,1981],{},[1954,1979,1980],{},"Expiring treated as non-compliant."," They are valid today; flag them for booking, not as failures.",[25,1983,1985],{"id":1984},"conclusion","Conclusion",[10,1987,1988],{},"A training compliance matrix joins people to the courses their role requires, takes each person's latest completion of each course, adds the course's validity period to get an expiry date, and classifies every pair as valid, expiring, expired or missing. Pivoting gives the matrix, a group-by gives compliance by department, and a filtered list of everything not valid becomes the action list, all written to a colour-coded workbook.",[25,1990,1992],{"id":1991},"frequently-asked-questions","Frequently asked questions",[10,1994,1995,1998,1999,23],{},[1954,1996,1997],{},"How do I get the latest training completion per person in pandas?","\nSort completions by date and use ",[37,2000,2001],{},"groupby([\"Employee\", \"Course\"]).last()",[10,2003,2004,2007,2008,2011],{},[1954,2005,2006],{},"How do I calculate certificate expiry dates in pandas?","\nAdd the course's validity in months with ",[37,2009,2010],{},"pd.DateOffset(months=n)"," to the completion date.",[10,2013,2014,2017],{},[1954,2015,2016],{},"How do I show \"not required\" differently from \"missing\"?","\nBuild the matrix only from required courses; cells for courses a role does not need stay empty and can be shown as a dash.",[10,2019,2020,2023,2024,2027],{},[1954,2021,2022],{},"How do I colour a training matrix in Excel from Python?","\nAdd ",[37,2025,2026],{},"CellIsRule"," conditional formatting rules for each status text with openpyxl.",[25,2029,2031],{"id":2030},"related","Related",[1948,2033,2034,2040,2047,2054],{},[1951,2035,2036,2037,2039],{},"Up one level: ",[19,2038,22],{"href":21}," — finance and operations reports.",[1951,2041,2042,2046],{},[19,2043,2045],{"href":2044},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-headcount-and-attrition-report\u002F","Build a Headcount and Attrition Report in Excel with Python"," — other people reporting.",[1951,2048,2049,2053],{},[19,2050,2052],{"href":2051},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fhighlight-cells-containing-text-with-openpyxl\u002F","Highlight Cells Containing Text with openpyxl"," — colouring by status text.",[1951,2055,2056,2060],{},[19,2057,2059],{"href":2058},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-an-employee-leave-balance-report\u002F","Build an Employee Leave Balance Report"," — another HR report.",[2062,2063,2064],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":35,"searchDepth":106,"depth":106,"links":2066},[2067,2068,2069,2070,2071,2072,2073,2074,2075,2076],{"id":27,"depth":106,"text":28},{"id":79,"depth":106,"text":80},{"id":289,"depth":106,"text":290},{"id":837,"depth":106,"text":838},{"id":1419,"depth":106,"text":1420},{"id":1840,"depth":106,"text":1841},{"id":1945,"depth":106,"text":1946},{"id":1984,"depth":106,"text":1985},{"id":1991,"depth":106,"text":1992},{"id":2030,"depth":106,"text":2031},"2026-10-02","Build the people-by-course matrix that shows who is trained, who is due and who is out of date: work out which courses each person needs from their role, find their latest completion of each, apply each course's validity period to get an expiry date, classify every cell as valid, expiring, expired, missing or not required, and write a colour-coded Excel matrix with compliance by department and an action list.","md",[2081,2083,2085,2087],{"q":1997,"a":2082},"Sort completions by date and use groupby([\"Employee\", \"Course\"]).last().",{"q":2006,"a":2084},"Add the course's validity in months with pd.DateOffset(months=n) to the completion date.",{"q":2016,"a":2086},"Build the matrix only from required courses; cells for courses a role does not need stay empty and can be shown as a dash.",{"q":2022,"a":2088},"Add CellIsRule conditional formatting rules for each status text with openpyxl.","training compliance matrix python",null,{"breadcrumb":2092},[2093,2095,2098],{"name":2094,"item":1039},"Home",{"name":2096,"item":2097},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":2099,"item":21},"Building Finance and Operations Reports in Excel with Python","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-training-compliance-matrix",{"title":2102,"description":2103},"Build a Training Compliance Matrix in Excel with Python","Training matrix with pandas: required courses by role, latest completion per person and course, expiry from validity periods, valid, expiring, expired and missing statuses, compliance by department and a colour-coded Excel sheet.","build-a-training-compliance-matrix","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-training-compliance-matrix\u002Findex",[2107,2108],"report-layouts","conditional-formatting","how-to","naVuSpXup3cxAp6nZFYHUETT3Uj3mVRXMaJucHZSEEs",[2112,2116],{"title":2113,"path":2114,"stem":2115,"children":-1},"Build a Support Ticket SLA Report","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-support-ticket-sla-report","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-support-ticket-sla-report\u002Findex",{"title":2117,"path":2118,"stem":2119,"children":-1},"Build a Trial Balance from a General Ledger","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-trial-balance-from-a-general-ledger","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-trial-balance-from-a-general-ledger\u002Findex",[2121,2123],{"path":2122,"title":2099},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports",{"path":2124,"title":2096},"\u002Fautomating-reporting-workflows",[],[2127,2131,2134,2137,2140,2143],{"path":2128,"title":2129,"kind":2130},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards","Building Multi-Sheet Excel Dashboards","guide",{"path":2132,"title":2133,"kind":2109},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-data-dictionary-sheet-to-an-excel-report","Add a Data Dictionary Sheet to an Excel Report",{"path":2135,"title":2136,"kind":2109},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-kpi-summary-block-to-an-excel-dashboard","Add a KPI Summary Block to an Excel Dashboard",{"path":2138,"title":2139,"kind":2109},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-table-of-contents-sheet-with-hyperlinks-in-excel","Add a Table of Contents Sheet with Hyperlinks in Excel",{"path":2141,"title":2142,"kind":2109},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-slicer-style-filters-to-an-excel-dashboard","Add a Dropdown Filter That Drives an Excel Dashboard",{"path":2144,"title":2145,"kind":2109},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-summary-sheet-to-excel-report-python","Add a Summary Sheet to an Excel Report with Python",1790972127378]