diff --git a/backend/tests/test_automation_engine.py b/backend/tests/test_automation_engine.py index 473960b..6f07b0b 100644 --- a/backend/tests/test_automation_engine.py +++ b/backend/tests/test_automation_engine.py @@ -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 @@ -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"] diff --git a/backend/utils/automation_actions.py b/backend/utils/automation_actions.py index 531cd06..d70f4cb 100644 --- a/backend/utils/automation_actions.py +++ b/backend/utils/automation_actions.py @@ -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 @@ -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: @@ -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. diff --git a/backend/utils/automation_catalog.py b/backend/utils/automation_catalog.py index fcec1e6..d18d757 100644 --- a/backend/utils/automation_catalog.py +++ b/backend/utils/automation_catalog.py @@ -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 @@ -251,6 +251,16 @@ 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 { @@ -258,7 +268,7 @@ def build_catalog() -> dict: { "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 ], diff --git a/backend/utils/automation_engine.py b/backend/utils/automation_engine.py index d6fb52f..8501125 100644 --- a/backend/utils/automation_engine.py +++ b/backend/utils/automation_engine.py @@ -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") @@ -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: