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):