Skip to content

Commit 42cfdcf

Browse files
committed
fix(compliance): 카테고리별 현황을 카탈로그 통제 개수 기준으로 집계 + 도메인 접기 UI (트리와 갯수 일치)
1 parent c40df01 commit 42cfdcf

3 files changed

Lines changed: 61 additions & 62 deletions

File tree

src/mori_soc/api/payloads.py

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -154,35 +154,38 @@ def build_pdca_payload(
154154
checked = total - status_counts["not_checked"] - status_counts["not_applicable"]
155155
pass_rate = round(status_counts["pass"] / checked * 100, 1) if checked > 0 else 0.0
156156

157-
# Control category 별 집계 — 통제 카탈로그의 '섹션명'으로 그룹핑해 카탈로그 트리와 매칭시킴.
158-
# (기존엔 control_id 프리픽스 "2.5"만 써서 이름 없음·문자열정렬로 카탈로그와 불일치)
157+
# Control category 별 집계 — 카탈로그 트리와 '통제 개수'로 매칭. (핵심)
158+
# 트리는 통제 개수(매핑됨/전체)를 세는데 표는 점검 '건수'를 세서 합계가 안 맞았다.
159+
# → 카탈로그 통제를 기준 유니버스로 잡고, 통제별로 점검 상태를 1건으로 집계하면
160+
# 섹션·도메인 total 이 트리의 분모(예: 2.보호대책 64)와 정확히 일치한다.
159161
from mori_soc.services.control_catalog import load_catalog
160162
_catalog = load_catalog()
161-
_sec_of: dict[str, str] = {}
162-
_fw_of: dict[str, str] = {}
163-
_dom_of: dict[str, str] = {}
164-
for cc in _catalog.get("controls", []):
165-
cid = str(cc.get("id", ""))
166-
_sec_of[cid] = cc.get("section") or cc.get("domain") or cid.rsplit(".", 1)[0]
167-
_fw_of[cid] = cc.get("framework", "")
168-
_dom_of[cid] = cc.get("domain") or _sec_of[cid]
169163

170-
def _cat_of(control_id: str) -> str:
171-
sec = _sec_of.get(control_id)
172-
if sec:
173-
return sec
174-
parts = control_id.rsplit(".", 1)
175-
return parts[0] if len(parts) > 1 else control_id
164+
# control_id → 그 통제에 달린 점검 상태들
165+
_statuses_of: dict[str, list[str]] = {}
166+
for c in checks:
167+
_statuses_of.setdefault(c.control_id, []).append(c.status)
168+
169+
def _agg_status(sts: list[str]) -> str:
170+
# 통제 1개의 대표 상태: 실패 > 경고 > 통과 > 미적용 > 미점검 우선순위
171+
if not sts:
172+
return "not_checked"
173+
for s in ("fail", "warning", "pass", "not_applicable"):
174+
if s in sts:
175+
return s
176+
return "not_checked"
176177

177178
by_category: dict[str, dict[str, int]] = {}
178179
cat_fw: dict[str, str] = {}
179180
cat_dom: dict[str, str] = {}
180-
for c in checks:
181-
cat = _cat_of(c.control_id)
182-
cat_fw.setdefault(cat, _fw_of.get(c.control_id, ""))
183-
cat_dom.setdefault(cat, _dom_of.get(c.control_id, cat))
184-
bucket = by_category.setdefault(cat, {"pass": 0, "fail": 0, "warning": 0, "not_applicable": 0, "not_checked": 0, "total": 0})
185-
bucket[c.status] = bucket.get(c.status, 0) + 1
181+
for cc in _catalog.get("controls", []):
182+
cid = str(cc.get("id", ""))
183+
sec = cc.get("section") or cc.get("domain") or (cid.rsplit(".", 1)[0] if "." in cid else cid)
184+
dom = cc.get("domain") or sec
185+
cat_fw.setdefault(sec, cc.get("framework", ""))
186+
cat_dom.setdefault(sec, dom)
187+
bucket = by_category.setdefault(sec, {"pass": 0, "fail": 0, "warning": 0, "not_applicable": 0, "not_checked": 0, "total": 0})
188+
bucket[_agg_status(_statuses_of.get(cid, []))] += 1
186189
bucket["total"] += 1
187190

188191
def _sort_key(cat: str):

src/mori_soc/api/templates/dashboard.py

Lines changed: 24 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3605,7 +3605,8 @@ def render_user_dashboard_html(
36053605
if (cats.length === 0) {
36063606
categoryEl.innerHTML = '<div class=\"empty\" style=\"color:#111827;padding:12px\">' + tt('dash.dyn.pdca.no_check_data','점검 데이터가 없습니다.') + '</div>';
36073607
} else {
3608-
// 통제 카탈로그 트리와 동일하게 프레임워크 → 도메인 → 섹션(카테고리) 계층으로 묶어 매칭.
3608+
// 통제 카탈로그 트리와 동일하게 프레임워크 → 도메인(접기) → 섹션 계층으로 매칭.
3609+
// total = 카탈로그 통제 개수 → 트리 분모(예: 2.보호대책 64)와 정확히 일치.
36093610
const _fwLabel = { 'isms-p': 'ISMS-P', 'iso27001': 'ISO 27001:2022', 'custom': 'Custom / 법령' };
36103611
// cats 는 payload 에서 이미 트리 순서(ISMS-P 먼저 · 섹션번호 자연정렬)로 정렬됨.
36113612
const groups = []; // {fw, domain, rows:[], agg:{}}
@@ -3620,45 +3621,33 @@ def render_user_dashboard_html(
36203621
g.agg.pass += c.pass; g.agg.fail += c.fail; g.agg.warning += c.warning;
36213622
g.agg.not_checked += c.not_checked; g.agg.total += c.total;
36223623
});
3623-
let lastFw = null;
3624-
const body = groups.map(g => {
3625-
let out = '';
3624+
const secTable = rows => `<table style=\"width:100%;border-collapse:collapse;font-size:12px;margin:4px 0 2px 14px\">
3625+
<tbody>${rows.map(c => `<tr style=\"border-bottom:1px solid #f1f5f9\">
3626+
<td style=\"padding:4px 8px;color:#111827\">${escapeHtml(c.category)}</td>
3627+
<td style=\"text-align:right;padding:4px 8px;color:#16a34a;width:42px\">${c.pass}</td>
3628+
<td style=\"text-align:right;padding:4px 8px;color:#dc2626;width:42px\">${c.fail}</td>
3629+
<td style=\"text-align:right;padding:4px 8px;color:#ea580c;width:42px\">${c.warning}</td>
3630+
<td style=\"text-align:right;padding:4px 8px;color:#111827;width:42px\">${c.not_checked}</td>
3631+
<td style=\"text-align:right;padding:4px 8px;color:#111827;width:42px\">${c.total}</td></tr>`).join('')}</tbody></table>`;
3632+
const legend = `<div style=\"display:flex;gap:12px;justify-content:flex-end;font-size:11px;color:#111827;margin-bottom:4px\">
3633+
<span style=\"color:#16a34a\">Pass</span><span style=\"color:#dc2626\">Fail</span><span style=\"color:#ea580c\">Warning</span><span>${tt('dash.dyn.pdca.not_checked','미점검')}</span><span>${tt('dash.dyn.lbl.total','합계')}</span></div>`;
3634+
let lastFw = null, html = legend;
3635+
groups.forEach(g => {
36263636
if (g.fw !== lastFw) {
36273637
lastFw = g.fw;
3628-
out += `<tr style=\"background:#f9fafb\"><td colspan=\"6\" style=\"padding:6px 8px;color:#2563eb;font-weight:700;font-size:12px\">${escapeHtml(_fwLabel[g.fw]||g.fw||'')}</td></tr>`;
3638+
html += `<div style=\"margin-top:10px;font-weight:700;color:#2563eb;font-size:13px\">${escapeHtml(_fwLabel[g.fw]||g.fw||'')}</div>`;
36293639
}
3640+
const a = g.agg;
3641+
const nums = `<span style=\"color:#16a34a\">${a.pass}</span> / <span style=\"color:#dc2626\">${a.fail}</span> / <span style=\"color:#ea580c\">${a.warning}</span> / ${a.not_checked} / ${a.total}`;
36303642
const domSameAsSec = (g.rows.length === 1 && g.rows[0].category === g.domain);
3631-
if (!domSameAsSec) {
3632-
out += `<tr style=\"border-bottom:1px solid #e5e7eb\">
3633-
<td style=\"padding:6px 8px;color:#111827;font-weight:700\">${escapeHtml(g.domain)}</td>
3634-
<td style=\"text-align:right;padding:6px 8px;color:#16a34a;font-weight:700\">${g.agg.pass}</td>
3635-
<td style=\"text-align:right;padding:6px 8px;color:#dc2626;font-weight:700\">${g.agg.fail}</td>
3636-
<td style=\"text-align:right;padding:6px 8px;color:#ea580c;font-weight:700\">${g.agg.warning}</td>
3637-
<td style=\"text-align:right;padding:6px 8px;color:#111827;font-weight:700\">${g.agg.not_checked}</td>
3638-
<td style=\"text-align:right;padding:6px 8px;color:#111827;font-weight:700\">${g.agg.total}</td></tr>`;
3643+
if (domSameAsSec) {
3644+
html += `<div style=\"margin:4px 0 0 4px;color:#111827;font-size:13px;padding:2px 0\">${escapeHtml(g.domain)} <span style=\"font-size:11px\">(${nums})</span></div>`;
3645+
} else {
3646+
html += `<details style=\"margin:4px 0 0 4px\"><summary style=\"cursor:pointer;color:#111827;font-size:13px;padding:2px 0\">${escapeHtml(g.domain)} <span style=\"font-size:11px\">(${nums})</span></summary>`
3647+
+ secTable(g.rows) + `</details>`;
36393648
}
3640-
out += g.rows.map(c => {
3641-
const pad = domSameAsSec ? '6px 8px' : '5px 8px 5px 22px';
3642-
const fw2 = domSameAsSec ? 'font-weight:700' : 'font-weight:400';
3643-
return `<tr style=\"border-bottom:1px solid #f1f5f9\">
3644-
<td style=\"padding:${pad};color:#111827;${fw2}\">${escapeHtml(c.category)}</td>
3645-
<td style=\"text-align:right;padding:${pad};color:#16a34a\">${c.pass}</td>
3646-
<td style=\"text-align:right;padding:${pad};color:#dc2626\">${c.fail}</td>
3647-
<td style=\"text-align:right;padding:${pad};color:#ea580c\">${c.warning}</td>
3648-
<td style=\"text-align:right;padding:${pad};color:#111827\">${c.not_checked}</td>
3649-
<td style=\"text-align:right;padding:${pad};color:#111827\">${c.total}</td></tr>`;
3650-
}).join('');
3651-
return out;
3652-
}).join('');
3653-
categoryEl.innerHTML = `<table style=\"width:100%;border-collapse:collapse;font-size:13px\">
3654-
<thead><tr style=\"color:#111827;border-bottom:1px solid #e5e7eb\">
3655-
<th style=\"text-align:left;padding:6px 8px\">${tt('dash.dyn.pdca.category','카테고리')}</th>
3656-
<th style=\"text-align:right;padding:6px 8px\">Pass</th>
3657-
<th style=\"text-align:right;padding:6px 8px\">Fail</th>
3658-
<th style=\"text-align:right;padding:6px 8px\">Warning</th>
3659-
<th style=\"text-align:right;padding:6px 8px\">${tt('dash.dyn.pdca.not_checked','미점검')}</th>
3660-
<th style=\"text-align:right;padding:6px 8px\">${tt('dash.dyn.lbl.total','합계')}</th>
3661-
</tr></thead><tbody>` + body + '</tbody></table>';
3649+
});
3650+
categoryEl.innerHTML = html;
36623651
}
36633652
}
36643653
// Pending remediations (control_check + trivy + alert)

tests/test_api_server.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -663,19 +663,26 @@ def test_pdca_cycle_mapping(self) -> None:
663663
self.assertEqual(pdca["act"], 1) # pass
664664

665665
def test_categories_grouped_by_catalog_section(self) -> None:
666-
# 카탈로그의 섹션/도메인명으로 그룹핑 (A.8.x → "A.8 Technological controls").
666+
# 카탈로그 섹션명으로 그룹핑 + '통제 개수' 기준(트리 분모와 일치).
667+
# A.8.x 는 "A.8 Technological controls" 섹션. 통제당 1건으로 집계, 나머지는 미점검.
667668
checks = [
668669
self._make_check("c1", "A.8.1", "pass"),
669670
self._make_check("c2", "A.8.2", "fail"),
670-
self._make_check("c3", "A.9.1", "pass"),
671+
self._make_check("c3", "A.8.1", "warning"), # 같은 통제 두 번째 점검 → fail>warning>pass 우선순위상 A.8.1은 warning
671672
]
672673
store = InMemoryQueryStore(control_checks=checks)
673674
payload = build_pdca_payload(QueryService(store))
674675
a8 = next(c for c in payload["categories"] if c["category"].startswith("A.8"))
675-
self.assertEqual(a8["pass"], 1)
676+
# 통제 기준 집계: A.8.1=warning, A.8.2=fail, 그 외 A.8 통제는 미점검.
676677
self.assertEqual(a8["fail"], 1)
677-
a9 = next(c for c in payload["categories"] if c["category"].startswith("A.9"))
678-
self.assertEqual(a9["pass"], 1)
678+
self.assertEqual(a8["warning"], 1)
679+
self.assertEqual(a8["pass"], 0)
680+
# total 은 카탈로그의 A.8 통제 개수(점검 건수 아님) → 미점검이 대부분.
681+
self.assertEqual(a8["total"], a8["pass"] + a8["fail"] + a8["warning"]
682+
+ a8["not_checked"] + a8["not_applicable"])
683+
self.assertGreater(a8["not_checked"], 0)
684+
self.assertEqual(a8["framework"], "iso27001")
685+
self.assertTrue(a8["domain"].startswith("A.8"))
679686

680687
def test_pending_remediations_includes_fail_and_warning(self) -> None:
681688
from datetime import timedelta

0 commit comments

Comments
 (0)