|
2 | 2 | import json |
3 | 3 | import os |
4 | 4 | import time |
5 | | -from typing import Dict, List, Tuple |
| 5 | +import sqlite3 |
| 6 | +from typing import Dict, List, Tuple, Any |
6 | 7 | from .models import Panel, Group, Snapshot, AuditEntry |
7 | | -from .config import PANELS_FILE, PANELS_CONFIG_FILE, PANELS_STATE_FILE, AUDIT_FILE |
| 8 | +from .config import PANELS_FILE, PANELS_CONFIG_FILE, PANELS_STATE_FILE, AUDIT_FILE, AUDIT_DB_FILE |
8 | 9 |
|
9 | 10 |
|
10 | 11 | def _ensure_dirs() -> None: |
11 | 12 | os.makedirs(os.path.dirname(PANELS_CONFIG_FILE), exist_ok=True) |
12 | 13 | os.makedirs(os.path.dirname(PANELS_STATE_FILE), exist_ok=True) |
13 | 14 | os.makedirs(os.path.dirname(AUDIT_FILE), exist_ok=True) |
| 15 | + # AUDIT_DB_FILE lives in the same directory as AUDIT_FILE so no extra work needed |
14 | 16 |
|
15 | 17 |
|
16 | 18 | def _migrate_from_legacy_panels_json() -> None: |
@@ -145,13 +147,95 @@ def save_snapshot(s: Snapshot) -> None: |
145 | 147 | save_state(s.panels) |
146 | 148 |
|
147 | 149 |
|
| 150 | +def _ensure_audit_db() -> None: |
| 151 | + """Create the SQLite database and table for audit logs if they do not exist.""" |
| 152 | + _ensure_dirs() |
| 153 | + conn = sqlite3.connect(AUDIT_DB_FILE) |
| 154 | + try: |
| 155 | + cur = conn.cursor() |
| 156 | + cur.execute( |
| 157 | + """ |
| 158 | + CREATE TABLE IF NOT EXISTS audit_log ( |
| 159 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 160 | + ts REAL NOT NULL, |
| 161 | + actor TEXT NOT NULL, |
| 162 | + target_type TEXT NOT NULL, |
| 163 | + target_id TEXT NOT NULL, |
| 164 | + level INTEGER NOT NULL, |
| 165 | + applied_to TEXT NOT NULL, |
| 166 | + result TEXT NOT NULL |
| 167 | + ) |
| 168 | + """ |
| 169 | + ) |
| 170 | + conn.commit() |
| 171 | + finally: |
| 172 | + conn.close() |
| 173 | + |
| 174 | + |
148 | 175 | def append_audit(entry: AuditEntry) -> None: |
| 176 | + """Append audit entry to JSON file and SQLite database.""" |
149 | 177 | _ensure_dirs() |
150 | 178 | row = entry.model_dump() |
151 | 179 | # write one JSON per line for easy tailing |
152 | 180 | with open(AUDIT_FILE, "a", encoding="utf-8") as f: |
153 | 181 | f.write(json.dumps(row) + "\n") |
154 | 182 |
|
| 183 | + # also mirror into SQLite |
| 184 | + _ensure_audit_db() |
| 185 | + conn = sqlite3.connect(AUDIT_DB_FILE) |
| 186 | + try: |
| 187 | + cur = conn.cursor() |
| 188 | + cur.execute( |
| 189 | + """ |
| 190 | + INSERT INTO audit_log (ts, actor, target_type, target_id, level, applied_to, result) |
| 191 | + VALUES (?, ?, ?, ?, ?, ?, ?) |
| 192 | + """, |
| 193 | + ( |
| 194 | + row["ts"], |
| 195 | + row["actor"], |
| 196 | + row["target_type"], |
| 197 | + row["target_id"], |
| 198 | + row["level"], |
| 199 | + json.dumps(row["applied_to"]), |
| 200 | + row["result"], |
| 201 | + ), |
| 202 | + ) |
| 203 | + conn.commit() |
| 204 | + finally: |
| 205 | + conn.close() |
| 206 | + |
| 207 | + |
| 208 | +def fetch_audit_entries(limit: int = 500, offset: int = 0) -> List[Dict[str, Any]]: |
| 209 | + """Fetch audit entries from SQLite ordered newest first.""" |
| 210 | + _ensure_audit_db() |
| 211 | + conn = sqlite3.connect(AUDIT_DB_FILE) |
| 212 | + try: |
| 213 | + conn.row_factory = sqlite3.Row |
| 214 | + cur = conn.cursor() |
| 215 | + cur.execute( |
| 216 | + """ |
| 217 | + SELECT ts, actor, target_type, target_id, level, applied_to, result |
| 218 | + FROM audit_log |
| 219 | + ORDER BY ts DESC |
| 220 | + LIMIT ? OFFSET ? |
| 221 | + """, |
| 222 | + (limit, offset), |
| 223 | + ) |
| 224 | + rows = cur.fetchall() |
| 225 | + result: List[Dict[str, Any]] = [] |
| 226 | + for r in rows: |
| 227 | + row_dict = dict(r) |
| 228 | + # applied_to is stored as JSON text |
| 229 | + try: |
| 230 | + row_dict["applied_to"] = json.loads(row_dict.get("applied_to") or "[]") |
| 231 | + except Exception: |
| 232 | + row_dict["applied_to"] = [] |
| 233 | + result.append(row_dict) |
| 234 | + return result |
| 235 | + finally: |
| 236 | + conn.close() |
| 237 | + |
| 238 | + |
155 | 239 |
|
156 | 240 | def bootstrap_default_if_empty() -> Snapshot: |
157 | 241 | """Bootstrap default panels and groups if config doesn't exist.""" |
|
0 commit comments