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
101 changes: 101 additions & 0 deletions backend/tests/test_automation_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@
semantics. Scoping, the depth guard, module gating and the executors are
covered end-to-end in tests/test_automations.py."""

from sqlalchemy import select

import utils.automation_engine as engine
from db import SessionLocal
from models.announcement import Announcement
from models.automation import AutomationRule
from models.challenge import Challenge
from models.competition import Competition, generate_invite_code
from models.user import User
from utils.automation_actions import enrich_payload
from utils.automation_catalog import build_catalog
from utils.automation_engine import evaluate_conditions
from utils.event_bus import event_bus

Expand Down Expand Up @@ -116,3 +124,96 @@ async def boom(db, rule, event_name, payload, action):
reloaded = await db.get(AutomationRule, rule_id)
assert reloaded.trigger_count == 1
assert reloaded.last_triggered_at is not None


# --- Friendly template fields (#27) ------------------------------------------


async def test_enrich_payload_resolves_friendly_fields():
async with SessionLocal() as db:
comp = Competition(name="Enrich CTF", invite_code=generate_invite_code())
user = User(display_name="ada", password_hash="x")
db.add_all([comp, user])
await db.flush()
challenge = Challenge(competition_id=comp.id, title="Baby RSA")
db.add(challenge)
await db.commit()

payload = {
"competition_id": comp.id,
"user_id": user.id,
"challenge_id": challenge.id,
"points": 100,
}
enriched = await enrich_payload(db, payload)
assert enriched["user_name"] == "ada"
assert enriched["challenge_title"] == "Baby RSA"
assert enriched["competition_name"] == "Enrich CTF"
# Ids stay ids — webhooks and conditions need the raw values.
assert enriched["user_id"] == user.id
assert enriched["points"] == 100
# The input payload is never mutated.
assert "user_name" not in payload

# An id that no longer resolves falls back to the raw value, so an
# advertised placeholder never renders as a literal {user_name}.
broken = await enrich_payload(db, {"user_id": "gone"})
assert broken["user_name"] == "gone"


async def test_run_rule_renders_friendly_fields_in_templates():
"""End to end through run_rule (#27): a create_announcement body written
with {user_name}/{challenge_title} renders names, not UUIDs."""
async with SessionLocal() as db:
comp = Competition(name="Friendly CTF", invite_code=generate_invite_code())
user = User(display_name="grace", password_hash="x")
db.add_all([comp, user])
await db.flush()
challenge = Challenge(competition_id=comp.id, title="Warmup")
rule = AutomationRule(
name="FB",
trigger_type="challenge.solved",
conditions=[],
actions=[
{
"type": "create_announcement",
"title": "First blood!",
"body": "{user_name} drew first blood on {challenge_title}.",
}
],
competition_id=comp.id,
is_enabled=True,
)
db.add_all([challenge, rule])
await db.commit()

await engine.run_rule(
db,
rule,
"challenge.solved",
{
"competition_id": comp.id,
"user_id": user.id,
"challenge_id": challenge.id,
},
)

await event_bus.wait_for_background()

async with SessionLocal() as db:
announcement = (await db.scalars(select(Announcement))).first()
assert announcement is not None
assert announcement.body == "grace drew first blood on Warmup."


def test_catalog_advertises_friendly_fields():
triggers = {t["event"]: t["fields"] for t in build_catalog()["triggers"]}
solved = triggers["challenge.solved"]
for derived in ("user_name", "team_name", "challenge_title", "competition_name"):
assert derived in solved
# The raw ids stay advertised alongside their friendly companions.
assert "user_id" in solved
assert "challenge_id" in solved
# A trigger with a different id vocabulary derives its own names.
assert "opener_user_name" in triggers["ticket.created"]
assert "ticket_subject" in triggers["ticket.created"]
50 changes: 47 additions & 3 deletions backend/utils/automation_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@
from models.competition import Competition
from models.hint import Hint, HintReveal
from models.role import Role, RoleAssignment
from models.feedback import Survey
from models.score_adjustment import ScoreAdjustment
from models.team import TeamMembership
from models.team import Team, TeamMembership
from models.ticket import Ticket, TicketMessage
from models.user import User
from utils import mailer, webhook_security
from utils.event_bus import event_bus
from utils.notifications import broadcast_notifications, create_notifications
Expand All @@ -70,6 +72,50 @@ def _sub(match: re.Match) -> str:
return _PLACEHOLDER.sub(_sub, template)


# --- Friendly template fields (#27) ------------------------------------------
# Event payloads carry raw ids, so a template like "{user_id} solved it" renders
# a UUID. Before a matched rule's actions run, the id fields below are resolved
# into human-friendly companion fields ({user_name}, {challenge_title}, …) —
# advertised to the builder via the automation catalog, which derives its
# suggestions from this map. The id fields themselves keep their raw values:
# webhooks and conditions legitimately need ids.
#
# payload id field -> (derived field name, model, attribute)
FRIENDLY_FIELDS: dict[str, tuple[str, type, str]] = {
"user_id": ("user_name", User, "display_name"),
"opener_user_id": ("opener_user_name", User, "display_name"),
"assignee_user_id": ("assignee_user_name", User, "display_name"),
"author_user_id": ("author_user_name", User, "display_name"),
"actor_user_id": ("actor_user_name", User, "display_name"),
"team_id": ("team_name", Team, "name"),
"challenge_id": ("challenge_title", Challenge, "title"),
"survey_id": ("survey_title", Survey, "title"),
"ticket_id": ("ticket_subject", Ticket, "subject"),
"competition_id": ("competition_name", Competition, "name"),
}


async def enrich_payload(db, payload: dict[str, Any]) -> dict[str, Any]:
"""Return the payload plus resolved friendly fields for every id it carries.

Runs once per matched rule (run_rule), on the background lane — a handful of
primary-key lookups. An id that no longer resolves (entity deleted between
the event and the rule firing) falls back to the raw id, so an advertised
placeholder never renders as a literal ``{user_name}``. Fields already in
the payload are never overwritten (an emitter that supplied its own
``title``/``subject``-style field wins).
"""
enriched = dict(payload)
for id_field, (name_field, model, attr) in FRIENDLY_FIELDS.items():
value = payload.get(id_field)
if value is None or name_field in enriched:
continue
row = await db.get(model, value)
resolved = getattr(row, attr, None) if row is not None else None
enriched[name_field] = resolved if resolved is not None else value
return enriched


async def _notify_users(
db, user_ids, *, type: str, title: str, body: str | None, competition_id
) -> None:
Expand Down Expand Up @@ -275,8 +321,6 @@ async def _execute_unlock_challenge(db, rule, event_name, payload, config) -> No


async def _execute_open_survey(db, rule, event_name, payload, config) -> None:
from models.feedback import Survey

competition_id = payload.get("competition_id")
survey = await db.get(Survey, config.get("survey_id"))
# Tenant guard (§6.2): only open a survey in the event's own competition.
Expand Down
14 changes: 12 additions & 2 deletions backend/utils/automation_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from __future__ import annotations

from config import settings
from utils.automation_actions import ACTIONS, DEMO_DISABLED_ACTIONS
from utils.automation_actions import ACTIONS, DEMO_DISABLED_ACTIONS, FRIENDLY_FIELDS
from utils.automation_engine import CONDITION_OPERATORS
from utils.event_catalog import TRIGGERABLE_EVENTS

Expand Down Expand Up @@ -251,14 +251,24 @@ def _titleize(event_or_type: str) -> str:
return " ".join(event_or_type.replace(".", " ").replace("_", " ").split()).capitalize()


def _with_friendly(fields: list[str]) -> list[str]:
"""Append the resolved friendly companions (#27) for every id field a
trigger carries — {user_name}, {challenge_title}, … — so the builder
suggests the human-readable placeholder alongside the raw id. The engine
resolves them at rule-run time (utils.automation_actions.enrich_payload)."""
return fields + [
FRIENDLY_FIELDS[f][0] for f in fields if f in FRIENDLY_FIELDS
]


def build_catalog() -> dict:
"""The full editor catalog (§5.5) as plain dicts for the response model."""
return {
"triggers": [
{
"event": event,
"label": _titleize(event),
"fields": TRIGGER_FIELDS.get(event, _COMMON_FIELDS),
"fields": _with_friendly(TRIGGER_FIELDS.get(event, _COMMON_FIELDS)),
}
for event in TRIGGERABLE_EVENTS
],
Expand Down
10 changes: 9 additions & 1 deletion backend/utils/automation_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from db import utcnow
from models.automation import AutomationRule
from plugins.loader import is_module_enabled
from utils.automation_actions import execute_action
from utils.automation_actions import enrich_payload, execute_action
from utils.event_bus import event_bus

logger = logging.getLogger("automation")
Expand Down Expand Up @@ -192,6 +192,14 @@ async def run_rule(
# session (MissingGreenlet), so keep plain copies for the reload + emit.
rule_id = rule.id
rule_name = rule.name
# Resolve friendly template fields ({user_name}, {challenge_title}, …) once
# per rule run (#27) — the single choke point both the engine and the
# time-based scheduler pass through, so every action's render_template sees
# them. Condition matching (the caller's job) stays on the raw payload.
try:
payload = await enrich_payload(db, payload)
except Exception: # noqa: BLE001 — enrichment must never block the rule
logger.exception("payload enrichment failed on rule %s (%s)", rule_id, rule_name)
needs_reload = False
for action in rule.actions or []:
try:
Expand Down
Loading