diff --git a/motm_app/.dockerignore b/motm_app/.dockerignore new file mode 100644 index 0000000..f7b22f8 --- /dev/null +++ b/motm_app/.dockerignore @@ -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 \ No newline at end of file diff --git a/motm_app/CONTAINER_DEPLOYMENT.md b/motm_app/CONTAINER_DEPLOYMENT.md new file mode 100644 index 0000000..34873de --- /dev/null +++ b/motm_app/CONTAINER_DEPLOYMENT.md @@ -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` + + diff --git a/motm_app/Containerfile b/motm_app/Containerfile index 0f173a8..96dbb01 100644 --- a/motm_app/Containerfile +++ b/motm_app/Containerfile @@ -34,9 +34,11 @@ FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PATH="/opt/venv/bin:$PATH" \ - DATABASE_TYPE=sqlite \ + DATABASE_TYPE=postgresql \ 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 RUN apt-get update && apt-get install -y \ @@ -61,6 +63,24 @@ COPY --chown=appuser:appuser . . RUN mkdir -p /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 USER appuser @@ -72,4 +92,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl -f http://localhost:5000/ || exit 1 # Default command -CMD ["python", "main.py"] +CMD ["/app/start.sh"] diff --git a/motm_app/docker-compose.yml b/motm_app/docker-compose.yml new file mode 100644 index 0000000..d223aeb --- /dev/null +++ b/motm_app/docker-compose.yml @@ -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 + + diff --git a/motm_app/forms.py b/motm_app/forms.py index 7349032..cb8bb10 100644 --- a/motm_app/forms.py +++ b/motm_app/forms.py @@ -148,3 +148,39 @@ class ClubSelectionForm(FlaskForm): select_all = SubmitField('Select All') select_none = SubmitField('Select None') 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') diff --git a/motm_app/init.sql b/motm_app/init.sql new file mode 100644 index 0000000..73cfb3a --- /dev/null +++ b/motm_app/init.sql @@ -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 + + diff --git a/motm_app/main.py b/motm_app/main.py index 0309a1f..4bc82cf 100644 --- a/motm_app/main.py +++ b/motm_app/main.py @@ -27,13 +27,14 @@ from flask_basicauth import BasicAuth from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms import DateField 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 sqlalchemy import text from tables import matchSquadTable 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 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 class DatabaseBasicAuth(BasicAuth): @@ -66,7 +67,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 +130,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/') @@ -144,18 +195,24 @@ def motm_vote(randomUrlSuffix): nextClub = nextInfo[0]['nextclub'] nextTeam = nextInfo[0]['nextteam'] nextFixture = nextInfo[0]['nextfixture'] - hkfcLogo = nextInfo[0]['hkfclogo'] - oppoLogo = nextInfo[0]['oppologo'] - currMotM = nextInfo[0]['currmotm'] - currDotD = nextInfo[0]['currdotd'] - oppo = nextTeam + # 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 + # 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: 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}) 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 if nextInfo and 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.") # Use default player images since playerPictureURL column doesn't exist - motmURL = '/static/images/default_player.png' - dotdURL = '/static/images/default_player.png' + motmURL = s3_asset_service.get_asset_url('images/default_player.png') + dotdURL = s3_asset_service.get_asset_url('images/default_player.png') # Get match comments 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']) 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'] + + # 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': _comment = request.form['matchComment'] if _comment != 'Optional comments added here': @@ -282,6 +352,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 +427,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 +437,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, @@ -539,7 +628,7 @@ def club_management(): """Admin page for managing clubs""" sql = text("SELECT id, hockey_club, logo_url FROM clubs ORDER BY hockey_club") 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']) @@ -993,6 +1082,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(): @@ -1682,5 +1839,201 @@ def database_status(): 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__": app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/motm_app/requirements.txt b/motm_app/requirements.txt index 9ddf81f..2a7018c 100644 --- a/motm_app/requirements.txt +++ b/motm_app/requirements.txt @@ -24,5 +24,8 @@ pymysql>=1.1.0 psycopg2-binary>=2.9.0 PyMySQL>=1.1.0 +# AWS S3 support +boto3>=1.34.0 + # Legacy support (can be removed after migration) flask-mysql diff --git a/motm_app/s3_config.json b/motm_app/s3_config.json new file mode 100644 index 0000000..704855e --- /dev/null +++ b/motm_app/s3_config.json @@ -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 +} \ No newline at end of file diff --git a/motm_app/s3_config.json.orig b/motm_app/s3_config.json.orig new file mode 100644 index 0000000..eafd819 --- /dev/null +++ b/motm_app/s3_config.json.orig @@ -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 +} \ No newline at end of file diff --git a/motm_app/s3_config.py b/motm_app/s3_config.py new file mode 100644 index 0000000..c5b1ca1 --- /dev/null +++ b/motm_app/s3_config.py @@ -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) diff --git a/motm_app/templates/add_club.html b/motm_app/templates/add_club.html index b999b3f..d7e189c 100644 --- a/motm_app/templates/add_club.html +++ b/motm_app/templates/add_club.html @@ -5,6 +5,7 @@ Add Club - HKFC Men's C Team +
@@ -43,7 +44,12 @@
{{ form.logo_url.label(class="form-label") }} - {{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }} +
+ {{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }} + +
{% if form.logo_url.errors %}
{% for error in form.logo_url.errors %} @@ -51,7 +57,7 @@ {% endfor %}
{% endif %} - Enter the full URL to the club's logo image + Enter the full URL to the club's logo image or use the S3 browser to select from your configured storage.
+ + + @@ -93,6 +125,205 @@ previewDiv.style.display = 'none'; } } + + // S3 Browser functionality + let selectedS3File = null; + let currentS3Path = ''; + + function browseS3() { + // Reset state + selectedS3File = null; + currentS3Path = ''; + + // Show loading state + document.getElementById('s3BrowserContent').innerHTML = ` +
+
+ Loading... +
+

