diff --git a/tests/unit/test_pets_api.py b/tests/unit/test_pets_api.py new file mode 100644 index 0000000..9aec54b --- /dev/null +++ b/tests/unit/test_pets_api.py @@ -0,0 +1,109 @@ +"""Tests for pets web blueprint API endpoints.""" + +import pytest +from sqlalchemy import create_engine + +from vigilar.config import VigilarConfig +from vigilar.storage.schema import metadata +from vigilar.web.app import create_app + + +@pytest.fixture +def client(tmp_path): + """Flask test client wired to an in-memory test DB.""" + db_path = tmp_path / "pets_test.db" + engine = create_engine(f"sqlite:///{db_path}", echo=False) + metadata.create_all(engine) + + cfg = VigilarConfig() + app = create_app(cfg) + app.config["DB_ENGINE"] = engine + app.config["TESTING"] = True + + with app.test_client() as c: + yield c + + +class TestPetsAPI: + def test_register_pet(self, client): + resp = client.post("/pets/register", json={ + "name": "Angel", "species": "cat", "breed": "DSH", + "color_description": "black", + }) + assert resp.status_code == 200 + data = resp.get_json() + assert data["name"] == "Angel" + assert "id" in data + + def test_get_pet_status(self, client): + client.post("/pets/register", json={"name": "Angel", "species": "cat"}) + resp = client.get("/pets/api/status") + assert resp.status_code == 200 + data = resp.get_json() + assert len(data["pets"]) == 1 + assert data["pets"][0]["name"] == "Angel" + + def test_get_sightings_empty(self, client): + resp = client.get("/pets/api/sightings") + assert resp.status_code == 200 + data = resp.get_json() + assert data["sightings"] == [] + + def test_get_wildlife_empty(self, client): + resp = client.get("/pets/api/wildlife") + assert resp.status_code == 200 + data = resp.get_json() + assert data["sightings"] == [] + + def test_get_unlabeled_empty(self, client): + resp = client.get("/pets/api/unlabeled") + assert resp.status_code == 200 + data = resp.get_json() + assert data["crops"] == [] + + def test_pets_dashboard_loads(self, client): + resp = client.get("/pets/") + assert resp.status_code == 200 + + def test_register_pet_missing_fields(self, client): + resp = client.post("/pets/register", json={"name": "Buddy"}) + assert resp.status_code == 400 + + def test_label_crop(self, client): + # Register a pet first + reg = client.post("/pets/register", json={"name": "Angel", "species": "cat"}) + pet_id = reg.get_json()["id"] + + # Insert an unlabeled sighting directly via DB + + from vigilar.storage.queries import insert_pet_sighting + app_engine = client.application.config["DB_ENGINE"] + sighting_id = insert_pet_sighting(app_engine, species="cat", camera_id="cam1", + confidence=0.9) + + resp = client.post(f"/pets/{sighting_id}/label", json={"pet_id": pet_id}) + assert resp.status_code == 200 + assert resp.get_json()["ok"] is True + + def test_delete_pet(self, client): + reg = client.post("/pets/register", json={"name": "Ghost", "species": "cat"}) + pet_id = reg.get_json()["id"] + + resp = client.delete(f"/pets/{pet_id}/delete") + assert resp.status_code == 200 + assert resp.get_json()["ok"] is True + + status = client.get("/pets/api/status").get_json() + assert len(status["pets"]) == 0 + + def test_training_status(self, client): + resp = client.get("/pets/api/training-status") + assert resp.status_code == 200 + data = resp.get_json() + assert "status" in data + + def test_highlights(self, client): + resp = client.get("/pets/api/highlights") + assert resp.status_code == 200 + data = resp.get_json() + assert "highlights" in data diff --git a/vigilar/web/app.py b/vigilar/web/app.py index c6194ab..06f7784 100644 --- a/vigilar/web/app.py +++ b/vigilar/web/app.py @@ -26,6 +26,7 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask: from vigilar.web.blueprints.cameras import cameras_bp from vigilar.web.blueprints.events import events_bp from vigilar.web.blueprints.kiosk import kiosk_bp + from vigilar.web.blueprints.pets import pets_bp from vigilar.web.blueprints.recordings import recordings_bp from vigilar.web.blueprints.sensors import sensors_bp from vigilar.web.blueprints.system import system_bp @@ -33,6 +34,7 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask: app.register_blueprint(cameras_bp) app.register_blueprint(events_bp) app.register_blueprint(kiosk_bp) + app.register_blueprint(pets_bp) app.register_blueprint(recordings_bp) app.register_blueprint(sensors_bp) app.register_blueprint(system_bp) diff --git a/vigilar/web/blueprints/pets.py b/vigilar/web/blueprints/pets.py new file mode 100644 index 0000000..2d533cb --- /dev/null +++ b/vigilar/web/blueprints/pets.py @@ -0,0 +1,205 @@ +"""Pets blueprint — pet registration, sightings, labeling, training.""" + +import time +from pathlib import Path + +from flask import Blueprint, current_app, jsonify, render_template, request + +pets_bp = Blueprint("pets", __name__, url_prefix="/pets") + + +def _engine(): + return current_app.config.get("DB_ENGINE") + + +@pets_bp.route("/") +def dashboard(): + return render_template("pets/dashboard.html") + + +@pets_bp.route("/register", methods=["POST"]) +def register_pet(): + data = request.get_json(silent=True) or {} + name = data.get("name") + species = data.get("species") + if not name or not species: + return jsonify({"error": "name and species required"}), 400 + + engine = _engine() + if engine is None: + return jsonify({"error": "database not available"}), 503 + + from vigilar.storage.queries import insert_pet + pet_id = insert_pet( + engine, + name=name, + species=species, + breed=data.get("breed"), + color_description=data.get("color_description"), + photo_path=data.get("photo_path"), + ) + return jsonify({"id": pet_id, "name": name, "species": species}) + + +@pets_bp.route("/api/status") +def pet_status(): + engine = _engine() + if engine is None: + return jsonify({"pets": []}) + + from vigilar.storage.queries import get_all_pets, get_pet_last_location + pets_list = get_all_pets(engine) + result = [] + for pet in pets_list: + last = get_pet_last_location(engine, pet["id"]) + result.append({ + **pet, + "last_seen_ts": last["ts"] if last else None, + "last_camera": last["camera_id"] if last else None, + }) + return jsonify({"pets": result}) + + +@pets_bp.route("/api/sightings") +def pet_sightings(): + engine = _engine() + if engine is None: + return jsonify({"sightings": []}) + + pet_id = request.args.get("pet_id") + camera_id = request.args.get("camera_id") + since_ts = request.args.get("since", type=float) + limit = request.args.get("limit", 100, type=int) + + from vigilar.storage.queries import get_pet_sightings + sightings = get_pet_sightings(engine, pet_id=pet_id, camera_id=camera_id, + since_ts=since_ts, limit=limit) + return jsonify({"sightings": sightings}) + + +@pets_bp.route("/api/wildlife") +def wildlife_sightings(): + engine = _engine() + if engine is None: + return jsonify({"sightings": []}) + + threat_level = request.args.get("threat_level") + camera_id = request.args.get("camera_id") + since_ts = request.args.get("since", type=float) + limit = request.args.get("limit", 100, type=int) + + from vigilar.storage.queries import get_wildlife_sightings + sightings = get_wildlife_sightings(engine, threat_level=threat_level, camera_id=camera_id, + since_ts=since_ts, limit=limit) + return jsonify({"sightings": sightings}) + + +@pets_bp.route("/api/unlabeled") +def unlabeled_crops(): + engine = _engine() + if engine is None: + return jsonify({"crops": []}) + + species = request.args.get("species") + limit = request.args.get("limit", 50, type=int) + + from vigilar.storage.queries import get_unlabeled_sightings + crops = get_unlabeled_sightings(engine, species=species, limit=limit) + return jsonify({"crops": crops}) + + +@pets_bp.route("//label", methods=["POST"]) +def label_crop(sighting_id: str): + data = request.get_json(silent=True) or {} + pet_id = data.get("pet_id") + if not pet_id: + return jsonify({"error": "pet_id required"}), 400 + + engine = _engine() + if engine is None: + return jsonify({"error": "database not available"}), 503 + + from vigilar.storage.queries import label_sighting + try: + label_sighting(engine, int(sighting_id), pet_id) + except (ValueError, TypeError): + return jsonify({"error": "invalid sighting_id"}), 400 + return jsonify({"ok": True}) + + +@pets_bp.route("//upload", methods=["POST"]) +def upload_photo(pet_id: str): + engine = _engine() + if engine is None: + return jsonify({"error": "database not available"}), 503 + + files = request.files.getlist("photos") + if not files: + return jsonify({"error": "no files provided"}), 400 + + cfg = current_app.config.get("VIGILAR_CONFIG") + upload_dir = Path(cfg.system.data_dir if cfg else "/tmp/vigilar") / "training" / pet_id + upload_dir.mkdir(parents=True, exist_ok=True) + + from vigilar.storage.queries import insert_training_image + saved = [] + for f in files: + if not f.filename: + continue + dest = upload_dir / f"{int(time.time() * 1000)}_{f.filename}" + f.save(str(dest)) + insert_training_image(engine, pet_id=pet_id, image_path=str(dest), source="upload") + saved.append(str(dest)) + + return jsonify({"ok": True, "saved": len(saved)}) + + +@pets_bp.route("//update", methods=["POST"]) +def update_pet(pet_id: str): + engine = _engine() + if engine is None: + return jsonify({"error": "database not available"}), 503 + + data = request.get_json(silent=True) or {} + from sqlalchemy import update as sa_update + + from vigilar.storage.schema import pets as pets_table + + allowed = {"name", "species", "breed", "color_description", "photo_path"} + values = {k: v for k, v in data.items() if k in allowed} + if not values: + return jsonify({"error": "no valid fields to update"}), 400 + + with engine.begin() as conn: + conn.execute(sa_update(pets_table).where(pets_table.c.id == pet_id).values(**values)) + return jsonify({"ok": True}) + + +@pets_bp.route("//delete", methods=["DELETE"]) +def delete_pet(pet_id: str): + engine = _engine() + if engine is None: + return jsonify({"error": "database not available"}), 503 + + from vigilar.storage.schema import pets as pets_table + with engine.begin() as conn: + conn.execute(pets_table.delete().where(pets_table.c.id == pet_id)) + return jsonify({"ok": True}) + + +@pets_bp.route("/train", methods=["POST"]) +def trigger_training(): + # Publish training trigger via MQTT or queue; for now return accepted + return jsonify({"ok": True, "status": "queued"}) + + +@pets_bp.route("/api/training-status") +def training_status(): + # TODO: read actual training progress from shared state / DB + return jsonify({"status": "idle", "progress": 0, "message": ""}) + + +@pets_bp.route("/api/highlights") +def highlights(): + # TODO: query today's highlight clips from recordings + return jsonify({"highlights": []}) diff --git a/vigilar/web/templates/pets/dashboard.html b/vigilar/web/templates/pets/dashboard.html new file mode 100644 index 0000000..82678fa --- /dev/null +++ b/vigilar/web/templates/pets/dashboard.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block title %}Pets — Vigilar{% endblock %} + +{% block content %} +
+

Pet & Wildlife Monitor

+ +
+ +
+ + Full pet dashboard UI coming in the next task. API endpoints are available at + /pets/api/status, /pets/api/sightings, + /pets/api/wildlife, and /pets/api/unlabeled. +
+{% endblock %}