mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
Merge pull request #3 from zvx-echo6/extraction-3-navi-contacts
Add navi-contacts service (extraction #3)
This commit is contained in:
commit
76a88947ea
14 changed files with 1067 additions and 1 deletions
18
backend/config/address_book.yaml
Normal file
18
backend/config/address_book.yaml
Normal 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
|
||||||
44
backend/deploy/nginx/navi-contacts.conf.snippet
Normal file
44
backend/deploy/nginx/navi-contacts.conf.snippet
Normal 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;
|
||||||
|
}
|
||||||
15
backend/deploy/systemd/navi-contacts.service
Normal file
15
backend/deploy/systemd/navi-contacts.service
Normal 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
|
||||||
0
backend/services/navi_contacts/__init__.py
Normal file
0
backend/services/navi_contacts/__init__.py
Normal file
174
backend/services/navi_contacts/address_book.py
Normal file
174
backend/services/navi_contacts/address_book.py
Normal 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)
|
||||||
28
backend/services/navi_contacts/address_book_route.py
Normal file
28
backend/services/navi_contacts/address_book_route.py
Normal 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)
|
||||||
57
backend/services/navi_contacts/admin.py
Normal file
57
backend/services/navi_contacts/admin.py
Normal 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)
|
||||||
64
backend/services/navi_contacts/app.py
Normal file
64
backend/services/navi_contacts/app.py
Normal 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
|
||||||
237
backend/services/navi_contacts/contacts_db.py
Normal file
237
backend/services/navi_contacts/contacts_db.py
Normal 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
|
||||||
141
backend/services/navi_contacts/contacts_route.py
Normal file
141
backend/services/navi_contacts/contacts_route.py
Normal 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})
|
||||||
0
backend/services/navi_contacts/tests/__init__.py
Normal file
0
backend/services/navi_contacts/tests/__init__.py
Normal file
111
backend/services/navi_contacts/tests/test_address_book.py
Normal file
111
backend/services/navi_contacts/tests/test_address_book.py
Normal 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'
|
||||||
172
backend/services/navi_contacts/tests/test_contacts.py
Normal file
172
backend/services/navi_contacts/tests/test_contacts.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue