diff --git a/lawrisk/api/v2.py b/lawrisk/api/v2.py index de0a57d..e700aab 100644 --- a/lawrisk/api/v2.py +++ b/lawrisk/api/v2.py @@ -11,6 +11,10 @@ from lawrisk.services.licensing_repo import ( load_permits_and_risks, list_region_theme_options, load_theme_payload, + create_checkpoint, + list_checkpoints, + restore_checkpoint, + delete_checkpoint, ) from lawrisk.services.lawrisk_service import suggest_questions_embed @@ -250,3 +254,57 @@ def admin_permit_details(): except Exception as exc: print(f"admin_permit_details error: {exc}") return jsonify({"success": False, "message": str(exc)}), 500 + + +@v2_bp.route('/admin/checkpoints', methods=['GET']) +def admin_list_checkpoints(): + """List all available checkpoints.""" + try: + checkpoints = list_checkpoints() + return jsonify({"success": True, "data": {"checkpoints": checkpoints}}) + except Exception as exc: + print(f"admin_list_checkpoints error: {exc}") + return jsonify({"success": False, "message": str(exc)}), 500 + + +@v2_bp.route('/admin/checkpoints', methods=['POST']) +def admin_create_checkpoint(): + """Create a new checkpoint.""" + if request.is_json: + payload = request.get_json(silent=True) or {} + else: + payload = request.form.to_dict(flat=True) if request.form else {} + + description = payload.get("description", "") + + try: + checkpoint = create_checkpoint(description) + return jsonify({"success": True, "data": checkpoint}) + except Exception as exc: + print(f"admin_create_checkpoint error: {exc}") + return jsonify({"success": False, "message": str(exc)}), 500 + + +@v2_bp.route('/admin/checkpoints//restore', methods=['POST']) +def admin_restore_checkpoint(checkpoint_id): + """Restore database from a checkpoint. DANGEROUS OPERATION!""" + try: + restore_summary = restore_checkpoint(checkpoint_id) + return jsonify({"success": True, "data": restore_summary}) + except Exception as exc: + print(f"admin_restore_checkpoint error: {exc}") + return jsonify({"success": False, "message": str(exc)}), 500 + + +@v2_bp.route('/admin/checkpoints/', methods=['DELETE']) +def admin_delete_checkpoint(checkpoint_id): + """Delete a checkpoint.""" + try: + success = delete_checkpoint(checkpoint_id) + if success: + return jsonify({"success": True, "message": "Checkpoint deleted"}) + else: + return jsonify({"success": False, "message": "Checkpoint not found"}), 404 + except Exception as exc: + print(f"admin_delete_checkpoint error: {exc}") + return jsonify({"success": False, "message": str(exc)}), 500 diff --git a/lawrisk/services/licensing_repo.py b/lawrisk/services/licensing_repo.py index 8e05430..f40a4ec 100644 --- a/lawrisk/services/licensing_repo.py +++ b/lawrisk/services/licensing_repo.py @@ -1,8 +1,10 @@ from __future__ import annotations +import json import os import re from collections import OrderedDict +from datetime import datetime from typing import Any, Dict, List, Optional, Tuple import pg8000.dbapi as pg @@ -325,3 +327,202 @@ def load_theme_payload(region_id: str, theme_id: str) -> Dict[str, object]: "theme": {"id": str(theme_uuid), "name": str(theme_name)}, "permits": permits, } + + +def _get_checkpoints_dir() -> str: + """Get the directory for storing checkpoint files.""" + base_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data") + checkpoints_dir = os.path.join(base_dir, "checkpoints") + os.makedirs(checkpoints_dir, exist_ok=True) + return checkpoints_dir + + +def _get_all_tables() -> List[str]: + """Get list of all tables in the licensing_risks database.""" + sql = """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + """ + with _lic_pg_conn() as conn: + cur = conn.cursor() + cur.execute(sql) + return [row[0] for row in cur.fetchall()] + + +def _backup_table(conn: pg.Connection, table_name: str) -> Tuple[List[Dict[str, Any]], int]: + """Backup a single table and return its data and row count.""" + sql = f"SELECT * FROM {table_name}" + cur = conn.cursor() + cur.execute(sql) + + rows = cur.fetchall() + colnames = [desc[0] for desc in cur.description] + + data = [] + for row in rows: + row_dict = {} + for i, col in enumerate(colnames): + value = row[i] + if value is not None: + # Convert UUID and other non-serializable types to strings + if hasattr(value, 'isoformat'): # UUID, datetime, etc. + row_dict[col] = str(value) + else: + row_dict[col] = value + data.append(row_dict) + + return data, len(data) + + +def _restore_table(conn: pg.Connection, table_name: str, data: List[Dict[str, Any]]) -> int: + """Restore a table from backup data. Returns number of rows restored.""" + if not data: + return 0 + + conn.autocommit = False + try: + cur = conn.cursor() + + delete_sql = f"DELETE FROM {table_name}" + cur.execute(delete_sql) + + if data: + columns = list(data[0].keys()) + placeholders = ", ".join(["%s"] * len(columns)) + insert_sql = f"INSERT INTO {table_name} ({', '.join(columns)}) VALUES ({placeholders})" + + for row in data: + values = [row.get(col) for col in columns] + cur.execute(insert_sql, values) + + conn.commit() + return len(data) + except Exception as e: + conn.rollback() + raise e + finally: + conn.autocommit = False + + +def create_checkpoint(description: str = "") -> Dict[str, Any]: + """Create a checkpoint by backing up all tables.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + checkpoint_id = f"checkpoint_{timestamp}" + + tables = _get_all_tables() + checkpoint_data = { + "checkpoint_id": checkpoint_id, + "timestamp": timestamp, + "description": description, + "tables": {} + } + + total_rows = 0 + table_counts = {} + + with _lic_pg_conn() as conn: + for table in tables: + data, row_count = _backup_table(conn, table) + checkpoint_data["tables"][table] = data + table_counts[table] = row_count + total_rows += row_count + + checkpoint_data["table_counts"] = table_counts + checkpoint_data["total_rows"] = total_rows + + checkpoints_dir = _get_checkpoints_dir() + checkpoint_file = os.path.join(checkpoints_dir, f"{checkpoint_id}.json") + + def json_serializer(obj): + """Convert non-JSON serializable objects to strings.""" + if hasattr(obj, 'isoformat'): # UUID, datetime, etc. + return str(obj) + raise TypeError(f"Object of type {type(obj)} is not JSON serializable") + + with open(checkpoint_file, "w", encoding="utf-8") as f: + json.dump(checkpoint_data, f, ensure_ascii=False, indent=2, default=json_serializer) + + return { + "checkpoint_id": checkpoint_id, + "timestamp": timestamp, + "description": description, + "total_rows": total_rows, + "table_counts": table_counts + } + + +def list_checkpoints() -> List[Dict[str, Any]]: + """List all available checkpoints.""" + checkpoints_dir = _get_checkpoints_dir() + checkpoints = [] + + if not os.path.exists(checkpoints_dir): + return checkpoints + + for filename in os.listdir(checkpoints_dir): + if filename.endswith(".json"): + filepath = os.path.join(checkpoints_dir, filename) + try: + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + checkpoints.append({ + "checkpoint_id": data["checkpoint_id"], + "timestamp": data["timestamp"], + "description": data.get("description", ""), + "total_rows": data.get("total_rows", 0), + "table_counts": data.get("table_counts", {}), + "filename": filename + }) + except Exception as e: + print(f"Error reading checkpoint {filename}: {e}") + + return sorted(checkpoints, key=lambda x: x["timestamp"], reverse=True) + + +def restore_checkpoint(checkpoint_id: str) -> Dict[str, Any]: + """Restore database from a checkpoint. This is a destructive operation!""" + checkpoints_dir = _get_checkpoints_dir() + checkpoint_file = os.path.join(checkpoints_dir, f"{checkpoint_id}.json") + + if not os.path.exists(checkpoint_file): + raise ValueError(f"Checkpoint {checkpoint_id} not found") + + with open(checkpoint_file, "r", encoding="utf-8") as f: + checkpoint_data = json.load(f) + + tables = checkpoint_data.get("tables", {}) + restore_summary = { + "checkpoint_id": checkpoint_id, + "tables_restored": 0, + "total_rows_restored": 0, + "table_details": {} + } + + with _lic_pg_conn(autocommit=False) as conn: + try: + for table_name, data in tables.items(): + rows_restored = _restore_table(conn, table_name, data) + restore_summary["tables_restored"] += 1 + restore_summary["total_rows_restored"] += rows_restored + restore_summary["table_details"][table_name] = rows_restored + + conn.commit() + except Exception as e: + conn.rollback() + raise e + + return restore_summary + + +def delete_checkpoint(checkpoint_id: str) -> bool: + """Delete a checkpoint file.""" + checkpoints_dir = _get_checkpoints_dir() + checkpoint_file = os.path.join(checkpoints_dir, f"{checkpoint_id}.json") + + if os.path.exists(checkpoint_file): + os.remove(checkpoint_file) + return True + return False diff --git a/static/db_admin.html b/static/db_admin.html index 17252f0..f0aab66 100644 --- a/static/db_admin.html +++ b/static/db_admin.html @@ -264,6 +264,259 @@ color: white; } + .checkpoint-nav-item { + background: #fff3cd; + border: 2px solid #ffc107; + } + + .checkpoint-nav-item:hover { + background: #ffe69c; + border-color: #ff9800; + } + + .checkpoint-section { + background: white; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + border: 1px solid #e0e0e0; + } + + .checkpoint-section h3 { + color: #667eea; + font-size: 18px; + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 10px; + } + + .checkpoint-section h3::before { + content: '🔒'; + font-size: 20px; + } + + .checkpoint-form { + margin-bottom: 20px; + } + + .form-group { + margin-bottom: 15px; + } + + .form-group label { + display: block; + margin-bottom: 8px; + color: #333; + font-weight: 500; + } + + .form-group input[type="text"] { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.3s; + } + + .form-group input[type="text"]:focus { + outline: none; + border-color: #667eea; + } + + .btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s; + display: inline-flex; + align-items: center; + gap: 8px; + } + + .btn-primary { + background: #667eea; + color: white; + } + + .btn-primary:hover:not(:disabled) { + background: #5568d3; + } + + .btn-danger { + background: #dc3545; + color: white; + } + + .btn-danger:hover:not(:disabled) { + background: #c82333; + } + + .btn-warning { + background: #ffc107; + color: #333; + } + + .btn-warning:hover:not(:disabled) { + background: #e0a800; + } + + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .checkpoint-list { + margin-top: 20px; + } + + .checkpoint-item { + background: #f8f9fa; + padding: 15px; + margin-bottom: 12px; + border-radius: 6px; + border: 1px solid #e0e0e0; + transition: all 0.3s; + } + + .checkpoint-item:hover { + background: #e9ecef; + border-color: #667eea; + } + + .checkpoint-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } + + .checkpoint-id { + font-weight: bold; + color: #333; + font-size: 15px; + } + + .checkpoint-timestamp { + color: #666; + font-size: 13px; + } + + .checkpoint-description { + color: #555; + margin-bottom: 10px; + line-height: 1.6; + } + + .checkpoint-stats { + display: flex; + gap: 15px; + margin-bottom: 10px; + font-size: 13px; + color: #666; + } + + .checkpoint-stats span { + display: inline-flex; + align-items: center; + gap: 5px; + } + + .checkpoint-actions { + display: flex; + gap: 10px; + margin-top: 10px; + } + + .btn-sm { + padding: 6px 12px; + font-size: 13px; + } + + .modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; + } + + .modal.show { + display: flex; + } + + .modal-content { + background: white; + border-radius: 12px; + padding: 30px; + max-width: 500px; + width: 90%; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + animation: modalFadeIn 0.3s; + } + + @keyframes modalFadeIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .modal-header { + text-align: center; + margin-bottom: 20px; + } + + .modal-header h3 { + color: #dc3545; + font-size: 20px; + margin-bottom: 10px; + } + + .modal-header .warning-icon { + font-size: 48px; + margin-bottom: 15px; + } + + .modal-body { + margin-bottom: 20px; + } + + .modal-body p { + color: #555; + line-height: 1.6; + margin-bottom: 10px; + } + + .modal-footer { + display: flex; + gap: 10px; + justify-content: flex-end; + } + + .danger-text { + color: #dc3545; + font-weight: bold; + } + + .success-text { + color: #28a745; + font-weight: bold; + } + .details-area { background: white; border-radius: 8px; @@ -457,6 +710,11 @@
4
查看详情
+
+
+
5
+
检查点
+
@@ -469,7 +727,12 @@
- +
@@ -487,20 +750,40 @@ + + +