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)
|
||||
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
|
||||
# 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 wrapper
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue