identifier #2
80
motm_app/.dockerignore
Normal file
80
motm_app/.dockerignore
Normal 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
|
||||||
190
motm_app/CONTAINER_DEPLOYMENT.md
Normal file
190
motm_app/CONTAINER_DEPLOYMENT.md
Normal 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`
|
||||||
|
|
||||||
|
|
||||||
@ -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"]
|
||||||
|
|||||||
71
motm_app/docker-compose.yml
Normal file
71
motm_app/docker-compose.yml
Normal 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
|
||||||
|
|
||||||
|
|
||||||
@ -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
13
motm_app/init.sql
Normal 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
|
||||||
|
|
||||||
|
|
||||||
389
motm_app/main.py
389
motm_app/main.py
@ -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)
|
||||||
|
|||||||
@ -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
14
motm_app/s3_config.json
Normal 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
|
||||||
|
}
|
||||||
14
motm_app/s3_config.json.orig
Normal file
14
motm_app/s3_config.json.orig
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"enable_s3": true,
|
||||||
|
"storage_provider": "minio",
|
||||||
|
"aws_access_key_id": "AKIARLJ7D6ZPRRLQHWD7",
|
||||||
|
"aws_secret_access_key": "Ih8C5I8z7Or/+JGMzT0Pqjuqm7ig9Qells8qsd8q",
|
||||||
|
"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
390
motm_app/s3_config.py
Normal 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)
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
230
motm_app/templates/device_tracking.html
Normal file
230
motm_app/templates/device_tracking.html
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Device Tracking - HKFC Men's C Team MOTM System</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.tracking-section {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.pattern-warning {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.device-id {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h1>Device Tracking Analysis</h1>
|
||||||
|
<p class="lead">Monitor voting patterns and detect potential duplicate voting</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="/admin" class="btn btn-outline-primary">Back to Admin Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Analysis Controls -->
|
||||||
|
<div class="tracking-section">
|
||||||
|
<h3>Analysis Controls</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="analyze_patterns">
|
||||||
|
<button type="submit" class="btn btn-primary">Analyze Voting Patterns</button>
|
||||||
|
</form>
|
||||||
|
<small class="text-muted">Find devices with multiple votes</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<a href="/admin/device-tracking" class="btn btn-outline-secondary">View Recent Votes</a>
|
||||||
|
<small class="text-muted">Show last 50 votes</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pattern Analysis Results -->
|
||||||
|
{% if analysis_mode and patterns %}
|
||||||
|
<div class="tracking-section">
|
||||||
|
<h3>Voting Pattern Analysis</h3>
|
||||||
|
<p class="text-muted">Devices that have voted multiple times:</p>
|
||||||
|
|
||||||
|
{% if patterns %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Device ID</th>
|
||||||
|
<th>Vote Count</th>
|
||||||
|
<th>Fixtures</th>
|
||||||
|
<th>MOTM Players</th>
|
||||||
|
<th>DotD Players</th>
|
||||||
|
<th>First Vote</th>
|
||||||
|
<th>Last Vote</th>
|
||||||
|
<th>IP Addresses</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pattern in patterns %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="device-id">{{ pattern.device_id }}</span></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'danger' if pattern.vote_count > 3 else 'warning' }}">
|
||||||
|
{{ pattern.vote_count }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ pattern.fixtures_voted }}</td>
|
||||||
|
<td>{{ pattern.motm_players }}</td>
|
||||||
|
<td>{{ pattern.dotd_players }}</td>
|
||||||
|
<td>{{ pattern.first_vote.strftime('%Y-%m-%d %H:%M') if pattern.first_vote else 'N/A' }}</td>
|
||||||
|
<td>{{ pattern.last_vote.strftime('%Y-%m-%d %H:%M') if pattern.last_vote else 'N/A' }}</td>
|
||||||
|
<td>{{ pattern.ip_addresses }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" style="display: inline;">
|
||||||
|
<input type="hidden" name="action" value="view_device_details">
|
||||||
|
<input type="hidden" name="device_id" value="{{ pattern.device_id }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-info">Details</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if patterns|length > 0 %}
|
||||||
|
<div class="pattern-warning">
|
||||||
|
<h5><i class="bi bi-exclamation-triangle"></i> Pattern Analysis</h5>
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>Warning:</strong> {{ patterns|length }} device(s) have voted multiple times.
|
||||||
|
This could indicate duplicate voting or shared devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h5>No Suspicious Patterns Found</h5>
|
||||||
|
<p>All devices have voted only once per fixture.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Device Details -->
|
||||||
|
{% if details_mode and device_details %}
|
||||||
|
<div class="tracking-section">
|
||||||
|
<h3>Device Details: <span class="device-id">{{ selected_device }}</span></h3>
|
||||||
|
<p class="text-muted">Complete voting history for this device:</p>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Fixture Date</th>
|
||||||
|
<th>MOTM Vote</th>
|
||||||
|
<th>DotD Vote</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Vote Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for vote in device_details %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ vote.fixture_date }}</td>
|
||||||
|
<td>{{ vote.motm_player_name }}</td>
|
||||||
|
<td>{{ vote.dotd_player_name }}</td>
|
||||||
|
<td>{{ vote.ip_address }}</td>
|
||||||
|
<td>{{ vote.vote_timestamp.strftime('%Y-%m-%d %H:%M:%S') if vote.vote_timestamp else 'N/A' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<h5>Device Information</h5>
|
||||||
|
{% if device_details %}
|
||||||
|
<p><strong>User Agent:</strong> {{ device_details[0].user_agent[:100] }}{% if device_details[0].user_agent|length > 100 %}...{% endif %}</p>
|
||||||
|
<p><strong>Total Votes:</strong> {{ device_details|length }}</p>
|
||||||
|
<p><strong>Unique Fixtures:</strong> {{ device_details|map(attribute='fixture_date')|unique|list|length }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Recent Votes -->
|
||||||
|
{% if recent_votes and not analysis_mode and not details_mode %}
|
||||||
|
<div class="tracking-section">
|
||||||
|
<h3>Recent Votes</h3>
|
||||||
|
<p class="text-muted">Last 50 votes cast:</p>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Device ID</th>
|
||||||
|
<th>Fixture Date</th>
|
||||||
|
<th>MOTM Vote</th>
|
||||||
|
<th>DotD Vote</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Vote Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for vote in recent_votes %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="device-id">{{ vote.device_id }}</span></td>
|
||||||
|
<td>{{ vote.fixture_date }}</td>
|
||||||
|
<td>{{ vote.motm_player_name }}</td>
|
||||||
|
<td>{{ vote.dotd_player_name }}</td>
|
||||||
|
<td>{{ vote.ip_address }}</td>
|
||||||
|
<td>{{ vote.vote_timestamp.strftime('%Y-%m-%d %H:%M') if vote.vote_timestamp else 'N/A' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- No Data Message -->
|
||||||
|
{% if not recent_votes and not patterns and not device_details %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h5>No Vote Data Available</h5>
|
||||||
|
<p>No votes have been cast yet, or the device tracking table is empty.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
255
motm_app/templates/s3_config.html
Normal file
255
motm_app/templates/s3_config.html
Normal 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>
|
||||||
154
motm_app/templates/s3_status.html
Normal file
154
motm_app/templates/s3_status.html
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user