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
+2
View File
@@ -55,6 +55,7 @@ def _register_blueprints(app):
from app.blueprints.messaging.routes import messaging_bp from app.blueprints.messaging.routes import messaging_bp
from app.blueprints.payments.routes import payments_bp from app.blueprints.payments.routes import payments_bp
from app.blueprints.ads.routes import ads_bp from app.blueprints.ads.routes import ads_bp
from app.blueprints.admin.routes import admin_bp
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(i18n_bp) app.register_blueprint(i18n_bp)
@@ -62,6 +63,7 @@ def _register_blueprints(app):
app.register_blueprint(messaging_bp) app.register_blueprint(messaging_bp)
app.register_blueprint(payments_bp) app.register_blueprint(payments_bp)
app.register_blueprint(ads_bp) app.register_blueprint(ads_bp)
app.register_blueprint(admin_bp)
def _register_errorhandlers(app): def _register_errorhandlers(app):
View File
+127
View File
@@ -0,0 +1,127 @@
"""Admin backend: dashboard KPIs + user management. Admin-only."""
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import current_user
from flask_babel import gettext as _
from app.extensions import db
from app.models.user import User
from app.models.listing import Listing
from app.models.plan import Plan
from app.models.trust import TrustEvent
from app.models.audit import AuditLog
from app.models.enums import UserStatus, TrustEventType
from app.utils import admin_required
from app.services import admin_users as usvc
from app.services import admin_dashboard as dash
admin_bp = Blueprint("admin", __name__)
PER_PAGE = 25
@admin_bp.route("/admin")
@admin_required
def dashboard():
kpis = {
"active_listings": dash.active_listings_count(),
"new_users_7d": dash.new_users_count(7),
"new_users_30d": dash.new_users_count(30),
"mrr_cents": dash.mrr_cents(),
"revenue_30d_cents": dash.revenue_30d_cents(),
"flag_queue_depth": dash.flag_queue_depth(),
}
return render_template("admin/dashboard.html", kpis=kpis)
@admin_bp.route("/admin/users")
@admin_required
def users():
page = request.args.get("page", 1, type=int)
role = request.args.get("role", type=str) or None
status = request.args.get("status", type=str) or None
q = request.args.get("q", type=str) or None
pagination = usvc.search_query(role=role, status=status, q=q).paginate(
page=page, per_page=PER_PAGE, error_out=False)
return render_template("admin/users.html", pagination=pagination,
filters=request.args)
@admin_bp.route("/admin/users/<int:user_id>")
@admin_required
def user_detail(user_id):
user = User.query.get_or_404(user_id)
listings = (Listing.query.filter_by(user_id=user.id)
.order_by(Listing.created_at.desc()).limit(50).all())
trust_events = (user.trust_events.order_by(TrustEvent.created_at.desc())
.limit(50).all())
audit_history = (AuditLog.query.filter_by(target_type="user", target_id=user.id)
.order_by(AuditLog.created_at.desc()).limit(50).all())
plans = Plan.query.order_by(Plan.sort_order).all()
return render_template("admin/user_detail.html", user=user, listings=listings,
trust_events=trust_events, audit_history=audit_history,
plans=plans, TrustEventType=TrustEventType)
def _guard_self_action(user):
if user.id == current_user.id:
flash(_("You cannot perform this action on your own account."), "danger")
return True
return False
@admin_bp.route("/admin/users/<int:user_id>/ban", methods=["POST"])
@admin_required
def ban_user(user_id):
user = User.query.get_or_404(user_id)
if _guard_self_action(user):
return redirect(url_for("admin.user_detail", user_id=user.id))
usvc.set_status(user, UserStatus.banned, actor=current_user)
db.session.commit()
flash(_("User banned."), "warning")
return redirect(url_for("admin.user_detail", user_id=user.id))
@admin_bp.route("/admin/users/<int:user_id>/suspend", methods=["POST"])
@admin_required
def suspend_user(user_id):
user = User.query.get_or_404(user_id)
if _guard_self_action(user):
return redirect(url_for("admin.user_detail", user_id=user.id))
usvc.set_status(user, UserStatus.suspended, actor=current_user)
db.session.commit()
flash(_("User suspended."), "warning")
return redirect(url_for("admin.user_detail", user_id=user.id))
@admin_bp.route("/admin/users/<int:user_id>/activate", methods=["POST"])
@admin_required
def activate_user(user_id):
user = User.query.get_or_404(user_id)
usvc.set_status(user, UserStatus.active, actor=current_user)
db.session.commit()
flash(_("User activated."), "success")
return redirect(url_for("admin.user_detail", user_id=user.id))
@admin_bp.route("/admin/users/<int:user_id>/tier", methods=["POST"])
@admin_required
def set_tier(user_id):
user = User.query.get_or_404(user_id)
plan_id = request.form.get("plan_id", type=int)
plan = db.session.get(Plan, plan_id) if plan_id else None
usvc.set_tier(user, plan, actor=current_user)
db.session.commit()
flash(_("Tier updated."), "success")
return redirect(url_for("admin.user_detail", user_id=user.id))
@admin_bp.route("/admin/users/<int:user_id>/trust", methods=["POST"])
@admin_required
def trust_adjust(user_id):
user = User.query.get_or_404(user_id)
delta = request.form.get("delta", type=int) or 0
event_type = request.form.get("event_type", type=str)
usvc.adjust_trust(user, TrustEventType(event_type), delta, actor=current_user)
db.session.commit()
flash(_("Trust adjusted."), "success")
return redirect(url_for("admin.user_detail", user_id=user.id))
+3 -1
View File
@@ -9,8 +9,10 @@ from app.models.messaging import Conversation, Message
from app.models.favorite import Favorite from app.models.favorite import Favorite
from app.models.payments import Subscription, Transaction, Boost from app.models.payments import Subscription, Transaction, Boost
from app.models.ads import Ad, Sponsor, PromotedKeyword from app.models.ads import Ad, Sponsor, PromotedKeyword
from app.models.audit import AuditLog
from app.models.setting import Setting
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing", __all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
"ListingImage", "ZipGeo", "Metro", "Conversation", "Message", "ListingImage", "ZipGeo", "Metro", "Conversation", "Message",
"Favorite", "Subscription", "Transaction", "Boost", "Favorite", "Subscription", "Transaction", "Boost",
"Ad", "Sponsor", "PromotedKeyword"] "Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting"]
+24
View File
@@ -0,0 +1,24 @@
"""Append-only audit trail for admin write actions."""
from datetime import datetime
from sqlalchemy import JSON
from app.extensions import db
class AuditLog(db.Model):
__tablename__ = "audit_logs"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
actor_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("users.id"), nullable=True, index=True)
action = db.Column(db.String(60), nullable=False, index=True)
target_type = db.Column(db.String(40), nullable=False)
target_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
nullable=True, index=True)
meta = db.Column(JSON, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
actor = db.relationship("User", backref=db.backref("audit_logs", lazy="dynamic"))
def __repr__(self):
return f"<AuditLog {self.action} {self.target_type}:{self.target_id}>"
+16
View File
@@ -0,0 +1,16 @@
"""Admin-editable runtime settings (registration_open, flag_threshold, etc.)."""
from datetime import datetime
from sqlalchemy import JSON
from app.extensions import db
class Setting(db.Model):
__tablename__ = "settings"
key = db.Column(db.String(80), primary_key=True)
value = db.Column(JSON, nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<Setting {self.key}>"
+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
+11
View File
@@ -187,3 +187,14 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
.sponsor-card:hover{box-shadow:0 2px 10px rgba(0,0,0,.08);text-decoration:none} .sponsor-card:hover{box-shadow:0 2px 10px rgba(0,0,0,.08);text-decoration:none}
.sponsor-logo{max-width:140px;max-height:80px;object-fit:contain;margin-bottom:8px} .sponsor-logo{max-width:140px;max-height:80px;object-fit:contain;margin-bottom:8px}
.sponsor-name-only{font-weight:700;font-size:16px;margin-bottom:8px} .sponsor-name-only{font-weight:700;font-size:16px;margin-bottom:8px}
/* --- Phase 6: admin --- */
.admin-nav{display:flex;gap:16px;margin-bottom:16px;border-bottom:1px solid var(--line);padding-bottom:10px}
.admin-nav a{color:var(--muted);font-weight:600}
.admin-nav a.on{color:var(--brand)}
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-top:14px}
.kpi-card{padding:16px}
.kpi-label{color:var(--muted);font-size:13px;margin-bottom:6px}
.kpi-value{font-size:26px;font-weight:800}
.admin-filter-row{display:flex;gap:12px;align-items:end;margin-bottom:16px;flex-wrap:wrap}
.admin-filter-row .field{margin-bottom:0}
+4
View File
@@ -0,0 +1,4 @@
<nav class="admin-nav">
<a href="{{ url_for('admin.dashboard') }}" class="{{ 'on' if request.endpoint == 'admin.dashboard' else '' }}">{{ _('Dashboard') }}</a>
<a href="{{ url_for('admin.users') }}" class="{{ 'on' if request.endpoint in ('admin.users', 'admin.user_detail') else '' }}">{{ _('Users') }}</a>
</nav>
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Dashboard') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Dashboard') }}</h2>
<div class="kpi-grid">
<div class="card kpi-card">
<div class="kpi-label">{{ _('Active listings') }}</div>
<div class="kpi-value">{{ kpis.active_listings }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('New users (7d)') }}</div>
<div class="kpi-value">{{ kpis.new_users_7d }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('New users (30d)') }}</div>
<div class="kpi-value">{{ kpis.new_users_30d }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('MRR') }}</div>
<div class="kpi-value">${{ "%.2f"|format(kpis.mrr_cents / 100) }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Revenue (30d): subscriptions + boosts') }}</div>
<div class="kpi-value">${{ "%.2f"|format(kpis.revenue_30d_cents / 100) }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Flag queue depth') }}</div>
<div class="kpi-value">{{ kpis.flag_queue_depth }}</div>
</div>
</div>
</div>
{% endblock %}
+106
View File
@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · %(email)s', email=user.email) }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ user.display_name }} &lt;{{ user.email }}&gt;</h2>
<table class="attrs">
<tr><th>{{ _('Role') }}</th><td><span class="badge cat">{{ user.role.value }}</span></td></tr>
<tr><th>{{ _('Status') }}</th><td><span class="badge {{ 'ok' if user.status.value == 'active' else 'warn' }}">{{ user.status.value }}</span></td></tr>
<tr><th>{{ _('Trust') }}</th><td>{{ user.trust_tier.value }} ({{ user.trust_score }})</td></tr>
<tr><th>{{ _('Tier') }}</th><td>{{ user.tier.name if user.tier else '—' }}</td></tr>
<tr><th>{{ _('Email verified') }}</th><td>{{ _('Yes') if user.email_verified else _('No') }}</td></tr>
<tr><th>{{ _('Verified badge') }}</th><td>{{ _('Yes') if user.verified else _('No') }}</td></tr>
<tr><th>{{ _('Last login') }}</th><td>{{ user.last_login_at or '—' }}</td></tr>
<tr><th>{{ _('Joined') }}</th><td>{{ user.created_at.strftime('%Y-%m-%d') }}</td></tr>
</table>
<div class="billing-actions" style="margin-top:14px">
<form method="post" action="{{ url_for('admin.ban_user', user_id=user.id) }}"
onsubmit="return confirm('{{ _('Ban this user?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn danger" type="submit">{{ _('Ban') }}</button>
</form>
<form method="post" action="{{ url_for('admin.suspend_user', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn ghost" type="submit">{{ _('Suspend') }}</button>
</form>
<form method="post" action="{{ url_for('admin.activate_user', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn" type="submit">{{ _('Activate') }}</button>
</form>
</div>
</div>
<div class="card">
<h3>{{ _('Tier override') }}</h3>
<form method="post" action="{{ url_for('admin.set_tier', user_id=user.id) }}" class="row2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select class="input" name="plan_id">
<option value="">{{ _('None') }}</option>
{% for p in plans %}
<option value="{{ p.id }}" {{ 'selected' if user.tier_id == p.id }}>{{ p.name }}</option>
{% endfor %}
</select>
<button class="btn" type="submit">{{ _('Update tier') }}</button>
</form>
</div>
<div class="card">
<h3>{{ _('Adjust trust') }}</h3>
<form method="post" action="{{ url_for('admin.trust_adjust', user_id=user.id) }}" class="row2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select class="input" name="event_type">
{% for et in TrustEventType %}
<option value="{{ et.value }}">{{ et.value }}</option>
{% endfor %}
</select>
<input class="input" type="number" name="delta" value="0">
<button class="btn" type="submit">{{ _('Apply') }}</button>
</form>
</div>
<div class="card">
<h3>{{ _('Listings') }}</h3>
{% if not listings %}<p class="muted">{{ _('No listings.') }}</p>{% endif %}
<table class="list">
{% for l in listings %}
<tr>
<td><a href="{{ url_for('listings.detail', listing_id=l.id) }}">{{ l.title }}</a></td>
<td><span class="badge {{ 'ok' if l.is_live else 'warn' }}">{{ l.status.value }}</span></td>
<td class="muted small">{{ l.created_at.strftime('%Y-%m-%d') }}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="card">
<h3>{{ _('Trust events') }}</h3>
{% if not trust_events %}<p class="muted">{{ _('None.') }}</p>{% endif %}
<table class="list">
{% for ev in trust_events %}
<tr>
<td>{{ ev.type.value }}</td>
<td>{{ '%+d'|format(ev.delta) }}</td>
<td class="muted small">{{ ev.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="card">
<h3>{{ _('Audit history') }}</h3>
{% if not audit_history %}<p class="muted">{{ _('None.') }}</p>{% endif %}
<table class="list">
{% for a in audit_history %}
<tr>
<td>{{ a.action }}</td>
<td>{{ a.actor.display_name if a.actor else _('system') }}</td>
<td class="muted small">{{ a.meta|tojson if a.meta else '' }}</td>
<td class="muted small">{{ a.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Users') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Users') }}</h2>
<form method="get" action="{{ url_for('admin.users') }}" class="admin-filter-row">
<div class="field">
<label>{{ _('Search') }}</label>
<input class="input" name="q" value="{{ filters.get('q','') }}" placeholder="{{ _('email or name') }}">
</div>
<div class="field">
<label>{{ _('Role') }}</label>
<select class="input" name="role">
<option value="">{{ _('All') }}</option>
{% for r in ['free', 'subscriber', 'moderator', 'admin'] %}
<option value="{{ r }}" {{ 'selected' if filters.get('role') == r }}>{{ r }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>{{ _('Status') }}</label>
<select class="input" name="status">
<option value="">{{ _('All') }}</option>
{% for s in ['active', 'suspended', 'banned'] %}
<option value="{{ s }}" {{ 'selected' if filters.get('status') == s }}>{{ s }}</option>
{% endfor %}
</select>
</div>
<button class="btn" type="submit">{{ _('Apply') }}</button>
</form>
{% if not pagination.items %}<p class="muted">{{ _('No users found.') }}</p>{% endif %}
<table class="list">
{% for u in pagination.items %}
<tr>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.display_name }}</td>
<td><span class="badge cat">{{ u.role.value }}</span></td>
<td><span class="badge {{ 'ok' if u.status.value == 'active' else 'warn' }}">{{ u.status.value }}</span></td>
<td>{{ u.trust_tier.value }}</td>
<td class="muted small">{{ u.created_at.strftime('%Y-%m-%d') }}</td>
</tr>
{% endfor %}
</table>
<div class="pager">
{% if pagination.has_prev %}<a href="{{ url_for('admin.users', **merge_query(page=pagination.prev_num)) }}">&larr; {{ _('Prev') }}</a>{% endif %}
{% if pagination.has_next %}<a href="{{ url_for('admin.users', **merge_query(page=pagination.next_num)) }}">{{ _('Next') }} &rarr;</a>{% endif %}
</div>
</div>
{% endblock %}
+3
View File
@@ -24,6 +24,9 @@
{% if unread_count %}<span class="badge-count">{{ unread_count }}</span>{% endif %} {% if unread_count %}<span class="badge-count">{{ unread_count }}</span>{% endif %}
</a> </a>
<a href="{{ url_for('payments.my_billing') }}">{{ _('Billing') }}</a> <a href="{{ url_for('payments.my_billing') }}">{{ _('Billing') }}</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.dashboard') }}">{{ _('Admin') }}</a>
{% endif %}
<span class="hi">{{ current_user.display_name }}</span> <span class="hi">{{ current_user.display_name }}</span>
<a href="{{ url_for('auth.logout') }}">{{ _('Sign out') }}</a> <a href="{{ url_for('auth.logout') }}">{{ _('Sign out') }}</a>
{% else %} {% else %}
@@ -0,0 +1,55 @@
"""phase6 admin foundation: audit_logs, settings
Revision ID: 74911acb8bfa
Revises: f2d6b231e3e4
Create Date: 2026-06-16 11:31:53.135086
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '74911acb8bfa'
down_revision = 'f2d6b231e3e4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('settings',
sa.Column('key', sa.String(length=80), nullable=False),
sa.Column('value', sa.JSON(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('key')
)
op.create_table('audit_logs',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), autoincrement=True, nullable=False),
sa.Column('actor_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=True),
sa.Column('action', sa.String(length=60), nullable=False),
sa.Column('target_type', sa.String(length=40), nullable=False),
sa.Column('target_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['actor_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('audit_logs', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_audit_logs_action'), ['action'], unique=False)
batch_op.create_index(batch_op.f('ix_audit_logs_actor_id'), ['actor_id'], unique=False)
batch_op.create_index(batch_op.f('ix_audit_logs_target_id'), ['target_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('audit_logs', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_audit_logs_target_id'))
batch_op.drop_index(batch_op.f('ix_audit_logs_actor_id'))
batch_op.drop_index(batch_op.f('ix_audit_logs_action'))
op.drop_table('audit_logs')
op.drop_table('settings')
# ### end Alembic commands ###
+120
View File
@@ -478,6 +478,125 @@ def _phase5(app):
print("Phase 5 route renders: ok") print("Phase 5 route renders: ok")
def _phase6(app):
"""Phase 6 foundation: audit log, settings, admin dashboard + user mgmt."""
import re
from app.models.user import User
from app.models.plan import Plan
from app.models.listing import Listing
from app.models.audit import AuditLog
from app.models.enums import Role, UserStatus, TrustEventType
from app.services import admin_users as usvc
from app.services import admin_dashboard as dash
from app.services.settings import get_setting, set_setting
with app.app_context():
admin = User(email="admin@example.com", display_name="Admin",
role=Role.admin, email_verified=True)
admin.set_password("AdminPass123")
admin.tier_id = Plan.query.filter_by(slug="free").first().id
db.session.add(admin); db.session.commit()
target = User.query.filter_by(email="t@example.com").first()
# --- settings round-trip ---
assert get_setting("flag_threshold", 5) == 5
set_setting("flag_threshold", 3)
db.session.commit()
assert get_setting("flag_threshold") == 3
print("settings round-trip: ok")
# --- dashboard KPIs return sane values ---
assert dash.active_listings_count() >= 0
assert dash.new_users_count(30) >= 2
assert dash.mrr_cents() >= 0
assert dash.revenue_30d_cents() >= 0
assert dash.flag_queue_depth() >= 0
print("dashboard KPI helpers: ok")
# --- ban + audit log ---
usvc.set_status(target, UserStatus.banned, actor=admin)
db.session.commit()
target_fresh = db.session.get(User, target.id)
assert target_fresh.status == UserStatus.banned
assert not target_fresh.is_active
log = AuditLog.query.filter_by(target_type="user", target_id=target.id,
action="user.banned").first()
assert log is not None and log.actor_id == admin.id
print("ban + audit log: ok")
# --- tier override + audit log ---
pro_plan = Plan.query.filter_by(slug="pro").first()
usvc.set_tier(target, pro_plan, actor=admin)
db.session.commit()
assert db.session.get(User, target.id).tier_id == pro_plan.id
assert AuditLog.query.filter_by(action="user.tier_override",
target_id=target.id).count() == 1
print("tier override + audit log: ok")
# --- trust adjust + audit log ---
score_before = target.trust_score
usvc.adjust_trust(target, TrustEventType.payment, 10, actor=admin)
db.session.commit()
assert db.session.get(User, target.id).trust_score == score_before + 10
assert AuditLog.query.filter_by(action="user.trust_adjust",
target_id=target.id).count() == 1
print("trust adjust + audit log: ok")
# --- reactivate so later route checks reflect a normal account ---
usvc.set_status(target, UserStatus.active, actor=admin)
db.session.commit()
# --- route checks ---
c = app.test_client(); B = "https://localhost"
def csrf(html):
return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html).group(1)
def login(email, password):
r = c.get("/auth/login", base_url=B)
tok = csrf(r.get_data(as_text=True))
return c.post("/auth/login", base_url=B,
data={"csrf_token": tok, "email": email, "password": password},
headers={"Referer": B + "/auth/login"}, follow_redirects=True)
# non-admin gets 403
login("t@example.com", "NewPass456")
assert c.get("/admin", base_url=B).status_code == 403
print("non-admin /admin -> 403: ok")
c.get("/auth/logout", base_url=B)
# admin gets 200 on dashboard/users/user_detail
login("admin@example.com", "AdminPass123")
with app.app_context():
target_id = User.query.filter_by(email="t@example.com").first().id
for path in ("/admin", "/admin/users", f"/admin/users/{target_id}"):
code = c.get(path, base_url=B).status_code
assert code == 200, f"{path} -> {code}"
print(f"{code} {path}")
print("Phase 6 route renders: ok")
# ban via route, confirm banned user's login is rejected
r = c.get(f"/admin/users/{target_id}", base_url=B)
tok = csrf(r.get_data(as_text=True))
c.post(f"/admin/users/{target_id}/ban", base_url=B,
data={"csrf_token": tok},
headers={"Referer": B + f"/admin/users/{target_id}"},
follow_redirects=True)
with app.app_context():
assert db.session.get(User, target_id).status == UserStatus.banned
c.get("/auth/logout", base_url=B)
r = login("t@example.com", "NewPass456")
assert "suspended" in r.get_data(as_text=True).lower()
print("banned user login rejected: ok")
# restore target to active for cleanliness (not strictly needed, smoke ends here)
with app.app_context():
u = User.query.filter_by(email="t@example.com").first()
u.status = UserStatus.active
db.session.commit()
def run(): def run():
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
@@ -577,6 +696,7 @@ def run():
_phase3(app) _phase3(app)
_phase4(app) _phase4(app)
_phase5(app) _phase5(app)
_phase6(app)
print("\nALL SMOKE CHECKS PASSED") print("\nALL SMOKE CHECKS PASSED")