Merge pull request #3 from zvx-echo6/extraction-3-navi-contacts

Add navi-contacts service (extraction #3)
This commit is contained in:
malice 2026-05-22 11:06:50 -06:00 committed by GitHub
commit 76a88947ea
14 changed files with 1067 additions and 1 deletions

View file

@ -0,0 +1,18 @@
# RECON Address Book — saved locations for navigation shortcuts.
# Entries are matched by name and aliases (case-insensitive).
# Add new entries by appending to the list below.
entries:
- id: home
name: Home
aliases:
- home
- matt's house
- 214 north st
- 214 north street
address: "214 North St, Filer, ID 83328"
lat: 42.5735833
lon: -114.6066389
tags:
- residence
- primary

View file

@ -0,0 +1,44 @@
# =============================================================================
# navi-contacts — nginx integration for the navi.echo6.co vhost
#
# TWO blocks. Add both INSIDE the existing
# server { server_name navi.echo6.co; ... }
# block, BEFORE the existing `location /api/ { ... }` block.
#
# `^~` is required (the lesson from extraction #1): it makes nginx skip regex
# evaluation when this is the longest prefix match, so the vhost's
# `location ~* \.(...)$` asset-regex can never shadow these API paths.
#
# NOTE on the missing trailing slash: the prefixes are `/api/contacts` and
# `/api/address_book` (NOT `/api/contacts/`). The contacts blueprint serves the
# *bare* `/api/contacts` for list (GET) and create (POST) — a trailing-slash
# prefix `^~ /api/contacts/` would NOT match the bare path and those two
# requests would fall through to `location /api/` -> recon. Caddy's matcher
# already handles both forms (`path /api/contacts /api/contacts/*`); the
# no-trailing-slash prefix here is the nginx equivalent that catches both.
#
# Caddy already routes both prefixes through nginx :8440 — /api/contacts/* via
# TIER 1 @authed_api (forward_auth, extraction #1) and /api/address_book/* via
# TIER 2 @public_api (extraction #2) — so NO Caddy edit is needed.
#
# No proxy_cache: contacts is per-user mutable data; address_book is tiny and
# hot-reloaded in-process. `X-Cache-Status: BYPASS` for parity with the other
# navi-* blocks. X-Authentik-Username is forwarded for contacts so the service
# can partition per user (Caddy injects it after forward_auth).
# -----------------------------------------------------------------------------
location ^~ /api/contacts {
proxy_pass http://127.0.0.1:8423;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Authentik-Username $http_x_authentik_username;
proxy_read_timeout 10s;
add_header X-Cache-Status BYPASS;
}
location ^~ /api/address_book {
proxy_pass http://127.0.0.1:8423;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 10s;
add_header X-Cache-Status BYPASS;
}

View file

@ -0,0 +1,15 @@
[Unit]
Description=navi-contacts — contacts + address-book API (Echo6 navi-backend, extraction #3)
After=network-online.target
Wants=network-online.target
[Service]
User=zvx
WorkingDirectory=/home/zvx/projects/repos/navi-backend
EnvironmentFile=/etc/navi-backend/navi-contacts.env
ExecStart=/home/zvx/projects/repos/navi-backend/.venv/bin/gunicorn 'services.navi_contacts.app:create_app()' --bind 127.0.0.1:8423 --workers 2
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,174 @@
"""Address Book — YAML-backed saved-location lookup.
Behavior-identical port of recon's ``lib/address_book.py``. Named locations
(home, work, etc.) with fuzzy matching over name + aliases + partial address.
Hot-reloads when the YAML's mtime changes.
Config path: env ``NAVI_ADDRESS_BOOK_YAML`` (default: the vendored
``config/address_book.yaml`` in this repo's deploy location).
"""
import logging
import os
import re
import threading
import yaml
logger = logging.getLogger('navi_contacts.address_book')
DEFAULT_CONFIG_PATH = '/home/zvx/projects/repos/navi-backend/config/address_book.yaml'
_lock = threading.Lock()
_entries: list[dict] = []
_mtime: float = 0.0
_loaded_path: str | None = None
def _config_path():
return os.environ.get('NAVI_ADDRESS_BOOK_YAML', DEFAULT_CONFIG_PATH)
def reset_cache():
"""Drop cached entries so the next access reloads (env/path may have changed)."""
global _entries, _mtime, _loaded_path
with _lock:
_entries = []
_mtime = 0.0
_loaded_path = None
def _reload_if_changed():
"""Reload the YAML file if its mtime (or path) has changed."""
global _entries, _mtime, _loaded_path
path = _config_path()
try:
st = os.stat(path)
except FileNotFoundError:
logger.warning("Address book not found: %s", path)
_entries = []
_mtime = 0.0
_loaded_path = path
return
if st.st_mtime == _mtime and path == _loaded_path:
return
with _lock:
# Double-check after acquiring lock
try:
st = os.stat(path)
except FileNotFoundError:
_entries = []
_mtime = 0.0
_loaded_path = path
return
if st.st_mtime == _mtime and path == _loaded_path:
return
with open(path, 'r') as f:
data = yaml.safe_load(f) or {}
raw = data.get('entries', [])
loaded = []
for entry in raw:
# Normalise aliases to lowercase for matching
aliases = [a.lower() for a in entry.get('aliases', [])]
loaded.append({
'id': entry.get('id', ''),
'name': entry.get('name', ''),
'aliases': aliases,
'address': entry.get('address', ''),
'lat': entry.get('lat'),
'lon': entry.get('lon'),
'tags': entry.get('tags', []),
})
_entries = loaded
_mtime = st.st_mtime
_loaded_path = path
logger.info("Address book loaded: %d entries from %s", len(_entries), path)
def load():
"""Ensure the address book is loaded (and refreshed if the file changed)."""
_reload_if_changed()
return _entries
def _normalize(text: str) -> str:
"""Lowercase, strip, remove commas, collapse whitespace."""
t = text.strip().lower()
t = t.replace(',', ' ')
return ' '.join(t.split())
def lookup(query: str):
"""
Look up a query against name and aliases.
Returns dict with the matching entry plus a 'confidence' field:
- "exact": full name/alias match, OR query starts with alias + word boundary
- "partial": alias starts with query + word boundary, or alias appears
as a contiguous token sequence inside the query
- None if no match
Matching order (first exact wins, else first partial):
1. normalized(query) == normalized(name or alias) exact
2. normalized(query) starts with normalized(alias) + " " exact
3. normalized(alias) starts with normalized(query) + " " partial
4. normalized(alias) is a contiguous token sub-sequence partial
"""
_reload_if_changed()
q = _normalize(query)
if not q:
return None
first_exact = None
first_partial = None
for entry in _entries:
norm_name = _normalize(entry['name'])
check_aliases = [_normalize(a) for a in entry.get('aliases', [])]
all_forms = [norm_name] + check_aliases
for form in all_forms:
if not form:
continue
# Rule 1: exact match
if q == form:
return {**entry, 'confidence': 'exact'}
# Rule 2: query starts with alias + word boundary
if q.startswith(form + ' '):
if first_exact is None:
first_exact = entry
continue
# Rule 3: alias starts with query (user still typing)
if form.startswith(q) and len(q) < len(form):
if first_partial is None:
first_partial = entry
continue
# Rule 4: alias is contiguous token sub-sequence in query
# Build regex: token1\s+token2\s+...tokenN
tokens = form.split()
if len(tokens) >= 1:
pattern = r'(?:^|\s)' + r'\s+'.join(re.escape(t) for t in tokens) + r'(?:\s|$)'
if re.search(pattern, q):
if first_partial is None:
first_partial = entry
if first_exact is not None:
return {**first_exact, 'confidence': 'exact'}
if first_partial is not None:
return {**first_partial, 'confidence': 'partial'}
return None
def list_all():
"""Return all address book entries."""
_reload_if_changed()
return list(_entries)

View file

@ -0,0 +1,28 @@
"""Address Book API blueprint — port of recon's ``lib/address_book_api.py``.
2 public routes (no auth). Same JSON shapes and status codes (400/404).
"""
from flask import Blueprint, request, jsonify
from . import address_book
bp = Blueprint('address_book', __name__)
@bp.route('/api/address_book/lookup')
def api_address_book_lookup():
q = request.args.get('q', '').strip()
if not q:
return jsonify({'error': 'Missing q parameter'}), 400
result = address_book.lookup(q)
if result is None:
return '', 404
return jsonify(result)
@bp.route('/api/address_book/list')
def api_address_book_list():
entries = address_book.list_all()
return jsonify(entries)

View file

@ -0,0 +1,57 @@
"""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)

View file

@ -0,0 +1,64 @@
"""navi-contacts Flask application factory + gunicorn entry.
Gunicorn entry:
gunicorn 'services.navi_contacts.app:create_app()' --bind 127.0.0.1:8423 --workers 2
Serves two blueprints: contacts (10 routes, auth-gated) and address_book
(2 routes, public), plus the §4.5 admin-info endpoint.
"""
import subprocess
import time
from flask import Flask
from . import contacts_route, address_book_route, admin
from . import address_book as address_book_mod
def _git_sha():
"""Short git SHA of the working tree at startup, or 'unknown' off-repo."""
try:
sha = subprocess.check_output(
['git', 'rev-parse', '--short', 'HEAD'],
stderr=subprocess.DEVNULL,
text=True,
).strip()
return sha or 'unknown'
except Exception:
return 'unknown'
def create_app():
app = Flask(__name__)
# Version + lightweight runtime metrics, read by the admin-info endpoint.
# NOTE: with gunicorn --workers 2 these counters are per-worker.
app.config['VERSION'] = _git_sha()
app.config['METRICS'] = {
'start_time': time.time(),
'request_count': 0,
'last_error_at': None,
}
# Fresh DB handle + address-book cache per app instance, so each gunicorn
# worker (and each test) picks up the current NAVI_CONTACTS_DB /
# NAVI_ADDRESS_BOOK_YAML env. ContactsDB auto-creates the schema on open.
contacts_route.reset_db()
address_book_mod.reset_cache()
@app.before_request
def _count_request():
app.config['METRICS']['request_count'] += 1
@app.after_request
def _track_errors(response):
if response.status_code >= 500:
app.config['METRICS']['last_error_at'] = time.strftime(
'%Y-%m-%dT%H:%M:%SZ', time.gmtime()
)
return response
app.register_blueprint(contacts_route.bp)
app.register_blueprint(address_book_route.bp)
app.register_blueprint(admin.bp)
return app

View file

@ -0,0 +1,237 @@
"""Contacts database — per-user phone book with soft delete and proximity queries.
Behavior-identical port of recon's ``lib/contacts.py``. Thread-local SQLite
connections with WAL mode. The schema (table + 5 indexes incl. the partial-
unique Home/Work index) is created on first connect via ``CREATE ... IF NOT
EXISTS``, so the DB **auto-creates** when absent no migration step needed.
DB path: env ``NAVI_CONTACTS_DB`` (default ``/var/lib/navi-backend/contacts.db``).
The parent directory is created if missing.
"""
import math
import os
import sqlite3
import threading
from datetime import datetime, timezone
_local = threading.local()
DEFAULT_DB_PATH = '/var/lib/navi-backend/contacts.db'
_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.environ.get('NAVI_CONTACTS_DB', DEFAULT_DB_PATH)
self.db_path = db_path
os.makedirs(os.path.dirname(os.path.abspath(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 restore_as(self, user_id, contact_id, new_label):
"""Restore a soft-deleted contact with a new label (for Home/Work conflict resolution)."""
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 not new_label or not new_label.strip():
return None, 'invalid_label'
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')
try:
conn.execute(
"UPDATE contacts SET deleted_at = NULL, deleted_by = NULL, label = ?, updated_at = ? WHERE id = ? AND user_id = ?",
(new_label.strip(), now, contact_id, user_id)
)
conn.commit()
except sqlite3.IntegrityError:
return None, 'conflict'
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

View file

@ -0,0 +1,141 @@
"""Contacts API blueprint — behavior-identical port of recon's ``lib/contacts_api.py``.
10 routes, all ``@require_auth``. ``request.user_id`` (set by the shared
require_auth from ``X-Authentik-Username``) partitions every query. Same JSON
shapes and status codes as recon (200/201/400/404/409).
"""
from flask import Blueprint, request, jsonify
from shared.auth import require_auth
from .contacts_db import ContactsDB
bp = Blueprint('contacts', __name__)
_db = None
def _get_db():
global _db
if _db is None:
_db = ContactsDB()
return _db
def reset_db():
"""Drop the cached ContactsDB so the next access reopens at the current
NAVI_CONTACTS_DB path. Called by create_app() so each worker/test is fresh."""
global _db
_db = None
@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))
@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
@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))
@bp.route('/api/contacts/deleted', methods=['GET'])
@require_auth
def list_deleted():
db = _get_db()
return jsonify(db.list_deleted(request.user_id))
@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)
@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)
@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)
@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)
@bp.route('/api/contacts/<int:contact_id>/restore-as', methods=['POST'])
@require_auth
def restore_as_contact(contact_id):
db = _get_db()
data = request.get_json(force=True)
new_label = data.get('label', '').strip()
if not new_label:
return jsonify({'error': 'label is required'}), 400
contact, err = db.restore_as(request.user_id, contact_id, new_label)
if err == 'not_found':
return jsonify({'error': 'Not found'}), 404
if err == 'invalid_label':
return jsonify({'error': 'Invalid label'}), 400
if err == 'conflict':
return jsonify({'error': 'Label conflict'}), 409
return jsonify(contact)
@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})

View file

@ -0,0 +1,111 @@
"""Tests for navi-contacts address_book — ported from recon's lib/address_book_test.py.
Uses a tmp fixture YAML (mirroring the vendored home entry) pointed at via
NAVI_ADDRESS_BOOK_YAML, exercising lookup() confidence levels and list_all().
"""
import pytest
import services.navi_contacts.address_book as ab
FIXTURE_YAML = """\
entries:
- id: home
name: Home
aliases:
- home
- matt's house
- 214 north st
- 214 north street
address: "214 North St, Filer, ID 83328"
lat: 42.5735833
lon: -114.6066389
tags:
- residence
- primary
"""
@pytest.fixture
def book(tmp_path, monkeypatch):
f = tmp_path / 'address_book.yaml'
f.write_text(FIXTURE_YAML)
monkeypatch.setenv('NAVI_ADDRESS_BOOK_YAML', str(f))
ab.reset_cache()
return f
def test_lookup_exact_name(book):
r = ab.lookup('home')
assert r is not None and r['id'] == 'home' and r['confidence'] == 'exact'
def test_lookup_case_insensitive(book):
r = ab.lookup('Home')
assert r is not None and r['confidence'] == 'exact'
def test_lookup_alias_address_exact(book):
assert ab.lookup('214 north st')['confidence'] == 'exact'
assert ab.lookup('214 North Street')['confidence'] == 'exact'
def test_lookup_comma_normalization(book):
# commas stripped; "214 north st" prefix + word boundary -> exact (rule 2)
r = ab.lookup('214 north st, filer, id')
assert r is not None and r['id'] == 'home' and r['confidence'] == 'exact'
def test_lookup_query_with_trailing_words_is_exact(book):
# query starts with a full alias + word boundary -> exact (rule 2)
assert ab.lookup('214 north st filer')['confidence'] == 'exact'
assert ab.lookup('214 North St Filer ID')['confidence'] == 'exact'
assert ab.lookup('home today')['confidence'] == 'exact'
def test_lookup_partial_prefix(book):
# query is a prefix of an alias (user still typing) -> partial
assert ab.lookup('214')['confidence'] == 'partial'
assert ab.lookup('214 n')['confidence'] == 'partial'
def test_lookup_miss(book):
assert ab.lookup('nonexistent place') is None
def test_lookup_empty(book):
assert ab.lookup('') is None
assert ab.lookup(' ') is None
def test_list_all(book):
entries = ab.list_all()
assert len(entries) == 1
e = entries[0]
assert e['id'] == 'home' and e['lat'] == 42.5735833
# aliases normalized to lowercase at load
assert all(a == a.lower() for a in e['aliases'])
def test_missing_file_is_empty(tmp_path, monkeypatch):
monkeypatch.setenv('NAVI_ADDRESS_BOOK_YAML', str(tmp_path / 'does_not_exist.yaml'))
ab.reset_cache()
assert ab.list_all() == []
assert ab.lookup('home') is None
def test_hot_reload_on_change(book):
assert len(ab.list_all()) == 1
# rewrite with an extra entry; mtime changes -> reload picks it up
book.write_text(FIXTURE_YAML + """\
- id: work
name: Work
aliases: [work, office]
address: "100 Main St"
lat: 42.6
lon: -114.5
tags: [work]
""")
import os, time
os.utime(book, (time.time() + 1, time.time() + 1)) # ensure mtime differs
assert len(ab.list_all()) == 2
assert ab.lookup('office')['id'] == 'work'

View file

@ -0,0 +1,172 @@
"""Tests for navi-contacts /api/contacts/* — the first tests this code has ever had.
Each test gets a fresh on-disk SQLite DB under tmp_path (auto-created by
ContactsDB on first open). The fixture also closes the module-level
thread-local connection so a previous test's DB handle can't leak in.
"""
import pytest
import services.navi_contacts.contacts_db as cdb
from services.navi_contacts.app import create_app
AUTH = {'X-Authentik-Username': 'alice'}
AUTH_B = {'X-Authentik-Username': 'bob'}
@pytest.fixture
def client(tmp_path, monkeypatch):
db_file = tmp_path / 'contacts.db'
monkeypatch.setenv('NAVI_CONTACTS_DB', str(db_file))
# Reset the thread-local connection so we don't reuse a prior test's DB.
conn = getattr(cdb._local, 'contacts_conn', None)
if conn is not None:
conn.close()
cdb._local.contacts_conn = None
app = create_app()
c = app.test_client()
c._db_file = db_file # for the auto-create assertion
return c
def _mk(label='Friend', **extra):
body = {'label': label, 'name': 'Test', 'phone': '555'}
body.update(extra)
return body
# ── auth + auto-create ──
def test_auth_required(client):
assert client.get('/api/contacts').status_code == 401
def test_autocreate_on_fresh_db(client):
# DB file does not exist until the first request touches ContactsDB.
assert not client._db_file.exists()
resp = client.get('/api/contacts', headers=AUTH)
assert resp.status_code == 200
assert resp.get_json() == []
assert client._db_file.exists() # schema auto-created
# ── CRUD ──
def test_create_and_list(client):
r = client.post('/api/contacts', json=_mk(), headers=AUTH)
assert r.status_code == 201
c = r.get_json()
assert c['id'] and c['user_id'] == 'alice' and c['label'] == 'Friend'
lst = client.get('/api/contacts', headers=AUTH).get_json()
assert len(lst) == 1 and lst[0]['id'] == c['id']
def test_get_by_id_and_404(client):
cid = client.post('/api/contacts', json=_mk(), headers=AUTH).get_json()['id']
assert client.get(f'/api/contacts/{cid}', headers=AUTH).status_code == 200
assert client.get('/api/contacts/99999', headers=AUTH).status_code == 404
def test_update_and_404(client):
cid = client.post('/api/contacts', json=_mk(), headers=AUTH).get_json()['id']
r = client.patch(f'/api/contacts/{cid}', json={'name': 'Renamed'}, headers=AUTH)
assert r.status_code == 200 and r.get_json()['name'] == 'Renamed'
assert client.patch('/api/contacts/99999', json={'name': 'x'}, headers=AUTH).status_code == 404
# ── soft delete / restore / purge ──
def test_soft_delete_hides_from_list(client):
cid = client.post('/api/contacts', json=_mk(), headers=AUTH).get_json()['id']
d = client.delete(f'/api/contacts/{cid}', headers=AUTH)
assert d.status_code == 200 and d.get_json()['deleted_at']
assert client.get('/api/contacts', headers=AUTH).get_json() == []
deleted = client.get('/api/contacts/deleted', headers=AUTH).get_json()
assert len(deleted) == 1 and deleted[0]['id'] == cid
def test_restore(client):
cid = client.post('/api/contacts', json=_mk(), headers=AUTH).get_json()['id']
client.delete(f'/api/contacts/{cid}', headers=AUTH)
r = client.post(f'/api/contacts/{cid}/restore', headers=AUTH)
assert r.status_code == 200 and r.get_json()['deleted_at'] is None
assert len(client.get('/api/contacts', headers=AUTH).get_json()) == 1
def test_purge_requires_deleted_then_removes(client):
cid = client.post('/api/contacts', json=_mk(), headers=AUTH).get_json()['id']
# live contact can't be purged
assert client.delete(f'/api/contacts/{cid}/purge', headers=AUTH).status_code == 400
client.delete(f'/api/contacts/{cid}', headers=AUTH) # soft delete
assert client.delete(f'/api/contacts/{cid}/purge', headers=AUTH).status_code == 200
# gone for good
assert client.get('/api/contacts/deleted', headers=AUTH).get_json() == []
# ── Home/Work uniqueness (409) ──
def test_home_work_conflict_on_create(client):
assert client.post('/api/contacts', json=_mk(label='Home'), headers=AUTH).status_code == 201
assert client.post('/api/contacts', json=_mk(label='Home'), headers=AUTH).status_code == 409
def test_restore_conflict_when_label_taken(client):
h1 = client.post('/api/contacts', json=_mk(label='Home'), headers=AUTH).get_json()['id']
client.delete(f'/api/contacts/{h1}', headers=AUTH) # soft-delete the old Home
client.post('/api/contacts', json=_mk(label='Home'), headers=AUTH) # new Home takes the slot
assert client.post(f'/api/contacts/{h1}/restore', headers=AUTH).status_code == 409
def test_restore_as_relabels(client):
h1 = client.post('/api/contacts', json=_mk(label='Home'), headers=AUTH).get_json()['id']
client.delete(f'/api/contacts/{h1}', headers=AUTH)
r = client.post(f'/api/contacts/{h1}/restore-as', json={'label': 'Cabin'}, headers=AUTH)
assert r.status_code == 200 and r.get_json()['label'] == 'Cabin'
# updated_at must be a well-formed ISO-8601 with seconds (regression guard:
# strftime %f is microseconds-only, so it must be preceded by %S.).
from datetime import datetime
ts = r.get_json()['updated_at']
datetime.strptime(ts, '%Y-%m-%dT%H:%M:%S.%fZ') # raises if seconds missing
def test_restore_as_requires_label(client):
h1 = client.post('/api/contacts', json=_mk(label='Home'), headers=AUTH).get_json()['id']
client.delete(f'/api/contacts/{h1}', headers=AUTH)
assert client.post(f'/api/contacts/{h1}/restore-as', json={'label': ' '}, headers=AUTH).status_code == 400
# ── nearby ──
def test_nearby_returns_distance(client):
client.post('/api/contacts', json=_mk(label='Spot', lat=42.5736, lon=-114.6066,
show_proximity=True), headers=AUTH)
r = client.get('/api/contacts/nearby?lat=42.5736&lon=-114.6066&radius_m=100', headers=AUTH)
assert r.status_code == 200
rows = r.get_json()
assert len(rows) == 1 and 'distance_m' in rows[0]
def test_nearby_excludes_far_and_non_proximity(client):
# show_proximity off → excluded
client.post('/api/contacts', json=_mk(label='Hidden', lat=42.5736, lon=-114.6066,
show_proximity=False), headers=AUTH)
r = client.get('/api/contacts/nearby?lat=42.5736&lon=-114.6066&radius_m=100', headers=AUTH)
assert r.get_json() == []
def test_nearby_requires_latlon(client):
assert client.get('/api/contacts/nearby', headers=AUTH).status_code == 400
# ── search/category filter + user partitioning ──
def test_search_and_category_filter(client):
client.post('/api/contacts', json=_mk(label='Alpha', category='friends'), headers=AUTH)
client.post('/api/contacts', json=_mk(label='Beta', category='work'), headers=AUTH)
assert len(client.get('/api/contacts?category=work', headers=AUTH).get_json()) == 1
assert len(client.get('/api/contacts?search=Alph', headers=AUTH).get_json()) == 1
def test_user_partitioning(client):
client.post('/api/contacts', json=_mk(), headers=AUTH) # alice
assert client.get('/api/contacts', headers=AUTH_B).get_json() == [] # bob sees nothing
assert len(client.get('/api/contacts', headers=AUTH).get_json()) == 1

View file

@ -23,7 +23,12 @@ def require_auth(fn):
""" """
@wraps(fn) @wraps(fn)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if not get_user_id(request): user_id = get_user_id(request)
if not user_id:
return jsonify({'error': 'authentication required'}), 401 return jsonify({'error': 'authentication required'}), 401
# Expose the validated identity to the handler (recon's contract;
# contacts routes read request.user_id). Harmless for endpoints that
# don't use it (navi-traffic/navi-config admin).
request.user_id = user_id
return fn(*args, **kwargs) return fn(*args, **kwargs)
return wrapper return wrapper