mirror of
https://github.com/zvx-echo6/recon.git
synced 2026-05-20 06:34:40 +02:00
Add contacts/phone book system with per-user scoping
New files: - lib/auth.py: Authentik forward-auth helpers (get_user_id, @require_auth) - lib/contacts.py: ContactsDB with CRUD, soft delete, restore, purge, find_nearby - lib/contacts_api.py: Flask Blueprint with 9 API endpoints at /api/contacts - templates/knowledge/deleted_contacts.html: Dashboard recovery page Modified: - lib/api.py: Register contacts_bp, add KNOWLEDGE_SUBNAV entry, /deleted-contacts route - config/profiles: has_contacts feature flag (true for home, false for pi profiles) Separate SQLite DB at data/contacts.db. Per-user isolation via X-Authentik-Username. Home/Work labels enforced unique per user. Haversine proximity queries (75m default). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
095bf8c2af
commit
a4288c0cd8
8 changed files with 423 additions and 0 deletions
|
|
@ -41,6 +41,7 @@ features:
|
||||||
has_address_book_write: false
|
has_address_book_write: false
|
||||||
has_overture_enrichment: true
|
has_overture_enrichment: true
|
||||||
has_google_places_enrichment: true
|
has_google_places_enrichment: true
|
||||||
|
has_contacts: true
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
center: [42.5736, -114.6066]
|
center: [42.5736, -114.6066]
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ features:
|
||||||
has_address_book_write: true
|
has_address_book_write: true
|
||||||
has_overture_enrichment: false
|
has_overture_enrichment: false
|
||||||
has_google_places_enrichment: false
|
has_google_places_enrichment: false
|
||||||
|
has_contacts: false
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
center: [44.0, -114.0]
|
center: [44.0, -114.0]
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ features:
|
||||||
has_address_book_write: true
|
has_address_book_write: true
|
||||||
has_overture_enrichment: false
|
has_overture_enrichment: false
|
||||||
has_google_places_enrichment: false
|
has_google_places_enrichment: false
|
||||||
|
has_contacts: false
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
center: [44.0, -114.0]
|
center: [44.0, -114.0]
|
||||||
|
|
|
||||||
17
lib/api.py
17
lib/api.py
|
|
@ -63,6 +63,10 @@ app.request_class = _LargeZimRequest
|
||||||
from .address_book_api import address_book_bp
|
from .address_book_api import address_book_bp
|
||||||
app.register_blueprint(address_book_bp)
|
app.register_blueprint(address_book_bp)
|
||||||
|
|
||||||
|
# ── Contacts Blueprint ──
|
||||||
|
from .contacts_api import contacts_bp
|
||||||
|
app.register_blueprint(contacts_bp)
|
||||||
|
|
||||||
# ── Netsyms + Geocode Blueprints ──
|
# ── Netsyms + Geocode Blueprints ──
|
||||||
from .netsyms_api import netsyms_bp, geocode_bp
|
from .netsyms_api import netsyms_bp, geocode_bp
|
||||||
app.register_blueprint(netsyms_bp)
|
app.register_blueprint(netsyms_bp)
|
||||||
|
|
@ -78,6 +82,7 @@ KNOWLEDGE_SUBNAV = [
|
||||||
{'href': '/upload', 'label': 'Upload'},
|
{'href': '/upload', 'label': 'Upload'},
|
||||||
{'href': '/web-ingest', 'label': 'Web Ingest'},
|
{'href': '/web-ingest', 'label': 'Web Ingest'},
|
||||||
{'href': '/failures', 'label': 'Failures'},
|
{'href': '/failures', 'label': 'Failures'},
|
||||||
|
{'href': '/deleted-contacts', 'label': 'Deleted Contacts'},
|
||||||
]
|
]
|
||||||
|
|
||||||
PEERTUBE_SUBNAV = [
|
PEERTUBE_SUBNAV = [
|
||||||
|
|
@ -323,6 +328,18 @@ def failures_page():
|
||||||
failures=failures)
|
failures=failures)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/deleted-contacts")
|
||||||
|
def deleted_contacts_page():
|
||||||
|
from .auth import get_user_id
|
||||||
|
from .contacts import ContactsDB
|
||||||
|
user_id = get_user_id() or "anonymous"
|
||||||
|
db = ContactsDB()
|
||||||
|
contacts = db.list_deleted(user_id)
|
||||||
|
return render_template("knowledge/deleted_contacts.html",
|
||||||
|
domain="knowledge", subnav=KNOWLEDGE_SUBNAV, active_page="/deleted-contacts",
|
||||||
|
contacts=contacts)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/peertube')
|
@app.route('/peertube')
|
||||||
def peertube_dashboard():
|
def peertube_dashboard():
|
||||||
return render_template('peertube/dashboard.html',
|
return render_template('peertube/dashboard.html',
|
||||||
|
|
|
||||||
22
lib/auth.py
Normal file
22
lib/auth.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""
|
||||||
|
RECON Auth Helper — extract user identity from Authentik forward-auth headers.
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_id():
|
||||||
|
"""Return X-Authentik-Username or None."""
|
||||||
|
return request.headers.get('X-Authentik-Username')
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(f):
|
||||||
|
"""Decorator: 401 if no Authentik auth header."""
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
user_id = get_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Authentication required'}), 401
|
||||||
|
request.user_id = user_id
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
211
lib/contacts.py
Normal file
211
lib/contacts.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"""
|
||||||
|
RECON Contacts Database — per-user phone book with soft delete and proximity queries.
|
||||||
|
|
||||||
|
Separate DB at data/contacts.db. Thread-local connections with WAL mode (StatusDB pattern).
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
_local = threading.local()
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
call_sign TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
email TEXT,
|
||||||
|
category TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
lat REAL,
|
||||||
|
lon REAL,
|
||||||
|
osm_type TEXT,
|
||||||
|
osm_id INTEGER,
|
||||||
|
address TEXT,
|
||||||
|
show_proximity INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
deleted_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_user_category ON contacts(user_id, category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_user_deleted ON contacts(user_id, deleted_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_geo ON contacts(lat, lon);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_home_work
|
||||||
|
ON contacts(user_id, label)
|
||||||
|
WHERE label IN ('Home', 'Work') AND deleted_at IS NULL;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine_m(lat1, lon1, lat2, lon2):
|
||||||
|
"""Haversine distance in meters."""
|
||||||
|
R = 6_371_000
|
||||||
|
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dlat = math.radians(lat2 - lat1)
|
||||||
|
dlon = math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
|
||||||
|
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row):
|
||||||
|
"""Convert sqlite3.Row to dict, casting show_proximity to bool."""
|
||||||
|
d = dict(row)
|
||||||
|
d['show_proximity'] = bool(d.get('show_proximity', 0))
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class ContactsDB:
|
||||||
|
def __init__(self, db_path=None):
|
||||||
|
if db_path is None:
|
||||||
|
db_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data', 'contacts.db')
|
||||||
|
self.db_path = db_path
|
||||||
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _get_conn(self):
|
||||||
|
if not hasattr(_local, 'contacts_conn') or _local.contacts_conn is None:
|
||||||
|
_local.contacts_conn = sqlite3.connect(self.db_path, timeout=30)
|
||||||
|
_local.contacts_conn.row_factory = sqlite3.Row
|
||||||
|
_local.contacts_conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
_local.contacts_conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
return _local.contacts_conn
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
conn = self._get_conn()
|
||||||
|
conn.executescript(_SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def list_all(self, user_id, category=None, search=None):
|
||||||
|
conn = self._get_conn()
|
||||||
|
sql = "SELECT * FROM contacts WHERE user_id = ? AND deleted_at IS NULL"
|
||||||
|
params = [user_id]
|
||||||
|
if category:
|
||||||
|
sql += " AND category = ?"
|
||||||
|
params.append(category)
|
||||||
|
if search:
|
||||||
|
sql += " AND (label LIKE ? OR name LIKE ? OR call_sign LIKE ? OR phone LIKE ?)"
|
||||||
|
like = f"%{search}%"
|
||||||
|
params.extend([like, like, like, like])
|
||||||
|
sql += " ORDER BY label"
|
||||||
|
return [_row_to_dict(r) for r in conn.execute(sql, params).fetchall()]
|
||||||
|
|
||||||
|
def list_deleted(self, user_id):
|
||||||
|
conn = self._get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM contacts WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC",
|
||||||
|
(user_id,)
|
||||||
|
).fetchall()
|
||||||
|
return [_row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
def get(self, user_id, contact_id, include_deleted=False):
|
||||||
|
conn = self._get_conn()
|
||||||
|
sql = "SELECT * FROM contacts WHERE id = ? AND user_id = ?"
|
||||||
|
if not include_deleted:
|
||||||
|
sql += " AND deleted_at IS NULL"
|
||||||
|
row = conn.execute(sql, (contact_id, user_id)).fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
def create(self, user_id, **fields):
|
||||||
|
conn = self._get_conn()
|
||||||
|
fields.pop('id', None)
|
||||||
|
fields.pop('user_id', None)
|
||||||
|
fields.pop('created_at', None)
|
||||||
|
fields.pop('updated_at', None)
|
||||||
|
fields.pop('deleted_at', None)
|
||||||
|
fields.pop('deleted_by', None)
|
||||||
|
if 'show_proximity' in fields:
|
||||||
|
fields['show_proximity'] = 1 if fields['show_proximity'] else 0
|
||||||
|
columns = ['user_id'] + list(fields.keys())
|
||||||
|
placeholders = ', '.join(['?'] * len(columns))
|
||||||
|
col_str = ', '.join(columns)
|
||||||
|
values = [user_id] + list(fields.values())
|
||||||
|
try:
|
||||||
|
cur = conn.execute(f"INSERT INTO contacts ({col_str}) VALUES ({placeholders})", values)
|
||||||
|
conn.commit()
|
||||||
|
return self.get(user_id, cur.lastrowid), None
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return None, 'conflict'
|
||||||
|
|
||||||
|
def update(self, user_id, contact_id, **fields):
|
||||||
|
conn = self._get_conn()
|
||||||
|
fields.pop('id', None)
|
||||||
|
fields.pop('user_id', None)
|
||||||
|
fields.pop('created_at', None)
|
||||||
|
fields.pop('deleted_at', None)
|
||||||
|
fields.pop('deleted_by', None)
|
||||||
|
if 'show_proximity' in fields:
|
||||||
|
fields['show_proximity'] = 1 if fields['show_proximity'] else 0
|
||||||
|
fields['updated_at'] = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')
|
||||||
|
sets = ', '.join(f"{k} = ?" for k in fields)
|
||||||
|
values = list(fields.values()) + [contact_id, user_id]
|
||||||
|
conn.execute(f"UPDATE contacts SET {sets} WHERE id = ? AND user_id = ? AND deleted_at IS NULL", values)
|
||||||
|
conn.commit()
|
||||||
|
return self.get(user_id, contact_id)
|
||||||
|
|
||||||
|
def soft_delete(self, user_id, contact_id):
|
||||||
|
conn = self._get_conn()
|
||||||
|
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE contacts SET deleted_at = ?, deleted_by = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
||||||
|
(now, user_id, contact_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return self.get(user_id, contact_id, include_deleted=True)
|
||||||
|
|
||||||
|
def restore(self, user_id, contact_id):
|
||||||
|
conn = self._get_conn()
|
||||||
|
row = self.get(user_id, contact_id, include_deleted=True)
|
||||||
|
if not row or not row.get('deleted_at'):
|
||||||
|
return None, 'not_found'
|
||||||
|
if row.get('label') in ('Home', 'Work'):
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM contacts WHERE user_id = ? AND label = ? AND deleted_at IS NULL AND id != ?",
|
||||||
|
(user_id, row['label'], contact_id)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
return None, 'conflict'
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE contacts SET deleted_at = NULL, deleted_by = NULL WHERE id = ? AND user_id = ?",
|
||||||
|
(contact_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return self.get(user_id, contact_id), None
|
||||||
|
|
||||||
|
def purge(self, user_id, contact_id):
|
||||||
|
conn = self._get_conn()
|
||||||
|
row = self.get(user_id, contact_id, include_deleted=True)
|
||||||
|
if not row:
|
||||||
|
return False, 'not_found'
|
||||||
|
if not row.get('deleted_at'):
|
||||||
|
return False, 'not_deleted'
|
||||||
|
conn.execute("DELETE FROM contacts WHERE id = ? AND user_id = ?", (contact_id, user_id))
|
||||||
|
conn.commit()
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def find_nearby(self, user_id, lat, lon, radius_m=75):
|
||||||
|
conn = self._get_conn()
|
||||||
|
# Bounding box pre-filter (~111km per degree lat)
|
||||||
|
dlat = radius_m / 111_000
|
||||||
|
dlon = radius_m / (111_000 * math.cos(math.radians(lat)))
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT * FROM contacts
|
||||||
|
WHERE user_id = ? AND deleted_at IS NULL AND show_proximity = 1
|
||||||
|
AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?""",
|
||||||
|
(user_id, lat - dlat, lat + dlat, lon - dlon, lon + dlon)
|
||||||
|
).fetchall()
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
dist = _haversine_m(lat, lon, r['lat'], r['lon'])
|
||||||
|
if dist <= radius_m:
|
||||||
|
d = _row_to_dict(r)
|
||||||
|
d['distance_m'] = round(dist, 1)
|
||||||
|
results.append(d)
|
||||||
|
results.sort(key=lambda x: x['distance_m'])
|
||||||
|
return results
|
||||||
114
lib/contacts_api.py
Normal file
114
lib/contacts_api.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""
|
||||||
|
RECON Contacts API — Flask Blueprint.
|
||||||
|
|
||||||
|
Per-user phone book with soft delete, restore, purge, and proximity queries.
|
||||||
|
All endpoints require Authentik forward-auth (X-Authentik-Username header).
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
|
||||||
|
from .auth import require_auth
|
||||||
|
from .contacts import ContactsDB
|
||||||
|
|
||||||
|
contacts_bp = Blueprint('contacts', __name__)
|
||||||
|
|
||||||
|
_db = None
|
||||||
|
|
||||||
|
def _get_db():
|
||||||
|
global _db
|
||||||
|
if _db is None:
|
||||||
|
_db = ContactsDB()
|
||||||
|
return _db
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_bp.route('/api/contacts', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def list_contacts():
|
||||||
|
db = _get_db()
|
||||||
|
category = request.args.get('category')
|
||||||
|
search = request.args.get('search')
|
||||||
|
return jsonify(db.list_all(request.user_id, category=category, search=search))
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_bp.route('/api/contacts', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def create_contact():
|
||||||
|
db = _get_db()
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
contact, err = db.create(request.user_id, **data)
|
||||||
|
if err == 'conflict':
|
||||||
|
return jsonify({'error': 'You already have a Home/Work contact'}), 409
|
||||||
|
return jsonify(contact), 201
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_bp.route('/api/contacts/nearby', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def nearby_contacts():
|
||||||
|
db = _get_db()
|
||||||
|
lat = request.args.get('lat', type=float)
|
||||||
|
lon = request.args.get('lon', type=float)
|
||||||
|
radius_m = request.args.get('radius_m', 75, type=float)
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return jsonify({'error': 'lat and lon required'}), 400
|
||||||
|
return jsonify(db.find_nearby(request.user_id, lat, lon, radius_m))
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_bp.route('/api/contacts/deleted', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def list_deleted():
|
||||||
|
db = _get_db()
|
||||||
|
return jsonify(db.list_deleted(request.user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_bp.route('/api/contacts/<int:contact_id>', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_contact(contact_id):
|
||||||
|
db = _get_db()
|
||||||
|
contact = db.get(request.user_id, contact_id)
|
||||||
|
if not contact:
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
return jsonify(contact)
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_bp.route('/api/contacts/<int:contact_id>', methods=['PATCH'])
|
||||||
|
@require_auth
|
||||||
|
def update_contact(contact_id):
|
||||||
|
db = _get_db()
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
contact = db.update(request.user_id, contact_id, **data)
|
||||||
|
if not contact:
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
return jsonify(contact)
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_bp.route('/api/contacts/<int:contact_id>', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def delete_contact(contact_id):
|
||||||
|
db = _get_db()
|
||||||
|
contact = db.soft_delete(request.user_id, contact_id)
|
||||||
|
if not contact:
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
return jsonify(contact)
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_bp.route('/api/contacts/<int:contact_id>/restore', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def restore_contact(contact_id):
|
||||||
|
db = _get_db()
|
||||||
|
contact, err = db.restore(request.user_id, contact_id)
|
||||||
|
if err == 'not_found':
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
if err == 'conflict':
|
||||||
|
return jsonify({'error': 'You already have a Home/Work contact'}), 409
|
||||||
|
return jsonify(contact)
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_bp.route('/api/contacts/<int:contact_id>/purge', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def purge_contact(contact_id):
|
||||||
|
db = _get_db()
|
||||||
|
ok, err = db.purge(request.user_id, contact_id)
|
||||||
|
if err == 'not_found':
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
if err == 'not_deleted':
|
||||||
|
return jsonify({'error': 'Contact must be deleted before purging'}), 400
|
||||||
|
return jsonify({'ok': True})
|
||||||
56
templates/knowledge/deleted_contacts.html
Normal file
56
templates/knowledge/deleted_contacts.html
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h3 style="color:#ffa500;margin-bottom:16px;">Deleted Contacts</h3>
|
||||||
|
{% if not contacts %}
|
||||||
|
<p class="text-dim">No deleted contacts.</p>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<tr><th>Label</th><th>Name</th><th>Category</th><th>Phone</th><th>Deleted At</th><th>Actions</th></tr>
|
||||||
|
{% for c in contacts %}
|
||||||
|
<tr id="row-{{ c.id }}">
|
||||||
|
<td>{{ c.label }}</td>
|
||||||
|
<td>{{ c.name or '' }}</td>
|
||||||
|
<td class="text-dim">{{ c.category or '' }}</td>
|
||||||
|
<td class="text-dim text-xs">{{ c.phone or '' }}</td>
|
||||||
|
<td class="text-dim text-xs">{{ c.deleted_at or '' }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn" onclick="restoreContact({{ c.id }})">Restore</button>
|
||||||
|
<button class="btn" style="margin-left:4px;color:#ff4444;" onclick="purgeContact({{ c.id }})">Purge</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function restoreContact(id) {
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/contacts/' + id + '/restore', {method: 'POST'});
|
||||||
|
if (resp.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
var data = await resp.json();
|
||||||
|
alert(data.error || 'Restore failed');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeContact(id) {
|
||||||
|
if (!confirm('Permanently delete this contact? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/contacts/' + id + '/purge', {method: 'DELETE'});
|
||||||
|
if (resp.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
var data = await resp.json();
|
||||||
|
alert(data.error || 'Purge failed');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue