GitHub Actions commited on
Commit
5582677
·
1 Parent(s): b3bb733

Sync from GitHub repo

Browse files
admin.py CHANGED
@@ -1,11 +1,17 @@
1
  from flask import Blueprint, render_template, current_app, jsonify, request, redirect, url_for, flash
2
- from models import db, User, Model, Vote, EloHistory, ModelType
 
 
 
 
 
3
  from auth import admin_required
4
  from security import check_user_security_score
5
  from sqlalchemy import func, desc, extract, text
6
  from datetime import datetime, timedelta
7
  import json
8
  import os
 
9
 
10
  admin = Blueprint("admin", __name__, url_prefix="/admin")
11
 
@@ -679,4 +685,207 @@ def security():
679
  return redirect(url_for("admin.index"))
680
  except Exception as e:
681
  flash(f"Error loading security data: {str(e)}", "error")
682
- return redirect(url_for("admin.index"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from flask import Blueprint, render_template, current_app, jsonify, request, redirect, url_for, flash
2
+ from 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_timeout
7
+ )
8
  from auth import admin_required
9
  from security import check_user_security_score
10
  from sqlalchemy import func, desc, extract, text
11
  from datetime import datetime, timedelta
12
  import json
13
  import os
14
+ from sqlalchemy import or_
15
 
16
  admin = Blueprint("admin", __name__, url_prefix="/admin")
17
 
 
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_required
693
+ def timeouts():
694
+ """Manage user timeouts"""
695
+ # Get active timeouts
696
+ active_timeouts = get_user_timeouts(active_only=True, limit=100)
697
+
698
+ # Get recent expired/cancelled timeouts
699
+ 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 context
707
+ 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_campaigns
714
+ )
715
+
716
+
717
+ @admin.route("/timeout/create", methods=["POST"])
718
+ @admin_required
719
+ def 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 exists
736
+ 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 timeout
742
+ 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 timeout
748
+ from flask_login import current_user
749
+ 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 None
755
+ )
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_required
767
+ def 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_user
776
+ 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_reason
780
+ )
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_required
795
+ def 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 statistics
805
+ 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_filter
817
+ )
818
+
819
+
820
+ @admin.route("/campaign/<int:campaign_id>")
821
+ @admin_required
822
+ def 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 details
827
+ participants = db.session.query(CampaignParticipant, User).join(
828
+ User, CampaignParticipant.user_id == User.id
829
+ ).filter(CampaignParticipant.campaign_id == campaign_id).all()
830
+
831
+ # Get related timeouts
832
+ related_timeouts = UserTimeout.query.filter_by(
833
+ related_campaign_id=campaign_id
834
+ ).all()
835
+
836
+ return render_template(
837
+ "admin/campaign_detail.html",
838
+ campaign=campaign,
839
+ participants=participants,
840
+ related_timeouts=related_timeouts
841
+ )
842
+
843
+
844
+ @admin.route("/campaign/resolve/<int:campaign_id>", methods=["POST"])
845
+ @admin_required
846
+ def 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_user
857
+ 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_notes
862
+ )
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_required
877
+ def 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])
app.py CHANGED
@@ -1,33 +1,1572 @@
1
- from flask import Flask, render_template_string
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  app = Flask(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- HTML = """
6
- <!DOCTYPE html>
7
- <html lang="en">
8
- <head>
9
- <meta charset="UTF-8">
10
- <title>Maintenance</title>
11
- <meta name="viewport" content="width=device-width, initial-scale=1">
12
- <script src="https://cdn.tailwindcss.com"></script>
13
- </head>
14
- <body class="bg-gray-100 flex items-center justify-center h-screen">
15
- <div class="bg-white p-8 rounded-2xl shadow-lg text-center max-w-md">
16
- <svg class="mx-auto mb-4 w-16 h-16 text-yellow-500" fill="none" stroke="currentColor" stroke-width="1.5"
17
- viewBox="0 0 24 24">
18
- <path stroke-linecap="round" stroke-linejoin="round"
19
- d="M12 9v2m0 4h.01M4.93 4.93a10 10 0 0114.14 0 10 10 0 010 14.14 10 10 0 01-14.14 0 10 10 0 010-14.14z"/>
20
- </svg>
21
- <h1 class="text-2xl font-bold text-gray-800 mb-2">We'll be back soon!</h1>
22
- <p class="text-gray-600">The TTS Arena is temporarily undergoing maintenance.<br>Thank you for your patience.</p>
23
- </div>
24
- </body>
25
- </html>
26
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  @app.route("/")
29
- def maintenance():
30
- return render_template_string(HTML)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  if __name__ == "__main__":
33
- app.run(host="0.0.0.0", port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from huggingface_hub import HfApi, hf_hub_download
3
+ from apscheduler.schedulers.background import BackgroundScheduler
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from datetime import datetime
6
+ import threading # Added for locking
7
+ from sqlalchemy import or_ # Added for vote counting query
8
+
9
+ year = datetime.now().year
10
+ month = datetime.now().month
11
+
12
+ # Check if running in a Huggin Face Space
13
+ IS_SPACES = False
14
+ if os.getenv("SPACE_REPO_NAME"):
15
+ print("Running in a Hugging Face Space 🤗")
16
+ IS_SPACES = True
17
+
18
+ # Setup database sync for HF Spaces
19
+ if not os.path.exists("instance/tts_arena.db"):
20
+ os.makedirs("instance", exist_ok=True)
21
+ try:
22
+ print("Database not found, downloading from HF dataset...")
23
+ hf_hub_download(
24
+ repo_id="TTS-AGI/database-arena-v2",
25
+ filename="tts_arena.db",
26
+ repo_type="dataset",
27
+ local_dir="instance",
28
+ token=os.getenv("HF_TOKEN"),
29
+ )
30
+ print("Database downloaded successfully ✅")
31
+ except Exception as e:
32
+ print(f"Error downloading database from HF dataset: {str(e)} ⚠️")
33
+
34
+ from flask import (
35
+ Flask,
36
+ render_template,
37
+ g,
38
+ request,
39
+ jsonify,
40
+ send_file,
41
+ redirect,
42
+ url_for,
43
+ session,
44
+ abort,
45
+ )
46
+ from flask_login import LoginManager, current_user
47
+ from models import *
48
+ from auth import auth, init_oauth, is_admin
49
+ from admin import admin
50
+ from security import is_vote_allowed, check_user_security_score, detect_coordinated_voting
51
+ import os
52
+ from dotenv import load_dotenv
53
+ from flask_limiter import Limiter
54
+ from flask_limiter.util import get_remote_address
55
+ import uuid
56
+ import tempfile
57
+ import shutil
58
+ from tts import predict_tts
59
+ import random
60
+ import json
61
+ from datetime import datetime, timedelta
62
+ from flask_migrate import Migrate
63
+ import requests
64
+ import functools
65
+ import time # Added for potential retries
66
+
67
+
68
+ def get_client_ip():
69
+ """Get the client's IP address, handling proxies and load balancers."""
70
+ # Check for forwarded headers first (common with reverse proxies)
71
+ if request.headers.get('X-Forwarded-For'):
72
+ # X-Forwarded-For can contain multiple IPs, take the first one
73
+ return request.headers.get('X-Forwarded-For').split(',')[0].strip()
74
+ elif request.headers.get('X-Real-IP'):
75
+ return request.headers.get('X-Real-IP')
76
+ elif request.headers.get('CF-Connecting-IP'): # Cloudflare
77
+ return request.headers.get('CF-Connecting-IP')
78
+ else:
79
+ return request.remote_addr
80
+
81
+
82
+ # Load environment variables
83
+ if not IS_SPACES:
84
+ load_dotenv() # Only load .env if not running in a Hugging Face Space
85
 
86
  app = Flask(__name__)
87
+ app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", os.urandom(24))
88
+ app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv(
89
+ "DATABASE_URI", "sqlite:///tts_arena.db"
90
+ )
91
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
92
+ app.config["SESSION_COOKIE_SECURE"] = True
93
+ app.config["SESSION_COOKIE_SAMESITE"] = (
94
+ "None" if IS_SPACES else "Lax"
95
+ ) # HF Spaces uses iframes to load the app, so we need to set SAMESITE to None
96
+ app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) # Set to desired duration
97
+
98
+ # Force HTTPS when running in HuggingFace Spaces
99
+ if IS_SPACES:
100
+ app.config["PREFERRED_URL_SCHEME"] = "https"
101
+
102
+ # Cloudflare Turnstile settings
103
+ app.config["TURNSTILE_ENABLED"] = (
104
+ os.getenv("TURNSTILE_ENABLED", "False").lower() == "true"
105
+ )
106
+ app.config["TURNSTILE_SITE_KEY"] = os.getenv("TURNSTILE_SITE_KEY", "")
107
+ app.config["TURNSTILE_SECRET_KEY"] = os.getenv("TURNSTILE_SECRET_KEY", "")
108
+ app.config["TURNSTILE_VERIFY_URL"] = (
109
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify"
110
+ )
111
+
112
+ migrate = Migrate(app, db)
113
+
114
+ # Initialize extensions
115
+ db.init_app(app)
116
+ login_manager = LoginManager()
117
+ login_manager.init_app(app)
118
+ login_manager.login_view = "auth.login"
119
+
120
+ # Initialize OAuth
121
+ init_oauth(app)
122
+
123
+ # Configure rate limits
124
+ limiter = Limiter(
125
+ app=app,
126
+ key_func=get_remote_address,
127
+ default_limits=["2000 per day", "50 per minute"],
128
+ storage_uri="memory://",
129
+ )
130
+
131
+ # TTS Cache Configuration - Read from environment
132
+ TTS_CACHE_SIZE = int(os.getenv("TTS_CACHE_SIZE", "10"))
133
+ CACHE_AUDIO_SUBDIR = "cache"
134
+ tts_cache = {} # sentence -> {model_a, model_b, audio_a, audio_b, created_at}
135
+ tts_cache_lock = threading.Lock()
136
+ SMOOTHING_FACTOR_MODEL_SELECTION = 500 # For weighted random model selection
137
+ # Increased max_workers to 8 for concurrent generation/refill
138
+ cache_executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix='CacheReplacer')
139
+ all_harvard_sentences = [] # Keep the full list available
140
+
141
+ # Create temp directories
142
+ TEMP_AUDIO_DIR = os.path.join(tempfile.gettempdir(), "tts_arena_audio")
143
+ CACHE_AUDIO_DIR = os.path.join(TEMP_AUDIO_DIR, CACHE_AUDIO_SUBDIR)
144
+ os.makedirs(TEMP_AUDIO_DIR, exist_ok=True)
145
+ os.makedirs(CACHE_AUDIO_DIR, exist_ok=True) # Ensure cache subdir exists
146
+
147
+
148
+ # Store active TTS sessions
149
+ app.tts_sessions = {}
150
+ tts_sessions = app.tts_sessions
151
+
152
+ # Store active conversational sessions
153
+ app.conversational_sessions = {}
154
+ conversational_sessions = app.conversational_sessions
155
+
156
+ # Register blueprints
157
+ app.register_blueprint(auth, url_prefix="/auth")
158
+ app.register_blueprint(admin)
159
+
160
+
161
+ @login_manager.user_loader
162
+ def load_user(user_id):
163
+ return User.query.get(int(user_id))
164
+
165
+
166
+ @app.before_request
167
+ def before_request():
168
+ g.user = current_user
169
+ g.is_admin = is_admin(current_user)
170
+
171
+ # Ensure HTTPS for HuggingFace Spaces environment
172
+ if IS_SPACES and request.headers.get("X-Forwarded-Proto") == "http":
173
+ url = request.url.replace("http://", "https://", 1)
174
+ return redirect(url, code=301)
175
+
176
+ # Check if Turnstile verification is required
177
+ if app.config["TURNSTILE_ENABLED"]:
178
+ # Exclude verification routes
179
+ excluded_routes = ["verify_turnstile", "turnstile_page", "static"]
180
+ if request.endpoint not in excluded_routes:
181
+ # Check if user is verified
182
+ if not session.get("turnstile_verified"):
183
+ # Save original URL for redirect after verification
184
+ redirect_url = request.url
185
+ # Force HTTPS in HuggingFace Spaces
186
+ if IS_SPACES and redirect_url.startswith("http://"):
187
+ redirect_url = redirect_url.replace("http://", "https://", 1)
188
+
189
+ # If it's an API request, return a JSON response
190
+ if request.path.startswith("/api/"):
191
+ return jsonify({"error": "Turnstile verification required"}), 403
192
+ # For regular requests, redirect to verification page
193
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
194
+ else:
195
+ # Check if verification has expired (default: 24 hours)
196
+ verification_timeout = (
197
+ int(os.getenv("TURNSTILE_TIMEOUT_HOURS", "24")) * 3600
198
+ ) # Convert hours to seconds
199
+ verified_at = session.get("turnstile_verified_at", 0)
200
+ current_time = datetime.utcnow().timestamp()
201
+
202
+ if current_time - verified_at > verification_timeout:
203
+ # Verification expired, clear status and redirect to verification page
204
+ session.pop("turnstile_verified", None)
205
+ session.pop("turnstile_verified_at", None)
206
+
207
+ redirect_url = request.url
208
+ # Force HTTPS in HuggingFace Spaces
209
+ if IS_SPACES and redirect_url.startswith("http://"):
210
+ redirect_url = redirect_url.replace("http://", "https://", 1)
211
+
212
+ if request.path.startswith("/api/"):
213
+ return jsonify({"error": "Turnstile verification expired"}), 403
214
+ return redirect(
215
+ url_for("turnstile_page", redirect_url=redirect_url)
216
+ )
217
+
218
+
219
+ @app.route("/turnstile", methods=["GET"])
220
+ def turnstile_page():
221
+ """Display Cloudflare Turnstile verification page"""
222
+ redirect_url = request.args.get("redirect_url", url_for("arena", _external=True))
223
 
224
+ # Force HTTPS in HuggingFace Spaces
225
+ if IS_SPACES and redirect_url.startswith("http://"):
226
+ redirect_url = redirect_url.replace("http://", "https://", 1)
227
+
228
+ return render_template(
229
+ "turnstile.html",
230
+ turnstile_site_key=app.config["TURNSTILE_SITE_KEY"],
231
+ redirect_url=redirect_url,
232
+ )
233
+
234
+
235
+ @app.route("/verify-turnstile", methods=["POST"])
236
+ def verify_turnstile():
237
+ """Verify Cloudflare Turnstile token"""
238
+ token = request.form.get("cf-turnstile-response")
239
+ redirect_url = request.form.get("redirect_url", url_for("arena", _external=True))
240
+
241
+ # Force HTTPS in HuggingFace Spaces
242
+ if IS_SPACES and redirect_url.startswith("http://"):
243
+ redirect_url = redirect_url.replace("http://", "https://", 1)
244
+
245
+ if not token:
246
+ # If AJAX request, return JSON error
247
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
248
+ return (
249
+ jsonify({"success": False, "error": "Missing verification token"}),
250
+ 400,
251
+ )
252
+ # Otherwise redirect back to turnstile page
253
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
254
+
255
+ # Verify token with Cloudflare
256
+ data = {
257
+ "secret": app.config["TURNSTILE_SECRET_KEY"],
258
+ "response": token,
259
+ "remoteip": request.remote_addr,
260
+ }
261
+
262
+ try:
263
+ response = requests.post(app.config["TURNSTILE_VERIFY_URL"], data=data)
264
+ result = response.json()
265
+
266
+ if result.get("success"):
267
+ # Set verification status in session
268
+ session["turnstile_verified"] = True
269
+ session["turnstile_verified_at"] = datetime.utcnow().timestamp()
270
+
271
+ # Determine response type based on request
272
+ is_xhr = request.headers.get("X-Requested-With") == "XMLHttpRequest"
273
+ accepts_json = "application/json" in request.headers.get("Accept", "")
274
+
275
+ # If AJAX or JSON request, return success JSON
276
+ if is_xhr or accepts_json:
277
+ return jsonify({"success": True, "redirect": redirect_url})
278
+
279
+ # For regular form submissions, redirect to the target URL
280
+ return redirect(redirect_url)
281
+ else:
282
+ # Verification failed
283
+ app.logger.warning(f"Turnstile verification failed: {result}")
284
+
285
+ # If AJAX request, return JSON error
286
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
287
+ return jsonify({"success": False, "error": "Verification failed"}), 403
288
+
289
+ # Otherwise redirect back to turnstile page
290
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
291
+
292
+ except Exception as e:
293
+ app.logger.error(f"Turnstile verification error: {str(e)}")
294
+
295
+ # If AJAX request, return JSON error
296
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
297
+ return (
298
+ jsonify(
299
+ {"success": False, "error": "Server error during verification"}
300
+ ),
301
+ 500,
302
+ )
303
+
304
+ # Otherwise redirect back to turnstile page
305
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
306
+
307
+ with open("sentences.txt", "r") as f, open("emotional_sentences.txt", "r") as f_emotional:
308
+ # Store all sentences and clean them up
309
+ all_harvard_sentences = [line.strip() for line in f.readlines() if line.strip()] + [line.strip() for line in f_emotional.readlines() if line.strip()]
310
+ # Shuffle for initial random selection if needed, but main list remains ordered
311
+ initial_sentences = random.sample(all_harvard_sentences, min(len(all_harvard_sentences), 500)) # Limit initial pass for template
312
 
313
  @app.route("/")