Loading S3 contents...

+
+ `; + + // 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 = ` + + `; + } + }) + .catch(error => { + console.error('Error:', error); + document.getElementById('s3BrowserContent').innerHTML = ` + + `; + }); + } + + function displayS3Contents(data) { + currentS3Path = data.path; + + let html = ` +
+
+ +
+
+
+ `; + + // Display folders + if (data.folders.length > 0) { + data.folders.forEach(folder => { + html += ` +
+
+
+ +
${folder.name}
+
+
+
+ `; + }); + } + + // 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 += ` +
+
+
+ `; + + if (isImage) { + html += ` + ${file.name} + `; + } else { + html += ` + + `; + } + + html += ` +
${file.name}
+ ${fileSize} +
+
+
+ `; + }); + } + + if (data.folders.length === 0 && data.files.length === 0) { + html += ` +
+
+ No files or folders found in this directory. +
+
+ `; + } + + html += ` +
+ `; + + 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 document.addEventListener('DOMContentLoaded', function() { diff --git a/motm_app/templates/admin_dashboard.html b/motm_app/templates/admin_dashboard.html index c16cb20..beb1a80 100644 --- a/motm_app/templates/admin_dashboard.html +++ b/motm_app/templates/admin_dashboard.html @@ -153,6 +153,14 @@
+
+ +
diff --git a/motm_app/templates/club_management.html b/motm_app/templates/club_management.html index 4d831c7..8e35938 100644 --- a/motm_app/templates/club_management.html +++ b/motm_app/templates/club_management.html @@ -54,6 +54,32 @@
+ + + {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} @@ -89,7 +115,7 @@ {{ club.hockey_club }} {% if club.logo_url %} - {{ club.hockey_club }} logo + {{ club.hockey_club }} logo {% else %} No logo {% endif %} @@ -142,6 +168,206 @@ statusDiv.classList.add('d-none'); }, 5000); } + + // S3 Browser functionality + let selectedS3File = null; + let currentS3Path = ''; + + function browseS3() { + // Reset state + selectedS3File = null; + currentS3Path = ''; + + // Show loading state + document.getElementById('s3BrowserContent').innerHTML = ` +
+
+ Loading... +
+

Loading S3 contents...

+
+ `; + + // 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 = ` + + `; + } + }) + .catch(error => { + console.error('Error:', error); + document.getElementById('s3BrowserContent').innerHTML = ` + + `; + }); + } + + function displayS3Contents(data) { + currentS3Path = data.path; + + let html = ` +
+
+ +
+
+
+ `; + + // Display folders + if (data.folders.length > 0) { + data.folders.forEach(folder => { + html += ` +
+
+
+ +
${folder.name}
+
+
+
+ `; + }); + } + + // 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 += ` +
+
+
+ `; + + if (isImage) { + html += ` + ${file.name} + `; + } else { + html += ` + + `; + } + + html += ` +
${file.name}
+ ${fileSize} +
+
+
+ `; + }); + } + + if (data.folders.length === 0 && data.files.length === 0) { + html += ` +
+
+ No files or folders found in this directory. +
+
+ `; + } + + html += ` +
+ `; + + 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() { const modal = new bootstrap.Modal(document.getElementById('previewModal')); diff --git a/motm_app/templates/device_tracking.html b/motm_app/templates/device_tracking.html new file mode 100644 index 0000000..aac6633 --- /dev/null +++ b/motm_app/templates/device_tracking.html @@ -0,0 +1,230 @@ + + + + + + Device Tracking - HKFC Men's C Team MOTM System + + + + +
+
+
+

Device Tracking Analysis

+

Monitor voting patterns and detect potential duplicate voting

+ + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + +
+

Analysis Controls

+
+
+
+ + +
+ Find devices with multiple votes +
+
+ View Recent Votes + Show last 50 votes +
+
+
+ + + {% if analysis_mode and patterns %} +
+

Voting Pattern Analysis

+

Devices that have voted multiple times:

+ + {% if patterns %} +
+ + + + + + + + + + + + + + + + {% for pattern in patterns %} + + + + + + + + + + + + {% endfor %} + +
Device IDVote CountFixturesMOTM PlayersDotD PlayersFirst VoteLast VoteIP AddressesActions
{{ pattern.device_id }} + + {{ pattern.vote_count }} + + {{ pattern.fixtures_voted }}{{ pattern.motm_players }}{{ pattern.dotd_players }}{{ pattern.first_vote.strftime('%Y-%m-%d %H:%M') if pattern.first_vote else 'N/A' }}{{ pattern.last_vote.strftime('%Y-%m-%d %H:%M') if pattern.last_vote else 'N/A' }}{{ pattern.ip_addresses }} +
+ + + +
+
+
+ + {% if patterns|length > 0 %} +
+
Pattern Analysis
+

+ Warning: {{ patterns|length }} device(s) have voted multiple times. + This could indicate duplicate voting or shared devices. +

+
+ {% endif %} + {% else %} +
+
No Suspicious Patterns Found
+

All devices have voted only once per fixture.

+
+ {% endif %} +
+ {% endif %} + + + {% if details_mode and device_details %} +
+

Device Details: {{ selected_device }}

+

Complete voting history for this device:

+ +
+ + + + + + + + + + + + {% for vote in device_details %} + + + + + + + + {% endfor %} + +
Fixture DateMOTM VoteDotD VoteIP AddressVote Time
{{ vote.fixture_date }}{{ vote.motm_player_name }}{{ vote.dotd_player_name }}{{ vote.ip_address }}{{ vote.vote_timestamp.strftime('%Y-%m-%d %H:%M:%S') if vote.vote_timestamp else 'N/A' }}
+
+ +
+
Device Information
+ {% if device_details %} +

User Agent: {{ device_details[0].user_agent[:100] }}{% if device_details[0].user_agent|length > 100 %}...{% endif %}

+

Total Votes: {{ device_details|length }}

+

Unique Fixtures: {{ device_details|map(attribute='fixture_date')|unique|list|length }}

+ {% endif %} +
+
+ {% endif %} + + + {% if recent_votes and not analysis_mode and not details_mode %} +
+

Recent Votes

+

Last 50 votes cast:

+ +
+ + + + + + + + + + + + + {% for vote in recent_votes %} + + + + + + + + + {% endfor %} + +
Device IDFixture DateMOTM VoteDotD VoteIP AddressVote Time
{{ vote.device_id }}{{ vote.fixture_date }}{{ vote.motm_player_name }}{{ vote.dotd_player_name }}{{ vote.ip_address }}{{ vote.vote_timestamp.strftime('%Y-%m-%d %H:%M') if vote.vote_timestamp else 'N/A' }}
+
+
+ {% endif %} + + + {% if not recent_votes and not patterns and not device_details %} +
+
No Vote Data Available
+

No votes have been cast yet, or the device tracking table is empty.

+
+ {% endif %} +
+
+
+ + + + diff --git a/motm_app/templates/edit_club.html b/motm_app/templates/edit_club.html index 3ce069c..dd710b7 100644 --- a/motm_app/templates/edit_club.html +++ b/motm_app/templates/edit_club.html @@ -5,6 +5,7 @@ Edit Club - HKFC Men's C Team +
@@ -43,7 +44,12 @@
{{ form.logo_url.label(class="form-label") }} - {{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }} +
+ {{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }} + +
{% if form.logo_url.errors %}
{% for error in form.logo_url.errors %} @@ -51,7 +57,7 @@ {% endfor %}
{% endif %} - Enter the full URL to the club's logo image + Enter the full URL to the club's logo image or use the S3 browser to select from your configured storage.
+ + + @@ -93,6 +125,205 @@ previewDiv.style.display = 'none'; } } + + // S3 Browser functionality + let selectedS3File = null; + let currentS3Path = ''; + + function browseS3() { + // Reset state + selectedS3File = null; + currentS3Path = ''; + + // Show loading state + document.getElementById('s3BrowserContent').innerHTML = ` +
+
+ Loading... +
+

