identifier #2
151
motm_app/main.py
151
motm_app/main.py
@ -66,7 +66,9 @@ class AdminProfileForm(FlaskForm):
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Main index page for MOTM system"""
|
||||
return render_template('index.html')
|
||||
# Check if user is authenticated as admin
|
||||
is_admin = is_admin_authenticated(request)
|
||||
return render_template('index.html', is_admin=is_admin)
|
||||
|
||||
|
||||
@app.route('/admin')
|
||||
@ -127,6 +129,54 @@ def admin_profile():
|
||||
return render_template('admin_profile.html', form=form, current_email=current_email)
|
||||
|
||||
|
||||
def generate_device_id(request):
|
||||
"""Generate a device identifier from request headers"""
|
||||
import hashlib
|
||||
|
||||
# Collect device characteristics
|
||||
user_agent = request.headers.get('User-Agent', '')
|
||||
accept_language = request.headers.get('Accept-Language', '')
|
||||
accept_encoding = request.headers.get('Accept-Encoding', '')
|
||||
ip_address = request.environ.get('REMOTE_ADDR', '')
|
||||
|
||||
# Create a fingerprint from these characteristics
|
||||
fingerprint_string = f"{user_agent}|{accept_language}|{accept_encoding}|{ip_address}"
|
||||
device_id = hashlib.sha256(fingerprint_string.encode()).hexdigest()[:16] # Use first 16 chars
|
||||
|
||||
return device_id
|
||||
|
||||
|
||||
def is_admin_authenticated(request):
|
||||
"""Check if the current request is authenticated as admin"""
|
||||
try:
|
||||
# Check if Authorization header exists
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Basic '):
|
||||
return False
|
||||
|
||||
# Decode the basic auth credentials
|
||||
import base64
|
||||
encoded_credentials = auth_header[6:] # Remove 'Basic ' prefix
|
||||
try:
|
||||
credentials = base64.b64decode(encoded_credentials).decode('utf-8')
|
||||
username, password = credentials.split(':', 1)
|
||||
|
||||
# Check against database
|
||||
sql = text("SELECT password_hash FROM admin_profiles WHERE username = :username")
|
||||
result = sql_read(sql, {'username': username})
|
||||
|
||||
if result:
|
||||
stored_hash = result[0]['password_hash']
|
||||
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
return password_hash == stored_hash
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ==================== PUBLIC VOTING SECTION ====================
|
||||
|
||||
@app.route('/motm/<randomUrlSuffix>')
|
||||
@ -206,16 +256,16 @@ def motm_vote(randomUrlSuffix):
|
||||
@app.route('/motm/comments', methods=['GET', 'POST'])
|
||||
def match_comments():
|
||||
"""Display and allow adding match comments"""
|
||||
sql = text("SELECT nextClub, nextTeam, nextdate, oppoLogo, hkfcLogo FROM motmadminsettings")
|
||||
sql = text("SELECT nextclub, nextteam, nextdate, oppologo, hkfclogo FROM motmadminsettings")
|
||||
row = sql_read_static(sql)
|
||||
if not row:
|
||||
return render_template('error.html', message="Database not initialized. Please go to Database Setup to initialize the database.")
|
||||
|
||||
_oppo = row[0]['nextClub']
|
||||
_oppo = row[0]['nextclub']
|
||||
commentDate = row[0]['nextdate'].strftime('%Y-%m-%d')
|
||||
_matchDate = row[0]['nextdate'].strftime('%Y_%m_%d')
|
||||
hkfcLogo = row[0]['hkfcLogo']
|
||||
oppoLogo = row[0]['oppoLogo']
|
||||
hkfcLogo = row[0]['hkfclogo']
|
||||
oppoLogo = row[0]['oppologo']
|
||||
if request.method == 'POST':
|
||||
_comment = request.form['matchComment']
|
||||
if _comment != 'Optional comments added here':
|
||||
@ -282,6 +332,25 @@ def vote_thanks():
|
||||
""")
|
||||
sql_write(sql_dotd, {'player_num': _dotd, 'player_name': dotd_name})
|
||||
|
||||
# Generate device identifier and record vote for tracking
|
||||
device_id = generate_device_id(request)
|
||||
sql_device = text("""
|
||||
INSERT INTO device_votes (device_id, fixture_date, motm_player_number, dotd_player_number,
|
||||
motm_player_name, dotd_player_name, ip_address, user_agent)
|
||||
VALUES (:device_id, :fixture_date, :motm_player, :dotd_player,
|
||||
:motm_name, :dotd_name, :ip_address, :user_agent)
|
||||
""")
|
||||
sql_write(sql_device, {
|
||||
'device_id': device_id,
|
||||
'fixture_date': fixture_date,
|
||||
'motm_player': _motm,
|
||||
'dotd_player': _dotd,
|
||||
'motm_name': motm_name,
|
||||
'dotd_name': dotd_name,
|
||||
'ip_address': request.environ.get('REMOTE_ADDR', 'unknown'),
|
||||
'user_agent': request.headers.get('User-Agent', 'unknown')
|
||||
})
|
||||
|
||||
# Handle comments
|
||||
if _comments and _comments != "Optional comments added here":
|
||||
sql3 = text("INSERT INTO _motmcomments (matchDate, comment) VALUES (:match_date, :comment)")
|
||||
@ -338,7 +407,7 @@ def motm_admin():
|
||||
|
||||
# Only update currMotM and currDotD if they were provided
|
||||
if _currMotM and _currMotM != '0' and _currDotD and _currDotD != '0':
|
||||
sql = text("UPDATE motmadminsettings SET nextdate = :next_date, nextClub = :next_club, nextTeam = :next_team, currMotM = :curr_motm, currDotD = :curr_dotd")
|
||||
sql = text("UPDATE motmadminsettings SET nextdate = :next_date, nextclub = :next_club, nextteam = :next_team, currmotm = :curr_motm, currdotd = :curr_dotd")
|
||||
sql_write_static(sql, {
|
||||
'next_date': _nextMatchDate,
|
||||
'next_club': _nextClub,
|
||||
@ -348,7 +417,7 @@ def motm_admin():
|
||||
})
|
||||
else:
|
||||
# Don't update currMotM and currDotD if not provided
|
||||
sql = text("UPDATE motmadminsettings SET nextdate = :next_date, nextClub = :next_club, nextTeam = :next_team")
|
||||
sql = text("UPDATE motmadminsettings SET nextdate = :next_date, nextclub = :next_club, nextteam = :next_team")
|
||||
sql_write_static(sql, {
|
||||
'next_date': _nextMatchDate,
|
||||
'next_club': _nextClub,
|
||||
@ -993,6 +1062,74 @@ def club_selection():
|
||||
return render_template('club_selection.html', form=form, clubs=clubs, selected_clubs=[])
|
||||
|
||||
|
||||
@app.route('/admin/device-tracking', methods=['GET', 'POST'])
|
||||
@basic_auth.required
|
||||
def device_tracking():
|
||||
"""Admin page for viewing device voting patterns"""
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'analyze_patterns':
|
||||
# Analyze voting patterns by device
|
||||
sql_patterns = text("""
|
||||
SELECT
|
||||
device_id,
|
||||
COUNT(*) as vote_count,
|
||||
COUNT(DISTINCT fixture_date) as fixtures_voted,
|
||||
STRING_AGG(DISTINCT motm_player_name, ', ') as motm_players,
|
||||
STRING_AGG(DISTINCT dotd_player_name, ', ') as dotd_players,
|
||||
MIN(vote_timestamp) as first_vote,
|
||||
MAX(vote_timestamp) as last_vote,
|
||||
STRING_AGG(DISTINCT ip_address::text, ', ') as ip_addresses
|
||||
FROM device_votes
|
||||
GROUP BY device_id
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY vote_count DESC, fixtures_voted DESC
|
||||
""")
|
||||
patterns = sql_read(sql_patterns)
|
||||
|
||||
return render_template('device_tracking.html', patterns=patterns, analysis_mode=True)
|
||||
|
||||
elif action == 'view_device_details':
|
||||
device_id = request.form.get('device_id')
|
||||
if device_id:
|
||||
sql_details = text("""
|
||||
SELECT
|
||||
device_id,
|
||||
fixture_date,
|
||||
motm_player_name,
|
||||
dotd_player_name,
|
||||
ip_address,
|
||||
user_agent,
|
||||
vote_timestamp
|
||||
FROM device_votes
|
||||
WHERE device_id = :device_id
|
||||
ORDER BY vote_timestamp DESC
|
||||
""")
|
||||
device_details = sql_read(sql_details, {'device_id': device_id})
|
||||
|
||||
return render_template('device_tracking.html', device_details=device_details,
|
||||
selected_device=device_id, details_mode=True)
|
||||
|
||||
# Default view - show recent votes
|
||||
sql_recent = text("""
|
||||
SELECT
|
||||
device_id,
|
||||
fixture_date,
|
||||
motm_player_name,
|
||||
dotd_player_name,
|
||||
ip_address,
|
||||
vote_timestamp
|
||||
FROM device_votes
|
||||
ORDER BY vote_timestamp DESC
|
||||
LIMIT 50
|
||||
""")
|
||||
recent_votes = sql_read(sql_recent)
|
||||
|
||||
return render_template('device_tracking.html', recent_votes=recent_votes)
|
||||
|
||||
|
||||
@app.route('/admin/motm/manage', methods=['GET', 'POST'])
|
||||
@basic_auth.required
|
||||
def motm_management():
|
||||
|
||||
@ -153,6 +153,14 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="list-group card-custom">
|
||||
<a href="/admin/device-tracking" class="list-group-item">
|
||||
<h4 class="list-group-item-heading">Device Tracking</h4>
|
||||
<p class="list-group-item-text">Monitor voting patterns and detect duplicate votes</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="list-group card-custom">
|
||||
<a href="/admin/poty" class="list-group-item">
|
||||
|
||||
230
motm_app/templates/device_tracking.html
Normal file
230
motm_app/templates/device_tracking.html
Normal file
@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Device Tracking - HKFC Men's C Team MOTM System</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.tracking-section {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.pattern-warning {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.device-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
background-color: #e9ecef;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Device Tracking Analysis</h1>
|
||||
<p class="lead">Monitor voting patterns and detect potential duplicate voting</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<a href="/admin" class="btn btn-outline-primary">Back to Admin Dashboard</a>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Analysis Controls -->
|
||||
<div class="tracking-section">
|
||||
<h3>Analysis Controls</h3>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="analyze_patterns">
|
||||
<button type="submit" class="btn btn-primary">Analyze Voting Patterns</button>
|
||||
</form>
|
||||
<small class="text-muted">Find devices with multiple votes</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<a href="/admin/device-tracking" class="btn btn-outline-secondary">View Recent Votes</a>
|
||||
<small class="text-muted">Show last 50 votes</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pattern Analysis Results -->
|
||||
{% if analysis_mode and patterns %}
|
||||
<div class="tracking-section">
|
||||
<h3>Voting Pattern Analysis</h3>
|
||||
<p class="text-muted">Devices that have voted multiple times:</p>
|
||||
|
||||
{% if patterns %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Device ID</th>
|
||||
<th>Vote Count</th>
|
||||
<th>Fixtures</th>
|
||||
<th>MOTM Players</th>
|
||||
<th>DotD Players</th>
|
||||
<th>First Vote</th>
|
||||
<th>Last Vote</th>
|
||||
<th>IP Addresses</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pattern in patterns %}
|
||||
<tr>
|
||||
<td><span class="device-id">{{ pattern.device_id }}</span></td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'danger' if pattern.vote_count > 3 else 'warning' }}">
|
||||
{{ pattern.vote_count }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ pattern.fixtures_voted }}</td>
|
||||
<td>{{ pattern.motm_players }}</td>
|
||||
<td>{{ pattern.dotd_players }}</td>
|
||||
<td>{{ pattern.first_vote.strftime('%Y-%m-%d %H:%M') if pattern.first_vote else 'N/A' }}</td>
|
||||
<td>{{ pattern.last_vote.strftime('%Y-%m-%d %H:%M') if pattern.last_vote else 'N/A' }}</td>
|
||||
<td>{{ pattern.ip_addresses }}</td>
|
||||
<td>
|
||||
<form method="POST" style="display: inline;">
|
||||
<input type="hidden" name="action" value="view_device_details">
|
||||
<input type="hidden" name="device_id" value="{{ pattern.device_id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-info">Details</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if patterns|length > 0 %}
|
||||
<div class="pattern-warning">
|
||||
<h5><i class="bi bi-exclamation-triangle"></i> Pattern Analysis</h5>
|
||||
<p class="mb-0">
|
||||
<strong>Warning:</strong> {{ patterns|length }} device(s) have voted multiple times.
|
||||
This could indicate duplicate voting or shared devices.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-success">
|
||||
<h5>No Suspicious Patterns Found</h5>
|
||||
<p>All devices have voted only once per fixture.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Device Details -->
|
||||
{% if details_mode and device_details %}
|
||||
<div class="tracking-section">
|
||||
<h3>Device Details: <span class="device-id">{{ selected_device }}</span></h3>
|
||||
<p class="text-muted">Complete voting history for this device:</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Fixture Date</th>
|
||||
<th>MOTM Vote</th>
|
||||
<th>DotD Vote</th>
|
||||
<th>IP Address</th>
|
||||
<th>Vote Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for vote in device_details %}
|
||||
<tr>
|
||||
<td>{{ vote.fixture_date }}</td>
|
||||
<td>{{ vote.motm_player_name }}</td>
|
||||
<td>{{ vote.dotd_player_name }}</td>
|
||||
<td>{{ vote.ip_address }}</td>
|
||||
<td>{{ vote.vote_timestamp.strftime('%Y-%m-%d %H:%M:%S') if vote.vote_timestamp else 'N/A' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<h5>Device Information</h5>
|
||||
{% if device_details %}
|
||||
<p><strong>User Agent:</strong> {{ device_details[0].user_agent[:100] }}{% if device_details[0].user_agent|length > 100 %}...{% endif %}</p>
|
||||
<p><strong>Total Votes:</strong> {{ device_details|length }}</p>
|
||||
<p><strong>Unique Fixtures:</strong> {{ device_details|map(attribute='fixture_date')|unique|list|length }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recent Votes -->
|
||||
{% if recent_votes and not analysis_mode and not details_mode %}
|
||||
<div class="tracking-section">
|
||||
<h3>Recent Votes</h3>
|
||||
<p class="text-muted">Last 50 votes cast:</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Device ID</th>
|
||||
<th>Fixture Date</th>
|
||||
<th>MOTM Vote</th>
|
||||
<th>DotD Vote</th>
|
||||
<th>IP Address</th>
|
||||
<th>Vote Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for vote in recent_votes %}
|
||||
<tr>
|
||||
<td><span class="device-id">{{ vote.device_id }}</span></td>
|
||||
<td>{{ vote.fixture_date }}</td>
|
||||
<td>{{ vote.motm_player_name }}</td>
|
||||
<td>{{ vote.dotd_player_name }}</td>
|
||||
<td>{{ vote.ip_address }}</td>
|
||||
<td>{{ vote.vote_timestamp.strftime('%Y-%m-%d %H:%M') if vote.vote_timestamp else 'N/A' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- No Data Message -->
|
||||
{% if not recent_votes and not patterns and not device_details %}
|
||||
<div class="alert alert-info">
|
||||
<h5>No Vote Data Available</h5>
|
||||
<p>No votes have been cast yet, or the device tracking table is empty.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -31,6 +31,10 @@
|
||||
<div class="col-md-6">
|
||||
<h3>Admin Section</h3>
|
||||
<div class="list-group">
|
||||
<a href="/admin" class="list-group-item">
|
||||
<h4 class="list-group-item-heading">Admin Dashboard</h4>
|
||||
<p class="list-group-item-text">Access all administrative functions</p>
|
||||
</a>
|
||||
<a href="/admin/players" class="list-group-item">
|
||||
<h4 class="list-group-item-heading">Player Management</h4>
|
||||
<p class="list-group-item-text">Add, edit, and manage players in the database</p>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user