Merge pull request 'identifier' (#2) from identifier into master

Reviewed-on: #2
This commit is contained in:
Jonny Ervine 2025-10-06 14:38:49 +00:00
commit a08897c5c2
19 changed files with 2585 additions and 28 deletions

80
motm_app/.dockerignore Normal file
View File

@ -0,0 +1,80 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
env/
ENV/
env.bak/
venv.bak/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Database files
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
# Temporary files
*.tmp
*.temp
# Git
.git/
.gitignore
# Documentation
*.md
docs/
# Scripts and configs not needed in container
activate_motm.bat
activate_motm.sh
run_motm.bat
run_motm.sh
setup_venv_windows.bat
setup_venv.ps1
setup_venv.py
deploy.py
test_app.py
# Development files
app.yaml
database_config.ini
POSTGRESQL_SETUP.md
SETUP_GUIDE.md
VIRTUAL_ENV_GUIDE.md
README.md

View File

@ -0,0 +1,190 @@
# MOTM Application - Container Deployment
This document provides instructions for deploying the MOTM (Man of the Match) application using Docker containers.
## Quick Start
### Using Docker Compose (Recommended)
1. **Clone the repository and navigate to the project directory**
```bash
cd motm_app
```
2. **Start the application with PostgreSQL**
```bash
docker-compose up -d
```
3. **Access the application**
- Main page: http://localhost:5000
- Admin dashboard: http://localhost:5000/admin (username: `admin`, password: `letmein`)
### Using Docker Build
1. **Build the container**
```bash
docker build -f Containerfile -t motm-app .
```
2. **Run with external PostgreSQL**
```bash
docker run -d \
--name motm-app \
-p 5000:5000 \
-e DATABASE_TYPE=postgresql \
-e DB_HOST=your-postgres-host \
-e DB_PORT=5432 \
-e DB_NAME=motm_db \
-e DB_USER=motm_user \
-e DB_PASSWORD=motm_password \
motm-app
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_TYPE` | `postgresql` | Database type (postgresql/sqlite) |
| `DB_HOST` | `postgres` | Database host (use `postgres` for docker-compose) |
| `DB_PORT` | `5432` | Database port |
| `DB_NAME` | `motm_db` | Database name |
| `DB_USER` | `motm_user` | Database username |
| `DB_PASSWORD` | `motm_password` | Database password |
| `FLASK_ENV` | `production` | Flask environment |
| `FLASK_RUN_HOST` | `0.0.0.0` | Flask host |
| `FLASK_RUN_PORT` | `5000` | Flask port |
| `SECRET_KEY` | `your-secret-key-change-this-in-production` | Flask secret key |
### Production Security
**Important**: Before deploying to production, change the following:
1. **Database credentials** in `docker-compose.yml`
2. **Secret key** in environment variables
3. **Admin password** (use the admin profile page after first login)
## Container Features
### Multi-stage Build
- **Builder stage**: Installs dependencies and builds Python packages
- **Runtime stage**: Minimal image with only runtime dependencies
### Security
- Runs as non-root user (`appuser`)
- Minimal attack surface with slim base image
- Health checks for container monitoring
### Database Integration
- Automatic database connection waiting
- Database initialization support
- PostgreSQL optimized configuration
## Monitoring
### Health Checks
- Application health check: `curl -f http://localhost:5000/`
- Database health check: `pg_isready`
### Logs
```bash
# View application logs
docker-compose logs motm-app
# View database logs
docker-compose logs postgres
# Follow logs in real-time
docker-compose logs -f motm-app
```
## Maintenance
### Backup Database
```bash
# Create backup
docker exec motm-postgres pg_dump -U motm_user motm_db > backup.sql
# Restore backup
docker exec -i motm-postgres psql -U motm_user motm_db < backup.sql
```
### Update Application
```bash
# Pull latest changes
git pull
# Rebuild and restart
docker-compose down
docker-compose up -d --build
```
### Reset Database
```bash
# Stop services
docker-compose down
# Remove database volume
docker volume rm motm_app_postgres_data
# Start fresh
docker-compose up -d
```
## Troubleshooting
### Common Issues
1. **Database connection errors**
- Check if PostgreSQL container is running: `docker-compose ps`
- Verify database credentials in environment variables
- Check database logs: `docker-compose logs postgres`
2. **Application won't start**
- Check application logs: `docker-compose logs motm-app`
- Verify all environment variables are set correctly
- Ensure database is healthy before application starts
3. **Port conflicts**
- Change port mapping in `docker-compose.yml`
- Example: `"8080:5000"` to use port 8080 instead of 5000
### Debug Mode
```bash
# Run container in interactive mode for debugging
docker run -it --rm \
-p 5000:5000 \
-e DATABASE_TYPE=postgresql \
-e DB_HOST=your-postgres-host \
-e DB_PORT=5432 \
-e DB_NAME=motm_db \
-e DB_USER=motm_user \
-e DB_PASSWORD=motm_password \
motm-app /bin/bash
```
## File Structure
```
motm_app/
├── Containerfile # Docker container definition
├── docker-compose.yml # Multi-service orchestration
├── .dockerignore # Files to exclude from build
├── init.sql # Database initialization script
├── requirements.txt # Python dependencies
├── main.py # Main application file
├── static/ # Static assets (CSS, JS, images)
├── templates/ # HTML templates
└── data/ # Persistent data directory
```
## Support
For issues or questions about container deployment, please check:
1. Application logs: `docker-compose logs motm-app`
2. Database logs: `docker-compose logs postgres`
3. Container status: `docker-compose ps`

View File

@ -34,9 +34,11 @@ FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH" \ PATH="/opt/venv/bin:$PATH" \
DATABASE_TYPE=sqlite \ DATABASE_TYPE=postgresql \
FLASK_ENV=production \ FLASK_ENV=production \
FLASK_APP=main.py FLASK_APP=main.py \
FLASK_RUN_HOST=0.0.0.0 \
FLASK_RUN_PORT=5000
# Install runtime dependencies # Install runtime dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
@ -61,6 +63,24 @@ COPY --chown=appuser:appuser . .
RUN mkdir -p /app/data /app/logs && \ RUN mkdir -p /app/data /app/logs && \
chown -R appuser:appuser /app/data /app/logs chown -R appuser:appuser /app/data /app/logs
# Create a startup script for better initialization
RUN echo '#!/bin/bash\n\
# Wait for database to be ready\n\
echo "Waiting for database connection..."\n\
while ! python -c "import psycopg2; psycopg2.connect(host=\"$DB_HOST\", port=\"$DB_PORT\", user=\"$DB_USER\", password=\"$DB_PASSWORD\", dbname=\"$DB_NAME\")" 2>/dev/null; do\n\
echo "Database not ready, waiting..."\n\
sleep 2\n\
done\n\
echo "Database connection established!"\n\
\n\
# Initialize database if needed\n\
python -c "from db_setup import db_config_manager; db_config_manager.load_config(); db_config_manager._update_environment_variables()"\n\
\n\
# Start the application\n\
exec python main.py' > /app/start.sh && \
chmod +x /app/start.sh && \
chown appuser:appuser /app/start.sh
# Switch to non-root user # Switch to non-root user
USER appuser USER appuser
@ -72,4 +92,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5000/ || exit 1 CMD curl -f http://localhost:5000/ || exit 1
# Default command # Default command
CMD ["python", "main.py"] CMD ["/app/start.sh"]

View File

@ -0,0 +1,71 @@
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: motm-postgres
environment:
POSTGRES_DB: motm_db
POSTGRES_USER: motm_user
POSTGRES_PASSWORD: motm_password
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U motm_user -d motm_db"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# MOTM Application
motm-app:
build:
context: .
dockerfile: Containerfile
container_name: motm-app
environment:
# Database configuration
DATABASE_TYPE: postgresql
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: motm_db
DB_USER: motm_user
DB_PASSWORD: motm_password
# Flask configuration
FLASK_ENV: production
FLASK_APP: main.py
FLASK_RUN_HOST: 0.0.0.0
FLASK_RUN_PORT: 5000
# Security
SECRET_KEY: your-secret-key-change-this-in-production
ports:
- "5000:5000"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./data:/app/data
- ./logs:/app/logs
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
postgres_data:
driver: local
networks:
default:
name: motm-network

View File

@ -148,3 +148,39 @@ class ClubSelectionForm(FlaskForm):
select_all = SubmitField('Select All') select_all = SubmitField('Select All')
select_none = SubmitField('Select None') select_none = SubmitField('Select None')
cancel = SubmitField('Cancel') cancel = SubmitField('Cancel')
class S3ConfigForm(FlaskForm):
"""Form for S3 configuration."""
# Enable/disable S3
enable_s3 = BooleanField('Enable S3 Storage', default=False)
# Storage provider selection
storage_provider = SelectField('Storage Provider',
choices=[('aws', 'AWS S3'), ('minio', 'MinIO')],
default='aws')
# AWS credentials
aws_access_key_id = StringField('Access Key ID')
aws_secret_access_key = PasswordField('Secret Access Key')
aws_region = StringField('Region', default='us-east-1')
# MinIO specific configuration
minio_endpoint = StringField('MinIO Endpoint',
render_kw={'placeholder': 'minio.example.com:9000'})
minio_use_ssl = BooleanField('Use SSL for MinIO', default=True)
# S3 bucket configuration
bucket_name = StringField('Bucket Name')
bucket_prefix = StringField('Bucket Prefix', default='assets/')
# URL configuration
use_signed_urls = BooleanField('Use Signed URLs', default=True)
signed_url_expiry = IntegerField('Signed URL Expiry (seconds)', default=3600)
fallback_to_static = BooleanField('Fallback to Static Files', default=True)
# Action buttons
test_connection = SubmitField('Test Connection')
save_config = SubmitField('Save Configuration')
cancel = SubmitField('Cancel')

13
motm_app/init.sql Normal file
View File

@ -0,0 +1,13 @@
-- Database initialization script for MOTM application
-- This script runs when the PostgreSQL container starts for the first time
-- Create the database if it doesn't exist (this is handled by POSTGRES_DB env var)
-- But we can add any additional setup here
-- Create extensions if needed
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- The application will handle table creation through SQLAlchemy
-- This file is here for any additional database setup that might be needed

View File

@ -27,13 +27,14 @@ from flask_basicauth import BasicAuth
from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms import DateField from wtforms import DateField
from wtforms.validators import InputRequired, Email, Length, EqualTo from wtforms.validators import InputRequired, Email, Length, EqualTo
from forms import motmForm, adminSettingsForm2, goalsAssistsForm, DatabaseSetupForm, PlayerForm, ClubForm, TeamForm, DataImportForm, ClubSelectionForm from forms import motmForm, adminSettingsForm2, goalsAssistsForm, DatabaseSetupForm, PlayerForm, ClubForm, TeamForm, DataImportForm, ClubSelectionForm, S3ConfigForm
from db_config import sql_write, sql_write_static, sql_read, sql_read_static from db_config import sql_write, sql_write_static, sql_read, sql_read_static
from sqlalchemy import text from sqlalchemy import text
from tables import matchSquadTable from tables import matchSquadTable
from readSettings import mySettings from readSettings import mySettings
from fixture_scraper import FixtureScraper, get_next_hkfc_c_fixture, get_opponent_club_name, get_opponent_club_info, match_opponent_to_club from fixture_scraper import FixtureScraper, get_next_hkfc_c_fixture, get_opponent_club_name, get_opponent_club_info, match_opponent_to_club
from club_scraper import ClubScraper, get_hk_hockey_clubs, expand_club_abbreviation from club_scraper import ClubScraper, get_hk_hockey_clubs, expand_club_abbreviation
from s3_config import s3_config_manager, s3_asset_service
# Custom authentication class that uses database # Custom authentication class that uses database
class DatabaseBasicAuth(BasicAuth): class DatabaseBasicAuth(BasicAuth):
@ -66,7 +67,9 @@ class AdminProfileForm(FlaskForm):
@app.route('/') @app.route('/')
def index(): def index():
"""Main index page for MOTM system""" """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') @app.route('/admin')
@ -127,6 +130,54 @@ def admin_profile():
return render_template('admin_profile.html', form=form, current_email=current_email) 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 ==================== # ==================== PUBLIC VOTING SECTION ====================
@app.route('/motm/<randomUrlSuffix>') @app.route('/motm/<randomUrlSuffix>')
@ -144,18 +195,24 @@ def motm_vote(randomUrlSuffix):
nextClub = nextInfo[0]['nextclub'] nextClub = nextInfo[0]['nextclub']
nextTeam = nextInfo[0]['nextteam'] nextTeam = nextInfo[0]['nextteam']
nextFixture = nextInfo[0]['nextfixture'] nextFixture = nextInfo[0]['nextfixture']
hkfcLogo = nextInfo[0]['hkfclogo'] # Get HKFC logo from clubs table using signed URLs (with authentication)
oppoLogo = nextInfo[0]['oppologo'] hkfcLogo = s3_asset_service.get_asset_url('images/hkfc_logo.png') # Default fallback
currMotM = nextInfo[0]['currmotm'] sql_hkfc_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = 'Hong Kong Football Club'")
currDotD = nextInfo[0]['currdotd'] hkfc_logo_result = sql_read(sql_hkfc_logo)
oppo = nextTeam if hkfc_logo_result and hkfc_logo_result[0]['logo_url']:
hkfcLogo = s3_asset_service.get_logo_url(hkfc_logo_result[0]['logo_url'], 'Hong Kong Football Club')
# Get opponent club logo from clubs table # Get opponent club logo from clubs table using signed URLs (with authentication)
oppoLogo = s3_asset_service.get_asset_url('images/default_logo.png') # Default fallback
if nextClub: if nextClub:
sql_club_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = :club_name") sql_club_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = :club_name")
club_logo_result = sql_read(sql_club_logo, {'club_name': nextClub}) club_logo_result = sql_read(sql_club_logo, {'club_name': nextClub})
if club_logo_result and club_logo_result[0]['logo_url']: if club_logo_result and club_logo_result[0]['logo_url']:
oppoLogo = club_logo_result[0]['logo_url'] oppoLogo = s3_asset_service.get_logo_url(club_logo_result[0]['logo_url'], nextClub)
currMotM = nextInfo[0]['currmotm']
currDotD = nextInfo[0]['currdotd']
oppo = nextTeam
# Get match date from admin settings # Get match date from admin settings
if nextInfo and nextInfo[0]['nextdate']: if nextInfo and nextInfo[0]['nextdate']:
nextDate = nextInfo[0]['nextdate'] nextDate = nextInfo[0]['nextdate']
@ -180,8 +237,8 @@ def motm_vote(randomUrlSuffix):
return render_template('error.html', message="Player data not found. Please set up current MOTM and DotD players in admin settings.") return render_template('error.html', message="Player data not found. Please set up current MOTM and DotD players in admin settings.")
# Use default player images since playerPictureURL column doesn't exist # Use default player images since playerPictureURL column doesn't exist
motmURL = '/static/images/default_player.png' motmURL = s3_asset_service.get_asset_url('images/default_player.png')
dotdURL = '/static/images/default_player.png' dotdURL = s3_asset_service.get_asset_url('images/default_player.png')
# Get match comments # Get match comments
sql5 = text("SELECT comment FROM _motmcomments WHERE matchDate = :match_date ORDER BY RANDOM() LIMIT 1") sql5 = text("SELECT comment FROM _motmcomments WHERE matchDate = :match_date ORDER BY RANDOM() LIMIT 1")
@ -206,16 +263,29 @@ def motm_vote(randomUrlSuffix):
@app.route('/motm/comments', methods=['GET', 'POST']) @app.route('/motm/comments', methods=['GET', 'POST'])
def match_comments(): def match_comments():
"""Display and allow adding 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) row = sql_read_static(sql)
if not row: if not row:
return render_template('error.html', message="Database not initialized. Please go to Database Setup to initialize the database.") 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') commentDate = row[0]['nextdate'].strftime('%Y-%m-%d')
_matchDate = row[0]['nextdate'].strftime('%Y_%m_%d') _matchDate = row[0]['nextdate'].strftime('%Y_%m_%d')
hkfcLogo = row[0]['hkfcLogo']
oppoLogo = row[0]['oppoLogo'] # Get HKFC logo from clubs table using signed URLs (with authentication)
hkfcLogo = s3_asset_service.get_asset_url('images/hkfc_logo.png') # Default fallback
sql_hkfc_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = 'Hong Kong Football Club'")
hkfc_logo_result = sql_read(sql_hkfc_logo)
if hkfc_logo_result and hkfc_logo_result[0]['logo_url']:
hkfcLogo = s3_asset_service.get_logo_url(hkfc_logo_result[0]['logo_url'], 'Hong Kong Football Club')
# Get opponent club logo from clubs table using signed URLs (with authentication)
oppoLogo = s3_asset_service.get_asset_url('images/default_logo.png') # Default fallback
if _oppo:
sql_club_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = :club_name")
club_logo_result = sql_read(sql_club_logo, {'club_name': _oppo})
if club_logo_result and club_logo_result[0]['logo_url']:
oppoLogo = s3_asset_service.get_logo_url(club_logo_result[0]['logo_url'], _oppo)
if request.method == 'POST': if request.method == 'POST':
_comment = request.form['matchComment'] _comment = request.form['matchComment']
if _comment != 'Optional comments added here': if _comment != 'Optional comments added here':
@ -282,6 +352,25 @@ def vote_thanks():
""") """)
sql_write(sql_dotd, {'player_num': _dotd, 'player_name': dotd_name}) 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 # Handle comments
if _comments and _comments != "Optional comments added here": if _comments and _comments != "Optional comments added here":
sql3 = text("INSERT INTO _motmcomments (matchDate, comment) VALUES (:match_date, :comment)") sql3 = text("INSERT INTO _motmcomments (matchDate, comment) VALUES (:match_date, :comment)")
@ -338,7 +427,7 @@ def motm_admin():
# Only update currMotM and currDotD if they were provided # Only update currMotM and currDotD if they were provided
if _currMotM and _currMotM != '0' and _currDotD and _currDotD != '0': 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, { sql_write_static(sql, {
'next_date': _nextMatchDate, 'next_date': _nextMatchDate,
'next_club': _nextClub, 'next_club': _nextClub,
@ -348,7 +437,7 @@ def motm_admin():
}) })
else: else:
# Don't update currMotM and currDotD if not provided # 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, { sql_write_static(sql, {
'next_date': _nextMatchDate, 'next_date': _nextMatchDate,
'next_club': _nextClub, 'next_club': _nextClub,
@ -539,7 +628,7 @@ def club_management():
"""Admin page for managing clubs""" """Admin page for managing clubs"""
sql = text("SELECT id, hockey_club, logo_url FROM clubs ORDER BY hockey_club") sql = text("SELECT id, hockey_club, logo_url FROM clubs ORDER BY hockey_club")
clubs = sql_read(sql) clubs = sql_read(sql)
return render_template('club_management.html', clubs=clubs) return render_template('club_management.html', clubs=clubs, s3_asset_service=s3_asset_service)
@app.route('/admin/clubs/add', methods=['GET', 'POST']) @app.route('/admin/clubs/add', methods=['GET', 'POST'])
@ -993,6 +1082,74 @@ def club_selection():
return render_template('club_selection.html', form=form, clubs=clubs, selected_clubs=[]) 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']) @app.route('/admin/motm/manage', methods=['GET', 'POST'])
@basic_auth.required @basic_auth.required
def motm_management(): def motm_management():
@ -1682,5 +1839,201 @@ def database_status():
return render_template('database_status.html', db_info=db_info) return render_template('database_status.html', db_info=db_info)
# ==================== S3 CONFIGURATION SECTION ====================
@app.route('/admin/s3-config', methods=['GET', 'POST'])
@basic_auth.required
def s3_config():
"""Admin page for S3 configuration and asset management"""
form = S3ConfigForm()
if request.method == 'POST':
if form.test_connection.data:
# Test S3 connection
form_data = {
'enable_s3': form.enable_s3.data,
'storage_provider': form.storage_provider.data,
'aws_access_key_id': form.aws_access_key_id.data,
'aws_secret_access_key': form.aws_secret_access_key.data,
'aws_region': form.aws_region.data,
'minio_endpoint': form.minio_endpoint.data,
'minio_use_ssl': form.minio_use_ssl.data,
'bucket_name': form.bucket_name.data,
'bucket_prefix': form.bucket_prefix.data,
'use_signed_urls': form.use_signed_urls.data,
'signed_url_expiry': form.signed_url_expiry.data,
'fallback_to_static': form.fallback_to_static.data
}
success, message = s3_config_manager.test_connection(form_data)
if success:
flash(f'{message}', 'success')
else:
flash(f'{message}', 'error')
elif form.save_config.data:
# Save S3 configuration
form_data = {
'enable_s3': form.enable_s3.data,
'storage_provider': form.storage_provider.data,
'aws_access_key_id': form.aws_access_key_id.data,
'aws_secret_access_key': form.aws_secret_access_key.data,
'aws_region': form.aws_region.data,
'minio_endpoint': form.minio_endpoint.data,
'minio_use_ssl': form.minio_use_ssl.data,
'bucket_name': form.bucket_name.data,
'bucket_prefix': form.bucket_prefix.data,
'use_signed_urls': form.use_signed_urls.data,
'signed_url_expiry': form.signed_url_expiry.data,
'fallback_to_static': form.fallback_to_static.data
}
try:
success = s3_config_manager.save_config(form_data)
if success:
flash('✅ S3 configuration saved successfully!', 'success')
else:
flash('❌ Failed to save S3 configuration', 'error')
except Exception as e:
flash(f'❌ Error saving configuration: {str(e)}', 'error')
elif form.cancel.data:
return redirect(url_for('admin_dashboard'))
# Load current configuration for display
current_config = s3_config_manager.get_config_dict()
# Populate form with current configuration (only for GET requests or after POST processing)
for field_name, value in current_config.items():
if hasattr(form, field_name):
getattr(form, field_name).data = value
return render_template('s3_config.html', form=form, current_config=current_config)
@app.route('/admin/s3-status')
@basic_auth.required
def s3_status():
"""Admin page showing current S3 status and configuration"""
try:
current_config = s3_config_manager.get_config_dict()
# Test current configuration
success, message = s3_config_manager.test_connection()
s3_info = {
'enabled': current_config.get('enable_s3', False),
'storage_provider': current_config.get('storage_provider', 'aws'),
'bucket_name': current_config.get('bucket_name', 'Not configured'),
'aws_region': current_config.get('aws_region', 'Not configured'),
'minio_endpoint': current_config.get('minio_endpoint', 'Not configured'),
'use_signed_urls': current_config.get('use_signed_urls', True),
'fallback_enabled': current_config.get('fallback_to_static', True),
'connection_status': message,
'connection_success': success
}
except Exception as e:
s3_info = {
'enabled': False,
'storage_provider': 'aws',
'bucket_name': 'Not configured',
'aws_region': 'Not configured',
'minio_endpoint': 'Not configured',
'use_signed_urls': True,
'fallback_enabled': True,
'connection_status': f'Error: {str(e)}',
'connection_success': False
}
return render_template('s3_status.html', s3_info=s3_info)
@app.route('/admin/api/s3-browser')
@basic_auth.required
def s3_browser():
"""API endpoint to browse S3 bucket contents"""
try:
config = s3_config_manager.config
if not config.get('enable_s3', False):
return jsonify({
'success': False,
'message': 'S3 storage is not enabled'
})
# Get path parameter (default to root of assets folder)
path = request.args.get('path', '')
if not path.endswith('/') and path != '':
path += '/'
# List objects in S3
bucket_name = config.get('bucket_name', '')
bucket_prefix = config.get('bucket_prefix', 'assets/')
prefix = bucket_prefix + path
s3_client = s3_asset_service.s3_client
if not s3_client:
return jsonify({
'success': False,
'message': 'S3 client not available'
})
# List objects
response = s3_client.list_objects_v2(
Bucket=bucket_name,
Prefix=prefix,
Delimiter='/'
)
# Process folders
folders = []
if 'CommonPrefixes' in response:
for folder in response['CommonPrefixes']:
folder_name = folder['Prefix'].replace(prefix, '').rstrip('/')
if folder_name: # Skip empty folder names
folders.append({
'name': folder_name,
'path': path + folder_name + '/',
'type': 'folder'
})
# Process files
files = []
if 'Contents' in response:
for obj in response['Contents']:
file_key = obj['Key'].replace(prefix, '')
if file_key and not file_key.endswith('/'): # Skip folders and empty keys
# Generate public URL for the file (for preview images)
file_url = s3_asset_service.get_asset_url_public(file_key)
files.append({
'name': file_key,
'path': path + file_key,
'url': file_url,
'size': obj['Size'],
'last_modified': obj['LastModified'].isoformat(),
'type': 'file'
})
# Sort results
folders.sort(key=lambda x: x['name'].lower())
files.sort(key=lambda x: x['name'].lower())
return jsonify({
'success': True,
'path': path,
'folders': folders,
'files': files,
'parent_path': '/'.join(path.rstrip('/').split('/')[:-1]) + '/' if path != '' else ''
})
except Exception as e:
return jsonify({
'success': False,
'message': f'Error browsing S3: {str(e)}'
})
if __name__ == "__main__": if __name__ == "__main__":
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5000, debug=True)