314
+ def arena():
315
+ # Pass a subset of sentences for the random button fallback
316
+ return render_template("arena.html", harvard_sentences=json.dumps(initial_sentences))
317
+
318
+
319
+ @app.route("/leaderboard")
320
+ def leaderboard():
321
+ tts_leaderboard = get_leaderboard_data(ModelType.TTS)
322
+ conversational_leaderboard = get_leaderboard_data(ModelType.CONVERSATIONAL)
323
+ top_voters = get_top_voters(10) # Get top 10 voters
324
+
325
+ # Initialize personal leaderboard data
326
+ tts_personal_leaderboard = None
327
+ conversational_personal_leaderboard = None
328
+ user_leaderboard_visibility = None
329
+
330
+ # If user is logged in, get their personal leaderboard and visibility setting
331
+ if current_user.is_authenticated:
332
+ tts_personal_leaderboard = get_user_leaderboard(current_user.id, ModelType.TTS)
333
+ conversational_personal_leaderboard = get_user_leaderboard(
334
+ current_user.id, ModelType.CONVERSATIONAL
335
+ )
336
+ user_leaderboard_visibility = current_user.show_in_leaderboard
337
+
338
+ # Get key dates for the timeline
339
+ tts_key_dates = get_key_historical_dates(ModelType.TTS)
340
+ conversational_key_dates = get_key_historical_dates(ModelType.CONVERSATIONAL)
341
+
342
+ # Format dates for display in the dropdown
343
+ formatted_tts_dates = [date.strftime("%B %Y") for date in tts_key_dates]
344
+ formatted_conversational_dates = [
345
+ date.strftime("%B %Y") for date in conversational_key_dates
346
+ ]
347
+
348
+ return render_template(
349
+ "leaderboard.html",
350
+ tts_leaderboard=tts_leaderboard,
351
+ conversational_leaderboard=conversational_leaderboard,
352
+ tts_personal_leaderboard=tts_personal_leaderboard,
353
+ conversational_personal_leaderboard=conversational_personal_leaderboard,
354
+ tts_key_dates=tts_key_dates,
355
+ conversational_key_dates=conversational_key_dates,
356
+ formatted_tts_dates=formatted_tts_dates,
357
+ formatted_conversational_dates=formatted_conversational_dates,
358
+ top_voters=top_voters,
359
+ user_leaderboard_visibility=user_leaderboard_visibility
360
+ )
361
+
362
+
363
+ @app.route("/api/historical-leaderboard/<model_type>")
364
+ def historical_leaderboard(model_type):
365
+ """Get historical leaderboard data for a specific date"""
366
+ if model_type not in [ModelType.TTS, ModelType.CONVERSATIONAL]:
367
+ return jsonify({"error": "Invalid model type"}), 400
368
+
369
+ # Get date from query parameter
370
+ date_str = request.args.get("date")
371
+ if not date_str:
372
+ return jsonify({"error": "Date parameter is required"}), 400
373
+
374
+ try:
375
+ # Parse date from URL parameter (format: YYYY-MM-DD)
376
+ target_date = datetime.strptime(date_str, "%Y-%m-%d")
377
+
378
+ # Get historical leaderboard data
379
+ leaderboard_data = get_historical_leaderboard_data(model_type, target_date)
380
+
381
+ return jsonify(
382
+ {"date": target_date.strftime("%B %d, %Y"), "leaderboard": leaderboard_data}
383
+ )
384
+ except ValueError:
385
+ return jsonify({"error": "Invalid date format. Use YYYY-MM-DD"}), 400
386
+
387
+
388
+ @app.route("/about")
389
+ def about():
390
+ return render_template("about.html")
391
+
392
+
393
+ # --- TTS Caching Functions ---
394
+
395
+ def generate_and_save_tts(text, model_id, output_dir):
396
+ """Generates TTS and saves it to a specific directory, returning the full path."""
397
+ temp_audio_path = None # Initialize to None
398
+ try:
399
+ app.logger.debug(f"[TTS Gen {model_id}] Starting generation for: '{text[:30]}...'")
400
+ # If predict_tts saves file itself and returns path:
401
+ temp_audio_path = predict_tts(text, model_id)
402
+ app.logger.debug(f"[TTS Gen {model_id}] predict_tts returned: {temp_audio_path}")
403
+
404
+ if not temp_audio_path or not os.path.exists(temp_audio_path):
405
+ app.logger.warning(f"[TTS Gen {model_id}] predict_tts failed or returned invalid path: {temp_audio_path}")
406
+ raise ValueError("predict_tts did not return a valid path or file does not exist")
407
+
408
+ file_uuid = str(uuid.uuid4())
409
+ dest_path = os.path.join(output_dir, f"{file_uuid}.wav")
410
+ app.logger.debug(f"[TTS Gen {model_id}] Moving {temp_audio_path} to {dest_path}")
411
+ # Move the file generated by predict_tts to the target cache directory
412
+ shutil.move(temp_audio_path, dest_path)
413
+ app.logger.debug(f"[TTS Gen {model_id}] Move successful. Returning {dest_path}")
414
+ return dest_path
415
+
416
+ except Exception as e:
417
+ app.logger.error(f"Error generating/saving TTS for model {model_id} and text '{text[:30]}...': {str(e)}")
418
+ # Ensure temporary file from predict_tts (if any) is cleaned up
419
+ if temp_audio_path and os.path.exists(temp_audio_path):
420
+ try:
421
+ app.logger.debug(f"[TTS Gen {model_id}] Cleaning up temporary file {temp_audio_path} after error.")
422
+ os.remove(temp_audio_path)
423
+ except OSError:
424
+ pass # Ignore error if file couldn't be removed
425
+ return None
426
+
427
+
428
+ def _generate_cache_entry_task(sentence):
429
+ """Task function to generate audio for a sentence and add to cache."""
430
+ # Wrap the entire task in an application context
431
+ with app.app_context():
432
+ if not sentence:
433
+ # Select a new sentence if not provided (for replacement)
434
+ with tts_cache_lock:
435
+ cached_keys = set(tts_cache.keys())
436
+ available_sentences = [s for s in all_harvard_sentences if s not in cached_keys]
437
+ if not available_sentences:
438
+ app.logger.warning("No more unique Harvard sentences available for caching.")
439
+ return
440
+ sentence = random.choice(available_sentences)
441
+
442
+ # app.logger.info removed duplicate log
443
+ print(f"[Cache Task] Querying models for: '{sentence[:50]}...'")
444
+ available_models = Model.query.filter_by(
445
+ model_type=ModelType.TTS, is_active=True
446
+ ).all()
447
+
448
+ if len(available_models) < 2:
449
+ app.logger.error("Not enough active TTS models to generate cache entry.")
450
+ return
451
+
452
+ try:
453
+ models = get_weighted_random_models(available_models, 2, ModelType.TTS)
454
+ model_a_id = models[0].id
455
+ model_b_id = models[1].id
456
+
457
+ # Generate audio concurrently using a local executor for clarity within the task
458
+ with ThreadPoolExecutor(max_workers=2, thread_name_prefix='AudioGen') as audio_executor:
459
+ future_a = audio_executor.submit(generate_and_save_tts, sentence, model_a_id, CACHE_AUDIO_DIR)
460
+ future_b = audio_executor.submit(generate_and_save_tts, sentence, model_b_id, CACHE_AUDIO_DIR)
461
+
462
+ timeout_seconds = 120
463
+ audio_a_path = future_a.result(timeout=timeout_seconds)
464
+ audio_b_path = future_b.result(timeout=timeout_seconds)
465
+
466
+ if audio_a_path and audio_b_path:
467
+ with tts_cache_lock:
468
+ # Only add if the sentence isn't already back in the cache
469
+ # And ensure cache size doesn't exceed limit
470
+ if sentence not in tts_cache and len(tts_cache) < TTS_CACHE_SIZE:
471
+ tts_cache[sentence] = {
472
+ "model_a": model_a_id,
473
+ "model_b": model_b_id,
474
+ "audio_a": audio_a_path,
475
+ "audio_b": audio_b_path,
476
+ "created_at": datetime.utcnow(),
477
+ }
478
+ app.logger.info(f"Successfully cached entry for: '{sentence[:50]}...'")
479
+ elif sentence in tts_cache:
480
+ app.logger.warning(f"Sentence '{sentence[:50]}...' already re-cached. Discarding new generation.")
481
+ # Clean up the newly generated files if not added
482
+ if os.path.exists(audio_a_path): os.remove(audio_a_path)
483
+ if os.path.exists(audio_b_path): os.remove(audio_b_path)
484
+ else: # Cache is full
485
+ app.logger.warning(f"Cache is full ({len(tts_cache)} entries). Discarding new generation for '{sentence[:50]}...'.")
486
+ # Clean up the newly generated files if not added
487
+ if os.path.exists(audio_a_path): os.remove(audio_a_path)
488
+ if os.path.exists(audio_b_path): os.remove(audio_b_path)
489
+
490
+ else:
491
+ app.logger.error(f"Failed to generate one or both audio files for cache: '{sentence[:50]}...'")
492
+ # Clean up whichever file might have been created
493
+ if audio_a_path and os.path.exists(audio_a_path): os.remove(audio_a_path)
494
+ if audio_b_path and os.path.exists(audio_b_path): os.remove(audio_b_path)
495
+
496
+ except Exception as e:
497
+ # Log the exception within the app context
498
+ app.logger.error(f"Exception in _generate_cache_entry_task for '{sentence[:50]}...': {str(e)}", exc_info=True)
499
+
500
+
501
+ def initialize_tts_cache():
502
+ print("Initializing TTS cache")
503
+ """Selects initial sentences and starts generation tasks."""
504
+ with app.app_context(): # Ensure access to models
505
+ if not all_harvard_sentences:
506
+ app.logger.error("Harvard sentences not loaded. Cannot initialize cache.")
507
+ return
508
+
509
+ initial_selection = random.sample(all_harvard_sentences, min(len(all_harvard_sentences), TTS_CACHE_SIZE))
510
+ app.logger.info(f"Initializing TTS cache with {len(initial_selection)} sentences...")
511
+
512
+ for sentence in initial_selection:
513
+ # Use the main cache_executor for initial population too
514
+ cache_executor.submit(_generate_cache_entry_task, sentence)
515
+ app.logger.info("Submitted initial cache generation tasks.")
516
+
517
+ # --- End TTS Caching Functions ---
518
+
519
+
520
+ @app.route("/api/tts/generate", methods=["POST"])
521
+ @limiter.limit("10 per minute") # Keep limit, cached responses are still requests
522
+ def generate_tts():
523
+ # If verification not setup, handle it first
524
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
525
+ return jsonify({"error": "Turnstile verification required"}), 403
526
+
527
+ # Require user to be logged in to generate audio
528
+ if not current_user.is_authenticated:
529
+ return jsonify({"error": "You must be logged in to generate audio"}), 401
530
+
531
+ data = request.json
532
+ text = data.get("text", "").strip() # Ensure text is stripped
533
+
534
+ if not text or len(text) > 1000:
535
+ return jsonify({"error": "Invalid or too long text"}), 400
536
+
537
+ # --- Cache Check ---
538
+ cache_hit = False
539
+ session_data_from_cache = None
540
+ with tts_cache_lock:
541
+ if text in tts_cache:
542
+ cache_hit = True
543
+ cached_entry = tts_cache.pop(text) # Remove from cache immediately
544
+ app.logger.info(f"TTS Cache HIT for: '{text[:50]}...'")
545
+
546
+ # Prepare session data using cached info
547
+ session_id = str(uuid.uuid4())
548
+ session_data_from_cache = {
549
+ "model_a": cached_entry["model_a"],
550
+ "model_b": cached_entry["model_b"],
551
+ "audio_a": cached_entry["audio_a"], # Paths are now from cache_dir
552
+ "audio_b": cached_entry["audio_b"],
553
+ "text": text,
554
+ "created_at": datetime.utcnow(),
555
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
556
+ "voted": False,
557
+ "cache_hit": True,
558
+ }
559
+ app.tts_sessions[session_id] = session_data_from_cache
560
+
561
+ # --- Trigger background tasks to refill the cache ---
562
+ # Calculate how many slots need refilling
563
+ current_cache_size = len(tts_cache) # Size *before* adding potentially new items
564
+ needed_refills = TTS_CACHE_SIZE - current_cache_size
565
+ # Limit concurrent refills to 8 or the actual need
566
+ refills_to_submit = min(needed_refills, 8)
567
+
568
+ if refills_to_submit > 0:
569
+ app.logger.info(f"Cache hit: Submitting {refills_to_submit} background task(s) to refill cache (current size: {current_cache_size}, target: {TTS_CACHE_SIZE}).")
570
+ for _ in range(refills_to_submit):
571
+ # Pass None to signal replacement selection within the task
572
+ cache_executor.submit(_generate_cache_entry_task, None)
573
+ else:
574
+ app.logger.info(f"Cache hit: Cache is already full or at target size ({current_cache_size}/{TTS_CACHE_SIZE}). No refill tasks submitted.")
575
+ # --- End Refill Trigger ---
576
+
577
+ if cache_hit and session_data_from_cache:
578
+ # Return response using cached data
579
+ # Note: The files are now managed by the session lifecycle (cleanup_session)
580
+ return jsonify(
581
+ {
582
+ "session_id": session_id,
583
+ "audio_a": f"/api/tts/audio/{session_id}/a",
584
+ "audio_b": f"/api/tts/audio/{session_id}/b",
585
+ "expires_in": 1800, # 30 minutes in seconds
586
+ "cache_hit": True,
587
+ }
588
+ )
589
+ # --- End Cache Check ---
590
+
591
+ # --- Cache Miss: Generate on the fly ---
592
+ app.logger.info(f"TTS Cache MISS for: '{text[:50]}...'. Generating on the fly.")
593
+ available_models = Model.query.filter_by(
594
+ model_type=ModelType.TTS, is_active=True
595
+ ).all()
596
+ if len(available_models) < 2:
597
+ return jsonify({"error": "Not enough TTS models available"}), 500
598
+
599
+ selected_models = get_weighted_random_models(available_models, 2, ModelType.TTS)
600
+
601
+ try:
602
+ audio_files = []
603
+ model_ids = []
604
+
605
+ # Function to process a single model (generate directly to TEMP_AUDIO_DIR, not cache subdir)
606
+ def process_model_on_the_fly(model):
607
+ # Generate and save directly to the main temp dir
608
+ # Assume predict_tts handles saving temporary files
609
+ temp_audio_path = predict_tts(text, model.id)
610
+ if not temp_audio_path or not os.path.exists(temp_audio_path):
611
+ raise ValueError(f"predict_tts failed for model {model.id}")
612
+
613
+ # Create a unique name in the main TEMP_AUDIO_DIR for the session
614
+ file_uuid = str(uuid.uuid4())
615
+ dest_path = os.path.join(TEMP_AUDIO_DIR, f"{file_uuid}.wav")
616
+ shutil.move(temp_audio_path, dest_path) # Move from predict_tts's temp location
617
+
618
+ return {"model_id": model.id, "audio_path": dest_path}
619
+
620
+
621
+ # Use ThreadPoolExecutor to process models concurrently
622
+ with ThreadPoolExecutor(max_workers=2) as executor:
623
+ results = list(executor.map(process_model_on_the_fly, selected_models))
624
+
625
+ # Extract results
626
+ for result in results:
627
+ model_ids.append(result["model_id"])
628
+ audio_files.append(result["audio_path"])
629
+
630
+ # Create session
631
+ session_id = str(uuid.uuid4())
632
+ app.tts_sessions[session_id] = {
633
+ "model_a": model_ids[0],
634
+ "model_b": model_ids[1],
635
+ "audio_a": audio_files[0], # Paths are now from TEMP_AUDIO_DIR directly
636
+ "audio_b": audio_files[1],
637
+ "text": text,
638
+ "created_at": datetime.utcnow(),
639
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
640
+ "voted": False,
641
+ "cache_hit": False,
642
+ }
643
+
644
+ # Return audio file paths and session
645
+ return jsonify(
646
+ {
647
+ "session_id": session_id,
648
+ "audio_a": f"/api/tts/audio/{session_id}/a",
649
+ "audio_b": f"/api/tts/audio/{session_id}/b",
650
+ "expires_in": 1800,
651
+ "cache_hit": False,
652
+ }
653
+ )
654
+
655
+ except Exception as e:
656
+ app.logger.error(f"TTS on-the-fly generation error: {str(e)}", exc_info=True)
657
+ # Cleanup any files potentially created during the failed attempt
658
+ if 'results' in locals():
659
+ for res in results:
660
+ if 'audio_path' in res and os.path.exists(res['audio_path']):
661
+ try:
662
+ os.remove(res['audio_path'])
663
+ except OSError:
664
+ pass
665
+ return jsonify({"error": "Failed to generate TTS"}), 500
666
+ # --- End Cache Miss ---
667
+
668
+
669
+ @app.route("/api/tts/audio/<session_id>/<model_key>")
670
+ def get_audio(session_id, model_key):
671
+ # If verification not setup, handle it first
672
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
673
+ return jsonify({"error": "Turnstile verification required"}), 403
674
+
675
+ if session_id not in app.tts_sessions:
676
+ return jsonify({"error": "Invalid or expired session"}), 404
677
+
678
+ session_data = app.tts_sessions[session_id]
679
+
680
+ # Check if session expired
681
+ if datetime.utcnow() > session_data["expires_at"]:
682
+ cleanup_session(session_id)
683
+ return jsonify({"error": "Session expired"}), 410
684
+
685
+ if model_key == "a":
686
+ audio_path = session_data["audio_a"]
687
+ elif model_key == "b":
688
+ audio_path = session_data["audio_b"]
689
+ else:
690
+ return jsonify({"error": "Invalid model key"}), 400
691
+
692
+ # Check if file exists
693
+ if not os.path.exists(audio_path):
694
+ return jsonify({"error": "Audio file not found"}), 404
695
+
696
+ return send_file(audio_path, mimetype="audio/wav")
697
+
698
+
699
+ @app.route("/api/tts/vote", methods=["POST"])
700
+ @limiter.limit("30 per minute")
701
+ def submit_vote():
702
+ # If verification not setup, handle it first
703
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
704
+ return jsonify({"error": "Turnstile verification required"}), 403
705
+
706
+ # Require user to be logged in to vote
707
+ if not current_user.is_authenticated:
708
+ return jsonify({"error": "You must be logged in to vote"}), 401
709
+
710
+ # Security checks for vote manipulation prevention
711
+ client_ip = get_client_ip()
712
+ vote_allowed, security_reason, security_score = is_vote_allowed(current_user.id, client_ip)
713
+
714
+ if not vote_allowed:
715
+ app.logger.warning(f"Vote blocked for user {current_user.username} (ID: {current_user.id}): {security_reason} (Score: {security_score})")
716
+ return jsonify({"error": f"Vote not allowed: {security_reason}"}), 403
717
+
718
+ data = request.json
719
+ session_id = data.get("session_id")
720
+ chosen_model_key = data.get("chosen_model") # "a" or "b"
721
+
722
+ if not session_id or session_id not in app.tts_sessions:
723
+ return jsonify({"error": "Invalid or expired session"}), 404
724
+
725
+ if not chosen_model_key or chosen_model_key not in ["a", "b"]:
726
+ return jsonify({"error": "Invalid chosen model"}), 400
727
+
728
+ session_data = app.tts_sessions[session_id]
729
+
730
+ # Check if session expired
731
+ if datetime.utcnow() > session_data["expires_at"]:
732
+ cleanup_session(session_id)
733
+ return jsonify({"error": "Session expired"}), 410
734
+
735
+ # Check if already voted
736
+ if session_data["voted"]:
737
+ return jsonify({"error": "Vote already submitted for this session"}), 400
738
+
739
+ # Get model IDs and audio paths
740
+ chosen_id = (
741
+ session_data["model_a"] if chosen_model_key == "a" else session_data["model_b"]
742
+ )
743
+ rejected_id = (
744
+ session_data["model_b"] if chosen_model_key == "a" else session_data["model_a"]
745
+ )
746
+ chosen_audio_path = (
747
+ session_data["audio_a"] if chosen_model_key == "a" else session_data["audio_b"]
748
+ )
749
+ rejected_audio_path = (
750
+ session_data["audio_b"] if chosen_model_key == "a" else session_data["audio_a"]
751
+ )
752
+
753
+ # Calculate session duration and gather analytics data
754
+ vote_time = datetime.utcnow()
755
+ session_duration = (vote_time - session_data["created_at"]).total_seconds()
756
+ client_ip = get_client_ip()
757
+ user_agent = request.headers.get('User-Agent')
758
+ cache_hit = session_data.get("cache_hit", False)
759
+
760
+ # Record vote in database with analytics data
761
+ vote, error = record_vote(
762
+ current_user.id,
763
+ session_data["text"],
764
+ chosen_id,
765
+ rejected_id,
766
+ ModelType.TTS,
767
+ session_duration=session_duration,
768
+ ip_address=client_ip,
769
+ user_agent=user_agent,
770
+ generation_date=session_data["created_at"],
771
+ cache_hit=cache_hit
772
+ )
773
+
774
+ if error:
775
+ return jsonify({"error": error}), 500
776
+
777
+ # --- Save preference data ---
778
+ try:
779
+ vote_uuid = str(uuid.uuid4())
780
+ vote_dir = os.path.join("./votes", vote_uuid)
781
+ os.makedirs(vote_dir, exist_ok=True)
782
+
783
+ # Copy audio files
784
+ shutil.copy(chosen_audio_path, os.path.join(vote_dir, "chosen.wav"))
785
+ shutil.copy(rejected_audio_path, os.path.join(vote_dir, "rejected.wav"))
786
+
787
+ # Create metadata
788
+ chosen_model_obj = Model.query.get(chosen_id)
789
+ rejected_model_obj = Model.query.get(rejected_id)
790
+ metadata = {
791
+ "text": session_data["text"],
792
+ "chosen_model": chosen_model_obj.name if chosen_model_obj else "Unknown",
793
+ "chosen_model_id": chosen_model_obj.id if chosen_model_obj else "Unknown",
794
+ "rejected_model": rejected_model_obj.name if rejected_model_obj else "Unknown",
795
+ "rejected_model_id": rejected_model_obj.id if rejected_model_obj else "Unknown",
796
+ "session_id": session_id,
797
+ "timestamp": datetime.utcnow().isoformat(),
798
+ "username": current_user.username,
799
+ "model_type": "TTS"
800
+ }
801
+ with open(os.path.join(vote_dir, "metadata.json"), "w") as f:
802
+ json.dump(metadata, f, indent=2)
803
+
804
+ except Exception as e:
805
+ app.logger.error(f"Error saving preference data for vote {session_id}: {str(e)}")
806
+ # Continue even if saving preference data fails, vote is already recorded
807
+
808
+ # Mark session as voted
809
+ session_data["voted"] = True
810
+
811
+ # Check for coordinated voting campaigns (async to not slow down response)
812
+ try:
813
+ from threading import Thread
814
+ campaign_check_thread = Thread(target=check_for_coordinated_campaigns)
815
+ campaign_check_thread.daemon = True
816
+ campaign_check_thread.start()
817
+ except Exception as e:
818
+ app.logger.error(f"Error starting coordinated campaign check thread: {str(e)}")
819
+
820
+ # Return updated models (use previously fetched objects)
821
+ return jsonify(
822
+ {
823
+ "success": True,
824
+ "chosen_model": {"id": chosen_id, "name": chosen_model_obj.name if chosen_model_obj else "Unknown"},
825
+ "rejected_model": {
826
+ "id": rejected_id,
827
+ "name": rejected_model_obj.name if rejected_model_obj else "Unknown",
828
+ },
829
+ "names": {
830
+ "a": (
831
+ chosen_model_obj.name if chosen_model_key == "a" else rejected_model_obj.name
832
+ if chosen_model_obj and rejected_model_obj else "Unknown"
833
+ ),
834
+ "b": (
835
+ rejected_model_obj.name if chosen_model_key == "a" else chosen_model_obj.name
836
+ if chosen_model_obj and rejected_model_obj else "Unknown"
837
+ ),
838
+ },
839
+ }
840
+ )
841
+
842
+
843
+ def cleanup_session(session_id):
844
+ """Remove session and its audio files"""
845
+ if session_id in app.tts_sessions:
846
+ session = app.tts_sessions[session_id]
847
+
848
+ # Remove audio files
849
+ for audio_file in [session["audio_a"], session["audio_b"]]:
850
+ if os.path.exists(audio_file):
851
+ try:
852
+ os.remove(audio_file)
853
+ except Exception as e:
854
+ app.logger.error(f"Error removing audio file: {str(e)}")
855
+
856
+ # Remove session
857
+ del app.tts_sessions[session_id]
858
+
859
+
860
+ @app.route("/api/conversational/generate", methods=["POST"])
861
+ @limiter.limit("5 per minute")
862
+ def generate_podcast():
863
+ # If verification not setup, handle it first
864
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
865
+ return jsonify({"error": "Turnstile verification required"}), 403
866
+
867
+ # Require user to be logged in to generate audio
868
+ if not current_user.is_authenticated:
869
+ return jsonify({"error": "You must be logged in to generate audio"}), 401
870
+
871
+ data = request.json
872
+ script = data.get("script")
873
+
874
+ if not script or not isinstance(script, list) or len(script) < 2:
875
+ return jsonify({"error": "Invalid script format or too short"}), 400
876
+
877
+ # Validate script format
878
+ for line in script:
879
+ if not isinstance(line, dict) or "text" not in line or "speaker_id" not in line:
880
+ return (
881
+ jsonify(
882
+ {
883
+ "error": "Invalid script line format. Each line must have text and speaker_id"
884
+ }
885
+ ),
886
+ 400,
887
+ )
888
+ if (
889
+ not line["text"]
890
+ or not isinstance(line["speaker_id"], int)
891
+ or line["speaker_id"] not in [0, 1]
892
+ ):
893
+ return (
894
+ jsonify({"error": "Invalid script content. Speaker ID must be 0 or 1"}),
895
+ 400,
896
+ )
897
+
898
+ # Get two conversational models (currently only CSM and PlayDialog)
899
+ available_models = Model.query.filter_by(
900
+ model_type=ModelType.CONVERSATIONAL, is_active=True
901
+ ).all()
902
+
903
+ if len(available_models) < 2:
904
+ return jsonify({"error": "Not enough conversational models available"}), 500
905
+
906
+ selected_models = get_weighted_random_models(available_models, 2, ModelType.CONVERSATIONAL)
907
+
908
+ try:
909
+ # Generate audio for both models concurrently
910
+ audio_files = []
911
+ model_ids = []
912
+
913
+ # Function to process a single model
914
+ def process_model(model):
915
+ # Call conversational TTS service
916
+ audio_content = predict_tts(script, model.id)
917
+
918
+ # Save to temp file with unique name
919
+ file_uuid = str(uuid.uuid4())
920
+ dest_path = os.path.join(TEMP_AUDIO_DIR, f"{file_uuid}.wav")
921
+
922
+ with open(dest_path, "wb") as f:
923
+ f.write(audio_content)
924
+
925
+ return {"model_id": model.id, "audio_path": dest_path}
926
+
927
+ # Use ThreadPoolExecutor to process models concurrently
928
+ with ThreadPoolExecutor(max_workers=2) as executor:
929
+ results = list(executor.map(process_model, selected_models))
930
+
931
+ # Extract results
932
+ for result in results:
933
+ model_ids.append(result["model_id"])
934
+ audio_files.append(result["audio_path"])
935
+
936
+ # Create session
937
+ session_id = str(uuid.uuid4())
938
+ script_text = " ".join([line["text"] for line in script])
939
+ app.conversational_sessions[session_id] = {
940
+ "model_a": model_ids[0],
941
+ "model_b": model_ids[1],
942
+ "audio_a": audio_files[0],
943
+ "audio_b": audio_files[1],
944
+ "text": script_text[:1000], # Limit text length
945
+ "created_at": datetime.utcnow(),
946
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
947
+ "voted": False,
948
+ "script": script,
949
+ "cache_hit": False, # Conversational is always generated on-demand
950
+ }
951
+
952
+ # Return audio file paths and session
953
+ return jsonify(
954
+ {
955
+ "session_id": session_id,
956
+ "audio_a": f"/api/conversational/audio/{session_id}/a",
957
+ "audio_b": f"/api/conversational/audio/{session_id}/b",
958
+ "expires_in": 1800, # 30 minutes in seconds
959
+ }
960
+ )
961
+
962
+ except Exception as e:
963
+ app.logger.error(f"Conversational generation error: {str(e)}")
964
+ return jsonify({"error": f"Failed to generate podcast: {str(e)}"}), 500
965
+
966
+
967
+ @app.route("/api/conversational/audio/<session_id>/<model_key>")
968
+ def get_podcast_audio(session_id, model_key):
969
+ # If verification not setup, handle it first
970
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
971
+ return jsonify({"error": "Turnstile verification required"}), 403
972
+
973
+ if session_id not in app.conversational_sessions:
974
+ return jsonify({"error": "Invalid or expired session"}), 404
975
+
976
+ session_data = app.conversational_sessions[session_id]
977
+
978
+ # Check if session expired
979
+ if datetime.utcnow() > session_data["expires_at"]:
980
+ cleanup_conversational_session(session_id)
981
+ return jsonify({"error": "Session expired"}), 410
982
+
983
+ if model_key == "a":
984
+ audio_path = session_data["audio_a"]
985
+ elif model_key == "b":
986
+ audio_path = session_data["audio_b"]
987
+ else:
988
+ return jsonify({"error": "Invalid model key"}), 400
989
+
990
+ # Check if file exists
991
+ if not os.path.exists(audio_path):
992
+ return jsonify({"error": "Audio file not found"}), 404
993
+
994
+ return send_file(audio_path, mimetype="audio/wav")
995
+
996
+
997
+ @app.route("/api/conversational/vote", methods=["POST"])
998
+ @limiter.limit("30 per minute")
999
+ def submit_podcast_vote():
1000
+ # If verification not setup, handle it first
1001
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
1002
+ return jsonify({"error": "Turnstile verification required"}), 403
1003
+
1004
+ # Require user to be logged in to vote
1005
+ if not current_user.is_authenticated:
1006
+ return jsonify({"error": "You must be logged in to vote"}), 401
1007
+
1008
+ # Security checks for vote manipulation prevention
1009
+ client_ip = get_client_ip()
1010
+ vote_allowed, security_reason, security_score = is_vote_allowed(current_user.id, client_ip)
1011
+
1012
+ if not vote_allowed:
1013
+ app.logger.warning(f"Conversational vote blocked for user {current_user.username} (ID: {current_user.id}): {security_reason} (Score: {security_score})")
1014
+ return jsonify({"error": f"Vote not allowed: {security_reason}"}), 403
1015
+
1016
+ data = request.json
1017
+ session_id = data.get("session_id")
1018
+ chosen_model_key = data.get("chosen_model") # "a" or "b"
1019
+
1020
+ if not session_id or session_id not in app.conversational_sessions:
1021
+ return jsonify({"error": "Invalid or expired session"}), 404
1022
+
1023
+ if not chosen_model_key or chosen_model_key not in ["a", "b"]:
1024
+ return jsonify({"error": "Invalid chosen model"}), 400
1025
+
1026
+ session_data = app.conversational_sessions[session_id]
1027
+
1028
+ # Check if session expired
1029
+ if datetime.utcnow() > session_data["expires_at"]:
1030
+ cleanup_conversational_session(session_id)
1031
+ return jsonify({"error": "Session expired"}), 410
1032
+
1033
+ # Check if already voted
1034
+ if session_data["voted"]:
1035
+ return jsonify({"error": "Vote already submitted for this session"}), 400
1036
+
1037
+ # Get model IDs and audio paths
1038
+ chosen_id = (
1039
+ session_data["model_a"] if chosen_model_key == "a" else session_data["model_b"]
1040
+ )
1041
+ rejected_id = (
1042
+ session_data["model_b"] if chosen_model_key == "a" else session_data["model_a"]
1043
+ )
1044
+ chosen_audio_path = (
1045
+ session_data["audio_a"] if chosen_model_key == "a" else session_data["audio_b"]
1046
+ )
1047
+ rejected_audio_path = (
1048
+ session_data["audio_b"] if chosen_model_key == "a" else session_data["audio_a"]
1049
+ )
1050
+
1051
+ # Calculate session duration and gather analytics data
1052
+ vote_time = datetime.utcnow()
1053
+ session_duration = (vote_time - session_data["created_at"]).total_seconds()
1054
+ client_ip = get_client_ip()
1055
+ user_agent = request.headers.get('User-Agent')
1056
+ cache_hit = session_data.get("cache_hit", False)
1057
+
1058
+ # Record vote in database with analytics data
1059
+ vote, error = record_vote(
1060
+ current_user.id,
1061
+ session_data["text"],
1062
+ chosen_id,
1063
+ rejected_id,
1064
+ ModelType.CONVERSATIONAL,
1065
+ session_duration=session_duration,
1066
+ ip_address=client_ip,
1067
+ user_agent=user_agent,
1068
+ generation_date=session_data["created_at"],
1069
+ cache_hit=cache_hit
1070
+ )
1071
+
1072
+ if error:
1073
+ return jsonify({"error": error}), 500
1074
+
1075
+ # --- Save preference data ---\
1076
+ try:
1077
+ vote_uuid = str(uuid.uuid4())
1078
+ vote_dir = os.path.join("./votes", vote_uuid)
1079
+ os.makedirs(vote_dir, exist_ok=True)
1080
+
1081
+ # Copy audio files
1082
+ shutil.copy(chosen_audio_path, os.path.join(vote_dir, "chosen.wav"))
1083
+ shutil.copy(rejected_audio_path, os.path.join(vote_dir, "rejected.wav"))
1084
+
1085
+ # Create metadata
1086
+ chosen_model_obj = Model.query.get(chosen_id)
1087
+ rejected_model_obj = Model.query.get(rejected_id)
1088
+ metadata = {
1089
+ "script": session_data["script"], # Save the full script
1090
+ "chosen_model": chosen_model_obj.name if chosen_model_obj else "Unknown",
1091
+ "chosen_model_id": chosen_model_obj.id if chosen_model_obj else "Unknown",
1092
+ "rejected_model": rejected_model_obj.name if rejected_model_obj else "Unknown",
1093
+ "rejected_model_id": rejected_model_obj.id if rejected_model_obj else "Unknown",
1094
+ "session_id": session_id,
1095
+ "timestamp": datetime.utcnow().isoformat(),
1096
+ "username": current_user.username,
1097
+ "model_type": "CONVERSATIONAL"
1098
+ }
1099
+ with open(os.path.join(vote_dir, "metadata.json"), "w") as f:
1100
+ json.dump(metadata, f, indent=2)
1101
+
1102
+ except Exception as e:
1103
+ app.logger.error(f"Error saving preference data for conversational vote {session_id}: {str(e)}")
1104
+ # Continue even if saving preference data fails, vote is already recorded
1105
+
1106
+ # Mark session as voted
1107
+ session_data["voted"] = True
1108
+
1109
+ # Check for coordinated voting campaigns (async to not slow down response)
1110
+ try:
1111
+ from threading import Thread
1112
+ campaign_check_thread = Thread(target=check_for_coordinated_campaigns)
1113
+ campaign_check_thread.daemon = True
1114
+ campaign_check_thread.start()
1115
+ except Exception as e:
1116
+ app.logger.error(f"Error starting coordinated campaign check thread: {str(e)}")
1117
+
1118
+ # Return updated models (use previously fetched objects)
1119
+ return jsonify(
1120
+ {
1121
+ "success": True,
1122
+ "chosen_model": {"id": chosen_id, "name": chosen_model_obj.name if chosen_model_obj else "Unknown"},
1123
+ "rejected_model": {
1124
+ "id": rejected_id,
1125
+ "name": rejected_model_obj.name if rejected_model_obj else "Unknown",
1126
+ },
1127
+ "names": {
1128
+ "a": Model.query.get(session_data["model_a"]).name,
1129
+ "b": Model.query.get(session_data["model_b"]).name,
1130
+ },
1131
+ }
1132
+ )
1133
+
1134
+
1135
+ def cleanup_conversational_session(session_id):
1136
+ """Remove conversational session and its audio files"""
1137
+ if session_id in app.conversational_sessions:
1138
+ session = app.conversational_sessions[session_id]
1139
+
1140
+ # Remove audio files
1141
+ for audio_file in [session["audio_a"], session["audio_b"]]:
1142
+ if os.path.exists(audio_file):
1143
+ try:
1144
+ os.remove(audio_file)
1145
+ except Exception as e:
1146
+ app.logger.error(
1147
+ f"Error removing conversational audio file: {str(e)}"
1148
+ )
1149
+
1150
+ # Remove session
1151
+ del app.conversational_sessions[session_id]
1152
+
1153
+
1154
+ # Schedule periodic cleanup
1155
+ def setup_cleanup():
1156
+ def cleanup_expired_sessions():
1157
+ with app.app_context(): # Ensure app context for logging
1158
+ current_time = datetime.utcnow()
1159
+ # Cleanup TTS sessions
1160
+ expired_tts_sessions = [
1161
+ sid
1162
+ for sid, session_data in app.tts_sessions.items()
1163
+ if current_time > session_data["expires_at"]
1164
+ ]
1165
+ for sid in expired_tts_sessions:
1166
+ cleanup_session(sid)
1167
+
1168
+ # Cleanup conversational sessions
1169
+ expired_conv_sessions = [
1170
+ sid
1171
+ for sid, session_data in app.conversational_sessions.items()
1172
+ if current_time > session_data["expires_at"]
1173
+ ]
1174
+ for sid in expired_conv_sessions:
1175
+ cleanup_conversational_session(sid)
1176
+ app.logger.info(f"Cleaned up {len(expired_tts_sessions)} TTS and {len(expired_conv_sessions)} conversational sessions.")
1177
+
1178
+ # Also cleanup potentially expired cache entries (e.g., > 1 hour old)
1179
+ # This prevents stale cache entries if generation is slow or failing
1180
+ # cleanup_stale_cache_entries()
1181
+
1182
+ # Run cleanup every 15 minutes
1183
+ scheduler = BackgroundScheduler(daemon=True) # Run scheduler as daemon thread
1184
+ scheduler.add_job(cleanup_expired_sessions, "interval", minutes=15)
1185
+ scheduler.start()
1186
+ print("Cleanup scheduler started") # Use print for startup messages
1187
+
1188
+
1189
+ # Schedule periodic tasks (database sync and preference upload)
1190
+ def setup_periodic_tasks():
1191
+ """Setup periodic database synchronization and preference data upload for Spaces"""
1192
+ if not IS_SPACES:
1193
+ return
1194
+
1195
+ db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "instance/") # Get relative path
1196
+ preferences_repo_id = "TTS-AGI/arena-v2-preferences"
1197
+ database_repo_id = "TTS-AGI/database-arena-v2"
1198
+ votes_dir = "./votes"
1199
+
1200
+ def sync_database():
1201
+ """Uploads the database to HF dataset"""
1202
+ with app.app_context(): # Ensure app context for logging
1203
+ try:
1204
+ if not os.path.exists(db_path):
1205
+ app.logger.warning(f"Database file not found at {db_path}, skipping sync.")
1206
+ return
1207
+
1208
+ api = HfApi(token=os.getenv("HF_TOKEN"))
1209
+ api.upload_file(
1210
+ path_or_fileobj=db_path,
1211
+ path_in_repo="tts_arena.db",
1212
+ repo_id=database_repo_id,
1213
+ repo_type="dataset",
1214
+ )
1215
+ app.logger.info(f"Database uploaded to {database_repo_id} at {datetime.utcnow()}")
1216
+ except Exception as e:
1217
+ app.logger.error(f"Error uploading database to {database_repo_id}: {str(e)}")
1218
+
1219
+ def sync_preferences_data():
1220
+ """Zips and uploads preference data folders in batches to HF dataset"""
1221
+ with app.app_context(): # Ensure app context for logging
1222
+ if not os.path.isdir(votes_dir):
1223
+ return # Don't log every 5 mins if dir doesn't exist yet
1224
+
1225
+ temp_batch_dir = None # Initialize to manage cleanup
1226
+ temp_individual_zip_dir = None # Initialize for individual zips
1227
+ local_batch_zip_path = None # Initialize for batch zip path
1228
+
1229
+ try:
1230
+ api = HfApi(token=os.getenv("HF_TOKEN"))
1231
+ vote_uuids = [d for d in os.listdir(votes_dir) if os.path.isdir(os.path.join(votes_dir, d))]
1232
+
1233
+ if not vote_uuids:
1234
+ return # No data to process
1235
+
1236
+ app.logger.info(f"Found {len(vote_uuids)} vote directories to process.")
1237
+
1238
+ # Create temporary directories
1239
+ temp_batch_dir = tempfile.mkdtemp(prefix="hf_batch_")
1240
+ temp_individual_zip_dir = tempfile.mkdtemp(prefix="hf_indiv_zips_")
1241
+ app.logger.debug(f"Created temp directories: {temp_batch_dir}, {temp_individual_zip_dir}")
1242
+
1243
+ processed_vote_dirs = []
1244
+ individual_zips_in_batch = []
1245
+
1246
+ # 1. Create individual zips and move them to the batch directory
1247
+ for vote_uuid in vote_uuids:
1248
+ dir_path = os.path.join(votes_dir, vote_uuid)
1249
+ individual_zip_base_path = os.path.join(temp_individual_zip_dir, vote_uuid)
1250
+ individual_zip_path = f"{individual_zip_base_path}.zip"
1251
+
1252
+ try:
1253
+ shutil.make_archive(individual_zip_base_path, 'zip', dir_path)
1254
+ app.logger.debug(f"Created individual zip: {individual_zip_path}")
1255
+
1256
+ # Move the created zip into the batch directory
1257
+ final_individual_zip_path = os.path.join(temp_batch_dir, f"{vote_uuid}.zip")
1258
+ shutil.move(individual_zip_path, final_individual_zip_path)
1259
+ app.logger.debug(f"Moved individual zip to batch dir: {final_individual_zip_path}")
1260
+
1261
+ processed_vote_dirs.append(dir_path) # Mark original dir for later cleanup
1262
+ individual_zips_in_batch.append(final_individual_zip_path)
1263
+
1264
+ except Exception as zip_err:
1265
+ app.logger.error(f"Error creating or moving zip for {vote_uuid}: {str(zip_err)}")
1266
+ # Clean up partial zip if it exists
1267
+ if os.path.exists(individual_zip_path):
1268
+ try:
1269
+ os.remove(individual_zip_path)
1270
+ except OSError:
1271
+ pass
1272
+ # Continue processing other votes
1273
+
1274
+ # Clean up the temporary dir used for creating individual zips
1275
+ shutil.rmtree(temp_individual_zip_dir)
1276
+ temp_individual_zip_dir = None # Mark as cleaned
1277
+ app.logger.debug("Cleaned up temporary individual zip directory.")
1278
+
1279
+ if not individual_zips_in_batch:
1280
+ app.logger.warning("No individual zips were successfully created for batching.")
1281
+ # Clean up batch dir if it's empty or only contains failed attempts
1282
+ if temp_batch_dir and os.path.exists(temp_batch_dir):
1283
+ shutil.rmtree(temp_batch_dir)
1284
+ temp_batch_dir = None
1285
+ return
1286
+
1287
+ # 2. Create the batch zip file
1288
+ batch_timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
1289
+ batch_uuid_short = str(uuid.uuid4())[:8]
1290
+ batch_zip_filename = f"{batch_timestamp}_batch_{batch_uuid_short}.zip"
1291
+ # Create batch zip in a standard temp location first
1292
+ local_batch_zip_base = os.path.join(tempfile.gettempdir(), batch_zip_filename.replace('.zip', ''))
1293
+ local_batch_zip_path = f"{local_batch_zip_base}.zip"
1294
+
1295
+ app.logger.info(f"Creating batch zip: {local_batch_zip_path} with {len(individual_zips_in_batch)} individual zips.")
1296
+ shutil.make_archive(local_batch_zip_base, 'zip', temp_batch_dir)
1297
+ app.logger.info(f"Batch zip created successfully: {local_batch_zip_path}")
1298
+
1299
+ # 3. Upload the batch zip file
1300
+ hf_repo_path = f"votes/{year}/{month}/{batch_zip_filename}"
1301
+ app.logger.info(f"Uploading batch zip to HF Hub: {preferences_repo_id}/{hf_repo_path}")
1302
+
1303
+ api.upload_file(
1304
+ path_or_fileobj=local_batch_zip_path,
1305
+ path_in_repo=hf_repo_path,
1306
+ repo_id=preferences_repo_id,
1307
+ repo_type="dataset",
1308
+ commit_message=f"Add batch preference data {batch_zip_filename} ({len(individual_zips_in_batch)} votes)"
1309
+ )
1310
+ app.logger.info(f"Successfully uploaded batch {batch_zip_filename} to {preferences_repo_id}")
1311
+
1312
+ # 4. Cleanup after successful upload
1313
+ app.logger.info("Cleaning up local files after successful upload.")
1314
+ # Remove original vote directories that were successfully zipped and uploaded
1315
+ for dir_path in processed_vote_dirs:
1316
+ try:
1317
+ shutil.rmtree(dir_path)
1318
+ app.logger.debug(f"Removed original vote directory: {dir_path}")
1319
+ except OSError as e:
1320
+ app.logger.error(f"Error removing processed vote directory {dir_path}: {str(e)}")
1321
+
1322
+ # Remove the temporary batch directory (containing the individual zips)
1323
+ shutil.rmtree(temp_batch_dir)
1324
+ temp_batch_dir = None
1325
+ app.logger.debug("Removed temporary batch directory.")
1326
+
1327
+ # Remove the local batch zip file
1328
+ os.remove(local_batch_zip_path)
1329
+ local_batch_zip_path = None
1330
+ app.logger.debug("Removed local batch zip file.")
1331
+
1332
+ app.logger.info(f"Finished preference data sync. Uploaded batch {batch_zip_filename}.")
1333
+
1334
+ except Exception as e:
1335
+ app.logger.error(f"Error during preference data batch sync: {str(e)}", exc_info=True)
1336
+ # If upload failed, the local batch zip might exist, clean it up.
1337
+ if local_batch_zip_path and os.path.exists(local_batch_zip_path):
1338
+ try:
1339
+ os.remove(local_batch_zip_path)
1340
+ app.logger.debug("Cleaned up local batch zip after failed upload.")
1341
+ except OSError as clean_err:
1342
+ app.logger.error(f"Error cleaning up batch zip after failed upload: {clean_err}")
1343
+ # Do NOT remove temp_batch_dir if it exists; its contents will be retried next time.
1344
+ # Do NOT remove original vote directories if upload failed.
1345
+
1346
+ finally:
1347
+ # Final cleanup for temporary directories in case of unexpected exits
1348
+ if temp_individual_zip_dir and os.path.exists(temp_individual_zip_dir):
1349
+ try:
1350
+ shutil.rmtree(temp_individual_zip_dir)
1351
+ except Exception as final_clean_err:
1352
+ app.logger.error(f"Error in final cleanup (indiv zips): {final_clean_err}")
1353
+ # Only clean up batch dir in finally block if it *wasn't* kept intentionally after upload failure
1354
+ if temp_batch_dir and os.path.exists(temp_batch_dir):
1355
+ # Check if an upload attempt happened and failed
1356
+ upload_failed = 'e' in locals() and isinstance(e, Exception) # Crude check if exception occurred
1357
+ if not upload_failed: # If no upload error or upload succeeded, clean up
1358
+ try:
1359
+ shutil.rmtree(temp_batch_dir)
1360
+ except Exception as final_clean_err:
1361
+ app.logger.error(f"Error in final cleanup (batch dir): {final_clean_err}")
1362
+ else:
1363
+ app.logger.warning("Keeping temporary batch directory due to upload failure for next attempt.")
1364
+
1365
+
1366
+ # Schedule periodic tasks
1367
+ scheduler = BackgroundScheduler()
1368
+ # Sync database less frequently if needed, e.g., every 15 minutes
1369
+ scheduler.add_job(sync_database, "interval", minutes=15, id="sync_db_job")
1370
+ # Sync preferences more frequently
1371
+ scheduler.add_job(sync_preferences_data, "interval", minutes=5, id="sync_pref_job")
1372
+ scheduler.start()
1373
+ print("Periodic tasks scheduler started (DB sync and Preferences upload)") # Use print for startup
1374
+
1375
+
1376
+ @app.cli.command("init-db")
1377
+ def init_db():
1378
+ """Initialize the database."""
1379
+ with app.app_context():
1380
+ db.create_all()
1381
+ print("Database initialized!")
1382
+
1383
+
1384
+ @app.route("/api/toggle-leaderboard-visibility", methods=["POST"])
1385
+ def toggle_leaderboard_visibility():
1386
+ """Toggle whether the current user appears in the top voters leaderboard"""
1387
+ if not current_user.is_authenticated:
1388
+ return jsonify({"error": "You must be logged in to change this setting"}), 401
1389
+
1390
+ new_status = toggle_user_leaderboard_visibility(current_user.id)
1391
+ if new_status is None:
1392
+ return jsonify({"error": "User not found"}), 404
1393
+
1394
+ return jsonify({
1395
+ "success": True,
1396
+ "visible": new_status,
1397
+ "message": "You are now visible in the voters leaderboard" if new_status else "You are now hidden from the voters leaderboard"
1398
+ })
1399
+
1400
+
1401
+ @app.route("/api/tts/cached-sentences")
1402
+ def get_cached_sentences():
1403
+ """Returns a list of sentences currently available in the TTS cache."""
1404
+ with tts_cache_lock:
1405
+ cached_keys = list(tts_cache.keys())
1406
+ return jsonify(cached_keys)
1407
+
1408
+
1409
+ def get_weighted_random_models(
1410
+ applicable_models: list[Model], num_to_select: int, model_type: ModelType
1411
+ ) -> list[Model]:
1412
+ """
1413
+ Selects a specified number of models randomly from a list of applicable_models,
1414
+ weighting models with fewer votes higher. A smoothing factor is used to ensure
1415
+ the preference is slight and to prevent models with zero votes from being
1416
+ overwhelmingly favored. Models are selected without replacement.
1417
+
1418
+ Assumes len(applicable_models) >= num_to_select, which should be checked by the caller.
1419
+ """
1420
+ model_votes_counts = {}
1421
+ for model in applicable_models:
1422
+ votes = (
1423
+ Vote.query.filter(Vote.model_type == model_type)
1424
+ .filter(or_(Vote.model_chosen == model.id, Vote.model_rejected == model.id))
1425
+ .count()
1426
+ )
1427
+ model_votes_counts[model.id] = votes
1428
+
1429
+ weights = [
1430
+ 1.0 / (model_votes_counts[model.id] + SMOOTHING_FACTOR_MODEL_SELECTION)
1431
+ for model in applicable_models
1432
+ ]
1433
+
1434
+ selected_models_list = []
1435
+ # Create copies to modify during selection process
1436
+ current_candidates = list(applicable_models)
1437
+ current_weights = list(weights)
1438
+
1439
+ # Assumes num_to_select is positive and less than or equal to len(current_candidates)
1440
+ # Callers should ensure this (e.g., len(available_models) >= 2).
1441
+ for _ in range(num_to_select):
1442
+ if not current_candidates: # Safety break
1443
+ app.logger.warning("Not enough candidates left for weighted selection.")
1444
+ break
1445
+
1446
+ chosen_model = random.choices(current_candidates, weights=current_weights, k=1)[0]
1447
+ selected_models_list.append(chosen_model)
1448
+
1449
+ try:
1450
+ idx_to_remove = current_candidates.index(chosen_model)
1451
+ current_candidates.pop(idx_to_remove)
1452
+ current_weights.pop(idx_to_remove)
1453
+ except ValueError:
1454
+ # This should ideally not happen if chosen_model came from current_candidates.
1455
+ app.logger.error(f"Error removing model {chosen_model.id} from weighted selection candidates.")
1456
+ break # Avoid potential issues
1457
+
1458
+ return selected_models_list
1459
+
1460
+
1461
+ def check_for_coordinated_campaigns():
1462
+ """Check all active models for potential coordinated voting campaigns"""
1463
+ try:
1464
+ from security import detect_coordinated_voting
1465
+ from models import Model, ModelType
1466
+
1467
+ # Check TTS models
1468
+ tts_models = Model.query.filter_by(model_type=ModelType.TTS, is_active=True).all()
1469
+ for model in tts_models:
1470
+ try:
1471
+ detect_coordinated_voting(model.id)
1472
+ except Exception as e:
1473
+ app.logger.error(f"Error checking coordinated voting for TTS model {model.id}: {str(e)}")
1474
+
1475
+ # Check conversational models
1476
+ conv_models = Model.query.filter_by(model_type=ModelType.CONVERSATIONAL, is_active=True).all()
1477
+ for model in conv_models:
1478
+ try:
1479
+ detect_coordinated_voting(model.id)
1480
+ except Exception as e:
1481
+ app.logger.error(f"Error checking coordinated voting for conversational model {model.id}: {str(e)}")
1482
+
1483
+ except Exception as e:
1484
+ app.logger.error(f"Error in coordinated campaign check: {str(e)}")
1485
+
1486
 
