From 2e9025fe55f5d43d7ffb05ef12a0ac0f928bb607 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 16 Jun 2026 11:45:00 -0400 Subject: [PATCH] 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. --- app/__init__.py | 2 + app/blueprints/admin/__init__.py | 0 app/blueprints/admin/routes.py | 127 ++++++++++++++++++ app/models/__init__.py | 4 +- app/models/audit.py | 24 ++++ app/models/setting.py | 16 +++ app/services/admin_dashboard.py | 44 ++++++ app/services/admin_users.py | 47 +++++++ app/services/audit.py | 14 ++ app/services/settings.py | 18 +++ app/static/style.css | 11 ++ app/templates/admin/_nav.html | 4 + app/templates/admin/dashboard.html | 34 +++++ app/templates/admin/user_detail.html | 106 +++++++++++++++ app/templates/admin/users.html | 52 +++++++ app/templates/base.html | 3 + ...bfa_phase6_admin_foundation_audit_logs_.py | 55 ++++++++ tests/test_smoke.py | 120 +++++++++++++++++ 18 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 app/blueprints/admin/__init__.py create mode 100644 app/blueprints/admin/routes.py create mode 100644 app/models/audit.py create mode 100644 app/models/setting.py create mode 100644 app/services/admin_dashboard.py create mode 100644 app/services/admin_users.py create mode 100644 app/services/audit.py create mode 100644 app/services/settings.py create mode 100644 app/templates/admin/_nav.html create mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/admin/user_detail.html create mode 100644 app/templates/admin/users.html create mode 100644 migrations/versions/74911acb8bfa_phase6_admin_foundation_audit_logs_.py diff --git a/app/__init__.py b/app/__init__.py index a9b93af..e00f91b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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): diff --git a/app/blueprints/admin/__init__.py b/app/blueprints/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blueprints/admin/routes.py b/app/blueprints/admin/routes.py new file mode 100644 index 0000000..1c4da26 --- /dev/null +++ b/app/blueprints/admin/routes.py @@ -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/") +@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//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//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//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//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//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)) diff --git a/app/models/__init__.py b/app/models/__init__.py index 9967773..9bda8fa 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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"] diff --git a/app/models/audit.py b/app/models/audit.py new file mode 100644 index 0000000..47cf477 --- /dev/null +++ b/app/models/audit.py @@ -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"" diff --git a/app/models/setting.py b/app/models/setting.py new file mode 100644 index 0000000..985c5fa --- /dev/null +++ b/app/models/setting.py @@ -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"" diff --git a/app/services/admin_dashboard.py b/app/services/admin_dashboard.py new file mode 100644 index 0000000..7b279d7 --- /dev/null +++ b/app/services/admin_dashboard.py @@ -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() diff --git a/app/services/admin_users.py b/app/services/admin_users.py new file mode 100644 index 0000000..8be1e69 --- /dev/null +++ b/app/services/admin_users.py @@ -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}) diff --git a/app/services/audit.py b/app/services/audit.py new file mode 100644 index 0000000..c2ee0ad --- /dev/null +++ b/app/services/audit.py @@ -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, + )) diff --git a/app/services/settings.py b/app/services/settings.py new file mode 100644 index 0000000..f1be8cb --- /dev/null +++ b/app/services/settings.py @@ -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 diff --git a/app/static/style.css b/app/static/style.css index a450353..f0d89f6 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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} diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html new file mode 100644 index 0000000..345aab2 --- /dev/null +++ b/app/templates/admin/_nav.html @@ -0,0 +1,4 @@ + diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..c2847c5 --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Dashboard') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Dashboard') }}

+
+
+
{{ _('Active listings') }}
+
{{ kpis.active_listings }}
+
+
+
{{ _('New users (7d)') }}
+
{{ kpis.new_users_7d }}
+
+
+
{{ _('New users (30d)') }}
+
{{ kpis.new_users_30d }}
+
+
+
{{ _('MRR') }}
+
${{ "%.2f"|format(kpis.mrr_cents / 100) }}
+
+
+
{{ _('Revenue (30d): subscriptions + boosts') }}
+
${{ "%.2f"|format(kpis.revenue_30d_cents / 100) }}
+
+
+
{{ _('Flag queue depth') }}
+
{{ kpis.flag_queue_depth }}
+
+
+
+{% endblock %} diff --git a/app/templates/admin/user_detail.html b/app/templates/admin/user_detail.html new file mode 100644 index 0000000..bf07da7 --- /dev/null +++ b/app/templates/admin/user_detail.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · %(email)s', email=user.email) }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} + +
+

{{ user.display_name }} <{{ user.email }}>

+ + + + + + + + + +
{{ _('Role') }}{{ user.role.value }}
{{ _('Status') }}{{ user.status.value }}
{{ _('Trust') }}{{ user.trust_tier.value }} ({{ user.trust_score }})
{{ _('Tier') }}{{ user.tier.name if user.tier else '—' }}
{{ _('Email verified') }}{{ _('Yes') if user.email_verified else _('No') }}
{{ _('Verified badge') }}{{ _('Yes') if user.verified else _('No') }}
{{ _('Last login') }}{{ user.last_login_at or '—' }}
{{ _('Joined') }}{{ user.created_at.strftime('%Y-%m-%d') }}
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

{{ _('Tier override') }}

+
+ + + +
+
+ +
+

{{ _('Adjust trust') }}

+
+ + + + +
+
+ +
+

{{ _('Listings') }}

+ {% if not listings %}

{{ _('No listings.') }}

{% endif %} + + {% for l in listings %} + + + + + + {% endfor %} +
{{ l.title }}{{ l.status.value }}{{ l.created_at.strftime('%Y-%m-%d') }}
+
+ +
+

{{ _('Trust events') }}

+ {% if not trust_events %}

{{ _('None.') }}

{% endif %} + + {% for ev in trust_events %} + + + + + + {% endfor %} +
{{ ev.type.value }}{{ '%+d'|format(ev.delta) }}{{ ev.created_at.strftime('%Y-%m-%d %H:%M') }}
+
+ +
+

{{ _('Audit history') }}

+ {% if not audit_history %}

{{ _('None.') }}

{% endif %} + + {% for a in audit_history %} + + + + + + + {% endfor %} +
{{ a.action }}{{ a.actor.display_name if a.actor else _('system') }}{{ a.meta|tojson if a.meta else '' }}{{ a.created_at.strftime('%Y-%m-%d %H:%M') }}
+
+{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html new file mode 100644 index 0000000..7257d0d --- /dev/null +++ b/app/templates/admin/users.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Users') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Users') }}

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {% if not pagination.items %}

{{ _('No users found.') }}

{% endif %} + + {% for u in pagination.items %} + + + + + + + + + {% endfor %} +
{{ u.email }}{{ u.display_name }}{{ u.role.value }}{{ u.status.value }}{{ u.trust_tier.value }}{{ u.created_at.strftime('%Y-%m-%d') }}
+ +
+ {% if pagination.has_prev %}← {{ _('Prev') }}{% endif %} + {% if pagination.has_next %}{{ _('Next') }} →{% endif %} +
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index bebaa59..081e467 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -24,6 +24,9 @@ {% if unread_count %}{{ unread_count }}{% endif %} {{ _('Billing') }} + {% if current_user.is_admin %} + {{ _('Admin') }} + {% endif %} {{ current_user.display_name }} {{ _('Sign out') }} {% else %} diff --git a/migrations/versions/74911acb8bfa_phase6_admin_foundation_audit_logs_.py b/migrations/versions/74911acb8bfa_phase6_admin_foundation_audit_logs_.py new file mode 100644 index 0000000..f0dbf00 --- /dev/null +++ b/migrations/versions/74911acb8bfa_phase6_admin_foundation_audit_logs_.py @@ -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 ### diff --git a/tests/test_smoke.py b/tests/test_smoke.py index bc941ed..7cd70c8 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -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")