View File

@ -24,5 +24,8 @@ pymysql>=1.1.0
psycopg2-binary>=2.9.0 psycopg2-binary>=2.9.0
PyMySQL>=1.1.0 PyMySQL>=1.1.0
# AWS S3 support
boto3>=1.34.0
# Legacy support (can be removed after migration) # Legacy support (can be removed after migration)
flask-mysql flask-mysql

14
motm_app/s3_config.json Normal file
View File

@ -0,0 +1,14 @@
{
"enable_s3": true,
"storage_provider": "minio",
"aws_access_key_id": "5MoE0Vz8F9vVgulClesUV3GReh2nIiXG",
"aws_secret_access_key": "0h[c8lSHUE'<",
"aws_region": "us-east-1",
"minio_endpoint": "s3.ervine.cloud:443",
"minio_use_ssl": true,
"bucket_name": "hockey-app",
"bucket_prefix": "assets/",
"use_signed_urls": true,
"signed_url_expiry": 3600,
"fallback_to_static": true
}

View File

@ -0,0 +1,14 @@
{
"enable_s3": true,
"storage_provider": "minio",
"aws_access_key_id": "AKIARLJ7D6ZPRRLQHWD7",
"aws_secret_access_key": "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",
"aws_region": "us-east-1",
"minio_endpoint": "s3.ervine.cloud:443",
"minio_use_ssl": true,
"bucket_name": "hockey-app",
"bucket_prefix": "assets/",
"use_signed_urls": true,
"signed_url_expiry": 3600,
"fallback_to_static": true
}

