CoolFace
Apppublic

channelcorp/JA-TTS-Arena

sourceHugging Faceupdated 9mo agoView on Hugging Face
2likes
admin.py891 linesDownload Raw Back to root
1from flask import Blueprint, render_template, current_app, jsonify, request, redirect, url_for, flash2from models import (3    db, User, Model, Vote, EloHistory, ModelType, 4    CoordinatedVotingCampaign, CampaignParticipant, UserTimeout,5    get_user_timeouts, get_coordinated_campaigns, resolve_campaign,6    create_user_timeout, cancel_user_timeout, check_user_timeout7)8from auth import admin_required9from security import check_user_security_score10from sqlalchemy import func, desc, extract, text11from datetime import datetime, timedelta12import json13import os14from sqlalchemy import or_15 16admin = Blueprint("admin", __name__, url_prefix="/admin")17 18@admin.route("/")19@admin_required20def index():21    """Admin dashboard homepage"""22    # Get count statistics23    stats = {24        "total_users": User.query.count(),25        "total_votes": Vote.query.count(),26        "tts_votes": Vote.query.filter_by(model_type=ModelType.TTS).count(),27        "conversational_votes": Vote.query.filter_by(model_type=ModelType.CONVERSATIONAL).count(),28        "tts_models": Model.query.filter_by(model_type=ModelType.TTS).count(),29        "conversational_models": Model.query.filter_by(model_type=ModelType.CONVERSATIONAL).count(),30    }31    32    # Get recent votes33    recent_votes = Vote.query.order_by(Vote.vote_date.desc()).limit(10).all()34    35    # Get recent users36    recent_users = User.query.order_by(User.join_date.desc()).limit(10).all()37    38    # Get daily votes for the past 30 days39    thirty_days_ago = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)40    41    daily_votes = db.session.query(42        func.date(Vote.vote_date).label('date'),43        func.count().label('count')44    ).filter(Vote.vote_date >= thirty_days_ago).group_by(45        func.date(Vote.vote_date)46    ).order_by(func.date(Vote.vote_date)).all()47    48    # Generate a complete list of dates for the past 30 days49    date_list = []50    current_date = datetime.utcnow()51    for i in range(30, -1, -1):52        date_list.append((current_date - timedelta(days=i)).date())53    54    # Create a dictionary with actual vote counts55    vote_counts = {day.date: day.count for day in daily_votes}56    57    # Build complete datasets including days with zero votes58    formatted_dates = [date.strftime("%Y-%m-%d") for date in date_list]59    vote_counts_list = [vote_counts.get(date, 0) for date in date_list]60    61    daily_votes_data = {62        "labels": formatted_dates,63        "counts": vote_counts_list64    }65    66    # Get top models67    top_tts_models = Model.query.filter_by(68        model_type=ModelType.TTS69    ).order_by(Model.current_elo.desc()).limit(5).all()70    71    top_conversational_models = Model.query.filter_by(72        model_type=ModelType.CONVERSATIONAL73    ).order_by(Model.current_elo.desc()).limit(5).all()74    75    return render_template(76        "admin/index.html",77        stats=stats,78        recent_votes=recent_votes,79        recent_users=recent_users,80        daily_votes_data=json.dumps(daily_votes_data),81        top_tts_models=top_tts_models,82        top_conversational_models=top_conversational_models83    )84 85@admin.route("/models")86@admin_required87def models():88    """Manage models"""89    tts_models = Model.query.filter_by(model_type=ModelType.TTS).order_by(Model.name).all()90    conversational_models = Model.query.filter_by(model_type=ModelType.CONVERSATIONAL).order_by(Model.name).all()91    92    return render_template(93        "admin/models.html",94        tts_models=tts_models,95        conversational_models=conversational_models96    )97 98 99@admin.route("/model/<model_id>", methods=["GET", "POST"])100@admin_required101def edit_model(model_id):102    """Edit a model"""103    model = Model.query.get_or_404(model_id)104    105    if request.method == "POST":106        model.name = request.form.get("name")107        model.is_active = "is_active" in request.form108        model.is_open = "is_open" in request.form109        model.model_url = request.form.get("model_url")110        111        db.session.commit()112        flash(f"Model '{model.name}' updated successfully", "success")113        return redirect(url_for("admin.models"))114    115    return render_template("admin/edit_model.html", model=model)116 117@admin.route("/users")118@admin_required119def users():120    """Manage users"""121    users = User.query.order_by(User.username).all()122    admin_users = os.getenv("ADMIN_USERS", "").split(",")123    admin_users = [username.strip() for username in admin_users]124    125    # Calculate security scores for all users126    users_with_scores = []127    for user in users:128        score, factors = check_user_security_score(user.id)129        users_with_scores.append({130            'user': user,131            'security_score': score,132            'security_factors': factors133        })134    135    # Sort by security score (lowest first to highlight problematic users)136    users_with_scores.sort(key=lambda x: x['security_score'])137    138    return render_template("admin/users.html", users_with_scores=users_with_scores, admin_users=admin_users)139 140@admin.route("/user/<int:user_id>")141@admin_required142def user_detail(user_id):143    """View user details"""144    user = User.query.get_or_404(user_id)145    146    # Get security score and factors147    security_score, security_factors = check_user_security_score(user_id)148    149    # Get user votes150    recent_votes = Vote.query.filter_by(user_id=user_id).order_by(Vote.vote_date.desc()).limit(20).all()151    152    # Get vote statistics153    tts_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.TTS).count()154    conversational_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.CONVERSATIONAL).count()155    156    # Get comprehensive model bias analysis157    # This counts how often each model was chosen vs how often it appeared158    model_bias_analysis = []159    160    # Get all votes by this user161    user_votes = Vote.query.filter_by(user_id=user_id).all()162    163    if user_votes:164        model_stats = {}165        166        for vote in user_votes:167            # Track model_chosen168            chosen_id = vote.model_chosen169            rejected_id = vote.model_rejected170            171            # Initialize model stats if not exists172            if chosen_id not in model_stats:173                model_stats[chosen_id] = {'chosen': 0, 'appeared': 0, 'name': None}174            if rejected_id not in model_stats:175                model_stats[rejected_id] = {'chosen': 0, 'appeared': 0, 'name': None}176            177            # Count appearances and choices178            model_stats[chosen_id]['chosen'] += 1179            model_stats[chosen_id]['appeared'] += 1180            model_stats[rejected_id]['appeared'] += 1181        182        # Get model names and calculate bias ratios183        for model_id, stats in model_stats.items():184            model = Model.query.get(model_id)185            if model:186                stats['name'] = model.name187                stats['bias_ratio'] = stats['chosen'] / stats['appeared'] if stats['appeared'] > 0 else 0188                stats['model_id'] = model_id189        190        # Sort by bias ratio (highest bias first) and take top 5191        model_bias_analysis = sorted(192            [stats for stats in model_stats.values() if stats['name'] is not None],193            key=lambda x: x['bias_ratio'],194            reverse=True195        )[:5]196    197    return render_template(198        "admin/user_detail.html",199        user=user,200        security_score=security_score,201        security_factors=security_factors,202        recent_votes=recent_votes,203        tts_votes=tts_votes,204        conversational_votes=conversational_votes,205        model_bias_analysis=model_bias_analysis,206        total_votes=tts_votes + conversational_votes207    )208 209@admin.route("/votes")210@admin_required211def votes():212    """View recent votes"""213    page = request.args.get('page', 1, type=int)214    per_page = 50215    216    # Get votes with pagination217    votes_pagination = Vote.query.order_by(218        Vote.vote_date.desc()219    ).paginate(page=page, per_page=per_page)220    221    return render_template(222        "admin/votes.html",223        votes=votes_pagination.items,224        pagination=votes_pagination225    )226 227@admin.route("/statistics")228@admin_required229def statistics():230    """View detailed statistics"""231    # Get daily votes for the past 30 days by model type232    thirty_days_ago = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)233    234    tts_daily_votes = db.session.query(235        func.date(Vote.vote_date).label('date'),236        func.count().label('count')237    ).filter(238        Vote.vote_date >= thirty_days_ago,239        Vote.model_type == ModelType.TTS240    ).group_by(241        func.date(Vote.vote_date)242    ).order_by(func.date(Vote.vote_date)).all()243    244    conv_daily_votes = db.session.query(245        func.date(Vote.vote_date).label('date'),246        func.count().label('count')247    ).filter(248        Vote.vote_date >= thirty_days_ago,249        Vote.model_type == ModelType.CONVERSATIONAL250    ).group_by(251        func.date(Vote.vote_date)252    ).order_by(func.date(Vote.vote_date)).all()253    254    # Monthly new users255    monthly_users = db.session.query(256        extract('year', User.join_date).label('year'),257        extract('month', User.join_date).label('month'),258        func.count().label('count')259    ).group_by(260        'year', 'month'261    ).order_by('year', 'month').all()262    263    # Generate a complete list of dates for the past 30 days264    date_list = []265    current_date = datetime.utcnow()266    for i in range(30, -1, -1):267        date_list.append((current_date - timedelta(days=i)).date())268    269    # Create dictionaries with actual vote counts270    tts_vote_counts = {day.date: day.count for day in tts_daily_votes}271    conv_vote_counts = {day.date: day.count for day in conv_daily_votes}272    273    # Format dates consistently for charts274    formatted_dates = [date.strftime("%Y-%m-%d") for date in date_list]275    276    # Build complete datasets including days with zero votes277    tts_counts = [tts_vote_counts.get(date, 0) for date in date_list]278    conv_counts = [conv_vote_counts.get(date, 0) for date in date_list]279    280    # Generate all month/year combinations for the past 12 months281    current_date = datetime.utcnow()282    month_list = []283    for i in range(11, -1, -1):284        past_date = current_date - timedelta(days=i*30)  # Approximate285        month_list.append((past_date.year, past_date.month))286    287    # Create a dictionary with actual user counts288    user_counts = {(record.year, record.month): record.count for record in monthly_users}289    290    # Build complete monthly datasets including months with zero new users291    monthly_labels = [f"{month}/{year}" for year, month in month_list]292    monthly_counts = [user_counts.get((year, month), 0) for year, month in month_list]293    294    # Model performance over time295    top_models = Model.query.order_by(Model.match_count.desc()).limit(5).all()296    297    # Get first and last timestamp to create a consistent timeline298    earliest = datetime.utcnow() - timedelta(days=30)  # Default to 30 days ago299    latest = datetime.utcnow()  # Default to now300    301    # Find actual earliest and latest timestamps across all models302    has_elo_history = False303    for model in top_models:304        first = EloHistory.query.filter_by(model_id=model.id).order_by(EloHistory.timestamp).first()305        last = EloHistory.query.filter_by(model_id=model.id).order_by(EloHistory.timestamp.desc()).first()306        307        if first and last:308            has_elo_history = True309            if first.timestamp < earliest:310                earliest = first.timestamp311            if last.timestamp > latest:312                latest = last.timestamp313    314    # If no history was found, use a default range of the last 30 days315    if not has_elo_history:316        earliest = datetime.utcnow() - timedelta(days=30)317        latest = datetime.utcnow()318    319    # Make sure the date range is valid (earliest before latest)320    if earliest > latest:321        earliest = latest - timedelta(days=30)322    323    # Generate a list of dates for the ELO history timeline324    # Using 1-day intervals for a smoother chart325    elo_dates = []326    current = earliest327    while current <= latest:328        elo_dates.append(current.date())329        current += timedelta(days=1)330    331    # Format dates consistently332    formatted_elo_dates = [date.strftime("%Y-%m-%d") for date in elo_dates]333    334    model_history = {}335    336    # Initialize empty data for all top models337    for model in top_models:338        model_history[model.name] = {339            "timestamps": formatted_elo_dates,340            "scores": [None] * len(formatted_elo_dates)  # Initialize with None values341        }342        343        history = EloHistory.query.filter_by(344            model_id=model.id345        ).order_by(EloHistory.timestamp).all()346        347        if history:348            # Create a dictionary mapping dates to scores349            history_dict = {}350            for h in history:351                date_key = h.timestamp.date().strftime("%Y-%m-%d")352                history_dict[date_key] = h.elo_score353            354            # Fill in missing dates with the previous score355            last_score = model.current_elo  # Default to current ELO if no history356            scores = []357            358            for date in formatted_elo_dates:359                if date in history_dict:360                    last_score = history_dict[date]361                scores.append(last_score)362            363            model_history[model.name]["scores"] = scores364        else:365            # If no history, use the current Elo for all dates366            model_history[model.name]["scores"] = [model.current_elo] * len(formatted_elo_dates)367    368    chart_data = {369        "dailyVotes": {370            "labels": formatted_dates,371            "ttsCounts": tts_counts,372            "convCounts": conv_counts373        },374        "monthlyUsers": {375            "labels": monthly_labels,376            "counts": monthly_counts377        },378        "modelHistory": model_history379    }380    381    return render_template(382        "admin/statistics.html",383        chart_data=json.dumps(chart_data)384    )385 386@admin.route("/activity")387@admin_required388def activity():389    """View recent text generations"""390    # Check if we have any active sessions from app.py391    tts_session_count = 0392    conversational_session_count = 0393    394    # Access global variables from app.py through current_app395    if hasattr(current_app, 'tts_sessions'):396        tts_session_count = len(current_app.tts_sessions)397    else:  # Try to access through app module398        from app import tts_sessions399        tts_session_count = len(tts_sessions)400    401    if hasattr(current_app, 'conversational_sessions'):402        conversational_session_count = len(current_app.conversational_sessions)403    else:  # Try to access through app module404        from app import conversational_sessions405        conversational_session_count = len(conversational_sessions)406    407    # Get recent votes which represent completed generations408    recent_tts_votes = Vote.query.filter_by(409        model_type=ModelType.TTS410    ).order_by(Vote.vote_date.desc()).limit(20).all()411    412    recent_conv_votes = Vote.query.filter_by(413        model_type=ModelType.CONVERSATIONAL414    ).order_by(Vote.vote_date.desc()).limit(20).all()415    416    # Get votes per hour for the last 24 hours417    current_time = datetime.utcnow()418    last_24h = current_time.replace(minute=0, second=0, microsecond=0) - timedelta(hours=24)419    420    # Use SQLite-compatible date formatting421    hourly_votes = db.session.query(422        func.strftime('%Y-%m-%d %H:00', Vote.vote_date).label('hour'),423        func.count().label('count')424    ).filter(425        Vote.vote_date >= last_24h426    ).group_by('hour').order_by('hour').all()427    428    # Generate all hours for the past 24 hours with correct hour formatting429    hour_list = []430    for i in range(24, -1, -1):431        # Calculate the hour time and truncate to hour432        hour_time = current_time - timedelta(hours=i)433        hour_time = hour_time.replace(minute=0, second=0, microsecond=0)434        hour_list.append(hour_time.strftime('%Y-%m-%d %H:00'))435    436    # Create a dictionary with actual vote counts437    vote_counts = {hour.hour: hour.count for hour in hourly_votes}438    439    # Build complete hourly datasets including hours with zero votes440    hourly_data = {441        "labels": hour_list,442        "counts": [vote_counts.get(hour, 0) for hour in hour_list]443    }444    445    return render_template(446        "admin/activity.html",447        tts_session_count=tts_session_count,448        conversational_session_count=conversational_session_count,449        recent_tts_votes=recent_tts_votes,450        recent_conv_votes=recent_conv_votes,451        hourly_data=json.dumps(hourly_data)452    )453 454@admin.route("/analytics")455@admin_required456def analytics():457    """View analytics data including session duration, IP addresses, etc."""458    459    # Get analytics statistics460    analytics_stats = {}461    462    try:463        # Session duration statistics464        duration_stats = db.session.execute(text("""465            SELECT 466                AVG(session_duration_seconds) as avg_duration,467                MIN(session_duration_seconds) as min_duration,468                MAX(session_duration_seconds) as max_duration,469                COUNT(session_duration_seconds) as total_with_duration470            FROM vote 471            WHERE session_duration_seconds IS NOT NULL472        """)).fetchone()473        474        analytics_stats['duration'] = {475            'avg': round(duration_stats.avg_duration, 2) if duration_stats.avg_duration else 0,476            'min': round(duration_stats.min_duration, 2) if duration_stats.min_duration else 0,477            'max': round(duration_stats.max_duration, 2) if duration_stats.max_duration else 0,478            'total': duration_stats.total_with_duration or 0479        }480        481        # Cache hit statistics482        cache_stats = db.session.execute(text("""483            SELECT 484                cache_hit,485                COUNT(*) as count486            FROM vote 487            WHERE cache_hit IS NOT NULL488            GROUP BY cache_hit489        """)).fetchall()490        491        analytics_stats['cache'] = {492            'hits': 0,493            'misses': 0,494            'total': 0495        }496        497        for stat in cache_stats:498            if stat.cache_hit:499                analytics_stats['cache']['hits'] = stat.count500            else:501                analytics_stats['cache']['misses'] = stat.count502            analytics_stats['cache']['total'] += stat.count503        504        # Top IP address regions (anonymized)505        ip_stats = db.session.execute(text("""506            SELECT 507                ip_address_partial,508                COUNT(*) as count509            FROM vote 510            WHERE ip_address_partial IS NOT NULL511            GROUP BY ip_address_partial512            ORDER BY count DESC513            LIMIT 10514        """)).fetchall()515        516        analytics_stats['top_ips'] = [517            {'ip': stat.ip_address_partial, 'count': stat.count}518            for stat in ip_stats519        ]520        521        # User agent statistics (top browsers/devices)522        ua_stats = db.session.execute(text("""523            SELECT 524                CASE 525                    WHEN user_agent LIKE '%Chrome%' THEN 'Chrome'526                    WHEN user_agent LIKE '%Firefox%' THEN 'Firefox'527                    WHEN user_agent LIKE '%Safari%' AND user_agent NOT LIKE '%Chrome%' THEN 'Safari'528                    WHEN user_agent LIKE '%Edge%' THEN 'Edge'529                    WHEN user_agent LIKE '%Mobile%' OR user_agent LIKE '%Android%' THEN 'Mobile'530                    ELSE 'Other'531                END as browser,532                COUNT(*) as count533            FROM vote 534            WHERE user_agent IS NOT NULL535            GROUP BY browser536            ORDER BY count DESC537        """)).fetchall()538        539        analytics_stats['browsers'] = [540            {'browser': stat.browser, 'count': stat.count}541            for stat in ua_stats542        ]543        544        # Recent votes with analytics data545        recent_analytics = db.session.execute(text("""546            SELECT 547                v.id,548                v.vote_date,549                v.session_duration_seconds,550                v.ip_address_partial,551                v.cache_hit,552                v.model_type,553                u.username,554                m1.name as chosen_model,555                m2.name as rejected_model556            FROM vote v557            LEFT JOIN user u ON v.user_id = u.id558            LEFT JOIN model m1 ON v.model_chosen = m1.id559            LEFT JOIN model m2 ON v.model_rejected = m2.id560            WHERE v.session_duration_seconds IS NOT NULL561            ORDER BY v.vote_date DESC562            LIMIT 20563        """)).fetchall()564        565        analytics_stats['recent_votes'] = [566            {567                'id': vote.id,568                'vote_date': vote.vote_date if isinstance(vote.vote_date, datetime) else datetime.fromisoformat(str(vote.vote_date).replace('Z', '+00:00')) if vote.vote_date else None,569                'duration': round(vote.session_duration_seconds, 2) if vote.session_duration_seconds else None,570                'ip': vote.ip_address_partial,571                'cache_hit': vote.cache_hit,572                'model_type': vote.model_type,573                'username': vote.username,574                'chosen_model': vote.chosen_model,575                'rejected_model': vote.rejected_model576            }577            for vote in recent_analytics578        ]579        580    except Exception as e:581        flash(f"Error retrieving analytics data: {str(e)}", "error")582        analytics_stats = {}583    584    return render_template(585        "admin/analytics.html", 586        analytics_stats=analytics_stats587    )588 589@admin.route("/security")590@admin_required591def security():592    """View security monitoring data and suspicious activity."""593    try:594        from security import (595            detect_suspicious_voting_patterns, 596            detect_coordinated_voting, 597            check_user_security_score,598            detect_model_bias599        )600        601        # Get recent suspicious users602        recent_users = User.query.order_by(User.join_date.desc()).limit(50).all()603        suspicious_users = []604        605        for user in recent_users:606            score, factors = check_user_security_score(user.id)607            if score < 50:  # Flag users with low security scores608                suspicious_users.append({609                    'user': user,610                    'score': score,611                    'factors': factors612                })613        614        # Sort by lowest score first615        suspicious_users.sort(key=lambda x: x['score'])616        617        # Check for coordinated voting on top models618        top_models = Model.query.order_by(Model.current_elo.desc()).limit(10).all()619        coordinated_campaigns = []620        621        for model in top_models:622            is_coordinated, user_count, vote_count, suspicious_users_list = detect_coordinated_voting(model.id)623            if is_coordinated:624                coordinated_campaigns.append({625                    'model': model,626                    'user_count': user_count,627                    'vote_count': vote_count,628                    'suspicious_users': suspicious_users_list629                })630        631        # Get users with high model bias632        biased_users = []633        for model in top_models:634            # Check recent voters for this model635            recent_voters = db.session.query(Vote.user_id).filter(636                Vote.model_chosen == model.id637            ).distinct().limit(20).all()638            639            for voter in recent_voters:640                if voter.user_id:641                    is_biased, bias_ratio, votes_for_model, total_votes = detect_model_bias(642                        voter.user_id, model.id643                    )644                    if is_biased and total_votes >= 5:645                        user = User.query.get(voter.user_id)646                        if user:647                            biased_users.append({648                                'user': user,649                                'model': model,650                                'bias_ratio': bias_ratio,651                                'votes_for_model': votes_for_model,652                                'total_votes': total_votes653                            })654        655        # Remove duplicates and sort by bias ratio656        seen_users = set()657        unique_biased_users = []658        for item in biased_users:659            user_model_key = (item['user'].id, item['model'].id)660            if user_model_key not in seen_users:661                seen_users.add(user_model_key)662                unique_biased_users.append(item)663        664        unique_biased_users.sort(key=lambda x: x['bias_ratio'], reverse=True)665        666        # Get recent security blocks from logs (if available)667        security_blocks = []668        try:669            # This would require parsing application logs670            # For now, we'll show a placeholder671            pass672        except Exception:673            pass674        675        return render_template(676            "admin/security.html",677            suspicious_users=suspicious_users[:20],  # Limit to top 20678            coordinated_campaigns=coordinated_campaigns,679            biased_users=unique_biased_users[:20],  # Limit to top 20680            security_blocks=security_blocks681        )682        683    except ImportError:684        flash("Security module not available", "error")685        return redirect(url_for("admin.index"))686    except Exception as e:687        flash(f"Error loading security data: {str(e)}", "error")688        return redirect(url_for("admin.index"))689 690 691@admin.route("/timeouts")692@admin_required693def timeouts():694    """Manage user timeouts"""695    # Get active timeouts696    active_timeouts = get_user_timeouts(active_only=True, limit=100)697    698    # Get recent expired/cancelled timeouts699    recent_inactive = UserTimeout.query.filter(700        or_(701            UserTimeout.is_active == False,702            UserTimeout.expires_at <= datetime.utcnow()703        )704    ).order_by(UserTimeout.created_at.desc()).limit(50).all()705    706    # Get coordinated campaigns for context707    recent_campaigns = get_coordinated_campaigns(limit=20)708    709    return render_template(710        "admin/timeouts.html",711        active_timeouts=active_timeouts,712        recent_inactive=recent_inactive,713        recent_campaigns=recent_campaigns714    )715 716 717@admin.route("/timeout/create", methods=["POST"])718@admin_required719def create_timeout():720    """Create a new user timeout"""721    try:722        user_id = request.form.get("user_id", type=int)723        reason = request.form.get("reason", "").strip()724        timeout_type = request.form.get("timeout_type", "manual")725        duration_days = request.form.get("duration_days", type=int)726        727        if not all([user_id, reason, duration_days]):728            flash("All fields are required", "error")729            return redirect(url_for("admin.timeouts"))730        731        if duration_days < 1 or duration_days > 365:732            flash("Duration must be between 1 and 365 days", "error")733            return redirect(url_for("admin.timeouts"))734        735        # Check if user exists736        user = User.query.get(user_id)737        if not user:738            flash("User not found", "error")739            return redirect(url_for("admin.timeouts"))740        741        # Check if user already has an active timeout742        is_timed_out, existing_timeout = check_user_timeout(user_id)743        if is_timed_out:744            flash(f"User {user.username} already has an active timeout until {existing_timeout.expires_at}", "error")745            return redirect(url_for("admin.timeouts"))746        747        # Create timeout748        from flask_login import current_user749        timeout = create_user_timeout(750            user_id=user_id,751            reason=reason,752            timeout_type=timeout_type,753            duration_days=duration_days,754            created_by=current_user.id if current_user.is_authenticated else None755        )756        757        flash(f"Timeout created for {user.username} (expires: {timeout.expires_at})", "success")758        759    except Exception as e:760        flash(f"Error creating timeout: {str(e)}", "error")761    762    return redirect(url_for("admin.timeouts"))763 764 765@admin.route("/timeout/cancel/<int:timeout_id>", methods=["POST"])766@admin_required767def cancel_timeout(timeout_id):768    """Cancel an active timeout"""769    try:770        cancel_reason = request.form.get("cancel_reason", "").strip()771        if not cancel_reason:772            flash("Cancel reason is required", "error")773            return redirect(url_for("admin.timeouts"))774        775        from flask_login import current_user776        success, message = cancel_user_timeout(777            timeout_id=timeout_id,778            cancelled_by=current_user.id if current_user.is_authenticated else None,779            cancel_reason=cancel_reason780        )781        782        if success:783            flash(message, "success")784        else:785            flash(message, "error")786            787    except Exception as e:788        flash(f"Error cancelling timeout: {str(e)}", "error")789    790    return redirect(url_for("admin.timeouts"))791 792 793@admin.route("/campaigns")794@admin_required795def campaigns():796    """View and manage coordinated voting campaigns"""797    status_filter = request.args.get("status", "all")798    799    if status_filter == "all":800        campaigns = get_coordinated_campaigns(limit=100)801    else:802        campaigns = get_coordinated_campaigns(status=status_filter, limit=100)803    804    # Get campaign statistics805    stats = {806        "total": CoordinatedVotingCampaign.query.count(),807        "active": CoordinatedVotingCampaign.query.filter_by(status="active").count(),808        "resolved": CoordinatedVotingCampaign.query.filter_by(status="resolved").count(),809        "false_positive": CoordinatedVotingCampaign.query.filter_by(status="false_positive").count(),810    }811    812    return render_template(813        "admin/campaigns.html",814        campaigns=campaigns,815        stats=stats,816        current_filter=status_filter817    )818 819 820@admin.route("/campaign/<int:campaign_id>")821@admin_required822def campaign_detail(campaign_id):823    """View detailed information about a coordinated voting campaign"""824    campaign = CoordinatedVotingCampaign.query.get_or_404(campaign_id)825    826    # Get participants with user details827    participants = db.session.query(CampaignParticipant, User).join(828        User, CampaignParticipant.user_id == User.id829    ).filter(CampaignParticipant.campaign_id == campaign_id).all()830    831    # Get related timeouts832    related_timeouts = UserTimeout.query.filter_by(833        related_campaign_id=campaign_id834    ).all()835    836    return render_template(837        "admin/campaign_detail.html",838        campaign=campaign,839        participants=participants,840        related_timeouts=related_timeouts841    )842 843 844@admin.route("/campaign/resolve/<int:campaign_id>", methods=["POST"])845@admin_required846def resolve_campaign_route(campaign_id):847    """Mark a campaign as resolved"""848    try:849        status = request.form.get("status")850        admin_notes = request.form.get("admin_notes", "").strip()851        852        if status not in ["resolved", "false_positive"]:853            flash("Invalid status", "error")854            return redirect(url_for("admin.campaign_detail", campaign_id=campaign_id))855        856        from flask_login import current_user857        success, message = resolve_campaign(858            campaign_id=campaign_id,859            resolved_by=current_user.id if current_user.is_authenticated else None,860            status=status,861            admin_notes=admin_notes862        )863        864        if success:865            flash(f"Campaign marked as {status}", "success")866        else:867            flash(message, "error")868            869    except Exception as e:870        flash(f"Error resolving campaign: {str(e)}", "error")871    872    return redirect(url_for("admin.campaign_detail", campaign_id=campaign_id))873 874 875@admin.route("/api/user-search")876@admin_required877def user_search():878    """Search for users by username (for timeout creation)"""879    query = request.args.get("q", "").strip()880    if len(query) < 2:881        return jsonify([])882    883    users = User.query.filter(884        User.username.ilike(f"%{query}%")885    ).limit(10).all()886    887    return jsonify([{888        "id": user.id,889        "username": user.username,890        "join_date": user.join_date.strftime("%Y-%m-%d") if user.join_date else "N/A"891    } for user in users])