mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
The toast was calibrated for power users — lowest bars were 100 prints,
50 archives, 100 cost. Most installs never crossed any of them, especially
with the install base ~doubling since March. Matomo confirms: only 4
prints-100 and 3 archives-50 deeplink visits to /sponsors.html in a 7-day
window despite tens of thousands of weekly pulls.
Adds lower thresholds without changing priority order or cooldown:
PRINT_MILESTONES = (10, 25, ...)
ARCHIVE_MILESTONES = (5, 10, ...)
COST_MILESTONES = (25, 50, ...)
Existing toast copy uses {count}/{total} interpolation in all 11 locales,
so no i18n changes. Tests rebalanced so "below the floor" still tests
with the new floor; new test_fires_at_lowest_threshold pins prints-10.
257 lines
8.9 KiB
Python
257 lines
8.9 KiB
Python
"""Sponsor-prompt trigger evaluator and dismiss handler.
|
|
|
|
Drives the in-app "support keeps Bambuddy independent" toast. Trigger families
|
|
fire at milestones the user has earned (prints, archives, filament cost,
|
|
anniversary) plus a soft version-update nudge after a major upgrade.
|
|
|
|
A 14-day cooldown applies across ALL families: if any toast fired in the last
|
|
14 days, no new toast fires. Each individual milestone is shown at most once
|
|
per user (or once per install in auth-disabled mode); version-update is the
|
|
exception — it re-arms every time the running version is newer than the one
|
|
last acknowledged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.config import APP_VERSION
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.print_log import PrintLogEntry
|
|
from backend.app.models.sponsor_toast_state import SponsorToastState
|
|
from backend.app.models.user import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
COOLDOWN_DAYS = 14
|
|
|
|
PRINT_MILESTONES = (10, 25, 100, 500, 1000, 2500, 5000)
|
|
COST_MILESTONES = (25, 50, 100, 500, 1000)
|
|
ARCHIVE_MILESTONES = (5, 10, 50, 250, 1000)
|
|
ANNIVERSARY_YEARS = 1
|
|
|
|
|
|
@dataclass
|
|
class Trigger:
|
|
"""Evaluated trigger result returned to the frontend."""
|
|
|
|
milestone: str # e.g. "prints-500", "anniversary-1", "version-update"
|
|
family: str # "prints" | "cost" | "archives" | "anniversary" | "version-update"
|
|
threshold: int | None = None
|
|
payload: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _get_or_create_state(db: AsyncSession, user_id: int | None) -> SponsorToastState:
|
|
"""Fetch the state row for this user (or the install-default NULL row).
|
|
|
|
Creates the row lazily on first access so the migration doesn't need to
|
|
seed anything.
|
|
"""
|
|
if user_id is None:
|
|
stmt = select(SponsorToastState).where(SponsorToastState.user_id.is_(None))
|
|
else:
|
|
stmt = select(SponsorToastState).where(SponsorToastState.user_id == user_id)
|
|
result = await db.execute(stmt)
|
|
state = result.scalar_one_or_none()
|
|
if state is None:
|
|
state = SponsorToastState(user_id=user_id, milestones_seen="[]")
|
|
db.add(state)
|
|
await db.flush()
|
|
return state
|
|
|
|
|
|
def _within_cooldown(state: SponsorToastState) -> bool:
|
|
if state.last_shown_at is None:
|
|
return False
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=COOLDOWN_DAYS)
|
|
last = state.last_shown_at
|
|
if last.tzinfo is None:
|
|
last = last.replace(tzinfo=timezone.utc)
|
|
return last >= cutoff
|
|
|
|
|
|
def _seen_milestones(state: SponsorToastState) -> set[str]:
|
|
try:
|
|
raw = json.loads(state.milestones_seen or "[]")
|
|
return set(raw) if isinstance(raw, list) else set()
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning(
|
|
"sponsor_toast_state.milestones_seen for user=%s was not valid JSON; resetting",
|
|
state.user_id,
|
|
)
|
|
return set()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-family checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _user_filter(column, user_id: int | None):
|
|
return column.is_(None) if user_id is None else column == user_id
|
|
|
|
|
|
async def _check_anniversary(
|
|
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
|
|
) -> Trigger | None:
|
|
milestone = f"anniversary-{ANNIVERSARY_YEARS}"
|
|
if milestone in seen:
|
|
return None
|
|
if user_id is None:
|
|
# Install-anchor = earliest users.created_at (the first admin row).
|
|
result = await db.execute(select(func.min(User.created_at)))
|
|
anchor = result.scalar()
|
|
else:
|
|
result = await db.execute(select(User.created_at).where(User.id == user_id))
|
|
anchor = result.scalar()
|
|
if anchor is None:
|
|
return None
|
|
if anchor.tzinfo is None:
|
|
anchor = anchor.replace(tzinfo=timezone.utc)
|
|
if datetime.now(timezone.utc) - anchor < timedelta(days=365 * ANNIVERSARY_YEARS):
|
|
return None
|
|
return Trigger(milestone=milestone, family="anniversary")
|
|
|
|
|
|
async def _check_prints(
|
|
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
|
|
) -> Trigger | None:
|
|
stmt = (
|
|
select(func.count())
|
|
.select_from(PrintLogEntry)
|
|
.where(
|
|
PrintLogEntry.status == "completed",
|
|
_user_filter(PrintLogEntry.created_by_id, user_id),
|
|
)
|
|
)
|
|
completed = (await db.execute(stmt)).scalar() or 0
|
|
# Pick the LARGEST milestone the user has crossed but not yet seen.
|
|
for threshold in sorted(PRINT_MILESTONES, reverse=True):
|
|
key = f"prints-{threshold}"
|
|
if completed >= threshold and key not in seen:
|
|
return Trigger(
|
|
milestone=key,
|
|
family="prints",
|
|
threshold=threshold,
|
|
payload={"count": completed},
|
|
)
|
|
return None
|
|
|
|
|
|
async def _check_archives(
|
|
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
|
|
) -> Trigger | None:
|
|
stmt = select(func.count()).select_from(PrintArchive).where(_user_filter(PrintArchive.created_by_id, user_id))
|
|
archived = (await db.execute(stmt)).scalar() or 0
|
|
for threshold in sorted(ARCHIVE_MILESTONES, reverse=True):
|
|
key = f"archives-{threshold}"
|
|
if archived >= threshold and key not in seen:
|
|
return Trigger(
|
|
milestone=key,
|
|
family="archives",
|
|
threshold=threshold,
|
|
payload={"count": archived},
|
|
)
|
|
return None
|
|
|
|
|
|
async def _check_cost(
|
|
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
|
|
) -> Trigger | None:
|
|
stmt = (
|
|
select(func.coalesce(func.sum(PrintLogEntry.cost), 0) + func.coalesce(func.sum(PrintLogEntry.energy_cost), 0))
|
|
.select_from(PrintLogEntry)
|
|
.where(_user_filter(PrintLogEntry.created_by_id, user_id))
|
|
)
|
|
total = float((await db.execute(stmt)).scalar() or 0)
|
|
for threshold in sorted(COST_MILESTONES, reverse=True):
|
|
key = f"cost-{threshold}"
|
|
if total >= threshold and key not in seen:
|
|
return Trigger(
|
|
milestone=key,
|
|
family="cost",
|
|
threshold=threshold,
|
|
payload={"total": round(total, 2)},
|
|
)
|
|
return None
|
|
|
|
|
|
async def _check_version_update(
|
|
_db: AsyncSession, _user_id: int | None, _seen: set[str], state: SponsorToastState
|
|
) -> Trigger | None:
|
|
# version-update is NOT in milestones_seen — it has its own state column
|
|
# so it can re-fire on each major bump.
|
|
if not APP_VERSION:
|
|
return None
|
|
last = state.last_seen_version
|
|
if last is None:
|
|
# First-ever read; treat as already-acknowledged so we don't toast
|
|
# immediately on a brand-new install. Persist current version silently.
|
|
state.last_seen_version = APP_VERSION
|
|
return None
|
|
if last == APP_VERSION:
|
|
return None
|
|
return Trigger(
|
|
milestone="version-update",
|
|
family="version-update",
|
|
payload={"from": last, "to": APP_VERSION},
|
|
)
|
|
|
|
|
|
# Priority order: most emotional / earned first; version-update is the soft fallback.
|
|
_CHECKS = (
|
|
_check_anniversary,
|
|
_check_prints,
|
|
_check_archives,
|
|
_check_cost,
|
|
_check_version_update,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def evaluate(db: AsyncSession, user_id: int | None) -> Trigger | None:
|
|
"""Return the next eligible sponsor-toast trigger, or None."""
|
|
state = await _get_or_create_state(db, user_id)
|
|
if _within_cooldown(state):
|
|
return None
|
|
seen = _seen_milestones(state)
|
|
for check in _CHECKS:
|
|
trigger = await check(db, user_id, seen, state)
|
|
if trigger is not None:
|
|
return trigger
|
|
# No triggers eligible — still commit any in-progress state changes
|
|
# (e.g. version-update's first-touch persistence).
|
|
await db.flush()
|
|
return None
|
|
|
|
|
|
async def dismiss(db: AsyncSession, user_id: int | None, milestone: str) -> None:
|
|
"""Mark a milestone as shown (sets cooldown anchor + records seen)."""
|
|
state = await _get_or_create_state(db, user_id)
|
|
if milestone == "version-update":
|
|
# Re-armable: just update last_seen_version, don't add to seen-list.
|
|
state.last_seen_version = APP_VERSION
|
|
else:
|
|
seen = _seen_milestones(state)
|
|
if milestone not in seen:
|
|
seen.add(milestone)
|
|
state.milestones_seen = json.dumps(sorted(seen))
|
|
state.last_shown_at = datetime.now(timezone.utc)
|
|
await db.flush()
|