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
26 changes: 17 additions & 9 deletions api/ADMIN_NOTIFICATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,23 @@ configured, email is skipped for every notification. `GET /api/health` reports

### Teams

`ADMIN_TEAMS_WEBHOOK_URL`: the HTTP trigger URL of a Power Automate flow (or
Logic App), the same kind used for the retrain alerts:

1. Power Automate → Create → Instant cloud flow → trigger **When a Teams
webhook request is received** (or **When a HTTP request is received**).
2. Add **Post card in a chat or channel**; post as Flow bot to the channel or
chat you want, and set the card to the trigger body (`triggerBody()`). The
API sends an Adaptive Card.
3. Save and copy the trigger URL into `ADMIN_TEAMS_WEBHOOK_URL`.
`ADMIN_TEAMS_WEBHOOK_URL`: the URL of a Teams **Workflows** webhook. The
simplest is the built-in template:

1. In Teams, open the channel → **Workflows** → **Send webhook alerts to a
channel** (or in Power Automate, the same template).
2. Save it and copy the webhook URL into `ADMIN_TEAMS_WEBHOOK_URL`.

The API sends each card as a Teams message
(`{"type": "message", "attachments": [{"contentType":
"application/vnd.microsoft.card.adaptive", "content": <card>}]}`), which that
template posts one card per attachment. A bare card would make the run
succeed without posting anything. If you use a classic flow that posts the
request body itself as the card, set `TEAMS_WEBHOOK_FORMAT=card`.

If the flow is turned off (Power Automate suspends flows, for example when a
connection expires), the webhook answers HTTP 400 and notifications are
retried for several hours before being marked failed.

### Email

Expand Down
31 changes: 28 additions & 3 deletions api/platform_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
Channels (each is skipped when not configured):
- Teams: ``ADMIN_TEAMS_WEBHOOK_URL``, a Power Automate / Logic App flow that
posts the Adaptive Card it receives (the same pattern as the retrain alerts).
- Teams: ``ADMIN_TEAMS_WEBHOOK_URL``, a Teams Workflows / Power Automate
webhook. Cards are sent in the message envelope the "Send webhook alerts to
a channel" template expects (``TEAMS_WEBHOOK_FORMAT=card`` sends a bare
card for classic flows).
- Email, either
- SMTP: ``SMTP_HOST``, ``SMTP_PORT`` (587), ``SMTP_USERNAME``,
``SMTP_PASSWORD``, ``SMTP_FROM``, ``SMTP_SECURITY`` (starttls | ssl | none);
Expand Down Expand Up @@ -281,11 +283,34 @@ def _post_json(url: str, payload: dict[str, Any], *, timeout: int = 15) -> None:
raise RuntimeError(f"webhook returned HTTP {status}")


def wrap_for_teams_webhook(card: dict[str, Any]) -> dict[str, Any]:
"""
Wrap an Adaptive Card in the message envelope Teams Workflows expect.
The "Send webhook alerts to a channel" template posts one card per entry
in ``attachments``; a bare card has none, so the run succeeds but posts
nothing. ``TEAMS_WEBHOOK_FORMAT=card`` sends the bare card instead, for a
classic flow that posts the request body itself.
"""
if os.getenv("TEAMS_WEBHOOK_FORMAT", "message").strip().lower() == "card":
return card
return {
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": None,
"content": card,
}
],
}


def send_teams(notification: dict[str, Any]) -> None:
url = os.getenv("ADMIN_TEAMS_WEBHOOK_URL", "").strip()
if not url:
raise ChannelNotConfigured("ADMIN_TEAMS_WEBHOOK_URL is not set")
_post_json(url, build_teams_card(notification))
_post_json(url, wrap_for_teams_webhook(build_teams_card(notification)))


