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:
@@ -55,6 +55,7 @@ def _register_blueprints(app):
|
||||
from app.blueprints.messaging.routes import messaging_bp
|
||||
from app.blueprints.payments.routes import payments_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(auth_bp)
|
||||
app.register_blueprint(i18n_bp)
|
||||
@@ -62,6 +63,7 @@ def _register_blueprints(app):
|
||||
app.register_blueprint(messaging_bp)
|
||||
app.register_blueprint(payments_bp)
|
||||
app.register_blueprint(ads_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
|
||||
def _register_errorhandlers(app):
|
||||
|
||||
@@ -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))
|
||||
@@ -9,8 +9,10 @@ from app.models.messaging import Conversation, Message
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.payments import Subscription, Transaction, Boost
|
||||
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",
|
||||
"ListingImage", "ZipGeo", "Metro", "Conversation", "Message",
|
||||
"Favorite", "Subscription", "Transaction", "Boost",
|
||||
"Ad", "Sponsor", "PromotedKeyword"]
|
||||
"Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting"]
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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
|
||||
@@ -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-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}
|
||||
|
||||
/* --- 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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 }} <{{ user.email }}></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 %}
|
||||
@@ -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)) }}">← {{ _('Prev') }}</a>{% endif %}
|
||||
{% if pagination.has_next %}<a href="{{ url_for('admin.users', **merge_query(page=pagination.next_num)) }}">{{ _('Next') }} →</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -24,6 +24,9 @@
|
||||
{% if unread_count %}<span class="badge-count">{{ unread_count }}</span>{% endif %}
|
||||
</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>
|
||||
<a href="{{ url_for('auth.logout') }}">{{ _('Sign out') }}</a>
|
||||
{% 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 ###
|
||||
@@ -478,6 +478,125 @@ def _phase5(app):
|
||||
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():
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
@@ -577,6 +696,7 @@ def run():
|
||||
_phase3(app)
|
||||
_phase4(app)
|
||||
_phase5(app)
|
||||
_phase6(app)
|
||||
|
||||
print("\nALL SMOKE CHECKS PASSED")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user