feat(S3): visitors blueprint with profiles, visits, labeling, privacy controls
Add /visitors/ blueprint with REST API endpoints for listing profiles and visits, labeling (with consent gate), household linking, ignore/unignore, and cascading forget. Register blueprint in app.py. Add dashboard and profile detail templates (Bootstrap 5 dark, tab navigation, fetch-based JS). All six API tests pass (339 total). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a5ddc53cf0
commit
23d5bf062a
69
tests/unit/test_visitors_api.py
Normal file
69
tests/unit/test_visitors_api.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
from vigilar.config import VigilarConfig
|
||||||
|
from vigilar.storage.queries import create_face_profile, insert_visit
|
||||||
|
from vigilar.web.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def visitor_app(test_db):
|
||||||
|
cfg = VigilarConfig()
|
||||||
|
app = create_app(cfg)
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
app.config["DB_ENGINE"] = test_db
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seeded_visitors(test_db):
|
||||||
|
now = time.time()
|
||||||
|
pid1 = create_face_profile(test_db, name="Bob", first_seen_at=now - 86400, last_seen_at=now)
|
||||||
|
pid2 = create_face_profile(test_db, first_seen_at=now - 3600, last_seen_at=now)
|
||||||
|
insert_visit(test_db, pid1, "front", now - 3600)
|
||||||
|
insert_visit(test_db, pid2, "front", now - 1800)
|
||||||
|
return pid1, pid2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_profiles(visitor_app, seeded_visitors):
|
||||||
|
with visitor_app.test_client() as c:
|
||||||
|
rv = c.get("/visitors/api/profiles")
|
||||||
|
assert rv.status_code == 200
|
||||||
|
assert len(rv.get_json()["profiles"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_visits(visitor_app, seeded_visitors):
|
||||||
|
with visitor_app.test_client() as c:
|
||||||
|
assert len(c.get("/visitors/api/visits").get_json()["visits"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_with_consent(visitor_app, seeded_visitors, test_db):
|
||||||
|
_, pid2 = seeded_visitors
|
||||||
|
with visitor_app.test_client() as c:
|
||||||
|
rv = c.post(f"/visitors/{pid2}/label", json={"name": "Alice", "consent": True})
|
||||||
|
assert rv.status_code == 200
|
||||||
|
from vigilar.storage.queries import get_face_profile
|
||||||
|
assert get_face_profile(test_db, pid2)["name"] == "Alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_requires_consent(visitor_app, seeded_visitors):
|
||||||
|
_, pid2 = seeded_visitors
|
||||||
|
with visitor_app.test_client() as c:
|
||||||
|
assert c.post(
|
||||||
|
f"/visitors/{pid2}/label", json={"name": "Alice", "consent": False}
|
||||||
|
).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_forget(visitor_app, seeded_visitors, test_db):
|
||||||
|
pid1, _ = seeded_visitors
|
||||||
|
with visitor_app.test_client() as c:
|
||||||
|
assert c.delete(f"/visitors/{pid1}/forget").status_code == 200
|
||||||
|
from vigilar.storage.queries import get_face_profile
|
||||||
|
assert get_face_profile(test_db, pid1) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignore(visitor_app, seeded_visitors, test_db):
|
||||||
|
_, pid2 = seeded_visitors
|
||||||
|
with visitor_app.test_client() as c:
|
||||||
|
assert c.post(f"/visitors/{pid2}/ignore").status_code == 200
|
||||||
|
from vigilar.storage.queries import get_face_profile
|
||||||
|
assert get_face_profile(test_db, pid2)["ignored"] == 1
|
||||||
@ -30,6 +30,7 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask:
|
|||||||
from vigilar.web.blueprints.recordings import recordings_bp
|
from vigilar.web.blueprints.recordings import recordings_bp
|
||||||
from vigilar.web.blueprints.sensors import sensors_bp
|
from vigilar.web.blueprints.sensors import sensors_bp
|
||||||
from vigilar.web.blueprints.system import system_bp
|
from vigilar.web.blueprints.system import system_bp
|
||||||
|
from vigilar.web.blueprints.visitors import visitors_bp
|
||||||
from vigilar.web.blueprints.wildlife import wildlife_bp
|
from vigilar.web.blueprints.wildlife import wildlife_bp
|
||||||
|
|
||||||
app.register_blueprint(cameras_bp)
|
app.register_blueprint(cameras_bp)
|
||||||
@ -39,6 +40,7 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask:
|
|||||||
app.register_blueprint(recordings_bp)
|
app.register_blueprint(recordings_bp)
|
||||||
app.register_blueprint(sensors_bp)
|
app.register_blueprint(sensors_bp)
|
||||||
app.register_blueprint(system_bp)
|
app.register_blueprint(system_bp)
|
||||||
|
app.register_blueprint(visitors_bp)
|
||||||
app.register_blueprint(wildlife_bp)
|
app.register_blueprint(wildlife_bp)
|
||||||
|
|
||||||
# Root route → dashboard
|
# Root route → dashboard
|
||||||
|
|||||||
129
vigilar/web/blueprints/visitors.py
Normal file
129
vigilar/web/blueprints/visitors.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
"""Visitors blueprint — face profiles, visits, labeling, privacy controls."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||||
|
|
||||||
|
visitors_bp = Blueprint("visitors", __name__, url_prefix="/visitors")
|
||||||
|
|
||||||
|
|
||||||
|
def _engine():
|
||||||
|
return current_app.config.get("DB_ENGINE")
|
||||||
|
|
||||||
|
|
||||||
|
@visitors_bp.route("/")
|
||||||
|
def dashboard():
|
||||||
|
return render_template("visitors/dashboard.html")
|
||||||
|
|
||||||
|
|
||||||
|
@visitors_bp.route("/api/profiles")
|
||||||
|
def profiles_api():
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({"profiles": []})
|
||||||
|
from vigilar.storage.queries import get_all_profiles
|
||||||
|
filter_type = request.args.get("filter")
|
||||||
|
profiles = get_all_profiles(
|
||||||
|
engine,
|
||||||
|
named_only=(filter_type == "known"),
|
||||||
|
include_ignored=(filter_type != "active"),
|
||||||
|
)
|
||||||
|
return jsonify({"profiles": profiles})
|
||||||
|
|
||||||
|
|
||||||
|
@visitors_bp.route("/api/visits")
|
||||||
|
def visits_api():
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({"visits": []})
|
||||||
|
from vigilar.storage.queries import get_visits
|
||||||
|
visits_list = get_visits(
|
||||||
|
engine,
|
||||||
|
profile_id=request.args.get("profile_id", type=int),
|
||||||
|
camera_id=request.args.get("camera_id"),
|
||||||
|
limit=min(request.args.get("limit", 50, type=int), 500),
|
||||||
|
)
|
||||||
|
return jsonify({"visits": visits_list})
|
||||||
|
|
||||||
|
|
||||||
|
@visitors_bp.route("/<int:profile_id>")
|
||||||
|
def profile_detail(profile_id):
|
||||||
|
return render_template("visitors/profile.html", profile_id=profile_id)
|
||||||
|
|
||||||
|
|
||||||
|
@visitors_bp.route("/<int:profile_id>/label", methods=["POST"])
|
||||||
|
def label_profile(profile_id):
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({"error": "database not available"}), 503
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not data.get("consent"):
|
||||||
|
return jsonify({"error": "Consent required to label a face"}), 400
|
||||||
|
if not data.get("name"):
|
||||||
|
return jsonify({"error": "name required"}), 400
|
||||||
|
from vigilar.storage.queries import update_face_profile
|
||||||
|
update_face_profile(engine, profile_id, name=data["name"])
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@visitors_bp.route("/<int:profile_id>/link", methods=["POST"])
|
||||||
|
def link_household(profile_id):
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({"error": "database not available"}), 503
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not data.get("presence_member"):
|
||||||
|
return jsonify({"error": "presence_member required"}), 400
|
||||||
|
from vigilar.storage.queries import update_face_profile
|
||||||
|
update_face_profile(engine, profile_id, is_household=1, presence_member=data["presence_member"])
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@visitors_bp.route("/<int:profile_id>/unlink", methods=["POST"])
|
||||||
|
def unlink_household(profile_id):
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({"error": "database not available"}), 503
|
||||||
|
from vigilar.storage.queries import update_face_profile
|
||||||
|
update_face_profile(engine, profile_id, is_household=0, presence_member=None)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@visitors_bp.route("/<int:profile_id>/forget", methods=["DELETE"])
|
||||||
|
def forget_profile(profile_id):
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({"error": "database not available"}), 503
|
||||||
|
from vigilar.storage.queries import get_face_profile
|
||||||
|
profile = get_face_profile(engine, profile_id)
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"error": "Profile not found"}), 404
|
||||||
|
cfg = current_app.config.get("VIGILAR_CONFIG")
|
||||||
|
face_dir = cfg.visitors.face_crop_dir if cfg else "/var/vigilar/faces"
|
||||||
|
profile_dir = Path(face_dir) / str(profile_id)
|
||||||
|
if profile_dir.exists():
|
||||||
|
shutil.rmtree(profile_dir)
|
||||||
|
from vigilar.storage.queries import delete_face_profile_cascade
|
||||||
|
delete_face_profile_cascade(engine, profile_id)
|
||||||
|
return jsonify({"status": "forgotten"})
|
||||||
|
|
||||||
|
|
||||||
|
@visitors_bp.route("/<int:profile_id>/ignore", methods=["POST"])
|
||||||
|
def ignore_profile(profile_id):
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({"error": "database not available"}), 503
|
||||||
|
from vigilar.storage.queries import update_face_profile
|
||||||
|
update_face_profile(engine, profile_id, ignored=1)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@visitors_bp.route("/<int:profile_id>/unignore", methods=["POST"])
|
||||||
|
def unignore_profile(profile_id):
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({"error": "database not available"}), 503
|
||||||
|
from vigilar.storage.queries import update_face_profile
|
||||||
|
update_face_profile(engine, profile_id, ignored=0)
|
||||||
|
return jsonify({"ok": True})
|
||||||
350
vigilar/web/templates/visitors/dashboard.html
Normal file
350
vigilar/web/templates/visitors/dashboard.html
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Visitors — Vigilar{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-people-fill me-2 text-info"></i>Visitor Recognition</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab nav -->
|
||||||
|
<ul class="nav nav-tabs border-secondary mb-3" id="visitors-tabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link active" data-tab="household">
|
||||||
|
<i class="bi bi-house-fill me-1"></i>Household
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" data-tab="known">
|
||||||
|
<i class="bi bi-person-check-fill me-1"></i>Known
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" data-tab="unknown">
|
||||||
|
<i class="bi bi-person-fill-question me-1"></i>Unknown
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" data-tab="visits">
|
||||||
|
<i class="bi bi-clock-history me-1"></i>Recent Visits
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Profiles panel -->
|
||||||
|
<div id="panel-household" class="tab-panel">
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header border-secondary py-2">
|
||||||
|
<span class="small fw-semibold">Household Members</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-hover mb-0 small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Presence Link</th>
|
||||||
|
<th>Visits</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody-household">
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-known" class="tab-panel d-none">
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header border-secondary py-2">
|
||||||
|
<span class="small fw-semibold">Named Visitors</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-hover mb-0 small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Visits</th>
|
||||||
|
<th>First Seen</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody-known">
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-unknown" class="tab-panel d-none">
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header border-secondary py-2">
|
||||||
|
<span class="small fw-semibold">Unidentified Faces</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-hover mb-0 small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Profile ID</th>
|
||||||
|
<th>Visits</th>
|
||||||
|
<th>First Seen</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
|
<th>Ignored</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody-unknown">
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-visits" class="tab-panel d-none">
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header border-secondary py-2">
|
||||||
|
<span class="small fw-semibold">Recent Visits</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-hover mb-0 small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Profile</th>
|
||||||
|
<th>Camera</th>
|
||||||
|
<th>Arrived</th>
|
||||||
|
<th>Departed</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody-visits">
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label modal -->
|
||||||
|
<div class="modal fade" id="label-modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content bg-dark border-secondary">
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h6 class="modal-title"><i class="bi bi-tag-fill me-2"></i>Label Face Profile</h6>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-warning small py-2">
|
||||||
|
<i class="bi bi-shield-exclamation me-1"></i>
|
||||||
|
Labeling stores biometric data. Only label faces with the subject's consent.
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
|
||||||
|
id="label-name-input" placeholder="Enter name">
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="label-consent-check">
|
||||||
|
<label class="form-check-label small" for="label-consent-check">
|
||||||
|
I confirm consent has been obtained
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-secondary">
|
||||||
|
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-primary" id="label-confirm-btn">Save Label</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let labelTargetId = null;
|
||||||
|
const labelModal = new bootstrap.Modal(document.getElementById('label-modal'));
|
||||||
|
|
||||||
|
function fmtTs(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
if (s < 60) return s.toFixed(0) + 's';
|
||||||
|
return (s / 60).toFixed(1) + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileLink(p) {
|
||||||
|
const label = p.name || ('Profile #' + p.id);
|
||||||
|
return `<a href="/visitors/${p.id}" class="text-info text-decoration-none">${label}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionBtns(p) {
|
||||||
|
const ignore = p.ignored
|
||||||
|
? `<button class="btn btn-xs btn-outline-secondary btn-unignore" data-id="${p.id}">Unignore</button>`
|
||||||
|
: `<button class="btn btn-xs btn-outline-warning btn-ignore" data-id="${p.id}">Ignore</button>`;
|
||||||
|
return `<div class="d-flex gap-1 justify-content-end">
|
||||||
|
<button class="btn btn-xs btn-outline-info btn-label" data-id="${p.id}">Label</button>
|
||||||
|
${ignore}
|
||||||
|
<button class="btn btn-xs btn-outline-danger btn-forget" data-id="${p.id}">Forget</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHousehold() {
|
||||||
|
fetch('/visitors/api/profiles')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
const rows = (data.profiles || []).filter(p => p.is_household);
|
||||||
|
const tbody = document.getElementById('tbody-household');
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No household members</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows.map(p => `<tr>
|
||||||
|
<td>${profileLink(p)}</td>
|
||||||
|
<td><span class="badge bg-secondary">${p.presence_member || '—'}</span></td>
|
||||||
|
<td>${p.visit_count}</td>
|
||||||
|
<td class="text-nowrap">${fmtTs(p.last_seen_at)}</td>
|
||||||
|
<td>${actionBtns(p)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKnown() {
|
||||||
|
fetch('/visitors/api/profiles?filter=known')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
const rows = (data.profiles || []).filter(p => !p.is_household);
|
||||||
|
const tbody = document.getElementById('tbody-known');
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No named visitors</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows.map(p => `<tr>
|
||||||
|
<td>${profileLink(p)}</td>
|
||||||
|
<td>${p.visit_count}</td>
|
||||||
|
<td class="text-nowrap">${fmtTs(p.first_seen_at)}</td>
|
||||||
|
<td class="text-nowrap">${fmtTs(p.last_seen_at)}</td>
|
||||||
|
<td>${actionBtns(p)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUnknown() {
|
||||||
|
fetch('/visitors/api/profiles')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
const rows = (data.profiles || []).filter(p => !p.name && !p.is_household);
|
||||||
|
const tbody = document.getElementById('tbody-unknown');
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">No unidentified faces</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows.map(p => `<tr>
|
||||||
|
<td>${profileLink(p)}</td>
|
||||||
|
<td>${p.visit_count}</td>
|
||||||
|
<td class="text-nowrap">${fmtTs(p.first_seen_at)}</td>
|
||||||
|
<td class="text-nowrap">${fmtTs(p.last_seen_at)}</td>
|
||||||
|
<td>${p.ignored ? '<span class="badge bg-secondary">Yes</span>' : '—'}</td>
|
||||||
|
<td>${actionBtns(p)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadVisits() {
|
||||||
|
fetch('/visitors/api/visits?limit=50')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
const rows = data.visits || [];
|
||||||
|
const tbody = document.getElementById('tbody-visits');
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No visits recorded</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows.map(v => `<tr>
|
||||||
|
<td><a href="/visitors/${v.profile_id}" class="text-info text-decoration-none">#${v.profile_id}</a></td>
|
||||||
|
<td>${v.camera_id}</td>
|
||||||
|
<td class="text-nowrap">${fmtTs(v.arrived_at)}</td>
|
||||||
|
<td class="text-nowrap">${fmtTs(v.departed_at)}</td>
|
||||||
|
<td>${fmtDuration(v.duration_s)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('[data-tab]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('d-none'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById('panel-' + btn.dataset.tab).classList.remove('d-none');
|
||||||
|
if (btn.dataset.tab === 'household') loadHousehold();
|
||||||
|
else if (btn.dataset.tab === 'known') loadKnown();
|
||||||
|
else if (btn.dataset.tab === 'unknown') loadUnknown();
|
||||||
|
else if (btn.dataset.tab === 'visits') loadVisits();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Action delegation
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('button[class*="btn-label"], button[class*="btn-ignore"],' +
|
||||||
|
'button[class*="btn-unignore"], button[class*="btn-forget"]');
|
||||||
|
if (!btn) return;
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
if (btn.classList.contains('btn-label')) {
|
||||||
|
labelTargetId = id;
|
||||||
|
document.getElementById('label-name-input').value = '';
|
||||||
|
document.getElementById('label-consent-check').checked = false;
|
||||||
|
labelModal.show();
|
||||||
|
} else if (btn.classList.contains('btn-ignore')) {
|
||||||
|
fetch(`/visitors/${id}/ignore`, { method: 'POST' }).then(() => loadAll());
|
||||||
|
} else if (btn.classList.contains('btn-unignore')) {
|
||||||
|
fetch(`/visitors/${id}/unignore`, { method: 'POST' }).then(() => loadAll());
|
||||||
|
} else if (btn.classList.contains('btn-forget')) {
|
||||||
|
if (confirm('Permanently delete this face profile and all associated data?')) {
|
||||||
|
fetch(`/visitors/${id}/forget`, { method: 'DELETE' }).then(() => loadAll());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('label-confirm-btn').addEventListener('click', () => {
|
||||||
|
if (!labelTargetId) return;
|
||||||
|
const name = document.getElementById('label-name-input').value.trim();
|
||||||
|
const consent = document.getElementById('label-consent-check').checked;
|
||||||
|
fetch(`/visitors/${labelTargetId}/label`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, consent }),
|
||||||
|
}).then(r => {
|
||||||
|
if (r.ok) { labelModal.hide(); loadAll(); }
|
||||||
|
else r.json().then(d => alert(d.error || 'Failed to label profile'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadAll() {
|
||||||
|
loadHousehold();
|
||||||
|
loadKnown();
|
||||||
|
loadUnknown();
|
||||||
|
loadVisits();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHousehold();
|
||||||
|
setInterval(loadAll, 60000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
211
vigilar/web/templates/visitors/profile.html
Normal file
211
vigilar/web/templates/visitors/profile.html
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Visitor Profile — Vigilar{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<a href="/visitors/" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h5 class="mb-0"><i class="bi bi-person-badge-fill me-2 text-info"></i>
|
||||||
|
<span id="profile-title">Loading…</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile summary -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card bg-dark border-secondary h-100">
|
||||||
|
<div class="card-header border-secondary py-2">
|
||||||
|
<span class="small fw-semibold"><i class="bi bi-info-circle me-1"></i>Profile Details</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body small" id="profile-details">
|
||||||
|
<div class="text-muted">Loading…</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer border-secondary py-2 d-flex flex-wrap gap-2" id="profile-actions">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card bg-dark border-secondary h-100">
|
||||||
|
<div class="card-header border-secondary py-2">
|
||||||
|
<span class="small fw-semibold"><i class="bi bi-clock-history me-1"></i>Visit History</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-hover mb-0 small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Camera</th>
|
||||||
|
<th>Arrived</th>
|
||||||
|
<th>Departed</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="visits-tbody">
|
||||||
|
<tr><td colspan="4" class="text-center text-muted py-3">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label modal -->
|
||||||
|
<div class="modal fade" id="label-modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content bg-dark border-secondary">
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h6 class="modal-title"><i class="bi bi-tag-fill me-2"></i>Label Face Profile</h6>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-warning small py-2">
|
||||||
|
<i class="bi bi-shield-exclamation me-1"></i>
|
||||||
|
Labeling stores biometric data. Only label faces with the subject's consent.
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
|
||||||
|
id="label-name-input" placeholder="Enter name">
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="label-consent-check">
|
||||||
|
<label class="form-check-label small" for="label-consent-check">
|
||||||
|
I confirm consent has been obtained
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-secondary">
|
||||||
|
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-primary" id="label-confirm-btn">Save Label</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const profileId = {{ profile_id }};
|
||||||
|
const labelModal = new bootstrap.Modal(document.getElementById('label-modal'));
|
||||||
|
|
||||||
|
function fmtTs(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
if (s < 60) return s.toFixed(0) + 's';
|
||||||
|
return (s / 60).toFixed(1) + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProfile() {
|
||||||
|
fetch('/visitors/api/profiles')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
const profile = (data.profiles || []).find(p => p.id === profileId);
|
||||||
|
if (!profile) {
|
||||||
|
document.getElementById('profile-title').textContent = 'Profile not found';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('profile-title').textContent =
|
||||||
|
profile.name || ('Visitor #' + profile.id);
|
||||||
|
|
||||||
|
const details = document.getElementById('profile-details');
|
||||||
|
details.innerHTML = `
|
||||||
|
<dl class="mb-0">
|
||||||
|
<dt class="text-muted">Name</dt>
|
||||||
|
<dd>${profile.name || '<em class="text-muted">Unlabeled</em>'}</dd>
|
||||||
|
<dt class="text-muted">Household</dt>
|
||||||
|
<dd>${profile.is_household
|
||||||
|
? `<span class="badge bg-primary">Yes</span>${profile.presence_member ? ' — ' + profile.presence_member : ''}`
|
||||||
|
: '—'}</dd>
|
||||||
|
<dt class="text-muted">Total Visits</dt>
|
||||||
|
<dd>${profile.visit_count}</dd>
|
||||||
|
<dt class="text-muted">First Seen</dt>
|
||||||
|
<dd>${fmtTs(profile.first_seen_at)}</dd>
|
||||||
|
<dt class="text-muted">Last Seen</dt>
|
||||||
|
<dd>${fmtTs(profile.last_seen_at)}</dd>
|
||||||
|
<dt class="text-muted">Status</dt>
|
||||||
|
<dd>${profile.ignored ? '<span class="badge bg-secondary">Ignored</span>' : '<span class="badge bg-success">Active</span>'}</dd>
|
||||||
|
</dl>`;
|
||||||
|
|
||||||
|
const actions = document.getElementById('profile-actions');
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="btn btn-sm btn-outline-info" id="btn-label">
|
||||||
|
<i class="bi bi-tag me-1"></i>Label
|
||||||
|
</button>
|
||||||
|
${profile.ignored
|
||||||
|
? '<button class="btn btn-sm btn-outline-secondary" id="btn-unignore">Unignore</button>'
|
||||||
|
: '<button class="btn btn-sm btn-outline-warning" id="btn-ignore">Ignore</button>'}
|
||||||
|
<button class="btn btn-sm btn-outline-danger" id="btn-forget">
|
||||||
|
<i class="bi bi-trash me-1"></i>Forget
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
document.getElementById('btn-label').addEventListener('click', () => {
|
||||||
|
document.getElementById('label-name-input').value = profile.name || '';
|
||||||
|
document.getElementById('label-consent-check').checked = false;
|
||||||
|
labelModal.show();
|
||||||
|
});
|
||||||
|
const ignBtn = document.getElementById('btn-ignore') || document.getElementById('btn-unignore');
|
||||||
|
if (ignBtn) {
|
||||||
|
ignBtn.addEventListener('click', () => {
|
||||||
|
const endpoint = profile.ignored ? 'unignore' : 'ignore';
|
||||||
|
fetch(`/visitors/${profileId}/${endpoint}`, { method: 'POST' })
|
||||||
|
.then(() => loadProfile());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('btn-forget').addEventListener('click', () => {
|
||||||
|
if (confirm('Permanently delete this profile and all associated data?')) {
|
||||||
|
fetch(`/visitors/${profileId}/forget`, { method: 'DELETE' })
|
||||||
|
.then(() => { window.location.href = '/visitors/'; });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
document.getElementById('profile-title').textContent = 'Error loading profile';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadVisits() {
|
||||||
|
fetch(`/visitors/api/visits?profile_id=${profileId}&limit=100`)
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
const rows = data.visits || [];
|
||||||
|
const tbody = document.getElementById('visits-tbody');
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">No visits recorded</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows.map(v => `<tr>
|
||||||
|
<td>${v.camera_id}</td>
|
||||||
|
<td class="text-nowrap">${fmtTs(v.arrived_at)}</td>
|
||||||
|
<td class="text-nowrap">${fmtTs(v.departed_at)}</td>
|
||||||
|
<td>${fmtDuration(v.duration_s)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('label-confirm-btn').addEventListener('click', () => {
|
||||||
|
const name = document.getElementById('label-name-input').value.trim();
|
||||||
|
const consent = document.getElementById('label-consent-check').checked;
|
||||||
|
fetch(`/visitors/${profileId}/label`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, consent }),
|
||||||
|
}).then(r => {
|
||||||
|
if (r.ok) { labelModal.hide(); loadProfile(); }
|
||||||
|
else r.json().then(d => alert(d.error || 'Failed to label profile'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadProfile();
|
||||||
|
loadVisits();
|
||||||
|
setInterval(() => { loadProfile(); loadVisits(); }, 30000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue
Block a user