navi/backend/services/navi_contacts/admin.py

57 lines
1.8 KiB
Python
Raw Permalink Normal View History

Add navi-contacts service (extraction #3) New services/navi_contacts/ on :8423 — two blueprints, behavior-identical ports of recon's contacts + address_book code. Routes: contacts (10, all @require_auth, per-user via X-Authentik-Username): GET/POST /api/contacts; GET /api/contacts/nearby; GET /api/contacts/deleted; GET/PATCH/DELETE /api/contacts/<id>; POST /api/contacts/<id>/restore; POST /api/contacts/<id>/restore-as; DELETE /api/contacts/<id>/purge address_book (2, public): GET /api/address_book/lookup?q= ; GET /api/address_book/list Data ownership (per Matt's rule — DBs live in navi-backend territory, auto-create on first run; only massive tilesets stay external): - contacts.db: env NAVI_CONTACTS_DB (default /var/lib/navi-backend/contacts.db). ContactsDB auto-creates the schema (table + 5 indexes incl. the partial- unique Home/Work index) on first open via CREATE ... IF NOT EXISTS — this is recon's own behavior, ported verbatim. WAL + busy_timeout=5000 preserved. - address_book.yaml: vendored into config/address_book.yaml (read-only, like the deployment profiles in extraction #2); path via NAVI_ADDRESS_BOOK_YAML. Tests (28 new; recon had none for contacts): full ContactsDB CRUD, soft-delete/ restore/restore-as/purge, Home/Work 409 (create + restore conflict), nearby proximity, search/category filter, per-user partitioning, auth-required, and DB auto-create; plus address_book ported from recon's test (exact/partial/ case-insensitive/alias/miss/empty/list/hot-reload/missing-file). Full suite 38. Timestamp fix (diverges from recon on purpose): restore_as builds updated_at with Python's strftime. recon uses the bare '%Y-%m-%dT%H:%M:%fZ' there — but Python's %f is microseconds-only (no seconds), so that yields malformed ISO strings like "...T15:30:123456Z". recon's own update()/soft_delete() use the correct '%Y-%m-%dT%H:%M:%S.%fZ'. This port uses the correct format in all three places and adds a regression guard (strptime) in test_restore_as_relabels. This is a PRE-EXISTING recon bug; we fix it here. The recon-side restore_as retires with extraction #7 (Jinja /nav-i + /deleted-contacts removal), so the recon refactor doesn't need to touch it. Also: shared/auth.py require_auth now sets request.user_id (recon's contract — the contacts routes read it). Backward-compatible: navi-traffic/navi-config admin endpoints don't use it. Deploy artifacts: systemd unit (:8423) + nginx snippet with two ^~ blocks. NOTE the nginx prefixes are `^~ /api/contacts` and `^~ /api/address_book` WITHOUT a trailing slash, so the bare `/api/contacts` (list/create) is matched too — a trailing-slash prefix would miss it and fall through to recon. See ../recon_refactor/extraction-3-phase-a.md for the route/schema/ownership analysis (which also corrects the handoff: contacts is /opt/recon/data/ contacts.db, NOT /mnt/nav/navi.db). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:52:32 -06:00
"""navi-contacts admin-info endpoint (handoff §4.5).
``GET /api/admin/navi-contacts/info`` Authentik-gated, read-only.
"""
import os
import time
from flask import Blueprint, jsonify, current_app
from shared.auth import require_auth
from shared.admin_info import build_info_response
from .contacts_db import DEFAULT_DB_PATH
from .address_book import DEFAULT_CONFIG_PATH
bp = Blueprint('contacts_admin', __name__)
PORT = 8423
@bp.route('/api/admin/navi-contacts/info')
@require_auth
def navi_contacts_info():
metrics = current_app.config['METRICS']
db_path = os.environ.get('NAVI_CONTACTS_DB', DEFAULT_DB_PATH)
yaml_path = os.environ.get('NAVI_ADDRESS_BOOK_YAML', DEFAULT_CONFIG_PATH)
# env values here are NOT secrets (filesystem paths) — shown as-is, no mask_key.
info = build_info_response(
service='navi-contacts',
version=current_app.config.get('VERSION', 'unknown'),
port=PORT,
config={},
env=[
{'name': 'NAVI_CONTACTS_DB', 'value': db_path},
{'name': 'NAVI_ADDRESS_BOOK_YAML', 'value': yaml_path},
],
dependencies=[], # no upstream HTTP — local SQLite + YAML
filesystem=[
{
'path': db_path,
'exists': os.path.exists(db_path),
'readable': os.access(db_path, os.R_OK),
'writable': os.access(db_path, os.W_OK),
},
{
'path': yaml_path,
'exists': os.path.exists(yaml_path),
'readable': os.access(yaml_path, os.R_OK),
},
],
runtime={
'uptime_s': round(time.time() - metrics['start_time'], 1),
'request_count': metrics['request_count'],
'last_error_at': metrics['last_error_at'],
},
)
return jsonify(info)