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:
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user