스냅샷 어드민 웹 UI + WBS-7.10 Tabler 테이블 그리드 조회

settings/account_snapshot SQLite를 직접 편집하는 잠금/승인/변경이력
기반 웹 에디터를 추가하고, 2026-06-21 비판적 리뷰에서 요청된 테이블별
그리드 조회 기능(Tabler CDN)을 /tables 경로로 덧붙인다.

- 잠금(lock)·승인(approval)·undo·변경로그 전체 감사 추적
- KIS Collection 대시보드 통합(별도 SQLite, 워크스페이스 DB와 분리)
- WBS-7.10: 워크스페이스/KIS수집/정성매도전략 3개 SQLite, 11개 테이블을
  Tabler 그리드로 조회 — 테이블명은 고정 화이트리스트와 정확히 일치할
  때만 SQL에 사용(SQL 인젝션 방지, 단위테스트로 검증)
This commit is contained in:
2026-06-21 20:06:55 +09:00
parent da0e1b0f7e
commit f99f9821d2
8 changed files with 4889 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
from __future__ import annotations
import json
from pathlib import Path
from src.quant_engine.snapshot_admin_server_v1 import build_ui_state
from src.quant_engine.snapshot_admin_store_v1 import (
ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS,
export_payload,
import_seed_json,
load_approval_for_domain,
load_change_log_rows,
load_locks,
load_account_snapshot_rows,
load_settings_rows,
parse_account_snapshot_tsv,
open_connection,
lock_conflicts_for_rows,
validate_account_snapshot_rows,
validate_settings_rows,
build_validation_suggestions,
build_safe_autofix_actions,
apply_safe_autofix_action,
set_lock,
undo_last_change,
write_export_json,
)
def _seed_json(path: Path) -> None:
payload = {
"data": {
"settings": {
"total_asset_krw": 150000000,
"weekly_target_cash_pct": 14,
"orbit_start_yyyymm": "2026-01",
},
"account_snapshot": [
{
"captured_at": "2026-06-21T09:00:00+09:00",
"account": "real",
"account_type": "일반계좌",
"ticker": "005930",
"name": "삼성전자",
"holding_quantity": 10,
"available_quantity": 10,
"average_cost": 70000,
"total_cost": 700000,
"current_price": 71000,
"market_value": 710000,
"profit_loss": 10000,
"return_pct": 1.43,
"immediate_cash": 1000000,
"settlement_cash_d2": 1000000,
"available_cash": 1000000,
"open_order_amount": 0,
"monthly_contribution_limit": "",
"monthly_contribution_used": "",
"parse_status": "CAPTURE_READ_OK",
"user_confirmed": "Y",
"stop_price": 65000,
"highest_price_since_entry": 72000,
"entry_date": "2026-06-01",
"entry_stage": "stage_1",
"position_type": "core",
"last_updated": "2026-06-21T09:05:00+09:00",
}
],
}
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def test_seed_import_and_export_round_trip(tmp_path):
db_path = tmp_path / "snapshot.db"
seed_path = tmp_path / "seed.json"
_seed_json(seed_path)
summary = import_seed_json(db_path, seed_path)
assert summary["settings_rows"] == 3
assert summary["account_snapshot_rows"] == 1
settings_rows = load_settings_rows(db_path)
assert settings_rows[0]["key"] == "total_asset_krw"
assert settings_rows[0]["value"] == 150000000
snapshot_rows = load_account_snapshot_rows(db_path)
assert snapshot_rows[0]["ticker"] == "005930"
assert snapshot_rows[0]["parse_status"] == "CAPTURE_READ_OK"
exported = export_payload(db_path)
assert exported["data"]["settings"]["weekly_target_cash_pct"] == 14
assert exported["data"]["account_snapshot"][0]["name"] == "삼성전자"
out = write_export_json(db_path, tmp_path / "export.json")
assert out.exists()
def test_parse_account_snapshot_tsv_supports_headerless_and_header_rows():
headerless = "\n".join(
[
"\t".join(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS),
"\t".join(
[
"2026-06-21T09:00:00+09:00",
"real",
"일반계좌",
"005930",
"삼성전자",
"10",
"10",
"70000",
"700000",
"71000",
"710000",
"10000",
"1.43",
"1000000",
"1000000",
"1000000",
"0",
"",
"",
"CAPTURE_READ_OK",
"Y",
"65000",
"72000",
"2026-06-01",
"stage_1",
"core",
"2026-06-21T09:05:00+09:00",
]
),
]
)
rows = parse_account_snapshot_tsv(headerless)
assert rows[0]["ticker"] == "005930"
assert rows[0]["holding_quantity"] == 10
with_header = "captured_at\taccount\tticker\n2026-06-21T09:00:00+09:00\treal\t005930"
rows2 = parse_account_snapshot_tsv(with_header)
assert rows2[0]["account"] == "real"
assert rows2[0]["ticker"] == "005930"
def test_build_ui_state_reports_schema(tmp_path):
db_path = tmp_path / "snapshot.db"
seed_path = tmp_path / "seed.json"
_seed_json(seed_path)
import_seed_json(db_path, seed_path)
state = build_ui_state(db_path)
assert state["summary"]["settings_rows"] == 3
assert state["account_snapshot_columns"][: len(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS)] == ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS
def test_change_log_approval_and_lock_workflow(tmp_path):
db_path = tmp_path / "snapshot.db"
seed_path = tmp_path / "seed.json"
_seed_json(seed_path)
import_seed_json(db_path, seed_path)
with open_connection(db_path) as conn:
set_lock(conn, "settings", "*", locked_by="tester", reason="review")
conn.commit()
locks = load_locks(db_path)
assert locks and locks[0]["domain"] == "settings"
approval = load_approval_for_domain(db_path, "settings")
assert approval["status"] == "PENDING"
changes = load_change_log_rows(db_path, limit=10)
assert changes
def test_lock_conflicts_detect_row_targets(tmp_path):
db_path = tmp_path / "snapshot.db"
seed_path = tmp_path / "seed.json"
_seed_json(seed_path)
import_seed_json(db_path, seed_path)
with open_connection(db_path) as conn:
set_lock(conn, "settings", "total_asset_krw", locked_by="tester", reason="review")
set_lock(conn, "account_snapshot", "005930", locked_by="tester", reason="review")
conn.commit()
settings_conflicts = lock_conflicts_for_rows(
db_path,
"settings",
[{"key": "total_asset_krw", "value": 123, "note": ""}],
)
snapshot_conflicts = lock_conflicts_for_rows(
db_path,
"account_snapshot",
[{"ticker": "005930", "name": "삼성전자", "ordinal": 1}],
)
assert settings_conflicts and settings_conflicts[0]["target_ref"] == "total_asset_krw"
assert snapshot_conflicts and snapshot_conflicts[0]["target_ref"] == "005930"
def test_undo_last_change_restores_previous_snapshot(tmp_path):
db_path = tmp_path / "snapshot.db"
seed_path = tmp_path / "seed.json"
_seed_json(seed_path)
import_seed_json(db_path, seed_path)
with open_connection(db_path) as conn:
from src.quant_engine.snapshot_admin_store_v1 import replace_settings
replace_settings(conn, [{"ordinal": 1, "key": "total_asset_krw", "value": 123, "note": "edited"}])
with open_connection(db_path) as conn:
undo_last_change(conn, "settings")
settings_rows = load_settings_rows(db_path)
assert settings_rows[0]["value"] == 150000000
def test_validation_helpers_detect_invalid_rows():
assert "settings.total_asset_krw is required" in validate_settings_rows([{"key": "weekly_target_cash_pct", "value": 10}])
assert "account_snapshot row 1: ticker required" in validate_account_snapshot_rows(
[{"captured_at": "2026-06-21", "account": "real", "name": "삼성전자", "parse_status": "BAD"}]
)
suggestions = build_validation_suggestions(
[{"key": "weekly_target_cash_pct", "value": 10}],
[{"captured_at": "2026-06-21", "account": "real", "account_type": "일반계좌", "ticker": "005930", "name": "삼성전자", "parse_status": "CAPTURE_READ_OK", "user_confirmed": "N"}],
)
assert any("user_confirmed=Y" in item for item in suggestions)
actions = build_safe_autofix_actions(
[{"key": "total_asset_krw", "value": 150000000}],
[{"captured_at": "2026-06-21", "account": "real", "account_type": "일반계좌", "ticker": "005930", "name": "삼성전자", "parse_status": "CAPTURE_READ_OK", "user_confirmed": "N", "entry_stage": "stage_1", "position_type": ""}],
)
assert any(item["action_id"] == "confirm_captured_rows" for item in actions)
def test_safe_autofix_updates_snapshot_defaults(tmp_path):
db_path = tmp_path / "snapshot.db"
seed_path = tmp_path / "seed.json"
_seed_json(seed_path)
import_seed_json(db_path, seed_path)
with open_connection(db_path) as conn:
result = apply_safe_autofix_action(conn, "confirm_captured_rows")
assert result["status"] == "AUTOFIXED"
snapshot_rows = load_account_snapshot_rows(db_path)
assert all(row.get("user_confirmed") == "Y" or str(row.get("parse_status")) != "CAPTURE_READ_OK" for row in snapshot_rows)
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import tools.validate_snapshot_admin_web_v1 as validator
from src.quant_engine.snapshot_admin_server_v1 import (
build_ui_state,
fetch_table_rows,
list_browsable_tables,
render_collection_html,
render_index_html,
render_tables_html,
)
from src.quant_engine.snapshot_admin_store_v1 import import_seed_json
def test_render_index_html_contains_spreadsheet_surface():
html = render_index_html()
assert "Snapshot Admin" in html
assert "contenteditable" in html
assert "/api/settings/save" in html
assert "/api/account_snapshot/save" in html
assert "Lock target" in html
assert "Lock row" in html
assert "Approve pending" in html
assert "Refresh diff" in html
assert "Export approval packet" in html
assert "Selection Inspector" in html
assert "Recent row history" in html
assert "Save view" in html
assert "Apply TSV to selection" in html
assert "Ctrl+S" in html
assert "KIS Collection" in html
assert "Recent collector snapshots" in html
assert "Collection detail" in html
assert "Filter runs / snapshots / errors" in html
assert "Filter change log" in html
assert "Timeline" in html
assert "/collection" in html
assert "Open collection dashboard" in html
def test_render_collection_html_contains_dashboard_surface():
html = render_collection_html()
assert "KIS Collection Dashboard" in html
assert "/api/state" in html
assert "Download raw JSON" in html
assert "Download CSV" in html
assert "Filter runs / snapshots / errors" in html
assert "Ticker quick search" in html
assert "Date quick search" in html
def test_build_ui_state_exposes_expected_columns(tmp_path):
db_path = tmp_path / "snapshot_admin.db"
seed_path = ROOT / "GatherTradingData.json"
import_seed_json(db_path, seed_path)
state = build_ui_state(db_path)
assert state["summary"]["settings_rows"] > 0
assert state["summary"]["account_snapshot_rows"] > 0
assert state["summary"]["topology"]["mode"] == "single_workspace_sqlite"
assert state["summary"]["topology"]["settings_and_snapshot_share_db"] is True
assert state["summary"]["topology"]["collector_separate_db"] is True
assert state["account_snapshot_columns"][0] == "captured_at"
assert "settings" in state["validation"]
assert state["version"]["app"]
assert "fingerprint" in state["version"]["source"]
assert "collection" in state
assert "counts" in state["collection"]
assert "latest_report" in state["collection"]
assert state["summary"]["topology"]["mode"] == "single_workspace_sqlite"
def test_snapshot_admin_workflow_and_script_exist():
workflow = ROOT / ".gitea" / "workflows" / "snapshot_admin.yml"
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
assert workflow.exists()
assert "--reload" in package["scripts"]["ops:snapshot-web"]
assert "ops:snapshot-validate" in package["scripts"]
assert "ops:snapshot-web-validate" in package["scripts"]
def test_render_tables_html_contains_tabler_grid_surface():
html = render_tables_html()
assert "tabler" in html.lower()
assert "tableSelect" in html
assert "/api/tables" in html
assert "/api/table_rows" in html
assert "gridTable" in html
def test_list_browsable_tables_covers_all_three_databases(tmp_path):
db_path = tmp_path / "snapshot_admin.db"
import_seed_json(db_path, ROOT / "GatherTradingData.json")
tables = list_browsable_tables(db_path)
names = {row["table"] for row in tables}
assert {"settings", "account_snapshot", "workspace_change_log"} <= names
assert {"collection_runs", "collection_snapshots", "collection_source_errors"} <= names
assert {"sell_strategy_results", "satellite_recommendations"} <= names
settings_row = next(row for row in tables if row["table"] == "settings")
assert settings_row["exists"] is True
assert settings_row["row_count"] > 0
def test_fetch_table_rows_paginates_and_rejects_unknown_table(tmp_path):
db_path = tmp_path / "snapshot_admin.db"
import_seed_json(db_path, ROOT / "GatherTradingData.json")
page1 = fetch_table_rows("settings", db_path, limit=2, offset=0)
assert page1["columns"]
assert len(page1["rows"]) == 2
assert page1["total"] > 2
page2 = fetch_table_rows("settings", db_path, limit=2, offset=2)
assert page1["rows"] != page2["rows"]
import pytest
with pytest.raises(ValueError):
fetch_table_rows("settings; DROP TABLE settings;--", db_path)
def test_snapshot_admin_web_validation_script_passes():
out = ROOT / "Temp" / "snapshot_admin_web_validation_v1.json"
if out.exists():
out.unlink()
rc = validator.main()
payload = json.loads(out.read_text(encoding="utf-8"))
assert rc == 0
assert payload["gate"] == "PASS"
assert payload["formula_id"] == "SNAPSHOT_ADMIN_WEB_VALIDATION_V1"
assert payload["settings_rows"] > 0
assert payload["account_snapshot_rows"] > 0