feat(reports): FTP night-report pipeline foundation #62
@@ -200,11 +200,17 @@ async def view_nightly_report(
|
|||||||
):
|
):
|
||||||
"""Render the night report and return the HTML inline (preview — no write, no email)."""
|
"""Render the night report and return the HTML inline (preview — no write, no email)."""
|
||||||
nd, bmode, bs, be, metric_keys = _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics)
|
nd, bmode, bs, be, metric_keys = _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics)
|
||||||
|
try:
|
||||||
result = run_nightly_report(
|
result = run_nightly_report(
|
||||||
db, project_id, nd,
|
db, project_id, nd,
|
||||||
metric_keys=metric_keys, baseline_mode=bmode, baseline_start=bs, baseline_end=be,
|
metric_keys=metric_keys, baseline_mode=bmode, baseline_start=bs, baseline_end=be,
|
||||||
send=False, # preview: no email
|
send=False, # preview: no email
|
||||||
)
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.error("nightly/view failed for %s (%s): %s", project_id, nd, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Report generation failed: {e}")
|
||||||
return HTMLResponse(result["html"])
|
return HTMLResponse(result["html"])
|
||||||
|
|
||||||
|
|
||||||
@@ -224,11 +230,17 @@ async def run_nightly_report_endpoint(
|
|||||||
is omitted from the JSON response (it's large and on disk); use /view to see it.
|
is omitted from the JSON response (it's large and on disk); use /view to see it.
|
||||||
"""
|
"""
|
||||||
nd, bmode, bs, be, metric_keys = _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics)
|
nd, bmode, bs, be, metric_keys = _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics)
|
||||||
|
try:
|
||||||
result = run_nightly_report(
|
result = run_nightly_report(
|
||||||
db, project_id, nd,
|
db, project_id, nd,
|
||||||
metric_keys=metric_keys, baseline_mode=bmode, baseline_start=bs, baseline_end=be,
|
metric_keys=metric_keys, baseline_mode=bmode, baseline_start=bs, baseline_end=be,
|
||||||
send=send,
|
send=send,
|
||||||
)
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.error("nightly/run failed for %s (%s): %s", project_id, nd, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Report generation failed: {e}")
|
||||||
result.pop("html", None) # keep the JSON response lean — view it via /view or the file
|
result.pop("html", None) # keep the JSON response lean — view it via /view or the file
|
||||||
result["view_url"] = (
|
result["view_url"] = (
|
||||||
f"/api/projects/{project_id}/reports/nightly/view"
|
f"/api/projects/{project_id}/reports/nightly/view"
|
||||||
|
|||||||
@@ -136,8 +136,14 @@ def send_report_email(
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# Validate the security mode: an unrecognized value (typo) must NOT silently
|
||||||
|
# fall through to a plaintext connection while still sending credentials.
|
||||||
|
sec = cfg.security if cfg.security in ("ssl", "starttls", "none") else "starttls"
|
||||||
|
if sec != cfg.security:
|
||||||
|
logger.warning("Unknown REPORT_SMTP_SECURITY=%r — falling back to 'starttls'", cfg.security)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if cfg.security == "ssl":
|
if sec == "ssl":
|
||||||
ctx = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
with smtplib.SMTP_SSL(cfg.host, cfg.port, timeout=cfg.timeout, context=ctx) as s:
|
with smtplib.SMTP_SSL(cfg.host, cfg.port, timeout=cfg.timeout, context=ctx) as s:
|
||||||
if cfg.user:
|
if cfg.user:
|
||||||
@@ -146,10 +152,15 @@ def send_report_email(
|
|||||||
else:
|
else:
|
||||||
with smtplib.SMTP(cfg.host, cfg.port, timeout=cfg.timeout) as s:
|
with smtplib.SMTP(cfg.host, cfg.port, timeout=cfg.timeout) as s:
|
||||||
s.ehlo()
|
s.ehlo()
|
||||||
if cfg.security == "starttls":
|
if sec == "starttls":
|
||||||
s.starttls(context=ssl.create_default_context())
|
s.starttls(context=ssl.create_default_context())
|
||||||
s.ehlo()
|
s.ehlo()
|
||||||
if cfg.user:
|
if cfg.user:
|
||||||
|
if sec == "none":
|
||||||
|
logger.warning(
|
||||||
|
"Sending SMTP credentials over an UNENCRYPTED connection "
|
||||||
|
"(REPORT_SMTP_SECURITY=none) — set starttls/ssl if the relay supports it."
|
||||||
|
)
|
||||||
s.login(cfg.user, cfg.password)
|
s.login(cfg.user, cfg.password)
|
||||||
s.send_message(msg)
|
s.send_message(msg)
|
||||||
result["sent"] = True
|
result["sent"] = True
|
||||||
|
|||||||
@@ -120,9 +120,13 @@ def run_nightly_report(
|
|||||||
# --- Email (best-effort; dry-run until SMTP is configured) ---
|
# --- Email (best-effort; dry-run until SMTP is configured) ---
|
||||||
email_result = {"sent": False, "dry_run": False, "skipped": True, "error": None}
|
email_result = {"sent": False, "dry_run": False, "skipped": True, "error": None}
|
||||||
if send:
|
if send:
|
||||||
|
try:
|
||||||
email_result = send_report_email(
|
email_result = send_report_email(
|
||||||
subject, html, attachments=attachments, recipients=recipients,
|
subject, html, attachments=attachments, recipients=recipients,
|
||||||
)
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 — artifacts are already written; never abort on email
|
||||||
|
logger.error("send_report_email raised for %s (%s): %s", project_id, night_date, e, exc_info=True)
|
||||||
|
email_result = {"sent": False, "dry_run": False, "skipped": False, "error": str(e)}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
|
|||||||
@@ -653,11 +653,22 @@ class SchedulerService:
|
|||||||
ing = await self._ingest_cycle_folder(db, action.location_id, unit_id, folder_name)
|
ing = await self._ingest_cycle_folder(db, action.location_id, unit_id, folder_name)
|
||||||
result["steps"]["ingest"] = ing
|
result["steps"]["ingest"] = ing
|
||||||
db.commit()
|
db.commit()
|
||||||
# The just-closed "recording" session was only a marker — if the
|
if ing.get("success"):
|
||||||
# ingest created the real data session, drop the empty placeholder.
|
|
||||||
if ing.get("success") and active_session:
|
|
||||||
from backend.models import DataFile
|
from backend.models import DataFile
|
||||||
if db.query(DataFile).filter_by(session_id=active_session.id).count() == 0:
|
sid = ing.get("session_id")
|
||||||
|
# ingest_nrl_zip leaves unit_id None — tie the data session to the
|
||||||
|
# unit that recorded it so it stays linked after we drop the placeholder.
|
||||||
|
if sid:
|
||||||
|
s = db.query(MonitoringSession).filter_by(id=sid).first()
|
||||||
|
if s and not s.unit_id:
|
||||||
|
s.unit_id = unit_id
|
||||||
|
db.commit()
|
||||||
|
# The just-closed "recording" session was only a marker; its data now
|
||||||
|
# lives in the ingested (unit-linked) session. Drop the empty placeholder
|
||||||
|
# and repoint old_session_id at the real row.
|
||||||
|
if active_session and db.query(DataFile).filter_by(session_id=active_session.id).count() == 0:
|
||||||
|
if sid:
|
||||||
|
result["old_session_id"] = sid
|
||||||
db.delete(active_session)
|
db.delete(active_session)
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"[CYCLE] Ingested {folder_name}: {ing}")
|
logger.info(f"[CYCLE] Ingested {folder_name}: {ing}")
|
||||||
@@ -885,51 +896,76 @@ class SchedulerService:
|
|||||||
last_run_date. Idempotent across restarts via last_run_date.
|
last_run_date. Idempotent across restarts via last_run_date.
|
||||||
"""
|
"""
|
||||||
from backend.models import SoundReportConfig
|
from backend.models import SoundReportConfig
|
||||||
from backend.services.report_orchestrator import run_nightly_report
|
|
||||||
from backend.utils.timezone import utc_to_local
|
from backend.utils.timezone import utc_to_local
|
||||||
|
|
||||||
|
# Decide what's due (cheap, on the loop); run each OFF the event loop.
|
||||||
|
due_jobs = []
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
configs = db.query(SoundReportConfig).filter_by(enabled=True).all()
|
configs = db.query(SoundReportConfig).filter_by(enabled=True).all()
|
||||||
if not configs:
|
if not configs:
|
||||||
return
|
return
|
||||||
|
|
||||||
local_now = utc_to_local(datetime.utcnow())
|
local_now = utc_to_local(datetime.utcnow())
|
||||||
night_date = local_now.date() - timedelta(days=1) # last night's evening date
|
night_date = local_now.date() - timedelta(days=1) # last night's evening date
|
||||||
|
|
||||||
for cfg in configs:
|
for cfg in configs:
|
||||||
try:
|
|
||||||
# Past the configured local report time (HH:MM)?
|
|
||||||
try:
|
try:
|
||||||
hh, mm = (int(x) for x in cfg.report_time.split(":"))
|
hh, mm = (int(x) for x in cfg.report_time.split(":"))
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
hh, mm = 8, 0
|
hh, mm = 8, 0
|
||||||
due = (local_now.hour, local_now.minute) >= (hh, mm)
|
if (local_now.hour, local_now.minute) < (hh, mm):
|
||||||
if not due or cfg.last_run_date == night_date:
|
|
||||||
continue
|
continue
|
||||||
|
if cfg.last_run_date == night_date:
|
||||||
|
continue
|
||||||
|
due_jobs.append({
|
||||||
|
"project_id": cfg.project_id,
|
||||||
|
"metric_keys": [m.strip() for m in (cfg.metric_keys or "").split(",") if m.strip()] or None,
|
||||||
|
"recipients": [r.strip() for r in (cfg.recipients or "").split(",") if r.strip()] or None,
|
||||||
|
"baseline_mode": cfg.baseline_mode,
|
||||||
|
"baseline_start": cfg.baseline_start,
|
||||||
|
"baseline_end": cfg.baseline_end,
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
metric_keys = [m.strip() for m in (cfg.metric_keys or "").split(",") if m.strip()] or None
|
# run_nightly_report is synchronous (blocking file I/O + smtplib up to the
|
||||||
recipients = [r.strip() for r in (cfg.recipients or "").split(",") if r.strip()] or None
|
# SMTP timeout). Run it in a worker thread so it never stalls the scheduler
|
||||||
|
# loop (which also drives time-sensitive device cycles).
|
||||||
logger.info(f"[REPORT] Running nightly report for project {cfg.project_id} (night {night_date})")
|
for job in due_jobs:
|
||||||
result = run_nightly_report(
|
try:
|
||||||
db, cfg.project_id, night_date,
|
logger.info(f"[REPORT] Running nightly report for project {job['project_id']} (night {night_date})")
|
||||||
metric_keys=metric_keys,
|
result = await asyncio.to_thread(self._run_one_report, night_date, job)
|
||||||
baseline_mode=cfg.baseline_mode,
|
email = (result or {}).get("email", {})
|
||||||
baseline_start=cfg.baseline_start,
|
|
||||||
baseline_end=cfg.baseline_end,
|
|
||||||
recipients=recipients,
|
|
||||||
)
|
|
||||||
cfg.last_run_date = night_date
|
|
||||||
db.commit()
|
|
||||||
email = result.get("email", {})
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[REPORT] project {cfg.project_id}: {result.get('location_count')} location(s); "
|
f"[REPORT] project {job['project_id']}: {(result or {}).get('location_count')} location(s); "
|
||||||
f"email={'sent' if email.get('sent') else ('dry-run' if email.get('dry_run') else (email.get('error') or 'skipped'))}"
|
f"email={'sent' if email.get('sent') else ('dry-run' if email.get('dry_run') else (email.get('error') or 'skipped'))}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[REPORT] Failed nightly report for project {cfg.project_id}: {e}", exc_info=True)
|
logger.error(f"[REPORT] Failed nightly report for project {job['project_id']}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _run_one_report(self, night_date, job) -> Dict[str, Any]:
|
||||||
|
"""Sync worker: build/send one project's report and stamp last_run_date.
|
||||||
|
Uses its own DB session (runs in a thread, off the event loop)."""
|
||||||
|
from backend.models import SoundReportConfig
|
||||||
|
from backend.services.report_orchestrator import run_nightly_report
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
result = run_nightly_report(
|
||||||
|
db, job["project_id"], night_date,
|
||||||
|
metric_keys=job["metric_keys"],
|
||||||
|
baseline_mode=job["baseline_mode"],
|
||||||
|
baseline_start=job["baseline_start"],
|
||||||
|
baseline_end=job["baseline_end"],
|
||||||
|
recipients=job["recipients"],
|
||||||
|
)
|
||||||
|
cfg = db.query(SoundReportConfig).filter_by(project_id=job["project_id"]).first()
|
||||||
|
if cfg:
|
||||||
|
cfg.last_run_date = night_date
|
||||||
|
db.commit()
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ function runNightReport(projectId) {
|
|||||||
var em = res.j.email || {};
|
var em = res.j.email || {};
|
||||||
var emailMsg = em.sent ? 'emailed' : (em.dry_run ? 'email dry-run (SMTP not set)' : (em.error || 'email skipped'));
|
var emailMsg = em.sent ? 'emailed' : (em.dry_run ? 'email dry-run (SMTP not set)' : (em.error || 'email skipped'));
|
||||||
st.style.color = '#1a7f37';
|
st.style.color = '#1a7f37';
|
||||||
st.innerHTML = 'Done — saved & ' + emailMsg + '. <a href="' + res.j.view_url + '" target="_blank" class="underline">view</a>';
|
st.innerHTML = 'Done — saved & ' + _mergeEsc(emailMsg) + '. <a href="' + _mergeEsc(res.j.view_url) + '" target="_blank" class="underline">view</a>';
|
||||||
loadRecentReports(projectId);
|
loadRecentReports(projectId);
|
||||||
})
|
})
|
||||||
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
|
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
|
||||||
@@ -206,10 +206,10 @@ function loadRecentReports(projectId) {
|
|||||||
}
|
}
|
||||||
box.innerHTML = j.reports.map(function (rp) {
|
box.innerHTML = j.reports.map(function (rp) {
|
||||||
var when = (rp.generated_at || '').replace('T', ' ').slice(0, 16);
|
var when = (rp.generated_at || '').replace('T', ' ').slice(0, 16);
|
||||||
var xlsx = rp.xlsx_url ? ' · <a href="' + rp.xlsx_url + '" class="text-indigo-600 dark:text-indigo-400 hover:underline">Excel</a>' : '';
|
var xlsx = rp.xlsx_url ? ' · <a href="' + _mergeEsc(rp.xlsx_url) + '" class="text-indigo-600 dark:text-indigo-400 hover:underline">Excel</a>' : '';
|
||||||
return '<div class="flex items-center justify-between px-3 py-2 text-sm">'
|
return '<div class="flex items-center justify-between px-3 py-2 text-sm">'
|
||||||
+ '<a href="' + rp.view_url + '" target="_blank" class="font-medium text-gray-800 dark:text-gray-200 hover:underline">Night of ' + rp.night_date + '</a>'
|
+ '<a href="' + _mergeEsc(rp.view_url) + '" target="_blank" class="font-medium text-gray-800 dark:text-gray-200 hover:underline">Night of ' + _mergeEsc(rp.night_date) + '</a>'
|
||||||
+ '<span class="text-xs text-gray-400">' + when + ' UTC' + xlsx + '</span></div>';
|
+ '<span class="text-xs text-gray-400">' + _mergeEsc(when) + ' UTC' + xlsx + '</span></div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
})
|
})
|
||||||
.catch(function () { box.innerHTML = '<div class="px-3 py-2 text-xs text-red-500">Failed to load.</div>'; });
|
.catch(function () { box.innerHTML = '<div class="px-3 py-2 text-xs text-red-500">Failed to load.</div>'; });
|
||||||
@@ -380,12 +380,12 @@ function renderRefGrid() {
|
|||||||
var rows = M.map(function (m) {
|
var rows = M.map(function (m) {
|
||||||
var cells = W.map(function (w) {
|
var cells = W.map(function (w) {
|
||||||
var v = (loc.values[w.key] && loc.values[w.key][m.key] != null) ? loc.values[w.key][m.key] : '';
|
var v = (loc.values[w.key] && loc.values[w.key][m.key] != null) ? loc.values[w.key][m.key] : '';
|
||||||
return '<td class="px-1"><input type="number" step="0.1" id="' + _refId(loc.id, w.key, m.key) + '" value="' + v + '" class="w-16 px-1.5 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm text-center"></td>';
|
return '<td class="px-1"><input type="number" step="0.1" id="' + _refId(loc.id, w.key, m.key) + '" value="' + _mergeEsc(v) + '" class="w-16 px-1.5 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm text-center"></td>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return '<tr><td class="text-sm text-gray-700 dark:text-gray-300 pr-2">' + m.label + '</td>' + cells + '</tr>';
|
return '<tr><td class="text-sm text-gray-700 dark:text-gray-300 pr-2">' + m.label + '</td>' + cells + '</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return '<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-2">'
|
return '<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-2">'
|
||||||
+ '<div class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-1">' + loc.name + '</div>'
|
+ '<div class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-1">' + _mergeEsc(loc.name) + '</div>'
|
||||||
+ '<table class="w-full">' + head + rows + '</table></div>';
|
+ '<table class="w-full">' + head + rows + '</table></div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user