Fix web blueprint: security, stubs, dashboard context, upload validation

- Pass pets list to dashboard template (Critical 3)
- Sanitize upload filenames with werkzeug secure_filename (Important 6)
- Validate image extension against allowlist (Important 7)
- 404 check for pet existence in upload and update endpoints (Important 8)
- Save uploads to training/{pet_name}/ not training/{pet_id}/ (Important 11)
- Wire /train to PetTrainer with background thread (Important 12)
- Wire /api/training-status to stored trainer status (Important 13)
- Implement /api/highlights from sightings + wildlife queries (Important 14)
- Cap limit param at 500 on sightings and wildlife endpoints (Important 15)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee 2026-04-03 13:48:38 -04:00
parent 6771923585
commit 9858738e82

View File

@ -1,12 +1,16 @@
"""Pets blueprint — pet registration, sightings, labeling, training."""
import os
import time
from pathlib import Path
from flask import Blueprint, current_app, jsonify, render_template, request
from werkzeug.utils import secure_filename
pets_bp = Blueprint("pets", __name__, url_prefix="/pets")
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
def _engine():
return current_app.config.get("DB_ENGINE")
@ -14,7 +18,12 @@ def _engine():
@pets_bp.route("/")
def dashboard():
return render_template("pets/dashboard.html")
engine = _engine()
all_pets = []
if engine:
from vigilar.storage.queries import get_all_pets
all_pets = get_all_pets(engine)
return render_template("pets/dashboard.html", pets=all_pets)
@pets_bp.route("/register", methods=["POST"])
@ -69,7 +78,7 @@ def pet_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)
limit = min(request.args.get("limit", 100, type=int), 500)
from vigilar.storage.queries import get_pet_sightings
sightings = get_pet_sightings(engine, pet_id=pet_id, camera_id=camera_id,
@ -86,7 +95,7 @@ def wildlife_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)
limit = min(request.args.get("limit", 100, type=int), 500)
from vigilar.storage.queries import get_wildlife_sightings
sightings = get_wildlife_sightings(engine, threat_level=threat_level, camera_id=camera_id,
@ -133,20 +142,35 @@ def upload_photo(pet_id: str):
if engine is None:
return jsonify({"error": "database not available"}), 503
from vigilar.storage.queries import get_pet, insert_training_image
pet = get_pet(engine, pet_id)
if not pet:
return jsonify({"error": "Pet not found"}), 404
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)
pets_cfg = current_app.config.get("PETS_CONFIG")
if pets_cfg:
training_dir = pets_cfg.training_dir
else:
training_dir = os.path.join(cfg.system.data_dir if cfg else "/tmp/vigilar", "training")
pet_dir = Path(training_dir) / pet["name"].lower()
pet_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}"
safe_name = secure_filename(f.filename)
if not safe_name:
continue
if Path(safe_name).suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS:
continue
dest = pet_dir / f"{int(time.time() * 1000)}_{safe_name}"
f.save(str(dest))
insert_training_image(engine, pet_id=pet_id, image_path=str(dest), source="upload")
saved.append(str(dest))
@ -160,6 +184,11 @@ def update_pet(pet_id: str):
if engine is None:
return jsonify({"error": "database not available"}), 503
from vigilar.storage.queries import get_pet
pet = get_pet(engine, pet_id)
if not pet:
return jsonify({"error": "Pet not found"}), 404
data = request.get_json(silent=True) or {}
from sqlalchemy import update as sa_update
@ -188,18 +217,85 @@ def delete_pet(pet_id: str):
@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"})
def train_model():
engine = _engine()
pets_cfg = current_app.config.get("PETS_CONFIG")
if not pets_cfg:
return jsonify({"error": "Pet detection not configured"}), 400
from vigilar.detection.trainer import PetTrainer
trainer = PetTrainer(
training_dir=pets_cfg.training_dir,
model_output_path=pets_cfg.pet_id_model_path,
)
ready, msg = trainer.check_readiness(min_images=pets_cfg.min_training_images)
if not ready:
return jsonify({"error": msg}), 400
# Store trainer on app for status polling
current_app.extensions["pet_trainer"] = trainer
import threading
thread = threading.Thread(target=trainer.train, daemon=True)
thread.start()
return jsonify({"ok": True, "status": "training_started", "message": msg})
@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": ""})
trainer = current_app.extensions.get("pet_trainer")
if not trainer:
return jsonify({"status": "idle"})
s = trainer.status
return jsonify({
"status": "training" if s.is_training else "complete" if s.progress >= 1.0 else "idle",
"progress": round(s.progress, 2),
"epoch": s.epoch,
"total_epochs": s.total_epochs,
"accuracy": round(s.accuracy, 4),
"error": s.error,
})
@pets_bp.route("/api/highlights")
def highlights():
# TODO: query today's highlight clips from recordings
return jsonify({"highlights": []})
engine = _engine()
if not engine:
return jsonify({"highlights": []})
import time as time_mod
since = time_mod.time() - 86400
from vigilar.storage.queries import get_pet_sightings, get_wildlife_sightings
result = []
# High-confidence pet sightings
sightings = get_pet_sightings(engine, since_ts=since, limit=50)
for s in sightings:
if s.get("confidence", 0) > 0.8 and s.get("pet_id"):
result.append({
"type": "pet_sighting",
"pet_id": s.get("pet_id"),
"camera_id": s["camera_id"],
"timestamp": s["ts"],
"confidence": s.get("confidence"),
})
# Wildlife events
wildlife = get_wildlife_sightings(engine, since_ts=since, limit=20)
for w in wildlife:
result.append({
"type": "wildlife",
"species": w["species"],
"threat_level": w["threat_level"],
"camera_id": w["camera_id"],
"timestamp": w["ts"],
"confidence": w.get("confidence"),
})
# Sort by timestamp descending, limit to 20
result.sort(key=lambda x: x["timestamp"], reverse=True)
return jsonify({"highlights": result[:20]})