Loading S3 contents...

+
+ `; + + // 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 = ` + + `; + } + }) + .catch(error => { + console.error('Error:', error); + document.getElementById('s3BrowserContent').innerHTML = ` + + `; + }); + } + + function displayS3Contents(data) { + currentS3Path = data.path; + + let html = ` +
+
+ +
+
+
+ `; + + // Display folders + if (data.folders.length > 0) { + data.folders.forEach(folder => { + html += ` +
+
+
+ +
${folder.name}
+
+
+
+ `; + }); + } + + // 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 += ` +
+
+
+ `; + + if (isImage) { + html += ` + ${file.name} + `; + } else { + html += ` + + `; + } + + html += ` +
${file.name}
+ ${fileSize} +
+
+
+ `; + }); + } + + if (data.folders.length === 0 && data.files.length === 0) { + html += ` +
+
+ No files or folders found in this directory. +
+
+ `; + } + + html += ` +
+ `; + + 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 document.addEventListener('DOMContentLoaded', function() { diff --git a/motm_app/templates/index.html b/motm_app/templates/index.html index bfa6b90..3c51b7b 100644 --- a/motm_app/templates/index.html +++ b/motm_app/templates/index.html @@ -28,9 +28,14 @@
+ {% if is_admin %}

Admin Section

+ {% else %} +
+

Admin Access

+
+

Authentication Required

+

Admin functions require authentication. Please contact the system administrator for access.

+
+
+ {% endif %} diff --git a/motm_app/templates/s3_config.html b/motm_app/templates/s3_config.html new file mode 100644 index 0000000..e111b20 --- /dev/null +++ b/motm_app/templates/s3_config.html @@ -0,0 +1,255 @@ + + + + + + S3 Configuration - HKFC Men's C Team + + + + +
+
+
+

S3 Configuration

+

Configure AWS S3 storage for logos and assets

+ + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+ {{ form.hidden_tag() }} + + +
+

Storage Configuration

+
+ {{ form.enable_s3(class="form-check-input") }} + {{ form.enable_s3.label(class="form-check-label") }} +
When enabled, logos and assets will be served from S3. When disabled, local static files will be used.
+
+ +
+ {{ form.fallback_to_static(class="form-check-input") }} + {{ form.fallback_to_static.label(class="form-check-label") }} +
If S3 is unavailable, fallback to local static files.
+
+
+ + +
+

Storage Provider

+
+ {{ form.storage_provider.label(class="form-label") }} + {{ form.storage_provider(class="form-select") }} +
Choose between AWS S3 or MinIO (self-hosted S3-compatible storage).
+
+
+ + +
+

Access Credentials

+
+
+
+ {{ form.aws_access_key_id.label(class="form-label") }} + {{ form.aws_access_key_id(class="form-control", placeholder="AKIA...") }} +
Your Access Key ID (AWS or MinIO)
+
+
+
+
+ {{ form.aws_secret_access_key.label(class="form-label") }} + {{ form.aws_secret_access_key(class="form-control", placeholder="Enter secret key") }} +
Your Secret Access Key (AWS or MinIO)
+
+
+
+ +
+
+
+ {{ form.aws_region.label(class="form-label") }} + {{ form.aws_region(class="form-control") }} +
Region (AWS) or leave default for MinIO
+
+
+
+
+ + + + + +
+

S3 Bucket Configuration

+
+
+
+ {{ form.bucket_name.label(class="form-label") }} + {{ form.bucket_name(class="form-control", placeholder="my-motm-assets") }} +
Name of your S3 bucket
+
+
+
+
+ {{ form.bucket_prefix.label(class="form-label") }} + {{ form.bucket_prefix(class="form-control") }} +
Prefix for objects in the bucket (e.g., motm-assets/)
+
+
+
+
+ + +
+

URL Configuration

+
+ {{ form.use_signed_urls(class="form-check-input") }} + {{ form.use_signed_urls.label(class="form-check-label") }} +
Use signed URLs for secure access to private objects. If disabled, objects must be public.
+
+ +
+
+
+ {{ form.signed_url_expiry.label(class="form-label") }} + {{ form.signed_url_expiry(class="form-control") }} +
How long signed URLs remain valid (in seconds)
+
+
+
+
+ + +
+ {{ form.test_connection(class="btn btn-info") }} + {{ form.save_config(class="btn btn-primary") }} + {{ form.cancel(class="btn btn-secondary") }} +
+
+ + +
+

Current Configuration

+
+
+ S3 Status: + + {{ 'Enabled' if current_config.get('enable_s3') else 'Disabled' }} + +
+
+ Bucket: {{ current_config.get('bucket_name', 'Not configured') }} +
+
+
+
+ Region: {{ current_config.get('aws_region', 'Not configured') }} +
+
+ Signed URLs: + + {{ 'Enabled' if current_config.get('use_signed_urls') else 'Disabled' }} + +
+
+
+ + +
+

Setup Instructions

+
    +
  1. Create S3 Bucket: Create an S3 bucket in your AWS account
  2. +
  3. Set Permissions: Configure bucket permissions for your access key
  4. +
  5. Upload Assets: Upload your logos and assets to the bucket
  6. +
  7. Configure Here: Enter your credentials and bucket details above
  8. +
  9. Test Connection: Use the "Test S3 Connection" button to verify
  10. +
  11. Save Configuration: Save your settings to enable S3 storage
  12. +
+ +
+
Asset Organization
+

Organize your assets in the S3 bucket as follows:

+
    +
  • assets/images/clubs/ - Club logos
  • +
  • assets/images/ - General images
  • +
  • assets/logos/ - Alternative logo location
  • +
+
+
+
+
+
+ + + + + + diff --git a/motm_app/templates/s3_status.html b/motm_app/templates/s3_status.html new file mode 100644 index 0000000..6c360b5 --- /dev/null +++ b/motm_app/templates/s3_status.html @@ -0,0 +1,154 @@ + + + + + + S3 Status - HKFC Men's C Team + + + +
+
+
+

S3 Storage Status

+

Current S3 configuration and connection status

+ + + +
+
+
Configuration Status
+
+
+
+
+ + + + + + + + + + + + + + + + + + {% if s3_info.get('storage_provider') == 'minio' %} + + + + + {% endif %} +
S3 Storage: + + {{ 'Enabled' if s3_info.enabled else 'Disabled' }} + +
Provider:{{ s3_info.storage_provider.title() if s3_info.get('storage_provider') else 'AWS S3' }}
Bucket Name:{{ s3_info.bucket_name }}
Region:{{ s3_info.aws_region }}
MinIO Endpoint:{{ s3_info.get('minio_endpoint', 'Not configured') }}
+
+
+ + + + + + + + + +
Signed URLs: + + {{ 'Enabled' if s3_info.use_signed_urls else 'Disabled' }} + +
Fallback: + + {{ 'Enabled' if s3_info.fallback_enabled else 'Disabled' }} + +
+
+
+
+
+ +
+
+
Connection Status
+
+
+ +
+
+ + {% if s3_info.enabled and s3_info.connection_success %} +
+
+
Asset Management
+
+
+

S3 storage is properly configured and connected. Assets will be served from S3.

+
+
Asset Locations
+
    +
  • Club Logos: {{ s3_info.bucket_name }}/assets/images/clubs/
  • +
  • General Images: {{ s3_info.bucket_name }}/assets/images/
  • +
  • Player Images: {{ s3_info.bucket_name }}/assets/images/players/
  • +
+
+
+
+ {% elif s3_info.enabled and not s3_info.connection_success %} +
+
+
Troubleshooting
+
+
+
+
S3 is enabled but connection failed
+

Check the following:

+
    +
  • Verify AWS credentials are correct
  • +
  • Ensure the bucket name exists
  • +
  • Check bucket permissions
  • +
  • Verify AWS region is correct
  • +
+
+
+
+ {% else %} +
+
+
Static File Storage
+
+
+

S3 storage is disabled. Assets are being served from local static files.

+
+
Local Asset Locations
+
    +
  • Club Logos: /static/images/clubs/
  • +
  • General Images: /static/images/
  • +
  • Player Images: /static/images/players/
  • +
+
+
+
+ {% endif %} +
+
+
+ + + +