token = match.group(1) if token == "prefix": return prefix if token == "date": return datetime.utcnow().strftime("%Y%m%d") if token.startswith("R"): # e.g. R4, R5 try: n = int(token[1:]) except ValueError: return token return rnd(upper, n) if token.startswith("r"): try: n = int(token[1:]) except ValueError: return token return rnd(lower, n) if token.startswith("d"): try: n = int(token[1:]) except ValueError: return token return rnd(digits_only, n) if token == "seq4": return rnd(digits_only, 4) if token.startswith("hash"): n = int(token[4:] or 8) return secrets.token_hex(n) return match.group(0) return re.sub(r"\{([A-Za-z0-9_]+)(\d*)\}", repl, fmt) def generate_amount(self) -> float: lo = float(self._cfg("amount_min", 300.0)) hi = float(self._cfg("amount_max", 1500.0)) if hi < lo: lo, hi = hi, lo return round(self.rng.uniform(lo, hi), 2) def generate_shipping(self) -> float: lo = float(self._cfg("shipping_min", 5.0)) hi = float(self._cfg("shipping_max", 50.0)) if hi < lo: lo, hi = hi, lo return round(self.rng.uniform(lo, hi), 2) def generate_random_name(self) -> str: first = ("James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda", "David", "Elizabeth", "William", "Barbara", "Richard", "Susan", "Joseph", "Jessica", "Thomas", "Sarah", "Charles", "Karen", "Ava", "Noah") last = ("Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin") return f"{self.rng.choice(first)} {self.rng.choice(last)}" # ------------------------------------------------------------------ substitution def substitute(self, text: str, recipient, generated: GeneratedValues, custom_fields: Optional[dict] = None, extra: Optional[dict] = None) -> SubstitutionResult: recipient = recipient or {} custom_fields = custom_fields or {} extra = extra or {} # Accept ORM Recipient objects as well as plain dicts if not isinstance(recipient, dict): recipient = {c.name: getattr(recipient, c.name, None) for c in recipient.__table__.columns} # Normalize: snake_case columns -> CamelCase variables (first_name -> FirstName) norm = {} for k, v in recipient.items(): norm[k] = v camel = "".join(p.capitalize() for p in str(k).split("_")) norm[camel] = v norm["Email"] = norm.get("Email", norm.get("email")) # custom CSV columns pass through with their raw names for k, v in list(custom_fields.items()): norm[k] = v camel = "".join(p.capitalize() for p in str(k).split("_")) norm[camel] = v recipient = norm unknown, missing, used = [], [], [] pool = dict(recipient) pool.update({k: v for k, v in extra.items() if k != "unsubscribe_link"}) def replace_fn(m): var = m.group(1) if var in ("RandomInvoiceNumber", "RandomTotal", "RandomShippingCharge", "RandomName", "InvoiceDate", "DueDate", "UnsubscribeLink", "CompanyPhone"): return m.group(0) # handled below by generated values pass if var in pool: val = pool.get(var) if val is None or str(val).strip() == "": if var in KNOWN_VARIABLES: missing.append(var) return f"[{var}]" used.append(var) return str(val) if var in KNOWN_VARIABLES: missing.append(var) return f"[{var}]" unknown.append(var) return f"[UNKNOWN:{var}]" text = re.sub(VARIABLE_PATTERN, replace_fn, text) # Generated / configured values second pass gen_map = { "RandomInvoiceNumber": generated.invoice_number, "RandomTotal": f"${generated.amount:,.2f}" if generated.amount is not None else None, "RandomShippingCharge": f"${generated.shipping:,.2f}" if generated.shipping is not None else None, "RandomName": generated.random_name, "InvoiceDate": generated.invoice_date, "DueDate": generated.due_date, "CompanyPhone": generated.phone, "UnsubscribeLink": extra.get("unsubscribe_link", ""), } for var, val in gen_map.items(): if val not in (None, ""): text = text.replace(f"%{var}%", str(val)) used.append(var) return SubstitutionResult(text=text, unknown=sorted(set(unknown)), missing=sorted(set(missing)), used=sorted(set(used))) # ------------------------------------------------------------------ analysis def analyze(self, text: str, custom_fields: Optional[list] = None) -> dict: """Report which variables a template uses and any unknown ones (pre-send validation).""" found = set(VARIABLE_PATTERN.findall(text or "")) custom_fields = set(custom_fields or []) unknown = sorted(found - KNOWN_VARIABLES - custom_fields) return {"variables": sorted(found), "unknown": unknown} def analyze_template_variables(text: str, custom_fields: Optional[list] = None) -> dict: return VariableEngine().analyze(text, custom_fields)