diff --git a/tests/unit/test_wildlife_api.py b/tests/unit/test_wildlife_api.py new file mode 100644 index 0000000..218a439 --- /dev/null +++ b/tests/unit/test_wildlife_api.py @@ -0,0 +1,49 @@ +import pytest +from vigilar.config import VigilarConfig +from vigilar.storage.queries import insert_wildlife_sighting +from vigilar.web.app import create_app + +@pytest.fixture +def wildlife_app(test_db): + cfg = VigilarConfig() + app = create_app(cfg) + app.config["TESTING"] = True + app.config["DB_ENGINE"] = test_db + for i in range(3): + insert_wildlife_sighting(test_db, species="deer", threat_level="PASSIVE", + camera_id="front", confidence=0.9) + insert_wildlife_sighting(test_db, species="bear", threat_level="PREDATOR", + camera_id="back", confidence=0.95) + return app + +def test_wildlife_sightings_api(wildlife_app): + with wildlife_app.test_client() as c: + rv = c.get("/wildlife/api/sightings") + assert rv.status_code == 200 + assert len(rv.get_json()["sightings"]) == 4 + +def test_wildlife_sightings_filter_species(wildlife_app): + with wildlife_app.test_client() as c: + rv = c.get("/wildlife/api/sightings?species=bear") + assert len(rv.get_json()["sightings"]) == 1 + +def test_wildlife_stats_api(wildlife_app): + with wildlife_app.test_client() as c: + rv = c.get("/wildlife/api/stats") + data = rv.get_json() + assert data["total"] == 4 + assert data["per_species"]["deer"] == 3 + +def test_wildlife_frequency_api(wildlife_app): + with wildlife_app.test_client() as c: + rv = c.get("/wildlife/api/frequency") + assert rv.status_code == 200 + assert len(rv.get_json()) == 6 + +def test_wildlife_export_csv(wildlife_app): + with wildlife_app.test_client() as c: + rv = c.get("/wildlife/api/export") + assert rv.status_code == 200 + assert "text/csv" in rv.content_type + lines = rv.data.decode().strip().split("\n") + assert len(lines) == 5 # header + 4 rows diff --git a/vigilar/web/app.py b/vigilar/web/app.py index 06f7784..9fb5d20 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.wildlife import wildlife_bp app.register_blueprint(cameras_bp) app.register_blueprint(events_bp) @@ -38,6 +39,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(wildlife_bp) # Root route → dashboard @app.route("/") diff --git a/vigilar/web/blueprints/wildlife.py b/vigilar/web/blueprints/wildlife.py new file mode 100644 index 0000000..cf0f729 --- /dev/null +++ b/vigilar/web/blueprints/wildlife.py @@ -0,0 +1,72 @@ +"""Wildlife journal blueprint — sighting log, stats, export.""" + +import csv +import io + +from flask import Blueprint, Response, current_app, jsonify, render_template, request + +wildlife_bp = Blueprint("wildlife", __name__, url_prefix="/wildlife") + + +def _engine(): + return current_app.config.get("DB_ENGINE") + + +@wildlife_bp.route("/") +def journal(): + return render_template("wildlife/journal.html") + + +@wildlife_bp.route("/api/sightings") +def sightings_api(): + engine = _engine() + if engine is None: + return jsonify({"sightings": []}) + from vigilar.storage.queries import get_wildlife_sightings_paginated + sightings = get_wildlife_sightings_paginated( + engine, + species=request.args.get("species"), + threat_level=request.args.get("threat_level"), + camera_id=request.args.get("camera_id"), + since_ts=request.args.get("since", type=float), + until_ts=request.args.get("until", type=float), + limit=min(request.args.get("limit", 50, type=int), 500), + offset=request.args.get("offset", 0, type=int), + ) + return jsonify({"sightings": sightings}) + + +@wildlife_bp.route("/api/stats") +def stats_api(): + engine = _engine() + if engine is None: + return jsonify({"total": 0, "species_count": 0, "per_species": {}}) + from vigilar.storage.queries import get_wildlife_stats + return jsonify(get_wildlife_stats(engine)) + + +@wildlife_bp.route("/api/frequency") +def frequency_api(): + engine = _engine() + if engine is None: + return jsonify({}) + from vigilar.storage.queries import get_wildlife_frequency + return jsonify(get_wildlife_frequency(engine)) + + +@wildlife_bp.route("/api/export") +def export_csv(): + engine = _engine() + if engine is None: + return Response("No data", mimetype="text/csv") + from vigilar.storage.queries import get_wildlife_sightings_paginated + sightings = get_wildlife_sightings_paginated(engine, limit=10000, offset=0) + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["id", "timestamp", "species", "threat_level", "camera_id", + "confidence", "temperature_c", "conditions"]) + for s in sightings: + writer.writerow([s["id"], s["ts"], s["species"], s["threat_level"], + s["camera_id"], s.get("confidence"), s.get("temperature_c"), s.get("conditions")]) + return Response(output.getvalue(), mimetype="text/csv", + headers={"Content-Disposition": "attachment; filename=wildlife_sightings.csv"}) diff --git a/vigilar/web/templates/wildlife/journal.html b/vigilar/web/templates/wildlife/journal.html new file mode 100644 index 0000000..30ea742 --- /dev/null +++ b/vigilar/web/templates/wildlife/journal.html @@ -0,0 +1,343 @@ +{% extends "base.html" %} +{% block title %}Wildlife Journal — Vigilar{% endblock %} + +{% block content %} +
+
Wildlife Journal
+
+ + Export CSV + +
+
+ + +
+
+
+
+
+
Total Sightings
+
+
+
+
+
+
+
+
Species Observed
+
+
+
+
+
+
+
+
Most Frequent
+
+
+
+
+
+
+
+
Predator Sightings
+
+
+
+
+ + +
+
+ Species Breakdown +
+
+
+ Loading… +
+
+
+ + +
+
+ Activity by Time of Day +
+
+
+
Loading…
+
+
+
+ + +
+
+ Sighting Log +
+ + + +
+
+
+
+ + + + + + + + + + + + + + +
TimeSpeciesThreatCameraConfidenceConditions
Loading…
+
+ +
+ +
+ + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %}