Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,11 @@
//"args": ["-v", "1", "--auv_name", "ahi", "--start", "20251001", "--end", "20251231", "--update_ssds_provenance", "--force"]
// Test web page building with a short deployment
//"args": ["-v", "1", "--dlist", "ahi/missionlogs/2025/20251022_20251024.dlist", "--update_ssds_provenance"]
"args": ["-v", "1", "--last_n_days", "10", "--update_ssds_provenance", "--force"]
// Test --force option for rebuilding web pages with a short deployment
//"args": ["-v", "1", "--last_n_days", "10", "--update_ssds_provenance", "--force"]
// Test --notify option
"args": ["-v", "1", "--dlist", "ahi/missionlogs/2025/20251022_20251024.dlist", "--update_ssds_provenance", "--force", "--notify", "mccann@mbari.org"]

},

]
Expand Down
79 changes: 78 additions & 1 deletion src/data/lrauv_deployment_plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import argparse # noqa: I001
import http
import logging
import os
import re
import sys
import time
Expand All @@ -36,6 +37,8 @@
from provenance import get_dods_url, get_script_github_url, submit_process_run
from resample import FREQ, LRAUV_OPENDAP_BASE

ENV_LRAUV_NOTIFY = "LRAUV_NOTIFY"


class DeploymentPlotter:
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -189,12 +192,13 @@ def _deployment_has_outputs(self, deployment_dir: Path, plot_name_stem: str) ->
"""Return True if any per-deployment PNG already exists in *deployment_dir*."""
return any(deployment_dir.glob(f"{plot_name_stem}_*.png"))

def plot_deployment( # noqa: C901, PLR0912, PLR0915
def plot_deployment( # noqa: C901, PLR0912, PLR0913, PLR0915
self,
dlist: str,
verbose: int = 0,
update_ssds_provenance: bool = False, # noqa: FBT001, FBT002
force: bool = False, # noqa: FBT001, FBT002
notify: str | None = None,
) -> None:
"""Main entry point: generate deployment-level plots from a .dlist path.

Expand All @@ -203,6 +207,8 @@ def plot_deployment( # noqa: C901, PLR0912, PLR0915
verbose: Verbosity level (0-2).
update_ssds_provenance: Submit provenance records to SSDS_Metadata.
force: Reprocess even when output PNGs already exist.
notify: Email address or Slack webhook URL to notify after completion.
Falls back to the ``LRAUV_NOTIFY`` environment variable.
"""
self.logger.setLevel(self._log_levels[min(verbose, 2)])

Expand Down Expand Up @@ -317,6 +323,7 @@ def plot_deployment( # noqa: C901, PLR0912, PLR0915
nc_files,
verbose=verbose,
update_ssds_provenance=update_ssds_provenance,
notify=notify,
)

def _build_and_write_html( # noqa: PLR0913
Expand All @@ -330,6 +337,7 @@ def _build_and_write_html( # noqa: PLR0913
nc_files: list[str],
verbose: int = 0,
update_ssds_provenance: bool = False, # noqa: FBT001, FBT002
notify: str | None = None,
) -> None:
"""Fetch STOQS permalink and write per-PNG HTML pages."""
dlist_no_ext = str(Path(dlist).with_suffix(""))
Expand Down Expand Up @@ -358,6 +366,10 @@ def _build_and_write_html( # noqa: PLR0913
archiver = Archiver(add_handlers=True, clobber=True)
archiver.logger.setLevel(self._log_levels[min(verbose, 2)])
archiver.copy_lrauv_deployment(deployment_dir, plot_name_stem)
html_paths = [
Path(p).with_suffix(".html") for p in png_paths if Path(p).with_suffix(".html").exists()
]
self._notify(notify or "", raw_name or plot_name_stem, html_paths, stoqs_url)
if update_ssds_provenance:
self._submit_provenance(
deployment_dir=deployment_dir,
Expand All @@ -368,6 +380,60 @@ def _build_and_write_html( # noqa: PLR0913
nc_files=nc_files,
)

def _notify(
self,
target: str,
deployment_name: str,
html_paths: list[Path],
stoqs_url: str | None,
) -> None:
"""Send an email or Slack notification with links to the new deployment HTML pages.

*target* is auto-detected:
- starts with ``https://`` → treated as a Slack incoming-webhook URL
- anything else → treated as an email address (sent via localhost SMTP)

The ``LRAUV_NOTIFY`` environment variable can supply the target so it
stays out of shell history and cron job command lines.
"""
resolved = target or os.environ.get(ENV_LRAUV_NOTIFY, "")
if not resolved:
return

lines = [f"New LRAUV deployment plots available: {deployment_name}"]
for p in html_paths:
lines.append(f" {get_dods_url(str(p))}") # noqa: PERF401
if stoqs_url:
lines.append(f"STOQS: {stoqs_url}")
body = "\n".join(lines)

if resolved.startswith("https://"):
# Slack incoming webhook
import requests # noqa: PLC0415

try:
resp = requests.post(resolved, json={"text": body}, timeout=10) # noqa: S113
resp.raise_for_status()
self.logger.info("Slack notification sent to webhook")
except Exception as exc: # noqa: BLE001
self.logger.warning("Slack notification failed: %s", exc)
else:
# Email via localhost SMTP relay
import smtplib # noqa: PLC0415
from email.message import EmailMessage # noqa: PLC0415

msg = EmailMessage()
msg["Subject"] = f"New LRAUV deployment plots: {deployment_name}"
msg["From"] = "auv-python@mbari.org"
msg["To"] = resolved
msg.set_content(body)
try:
with smtplib.SMTP("localhost") as s:
s.send_message(msg)
self.logger.info("Email notification sent to %s", resolved)
except Exception as exc: # noqa: BLE001
self.logger.warning("Email notification failed: %s", exc)

def _submit_provenance( # noqa: PLR0913
self,
deployment_dir: Path,
Expand Down Expand Up @@ -693,6 +759,16 @@ def process_command_line(self) -> None:
" By default, deployments with existing outputs are skipped."
),
)
parser.add_argument(
"--notify",
default="",
metavar="EMAIL_OR_WEBHOOK",
help=(
"Send a notification when new plots are written. Provide an email"
" address or a Slack incoming-webhook URL. Falls back to the"
f" {ENV_LRAUV_NOTIFY} environment variable if not specified."
),
)
self.args = parser.parse_args()
if self.args.start and not self.args.end:
self.args.end = datetime.now(tz=UTC).strftime("%Y%m%d")
Expand Down Expand Up @@ -725,4 +801,5 @@ def process_command_line(self) -> None:
verbose=args.verbose,
update_ssds_provenance=args.update_ssds_provenance,
force=args.force,
notify=args.notify,
)
83 changes: 83 additions & 0 deletions src/data/test_lrauv_deployment_plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,3 +634,86 @@ def test_depth_range_in_permalink(self):
# padding of 1 m applied each side
assert payload["start-depth"] == pytest.approx(9.0) # noqa: S101
assert payload["end-depth"] == pytest.approx(51.0) # noqa: S101


# ===========================================================================
# Tests for _notify()
# ===========================================================================


class TestNotify:
"""Unit tests for DeploymentPlotter._notify()."""

def test_email_sent(self, dp, tmp_path):
"""_notify() with an email target should call smtplib.SMTP.send_message."""
html_file = tmp_path / "test.html"
html_file.touch()

with patch("smtplib.SMTP") as mock_smtp_cls:
mock_smtp = MagicMock()
mock_smtp_cls.return_value.__enter__ = MagicMock(return_value=mock_smtp)
mock_smtp_cls.return_value.__exit__ = MagicMock(return_value=False)
dp._notify(
"team@mbari.org",
"CANON_April_2025",
[html_file],
"https://stoqs.mbari.org/query/?permalink_id=abc",
)

mock_smtp.send_message.assert_called_once() # noqa: S101
msg = mock_smtp.send_message.call_args[0][0]
assert "team@mbari.org" in msg["To"] # noqa: S101
assert "CANON_April_2025" in msg["Subject"] # noqa: S101

def test_slack_webhook_posts(self, dp, tmp_path):
"""_notify() with a Slack webhook URL should POST to that URL."""
html_file = tmp_path / "test.html"
html_file.touch()
webhook_url = "https://hooks.slack.com/services/T0000/B0000/xxxx"

with patch("requests.post") as mock_post:
mock_post.return_value.raise_for_status = MagicMock()
dp._notify(
"https://hooks.slack.com/services/T0000/B0000/xxxx",
"CANON_April_2025",
[html_file],
None,
)

mock_post.assert_called_once() # noqa: S101
call_kwargs = mock_post.call_args
assert call_kwargs[0][0] == webhook_url # noqa: S101
assert "text" in call_kwargs[1]["json"] # noqa: S101

def test_noop_when_no_target_and_no_env(self, dp, tmp_path, monkeypatch):
"""_notify() must not call anything when target is empty and env var unset."""
monkeypatch.delenv("LRAUV_NOTIFY", raising=False)

with (
patch("smtplib.SMTP") as mock_smtp_cls,
patch("requests.post") as mock_post,
):
dp._notify("", "CANON_April_2025", [], None)

mock_smtp_cls.assert_not_called() # noqa: S101
mock_post.assert_not_called() # noqa: S101

def test_env_var_fallback_used(self, dp, tmp_path, monkeypatch):
"""When notify='' but LRAUV_NOTIFY env var is set, that value is used."""
monkeypatch.setenv("LRAUV_NOTIFY", "fallback@mbari.org")
html_file = tmp_path / "test.html"
html_file.touch()

with (
patch("lrauv_deployment_plots.os") as mock_os,
patch("smtplib.SMTP") as mock_smtp_cls,
):
mock_os.environ = {"LRAUV_NOTIFY": "fallback@mbari.org"}
mock_smtp = MagicMock()
mock_smtp_cls.return_value.__enter__ = MagicMock(return_value=mock_smtp)
mock_smtp_cls.return_value.__exit__ = MagicMock(return_value=False)
dp._notify("", "CANON_April_2025", [html_file], None)

mock_smtp.send_message.assert_called_once() # noqa: S101
msg = mock_smtp.send_message.call_args[0][0]
assert "fallback@mbari.org" in msg["To"] # noqa: S101
Loading