diff --git a/tests/unit/test_visitors_api.py b/tests/unit/test_visitors_api.py new file mode 100644 index 0000000..8cd6eb6 --- /dev/null +++ b/tests/unit/test_visitors_api.py @@ -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 diff --git a/vigilar/web/app.py b/vigilar/web/app.py index 9fb5d20..9173cb1 100644 --- a/vigilar/web/app.py +++ b/vigilar/web/app.py @@ -30,6 +30,7 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask: from vigilar.web.blueprints.recordings import recordings_bp from vigilar.web.blueprints.sensors import sensors_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 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(sensors_bp) app.register_blueprint(system_bp) + app.register_blueprint(visitors_bp) app.register_blueprint(wildlife_bp) # Root route → dashboard diff --git a/vigilar/web/blueprints/visitors.py b/vigilar/web/blueprints/visitors.py new file mode 100644 index 0000000..0d87db0 --- /dev/null +++ b/vigilar/web/blueprints/visitors.py @@ -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("/") +def profile_detail(profile_id): + return render_template("visitors/profile.html", profile_id=profile_id) + + +@visitors_bp.route("//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("//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("//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("//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("//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("//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}) diff --git a/vigilar/web/templates/visitors/dashboard.html b/vigilar/web/templates/visitors/dashboard.html new file mode 100644 index 0000000..caefa9e --- /dev/null +++ b/vigilar/web/templates/visitors/dashboard.html @@ -0,0 +1,350 @@ +{% extends "base.html" %} +{% block title %}Visitors — Vigilar{% endblock %} + +{% block content %} +
+
Visitor Recognition
+
+ + + + + +
+
+
+ Household Members +
+
+
+ + + + + + + + + + + + + +
NamePresence LinkVisitsLast Seen
Loading…
+
+
+
+
+ +
+
+
+ Named Visitors +
+
+
+ + + + + + + + + + + + + +
NameVisitsFirst SeenLast Seen
Loading…
+
+
+
+
+ +
+
+
+ Unidentified Faces +
+
+
+ + + + + + + + + + + + + + +
Profile IDVisitsFirst SeenLast SeenIgnored
Loading…
+
+
+
+
+ +
+
+
+ Recent Visits +
+
+
+ + + + + + + + + + + + + +
ProfileCameraArrivedDepartedDuration
Loading…
+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/vigilar/web/templates/visitors/profile.html b/vigilar/web/templates/visitors/profile.html new file mode 100644 index 0000000..4d20d72 --- /dev/null +++ b/vigilar/web/templates/visitors/profile.html @@ -0,0 +1,211 @@ +{% extends "base.html" %} +{% block title %}Visitor Profile — Vigilar{% endblock %} + +{% block content %} +
+ + + +
+ Loading… +
+
+ + +
+
+
+
+ Profile Details +
+
+
Loading…
+
+ +
+
+
+
+
+ Visit History +
+
+
+ + + + + + + + + + + + +
CameraArrivedDepartedDuration
Loading…
+
+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %}