channelcorp/JA-TTS-Arena
2
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]) 