def email_configured() -> bool:
Expand Down
8 changes: 6 additions & 2 deletions api/tests/test_admin_notifications_pg.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,12 @@ def fake_urlopen(request, timeout=0):
self.assertEqual(self._state(key)[:4], ("sent", "sent", 1, True))

teams = next(body for url, body in posted if url.startswith("https://teams"))
self.assertEqual(teams["type"], "AdaptiveCard")
self.assertEqual(teams["actions"][0]["url"], "https://dt.example.test/admin/users")
# Teams Workflows webhooks post one card per attachment.
self.assertEqual(teams["type"], "message")
card = teams["attachments"][0]["content"]
self.assertEqual(teams["attachments"][0]["contentType"], "application/vnd.microsoft.card.adaptive")
self.assertEqual(card["type"], "AdaptiveCard")
self.assertEqual(card["actions"][0]["url"], "https://dt.example.test/admin/users")
email = next(body for url, body in posted if url.startswith("https://mail"))
self.assertIn(self.admin.email, email["to"])
self.assertNotIn(self.researcher.email, email["to"])
Expand Down
16 changes: 16 additions & 0 deletions api/tests/test_platform_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ def test_teams_card_link_only_when_base_url_configured(self):
self.assertEqual(card["actions"][0]["url"], "https://dt.example.org/admin/role-requests")
self.assertEqual(card["body"][3]["facts"][0], {"title": "Justification", "value": "I need \"admin\" & more"})

def test_teams_payload_is_a_message_with_one_card_attachment(self):
env = {**CLEAR_ENV, "ADMIN_TEAMS_WEBHOOK_URL": "https://teams.example.org/hook", "TEAMS_WEBHOOK_FORMAT": ""}
with patch.dict(os.environ, env), patch.object(pn, "_post_json") as post:
pn.send_teams(_notification())
payload = post.call_args.args[1]
self.assertEqual(payload["type"], "message")
self.assertEqual(len(payload["attachments"]), 1)
self.assertEqual(payload["attachments"][0]["contentType"], "application/vnd.microsoft.card.adaptive")
self.assertEqual(payload["attachments"][0]["content"]["type"], "AdaptiveCard")

def test_bare_card_format_for_classic_flows(self):
env = {**CLEAR_ENV, "ADMIN_TEAMS_WEBHOOK_URL": "https://teams.example.org/hook", "TEAMS_WEBHOOK_FORMAT": "card"}
with patch.dict(os.environ, env), patch.object(pn, "_post_json") as post:
pn.send_teams(_notification())
self.assertEqual(post.call_args.args[1]["type"], "AdaptiveCard")

def test_facts_drop_empty_values_and_truncate_long_ones(self):
facts = pn._clean_facts([("Empty", ""), ("None", None), ("Long", "x" * 5000)])
self.assertEqual([title for title, _ in facts], ["Long"])
Expand Down
9 changes: 6 additions & 3 deletions geddes/k8s/01-secrets.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,12 @@ stringData:
PG_SUPER_PASS: "REPLACE_WITH_POSTGRES_PASSWORD"

# ── Admin notifications (api/ADMIN_NOTIFICATIONS.md) ──
# Teams: a Power Automate / Logic App flow URL that posts the Adaptive Card
# it receives to a Teams chat or channel.
# ADMIN_TEAMS_WEBHOOK_URL: "https://prod-00.westus.logic.azure.com/workflows/..."
# Teams: a Teams Workflows webhook ("Send webhook alerts to a channel").
# Cards are sent as a Teams message with the card as an attachment; set
# TEAMS_WEBHOOK_FORMAT=card for a classic flow that posts the request body
# itself as the card.
# ADMIN_TEAMS_WEBHOOK_URL: "https://<env>.environment.api.powerplatform.com/powerautomate/automations/direct/workflows/..."
# TEAMS_WEBHOOK_FORMAT: "message"
# Email, either through an SMTP relay ...
# SMTP_HOST: "smtp.example.edu"
# SMTP_PORT: "587"
Expand Down