mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
New services/navi_contacts/ on :8423 — two blueprints, behavior-identical
ports of recon's contacts + address_book code.
Routes:
contacts (10, all @require_auth, per-user via X-Authentik-Username):
GET/POST /api/contacts; GET /api/contacts/nearby; GET /api/contacts/deleted;
GET/PATCH/DELETE /api/contacts/<id>;
POST /api/contacts/<id>/restore; POST /api/contacts/<id>/restore-as;
DELETE /api/contacts/<id>/purge
address_book (2, public):
GET /api/address_book/lookup?q= ; GET /api/address_book/list
Data ownership (per Matt's rule — DBs live in navi-backend territory,
auto-create on first run; only massive tilesets stay external):
- contacts.db: env NAVI_CONTACTS_DB (default /var/lib/navi-backend/contacts.db).
ContactsDB auto-creates the schema (table + 5 indexes incl. the partial-
unique Home/Work index) on first open via CREATE ... IF NOT EXISTS — this
is recon's own behavior, ported verbatim. WAL + busy_timeout=5000 preserved.
- address_book.yaml: vendored into config/address_book.yaml (read-only, like
the deployment profiles in extraction #2); path via NAVI_ADDRESS_BOOK_YAML.
Tests (28 new; recon had none for contacts): full ContactsDB CRUD, soft-delete/
restore/restore-as/purge, Home/Work 409 (create + restore conflict), nearby
proximity, search/category filter, per-user partitioning, auth-required, and
DB auto-create; plus address_book ported from recon's test (exact/partial/
case-insensitive/alias/miss/empty/list/hot-reload/missing-file). Full suite 38.
Timestamp fix (diverges from recon on purpose): restore_as builds updated_at
with Python's strftime. recon uses the bare '%Y-%m-%dT%H:%M:%fZ' there — but
Python's %f is microseconds-only (no seconds), so that yields malformed ISO
strings like "...T15:30:123456Z". recon's own update()/soft_delete() use the
correct '%Y-%m-%dT%H:%M:%S.%fZ'. This port uses the correct format in all three
places and adds a regression guard (strptime) in test_restore_as_relabels.
This is a PRE-EXISTING recon bug; we fix it here. The recon-side restore_as
retires with extraction #7 (Jinja /nav-i + /deleted-contacts removal), so the
recon refactor doesn't need to touch it.
Also: shared/auth.py require_auth now sets request.user_id (recon's contract —
the contacts routes read it). Backward-compatible: navi-traffic/navi-config
admin endpoints don't use it.
Deploy artifacts: systemd unit (:8423) + nginx snippet with two ^~ blocks.
NOTE the nginx prefixes are `^~ /api/contacts` and `^~ /api/address_book`
WITHOUT a trailing slash, so the bare `/api/contacts` (list/create) is matched
too — a trailing-slash prefix would miss it and fall through to recon.
See ../recon_refactor/extraction-3-phase-a.md for the route/schema/ownership
analysis (which also corrects the handoff: contacts is /opt/recon/data/
contacts.db, NOT /mnt/nav/navi.db).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
141 lines
4.2 KiB
Python
141 lines
4.2 KiB
Python
"""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})
|