"""Campaign engine: materialization, rotation, sequencing, assignment. The engine generates per-recipient values (invoice number, amount, shipping, subject, template, phone, sender account) with collision-checked uniqueness and full audit data. """ import random from datetime import date, datetime, timedelta, timezone from typing import List, Optional, Sequence from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models import (Campaign, CampaignRecipient, EmailTemplate, GmailAccount, SubjectLine, CompanyPhoneNumber, Recipient, SuppressionEntry) from app.services.variable_engine import VariableEngine, GeneratedValues from app.core.config import settings def utcnow(): return datetime.now(timezone.utc) class RotationAssigner: """Assigns sender accounts per message: sequential or weighted, with limits & cooldown.""" def __init__(self, accounts: list, strategy: str, weights: dict = None, max_per_account: int = 200): self.accounts = [a for a in accounts if a.status == "authorized" and a.is_active] self.strategy = strategy if strategy in ("sequential", "weighted") else "sequential" self.weights = weights or {} self.max_per_account = max_per_account self.usage = {a.id: 0 for a in self.accounts} self.cooling = {a.id: False for a in self.accounts} self._seq_index = 0 self._pool = [] self._pool_pos = 0 def _available(self): avail = [] for a in self.accounts: if self.usage[a.id] >= self.max_per_account: continue if a.cooldown_until and a.cooldown_until > utcnow(): continue avail.append(a) return avail def next_account(self, account_id=None): """Return next account or None if all at limit/cooldown.""" if account_id is not None: for a in self.accounts: if a.id == account_id: self.usage[a.id] += 1 return a avail = self._available() if not avail: return None if self.strategy == "weighted": if not self._pool: pool = [] for a in avail: w = int(self.weights.get(str(a.id), 1)) pool.extend([a] * max(1, w)) random.shuffle(pool) self._pool = pool self._pool_pos = 0 while self._pool and self._pool_pos < len(self._pool): a = self._pool[self._pool_pos] self._pool_pos += 1 if self.usage[a.id] < self.max_per_account and not ( a.cooldown_until and a.cooldown_until > utcnow()): self.usage[a.id] += 1 return a self._pool = [] return self.next_account() # sequential for _ in range(len(self.accounts)): a = self.accounts[self._seq_index % len(self.accounts)] self._seq_index += 1 if self.usage[a.id] < self.max_per_account and not ( a.cooldown_until and a.cooldown_until > utcnow()): self.usage[a.id] += 1 return a return None def status(self): return {"accounts": [{"id": a.id, "email": a.email, "usage": self.usage[a.id], "limit": self.max_per_account} for a in self.accounts]} class SubjectSelector: def __init__(self, subjects: List[SubjectLine], strategy: str = "round_robin"): self.subjects = subjects self.strategy = strategy if strategy in ("round_robin", "random", "fixed_first") else "round_robin" self._idx = 0 def next_subject(self) -> Optional[SubjectLine]: if not self.subjects: return None if self.strategy == "random": return random.choice(self.subjects) if self.strategy == "fixed_first": return self.subjects[0] s = self.subjects[self._idx % len(self.subjects)] self._idx += 1 return s class TemplateSequencer: """Template 1 -> recipient 1, template 2 -> recipient 2 ... then repeat/stop/random.""" def __init__(self, templates: List[EmailTemplate], mode: str = "repeat"): self.templates = templates self.mode = mode if mode in ("repeat", "stop", "random") else "repeat" self._idx = 0 self._stopped = False def next_template(self) -> Optional[EmailTemplate]: if not self.templates: return None if self.mode == "random": return random.choice(self.templates) if self.mode == "stop": if self._idx >= len(self.templates): self._stopped = True return None t = self.templates[self._idx] self._idx += 1 return t t = self.templates[self._idx % len(self.templates)] self._idx += 1 return t @property def stopped(self): return self._stopped class PhoneSelector: def __init__(self, phones: List[CompanyPhoneNumber], mode: str = "round_robin"): self.phones = [p for p in phones if p.is_active] self.mode = mode if mode in ("round_robin", "random", "fixed") else "round_robin" self._idx = 0 def next_phone(self) -> str: if not self.phones: return "" if self.mode == "random": return random.choice(self.phones).phone if self.mode == "fixed": return self.phones[0].phone p = self.phones[self._idx % len(self.phones)] self._idx += 1 return p.phone async def get_setting(db: AsyncSession, key: str, default): from app.models import ApplicationSettings row = (await db.execute(select(ApplicationSettings).where(ApplicationSettings.key == key))).scalar_one_or_none() if row is not None: return row.value return default