06/16 Phase 6 foundation: admin blueprint, audit log, user management

Adds the admin backend foundation: AuditLog + Setting models/migration,
an admin-only dashboard with KPI cards, and user management (search,
ban/suspend/activate, tier override, trust adjust) with audit logging
on every write action. Smoke suite extended to 64 checks.
This commit is contained in:
2026-06-16 11:45:00 -04:00
parent 62b04803ad
commit 2e9025fe55
18 changed files with 680 additions and 1 deletions
+44
View File
@@ -0,0 +1,44 @@
"""Admin dashboard KPI queries. Money returned as integer cents."""
from datetime import datetime, timedelta
from app.extensions import db
from app.models.user import User
from app.models.listing import Listing
from app.models.enums import ListingStatus
from app.models.plan import Plan
from app.models.payments import Subscription, Transaction
_ACTIVE_SUB_STATUSES = ("active", "trialing")
def active_listings_count():
return Listing.query.filter(
Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow(),
).count()
def new_users_count(days):
since = datetime.utcnow() - timedelta(days=days)
return User.query.filter(User.created_at >= since).count()
def mrr_cents():
"""Sum price_monthly_cents for users with an active/trialing subscription."""
return (db.session.query(db.func.coalesce(db.func.sum(Plan.price_monthly_cents), 0))
.join(Subscription, Subscription.plan_id == Plan.id)
.filter(Subscription.status.in_(_ACTIVE_SUB_STATUSES))
.scalar())
def revenue_30d_cents():
"""Subscription + boost transactions in the last 30 days."""
since = datetime.utcnow() - timedelta(days=30)
return (db.session.query(db.func.coalesce(db.func.sum(Transaction.amount_cents), 0))
.filter(Transaction.type.in_(("subscription", "boost")),
Transaction.created_at >= since,
Transaction.status == "succeeded")
.scalar())
def flag_queue_depth():
return Listing.query.filter(Listing.flag_count > 0).count()
+47
View File
@@ -0,0 +1,47 @@
"""Admin user-management: search/filter and state-transition helpers.
Each mutation function appends an AuditLog row via services/audit.py but
does not commit — the calling route commits once after invoking it.
"""
from app.extensions import db
from app.models.user import User
from app.models.enums import Role, UserStatus, TrustEventType
from app.services import audit
def search_query(*, role=None, status=None, q=None):
query = User.query
if role:
query = query.filter(User.role == Role(role))
if status:
query = query.filter(User.status == UserStatus(status))
if q:
term = f"%{q.strip()}%"
query = query.filter(db.or_(
User.email.ilike(term),
User.display_name.ilike(term),
))
return query.order_by(User.created_at.desc())
def set_status(user, new_status: UserStatus, *, actor, reason=None):
"""Transition a user's status (active/suspended/banned). Caller commits."""
user.status = new_status
audit.log_action(actor, f"user.{new_status.value}", "user", user.id,
meta={"reason": reason} if reason else None)
def set_tier(user, plan, *, actor):
"""Override a user's plan tier directly. Caller commits."""
old_tier_id = user.tier_id
user.tier_id = plan.id if plan else None
audit.log_action(actor, "user.tier_override", "user", user.id,
meta={"old_tier_id": old_tier_id, "new_tier_id": user.tier_id})
def adjust_trust(user, event_type: TrustEventType, delta: int, *, actor):
"""Manually adjust a user's trust score/tier. Caller commits."""
from app.services import trust as trust_svc
trust_svc.record_event(user, event_type, delta)
audit.log_action(actor, "user.trust_adjust", "user", user.id,
meta={"event_type": event_type.value, "delta": delta})
+14
View File
@@ -0,0 +1,14 @@
"""Append-only audit trail writer for admin actions."""
from app.extensions import db
from app.models.audit import AuditLog
def log_action(actor, action: str, target_type: str, target_id, meta: dict | None = None):
"""Append an AuditLog row. Caller commits."""
db.session.add(AuditLog(
actor_id=actor.id if actor else None,
action=action,
target_type=target_type,
target_id=target_id,
meta=meta,
))
+18
View File
@@ -0,0 +1,18 @@
"""Admin-editable runtime settings, stored as key/JSON-value rows."""
from app.extensions import db
from app.models.setting import Setting
def get_setting(key: str, default=None):
row = db.session.get(Setting, key)
return row.value if row is not None else default
def set_setting(key: str, value):
row = db.session.get(Setting, key)
if row is None:
row = Setting(key=key, value=value)
db.session.add(row)
else:
row.value = value
return row