bottleneckcrm/backend/macros/render.py
Ashwin e3b5181b19 Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 09:36:48 +05:30

147 lines
5.4 KiB
Python

"""Macro placeholder rendering.
Server-side substitution keeps the supported placeholder set in one place so
clients can't drift. Tokens unknown to this module are left literal so that
agents see the un-substituted form rather than getting silent damage.
"""
from __future__ import annotations
import re
from typing import Iterable
# Match `%token%` where token is letters/digits/underscores. Greedy enough
# to handle adjacent placeholders without eating intermediate text.
_PLACEHOLDER_RE = re.compile(r"%([a-zA-Z_][a-zA-Z0-9_]*)%")
def _profile_full_name(profile) -> str:
"""Best display name for a Profile.
The project's `User` model has no first_name/last_name fields, only
email, so the agent display falls back to the email's local part to
keep replies readable ("alex@…" rather than the full address).
"""
user = getattr(profile, "user", None)
if user is None:
return ""
email = (getattr(user, "email", "") or "").strip()
if not email:
return ""
local = email.split("@", 1)[0]
return local or email
def _primary_contact(case):
"""Return the contact we treat as 'the customer' for placeholder purposes.
Cases can have multiple contacts; v1 picks the first by FK order.
"""
if case is None:
return None
contacts_mgr = getattr(case, "contacts", None)
if contacts_mgr is None:
return None
return contacts_mgr.first()
def _contact_full_name(contact) -> str:
if contact is None:
return ""
first = (getattr(contact, "first_name", "") or "").strip()
last = (getattr(contact, "last_name", "") or "").strip()
full = f"{first} {last}".strip()
return full or (getattr(contact, "email", "") or "")
def _supported_values(case, profile) -> dict[str, str]:
"""Build the substitution table for one render call.
Anything that resolves to None becomes "" so the placeholder is still
swapped out (matches user expectation: empty rather than literal).
"""
contact = _primary_contact(case)
org = getattr(case, "org", None) or (
getattr(profile, "org", None) if profile is not None else None
)
return {
"customer_name": _contact_full_name(contact),
"customer_email": (getattr(contact, "email", "") or "") if contact else "",
"case_id": str(getattr(case, "id", "") or "") if case else "",
"case_subject": (getattr(case, "name", "") or "") if case else "",
"agent_name": _profile_full_name(profile) if profile is not None else "",
"agent_email": (
getattr(getattr(profile, "user", None), "email", "") or ""
if profile is not None
else ""
),
"org_name": (getattr(org, "name", "") or "") if org is not None else "",
}
# The placeholders `render_macro` substitutes, in the order the composer shows
# them, each paired with a human description of what it resolves to. This tuple
# is the single source of truth for the supported set: `SUPPORTED_TOKENS` (used
# by both the renderer and `find_unknown_placeholders`) is derived from it, and
# the macros list API returns it verbatim so a web/mobile reference card cannot
# drift from the tokens the server actually expands. Tokens carry their `%...%`
# wrapping so consumers can show them exactly as they appear in a macro body.
SUPPORTED_PLACEHOLDERS: tuple[dict[str, str], ...] = (
{"token": "%customer_name%", "resolves": "The case's first contact"},
{"token": "%customer_email%", "resolves": "That contact's email"},
{"token": "%case_id%", "resolves": "The case number"},
{"token": "%case_subject%", "resolves": "The case title"},
{"token": "%agent_name%", "resolves": "You: the local part of your email"},
{"token": "%agent_email%", "resolves": "Your email"},
{"token": "%org_name%", "resolves": "The organisation name"},
)
SUPPORTED_TOKENS: Iterable[str] = frozenset(
p["token"].strip("%") for p in SUPPORTED_PLACEHOLDERS
)
def find_unknown_placeholders(body: str | None) -> list[str]:
"""Return the `%token%` placeholders in `body` that aren't supported.
Order-preserving and de-duplicated so callers can render them in the
order they appear. Tokens are returned with their `%...%` wrapping so
the UI / API consumer can show them verbatim. The result is non-empty
only when the macro author has typoed (`%custmer_name%`) or used a
token we don't expand server-side. Same set as `render_macro`.
"""
if not body:
return []
seen: list[str] = []
seen_set: set[str] = set()
for match in _PLACEHOLDER_RE.finditer(body):
token = match.group(1)
if token in SUPPORTED_TOKENS or token in seen_set:
continue
seen.append(f"%{token}%")
seen_set.add(token)
return seen
def render_macro(macro, case, profile) -> str:
"""Substitute supported `%token%` placeholders in `macro.body`.
- Empty body -> empty string.
- Unknown tokens (e.g. `%priority%`) are left literal so the agent can
see them and fix the macro rather than silently sending broken text.
- Missing data (case has no contact, profile has no last name) renders
as the empty string.
"""
body = (macro.body or "") if macro is not None else ""
if not body:
return ""
values = _supported_values(case, profile)
def _sub(match: re.Match) -> str:
token = match.group(1)
if token in values:
return values[token]
return match.group(0)
return _PLACEHOLDER_RE.sub(_sub, body)