1487
  if __name__ == "__main__":
1488
+ with app.app_context():
1489
+ # Ensure ./instance and ./votes directories exist
1490
+ os.makedirs("instance", exist_ok=True)
1491
+ os.makedirs("./votes", exist_ok=True) # Create votes directory if it doesn't exist
1492
+ os.makedirs(CACHE_AUDIO_DIR, exist_ok=True) # Ensure cache audio dir exists
1493
+
1494
+ # Clean up old cache audio files on startup
1495
+ try:
1496
+ app.logger.info(f"Clearing old cache audio files from {CACHE_AUDIO_DIR}")
1497
+ for filename in os.listdir(CACHE_AUDIO_DIR):
1498
+ file_path = os.path.join(CACHE_AUDIO_DIR, filename)
1499
+ try:
1500
+ if os.path.isfile(file_path) or os.path.islink(file_path):
1501
+ os.unlink(file_path)
1502
+ elif os.path.isdir(file_path):
1503
+ shutil.rmtree(file_path)
1504
+ except Exception as e:
1505
+ app.logger.error(f'Failed to delete {file_path}. Reason: {e}')
1506
+ except Exception as e:
1507
+ app.logger.error(f"Error clearing cache directory {CACHE_AUDIO_DIR}: {e}")
1508
+
1509
+
1510
+ # Download database if it doesn't exist (only on initial space start)
1511
+ if IS_SPACES and not os.path.exists(app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")):
1512
+ try:
1513
+ print("Database not found, downloading from HF dataset...")
1514
+ hf_hub_download(
1515
+ repo_id="TTS-AGI/database-arena-v2",
1516
+ filename="tts_arena.db",
1517
+ repo_type="dataset",
1518
+ local_dir="instance", # download to instance/
1519
+ token=os.getenv("HF_TOKEN"),
1520
+ )
1521
+ print("Database downloaded successfully ✅")
1522
+ except Exception as e:
1523
+ print(f"Error downloading database from HF dataset: {str(e)} ⚠️")
1524
+
1525
+
1526
+ db.create_all() # Create tables if they don't exist
1527
+ insert_initial_models()
1528
+ # Setup background tasks
1529
+ initialize_tts_cache() # Start populating the cache
1530
+ setup_cleanup()
1531
+ setup_periodic_tasks() # Renamed function call
1532
+
1533
+ # Configure Flask to recognize HTTPS when behind a reverse proxy
1534
+ from werkzeug.middleware.proxy_fix import ProxyFix
1535
+
1536
+ # Apply ProxyFix middleware to handle reverse proxy headers
1537
+ # This ensures Flask generates correct URLs with https scheme
1538
+ # X-Forwarded-Proto header will be used to detect the original protocol
1539
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
1540
+
1541
+ # Force Flask to prefer HTTPS for generated URLs
1542
+ app.config["PREFERRED_URL_SCHEME"] = "https"
1543
+
1544
+ from waitress import serve
1545
+
1546
+ # Configuration for 2 vCPUs:
1547
+ # - threads: typically 4-8 threads per CPU core is a good balance
1548
+ # - connection_limit: maximum concurrent connections
1549
+ # - channel_timeout: prevent hanging connections
1550
+ threads = 12 # 6 threads per vCPU is a good balance for mixed IO/CPU workloads
1551
+
1552
+ if IS_SPACES:
1553
+ serve(
1554
+ app,
1555
+ host="0.0.0.0",
1556
+ port=int(os.environ.get("PORT", 7860)),
1557
+ threads=threads,
1558
+ connection_limit=100,
1559
+ channel_timeout=30,
1560
+ url_scheme='https'
1561
+ )
1562
+ else:
1563
+ print(f"Starting Waitress server with {threads} threads")
1564
+ serve(
1565
+ app,
1566
+ host="0.0.0.0",
1567
+ port=5000,
1568
+ threads=threads,
1569
+ connection_limit=100,
1570
+ channel_timeout=30,
1571
+ url_scheme='https' # Keep https for local dev if using proxy/tunnel
1572
+ )
migrate.py CHANGED
@@ -1,6 +1,6 @@
1
  #!/usr/bin/env python3
2
  """
3
- Database migration script for TTS Arena analytics columns.
4
 
5
  Usage:
6
  python migrate.py database.db
@@ -21,8 +21,93 @@ def check_column_exists(cursor, table_name, column_name):
21
  return column_name in columns
22
 
23
 
24
- def add_analytics_columns(db_path):
25
- """Add analytics columns to the vote table."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  if not os.path.exists(db_path):
27
  click.echo(f"❌ Database file not found: {db_path}", err=True)
28
  return False
@@ -87,6 +172,10 @@ def add_analytics_columns(db_path):
87
  conn.rollback()
88
  return False
89
 
 
 
 
 
90
  # Commit the changes
91
  conn.commit()
92
  conn.close()
@@ -102,11 +191,26 @@ def add_analytics_columns(db_path):
102
  for col in skipped_columns:
103
  click.echo(f" • {col}")
104
 
105
- if not added_columns and not skipped_columns:
106
- click.echo(" No columns were processed")
 
 
 
 
 
107
  return False
108
 
109
  click.echo(f"\n✨ Migration completed successfully!")
 
 
 
 
 
 
 
 
 
 
110
  return True
111
 
112
  except sqlite3.Error as e:
@@ -123,11 +227,12 @@ def add_analytics_columns(db_path):
123
  @click.option('--backup', is_flag=True, help='Create a backup before migration')
124
  def migrate(database_path, dry_run, backup):
125
  """
126
- Add analytics columns to the TTS Arena database.
127
 
128
  DATABASE_PATH: Path to the SQLite database file (e.g., instance/tts_arena.db)
129
  """
130
- click.echo("🚀 TTS Arena Analytics Migration Tool")
 
131
  click.echo("=" * 40)
132
 
133
  # Resolve the database path
@@ -151,7 +256,7 @@ def migrate(database_path, dry_run, backup):
151
 
152
  if dry_run:
153
  click.echo("\n🔍 DRY RUN MODE - No changes will be made")
154
- click.echo("The following columns would be added to the 'vote' table:")
155
  click.echo(" • session_duration_seconds (REAL)")
156
  click.echo(" • ip_address_partial (VARCHAR(20))")
157
  click.echo(" • user_agent (VARCHAR(500))")
@@ -159,6 +264,10 @@ def migrate(database_path, dry_run, backup):
159
  click.echo(" • cache_hit (BOOLEAN)")
160
  click.echo("\nThe following columns would be added to the 'user' table:")
161
  click.echo(" • hf_account_created (DATETIME)")
 
 
 
 
162
  click.echo("\nRun without --dry-run to apply changes.")
163
  return
164
 
@@ -169,11 +278,11 @@ def migrate(database_path, dry_run, backup):
169
 
170
  # Perform the migration
171
  click.echo("\n🔧 Starting migration...")
172
- success = add_analytics_columns(str(db_path))
173
 
174
  if success:
175
  click.echo("\n🎊 Migration completed successfully!")
176
- click.echo("You can now restart your TTS Arena application to use analytics features.")
177
  else:
178
  click.echo("\n💥 Migration failed!")
179
  sys.exit(1)
 
1
  #!/usr/bin/env python3
2
  """
3
+ Database migration script for TTS Arena analytics columns and new security features.
4
 
5
  Usage:
6
  python migrate.py database.db
 
21
  return column_name in columns
22
 
23
 
24
+ def check_table_exists(cursor, table_name):
25
+ """Check if a table exists in the database."""
26
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
27
+ return cursor.fetchone() is not None
28
+
29
+
30
+ def create_timeout_and_campaign_tables(cursor):
31
+ """Create the new timeout and campaign tables."""
32
+ tables_created = []
33
+
34
+ # Create coordinated_voting_campaign table
35
+ if not check_table_exists(cursor, "coordinated_voting_campaign"):
36
+ cursor.execute("""
37
+ CREATE TABLE coordinated_voting_campaign (
38
+ id INTEGER PRIMARY KEY,
39
+ model_id VARCHAR(100) NOT NULL,
40
+ model_type VARCHAR(20) NOT NULL,
41
+ detected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
42
+ time_window_hours INTEGER NOT NULL,
43
+ vote_count INTEGER NOT NULL,
44
+ user_count INTEGER NOT NULL,
45
+ confidence_score REAL NOT NULL,
46
+ status VARCHAR(20) DEFAULT 'active',
47
+ admin_notes TEXT,
48
+ resolved_by INTEGER,
49
+ resolved_at DATETIME,
50
+ FOREIGN KEY (model_id) REFERENCES model (id),
51
+ FOREIGN KEY (resolved_by) REFERENCES user (id)
52
+ )
53
+ """)
54
+ tables_created.append("coordinated_voting_campaign")
55
+ click.echo("✅ Created table 'coordinated_voting_campaign'")
56
+ else:
57
+ click.echo("⏭️ Table 'coordinated_voting_campaign' already exists, skipping")
58
+
59
+ # Create campaign_participant table
60
+ if not check_table_exists(cursor, "campaign_participant"):
61
+ cursor.execute("""
62
+ CREATE TABLE campaign_participant (
63
+ id INTEGER PRIMARY KEY,
64
+ campaign_id INTEGER NOT NULL,
65
+ user_id INTEGER NOT NULL,
66
+ votes_in_campaign INTEGER NOT NULL,
67
+ first_vote_at DATETIME NOT NULL,
68
+ last_vote_at DATETIME NOT NULL,
69
+ suspicion_level VARCHAR(20) NOT NULL,
70
+ FOREIGN KEY (campaign_id) REFERENCES coordinated_voting_campaign (id),
71
+ FOREIGN KEY (user_id) REFERENCES user (id)
72
+ )
73
+ """)
74
+ tables_created.append("campaign_participant")
75
+ click.echo("✅ Created table 'campaign_participant'")
76
+ else:
77
+ click.echo("⏭️ Table 'campaign_participant' already exists, skipping")
78
+
79
+ # Create user_timeout table
80
+ if not check_table_exists(cursor, "user_timeout"):
81
+ cursor.execute("""
82
+ CREATE TABLE user_timeout (
83
+ id INTEGER PRIMARY KEY,
84
+ user_id INTEGER NOT NULL,
85
+ reason VARCHAR(500) NOT NULL,
86
+ timeout_type VARCHAR(50) NOT NULL,
87
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
88
+ expires_at DATETIME NOT NULL,
89
+ created_by INTEGER,
90
+ is_active BOOLEAN DEFAULT 1,
91
+ cancelled_at DATETIME,
92
+ cancelled_by INTEGER,
93
+ cancel_reason VARCHAR(500),
94
+ related_campaign_id INTEGER,
95
+ FOREIGN KEY (user_id) REFERENCES user (id),
96
+ FOREIGN KEY (created_by) REFERENCES user (id),
97
+ FOREIGN KEY (cancelled_by) REFERENCES user (id),
98
+ FOREIGN KEY (related_campaign_id) REFERENCES coordinated_voting_campaign (id)
99
+ )
100
+ """)
101
+ tables_created.append("user_timeout")
102
+ click.echo("✅ Created table 'user_timeout'")
103
+ else:
104
+ click.echo("⏭️ Table 'user_timeout' already exists, skipping")
105
+
106
+ return tables_created
107
+
108
+
109
+ def add_analytics_columns_and_tables(db_path):
110
+ """Add analytics columns and create new security tables."""
111
  if not os.path.exists(db_path):
112
  click.echo(f"❌ Database file not found: {db_path}", err=True)
113
  return False
 
172
  conn.rollback()
173
  return False
174
 
175
+ # Create new security tables
176
+ click.echo("🔒 Creating security and timeout management tables...")
177
+ tables_created = create_timeout_and_campaign_tables(cursor)
178
+
179
  # Commit the changes
180
  conn.commit()
181
  conn.close()
 
191
  for col in skipped_columns:
192
  click.echo(f" • {col}")
193
 
194
+ if tables_created:
195
+ click.echo(f"\n🔒 Successfully created {len(tables_created)} security tables:")
196
+ for table in tables_created:
197
+ click.echo(f" • {table}")
198
+
199
+ if not added_columns and not skipped_columns and not tables_created:
200
+ click.echo("❌ No columns or tables were processed")
201
  return False
202
 
203
  click.echo(f"\n✨ Migration completed successfully!")
204
+
205
+ if tables_created:
206
+ click.echo("\n🚨 New Security Features Enabled:")
207
+ click.echo(" • Automatic coordinated voting campaign detection")
208
+ click.echo(" • User timeout management")
209
+ click.echo(" • Admin panels for security monitoring")
210
+ click.echo("\nNew admin panel sections:")
211
+ click.echo(" • /admin/timeouts - Manage user timeouts")
212
+ click.echo(" • /admin/campaigns - View coordinated voting campaigns")
213
+
214
  return True
215
 
216
  except sqlite3.Error as e:
 
227
  @click.option('--backup', is_flag=True, help='Create a backup before migration')
228
  def migrate(database_path, dry_run, backup):
229
  """
230
+ Add analytics columns and security tables to the TTS Arena database.
231
 
232
  DATABASE_PATH: Path to the SQLite database file (e.g., instance/tts_arena.db)
233
  """
234
+ click.echo("🚀 TTS Arena Migration Tool")
235
+ click.echo("Analytics + Security Features")
236
  click.echo("=" * 40)
237
 
238
  # Resolve the database path
 
256
 
257
  if dry_run:
258
  click.echo("\n🔍 DRY RUN MODE - No changes will be made")
259
+ click.echo("\nThe following columns would be added to the 'vote' table:")
260
  click.echo(" • session_duration_seconds (REAL)")
261
  click.echo(" • ip_address_partial (VARCHAR(20))")
262
  click.echo(" • user_agent (VARCHAR(500))")
 
264
  click.echo(" • cache_hit (BOOLEAN)")
265
  click.echo("\nThe following columns would be added to the 'user' table:")
266
  click.echo(" • hf_account_created (DATETIME)")
267
+ click.echo("\nThe following security tables would be created:")
268
+ click.echo(" • coordinated_voting_campaign - Track detected voting campaigns")
269
+ click.echo(" • campaign_participant - Track users involved in campaigns")
270
+ click.echo(" • user_timeout - Manage user timeouts/bans")
271
  click.echo("\nRun without --dry-run to apply changes.")
272
  return
273
 
 
278
 
279
  # Perform the migration
280
  click.echo("\n🔧 Starting migration...")
281
+ success = add_analytics_columns_and_tables(str(db_path))
282
 
283
  if success:
284
  click.echo("\n🎊 Migration completed successfully!")
285
+ click.echo("You can now restart your TTS Arena application to use all new features.")
286
  else:
287
  click.echo("\n💥 Migration failed!")
288
  sys.exit(1)
migrate_timeout_tables.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Migration script to add coordinated voting campaign detection and user timeout tables.
4
+ Run this script to add the new tables to your existing database.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ from datetime import datetime
10
+
11
+ # Add the current directory to the Python path
12
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
+
14
+ from app import app
15
+ from models import db, CoordinatedVotingCampaign, CampaignParticipant, UserTimeout
16
+
17
+ def migrate_database():
18
+ """Add the new tables to the database"""
19
+ with app.app_context():
20
+ try:
21
+ print("Creating new tables for coordinated voting detection and user timeouts...")
22
+
23
+ # Create the new tables
24
+ db.create_all()
25
+
26
+ print("✅ Successfully created new tables:")
27
+ print(" - coordinated_voting_campaign")
28
+ print(" - campaign_participant")
29
+ print(" - user_timeout")
30
+
31
+ print("\nMigration completed successfully!")
32
+ return True
33
+
34
+ except Exception as e:
35
+ print(f"❌ Error during migration: {str(e)}")
36
+ return False
37
+
38
+ if __name__ == "__main__":
39
+ print("TTS Arena - Database Migration for Timeout and Campaign Management")
40
+ print("=" * 70)
41
+
42
+ # Confirm with user
43
+ response = input("This will add new tables to your database. Continue? (y/N): ")
44
+ if response.lower() != 'y':
45
+ print("Migration cancelled.")
46
+ sys.exit(0)
47
+
48
+ success = migrate_database()
49
+
50
+ if success:
51
+ print("\n" + "=" * 70)
52
+ print("Migration completed! You can now:")
53
+ print("1. Access the new admin panels for timeout and campaign management")
54
+ print("2. Automatic coordinated voting detection is now active")
55
+ print("3. Users involved in coordinated campaigns will be automatically timed out")
56
+ print("\nNew admin panel sections:")
57
+ print("- /admin/timeouts - Manage user timeouts")
58
+ print("- /admin/campaigns - View and manage coordinated voting campaigns")
59
+ else:
60
+ print("\n❌ Migration failed. Please check the error messages above.")
61
+ sys.exit(1)
models.py CHANGED
@@ -1,6 +1,6 @@
1
  from flask_sqlalchemy import SQLAlchemy
2
  from flask_login import UserMixin
3
- from datetime import datetime
4
  import math
5
  from sqlalchemy import func, text
6
  import logging
@@ -103,6 +103,77 @@ class EloHistory(db.Model):
103
  return f"<EloHistory {self.model_id}: {self.elo_score} at {self.timestamp} ({self.model_type})>"
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  def calculate_elo_change(winner_elo, loser_elo, k_factor=32):
107
  """Calculate Elo rating changes for a match."""
108
  expected_winner = 1 / (1 + math.pow(10, (loser_elo - winner_elo) / 400))
@@ -624,20 +695,131 @@ def get_top_voters(limit=10):
624
 
625
 
626
  def toggle_user_leaderboard_visibility(user_id):
627
- """
628
- Toggle whether a user appears in the voters leaderboard
629
-
630
- Args:
631
- user_id (int): The user ID
632
-
633
- Returns:
634
- bool: New visibility state
635
- """
636
  user = User.query.get(user_id)
637
  if not user:
638
  return None
639
-
640
  user.show_in_leaderboard = not user.show_in_leaderboard
641
  db.session.commit()
642
-
643
  return user.show_in_leaderboard
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from flask_sqlalchemy import SQLAlchemy
2
  from flask_login import UserMixin
3
+ from datetime import datetime, timedelta
4
  import math
5
  from sqlalchemy import func, text
6
  import logging
 
103
  return f"<EloHistory {self.model_id}: {self.elo_score} at {self.timestamp} ({self.model_type})>"
104
 
105
 
106
+ class CoordinatedVotingCampaign(db.Model):
107
+ """Log detected coordinated voting campaigns"""
108
+ id = db.Column(db.Integer, primary_key=True)
109
+ model_id = db.Column(db.String(100), db.ForeignKey("model.id"), nullable=False)
110
+ model_type = db.Column(db.String(20), nullable=False)
111
+ detected_at = db.Column(db.DateTime, default=datetime.utcnow)
112
+ time_window_hours = db.Column(db.Integer, nullable=False) # Detection window (e.g., 6 hours)
113
+ vote_count = db.Column(db.Integer, nullable=False) # Total votes in the campaign
114
+ user_count = db.Column(db.Integer, nullable=False) # Number of users involved
115
+ confidence_score = db.Column(db.Float, nullable=False) # 0-1 confidence level
116
+ status = db.Column(db.String(20), default='active') # active, resolved, false_positive
117
+ admin_notes = db.Column(db.Text, nullable=True)
118
+ resolved_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
119
+ resolved_at = db.Column(db.DateTime, nullable=True)
120
+
121
+ model = db.relationship("Model", backref=db.backref("coordinated_campaigns", lazy=True))
122
+ resolver = db.relationship("User", backref=db.backref("resolved_campaigns", lazy=True))
123
+
124
+ def __repr__(self):
125
+ return f"<CoordinatedVotingCampaign {self.id}: {self.model_id} ({self.vote_count} votes, {self.user_count} users)>"
126
+
127
+
128
+ class CampaignParticipant(db.Model):
129
+ """Track users involved in coordinated voting campaigns"""
130
+ id = db.Column(db.Integer, primary_key=True)
131
+ campaign_id = db.Column(db.Integer, db.ForeignKey("coordinated_voting_campaign.id"), nullable=False)
132
+ user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
133
+ votes_in_campaign = db.Column(db.Integer, nullable=False)
134
+ first_vote_at = db.Column(db.DateTime, nullable=False)
135
+ last_vote_at = db.Column(db.DateTime, nullable=False)
136
+ suspicion_level = db.Column(db.String(20), nullable=False) # low, medium, high
137
+
138
+ campaign = db.relationship("CoordinatedVotingCampaign", backref=db.backref("participants", lazy=True))
139
+ user = db.relationship("User", backref=db.backref("campaign_participations", lazy=True))
140
+
141
+ def __repr__(self):
142
+ return f"<CampaignParticipant {self.user_id} in campaign {self.campaign_id} ({self.votes_in_campaign} votes)>"
143
+
144
+
145
+ class UserTimeout(db.Model):
146
+ """Track user timeouts/bans for suspicious activity"""
147
+ id = db.Column(db.Integer, primary_key=True)
148
+ user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
149
+ reason = db.Column(db.String(500), nullable=False) # Reason for timeout
150
+ timeout_type = db.Column(db.String(50), nullable=False) # coordinated_voting, rapid_voting, manual, etc.
151
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
152
+ expires_at = db.Column(db.DateTime, nullable=False)
153
+ created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) # Admin who created timeout
154
+ is_active = db.Column(db.Boolean, default=True)
155
+ cancelled_at = db.Column(db.DateTime, nullable=True)
156
+ cancelled_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
157
+ cancel_reason = db.Column(db.String(500), nullable=True)
158
+
159
+ # Related campaign if timeout was due to coordinated voting
160
+ related_campaign_id = db.Column(db.Integer, db.ForeignKey("coordinated_voting_campaign.id"), nullable=True)
161
+
162
+ user = db.relationship("User", foreign_keys=[user_id], backref=db.backref("timeouts", lazy=True))
163
+ creator = db.relationship("User", foreign_keys=[created_by], backref=db.backref("created_timeouts", lazy=True))
164
+ canceller = db.relationship("User", foreign_keys=[cancelled_by], backref=db.backref("cancelled_timeouts", lazy=True))
165
+ related_campaign = db.relationship("CoordinatedVotingCampaign", backref=db.backref("resulting_timeouts", lazy=True))
166
+
167
+ def is_currently_active(self):
168
+ """Check if timeout is currently active"""
169
+ if not self.is_active:
170
+ return False
171
+ return datetime.utcnow() < self.expires_at
172
+
173
+ def __repr__(self):
174
+ return f"<UserTimeout {self.user_id}: {self.timeout_type} until {self.expires_at}>"
175
+
176
+
177
  def calculate_elo_change(winner_elo, loser_elo, k_factor=32):
178
  """Calculate Elo rating changes for a match."""
179
  expected_winner = 1 / (1 + math.pow(10, (loser_elo - winner_elo) / 400))
 
695
 
696
 
697
  def toggle_user_leaderboard_visibility(user_id):
698
+ """Toggle user's leaderboard visibility setting"""
 
 
 
 
 
 
 
 
699
  user = User.query.get(user_id)
700
  if not user:
701
  return None
702
+
703
  user.show_in_leaderboard = not user.show_in_leaderboard
704
  db.session.commit()
 
705
  return user.show_in_leaderboard
706
+
707
+
708
+ def check_user_timeout(user_id):
709
+ """Check if a user is currently timed out"""
710
+ if not user_id:
711
+ return False, None
712
+
713
+ active_timeout = UserTimeout.query.filter_by(
714
+ user_id=user_id,
715
+ is_active=True
716
+ ).filter(
717
+ UserTimeout.expires_at > datetime.utcnow()
718
+ ).order_by(UserTimeout.expires_at.desc()).first()
719
+
720
+ return active_timeout is not None, active_timeout
721
+
722
+
723
+ def create_user_timeout(user_id, reason, timeout_type, duration_days, created_by=None, related_campaign_id=None):
724
+ """Create a new user timeout"""
725
+ expires_at = datetime.utcnow() + timedelta(days=duration_days)
726
+
727
+ timeout = UserTimeout(
728
+ user_id=user_id,
729
+ reason=reason,
730
+ timeout_type=timeout_type,
731
+ expires_at=expires_at,
732
+ created_by=created_by,
733
+ related_campaign_id=related_campaign_id
734
+ )
735
+
736
+ db.session.add(timeout)
737
+ db.session.commit()
738
+ return timeout
739
+
740
+
741
+ def cancel_user_timeout(timeout_id, cancelled_by, cancel_reason):
742
+ """Cancel an active timeout"""
743
+ timeout = UserTimeout.query.get(timeout_id)
744
+ if not timeout:
745
+ return False, "Timeout not found"
746
+
747
+ timeout.is_active = False
748
+ timeout.cancelled_at = datetime.utcnow()
749
+ timeout.cancelled_by = cancelled_by
750
+ timeout.cancel_reason = cancel_reason
751
+
752
+ db.session.commit()
753
+ return True, "Timeout cancelled successfully"
754
+
755
+
756
+ def log_coordinated_campaign(model_id, model_type, vote_count, user_count,
757
+ time_window_hours, confidence_score, participants_data):
758
+ """Log a detected coordinated voting campaign"""
759
+ campaign = CoordinatedVotingCampaign(
760
+ model_id=model_id,
761
+ model_type=model_type,
762
+ time_window_hours=time_window_hours,
763
+ vote_count=vote_count,
764
+ user_count=user_count,
765
+ confidence_score=confidence_score
766
+ )
767
+
768
+ db.session.add(campaign)
769
+ db.session.flush() # Get campaign ID
770
+
771
+ # Add participants
772
+ for participant_data in participants_data:
773
+ participant = CampaignParticipant(
774
+ campaign_id=campaign.id,
775
+ user_id=participant_data['user_id'],
776
+ votes_in_campaign=participant_data['votes_in_campaign'],
777
+ first_vote_at=participant_data['first_vote_at'],
778
+ last_vote_at=participant_data['last_vote_at'],
779
+ suspicion_level=participant_data['suspicion_level']
780
+ )
781
+ db.session.add(participant)
782
+
783
+ db.session.commit()
784
+ return campaign
785
+
786
+
787
+ def get_user_timeouts(user_id=None, active_only=True, limit=50):
788
+ """Get user timeouts with optional filtering"""
789
+ query = UserTimeout.query
790
+
791
+ if user_id:
792
+ query = query.filter_by(user_id=user_id)
793
+
794
+ if active_only:
795
+ query = query.filter_by(is_active=True).filter(
796
+ UserTimeout.expires_at > datetime.utcnow()
797
+ )
798
+
799
+ return query.order_by(UserTimeout.created_at.desc()).limit(limit).all()
800
+
801
+
802
+ def get_coordinated_campaigns(status=None, limit=50):
803
+ """Get coordinated voting campaigns with optional status filtering"""
804
+ query = CoordinatedVotingCampaign.query
805
+
806
+ if status:
807
+ query = query.filter_by(status=status)
808
+
809
+ return query.order_by(CoordinatedVotingCampaign.detected_at.desc()).limit(limit).all()
810
+
811
+
812
+ def resolve_campaign(campaign_id, resolved_by, status, admin_notes=None):
813
+ """Mark a campaign as resolved"""
814
+ campaign = CoordinatedVotingCampaign.query.get(campaign_id)
815
+ if not campaign:
816
+ return False, "Campaign not found"
817
+
818
+ campaign.status = status
819
+ campaign.resolved_by = resolved_by
820
+ campaign.resolved_at = datetime.utcnow()
821
+ if admin_notes:
822
+ campaign.admin_notes = admin_notes
823
+
824
+ db.session.commit()
825
+ return True, "Campaign resolved successfully"
security.py CHANGED
@@ -89,7 +89,7 @@ def detect_coordinated_voting(model_id, hours_back=6, min_users=3, vote_threshol
89
  time_threshold = datetime.utcnow() - timedelta(hours=hours_back)
90
 
91
  # Get recent votes for this model
92
- recent_votes = db.session.query(Vote.user_id).filter(
93
  and_(
94
  Vote.model_chosen == model_id,
95
  Vote.vote_date >= time_threshold
@@ -99,34 +99,118 @@ def detect_coordinated_voting(model_id, hours_back=6, min_users=3, vote_threshol
99
  if len(recent_votes) < vote_threshold:
100
  return False, 0, len(recent_votes), []
101
 
102
- # Count unique users
103
- unique_users = set(vote.user_id for vote in recent_votes if vote.user_id)
104
- user_count = len(unique_users)
 
 
 
 
105
 
106
- # Check if multiple users are voting for the same model in a short time
 
 
107
  if user_count >= min_users and len(recent_votes) >= vote_threshold:
108
- # Get user details for suspicious users
109
  suspicious_users = []
110
- for user_id in unique_users:
111
- user_votes_for_model = Vote.query.filter(
112
- and_(
113
- Vote.user_id == user_id,
114
- Vote.model_chosen == model_id,
115
- Vote.vote_date >= time_threshold
116
- )
117
- ).count()
118
 
119
  if user_votes_for_model > 1: # Multiple votes for same model in short time
120
  user = User.query.get(user_id)
121
  if user:
122
- suspicious_users.append({
 
 
 
 
 
 
 
 
 
 
 
 
123
  'user_id': user_id,
124
  'username': user.username,
125
  'votes_for_model': user_votes_for_model,
126
- 'account_age_days': (datetime.utcnow() - user.join_date).days if user.join_date else None
127
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
- return True, user_count, len(recent_votes), suspicious_users
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
  return False, user_count, len(recent_votes), []
132
 
@@ -325,6 +409,27 @@ def is_vote_allowed(user_id, ip_address=None):
325
  if not user_id:
326
  return False, "User not authenticated", 0
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  # Check security score
329
  score, factors = check_user_security_score(user_id)
330
 
 
89
  time_threshold = datetime.utcnow() - timedelta(hours=hours_back)
90
 
91
  # Get recent votes for this model
92
+ recent_votes = db.session.query(Vote.user_id, Vote.vote_date).filter(
93
  and_(
94
  Vote.model_chosen == model_id,
95
  Vote.vote_date >= time_threshold
 
99
  if len(recent_votes) < vote_threshold:
100
  return False, 0, len(recent_votes), []
101
 
102
+ # Count unique users and analyze patterns
103
+ user_vote_data = {}
104
+ for vote in recent_votes:
105
+ if vote.user_id:
106
+ if vote.user_id not in user_vote_data:
107
+ user_vote_data[vote.user_id] = []
108
+ user_vote_data[vote.user_id].append(vote.vote_date)
109
 
110
+ user_count = len(user_vote_data)
111
+
112
+ # Enhanced detection logic
113
  if user_count >= min_users and len(recent_votes) >= vote_threshold:
 
114
  suspicious_users = []
115
+ high_suspicion_users = []
116
+
117
+ for user_id, vote_dates in user_vote_data.items():
118
+ user_votes_for_model = len(vote_dates)
 
 
 
 
119
 
120
  if user_votes_for_model > 1: # Multiple votes for same model in short time
121
  user = User.query.get(user_id)
122
  if user:
123
+ # Calculate suspicion level
124
+ account_age_days = (datetime.utcnow() - user.join_date).days if user.join_date else 0
125
+ vote_frequency = user_votes_for_model / hours_back # votes per hour
126
+
127
+ # Determine suspicion level
128
+ suspicion_level = "low"
129
+ if account_age_days < 30 or vote_frequency > 3:
130
+ suspicion_level = "high"
131
+ high_suspicion_users.append(user_id)
132
+ elif account_age_days < 90 or vote_frequency > 1:
133
+ suspicion_level = "medium"
134
+
135
+ user_data = {
136
  'user_id': user_id,
137
  'username': user.username,
138
  'votes_for_model': user_votes_for_model,
139
+ 'account_age_days': account_age_days,
140
+ 'suspicion_level': suspicion_level,
141
+ 'first_vote_at': min(vote_dates),
142
+ 'last_vote_at': max(vote_dates)
143
+ }
144
+ suspicious_users.append(user_data)
145
+
146
+ # Calculate confidence score
147
+ confidence_factors = []
148
+
149
+ # Factor 1: Ratio of high suspicion users
150
+ if suspicious_users:
151
+ high_suspicion_ratio = len(high_suspicion_users) / len(suspicious_users)
152
+ confidence_factors.append(min(high_suspicion_ratio * 0.4, 0.4))
153
+
154
+ # Factor 2: Vote concentration (more votes in shorter time = higher confidence)
155
+ vote_concentration = min(len(recent_votes) / (hours_back * user_count), 1.0)
156
+ confidence_factors.append(vote_concentration * 0.3)
157
+
158
+ # Factor 3: New account participation
159
+ new_account_ratio = sum(1 for u in suspicious_users if u['account_age_days'] < 30) / len(suspicious_users) if suspicious_users else 0
160
+ confidence_factors.append(new_account_ratio * 0.3)
161
 
162
+ confidence_score = sum(confidence_factors)
163
+
164
+ # Only consider it coordinated if confidence is above threshold
165
+ is_coordinated = confidence_score >= 0.6
166
+
167
+ if is_coordinated:
168
+ # Log the campaign automatically
169
+ try:
170
+ from models import log_coordinated_campaign, Model
171
+ model = Model.query.get(model_id)
172
+ model_type = model.model_type if model else "unknown"
173
+
174
+ participants_data = [{
175
+ 'user_id': u['user_id'],
176
+ 'votes_in_campaign': u['votes_for_model'],
177
+ 'first_vote_at': u['first_vote_at'],
178
+ 'last_vote_at': u['last_vote_at'],
179
+ 'suspicion_level': u['suspicion_level']
180
+ } for u in suspicious_users]
181
+
182
+ campaign = log_coordinated_campaign(
183
+ model_id=model_id,
184
+ model_type=model_type,
185
+ vote_count=len(recent_votes),
186
+ user_count=user_count,
187
+ time_window_hours=hours_back,
188
+ confidence_score=confidence_score,
189
+ participants_data=participants_data
190
+ )
191
+
192
+ # Automatically timeout high suspicion users
193
+ from models import create_user_timeout
194
+ timeout_count = 0
195
+ for user_id in high_suspicion_users:
196
+ try:
197
+ create_user_timeout(
198
+ user_id=user_id,
199
+ reason=f"Automatic timeout for participation in coordinated voting campaign (Campaign ID: {campaign.id})",
200
+ timeout_type="coordinated_voting",
201
+ duration_days=30,
202
+ related_campaign_id=campaign.id
203
+ )
204
+ timeout_count += 1
205
+ except Exception as e:
206
+ logger.error(f"Error creating timeout for user {user_id}: {str(e)}")
207
+
208
+ logger.warning(f"Coordinated voting campaign detected and logged (ID: {campaign.id}). {timeout_count} users timed out.")
209
+
210
+ except Exception as e:
211
+ logger.error(f"Error logging coordinated campaign: {str(e)}")
212
+
213
+ return is_coordinated, user_count, len(recent_votes), suspicious_users
214
 
215
  return False, user_count, len(recent_votes), []
216
 
 
409
  if not user_id:
410
  return False, "User not authenticated", 0
411
 
412
+ # Check if user is currently timed out
413
+ try:
414
+ from models import check_user_timeout
415
+ is_timed_out, timeout = check_user_timeout(user_id)
416
+ if is_timed_out:
417
+ remaining_time = timeout.expires_at - datetime.utcnow()
418
+ days_remaining = remaining_time.days
419
+ hours_remaining = remaining_time.seconds // 3600
420
+
421
+ if days_remaining > 0:
422
+ time_str = f"{days_remaining} day(s)"
423
+ else:
424
+ time_str = f"{hours_remaining} hour(s)"
425
+
426
+ return False, f"Account temporarily suspended until {timeout.expires_at.strftime('%Y-%m-%d %H:%M')} ({time_str} remaining). Reason: {timeout.reason}", 0
427
+ except ImportError:
428
+ # If models import fails, continue with other checks
429
+ pass
430
+ except Exception as e:
431
+ logger.error(f"Error checking user timeout: {str(e)}")
432
+
433
  # Check security score
434
  score, factors = check_user_security_score(user_id)
435
 
templates/admin/base.html CHANGED
@@ -534,6 +534,14 @@
534
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
535
  Security
536
  </a>
 
 
 
 
 
 
 
 
537
  <a href="{{ url_for('admin.activity') }}" class="admin-nav-item {% if request.endpoint == 'admin.activity' %}active{% endif %}">
538
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8v8h8v-8z"/><path d="M3 21V3h18v9"/><path d="M12 3v6H3"/></svg>
539
  Activity
 
534
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
535
  Security
536
  </a>
537
+ <a href="{{ url_for('admin.timeouts') }}" class="admin-nav-item {% if request.endpoint in ['admin.timeouts', 'admin.create_timeout', 'admin.cancel_timeout'] %}active{% endif %}">
538
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg>
539
+ Timeouts
540
+ </a>
541
+ <a href="{{ url_for('admin.campaigns') }}" class="admin-nav-item {% if request.endpoint in ['admin.campaigns', 'admin.campaign_detail', 'admin.resolve_campaign_route'] %}active{% endif %}">
542
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m22 21-3-3m0 0a5 5 0 0 0-7-7 5 5 0 0 0 7 7Z"/></svg>
543
+ Campaigns
544
+ </a>
545
  <a href="{{ url_for('admin.activity') }}" class="admin-nav-item {% if request.endpoint == 'admin.activity' %}active{% endif %}">
546
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8v8h8v-8z"/><path d="M3 21V3h18v9"/><path d="M12 3v6H3"/></svg>
547
  Activity
templates/admin/campaign_detail.html ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">Campaign #{{ campaign.id }} Details</div>
6
+ <a href="{{ url_for('admin.campaigns') }}" class="btn-secondary">Back to Campaigns</a>
7
+ </div>
8
+
9
+ <!-- Campaign Overview -->
10
+ <div class="admin-card">
11
+ <div class="admin-card-header">
12
+ <div class="admin-card-title">Campaign Overview</div>
13
+ <div class="campaign-status">
14
+ <span class="status-badge status-{{ campaign.status }}">
15
+ {{ campaign.status.replace('_', ' ').title() }}
16
+ </span>
17
+ </div>
18
+ </div>
19
+ <div class="campaign-details">
20
+ <div class="detail-grid">
21
+ <div class="detail-item">
22
+ <div class="detail-label">Target Model:</div>
23
+ <div class="detail-value">
24
+ <strong>{{ campaign.model.name }}</strong>
25
+ <span class="model-type-badge model-type-{{ campaign.model_type }}">
26
+ {{ campaign.model_type.upper() }}
27
+ </span>
28
+ </div>
29
+ </div>
30
+ <div class="detail-item">
31
+ <div class="detail-label">Detected At:</div>
32
+ <div class="detail-value">{{ campaign.detected_at.strftime('%Y-%m-%d %H:%M:%S') }}</div>
33
+ </div>
34
+ <div class="detail-item">
35
+ <div class="detail-label">Detection Window:</div>
36
+ <div class="detail-value">{{ campaign.time_window_hours }} hours</div>
37
+ </div>
38
+ <div class="detail-item">
39
+ <div class="detail-label">Total Votes:</div>
40
+ <div class="detail-value">{{ campaign.vote_count }}</div>
41
+ </div>
42
+ <div class="detail-item">
43
+ <div class="detail-label">Users Involved:</div>
44
+ <div class="detail-value">{{ campaign.user_count }}</div>
45
+ </div>
46
+ <div class="detail-item">
47
+ <div class="detail-label">Confidence Score:</div>
48
+ <div class="detail-value">
49
+ <div class="confidence-bar">
50
+ <div class="confidence-fill" style="width: {{ (campaign.confidence_score * 100)|round }}%"></div>
51
+ <span class="confidence-text">{{ (campaign.confidence_score * 100)|round }}%</span>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ {% if campaign.resolved_at %}
58
+ <div class="resolution-info">
59
+ <h4>Resolution Information</h4>
60
+ <div class="detail-grid">
61
+ <div class="detail-item">
62
+ <div class="detail-label">Resolved By:</div>
63
+ <div class="detail-value">{{ campaign.resolver.username if campaign.resolver else 'System' }}</div>
64
+ </div>
65
+ <div class="detail-item">
66
+ <div class="detail-label">Resolved At:</div>
67
+ <div class="detail-value">{{ campaign.resolved_at.strftime('%Y-%m-%d %H:%M:%S') }}</div>
68
+ </div>
69
+ </div>
70
+ {% if campaign.admin_notes %}
71
+ <div class="admin-notes">
72
+ <div class="detail-label">Admin Notes:</div>
73
+ <div class="detail-value">{{ campaign.admin_notes }}</div>
74
+ </div>
75
+ {% endif %}
76
+ </div>
77
+ {% endif %}
78
+ </div>
79
+ </div>
80
+
81
+ <!-- Campaign Participants -->
82
+ <div class="admin-card">
83
+ <div class="admin-card-header">
84
+ <div class="admin-card-title">Campaign Participants ({{ participants|length }})</div>
85
+ </div>
86
+ {% if participants %}
87
+ <div class="table-responsive">
88
+ <table class="admin-table">
89
+ <thead>
90
+ <tr>
91
+ <th>User</th>
92
+ <th>Votes in Campaign</th>
93
+ <th>First Vote</th>
94
+ <th>Last Vote</th>
95
+ <th>Suspicion Level</th>
96
+ <th>Account Age</th>
97
+ <th>Current Status</th>
98
+ <th>Actions</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody>
102
+ {% for participant, user in participants %}
103
+ <tr>
104
+ <td>
105
+ <a href="{{ url_for('admin.user_detail', user_id=user.id) }}">
106
+ {{ user.username }}
107
+ </a>
108
+ </td>
109
+ <td>{{ participant.votes_in_campaign }}</td>
110
+ <td>{{ participant.first_vote_at.strftime('%Y-%m-%d %H:%M') }}</td>
111
+ <td>{{ participant.last_vote_at.strftime('%Y-%m-%d %H:%M') }}</td>
112
+ <td>
113
+ <span class="suspicion-badge suspicion-{{ participant.suspicion_level }}">
114
+ {{ participant.suspicion_level.title() }}
115
+ </span>
116
+ </td>
117
+ <td>
118
+ {% if user.join_date %}
119
+ {{ ((campaign.detected_at - user.join_date).days) }} days
120
+ {% else %}
121
+ Unknown
122
+ {% endif %}
123
+ </td>
124
+ <td>
125
+ <div class="user-status" data-user-id="{{ user.id }}">
126
+ Checking...
127
+ </div>
128
+ </td>
129
+ <td>
130
+ <a href="{{ url_for('admin.user_detail', user_id=user.id) }}" class="action-btn">
131
+ View User
132
+ </a>
133
+ </td>
134
+ </tr>
135
+ {% endfor %}
136
+ </tbody>
137
+ </table>
138
+ </div>
139
+ {% else %}
140
+ <p>No participants found.</p>
141
+ {% endif %}
142
+ </div>
143
+
144
+ <!-- Related Timeouts -->
145
+ <div class="admin-card">
146
+ <div class="admin-card-header">
147
+ <div class="admin-card-title">Related Timeouts ({{ related_timeouts|length }})</div>
148
+ </div>
149
+ {% if related_timeouts %}
150
+ <div class="table-responsive">
151
+ <table class="admin-table">
152
+ <thead>
153
+ <tr>
154
+ <th>User</th>
155
+ <th>Reason</th>
156
+ <th>Created</th>
157
+ <th>Expires</th>
158
+ <th>Status</th>
159
+ <th>Actions</th>
160
+ </tr>
161
+ </thead>
162
+ <tbody>
163
+ {% for timeout in related_timeouts %}
164
+ <tr>
165
+ <td>
166
+ <a href="{{ url_for('admin.user_detail', user_id=timeout.user.id) }}">
167
+ {{ timeout.user.username }}
168
+ </a>
169
+ </td>
170
+ <td class="text-truncate" title="{{ timeout.reason }}">{{ timeout.reason }}</td>
171
+ <td>{{ timeout.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
172
+ <td>{{ timeout.expires_at.strftime('%Y-%m-%d %H:%M') }}</td>
173
+ <td>
174
+ {% if timeout.is_currently_active() %}
175
+ <span class="status-badge status-active">Active</span>
176
+ {% else %}
177
+ <span class="status-badge status-expired">Expired</span>
178
+ {% endif %}
179
+ </td>
180
+ <td>
181
+ <a href="{{ url_for('admin.timeouts') }}" class="action-btn">
182
+ Manage
183
+ </a>
184
+ </td>
185
+ </tr>
186
+ {% endfor %}
187
+ </tbody>
188
+ </table>
189
+ </div>
190
+ {% else %}
191
+ <p>No related timeouts.</p>
192
+ {% endif %}
193
+ </div>
194
+
195
+ <!-- Resolution Actions -->
196
+ {% if campaign.status == 'active' %}
197
+ <div class="admin-card">
198
+ <div class="admin-card-header">
199
+ <div class="admin-card-title">Resolve Campaign</div>
200
+ </div>
201
+ <form method="POST" action="{{ url_for('admin.resolve_campaign_route', campaign_id=campaign.id) }}" class="admin-form">
202
+ <div class="form-group">
203
+ <label for="status">Resolution Status</label>
204
+ <select id="status" name="status" class="form-control" required>
205
+ <option value="">Select resolution...</option>
206
+ <option value="resolved">Resolved - Legitimate coordinated campaign</option>
207
+ <option value="false_positive">False Positive - Not a real campaign</option>
208
+ </select>
209
+ </div>
210
+
211
+ <div class="form-group">
212
+ <label for="admin_notes">Admin Notes</label>
213
+ <textarea id="admin_notes" name="admin_notes" class="form-control" rows="3"
214
+ placeholder="Add notes about the resolution decision..."></textarea>
215
+ </div>
216
+
217
+ <button type="submit" class="btn-primary">Resolve Campaign</button>
218
+ </form>
219
+ </div>
220
+ {% endif %}
221
+
222
+ <style>
223
+ .campaign-details {
224
+ background-color: var(--light-gray);
225
+ padding: 20px;
226
+ border-radius: var(--radius);
227
+ border: 1px solid var(--border-color);
228
+ }
229
+
230
+ .detail-grid {
231
+ display: grid;
232
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
233
+ gap: 16px;
234
+ margin-bottom: 20px;
235
+ }
236
+
237
+ .detail-item {
238
+ display: flex;
239
+ flex-direction: column;
240
+ gap: 4px;
241
+ }
242
+
243
+ .detail-label {
244
+ font-weight: 500;
245
+ color: #666;
246
+ font-size: 14px;
247
+ }
248
+
249
+ .detail-value {
250
+ font-size: 16px;
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 8px;
254
+ }
255
+
256
+ .campaign-status {
257
+ display: flex;
258
+ align-items: center;
259
+ }
260
+
261
+ .model-type-badge {
262
+ padding: 4px 8px;
263
+ border-radius: 4px;
264
+ font-size: 11px;
265
+ font-weight: 500;
266
+ color: white;
267
+ text-transform: uppercase;
268
+ letter-spacing: 0.5px;
269
+ }
270
+
271
+ .model-type-tts {
272
+ background-color: #007bff;
273
+ }
274
+
275
+ .model-type-conversational {
276
+ background-color: #28a745;
277
+ }
278
+
279
+ .confidence-bar {
280
+ position: relative;
281
+ width: 120px;
282
+ height: 24px;
283
+ background-color: #e9ecef;
284
+ border-radius: 12px;
285
+ overflow: hidden;
286
+ }
287
+
288
+ .confidence-fill {
289
+ height: 100%;
290
+ background: linear-gradient(90deg, #dc3545 0%, #ffc107 50%, #28a745 100%);
291
+ transition: width 0.3s ease;
292
+ }
293
+
294
+ .confidence-text {
295
+ position: absolute;
296
+ top: 50%;
297
+ left: 50%;
298
+ transform: translate(-50%, -50%);
299
+ font-size: 12px;
300
+ font-weight: 500;
301
+ color: #333;
302
+ text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
303
+ }
304
+
305
+ .resolution-info {
306
+ border-top: 1px solid var(--border-color);
307
+ padding-top: 20px;
308
+ margin-top: 20px;
309
+ }
310
+
311
+ .resolution-info h4 {
312
+ margin: 0 0 16px 0;
313
+ color: var(--primary-color);
314
+ }
315
+
316
+ .admin-notes {
317
+ margin-top: 16px;
318
+ }
319
+
320
+ .suspicion-badge {
321
+ padding: 4px 8px;
322
+ border-radius: 4px;
323
+ font-size: 12px;
324
+ font-weight: 500;
325
+ color: white;
326
+ }
327
+
328
+ .suspicion-low {
329
+ background-color: #28a745;
330
+ }
331
+
332
+ .suspicion-medium {
333
+ background-color: #ffc107;
334
+ color: black;
335
+ }
336
+
337
+ .suspicion-high {
338
+ background-color: #dc3545;
339
+ }
340
+
341
+ .status-badge {
342
+ padding: 4px 8px;
343
+ border-radius: 4px;
344
+ font-size: 12px;
345
+ font-weight: 500;
346
+ color: white;
347
+ }
348
+
349
+ .status-active {
350
+ background-color: #dc3545;
351
+ }
352
+
353
+ .status-resolved {
354
+ background-color: #28a745;
355
+ }
356
+
357
+ .status-false_positive {
358
+ background-color: #ffc107;
359
+ color: black;
360
+ }
361
+
362
+ .status-expired {
363
+ background-color: #6c757d;
364
+ }
365
+
366
+ .user-status {
367
+ font-size: 12px;
368
+ }
369
+
370
+ .user-status.timed-out {
371
+ color: #dc3545;
372
+ font-weight: 500;
373
+ }
374
+
375
+ .user-status.active {
376
+ color: #28a745;
377
+ }
378
+
379
+ @media (max-width: 768px) {
380
+ .detail-grid {
381
+ grid-template-columns: 1fr;
382
+ }
383
+
384
+ .confidence-bar {
385
+ width: 100px;
386
+ }
387
+
388
+ .admin-header {
389
+ flex-direction: column;
390
+ gap: 12px;
391
+ align-items: flex-start;
392
+ }
393
+ }
394
+ </style>
395
+
396
+ <script>
397
+ document.addEventListener('DOMContentLoaded', function() {
398
+ // Check user timeout status for each participant
399
+ const userStatusElements = document.querySelectorAll('.user-status');
400
+
401
+ userStatusElements.forEach(async (element) => {
402
+ const userId = element.dataset.userId;
403
+
404
+ try {
405
+ // This would need to be implemented as an API endpoint
406
+ // For now, we'll just show a placeholder
407
+ element.textContent = 'Active';
408
+ element.className = 'user-status active';
409
+ } catch (error) {
410
+ element.textContent = 'Unknown';
411
+ element.className = 'user-status';
412
+ }
413
+ });
414
+ });
415
+ </script>
416
+ {% endblock %}
templates/admin/campaigns.html ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">Coordinated Voting Campaigns</div>
6
+ </div>
7
+
8
+ <!-- Campaign Statistics -->
9
+ <div class="admin-stats">
10
+ <div class="stat-card">
11
+ <div class="stat-title">Total Campaigns</div>
12
+ <div class="stat-value">{{ stats.total }}</div>
13
+ </div>
14
+ <div class="stat-card">
15
+ <div class="stat-title">Active</div>
16
+ <div class="stat-value">{{ stats.active }}</div>
17
+ </div>
18
+ <div class="stat-card">
19
+ <div class="stat-title">Resolved</div>
20
+ <div class="stat-value">{{ stats.resolved }}</div>
21
+ </div>
22
+ <div class="stat-card">
23
+ <div class="stat-title">False Positives</div>
24
+ <div class="stat-value">{{ stats.false_positive }}</div>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- Filter Controls -->
29
+ <div class="admin-card">
30
+ <div class="admin-card-header">
31
+ <div class="admin-card-title">Filter Campaigns</div>
32
+ </div>
33
+ <div class="filter-controls">
34
+ <a href="{{ url_for('admin.campaigns', status='all') }}"
35
+ class="filter-btn {% if current_filter == 'all' %}active{% endif %}">
36
+ All ({{ stats.total }})
37
+ </a>
38
+ <a href="{{ url_for('admin.campaigns', status='active') }}"
39
+ class="filter-btn {% if current_filter == 'active' %}active{% endif %}">
40
+ Active ({{ stats.active }})
41
+ </a>
42
+ <a href="{{ url_for('admin.campaigns', status='resolved') }}"
43
+ class="filter-btn {% if current_filter == 'resolved' %}active{% endif %}">
44
+ Resolved ({{ stats.resolved }})
45
+ </a>
46
+ <a href="{{ url_for('admin.campaigns', status='false_positive') }}"
47
+ class="filter-btn {% if current_filter == 'false_positive' %}active{% endif %}">
48
+ False Positives ({{ stats.false_positive }})
49
+ </a>
50
+ </div>
51
+ </div>
52
+
53
+ <!-- Campaigns List -->
54
+ <div class="admin-card">
55
+ <div class="admin-card-header">
56
+ <div class="admin-card-title">
57
+ {% if current_filter == 'all' %}
58
+ All Campaigns
59
+ {% else %}
60
+ {{ current_filter.replace('_', ' ').title() }} Campaigns
61
+ {% endif %}
62
+ ({{ campaigns|length }})
63
+ </div>
64
+ </div>
65
+ {% if campaigns %}
66
+ <div class="table-responsive">
67
+ <table class="admin-table">
68
+ <thead>
69
+ <tr>
70
+ <th>ID</th>
71
+ <th>Model</th>
72
+ <th>Type</th>
73
+ <th>Detected</th>
74
+ <th>Votes</th>
75
+ <th>Users</th>
76
+ <th>Confidence</th>
77
+ <th>Status</th>
78
+ <th>Actions</th>
79
+ </tr>
80
+ </thead>
81
+ <tbody>
82
+ {% for campaign in campaigns %}
83
+ <tr>
84
+ <td>{{ campaign.id }}</td>
85
+ <td>
86
+ <div class="model-info">
87
+ <strong>{{ campaign.model.name }}</strong>
88
+ <small class="model-type">{{ campaign.model_type.upper() }}</small>
89
+ </div>
90
+ </td>
91
+ <td>
92
+ <span class="model-type-badge model-type-{{ campaign.model_type }}">
93
+ {{ campaign.model_type.upper() }}
94
+ </span>
95
+ </td>
96
+ <td>{{ campaign.detected_at.strftime('%Y-%m-%d %H:%M') }}</td>
97
+ <td>{{ campaign.vote_count }}</td>
98
+ <td>{{ campaign.user_count }}</td>
99
+ <td>
100
+ <div class="confidence-bar">
101
+ <div class="confidence-fill" style="width: {{ (campaign.confidence_score * 100)|round }}%"></div>
102
+ <span class="confidence-text">{{ (campaign.confidence_score * 100)|round }}%</span>
103
+ </div>
104
+ </td>
105
+ <td>
106
+ <span class="status-badge status-{{ campaign.status }}">
107
+ {{ campaign.status.replace('_', ' ').title() }}
108
+ </span>
109
+ </td>
110
+ <td>
111
+ <a href="{{ url_for('admin.campaign_detail', campaign_id=campaign.id) }}" class="action-btn">
112
+ View Details
113
+ </a>
114
+ </td>
115
+ </tr>
116
+ {% endfor %}
117
+ </tbody>
118
+ </table>
119
+ </div>
120
+ {% else %}
121
+ <p>No campaigns found with the current filter.</p>
122
+ {% endif %}
123
+ </div>
124
+
125
+ <style>
126
+ .filter-controls {
127
+ display: flex;
128
+ gap: 12px;
129
+ flex-wrap: wrap;
130
+ }
131
+
132
+ .filter-btn {
133
+ padding: 8px 16px;
134
+ border: 1px solid var(--border-color);
135
+ border-radius: var(--radius);
136
+ text-decoration: none;
137
+ color: var(--text-color);
138
+ background-color: white;
139
+ transition: all 0.2s;
140
+ font-size: 14px;
141
+ }
142
+
143
+ .filter-btn:hover {
144
+ background-color: var(--secondary-color);
145
+ }
146
+
147
+ .filter-btn.active {
148
+ background-color: var(--primary-color);
149
+ color: white;
150
+ border-color: var(--primary-color);
151
+ }
152
+
153
+ .model-info {
154
+ display: flex;
155
+ flex-direction: column;
156
+ gap: 4px;
157
+ }
158
+
159
+ .model-info strong {
160
+ font-weight: 500;
161
+ }
162
+
163
+ .model-info .model-type {
164
+ color: #666;
165
+ font-size: 11px;
166
+ text-transform: uppercase;
167
+ letter-spacing: 0.5px;
168
+ }
169
+
170
+ .model-type-badge {
171
+ padding: 4px 8px;
172
+ border-radius: 4px;
173
+ font-size: 11px;
174
+ font-weight: 500;
175
+ color: white;
176
+ text-transform: uppercase;
177
+ letter-spacing: 0.5px;
178
+ }
179
+
180
+ .model-type-tts {
181
+ background-color: #007bff;
182
+ }
183
+
184
+ .model-type-conversational {
185
+ background-color: #28a745;
186
+ }
187
+
188
+ .confidence-bar {
189
+ position: relative;
190
+ width: 80px;
191
+ height: 20px;
192
+ background-color: #e9ecef;
193
+ border-radius: 10px;
194
+ overflow: hidden;
195
+ }
196
+
197
+ .confidence-fill {
198
+ height: 100%;
199
+ background: linear-gradient(90deg, #dc3545 0%, #ffc107 50%, #28a745 100%);
200
+ transition: width 0.3s ease;
201
+ }
202
+
203
+ .confidence-text {
204
+ position: absolute;
205
+ top: 50%;
206
+ left: 50%;
207
+ transform: translate(-50%, -50%);
208
+ font-size: 11px;
209
+ font-weight: 500;
210
+ color: #333;
211
+ text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
212
+ }
213
+
214
+ .status-badge {
215
+ padding: 4px 8px;
216
+ border-radius: 4px;
217
+ font-size: 12px;
218
+ font-weight: 500;
219
+ color: white;
220
+ }
221
+
222
+ .status-active {
223
+ background-color: #dc3545;
224
+ }
225
+
226
+ .status-resolved {
227
+ background-color: #28a745;
228
+ }
229
+
230
+ .status-false_positive {
231
+ background-color: #ffc107;
232
+ color: black;
233
+ }
234
+
235
+ @media (max-width: 768px) {
236
+ .filter-controls {
237
+ flex-direction: column;
238
+ }
239
+
240
+ .filter-btn {
241
+ text-align: center;
242
+ }
243
+
244
+ .confidence-bar {
245
+ width: 60px;
246
+ }
247
+
248
+ .model-info {
249
+ min-width: 120px;
250
+ }
251
+ }
252
+ </style>
253
+ {% endblock %}
templates/admin/timeouts.html ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">User Timeout Management</div>
6
+ </div>
7
+
8
+ <!-- Create Timeout Form -->
9
+ <div class="admin-card">
10
+ <div class="admin-card-header">
11
+ <div class="admin-card-title">Create New Timeout</div>
12
+ </div>
13
+ <form method="POST" action="{{ url_for('admin.create_timeout') }}" class="admin-form">
14
+ <div class="form-group">
15
+ <label for="user_search">Search User</label>
16
+ <input type="text" id="user_search" class="form-control" placeholder="Type username to search..." autocomplete="off">
17
+ <div id="user_search_results" class="user-search-results"></div>
18
+ <input type="hidden" id="user_id" name="user_id" required>
19
+ <div id="selected_user" class="selected-user"></div>
20
+ </div>
21
+
22
+ <div class="form-group">
23
+ <label for="reason">Reason for Timeout</label>
24
+ <textarea id="reason" name="reason" class="form-control" rows="3" required placeholder="Explain why this user is being timed out..."></textarea>
25
+ </div>
26
+
27
+ <div class="form-group">
28
+ <label for="timeout_type">Timeout Type</label>
29
+ <select id="timeout_type" name="timeout_type" class="form-control" required>
30
+ <option value="manual">Manual Admin Action</option>
31
+ <option value="coordinated_voting">Coordinated Voting</option>
32
+ <option value="rapid_voting">Rapid Voting</option>
33
+ <option value="security_violation">Security Violation</option>
34
+ <option value="spam">Spam/Abuse</option>
35
+ <option value="other">Other</option>
36
+ </select>
37
+ </div>
38
+
39
+ <div class="form-group">
40
+ <label for="duration_days">Duration (Days)</label>
41
+ <select id="duration_days" name="duration_days" class="form-control" required>
42
+ <option value="1">1 Day</option>
43
+ <option value="3">3 Days</option>
44
+ <option value="7">1 Week</option>
45
+ <option value="14">2 Weeks</option>
46
+ <option value="30" selected>30 Days (Default)</option>
47
+ <option value="60">60 Days</option>
48
+ <option value="90">90 Days</option>
49
+ <option value="180">180 Days</option>
50
+ <option value="365">1 Year</option>
51
+ </select>
52
+ </div>
53
+
54
+ <button type="submit" class="btn-primary">Create Timeout</button>
55
+ </form>
56
+ </div>
57
+
58
+ <!-- Active Timeouts -->
59
+ <div class="admin-card">
60
+ <div class="admin-card-header">
61
+ <div class="admin-card-title">Active Timeouts ({{ active_timeouts|length }})</div>
62
+ </div>
63
+ {% if active_timeouts %}
64
+ <div class="table-responsive">
65
+ <table class="admin-table">
66
+ <thead>
67
+ <tr>
68
+ <th>User</th>
69
+ <th>Reason</th>
70
+ <th>Type</th>
71
+ <th>Created</th>
72
+ <th>Expires</th>
73
+ <th>Remaining</th>
74
+ <th>Created By</th>
75
+ <th>Actions</th>
76
+ </tr>
77
+ </thead>
78
+ <tbody>
79
+ {% for timeout in active_timeouts %}
80
+ <tr>
81
+ <td>
82
+ <a href="{{ url_for('admin.user_detail', user_id=timeout.user.id) }}">
83
+ {{ timeout.user.username }}
84
+ </a>
85
+ </td>
86
+ <td class="text-truncate" title="{{ timeout.reason }}">{{ timeout.reason }}</td>
87
+ <td>
88
+ <span class="timeout-type-badge timeout-{{ timeout.timeout_type }}">
89
+ {{ timeout.timeout_type.replace('_', ' ').title() }}
90
+ </span>
91
+ </td>
92
+ <td>{{ timeout.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
93
+ <td>{{ timeout.expires_at.strftime('%Y-%m-%d %H:%M') }}</td>
94
+ <td>
95
+ <span class="remaining-time" data-expires="{{ timeout.expires_at.isoformat() }}">
96
+ Calculating...
97
+ </span>
98
+ </td>
99
+ <td>
100
+ {% if timeout.creator %}
101
+ {{ timeout.creator.username }}
102
+ {% else %}
103
+ System
104
+ {% endif %}
105
+ </td>
106
+ <td>
107
+ <button class="action-btn cancel-timeout-btn" data-timeout-id="{{ timeout.id }}" data-username="{{ timeout.user.username }}">
108
+ Cancel
109
+ </button>
110
+ </td>
111
+ </tr>
112
+ {% endfor %}
113
+ </tbody>
114
+ </table>
115
+ </div>
116
+ {% else %}
117
+ <p>No active timeouts.</p>
118
+ {% endif %}
119
+ </div>
120
+
121
+ <!-- Recent Inactive Timeouts -->
122
+ <div class="admin-card">
123
+ <div class="admin-card-header">
124
+ <div class="admin-card-title">Recent Expired/Cancelled Timeouts</div>
125
+ </div>
126
+ {% if recent_inactive %}
127
+ <div class="table-responsive">
128
+ <table class="admin-table">
129
+ <thead>
130
+ <tr>
131
+ <th>User</th>
132
+ <th>Reason</th>
133
+ <th>Type</th>
134
+ <th>Created</th>
135
+ <th>Expired/Cancelled</th>
136
+ <th>Status</th>
137
+ <th>Cancelled By</th>
138
+ </tr>
139
+ </thead>
140
+ <tbody>
141
+ {% for timeout in recent_inactive %}
142
+ <tr>
143
+ <td>
144
+ <a href="{{ url_for('admin.user_detail', user_id=timeout.user.id) }}">
145
+ {{ timeout.user.username }}
146
+ </a>
147
+ </td>
148
+ <td class="text-truncate" title="{{ timeout.reason }}">{{ timeout.reason }}</td>
149
+ <td>
150
+ <span class="timeout-type-badge timeout-{{ timeout.timeout_type }}">
151
+ {{ timeout.timeout_type.replace('_', ' ').title() }}
152
+ </span>
153
+ </td>
154
+ <td>{{ timeout.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
155
+ <td>
156
+ {% if timeout.cancelled_at %}
157
+ {{ timeout.cancelled_at.strftime('%Y-%m-%d %H:%M') }}
158
+ {% else %}
159
+ {{ timeout.expires_at.strftime('%Y-%m-%d %H:%M') }}
160
+ {% endif %}
161
+ </td>
162
+ <td>
163
+ {% if timeout.cancelled_at %}
164
+ <span class="status-badge cancelled">Cancelled</span>
165
+ {% else %}
166
+ <span class="status-badge expired">Expired</span>
167
+ {% endif %}
168
+ </td>
169
+ <td>
170
+ {% if timeout.canceller %}
171
+ {{ timeout.canceller.username }}
172
+ {% else %}
173
+ -
174
+ {% endif %}
175
+ </td>
176
+ </tr>
177
+ {% endfor %}
178
+ </tbody>
179
+ </table>
180
+ </div>
181
+ {% else %}
182
+ <p>No recent inactive timeouts.</p>
183
+ {% endif %}
184
+ </div>
185
+
186
+ <!-- Cancel Timeout Modal -->
187
+ <div id="cancelTimeoutModal" class="modal" style="display: none;">
188
+ <div class="modal-content">
189
+ <div class="modal-header">
190
+ <h3>Cancel Timeout</h3>
191
+ <span class="modal-close">&times;</span>
192
+ </div>
193
+ <form method="POST" id="cancelTimeoutForm">
194
+ <div class="modal-body">
195
+ <p>Are you sure you want to cancel the timeout for <strong id="cancelUsername"></strong>?</p>
196
+ <div class="form-group">
197
+ <label for="cancel_reason">Reason for Cancellation</label>
198
+ <textarea id="cancel_reason" name="cancel_reason" class="form-control" rows="3" required placeholder="Explain why this timeout is being cancelled..."></textarea>
199
+ </div>
200
+ </div>
201
+ <div class="modal-footer">
202
+ <button type="button" class="btn-secondary modal-close">Cancel</button>
203
+ <button type="submit" class="btn-primary">Confirm Cancellation</button>
204
+ </div>
205
+ </form>
206
+ </div>
207
+ </div>
208
+
209
+ <style>
210
+ .user-search-results {
211
+ position: absolute;
212
+ background: white;
213
+ border: 1px solid var(--border-color);
214
+ border-top: none;
215
+ border-radius: 0 0 var(--radius) var(--radius);
216
+ max-height: 200px;
217
+ overflow-y: auto;
218
+ z-index: 1000;
219
+ display: none;
220
+ width: 100%;
221
+ }
222
+
223
+ .user-search-item {
224
+ padding: 12px;
225
+ cursor: pointer;
226
+ border-bottom: 1px solid var(--border-color);
227
+ }
228
+
229
+ .user-search-item:hover {
230
+ background-color: var(--secondary-color);
231
+ }
232
+
233
+ .user-search-item:last-child {
234
+ border-bottom: none;
235
+ }
236
+
237
+ .selected-user {
238
+ margin-top: 8px;
239
+ padding: 8px 12px;
240
+ background-color: var(--secondary-color);
241
+ border-radius: var(--radius);
242
+ display: none;
243
+ }
244
+
245
+ .timeout-type-badge {
246
+ padding: 4px 8px;
247
+ border-radius: 4px;
248
+ font-size: 12px;
249
+ font-weight: 500;
250
+ color: white;
251
+ }
252
+
253
+ .timeout-manual { background-color: #6c757d; }
254
+ .timeout-coordinated_voting { background-color: #dc3545; }
255
+ .timeout-rapid_voting { background-color: #fd7e14; }
256
+ .timeout-security_violation { background-color: #e83e8c; }
257
+ .timeout-spam { background-color: #6f42c1; }
258
+ .timeout-other { background-color: #20c997; }
259
+
260
+ .status-badge {
261
+ padding: 4px 8px;
262
+ border-radius: 4px;
263
+ font-size: 12px;
264
+ font-weight: 500;
265
+ color: white;
266
+ }
267
+
268
+ .status-badge.cancelled {
269
+ background-color: #ffc107;
270
+ color: black;
271
+ }
272
+
273
+ .status-badge.expired {
274
+ background-color: #6c757d;
275
+ }
276
+
277
+ .modal {
278
+ position: fixed;
279
+ z-index: 1000;
280
+ left: 0;
281
+ top: 0;
282
+ width: 100%;
283
+ height: 100%;
284
+ background-color: rgba(0,0,0,0.5);
285
+ }
286
+
287
+ .modal-content {
288
+ background-color: white;
289
+ margin: 10% auto;
290
+ padding: 0;
291
+ border-radius: var(--radius);
292
+ width: 90%;
293
+ max-width: 500px;
294
+ box-shadow: var(--shadow);
295
+ }
296
+
297
+ .modal-header {
298
+ padding: 20px;
299
+ border-bottom: 1px solid var(--border-color);
300
+ display: flex;
301
+ justify-content: space-between;
302
+ align-items: center;
303
+ }
304
+
305
+ .modal-header h3 {
306
+ margin: 0;
307
+ }
308
+
309
+ .modal-close {
310
+ font-size: 24px;
311
+ cursor: pointer;
312
+ color: #666;
313
+ }
314
+
315
+ .modal-close:hover {
316
+ color: #000;
317
+ }
318
+
319
+ .modal-body {
320
+ padding: 20px;
321
+ }
322
+
323
+ .modal-footer {
324
+ padding: 20px;
325
+ border-top: 1px solid var(--border-color);
326
+ display: flex;
327
+ justify-content: flex-end;
328
+ gap: 12px;
329
+ }
330
+
331
+ .form-group {
332
+ position: relative;
333
+ }
334
+ </style>
335
+
336
+ <script>
337
+ document.addEventListener('DOMContentLoaded', function() {
338
+ // User search functionality
339
+ const userSearch = document.getElementById('user_search');
340
+ const userSearchResults = document.getElementById('user_search_results');
341
+ const userIdInput = document.getElementById('user_id');
342
+ const selectedUserDiv = document.getElementById('selected_user');
343
+
344
+ let searchTimeout;
345
+
346
+ userSearch.addEventListener('input', function() {
347
+ const query = this.value.trim();
348
+
349
+ if (query.length < 2) {
350
+ userSearchResults.style.display = 'none';
351
+ return;
352
+ }
353
+
354
+ clearTimeout(searchTimeout);
355
+ searchTimeout = setTimeout(() => {
356
+ fetch(`{{ url_for('admin.user_search') }}?q=${encodeURIComponent(query)}`)
357
+ .then(response => response.json())
358
+ .then(users => {
359
+ userSearchResults.innerHTML = '';
360
+
361
+ if (users.length === 0) {
362
+ userSearchResults.innerHTML = '<div class="user-search-item">No users found</div>';
363
+ } else {
364
+ users.forEach(user => {
365
+ const item = document.createElement('div');
366
+ item.className = 'user-search-item';
367
+ item.innerHTML = `<strong>${user.username}</strong><br><small>ID: ${user.id}, Joined: ${user.join_date}</small>`;
368
+ item.addEventListener('click', () => selectUser(user));
369
+ userSearchResults.appendChild(item);
370
+ });
371
+ }
372
+
373
+ userSearchResults.style.display = 'block';
374
+ })
375
+ .catch(error => {
376
+ console.error('Error searching users:', error);
377
+ });
378
+ }, 300);
379
+ });
380
+
381
+ function selectUser(user) {
382
+ userIdInput.value = user.id;
383
+ userSearch.value = '';
384
+ userSearchResults.style.display = 'none';
385
+ selectedUserDiv.innerHTML = `<strong>Selected:</strong> ${user.username} (ID: ${user.id})`;
386
+ selectedUserDiv.style.display = 'block';
387
+ }
388
+
389
+ // Hide search results when clicking outside
390
+ document.addEventListener('click', function(e) {
391
+ if (!userSearch.contains(e.target) && !userSearchResults.contains(e.target)) {
392
+ userSearchResults.style.display = 'none';
393
+ }
394
+ });
395
+
396
+ // Cancel timeout modal
397
+ const modal = document.getElementById('cancelTimeoutModal');
398
+ const cancelForm = document.getElementById('cancelTimeoutForm');
399
+ const cancelUsername = document.getElementById('cancelUsername');
400
+ const cancelButtons = document.querySelectorAll('.cancel-timeout-btn');
401
+ const modalCloseButtons = document.querySelectorAll('.modal-close');
402
+
403
+ cancelButtons.forEach(button => {
404
+ button.addEventListener('click', function() {
405
+ const timeoutId = this.dataset.timeoutId;
406
+ const username = this.dataset.username;
407
+
408
+ cancelForm.action = `{{ url_for('admin.cancel_timeout', timeout_id=0) }}`.replace('0', timeoutId);
409
+ cancelUsername.textContent = username;
410
+ modal.style.display = 'block';
411
+ });
412
+ });
413
+
414
+ modalCloseButtons.forEach(button => {
415
+ button.addEventListener('click', function() {
416
+ modal.style.display = 'none';
417
+ });
418
+ });
419
+
420
+ // Close modal when clicking outside
421
+ window.addEventListener('click', function(e) {
422
+ if (e.target === modal) {
423
+ modal.style.display = 'none';
424
+ }
425
+ });
426
+
427
+ // Calculate remaining time for timeouts
428
+ function updateRemainingTimes() {
429
+ const remainingTimeElements = document.querySelectorAll('.remaining-time');
430
+ const now = new Date();
431
+
432
+ remainingTimeElements.forEach(element => {
433
+ const expiresAt = new Date(element.dataset.expires);
434
+ const remaining = expiresAt - now;
435
+
436
+ if (remaining <= 0) {
437
+ element.textContent = 'Expired';
438
+ element.style.color = '#dc3545';
439
+ } else {
440
+ const days = Math.floor(remaining / (1000 * 60 * 60 * 24));
441
+ const hours = Math.floor((remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
442
+
443
+ if (days > 0) {
444
+ element.textContent = `${days} day(s)`;
445
+ } else {
446
+ element.textContent = `${hours} hour(s)`;
447
+ }
448
+ }
449
+ });
450
+ }
451
+
452
+ // Update remaining times initially and every minute
453
+ updateRemainingTimes();
454
+ setInterval(updateRemainingTimes, 60000);
455
+ });
456
+ </script>
457
+ {% endblock %}
templates/admin/user_detail.html CHANGED
@@ -134,7 +134,15 @@
134
  </div>
135
  </div>
136
 
137
-
 
 
 
 
 
 
 
 
138
 
139
  {% if recent_votes %}
140
  <div class="admin-card">
@@ -348,24 +356,82 @@
348
  }
349
 
350
  .text-truncate {
351
- max-width: 300px;
352
- white-space: nowrap;
353
- overflow: hidden;
354
- text-overflow: ellipsis;
355
  }
356
 
357
- @media (max-width: 576px) {
358
- .user-detail-row {
359
- flex-direction: column;
360
- }
361
-
362
- .user-detail-label {
363
- margin-bottom: 4px;
364
- }
365
-
366
- .security-factors {
367
- grid-template-columns: 1fr;
368
- }
369
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  {% endblock %}
 
134
  </div>
135
  </div>
136
 
137
+ <!-- User Timeout Information -->
138
+ <div class="admin-card">
139
+ <div class="admin-card-header">
140
+ <div class="admin-card-title">Timeout Status</div>
141
+ </div>
142
+ <div class="timeout-status" data-user-id="{{ user.id }}">
143
+ <div class="loading-status">Checking timeout status...</div>
144
+ </div>
145
+ </div>
146
 
147
  {% if recent_votes %}
148
  <div class="admin-card">
 
356
  }
357
 
358
  .text-truncate {
359
+ max-width: 150px;
 
 
 
360
  }
361
 
362
+ .admin-stats {
363
+ grid-template-columns: 1fr;
 
 
 
 
 
 
 
 
 
 
364
  }
365
+ }
366
+
367
+ .timeout-status {
368
+ padding: 16px;
369
+ border-radius: var(--radius);
370
+ border: 1px solid var(--border-color);
371
+ }
372
+
373
+ .timeout-active {
374
+ background-color: #f8d7da;
375
+ border-color: #f1aeb5;
376
+ color: #721c24;
377
+ }
378
+
379
+ .timeout-none {
380
+ background-color: #d1edff;
381
+ border-color: #9fccff;
382
+ color: #004085;
383
+ }
384
+
385
+ .timeout-expired {
386
+ background-color: #fff3cd;
387
+ border-color: #ffeaa7;
388
+ color: #856404;
389
+ }
390
+
391
+ .timeout-details {
392
+ margin-top: 12px;
393
+ padding: 12px;
394
+ background-color: rgba(0,0,0,0.05);
395
+ border-radius: 4px;
396
+ font-size: 14px;
397
+ }
398
+
399
+ .timeout-actions {
400
+ margin-top: 12px;
401
+ display: flex;
402
+ gap: 8px;
403
+ }
404
+
405
+ .loading-status {
406
+ color: #666;
407
+ font-style: italic;
408
+ }
409
  </style>
410
+
411
+ <script>
412
+ document.addEventListener('DOMContentLoaded', function() {
413
+ // Check user timeout status
414
+ const timeoutStatusDiv = document.querySelector('.timeout-status');
415
+ const userId = timeoutStatusDiv.dataset.userId;
416
+
417
+ // Since we don't have a direct API endpoint for timeout status,
418
+ // we'll simulate the check for now. In a real implementation,
419
+ // you would make an AJAX call to check the user's timeout status.
420
+
421
+ // For demonstration, let's show that no timeout is active
422
+ setTimeout(() => {
423
+ timeoutStatusDiv.innerHTML = `
424
+ <div class="timeout-none">
425
+ <strong>✅ No Active Timeout</strong>
426
+ <div style="margin-top: 8px;">
427
+ This user is not currently timed out and can vote normally.
428
+ </div>
429
+ <div class="timeout-actions">
430
+ <a href="{{ url_for('admin.timeouts') }}" class="action-btn">Manage Timeouts</a>
431
+ </div>
432
+ </div>
433
+ `;
434
+ }, 1000);
435
+ });
436
+ </script>
437
  {% endblock %}