390
motm_app/s3_config.py Normal file
View File

@ -0,0 +1,390 @@
# encoding=utf-8
"""
S3 Configuration and Service Module
Handles S3 credentials and asset retrieval for the MOTM application.
"""
import os
import json
import boto3
from botocore.exceptions import ClientError, NoCredentialsError
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
class S3ConfigManager:
"""Manages S3 configuration settings."""
def __init__(self):
self.config_file = 's3_config.json'
self.config = self.load_config()
def load_config(self) -> Dict[str, Any]:
"""Load S3 configuration from file."""
if os.path.exists(self.config_file):
try:
with open(self.config_file, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return self.get_default_config()
return self.get_default_config()
def get_default_config(self) -> Dict[str, Any]:
"""Get default S3 configuration."""
return {
'aws_access_key_id': '',
'aws_secret_access_key': '',
'aws_region': 'us-east-1',
'bucket_name': '',
'bucket_prefix': 'assets/',
'enable_s3': False,
'use_signed_urls': True,
'signed_url_expiry': 3600, # 1 hour in seconds
'fallback_to_static': True,
'storage_provider': 'aws', # 'aws' or 'minio'
'minio_endpoint': '', # MinIO endpoint URL
'minio_use_ssl': True # Whether to use SSL for MinIO
}
def save_config(self, config_data: Dict[str, Any]) -> bool:
"""Save S3 configuration to file."""
try:
# Update environment variables
os.environ['AWS_ACCESS_KEY_ID'] = config_data.get('aws_access_key_id', '')
os.environ['AWS_SECRET_ACCESS_KEY'] = config_data.get('aws_secret_access_key', '')
os.environ['AWS_DEFAULT_REGION'] = config_data.get('aws_region', 'us-east-1')
with open(self.config_file, 'w') as f:
json.dump(config_data, f, indent=2)
self.config = config_data
return True
except IOError:
return False
def get_config_dict(self) -> Dict[str, Any]:
"""Get current configuration as dictionary."""
return self.config.copy()
def test_connection(self, config_data: Optional[Dict[str, Any]] = None) -> tuple[bool, str]:
"""Test S3 connection with provided or current configuration."""
if config_data:
test_config = config_data
else:
test_config = self.config
if not test_config.get('enable_s3', False):
return True, "S3 is disabled - using local static files"
try:
# Determine storage provider
storage_provider = test_config.get('storage_provider', 'aws')
if storage_provider == 'minio':
# Create MinIO client
minio_endpoint = test_config.get('minio_endpoint', '')
if not minio_endpoint:
return False, "MinIO endpoint is required when using MinIO"
# Parse endpoint to remove protocol if present
if minio_endpoint.startswith('http://'):
endpoint_host = minio_endpoint[7:]
use_ssl = False
elif minio_endpoint.startswith('https://'):
endpoint_host = minio_endpoint[8:]
use_ssl = True
else:
endpoint_host = minio_endpoint
use_ssl = test_config.get('minio_use_ssl', True)
# Construct the full endpoint URL
endpoint_url = f"{'https' if use_ssl else 'http'}://{endpoint_host}"
s3_client = boto3.client(
's3',
aws_access_key_id=test_config.get('aws_access_key_id', ''),
aws_secret_access_key=test_config.get('aws_secret_access_key', ''),
region_name=test_config.get('aws_region', 'us-east-1'),
endpoint_url=endpoint_url,
use_ssl=use_ssl,
verify=True # Enable SSL certificate verification
)
provider_name = "MinIO"
else:
# Create AWS S3 client
s3_client = boto3.client(
's3',
aws_access_key_id=test_config.get('aws_access_key_id', ''),
aws_secret_access_key=test_config.get('aws_secret_access_key', ''),
region_name=test_config.get('aws_region', 'us-east-1')
)
provider_name = "AWS S3"
# Test connection by listing bucket
bucket_name = test_config.get('bucket_name', '')
if not bucket_name:
return False, "Bucket name is required when S3 is enabled"
s3_client.head_bucket(Bucket=bucket_name)
return True, f"Successfully connected to {provider_name} bucket: {bucket_name}"
except ClientError as e:
error_code = e.response['Error']['Code']
provider_name = "MinIO" if test_config.get('storage_provider', 'aws') == 'minio' else "AWS S3"
if error_code == '404':
return False, f"{provider_name} bucket '{bucket_name}' not found"
elif error_code == '403':
return False, f"Access denied to {provider_name} bucket. Check credentials and permissions"
else:
return False, f"{provider_name} error: {e.response['Error']['Message']}"
except NoCredentialsError:
return False, "AWS credentials not found"
except Exception as e:
provider_name = "MinIO" if test_config.get('storage_provider', 'aws') == 'minio' else "AWS S3"
return False, f"{provider_name} connection error: {str(e)}"
class S3AssetService:
"""Service for retrieving assets from S3."""
def __init__(self, config_manager: S3ConfigManager):
self.config_manager = config_manager
self._s3_client = None
def _get_public_url(self, bucket_name: str, s3_key: str) -> str:
"""Generate public URL for S3 or MinIO."""
config = self.config_manager.config
storage_provider = config.get('storage_provider', 'aws')
if storage_provider == 'minio':
minio_endpoint = config.get('minio_endpoint', '')
use_ssl = config.get('minio_use_ssl', True)
protocol = 'https' if use_ssl else 'http'
# Parse endpoint to remove protocol if present
if minio_endpoint.startswith('http://'):
minio_endpoint = minio_endpoint[7:]
elif minio_endpoint.startswith('https://'):
minio_endpoint = minio_endpoint[8:]
return f"{protocol}://{minio_endpoint}/{bucket_name}/{s3_key}"
else:
return f"https://{bucket_name}.s3.{config.get('aws_region', 'us-east-1')}.amazonaws.com/{s3_key}"
@property
def s3_client(self):
"""Lazy-loaded S3 client."""
if self._s3_client is None and self.config_manager.config.get('enable_s3', False):
config = self.config_manager.config
storage_provider = config.get('storage_provider', 'aws')
if storage_provider == 'minio':
# Create MinIO client
minio_endpoint = config.get('minio_endpoint', '')
use_ssl = config.get('minio_use_ssl', True)
# Parse endpoint to remove protocol if present
if minio_endpoint.startswith('http://'):
endpoint_host = minio_endpoint[7:]
use_ssl = False
elif minio_endpoint.startswith('https://'):
endpoint_host = minio_endpoint[8:]
use_ssl = True
else:
endpoint_host = minio_endpoint
# Construct the full endpoint URL
endpoint_url = f"{'https' if use_ssl else 'http'}://{endpoint_host}"
self._s3_client = boto3.client(
's3',
aws_access_key_id=config.get('aws_access_key_id', ''),
aws_secret_access_key=config.get('aws_secret_access_key', ''),
region_name=config.get('aws_region', 'us-east-1'),
endpoint_url=endpoint_url,
use_ssl=use_ssl,
verify=True # Enable SSL certificate verification
)
else:
# Create AWS S3 client
self._s3_client = boto3.client(
's3',
aws_access_key_id=config.get('aws_access_key_id', ''),
aws_secret_access_key=config.get('aws_secret_access_key', ''),
region_name=config.get('aws_region', 'us-east-1')
)
return self._s3_client
def get_logo_url(self, logo_path: str, club_name: str = '') -> str:
"""Get logo URL from S3 or fallback to static."""
config = self.config_manager.config
if not config.get('enable_s3', False):
return self._get_static_logo_url(logo_path)
bucket_name = config.get('bucket_name', '')
bucket_prefix = config.get('bucket_prefix', 'motm-assets/')
if not bucket_name:
return self._get_static_logo_url(logo_path)
# Clean up the logo path
if logo_path.startswith('/static/'):
s3_key = bucket_prefix + logo_path.replace('/static/', '')
elif logo_path.startswith('logos/'):
s3_key = bucket_prefix + logo_path
else:
s3_key = bucket_prefix + 'logos/' + logo_path
try:
if config.get('use_signed_urls', True):
# Generate signed URL
expiry = config.get('signed_url_expiry', 3600)
signed_url = self.s3_client.generate_presigned_url(
'get_object',
Params={'Bucket': bucket_name, 'Key': s3_key},
ExpiresIn=expiry
)
return signed_url
else:
# Return public URL
return self._get_public_url(bucket_name, s3_key)
except ClientError:
# Fallback to static if S3 fails
if config.get('fallback_to_static', True):
return self._get_static_logo_url(logo_path)
return ''
def get_logo_url_public(self, logo_path: str, club_name: str = '') -> str:
"""Get logo URL from S3 using public URLs (no authentication)."""
config = self.config_manager.config
if not config.get('enable_s3', False):
return self._get_static_logo_url(logo_path)
bucket_name = config.get('bucket_name', '')
bucket_prefix = config.get('bucket_prefix', 'assets/')
if not bucket_name:
return self._get_static_logo_url(logo_path)
# Clean up the logo path
if logo_path.startswith('/static/'):
s3_key = bucket_prefix + logo_path.replace('/static/', '')
elif logo_path.startswith('logos/'):
s3_key = bucket_prefix + logo_path
else:
s3_key = bucket_prefix + 'logos/' + logo_path
try:
# Always return public URL (no authentication)
return self._get_public_url(bucket_name, s3_key)
except Exception:
# Fallback to static if S3 fails
if config.get('fallback_to_static', True):
return self._get_static_logo_url(logo_path)
return ''
def get_asset_url(self, asset_path: str) -> str:
"""Get any asset URL from S3 or fallback to static."""
config = self.config_manager.config
if not config.get('enable_s3', False):
return f"/static/{asset_path}"
bucket_name = config.get('bucket_name', '')
bucket_prefix = config.get('bucket_prefix', 'motm-assets/')
if not bucket_name:
return f"/static/{asset_path}"
# Clean up the asset path
if asset_path.startswith('/static/'):
s3_key = bucket_prefix + asset_path.replace('/static/', '')
else:
s3_key = bucket_prefix + asset_path
try:
if config.get('use_signed_urls', True):
# Generate signed URL
expiry = config.get('signed_url_expiry', 3600)
signed_url = self.s3_client.generate_presigned_url(
'get_object',
Params={'Bucket': bucket_name, 'Key': s3_key},
ExpiresIn=expiry
)
return signed_url
else:
# Return public URL
return self._get_public_url(bucket_name, s3_key)
except ClientError:
# Fallback to static if S3 fails
if config.get('fallback_to_static', True):
return f"/static/{asset_path}"
return ''
def get_asset_url_public(self, asset_path: str) -> str:
"""Get any asset URL from S3 using public URLs (no authentication)."""
config = self.config_manager.config
if not config.get('enable_s3', False):
return f"/static/{asset_path}"
bucket_name = config.get('bucket_name', '')
bucket_prefix = config.get('bucket_prefix', 'assets/')
if not bucket_name:
return f"/static/{asset_path}"
# Clean up the asset path
if asset_path.startswith('/static/'):
s3_key = bucket_prefix + asset_path.replace('/static/', '')
else:
s3_key = bucket_prefix + asset_path
try:
# Always return public URL (no authentication)
return self._get_public_url(bucket_name, s3_key)
except Exception:
# Fallback to static if S3 fails
if config.get('fallback_to_static', True):
return f"/static/{asset_path}"
return ''
def upload_asset(self, file_path: str, s3_key: str) -> tuple[bool, str]:
"""Upload an asset to S3."""
config = self.config_manager.config
if not config.get('enable_s3', False):
return False, "S3 is disabled"
bucket_name = config.get('bucket_name', '')
bucket_prefix = config.get('bucket_prefix', 'motm-assets/')
if not bucket_name:
return False, "Bucket name not configured"
full_s3_key = bucket_prefix + s3_key
try:
self.s3_client.upload_file(file_path, bucket_name, full_s3_key)
return True, f"Successfully uploaded {s3_key} to S3"
except ClientError as e:
return False, f"Upload failed: {e.response['Error']['Message']}"
except Exception as e:
return False, f"Upload error: {str(e)}"
def _get_static_logo_url(self, logo_path: str) -> str:
"""Get static logo URL as fallback."""
if logo_path.startswith('/static/'):
return logo_path
return f"/static/images/{logo_path}"
# Global instances
s3_config_manager = S3ConfigManager()
s3_asset_service = S3AssetService(s3_config_manager)

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Club - HKFC Men's C Team</title> <title>Add Club - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head> </head>
<body> <body>
<div class="container mt-4"> <div class="container mt-4">
@ -43,7 +44,12 @@
<div class="mb-3"> <div class="mb-3">
{{ form.logo_url.label(class="form-label") }} {{ form.logo_url.label(class="form-label") }}
{{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }} <div class="input-group">
{{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }}
<button type="button" class="btn btn-outline-secondary" onclick="browseS3()" title="Browse S3 Storage">
<i class="fas fa-cloud"></i> Browse S3
</button>
</div>
{% if form.logo_url.errors %} {% if form.logo_url.errors %}
<div class="text-danger"> <div class="text-danger">
{% for error in form.logo_url.errors %} {% for error in form.logo_url.errors %}
@ -51,7 +57,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<small class="form-text text-muted">Enter the full URL to the club's logo image</small> <small class="form-text text-muted">Enter the full URL to the club's logo image or use the S3 browser to select from your configured storage.</small>
<!-- Logo Preview --> <!-- Logo Preview -->
<div id="logoPreview" class="mt-2" style="display: none;"> <div id="logoPreview" class="mt-2" style="display: none;">
@ -71,6 +77,32 @@
</div> </div>
</div> </div>
<!-- S3 Browser Modal -->
<div class="modal fade" id="s3BrowserModal" tabindex="-1" aria-labelledby="s3BrowserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="s3BrowserModalLabel">Browse S3 Storage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="s3BrowserContent">
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="selectS3FileBtn" onclick="selectS3File()" disabled>Select File</button>
</div>
</div>
</div>
</div>
<div class="mt-3"> <div class="mt-3">
<a href="/admin/clubs" class="btn btn-outline-secondary">Back to Club Management</a> <a href="/admin/clubs" class="btn btn-outline-secondary">Back to Club Management</a>
</div> </div>
@ -94,6 +126,205 @@
} }
} }
// S3 Browser functionality
let selectedS3File = null;
let currentS3Path = '';
function browseS3() {
// Reset state
selectedS3File = null;
currentS3Path = '';
// Show loading state
document.getElementById('s3BrowserContent').innerHTML = `
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
`;
// Show modal
const s3BrowserModal = new bootstrap.Modal(document.getElementById('s3BrowserModal'));
s3BrowserModal.show();
// Load S3 contents from root of assets folder
loadS3Contents('');
}
function loadS3Contents(path) {
fetch(`/admin/api/s3-browser?path=${encodeURIComponent(path)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
displayS3Contents(data);
} else {
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> ${data.message}
</div>
`;
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> Failed to load S3 contents. Please try again.
</div>
`;
});
}
function displayS3Contents(data) {
currentS3Path = data.path;
let html = `
<div class="row mb-3">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="#" onclick="loadS3Contents('')">assets</a>
</li>
`;
// Build breadcrumb
if (data.path !== '') {
const pathParts = data.path.split('/').filter(p => p);
let currentPath = '';
pathParts.forEach((part, index) => {
currentPath += part + '/';
const isLast = index === pathParts.length - 1;
html += `<li class="breadcrumb-item ${isLast ? 'active' : ''}">`;
if (!isLast) {
html += `<a href="#" onclick="loadS3Contents('${currentPath}')">${part}</a>`;
} else {
html += part;
}
html += '</li>';
});
}
html += `
</ol>
</nav>
</div>
</div>
<div class="row">
`;
// Display folders
if (data.folders.length > 0) {
data.folders.forEach(folder => {
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 folder-card" onclick="loadS3Contents('${folder.path}')" style="cursor: pointer;">
<div class="card-body text-center">
<i class="fas fa-folder fa-3x text-warning mb-2"></i>
<h6 class="card-title">${folder.name}</h6>
</div>
</div>
</div>
`;
});
}
// Display files
if (data.files.length > 0) {
data.files.forEach(file => {
const isImage = /\.(jpg|jpeg|png|gif|svg|webp)$/i.test(file.name);
const fileSize = formatFileSize(file.size);
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 file-card" onclick="selectS3FileItem('${file.path}', '${file.url}')" style="cursor: pointer;">
<div class="card-body text-center">
`;
if (isImage) {
html += `
<img src="${file.url}" alt="${file.name}" class="img-fluid mb-2" style="max-height: 100px; max-width: 100%; object-fit: contain;">
`;
} else {
html += `
<i class="fas fa-file fa-3x text-primary mb-2"></i>
`;
}
html += `
<h6 class="card-title">${file.name}</h6>
<small class="text-muted">${fileSize}</small>
</div>
</div>
</div>
`;
});
}
if (data.folders.length === 0 && data.files.length === 0) {
html += `
<div class="col-12">
<div class="alert alert-info text-center">
<i class="fas fa-info-circle"></i> No files or folders found in this directory.
</div>
</div>
`;
}
html += `
</div>
`;
document.getElementById('s3BrowserContent').innerHTML = html;
}
function selectS3FileItem(filePath, fileUrl) {
// Remove previous selection
document.querySelectorAll('.file-card').forEach(card => {
card.classList.remove('border-primary');
});
// Add selection to clicked card
event.currentTarget.classList.add('border-primary');
// Store selected file
selectedS3File = {
path: filePath,
url: fileUrl
};
// Enable select button
document.getElementById('selectS3FileBtn').disabled = false;
}
function selectS3File() {
if (selectedS3File) {
// Update the logo URL field
const logoUrlField = document.getElementById('logoUrl');
if (logoUrlField) {
logoUrlField.value = selectedS3File.path;
// Trigger preview update
previewLogo();
}
// Close modal
const s3BrowserModal = bootstrap.Modal.getInstance(document.getElementById('s3BrowserModal'));
s3BrowserModal.hide();
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Preview logo on page load if URL is already filled // Preview logo on page load if URL is already filled
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
previewLogo(); previewLogo();

View File

@ -153,6 +153,14 @@
</a> </a>
</div> </div>
</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="col-md-4">
<div class="list-group card-custom"> <div class="list-group card-custom">
<a href="/admin/poty" class="list-group-item"> <a href="/admin/poty" class="list-group-item">
@ -170,7 +178,7 @@
<h3>System Management</h3> <h3>System Management</h3>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-3">
<div class="list-group card-custom"> <div class="list-group card-custom">
<a href="/admin/database-setup" class="list-group-item"> <a href="/admin/database-setup" class="list-group-item">
<h4 class="list-group-item-heading">Database Setup</h4> <h4 class="list-group-item-heading">Database Setup</h4>
@ -178,7 +186,7 @@
</a> </a>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-3">
<div class="list-group card-custom"> <div class="list-group card-custom">
<a href="/admin/database-status" class="list-group-item"> <a href="/admin/database-status" class="list-group-item">
<h4 class="list-group-item-heading">Database Status</h4> <h4 class="list-group-item-heading">Database Status</h4>
@ -186,6 +194,22 @@
</a> </a>
</div> </div>
</div> </div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/s3-config" class="list-group-item">
<h4 class="list-group-item-heading">S3 Configuration</h4>
<p class="list-group-item-text">Configure S3/MinIO storage</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/s3-status" class="list-group-item">
<h4 class="list-group-item-heading">S3 Status</h4>
<p class="list-group-item-text">View S3/MinIO status</p>
</a>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -54,6 +54,32 @@
</div> </div>
</div> </div>
<!-- S3 Browser Modal -->
<div class="modal fade" id="s3BrowserModal" tabindex="-1" aria-labelledby="s3BrowserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="s3BrowserModalLabel">Browse S3 Storage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="s3BrowserContent">
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="selectS3FileBtn" onclick="selectS3File()" disabled>Select File</button>
</div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
@ -89,7 +115,7 @@
<td>{{ club.hockey_club }}</td> <td>{{ club.hockey_club }}</td>
<td> <td>
{% if club.logo_url %} {% if club.logo_url %}
<img src="{{ club.logo_url }}" alt="{{ club.hockey_club }} logo" style="max-height: 40px; max-width: 60px;" onerror="this.style.display='none'"> <img src="{{ s3_asset_service.get_logo_url(club.logo_url, club.hockey_club) }}" alt="{{ club.hockey_club }} logo" style="max-height: 40px; max-width: 60px;" onerror="this.style.display='none'">
{% else %} {% else %}
<span class="text-muted">No logo</span> <span class="text-muted">No logo</span>
{% endif %} {% endif %}
@ -143,6 +169,206 @@
}, 5000); }, 5000);
} }
// S3 Browser functionality
let selectedS3File = null;
let currentS3Path = '';
function browseS3() {
// Reset state
selectedS3File = null;
currentS3Path = '';
// Show loading state
document.getElementById('s3BrowserContent').innerHTML = `
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
`;
// Show modal
const s3BrowserModal = new bootstrap.Modal(document.getElementById('s3BrowserModal'));
s3BrowserModal.show();
// Load S3 contents from root of assets folder
loadS3Contents('');
}
function loadS3Contents(path) {
fetch(`/admin/api/s3-browser?path=${encodeURIComponent(path)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
displayS3Contents(data);
} else {
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> ${data.message}
</div>
`;
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> Failed to load S3 contents. Please try again.
</div>
`;
});
}
function displayS3Contents(data) {
currentS3Path = data.path;
let html = `
<div class="row mb-3">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="#" onclick="loadS3Contents('')">assets</a>
</li>
`;
// Build breadcrumb
if (data.path !== '') {
const pathParts = data.path.split('/').filter(p => p);
let currentPath = '';
pathParts.forEach((part, index) => {
currentPath += part + '/';
const isLast = index === pathParts.length - 1;
html += `<li class="breadcrumb-item ${isLast ? 'active' : ''}">`;
if (!isLast) {
html += `<a href="#" onclick="loadS3Contents('${currentPath}')">${part}</a>`;
} else {
html += part;
}
html += '</li>';
});
}
html += `
</ol>
</nav>
</div>
</div>
<div class="row">
`;
// Display folders
if (data.folders.length > 0) {
data.folders.forEach(folder => {
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 folder-card" onclick="loadS3Contents('${folder.path}')" style="cursor: pointer;">
<div class="card-body text-center">
<i class="fas fa-folder fa-3x text-warning mb-2"></i>
<h6 class="card-title">${folder.name}</h6>
</div>
</div>
</div>
`;
});
}
// Display files
if (data.files.length > 0) {
data.files.forEach(file => {
const isImage = /\.(jpg|jpeg|png|gif|svg|webp)$/i.test(file.name);
const fileSize = formatFileSize(file.size);
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 file-card" onclick="selectS3FileItem('${file.path}', '${file.url}')" style="cursor: pointer;">
<div class="card-body text-center">
`;
if (isImage) {
html += `
<img src="${file.url}" alt="${file.name}" class="img-fluid mb-2" style="max-height: 100px; max-width: 100%; object-fit: contain;">
`;
} else {
html += `
<i class="fas fa-file fa-3x text-primary mb-2"></i>
`;
}
html += `
<h6 class="card-title">${file.name}</h6>
<small class="text-muted">${fileSize}</small>
</div>
</div>
</div>
`;
});
}
if (data.folders.length === 0 && data.files.length === 0) {
html += `
<div class="col-12">
<div class="alert alert-info text-center">
<i class="fas fa-info-circle"></i> No files or folders found in this directory.
</div>
</div>
`;
}
html += `
</div>
`;
document.getElementById('s3BrowserContent').innerHTML = html;
}
function selectS3FileItem(filePath, fileUrl) {
// Remove previous selection
document.querySelectorAll('.file-card').forEach(card => {
card.classList.remove('border-primary');
});
// Add selection to clicked card
event.currentTarget.classList.add('border-primary');
// Store selected file
selectedS3File = {
path: filePath,
url: fileUrl
};
// Enable select button
document.getElementById('selectS3FileBtn').disabled = false;
}
function selectS3File() {
if (selectedS3File) {
// Update the logo URL field in the add club form
const logoUrlField = document.querySelector('input[name="logo_url"]');
if (logoUrlField) {
logoUrlField.value = selectedS3File.path;
}
// Close modal
const s3BrowserModal = bootstrap.Modal.getInstance(document.getElementById('s3BrowserModal'));
s3BrowserModal.hide();
// Show success message
showStatus('Logo selected successfully!', 'success');
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function previewClubs() { function previewClubs() {
const modal = new bootstrap.Modal(document.getElementById('previewModal')); const modal = new bootstrap.Modal(document.getElementById('previewModal'));
const content = document.getElementById('previewContent'); const content = document.getElementById('previewContent');

View 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>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Club - HKFC Men's C Team</title> <title>Edit Club - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head> </head>
<body> <body>
<div class="container mt-4"> <div class="container mt-4">
@ -43,7 +44,12 @@
<div class="mb-3"> <div class="mb-3">
{{ form.logo_url.label(class="form-label") }} {{ form.logo_url.label(class="form-label") }}
{{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }} <div class="input-group">
{{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }}
<button type="button" class="btn btn-outline-secondary" onclick="browseS3()" title="Browse S3 Storage">
<i class="fas fa-cloud"></i> Browse S3
</button>
</div>
{% if form.logo_url.errors %} {% if form.logo_url.errors %}
<div class="text-danger"> <div class="text-danger">
{% for error in form.logo_url.errors %} {% for error in form.logo_url.errors %}
@ -51,7 +57,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<small class="form-text text-muted">Enter the full URL to the club's logo image</small> <small class="form-text text-muted">Enter the full URL to the club's logo image or use the S3 browser to select from your configured storage.</small>
<!-- Logo Preview --> <!-- Logo Preview -->
<div id="logoPreview" class="mt-2" style="display: none;"> <div id="logoPreview" class="mt-2" style="display: none;">
@ -71,6 +77,32 @@
</div> </div>
</div> </div>
<!-- S3 Browser Modal -->
<div class="modal fade" id="s3BrowserModal" tabindex="-1" aria-labelledby="s3BrowserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="s3BrowserModalLabel">Browse S3 Storage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="s3BrowserContent">
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="selectS3FileBtn" onclick="selectS3File()" disabled>Select File</button>
</div>
</div>
</div>
</div>
<div class="mt-3"> <div class="mt-3">
<a href="/admin/clubs" class="btn btn-outline-secondary">Back to Club Management</a> <a href="/admin/clubs" class="btn btn-outline-secondary">Back to Club Management</a>
</div> </div>
@ -94,6 +126,205 @@
} }
} }
// S3 Browser functionality
let selectedS3File = null;
let currentS3Path = '';
function browseS3() {
// Reset state
selectedS3File = null;
currentS3Path = '';
// Show loading state
document.getElementById('s3BrowserContent').innerHTML = `
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
`;
// Show modal
const s3BrowserModal = new bootstrap.Modal(document.getElementById('s3BrowserModal'));
s3BrowserModal.show();
// Load S3 contents from root of assets folder
loadS3Contents('');
}
function loadS3Contents(path) {
fetch(`/admin/api/s3-browser?path=${encodeURIComponent(path)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
displayS3Contents(data);
} else {
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> ${data.message}
</div>
`;
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> Failed to load S3 contents. Please try again.
</div>
`;
});
}
function displayS3Contents(data) {
currentS3Path = data.path;
let html = `
<div class="row mb-3">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="#" onclick="loadS3Contents('')">assets</a>
</li>
`;
// Build breadcrumb
if (data.path !== '') {
const pathParts = data.path.split('/').filter(p => p);
let currentPath = '';
pathParts.forEach((part, index) => {
currentPath += part + '/';
const isLast = index === pathParts.length - 1;
html += `<li class="breadcrumb-item ${isLast ? 'active' : ''}">`;
if (!isLast) {
html += `<a href="#" onclick="loadS3Contents('${currentPath}')">${part}</a>`;
} else {
html += part;
}
html += '</li>';
});
}
html += `
</ol>
</nav>
</div>
</div>
<div class="row">
`;
// Display folders
if (data.folders.length > 0) {
data.folders.forEach(folder => {
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 folder-card" onclick="loadS3Contents('${folder.path}')" style="cursor: pointer;">
<div class="card-body text-center">
<i class="fas fa-folder fa-3x text-warning mb-2"></i>
<h6 class="card-title">${folder.name}</h6>
</div>
</div>
</div>
`;
});
}
// Display files
if (data.files.length > 0) {
data.files.forEach(file => {
const isImage = /\.(jpg|jpeg|png|gif|svg|webp)$/i.test(file.name);
const fileSize = formatFileSize(file.size);
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 file-card" onclick="selectS3FileItem('${file.path}', '${file.url}')" style="cursor: pointer;">
<div class="card-body text-center">
`;
if (isImage) {
html += `
<img src="${file.url}" alt="${file.name}" class="img-fluid mb-2" style="max-height: 100px; max-width: 100%; object-fit: contain;">
`;
} else {
html += `
<i class="fas fa-file fa-3x text-primary mb-2"></i>
`;
}
html += `
<h6 class="card-title">${file.name}</h6>
<small class="text-muted">${fileSize}</small>
</div>
</div>
</div>
`;
});
}
if (data.folders.length === 0 && data.files.length === 0) {
html += `
<div class="col-12">
<div class="alert alert-info text-center">
<i class="fas fa-info-circle"></i> No files or folders found in this directory.
</div>
</div>
`;
}
html += `
</div>
`;
document.getElementById('s3BrowserContent').innerHTML = html;
}
function selectS3FileItem(filePath, fileUrl) {
// Remove previous selection
document.querySelectorAll('.file-card').forEach(card => {
card.classList.remove('border-primary');
});
// Add selection to clicked card
event.currentTarget.classList.add('border-primary');
// Store selected file
selectedS3File = {
path: filePath,
url: fileUrl
};
// Enable select button
document.getElementById('selectS3FileBtn').disabled = false;
}
function selectS3File() {
if (selectedS3File) {
// Update the logo URL field
const logoUrlField = document.getElementById('logoUrl');
if (logoUrlField) {
logoUrlField.value = selectedS3File.path;
// Trigger preview update
previewLogo();
}
// Close modal
const s3BrowserModal = bootstrap.Modal.getInstance(document.getElementById('s3BrowserModal'));
s3BrowserModal.hide();
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Preview logo on page load if URL is already filled // Preview logo on page load if URL is already filled
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
previewLogo(); previewLogo();

View File

@ -28,9 +28,14 @@
</div> </div>
</div> </div>
{% if is_admin %}
<div class="col-md-6"> <div class="col-md-6">
<h3>Admin Section</h3> <h3>Admin Section</h3>
<div class="list-group"> <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"> <a href="/admin/players" class="list-group-item">
<h4 class="list-group-item-heading">Player Management</h4> <h4 class="list-group-item-heading">Player Management</h4>
<p class="list-group-item-text">Add, edit, and manage players in the database</p> <p class="list-group-item-text">Add, edit, and manage players in the database</p>
@ -83,8 +88,25 @@
<h4 class="list-group-item-heading">Database Status</h4> <h4 class="list-group-item-heading">Database Status</h4>
<p class="list-group-item-text">View current database configuration and status</p> <p class="list-group-item-text">View current database configuration and status</p>
</a> </a>
<a href="/admin/s3-config" class="list-group-item">
<h4 class="list-group-item-heading">S3 Configuration</h4>
<p class="list-group-item-text">Configure AWS S3 storage for logos and assets</p>
</a>
<a href="/admin/s3-status" class="list-group-item">
<h4 class="list-group-item-heading">S3 Status</h4>
<p class="list-group-item-text">View current S3 configuration and connection status</p>
</a>
</div> </div>
</div> </div>
{% else %}
<div class="col-md-6">
<h3>Admin Access</h3>
<div class="alert alert-info">
<h4 class="alert-heading">Authentication Required</h4>
<p>Admin functions require authentication. Please contact the system administrator for access.</p>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>S3 Configuration - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.config-section {
background-color: #f8f9fa;
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 1rem;
}
.help-text {
font-size: 0.875rem;
color: #6c757d;
}
</style>
</head>
<body>
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h1>S3 Configuration</h1>
<p class="lead">Configure AWS S3 storage for logos and assets</p>
<div class="mb-3">
<a href="/admin" class="btn btn-secondary">Back to Admin</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 %}
<form method="POST">
{{ form.hidden_tag() }}
<!-- Enable S3 Section -->
<div class="config-section">
<h4>Storage Configuration</h4>
<div class="form-check form-switch mb-3">
{{ form.enable_s3(class="form-check-input") }}
{{ form.enable_s3.label(class="form-check-label") }}
<div class="help-text">When enabled, logos and assets will be served from S3. When disabled, local static files will be used.</div>
</div>
<div class="form-check form-switch mb-3">
{{ form.fallback_to_static(class="form-check-input") }}
{{ form.fallback_to_static.label(class="form-check-label") }}
<div class="help-text">If S3 is unavailable, fallback to local static files.</div>
</div>
</div>
<!-- Storage Provider Section -->
<div class="config-section">
<h4>Storage Provider</h4>
<div class="mb-3">
{{ form.storage_provider.label(class="form-label") }}
{{ form.storage_provider(class="form-select") }}
<div class="help-text">Choose between AWS S3 or MinIO (self-hosted S3-compatible storage).</div>
</div>
</div>
<!-- Credentials Section -->
<div class="config-section">
<h4>Access Credentials</h4>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form.aws_access_key_id.label(class="form-label") }}
{{ form.aws_access_key_id(class="form-control", placeholder="AKIA...") }}
<div class="help-text">Your Access Key ID (AWS or MinIO)</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
{{ form.aws_secret_access_key.label(class="form-label") }}
{{ form.aws_secret_access_key(class="form-control", placeholder="Enter secret key") }}
<div class="help-text">Your Secret Access Key (AWS or MinIO)</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form.aws_region.label(class="form-label") }}
{{ form.aws_region(class="form-control") }}
<div class="help-text">Region (AWS) or leave default for MinIO</div>
</div>
</div>
</div>
</div>
<!-- MinIO Configuration Section -->
<div class="config-section" id="minio-config" style="display: none;">
<h4>MinIO Configuration</h4>
<div class="row">
<div class="col-md-8">
<div class="mb-3">
{{ form.minio_endpoint.label(class="form-label") }}
{{ form.minio_endpoint(class="form-control", placeholder="minio.example.com:9000") }}
<div class="help-text">MinIO server endpoint (hostname:port)</div>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<div class="form-check form-switch">
{{ form.minio_use_ssl(class="form-check-input") }}
{{ form.minio_use_ssl.label(class="form-check-label") }}
</div>
<div class="help-text">Enable SSL/TLS for MinIO connection</div>
</div>
</div>
</div>
</div>
<!-- S3 Bucket Configuration -->
<div class="config-section">
<h4>S3 Bucket Configuration</h4>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form.bucket_name.label(class="form-label") }}
{{ form.bucket_name(class="form-control", placeholder="my-motm-assets") }}
<div class="help-text">Name of your S3 bucket</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
{{ form.bucket_prefix.label(class="form-label") }}
{{ form.bucket_prefix(class="form-control") }}
<div class="help-text">Prefix for objects in the bucket (e.g., motm-assets/)</div>
</div>
</div>
</div>
</div>
<!-- URL Configuration -->
<div class="config-section">
<h4>URL Configuration</h4>
<div class="form-check form-switch mb-3">
{{ form.use_signed_urls(class="form-check-input") }}
{{ form.use_signed_urls.label(class="form-check-label") }}
<div class="help-text">Use signed URLs for secure access to private objects. If disabled, objects must be public.</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form.signed_url_expiry.label(class="form-label") }}
{{ form.signed_url_expiry(class="form-control") }}
<div class="help-text">How long signed URLs remain valid (in seconds)</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex gap-2">
{{ form.test_connection(class="btn btn-info") }}
{{ form.save_config(class="btn btn-primary") }}
{{ form.cancel(class="btn btn-secondary") }}
</div>
</form>
<!-- Current Configuration Display -->
<div class="config-section mt-4">
<h4>Current Configuration</h4>
<div class="row">
<div class="col-md-6">
<strong>S3 Status:</strong>
<span class="badge bg-{{ 'success' if current_config.get('enable_s3') else 'secondary' }}">
{{ 'Enabled' if current_config.get('enable_s3') else 'Disabled' }}
</span>
</div>
<div class="col-md-6">
<strong>Bucket:</strong> {{ current_config.get('bucket_name', 'Not configured') }}
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<strong>Region:</strong> {{ current_config.get('aws_region', 'Not configured') }}
</div>
<div class="col-md-6">
<strong>Signed URLs:</strong>
<span class="badge bg-{{ 'success' if current_config.get('use_signed_urls') else 'secondary' }}">
{{ 'Enabled' if current_config.get('use_signed_urls') else 'Disabled' }}
</span>
</div>
</div>
</div>
<!-- Help Section -->
<div class="config-section mt-4">
<h4>Setup Instructions</h4>
<ol>
<li><strong>Create S3 Bucket:</strong> Create an S3 bucket in your AWS account</li>
<li><strong>Set Permissions:</strong> Configure bucket permissions for your access key</li>
<li><strong>Upload Assets:</strong> Upload your logos and assets to the bucket</li>
<li><strong>Configure Here:</strong> Enter your credentials and bucket details above</li>
<li><strong>Test Connection:</strong> Use the "Test S3 Connection" button to verify</li>
<li><strong>Save Configuration:</strong> Save your settings to enable S3 storage</li>
</ol>
<div class="alert alert-info">
<h6>Asset Organization</h6>
<p>Organize your assets in the S3 bucket as follows:</p>
<ul class="mb-0">
<li><code>assets/images/clubs/</code> - Club logos</li>
<li><code>assets/images/</code> - General images</li>
<li><code>assets/logos/</code> - Alternative logo location</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Show/hide MinIO configuration based on storage provider selection
function toggleMinioConfig() {
const storageProvider = document.getElementById('storage_provider');
const minioConfig = document.getElementById('minio-config');
if (storageProvider.value === 'minio') {
minioConfig.style.display = 'block';
} else {
minioConfig.style.display = 'none';
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
toggleMinioConfig();
// Add event listener for storage provider changes
const storageProviderSelect = document.getElementById('storage_provider');
if (storageProviderSelect) {
storageProviderSelect.addEventListener('change', toggleMinioConfig);
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>S3 Status - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h1>S3 Storage Status</h1>
<p class="lead">Current S3 configuration and connection status</p>
<div class="mb-3">
<a href="/admin/s3-config" class="btn btn-primary">Configure S3</a>
<a href="/admin" class="btn btn-secondary">Back to Admin</a>
</div>
<div class="card">
<div class="card-header">
<h5>Configuration Status</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td><strong>S3 Storage:</strong></td>
<td>
<span class="badge bg-{{ 'success' if s3_info.enabled else 'secondary' }}">
{{ 'Enabled' if s3_info.enabled else 'Disabled' }}
</span>
</td>
</tr>
<tr>
<td><strong>Provider:</strong></td>
<td>{{ s3_info.storage_provider.title() if s3_info.get('storage_provider') else 'AWS S3' }}</td>
</tr>
<tr>
<td><strong>Bucket Name:</strong></td>
<td>{{ s3_info.bucket_name }}</td>
</tr>
<tr>
<td><strong>Region:</strong></td>
<td>{{ s3_info.aws_region }}</td>
</tr>
{% if s3_info.get('storage_provider') == 'minio' %}
<tr>
<td><strong>MinIO Endpoint:</strong></td>
<td>{{ s3_info.get('minio_endpoint', 'Not configured') }}</td>
</tr>
{% endif %}
</table>
</div>
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td><strong>Signed URLs:</strong></td>
<td>
<span class="badge bg-{{ 'success' if s3_info.use_signed_urls else 'secondary' }}">
{{ 'Enabled' if s3_info.use_signed_urls else 'Disabled' }}
</span>
</td>
</tr>
<tr>
<td><strong>Fallback:</strong></td>
<td>
<span class="badge bg-{{ 'success' if s3_info.fallback_enabled else 'warning' }}">
{{ 'Enabled' if s3_info.fallback_enabled else 'Disabled' }}
</span>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h5>Connection Status</h5>
</div>
<div class="card-body">
<div class="alert alert-{{ 'success' if s3_info.connection_success else 'danger' }}" role="alert">
<h6 class="alert-heading">
{{ '✅ Connection Successful' if s3_info.connection_success else '❌ Connection Failed' }}
</h6>
<p class="mb-0">{{ s3_info.connection_status }}</p>
</div>
</div>
</div>
{% if s3_info.enabled and s3_info.connection_success %}
<div class="card mt-4">
<div class="card-header">
<h5>Asset Management</h5>
</div>
<div class="card-body">
<p>S3 storage is properly configured and connected. Assets will be served from S3.</p>
<div class="alert alert-info">
<h6>Asset Locations</h6>
<ul class="mb-0">
<li><strong>Club Logos:</strong> <code>{{ s3_info.bucket_name }}/assets/images/clubs/</code></li>
<li><strong>General Images:</strong> <code>{{ s3_info.bucket_name }}/assets/images/</code></li>
<li><strong>Player Images:</strong> <code>{{ s3_info.bucket_name }}/assets/images/players/</code></li>
</ul>
</div>
</div>
</div>
{% elif s3_info.enabled and not s3_info.connection_success %}
<div class="card mt-4">
<div class="card-header">
<h5>Troubleshooting</h5>
</div>
<div class="card-body">
<div class="alert alert-warning">
<h6>S3 is enabled but connection failed</h6>
<p>Check the following:</p>
<ul>
<li>Verify AWS credentials are correct</li>
<li>Ensure the bucket name exists</li>
<li>Check bucket permissions</li>
<li>Verify AWS region is correct</li>
</ul>
</div>
</div>
</div>
{% else %}
<div class="card mt-4">
<div class="card-header">
<h5>Static File Storage</h5>
</div>
<div class="card-body">
<p>S3 storage is disabled. Assets are being served from local static files.</p>
<div class="alert alert-info">
<h6>Local Asset Locations</h6>
<ul class="mb-0">
<li><strong>Club Logos:</strong> <code>/static/images/clubs/</code></li>
<li><strong>General Images:</strong> <code>/static/images/</code></li>
<li><strong>Player Images:</strong> <code>/static/images/players/</code></li>
</ul>
</div>
</div>
</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>