chilltrack-api / main.py
rairo's picture
Claude Sonnet 5
Add admin bulk import for historical paper-form records
df0db1f
Raw History Blame Contribute Delete
34.5 kB
"""
ChillTrack API — cold-chain temperature logging for Colcom.
A technician photographs a chiller/freezer's gauge or digital display, Gemini
extracts the reading, the technician confirms/edits it, and it's logged
against one of the 12 fixed daily time slots (see slots.py).
Firebase Admin / Gemini init patterns follow NeoFix-API/main.py and
iristrack-api/main.py. Auth decorator + audit-log shape follow
RentNow-Admin-API/app.py. PDF export follows sqx-api's in-memory BytesIO
pattern (pdf_export.py).
"""
import os
import uuid
import secrets
from datetime import datetime, timedelta
from flask import Flask, request, jsonify, send_file
from flask_cors import CORS
from firebase_admin import auth as fb_auth, firestore, storage
from auth import init_firebase, require_auth, require_role, audit_log
import gemini_client
import slots as slots_mod
import insights
import pdf_export
from seed_units import seed_units
app = Flask(__name__)
# Allow the deployed frontend plus local dev. ALLOWED_ORIGINS can hold a
# comma-separated list to add another origin (a custom domain, a preview
# deploy) without a code change.
DEFAULT_ORIGINS = "https://chilltrack-colcom.web.app,http://localhost:5173,http://localhost:5180"
ALLOWED_ORIGINS = [o.strip() for o in os.environ.get("ALLOWED_ORIGINS", DEFAULT_ORIGINS).split(",") if o.strip()]
CORS(app, origins=ALLOWED_ORIGINS)
db = init_firebase()
# Admin access is granted by Google sign-in from an allow-listed address, not by
# a password or an invite flow. Add or remove an address here to change who can
# reach the admin area; everything else about a technician account is unchanged.
ADMIN_EMAILS = {e.strip().lower() for e in os.environ.get("ADMIN_EMAILS", "").split(",") if e.strip()}
PROFILES = "ctk_profiles"
UNITS = "ctk_units"
READINGS = "ctk_readings"
DIGESTS = "ctk_digests"
SHARES = "ctk_shares"
DIGEST_MAX_AGE = timedelta(hours=24)
SHARE_TTL = timedelta(days=7)
# =============================================================================
# HELPERS
# =============================================================================
def _actor():
"""The {uid, email} pair for audit_log, from the verified token on request.user."""
user = getattr(request, "user", {}) or {}
return {"uid": user.get("uid"), "email": user.get("email")}
def _serialize(doc_snapshot):
if not doc_snapshot.exists:
return None
data = doc_snapshot.to_dict() or {}
data["id"] = doc_snapshot.id
return data
def _get_unit(unit_id):
snap = db.collection(UNITS).document(unit_id).get()
return _serialize(snap)
def _get_profile(uid):
snap = db.collection(PROFILES).document(uid).get()
return _serialize(snap)
def _is_admin_request():
return (getattr(request, "user", {}) or {}).get("role") == "admin"
def _upload_image(path, image_bytes, content_type):
"""Uploads bytes to Firebase Storage. Returns the blob path, or a placeholder
path (still correct/enabled code) if the Storage bucket isn't configured."""
try:
bucket = storage.bucket()
blob = bucket.blob(path)
blob.upload_from_string(image_bytes, content_type=content_type)
return path
except Exception as e:
# Storage bucket may not be configured yet in this environment — the upload
# call above is the real, enabled code path; this is just a safe fallback
# so capture still works end-to-end without a live bucket.
print(f"_upload_image: storage upload failed for {path}: {e}")
return path
def _move_image(old_path, new_path):
"""Moves (copies + deletes) a blob from old_path to new_path. Best-effort."""
try:
bucket = storage.bucket()
source_blob = bucket.blob(old_path)
if source_blob.exists():
bucket.copy_blob(source_blob, bucket, new_path)
source_blob.delete()
return new_path
except Exception as e:
print(f"_move_image: could not move {old_path} -> {new_path}: {e}")
return new_path
def _paginate_by_created_at(query, limit, start_after_iso):
"""Applies a created_at cursor + limit to a Firestore query."""
query = query.order_by("created_at", direction=firestore.Query.DESCENDING)
if start_after_iso:
query = query.start_after({"created_at": start_after_iso})
return query.limit(limit)
# =============================================================================
# ===== AUTH =====
# =============================================================================
@app.route("/api/auth/google-session", methods=["POST"])
@require_auth
def google_session():
"""Called right after a Google sign-in popup succeeds. Grants admin access
if the signed-in address is on ADMIN_EMAILS (or is already an admin), and
otherwise refuses. This is the only path into the admin role: there is no
password and no invite flow, only this allow-list."""
uid = request.user["uid"]
email = (request.user.get("email") or "").lower()
profile_ref = db.collection(PROFILES).document(uid)
profile_snap = profile_ref.get()
profile = profile_snap.to_dict() if profile_snap.exists else {}
already_admin = request.user.get("role") == "admin" or profile.get("role") == "admin"
if email not in ADMIN_EMAILS and not already_admin:
return jsonify({
"error": "This Google account is not set up for ChillTrack admin access. Ask an existing admin to add it."
}), 403
if not already_admin:
fb_auth.set_custom_user_claims(uid, {"role": "admin"})
profile_ref.set({
"email": email,
"display_name": profile.get("display_name") or request.user.get("name") or email,
"role": "admin",
"active": True,
"created_at": profile.get("created_at") or firestore.SERVER_TIMESTAMP,
"last_login": firestore.SERVER_TIMESTAMP,
}, merge=True)
audit_log(db, {"uid": uid, "email": email}, "auth.google_session", "profile", uid, {"first_grant": not already_admin})
return jsonify({"uid": uid, "email": email, "role": "admin"}), 200
@app.route("/api/auth/users", methods=["POST"])
@require_role("admin")
def auth_create_user():
body = request.get_json(silent=True) or {}
email = body.get("email")
display_name = body.get("display_name")
role = body.get("role", "technician")
if not email:
return jsonify({"error": "email is required."}), 400
if role == "admin":
return jsonify({
"error": "Admin access comes from Google sign-in with an address on ADMIN_EMAILS, not from this form."
}), 400
temp_password = secrets.token_urlsafe(12)
try:
user = fb_auth.create_user(email=email, password=temp_password, display_name=display_name)
fb_auth.set_custom_user_claims(user.uid, {"role": role})
db.collection(PROFILES).document(user.uid).set({
"email": email,
"display_name": display_name,
"role": role,
"active": True,
"created_at": firestore.SERVER_TIMESTAMP,
})
reset_link = fb_auth.generate_password_reset_link(email)
except Exception as e:
return jsonify({"error": str(e)}), 400
audit_log(db, _actor(), "auth.create_user", "profile", user.uid, {"email": email, "role": role})
return jsonify({"uid": user.uid, "email": email, "role": role, "password_reset_link": reset_link}), 201
@app.route("/api/auth/users/<uid>", methods=["PATCH"])
@require_role("admin")
def auth_update_user(uid):
body = request.get_json(silent=True) or {}
profile_ref = db.collection(PROFILES).document(uid)
if not profile_ref.get().exists:
return jsonify({"error": "Profile not found."}), 404
if body.get("role") == "admin":
return jsonify({
"error": "Admin access comes from Google sign-in with an address on ADMIN_EMAILS, not from this form."
}), 400
updates = {}
for field in ("role", "active", "display_name"):
if field in body:
updates[field] = body[field]
if not updates:
return jsonify({"error": "No valid fields to update."}), 400
profile_ref.update(updates)
if "role" in updates:
try:
fb_auth.set_custom_user_claims(uid, {"role": updates["role"]})
except Exception as e:
print(f"auth_update_user: failed to set custom claims for {uid}: {e}")
audit_log(db, _actor(), "auth.update_user", "profile", uid, updates)
return jsonify(_serialize(profile_ref.get())), 200
@app.route("/api/auth/me", methods=["GET"])
@require_auth
def auth_me():
user = request.user
profile = _get_profile(user["uid"]) or {}
return jsonify({
**profile,
"uid": user.get("uid"),
"email": user.get("email"),
"role": user.get("role"),
}), 200
# =============================================================================
# ===== UNITS =====
# =============================================================================
@app.route("/api/units", methods=["GET"])
@require_auth
def list_units():
include_inactive = request.args.get("include_inactive") == "1"
if include_inactive and not _is_admin_request():
return jsonify({"error": "Only admins may list inactive units."}), 403
query = db.collection(UNITS)
if not include_inactive:
query = query.where("active", "==", True)
units = [_serialize(doc) for doc in query.stream()]
return jsonify({"units": units}), 200
@app.route("/api/units", methods=["POST"])
@require_role("admin")
def create_unit():
body = request.get_json(silent=True) or {}
if not body.get("name") or body.get("setpoint_c") is None or body.get("tolerance_c") is None:
return jsonify({"error": "name, setpoint_c and tolerance_c are required."}), 400
doc = {
"name": body["name"],
"code": body.get("code", ""),
"category": body.get("category", "other"),
"setpoint_c": body["setpoint_c"],
"tolerance_c": body["tolerance_c"],
"display_type": body.get("display_type", "analog_dial"),
"active": True,
"calibration": body.get("calibration"),
"created_at": firestore.SERVER_TIMESTAMP,
}
ref = db.collection(UNITS).add(doc)[1]
audit_log(db, _actor(), "unit.create", "unit", ref.id, {"name": doc["name"]})
return jsonify(_serialize(ref.get())), 201
@app.route("/api/units/<unit_id>", methods=["PATCH"])
@require_role("admin")
def update_unit(unit_id):
ref = db.collection(UNITS).document(unit_id)
if not ref.get().exists:
return jsonify({"error": "Unit not found."}), 404
body = request.get_json(silent=True) or {}
allowed = ("name", "code", "category", "setpoint_c", "tolerance_c", "display_type", "active", "calibration")
updates = {field: body[field] for field in allowed if field in body}
if not updates:
return jsonify({"error": "No valid fields to update."}), 400
ref.update(updates)
audit_log(db, _actor(), "unit.update", "unit", unit_id, updates)
return jsonify(_serialize(ref.get())), 200
@app.route("/api/units/<unit_id>", methods=["DELETE"])
@require_role("admin")
def delete_unit(unit_id):
ref = db.collection(UNITS).document(unit_id)
if not ref.get().exists:
return jsonify({"error": "Unit not found."}), 404
hard = request.args.get("hard") == "1"
if hard:
ref.delete()
audit_log(db, _actor(), "unit.delete_hard", "unit", unit_id)
else:
ref.update({"active": False})
audit_log(db, _actor(), "unit.delete_soft", "unit", unit_id)
return jsonify({"success": True, "hard": hard}), 200
@app.route("/api/units/seed", methods=["POST"])
@require_role("admin")
def units_seed_endpoint():
force = request.args.get("force") == "1"
summary = seed_units(db, force=force)
audit_log(db, _actor(), "unit.seed", "units", "bulk", summary)
return jsonify(summary), 200
# =============================================================================
# ===== CAPTURE / READINGS =====
# =============================================================================
@app.route("/api/readings/extract", methods=["POST"])
@require_auth
def extract_reading():
if "image" not in request.files:
return jsonify({"error": "An 'image' file is required."}), 400
unit_id = request.form.get("unit_id")
if not unit_id:
return jsonify({"error": "unit_id is required."}), 400
unit = _get_unit(unit_id)
if not unit:
return jsonify({"error": "Unit not found."}), 404
image_file = request.files["image"]
image_bytes = image_file.read()
mime_type = image_file.mimetype or "image/jpeg"
try:
ai_raw = gemini_client.extract_reading(image_bytes, mime_type, unit)
except Exception as e:
return jsonify({"error": f"Extraction failed: {e}"}), 502
value_c = ai_raw.get("value_c")
setpoint_c = unit.get("setpoint_c")
tolerance_c = unit.get("tolerance_c")
within_tolerance = (
abs(value_c - setpoint_c) <= tolerance_c
if isinstance(value_c, (int, float)) and setpoint_c is not None and tolerance_c is not None
else None
)
# Sudden-jump check against the unit's most recent reading (if any).
sudden_jump = False
last_readings = list(
db.collection(READINGS)
.where("unit_id", "==", unit_id)
.order_by("created_at", direction=firestore.Query.DESCENDING)
.limit(1)
.stream()
)
if last_readings and isinstance(value_c, (int, float)) and tolerance_c is not None:
last_value = last_readings[0].to_dict().get("value_c")
if isinstance(last_value, (int, float)):
sudden_jump = abs(value_c - last_value) > (tolerance_c * 2)
anomaly_flags = []
if isinstance(ai_raw.get("confidence"), (int, float)) and ai_raw["confidence"] < 0.6:
anomaly_flags.append("low_confidence")
if within_tolerance is False:
anomaly_flags.append("out_of_tolerance")
if sudden_jump:
anomaly_flags.append("sudden_jump")
uid = request.user["uid"]
image_path = f"pending/{uid}/{uuid.uuid4()}.jpg"
image_ref = _upload_image(image_path, image_bytes, mime_type)
proposed_slot = slots_mod.resolve_slot(datetime.utcnow())
proposed_slot = {**proposed_slot, "expected_at": proposed_slot["expected_at"].isoformat()}
return jsonify({
"unit_id": unit_id,
"ai_raw": ai_raw,
"within_tolerance": within_tolerance,
"anomaly_flags": anomaly_flags,
"proposed_slot": proposed_slot,
"image_ref": image_ref,
}), 200
@app.route("/api/readings/confirm", methods=["POST"])
@require_auth
def confirm_reading():
body = request.get_json(silent=True) or {}
required = ("unit_id", "value_c", "ai_raw", "image_ref", "shift_date", "slot_index", "slot_label", "status")
missing = [f for f in required if f not in body]
if missing:
return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400
unit = _get_unit(body["unit_id"])
if not unit:
return jsonify({"error": "Unit not found."}), 404
uid = request.user["uid"]
profile = _get_profile(uid) or {}
value_c = body["value_c"]
ai_raw = body["ai_raw"] or {}
ai_value = ai_raw.get("value_c")
user_edited = (
abs(value_c - ai_value) > 0.05
if isinstance(value_c, (int, float)) and isinstance(ai_value, (int, float))
else True
)
edit_delta = (
round(value_c - ai_value, 3)
if isinstance(value_c, (int, float)) and isinstance(ai_value, (int, float))
else None
)
setpoint_c = unit.get("setpoint_c")
tolerance_c = unit.get("tolerance_c")
within_tolerance = (
abs(value_c - setpoint_c) <= tolerance_c
if isinstance(value_c, (int, float)) and setpoint_c is not None and tolerance_c is not None
else None
)
# Conceptually promote the image from pending/ to confirmed/. Actually moves the
# blob when a real Storage bucket is configured; otherwise _move_image is a no-op
# that still returns a sensible path.
old_ref = body["image_ref"]
new_ref = old_ref.replace("pending/", "confirmed/", 1) if old_ref.startswith("pending/") else old_ref
if new_ref != old_ref:
new_ref = _move_image(old_ref, new_ref)
doc = {
"unit_id": body["unit_id"],
"unit_name": unit.get("name"),
"user_id": uid,
"user_display_name": profile.get("display_name") or request.user.get("email"),
"value_c": value_c,
"ai_raw": ai_raw,
"user_edited": user_edited,
"edit_delta": edit_delta,
"within_tolerance": within_tolerance,
"image_ref": new_ref,
"shift_date": body["shift_date"],
"slot_index": body["slot_index"],
"slot_label": body["slot_label"],
"status": body["status"],
"source": "capture",
"created_at": firestore.SERVER_TIMESTAMP,
}
ref = db.collection(READINGS).add(doc)[1]
audit_log(db, _actor(), "reading.confirm", "reading", ref.id, {
"unit_id": body["unit_id"], "value_c": value_c, "user_edited": user_edited,
})
return jsonify(_serialize(ref.get())), 201
@app.route("/api/readings/mine", methods=["GET"])
@require_auth
def my_readings():
uid = request.user["uid"]
query = db.collection(READINGS).where("user_id", "==", uid)
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
unit_id = request.args.get("unit_id")
if start_date:
query = query.where("shift_date", ">=", start_date)
if end_date:
query = query.where("shift_date", "<=", end_date)
if unit_id:
query = query.where("unit_id", "==", unit_id)
limit = int(request.args.get("limit", 50))
start_after = request.args.get("start_after")
query = _paginate_by_created_at(query, limit, start_after)
readings = [_serialize(doc) for doc in query.stream()]
return jsonify({"readings": readings, "limit": limit}), 200
@app.route("/api/readings/pending-slots", methods=["GET"])
@require_auth
def pending_slots():
now = datetime.utcnow()
current = slots_mod.resolve_slot(now)
units = [_serialize(doc) for doc in db.collection(UNITS).where("active", "==", True).stream()]
existing = {
doc.to_dict().get("unit_id")
for doc in db.collection(READINGS)
.where("shift_date", "==", current["shift_date"])
.where("slot_index", "==", current["slot_index"])
.stream()
}
due = []
for unit in units:
if unit["id"] in existing:
continue
minutes_from_expected = (now - current["expected_at"]).total_seconds() / 60.0
if abs(minutes_from_expected) <= 30:
due_status = "current"
elif minutes_from_expected < 0:
due_status = "due_soon"
else:
due_status = "overdue"
due.append({
"unit_id": unit["id"],
"unit_name": unit.get("name"),
"shift_date": current["shift_date"],
"slot_index": current["slot_index"],
"slot_label": current["slot_label"],
"due_status": due_status,
})
return jsonify({"slot": {**current, "expected_at": current["expected_at"].isoformat()}, "units": due}), 200
@app.route("/api/readings/<reading_id>", methods=["GET"])
@require_auth
def get_reading(reading_id):
ref = db.collection(READINGS).document(reading_id)
snap = ref.get()
if not snap.exists:
return jsonify({"error": "Reading not found."}), 404
reading = _serialize(snap)
if reading.get("user_id") != request.user["uid"] and not _is_admin_request():
return jsonify({"error": "Access denied."}), 403
return jsonify(reading), 200
# =============================================================================
# ===== ADMIN: USERS & ACTIVITY =====
# =============================================================================
@app.route("/api/admin/readings", methods=["GET"])
@require_role("admin")
def admin_readings():
query = db.collection(READINGS)
user_id = request.args.get("user_id")
unit_id = request.args.get("unit_id")
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
status = request.args.get("status")
low_confidence = request.args.get("low_confidence") == "1"
if user_id:
query = query.where("user_id", "==", user_id)
if unit_id:
query = query.where("unit_id", "==", unit_id)
if start_date:
query = query.where("shift_date", ">=", start_date)
if end_date:
query = query.where("shift_date", "<=", end_date)
if status:
query = query.where("status", "==", status)
readings = [_serialize(doc) for doc in query.stream()]
if low_confidence:
readings = [r for r in readings if (r.get("ai_raw") or {}).get("confidence", 1) < 0.6]
return jsonify({"readings": readings}), 200
@app.route("/api/admin/users", methods=["GET"])
@require_role("admin")
def admin_users():
profiles = [_serialize(doc) for doc in db.collection(PROFILES).stream()]
counts = {}
for doc in db.collection(READINGS).stream():
uid = doc.to_dict().get("user_id")
if uid:
counts[uid] = counts.get(uid, 0) + 1
for profile in profiles:
profile["reading_count"] = counts.get(profile["id"], 0)
return jsonify({"users": profiles}), 200
@app.route("/api/admin/activity", methods=["GET"])
@require_role("admin")
def admin_activity():
query = db.collection("ctk_audit")
actor_email = request.args.get("actor")
action = request.args.get("action")
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
if action:
query = query.where("action", "==", action)
limit = int(request.args.get("limit", 50))
start_after = request.args.get("start_after")
query = query.order_by("ts", direction=firestore.Query.DESCENDING)
if start_after:
query = query.start_after({"ts": start_after})
query = query.limit(limit)
entries = [_serialize(doc) for doc in query.stream()]
if actor_email:
entries = [e for e in entries if (e.get("actor") or {}).get("email") == actor_email]
if start_date:
entries = [e for e in entries if str(e.get("ts", "")) >= start_date]
if end_date:
entries = [e for e in entries if str(e.get("ts", "")) <= end_date]
return jsonify({"activity": entries}), 200
# =============================================================================
# ===== ADMIN: BULK IMPORT =====
# =============================================================================
# Backfills historical readings from a photographed paper form: an admin
# uploads a photo (gallery access is fine here, unlike the technician capture
# flow), Gemini transcribes every legible cell it can find, the admin reviews
# and corrects the draft rows in the UI, then commits the finalized set. Every
# row created this way is tagged source="bulk_import" / status="backfilled" so
# it's clearly distinguishable from a live capture in insights and the audit
# trail, while still counting toward historical compliance reporting.
@app.route("/api/admin/import/extract", methods=["POST"])
@require_role("admin")
def import_extract():
if "image" not in request.files:
return jsonify({"error": "An 'image' file is required."}), 400
image_file = request.files["image"]
image_bytes = image_file.read()
mime_type = image_file.mimetype or "image/jpeg"
units = [_serialize(doc) for doc in db.collection(UNITS).where("active", "==", True).stream()]
unit_names = [u["name"] for u in units]
name_to_id = {u["name"]: u["id"] for u in units}
try:
extraction = gemini_client.extract_form(image_bytes, mime_type, unit_names)
except Exception as e:
return jsonify({"error": f"Extraction failed: {e}"}), 502
rows = []
for row in extraction.get("rows", []):
matched_name = row.get("matched_unit_name")
slot_index = row.get("slot_index")
valid_slot = isinstance(slot_index, int) and 0 <= slot_index < len(slots_mod.SLOT_TIMES)
rows.append({
"unit_name_seen": row.get("unit_name_seen"),
"matched_unit_id": name_to_id.get(matched_name) if matched_name else None,
"matched_unit_name": matched_name,
"slot_index": slot_index if valid_slot else None,
"slot_label": slots_mod.SLOT_TIMES[slot_index] if valid_slot else None,
"value_c": row.get("value_c"),
"confidence": row.get("confidence"),
"raw_text_seen": row.get("raw_text_seen"),
})
uid = request.user["uid"]
image_path = f"imports/{uid}/{uuid.uuid4()}.jpg"
image_ref = _upload_image(image_path, image_bytes, mime_type)
return jsonify({
"shift_date_seen": extraction.get("shift_date_seen"),
"rows": rows,
"units": units,
"image_ref": image_ref,
}), 200
@app.route("/api/admin/import/commit", methods=["POST"])
@require_role("admin")
def import_commit():
body = request.get_json(silent=True) or {}
shift_date = body.get("shift_date")
rows = body.get("rows")
image_ref = body.get("image_ref")
if not shift_date or not isinstance(rows, list) or not rows:
return jsonify({"error": "shift_date and at least one row are required."}), 400
uid = request.user["uid"]
profile = _get_profile(uid) or {}
admin_actor = {"uid": uid, "email": request.user.get("email")}
created = []
skipped = 0
for row in rows:
unit_id = row.get("unit_id")
value_c = row.get("value_c")
slot_index = row.get("slot_index")
if not unit_id or not isinstance(value_c, (int, float)) or not isinstance(slot_index, int):
skipped += 1
continue
unit = _get_unit(unit_id)
if not unit or not (0 <= slot_index < len(slots_mod.SLOT_TIMES)):
skipped += 1
continue
setpoint_c = unit.get("setpoint_c")
tolerance_c = unit.get("tolerance_c")
within_tolerance = (
abs(value_c - setpoint_c) <= tolerance_c
if setpoint_c is not None and tolerance_c is not None
else None
)
doc = {
"unit_id": unit_id,
"unit_name": unit.get("name"),
"user_id": uid,
"user_display_name": profile.get("display_name") or request.user.get("email"),
"value_c": value_c,
"ai_raw": None,
"user_edited": True,
"edit_delta": None,
"within_tolerance": within_tolerance,
"image_ref": image_ref,
"shift_date": shift_date,
"slot_index": slot_index,
"slot_label": slots_mod.SLOT_TIMES[slot_index],
"status": "backfilled",
"source": "bulk_import",
"imported_by": admin_actor,
"original_recorder_name": row.get("original_recorder_name") or None,
"created_at": firestore.SERVER_TIMESTAMP,
}
ref = db.collection(READINGS).add(doc)[1]
created.append(ref.id)
audit_log(db, admin_actor, "reading.bulk_import", "reading", "bulk", {
"shift_date": shift_date, "created": len(created), "skipped": skipped,
})
return jsonify({"created": len(created), "skipped": skipped, "reading_ids": created}), 201
# =============================================================================
# ===== ADMIN: INSIGHTS =====
# =============================================================================
@app.route("/api/admin/insights/compliance", methods=["GET"])
@require_role("admin")
def insights_compliance():
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
if not start_date or not end_date:
return jsonify({"error": "start_date and end_date are required."}), 400
result = insights.compute_compliance(
db, start_date, end_date,
unit_id=request.args.get("unit_id"),
user_id=request.args.get("user_id"),
)
return jsonify(result), 200
@app.route("/api/admin/insights/anomalies", methods=["GET"])
@require_role("admin")
def insights_anomalies():
lookback_days = int(request.args.get("lookback_days", 7))
result = insights.compute_anomalies(db, lookback_days=lookback_days)
return jsonify(result), 200
@app.route("/api/admin/insights/calibration", methods=["GET"])
@require_role("admin")
def insights_calibration():
result = insights.compute_calibration_flags(db)
return jsonify(result), 200
@app.route("/api/admin/insights/performance", methods=["GET"])
@require_role("admin")
def insights_performance():
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
if not start_date or not end_date:
return jsonify({"error": "start_date and end_date are required."}), 400
result = insights.compute_performance(db, start_date, end_date)
return jsonify(result), 200
@app.route("/api/admin/insights/rework", methods=["GET"])
@require_role("admin")
def insights_rework():
threshold = float(request.args.get("confidence_threshold", 0.6))
result = insights.compute_rework(db, confidence_threshold=threshold)
return jsonify(result), 200
@app.route("/api/admin/insights/digest", methods=["GET"])
@require_role("admin")
def insights_digest():
today = datetime.utcnow().strftime("%Y-%m-%d")
digest_ref = db.collection(DIGESTS).document(today)
cached = digest_ref.get()
if cached.exists:
data = cached.to_dict()
generated_at = data.get("generated_at")
if generated_at:
try:
age = datetime.utcnow() - datetime.fromisoformat(str(generated_at).replace("Z", ""))
except Exception:
age = DIGEST_MAX_AGE + timedelta(seconds=1)
else:
age = DIGEST_MAX_AGE + timedelta(seconds=1)
if age <= DIGEST_MAX_AGE:
return jsonify(data), 200
week_ago = (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d")
stats = {
"compliance": insights.compute_compliance(db, week_ago, today),
"anomalies": insights.compute_anomalies(db, lookback_days=7),
"calibration": insights.compute_calibration_flags(db),
"performance": insights.compute_performance(db, week_ago, today),
}
try:
narrative = insights.generate_digest_narrative(db, stats)
except Exception as e:
narrative = f"Digest narrative unavailable: {e}"
payload = {
"date": today,
"stats": stats,
"narrative": narrative,
"generated_at": datetime.utcnow().isoformat(),
}
digest_ref.set(payload)
return jsonify(payload), 200
# =============================================================================
# ===== EXPORT =====
# =============================================================================
def _units_and_readings_for_export(shift_date, scope, uid):
units = [_serialize(doc) for doc in db.collection(UNITS).where("active", "==", True).stream()]
query = db.collection(READINGS).where("shift_date", "==", shift_date)
if scope == "mine":
query = query.where("user_id", "==", uid)
readings = [_serialize(doc) for doc in query.stream()]
return units, readings
@app.route("/api/export/pdf", methods=["GET"])
@require_auth
def export_pdf():
shift_date = request.args.get("shift_date")
scope = request.args.get("scope", "mine")
if not shift_date:
return jsonify({"error": "shift_date is required."}), 400
if scope == "all" and not _is_admin_request():
return jsonify({"error": "Only admins may export scope=all."}), 403
units, readings = _units_and_readings_for_export(shift_date, scope, request.user["uid"])
buffer = pdf_export.build_room_temperature_pdf(units, readings, shift_date)
return send_file(
buffer,
mimetype="application/pdf",
as_attachment=request.args.get("download") == "1",
download_name=f"room-temperature-{shift_date}.pdf",
)
@app.route("/api/export/share", methods=["POST"])
@require_auth
def export_share():
body = request.get_json(silent=True) or {}
shift_date = body.get("shift_date")
scope = body.get("scope", "mine")
if not shift_date:
return jsonify({"error": "shift_date is required."}), 400
if not _is_admin_request():
scope = "mine"
token = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + SHARE_TTL
db.collection(SHARES).document(token).set({
"shift_date": shift_date,
"scope": scope,
"user_id": request.user["uid"],
"expires_at": expires_at.isoformat(),
"created_at": firestore.SERVER_TIMESTAMP,
})
audit_log(db, _actor(), "export.share", "share", token, {"shift_date": shift_date, "scope": scope})
return jsonify({"share_url": f"/api/export/share/{token}"}), 201
@app.route("/api/export/share/<token>", methods=["GET"])
def export_share_view(token):
# Intentionally NOT behind @require_auth — this is a public, unguessable-token link.
share_ref = db.collection(SHARES).document(token)
snap = share_ref.get()
if not snap.exists:
return jsonify({"error": "Share link not found."}), 404
share = snap.to_dict()
expires_at = share.get("expires_at")
if expires_at and datetime.utcnow() > datetime.fromisoformat(str(expires_at)):
return jsonify({"error": "Share link has expired."}), 404
units, readings = _units_and_readings_for_export(share["shift_date"], share["scope"], share.get("user_id"))
buffer = pdf_export.build_room_temperature_pdf(units, readings, share["shift_date"])
return send_file(
buffer,
mimetype="application/pdf",
as_attachment=False,
download_name=f"room-temperature-{share['shift_date']}.pdf",
)
# =============================================================================
# ROOT
# =============================================================================
@app.route("/", methods=["GET"])
def health():
return jsonify({"status": "ok", "service": "chilltrack-api"}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))