Enhance match detail page with comprehensive functionality

Major Features Added:
- Navigation bar integration with consistent styling across all pages
- ZIP file management with upload/replace/delete functionality
- Real-time progress bars for ZIP uploads with visual feedback
- Complete outcomes management with editable values
- Add new outcomes functionality with validation
- Professional UI matching fixture detail page design

Backend Enhancements:
- New routes for outcome CRUD operations (/match/<id>/outcomes)
- AJAX-powered interactions for seamless user experience
- Proper error handling and user feedback systems
- Input validation for numeric outcome values
- User permission checks and security controls

Frontend Improvements:
- Responsive design optimized for mobile devices
- Real-time visual feedback for changed values
- Batch saving of multiple outcome changes
- Individual outcome deletion with confirmation
- Professional card-based layout for better information display
- Consistent boxing theme branding throughout

Technical Implementation:
- Database migration system with version tracking
- Fixture-based navigation and URL structure
- Event-driven JavaScript with proper event listeners
- Progress tracking with XMLHttpRequest for uploads
- Template inheritance with Jinja2 templating
- CSS grid layouts for responsive design

Files Modified:
- app/main/routes.py: Added outcome management routes
- app/templates/main/match_detail.html: Complete redesign
- app/database/: New migration system implementation
- Multiple template files: Navigation consistency updates

This commit provides a complete, professional match detail interface
with full editing capabilities and consistent user experience.
parent 8eb3aa0f
......@@ -31,6 +31,13 @@ def create_app(config_name=None):
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info'
# Register user loader
@login_manager.user_loader
def load_user(user_id):
"""Load user for Flask-Login"""
from app.models import User
return User.query.get(int(user_id))
# Configure logging
setup_logging(app)
......@@ -47,20 +54,37 @@ def create_app(config_name=None):
from app.upload import bp as upload_bp
app.register_blueprint(upload_bp, url_prefix='/upload')
# Create database tables
# Create database tables and run migrations (handle missing database gracefully)
with app.app_context():
try:
db.create_all()
# Run database migrations
from app.database import run_migrations
migration_result = run_migrations()
if migration_result['status'] == 'success':
app.logger.info(f"Database migrations completed: {migration_result['message']}")
elif migration_result['status'] == 'partial':
app.logger.warning(f"Database migrations partially completed: {migration_result['message']}")
elif migration_result['status'] == 'error':
app.logger.error(f"Database migrations failed: {migration_result['message']}")
except Exception as e:
app.logger.warning(f"Database initialization failed: {str(e)}")
app.logger.warning("Database may not exist or be accessible. Please check database configuration.")
return app
def setup_logging(app):
"""Setup application logging with colors for development"""
if not app.debug and not app.testing:
# Production logging
from logging import handlers as logging_handlers
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = logging.handlers.RotatingFileHandler(
file_handler = logging_handlers.RotatingFileHandler(
'logs/fixture-daemon.log', maxBytes=10240, backupCount=10
)
file_handler.setFormatter(logging.Formatter(
......@@ -87,9 +111,3 @@ def setup_logging(app):
app.logger.addHandler(handler)
app.logger.setLevel(logging.DEBUG)
@login_manager.user_loader
def load_user(user_id):
"""Load user for Flask-Login"""
from app.models import User
return User.query.get(int(user_id))
\ No newline at end of file
......@@ -5,10 +5,8 @@ from flask_jwt_extended import jwt_required, get_jwt_identity
from sqlalchemy import func, desc
from app.api import bp
from app import db
from app.models import Match, FileUpload, User, SystemLog, MatchOutcome, UserSession
from app.utils.security import require_admin, require_active_user
from app.utils.logging import log_api_request
from app.database import get_db_manager
from app.upload.file_handler import get_file_upload_handler
from app.upload.fixture_parser import get_fixture_parser
......@@ -19,6 +17,7 @@ logger = logging.getLogger(__name__)
def api_get_matches():
"""Get matches with pagination and filtering"""
try:
from app.models import User, Match, FileUpload
user_id = get_jwt_identity()
user = User.query.get(user_id)
......@@ -121,6 +120,7 @@ def api_get_matches():
def api_get_match(match_id):
"""Get specific match details"""
try:
from app.models import User, Match, FileUpload
user_id = get_jwt_identity()
user = User.query.get(user_id)
......@@ -153,6 +153,7 @@ def api_get_match(match_id):
def api_update_match(match_id):
"""Update match details"""
try:
from app.models import User, Match
user_id = get_jwt_identity()
user = User.query.get(user_id)
......@@ -210,6 +211,7 @@ def api_update_match(match_id):
def api_delete_match(match_id):
"""Delete match (admin only)"""
try:
from app.models import User, Match, FileUpload
user_id = get_jwt_identity()
user = User.query.get(user_id)
......@@ -246,6 +248,7 @@ def api_delete_match(match_id):
def api_get_statistics():
"""Get comprehensive statistics"""
try:
from app.models import User, Match, FileUpload
user_id = get_jwt_identity()
user = User.query.get(user_id)
......@@ -273,6 +276,7 @@ def api_get_statistics():
# Global statistics (if admin)
global_stats = {}
if user.is_admin:
from app.database import get_db_manager
db_manager = get_db_manager()
global_stats = db_manager.get_database_stats()
......@@ -319,6 +323,7 @@ def api_admin_get_users():
search_query = request.args.get('search', '').strip()
status_filter = request.args.get('status')
from app.models import User
# Base query
query = User.query
......@@ -367,6 +372,7 @@ def api_admin_get_users():
def api_admin_update_user(user_id):
"""Update user (admin only)"""
try:
from app.models import User
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
......@@ -413,6 +419,7 @@ def api_admin_get_logs():
level_filter = request.args.get('level')
module_filter = request.args.get('module')
from app.models import SystemLog
# Base query
query = SystemLog.query
......@@ -450,6 +457,7 @@ def api_admin_get_logs():
def api_admin_system_info():
"""Get system information (admin only)"""
try:
from app.database import get_db_manager
db_manager = get_db_manager()
# Database statistics
......@@ -466,6 +474,7 @@ def api_admin_system_info():
fixture_parser = get_fixture_parser()
parsing_stats = fixture_parser.get_parsing_statistics()
from app.models import UserSession
# Active sessions
active_sessions = UserSession.query.filter_by(is_active=True).count()
......@@ -500,6 +509,7 @@ def api_admin_cleanup():
if cleanup_type in ['all', 'sessions']:
# Clean up expired sessions
from app.database import get_db_manager
db_manager = get_db_manager()
expired_sessions = db_manager.cleanup_expired_sessions()
results['expired_sessions_cleaned'] = expired_sessions
......@@ -507,6 +517,7 @@ def api_admin_cleanup():
if cleanup_type in ['all', 'logs']:
# Clean up old logs (older than 30 days)
days = request.json.get('log_retention_days', 30) if request.json else 30
from app.database import get_db_manager
db_manager = get_db_manager()
old_logs = db_manager.cleanup_old_logs(days)
results['old_logs_cleaned'] = old_logs
......
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
from app.models import User
import re
class LoginForm(FlaskForm):
......@@ -44,12 +43,14 @@ class RegistrationForm(FlaskForm):
raise ValidationError('Username can only contain letters, numbers, and underscores')
# Check if username already exists
from app.models import User
user = User.query.filter_by(username=username.data).first()
if user is not None:
raise ValidationError('Username already exists. Please choose a different one.')
def validate_email(self, email):
"""Validate email uniqueness"""
from app.models import User
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('Email already registered. Please use a different email address.')
......@@ -143,6 +144,7 @@ class ForgotPasswordForm(FlaskForm):
def validate_email(self, email):
"""Validate email exists"""
from app.models import User
user = User.query.filter_by(email=email.data).first()
if user is None:
raise ValidationError('No account found with that email address.')
......@@ -190,3 +192,136 @@ class ResetPasswordForm(FlaskForm):
]
if password_value.lower() in weak_passwords:
raise ValidationError('Password is too common. Please choose a stronger password.')
class AdminUserForm(FlaskForm):
"""Admin user management form"""
username = StringField('Username', validators=[
DataRequired(message='Username is required'),
Length(min=3, max=80, message='Username must be between 3 and 80 characters')
])
email = StringField('Email', validators=[
DataRequired(message='Email is required'),
Email(message='Invalid email address'),
Length(max=120, message='Email must be less than 120 characters')
])
is_active = BooleanField('Active')
is_admin = BooleanField('Administrator')
submit = SubmitField('Update User')
class AdminCreateUserForm(FlaskForm):
"""Admin create user form"""
username = StringField('Username', validators=[
DataRequired(message='Username is required'),
Length(min=3, max=80, message='Username must be between 3 and 80 characters')
])
email = StringField('Email', validators=[
DataRequired(message='Email is required'),
Email(message='Invalid email address'),
Length(max=120, message='Email must be less than 120 characters')
])
password = PasswordField('Password', validators=[
DataRequired(message='Password is required'),
Length(min=8, message='Password must be at least 8 characters long')
])
password2 = PasswordField('Repeat Password', validators=[
DataRequired(message='Please confirm your password'),
EqualTo('password', message='Passwords must match')
])
is_active = BooleanField('Active', default=True)
is_admin = BooleanField('Administrator')
submit = SubmitField('Create User')
def validate_username(self, username):
"""Validate username uniqueness and format"""
# Check for valid characters (alphanumeric and underscore only)
if not re.match(r'^[a-zA-Z0-9_]+$', username.data):
raise ValidationError('Username can only contain letters, numbers, and underscores')
# Check if username already exists
from app.models import User
user = User.query.filter_by(username=username.data).first()
if user is not None:
raise ValidationError('Username already exists. Please choose a different one.')
def validate_email(self, email):
"""Validate email uniqueness"""
from app.models import User
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('Email already registered. Please use a different email address.')
def validate_password(self, password):
"""Validate password strength"""
password_value = password.data
# Check minimum length
if len(password_value) < 8:
raise ValidationError('Password must be at least 8 characters long')
# Check for at least one uppercase letter
if not re.search(r'[A-Z]', password_value):
raise ValidationError('Password must contain at least one uppercase letter')
# Check for at least one lowercase letter
if not re.search(r'[a-z]', password_value):
raise ValidationError('Password must contain at least one lowercase letter')
# Check for at least one digit
if not re.search(r'\d', password_value):
raise ValidationError('Password must contain at least one number')
# Check for at least one special character
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password_value):
raise ValidationError('Password must contain at least one special character')
# Check for common weak passwords
weak_passwords = [
'password', '12345678', 'qwerty123', 'admin123',
'password123', '123456789', 'welcome123'
]
if password_value.lower() in weak_passwords:
raise ValidationError('Password is too common. Please choose a stronger password.')
class AdminResetPasswordForm(FlaskForm):
"""Admin reset user password form"""
new_password = PasswordField('New Password', validators=[
DataRequired(message='New password is required'),
Length(min=8, message='Password must be at least 8 characters long')
])
new_password2 = PasswordField('Repeat New Password', validators=[
DataRequired(message='Please confirm the new password'),
EqualTo('new_password', message='Passwords must match')
])
submit = SubmitField('Reset Password')
def validate_new_password(self, new_password):
"""Validate new password strength"""
password_value = new_password.data
# Check minimum length
if len(password_value) < 8:
raise ValidationError('Password must be at least 8 characters long')
# Check for at least one uppercase letter
if not re.search(r'[A-Z]', password_value):
raise ValidationError('Password must contain at least one uppercase letter')
# Check for at least one lowercase letter
if not re.search(r'[a-z]', password_value):
raise ValidationError('Password must contain at least one lowercase letter')
# Check for at least one digit
if not re.search(r'\d', password_value):
raise ValidationError('Password must contain at least one number')
# Check for at least one special character
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password_value):
raise ValidationError('Password must contain at least one special character')
# Check for common weak passwords
weak_passwords = [
'password', '12345678', 'qwerty123', 'admin123',
'password123', '123456789', 'welcome123'
]
if password_value.lower() in weak_passwords:
raise ValidationError('Password is too common. Please choose a stronger password.')
\ No newline at end of file
......@@ -6,7 +6,6 @@ from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identi
from werkzeug.security import check_password_hash
from app.auth import bp
from app import db
from app.models import User, UserSession, SystemLog
from app.auth.forms import LoginForm, RegistrationForm
from app.utils.security import validate_password_strength, generate_secure_token, rate_limit_check
from app.utils.logging import log_security_event
......@@ -29,6 +28,7 @@ def login():
flash('Too many login attempts. Please try again later.', 'error')
return render_template('auth/login.html', form=form)
from app.models import User, UserSession
user = User.query.filter_by(username=form.username.data).first()
if user and user.check_password(form.password.data):
......@@ -75,6 +75,7 @@ def logout():
# Deactivate user session
if 'session_id' in session:
from app.models import UserSession
user_session = UserSession.query.filter_by(session_id=session['session_id']).first()
if user_session:
user_session.deactivate()
......@@ -108,6 +109,7 @@ def register():
return render_template('auth/register.html', form=form)
# Check if user already exists
from app.models import User
if User.query.filter_by(username=form.username.data).first():
flash('Username already exists.', 'error')
return render_template('auth/register.html', form=form)
......@@ -153,6 +155,7 @@ def api_login():
log_security_event('API_LOGIN_RATE_LIMIT', client_ip, username=data.get('username'))
return jsonify({'error': 'Too many login attempts'}), 429
from app.models import User, UserSession
user = User.query.filter_by(username=data['username']).first()
if user and user.check_password(data['password']):
......@@ -206,6 +209,7 @@ def api_logout():
session_id = data.get('session_id')
if session_id:
from app.models import UserSession
user_session = UserSession.query.filter_by(
session_id=session_id,
user_id=user_id
......@@ -227,6 +231,7 @@ def api_refresh():
"""Refresh JWT token"""
try:
user_id = get_jwt_identity()
from app.models import User
user = User.query.get(user_id)
if not user or not user.is_active:
......@@ -253,6 +258,7 @@ def api_profile():
"""Get user profile"""
try:
user_id = get_jwt_identity()
from app.models import User
user = User.query.get(user_id)
if not user:
......@@ -270,6 +276,7 @@ def api_change_password():
"""Change user password"""
try:
user_id = get_jwt_identity()
from app.models import User
user = User.query.get(user_id)
if not user:
......@@ -309,6 +316,7 @@ def api_user_sessions():
"""Get user's active sessions"""
try:
user_id = get_jwt_identity()
from app.models import UserSession
sessions = UserSession.query.filter_by(
user_id=user_id,
is_active=True
......@@ -328,6 +336,7 @@ def api_terminate_session(session_id):
"""Terminate a specific session"""
try:
user_id = get_jwt_identity()
from app.models import UserSession
user_session = UserSession.query.filter_by(
session_id=session_id,
user_id=user_id
......
"""
Database package for Fixture Manager
Handles migrations and database versioning
"""
from .migrations import (
get_migration_manager,
run_migrations,
get_migration_status,
DatabaseVersion
)
# Import functions from the manager module for backward compatibility
from .manager import (
init_database_manager,
get_db_manager,
DatabaseManager
)
__all__ = [
'get_migration_manager',
'run_migrations',
'get_migration_status',
'DatabaseVersion',
'init_database_manager',
'get_db_manager',
'DatabaseManager'
]
\ No newline at end of file
......@@ -6,7 +6,6 @@ from sqlalchemy.exc import SQLAlchemyError, OperationalError
from sqlalchemy.pool import QueuePool
from flask import current_app
from app import db
from app.models import User, Match, MatchOutcome, FileUpload, SystemLog, UserSession
logger = logging.getLogger(__name__)
......@@ -151,6 +150,7 @@ class DatabaseManager:
"""Create default admin user if it doesn't exist"""
try:
with self.app.app_context():
from app.models import User
admin_user = User.query.filter_by(username='admin').first()
if not admin_user:
admin_user = User(
......@@ -173,6 +173,7 @@ class DatabaseManager:
"""Get database statistics"""
try:
with self.app.app_context():
from app.models import User, Match, MatchOutcome, FileUpload, SystemLog, UserSession
stats = {
'users': User.query.count(),
'matches': Match.query.count(),
......@@ -192,6 +193,7 @@ class DatabaseManager:
try:
with self.app.app_context():
from datetime import datetime
from app.models import UserSession
expired_sessions = UserSession.query.filter(
UserSession.expires_at < datetime.utcnow()
).all()
......@@ -213,6 +215,7 @@ class DatabaseManager:
try:
with self.app.app_context():
from datetime import datetime, timedelta
from app.models import SystemLog
cutoff_date = datetime.utcnow() - timedelta(days=days)
old_logs = SystemLog.query.filter(
......
"""
Database migration system for Fixture Manager
Handles automatic schema updates and versioning
"""
import logging
import os
from datetime import datetime
from typing import List, Dict, Any
from app import db
from sqlalchemy import text, inspect
from sqlalchemy.exc import SQLAlchemyError
logger = logging.getLogger(__name__)
class DatabaseVersion(db.Model):
"""Track database schema versions"""
__tablename__ = 'database_versions'
id = db.Column(db.Integer, primary_key=True)
version = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.String(255), nullable=False)
applied_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<DatabaseVersion {self.version}: {self.description}>'
class Migration:
"""Base migration class"""
def __init__(self, version: str, description: str):
self.version = version
self.description = description
def up(self):
"""Apply the migration"""
raise NotImplementedError("Subclasses must implement up() method")
def down(self):
"""Rollback the migration (optional)"""
pass
def can_rollback(self) -> bool:
"""Check if migration can be rolled back"""
return False
class Migration_001_RemoveFixtureIdUnique(Migration):
"""Remove unique constraint from fixture_id in matches table"""
def __init__(self):
super().__init__("001", "Remove unique constraint from fixture_id in matches table")
def up(self):
"""Remove unique constraint from fixture_id"""
try:
# Check if we're using MySQL or SQLite
inspector = inspect(db.engine)
# Get current constraints
constraints = inspector.get_unique_constraints('matches')
indexes = inspector.get_indexes('matches')
# Find fixture_id unique constraint/index
fixture_id_constraint = None
fixture_id_index = None
for constraint in constraints:
if 'fixture_id' in constraint['column_names']:
fixture_id_constraint = constraint['name']
break
for index in indexes:
if 'fixture_id' in index['column_names'] and index['unique']:
fixture_id_index = index['name']
break
# Drop unique constraint if it exists
if fixture_id_constraint:
try:
with db.engine.connect() as conn:
conn.execute(text(f"ALTER TABLE matches DROP CONSTRAINT {fixture_id_constraint}"))
conn.commit()
logger.info(f"Dropped unique constraint {fixture_id_constraint} from matches.fixture_id")
except Exception as e:
logger.warning(f"Could not drop constraint {fixture_id_constraint}: {str(e)}")
# Drop unique index if it exists
if fixture_id_index:
try:
with db.engine.connect() as conn:
conn.execute(text(f"DROP INDEX {fixture_id_index}"))
conn.commit()
logger.info(f"Dropped unique index {fixture_id_index} from matches.fixture_id")
except Exception as e:
logger.warning(f"Could not drop index {fixture_id_index}: {str(e)}")
# Create regular index for performance (non-unique)
try:
with db.engine.connect() as conn:
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_matches_fixture_id ON matches(fixture_id)"))
conn.commit()
logger.info("Created non-unique index on matches.fixture_id")
except Exception as e:
logger.warning(f"Could not create index on fixture_id: {str(e)}")
return True
except Exception as e:
logger.error(f"Migration 001 failed: {str(e)}")
raise
def can_rollback(self) -> bool:
return False # Cannot safely rollback unique constraint removal
class Migration_002_AddDatabaseVersionTable(Migration):
"""Ensure database version tracking table exists"""
def __init__(self):
super().__init__("002", "Create database version tracking table")
def up(self):
"""Create database version table if it doesn't exist"""
try:
# This migration is handled by the migration system itself
# when it creates the DatabaseVersion table
return True
except Exception as e:
logger.error(f"Migration 002 failed: {str(e)}")
raise
class MigrationManager:
"""Manages database migrations and versioning"""
def __init__(self):
self.migrations = [
Migration_002_AddDatabaseVersionTable(),
Migration_001_RemoveFixtureIdUnique(),
]
def ensure_version_table(self):
"""Ensure the database version table exists"""
try:
# Create all tables (this will create DatabaseVersion if it doesn't exist)
db.create_all()
logger.info("Database version table ensured")
except Exception as e:
logger.error(f"Failed to create version table: {str(e)}")
raise
def get_current_version(self) -> str:
"""Get the current database version"""
try:
latest_version = DatabaseVersion.query.order_by(DatabaseVersion.applied_at.desc()).first()
return latest_version.version if latest_version else "000"
except Exception as e:
logger.warning(f"Could not get current version: {str(e)}")
return "000"
def get_applied_versions(self) -> List[str]:
"""Get list of applied migration versions"""
try:
versions = DatabaseVersion.query.order_by(DatabaseVersion.applied_at.asc()).all()
return [v.version for v in versions]
except Exception as e:
logger.warning(f"Could not get applied versions: {str(e)}")
return []
def is_migration_applied(self, version: str) -> bool:
"""Check if a migration version has been applied"""
try:
return DatabaseVersion.query.filter_by(version=version).first() is not None
except Exception as e:
logger.warning(f"Could not check migration status for {version}: {str(e)}")
return False
def apply_migration(self, migration: Migration) -> bool:
"""Apply a single migration"""
try:
if self.is_migration_applied(migration.version):
logger.info(f"Migration {migration.version} already applied, skipping")
return True
logger.info(f"Applying migration {migration.version}: {migration.description}")
# Apply the migration
migration.up()
# Record the migration
version_record = DatabaseVersion(
version=migration.version,
description=migration.description
)
db.session.add(version_record)
db.session.commit()
logger.info(f"Migration {migration.version} applied successfully")
return True
except Exception as e:
db.session.rollback()
logger.error(f"Migration {migration.version} failed: {str(e)}")
raise
def run_migrations(self) -> Dict[str, Any]:
"""Run all pending migrations"""
try:
# Ensure version table exists
self.ensure_version_table()
applied_versions = self.get_applied_versions()
pending_migrations = []
failed_migrations = []
# Find pending migrations
for migration in self.migrations:
if migration.version not in applied_versions:
pending_migrations.append(migration)
if not pending_migrations:
logger.info("No pending migrations")
return {
'status': 'success',
'message': 'Database is up to date',
'applied_count': 0,
'failed_count': 0
}
# Apply pending migrations
applied_count = 0
for migration in pending_migrations:
try:
if self.apply_migration(migration):
applied_count += 1
else:
failed_migrations.append(migration.version)
except Exception as e:
failed_migrations.append(migration.version)
logger.error(f"Failed to apply migration {migration.version}: {str(e)}")
# Return results
if failed_migrations:
return {
'status': 'partial',
'message': f'Applied {applied_count} migrations, {len(failed_migrations)} failed',
'applied_count': applied_count,
'failed_count': len(failed_migrations),
'failed_migrations': failed_migrations
}
else:
return {
'status': 'success',
'message': f'Successfully applied {applied_count} migrations',
'applied_count': applied_count,
'failed_count': 0
}
except Exception as e:
logger.error(f"Migration system failed: {str(e)}")
return {
'status': 'error',
'message': f'Migration system failed: {str(e)}',
'applied_count': 0,
'failed_count': 0
}
def get_migration_status(self) -> Dict[str, Any]:
"""Get current migration status"""
try:
self.ensure_version_table()
current_version = self.get_current_version()
applied_versions = self.get_applied_versions()
all_versions = [m.version for m in self.migrations]
pending_versions = [v for v in all_versions if v not in applied_versions]
return {
'current_version': current_version,
'applied_versions': applied_versions,
'pending_versions': pending_versions,
'total_migrations': len(self.migrations),
'applied_count': len(applied_versions),
'pending_count': len(pending_versions)
}
except Exception as e:
logger.error(f"Could not get migration status: {str(e)}")
return {
'error': str(e),
'current_version': 'unknown',
'applied_versions': [],
'pending_versions': [],
'total_migrations': 0,
'applied_count': 0,
'pending_count': 0
}
# Global migration manager instance
migration_manager = MigrationManager()
def get_migration_manager():
"""Get migration manager instance"""
return migration_manager
def run_migrations():
"""Run all pending migrations"""
return migration_manager.run_migrations()
def get_migration_status():
"""Get migration status"""
return migration_manager.get_migration_status()
\ No newline at end of file
import logging
import os
from datetime import datetime
from flask import render_template, request, jsonify, redirect, url_for, flash, current_app
from flask_login import login_required, current_user
from app.main import bp
from app import db
from app.models import Match, FileUpload, User, SystemLog, MatchOutcome
from app.upload.file_handler import get_file_upload_handler
from app.upload.fixture_parser import get_fixture_parser
from app.utils.security import require_admin, require_active_user
from app.database import get_db_manager
logger = logging.getLogger(__name__)
@bp.route('/')
def index():
"""Home page"""
# Check if database is available
try:
from app.database import get_db_manager
db_manager = get_db_manager()
if not db_manager.test_connection():
# Add logging to debug template loading
logger.info(f"Template folder path: {current_app.template_folder}")
logger.info(f"Template folder exists: {os.path.exists(current_app.template_folder) if current_app.template_folder else 'None'}")
template_path = os.path.join(current_app.template_folder or '', 'setup', 'database.html')
logger.info(f"Full template path: {template_path}")
logger.info(f"Template file exists: {os.path.exists(template_path)}")
return render_template('setup/database.html')
except Exception as e:
logger.warning(f"Database check failed: {str(e)}")
# Add logging to debug template loading
logger.info(f"Template folder path: {current_app.template_folder}")
logger.info(f"Template folder exists: {os.path.exists(current_app.template_folder) if current_app.template_folder else 'None'}")
template_path = os.path.join(current_app.template_folder or '', 'setup', 'database.html')
logger.info(f"Full template path: {template_path}")
logger.info(f"Template file exists: {os.path.exists(template_path)}")
return render_template('setup/database.html')
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
return render_template('main/index.html')
......@@ -24,17 +46,32 @@ def index():
def dashboard():
"""Main dashboard"""
try:
# Check if database is available
from app.database import get_db_manager
db_manager = get_db_manager()
if not db_manager.test_connection():
flash('Database connection failed. Please check database setup.', 'error')
return render_template('setup/database.html')
from app.models import Match, FileUpload
from sqlalchemy import func
# Get user statistics
user_matches = Match.query.filter_by(created_by=current_user.id).count()
user_uploads = FileUpload.query.filter_by(uploaded_by=current_user.id).count()
# Get recent matches
recent_matches = Match.query.filter_by(created_by=current_user.id)\
.order_by(Match.created_at.desc()).limit(5).all()
# Get recent fixtures (grouped by fixture_id)
recent_fixtures_query = db.session.query(
Match.fixture_id,
Match.filename,
func.count(Match.id).label('match_count'),
func.min(Match.created_at).label('upload_date')
).filter_by(created_by=current_user.id)\
.group_by(Match.fixture_id, Match.filename)\
.order_by(func.min(Match.created_at).desc())\
.limit(5)
# Get recent uploads
recent_uploads = FileUpload.query.filter_by(uploaded_by=current_user.id)\
.order_by(FileUpload.created_at.desc()).limit(5).all()
recent_fixtures = recent_fixtures_query.all()
# Get system statistics (for admins)
system_stats = {}
......@@ -49,8 +86,7 @@ def dashboard():
return render_template('main/dashboard.html',
user_matches=user_matches,
user_uploads=user_uploads,
recent_matches=recent_matches,
recent_uploads=recent_uploads,
recent_fixtures=recent_fixtures,
system_stats=system_stats)
except Exception as e:
......@@ -58,11 +94,11 @@ def dashboard():
flash('Error loading dashboard', 'error')
return render_template('main/dashboard.html')
@bp.route('/matches')
@bp.route('/fixtures')
@login_required
@require_active_user
def matches():
"""List matches with pagination and filtering"""
def fixtures():
"""List fixtures with pagination and filtering"""
try:
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
......@@ -71,56 +107,182 @@ def matches():
status_filter = request.args.get('status')
search_query = request.args.get('search', '').strip()
# Base query
if current_user.is_admin:
query = Match.query
else:
query = Match.query.filter_by(created_by=current_user.id)
from app.models import Match
from sqlalchemy import func, and_
# Apply filters
if status_filter == 'active':
query = query.filter_by(active_status=True)
elif status_filter == 'pending':
query = query.filter_by(active_status=False)
elif status_filter == 'zip_pending':
query = query.filter_by(zip_upload_status='pending')
elif status_filter == 'zip_completed':
query = query.filter_by(zip_upload_status='completed')
# Base query for fixtures (grouped by fixture_id)
base_query = db.session.query(
Match.fixture_id,
Match.filename,
func.count(Match.id).label('match_count'),
func.min(Match.created_at).label('upload_date'),
func.sum(Match.active_status.cast(db.Integer)).label('active_matches'),
func.max(Match.created_by).label('created_by') # Assuming all matches in fixture have same creator
).group_by(Match.fixture_id, Match.filename)
# Search functionality
# Apply user filter
if not current_user.is_admin:
base_query = base_query.filter(Match.created_by == current_user.id)
# Apply search filter
if search_query:
search_pattern = f"%{search_query}%"
query = query.filter(
db.or_(
Match.fighter1_township.ilike(search_pattern),
Match.fighter2_township.ilike(search_pattern),
Match.venue_kampala_township.ilike(search_pattern),
Match.match_number.like(search_pattern)
)
)
base_query = base_query.filter(Match.filename.ilike(search_pattern))
# Pagination
matches_pagination = query.order_by(Match.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
# Get all fixtures
fixtures_query = base_query.order_by(func.min(Match.created_at).desc())
# Apply status filter after grouping
fixtures = fixtures_query.all()
if status_filter:
filtered_fixtures = []
for fixture in fixtures:
if status_filter == 'active' and fixture.active_matches > 0:
filtered_fixtures.append(fixture)
elif status_filter == 'inactive' and fixture.active_matches == 0:
filtered_fixtures.append(fixture)
elif status_filter == 'complete' and fixture.active_matches == fixture.match_count:
filtered_fixtures.append(fixture)
elif not status_filter:
filtered_fixtures.append(fixture)
fixtures = filtered_fixtures
# Manual pagination
total = len(fixtures)
start = (page - 1) * per_page
end = start + per_page
fixtures_page = fixtures[start:end]
# Create pagination object
class SimplePagination:
def __init__(self, page, per_page, total, items):
self.page = page
self.per_page = per_page
self.total = total
self.items = items
self.pages = (total + per_page - 1) // per_page
self.has_prev = page > 1
self.has_next = page < self.pages
self.prev_num = page - 1 if self.has_prev else None
self.next_num = page + 1 if self.has_next else None
pagination = SimplePagination(page, per_page, total, fixtures_page)
return render_template('main/matches.html',
matches=matches_pagination.items,
pagination=matches_pagination,
fixtures=fixtures_page,
pagination=pagination,
status_filter=status_filter,
search_query=search_query)
except Exception as e:
logger.error(f"Matches list error: {str(e)}")
flash('Error loading matches', 'error')
return render_template('main/matches.html', matches=[], pagination=None)
logger.error(f"Fixtures list error: {str(e)}")
flash('Error loading fixtures', 'error')
return render_template('main/matches.html', fixtures=[], pagination=None)
# Keep the old /matches route for backward compatibility
@bp.route('/matches')
@login_required
@require_active_user
def matches():
"""Redirect to fixtures for backward compatibility"""
return redirect(url_for('main.fixtures'))
@bp.route('/fixture/<fixture_id>')
@login_required
@require_active_user
def fixture_detail(fixture_id):
"""Fixture detail page showing all matches in the fixture"""
try:
from app.models import Match, FileUpload
# Get all matches for this fixture
if current_user.is_admin:
matches = Match.query.filter_by(fixture_id=fixture_id).all()
else:
matches = Match.query.filter_by(fixture_id=fixture_id, created_by=current_user.id).all()
if not matches:
flash('Fixture not found', 'error')
return redirect(url_for('main.matches'))
# Get fixture info from first match
fixture_info = {
'fixture_id': fixture_id,
'filename': matches[0].filename,
'upload_date': matches[0].created_at,
'total_matches': len(matches),
'active_matches': sum(1 for m in matches if m.active_status),
'created_by': matches[0].created_by
}
# Get associated uploads for the fixture
match_ids = [m.id for m in matches]
uploads = FileUpload.query.filter(FileUpload.match_id.in_(match_ids)).all() if match_ids else []
return render_template('main/fixture_detail.html',
fixture_info=fixture_info,
matches=matches,
uploads=uploads)
except Exception as e:
logger.error(f"Fixture detail error: {str(e)}")
flash('Error loading fixture details', 'error')
return redirect(url_for('main.matches'))
@bp.route('/fixture/<fixture_id>/delete', methods=['POST'])
@login_required
@require_active_user
def delete_fixture(fixture_id):
"""Delete entire fixture and all related matches"""
try:
from app.models import Match, MatchOutcome, FileUpload
# Get all matches for this fixture
if current_user.is_admin:
matches = Match.query.filter_by(fixture_id=fixture_id).all()
else:
matches = Match.query.filter_by(fixture_id=fixture_id, created_by=current_user.id).all()
if not matches:
flash('Fixture not found', 'error')
return redirect(url_for('main.matches'))
fixture_filename = matches[0].filename
match_count = len(matches)
# Delete all related data
match_ids = [m.id for m in matches]
# Delete match outcomes
MatchOutcome.query.filter(MatchOutcome.match_id.in_(match_ids)).delete(synchronize_session=False)
# Delete file uploads associated with matches
FileUpload.query.filter(FileUpload.match_id.in_(match_ids)).delete(synchronize_session=False)
# Delete matches
Match.query.filter_by(fixture_id=fixture_id).delete(synchronize_session=False)
db.session.commit()
logger.info(f"Fixture {fixture_id} ({fixture_filename}) with {match_count} matches deleted by user {current_user.username}")
flash(f'Fixture "{fixture_filename}" and all {match_count} related matches have been deleted successfully.', 'success')
return redirect(url_for('main.matches'))
except Exception as e:
db.session.rollback()
logger.error(f"Fixture deletion error: {str(e)}")
flash('Error deleting fixture', 'error')
return redirect(url_for('main.matches'))
@bp.route('/match/<int:id>')
@login_required
@require_active_user
def match_detail(id):
"""Match detail page"""
"""Individual match detail page"""
try:
from app.models import Match, FileUpload
if current_user.is_admin:
match = Match.query.get_or_404(id)
else:
......@@ -140,7 +302,113 @@ def match_detail(id):
except Exception as e:
logger.error(f"Match detail error: {str(e)}")
flash('Error loading match details', 'error')
return redirect(url_for('main.matches'))
return redirect(url_for('main.fixture_detail', fixture_id=request.args.get('fixture_id', '')))
@bp.route('/match/<int:match_id>/outcomes', methods=['POST'])
@login_required
@require_active_user
def update_match_outcomes(match_id):
"""Update match outcomes via AJAX"""
try:
from app.models import Match, MatchOutcome
# Get the match
if current_user.is_admin:
match = Match.query.get_or_404(match_id)
else:
match = Match.query.filter_by(id=match_id, created_by=current_user.id).first_or_404()
data = request.get_json()
if not data or 'outcomes' not in data:
return jsonify({'error': 'No outcomes data provided'}), 400
outcomes_data = data['outcomes']
updated_outcomes = []
# Update or create outcomes
for column_name, float_value in outcomes_data.items():
try:
# Validate the float value
float_val = float(float_value)
# Find existing outcome or create new one
outcome = MatchOutcome.query.filter_by(
match_id=match_id,
column_name=column_name
).first()
if outcome:
outcome.float_value = float_val
outcome.updated_at = datetime.utcnow()
else:
outcome = MatchOutcome(
match_id=match_id,
column_name=column_name,
float_value=float_val
)
db.session.add(outcome)
updated_outcomes.append({
'column_name': column_name,
'float_value': float_val
})
except ValueError:
return jsonify({'error': f'Invalid numeric value for {column_name}'}), 400
# Update match timestamp
match.updated_at = datetime.utcnow()
db.session.commit()
logger.info(f"Match {match_id} outcomes updated by user {current_user.username}")
return jsonify({
'message': 'Outcomes updated successfully',
'updated_outcomes': updated_outcomes
}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Update match outcomes error: {str(e)}")
return jsonify({'error': 'Failed to update outcomes'}), 500
@bp.route('/match/<int:match_id>/outcomes/<int:outcome_id>', methods=['DELETE'])
@login_required
@require_active_user
def delete_match_outcome(match_id, outcome_id):
"""Delete a specific match outcome"""
try:
from app.models import Match, MatchOutcome
# Get the match to verify ownership
if current_user.is_admin:
match = Match.query.get_or_404(match_id)
else:
match = Match.query.filter_by(id=match_id, created_by=current_user.id).first_or_404()
# Get the outcome
outcome = MatchOutcome.query.filter_by(
id=outcome_id,
match_id=match_id
).first_or_404()
column_name = outcome.column_name
db.session.delete(outcome)
# Update match timestamp
match.updated_at = datetime.utcnow()
db.session.commit()
logger.info(f"Outcome {column_name} deleted from match {match_id} by user {current_user.username}")
return jsonify({
'message': f'Outcome "{column_name}" deleted successfully'
}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Delete match outcome error: {str(e)}")
return jsonify({'error': 'Failed to delete outcome'}), 500
@bp.route('/uploads')
@login_required
......@@ -155,6 +423,7 @@ def uploads():
file_type_filter = request.args.get('file_type')
status_filter = request.args.get('status')
from app.models import FileUpload
# Base query
if current_user.is_admin:
query = FileUpload.query
......@@ -197,6 +466,7 @@ def statistics():
upload_stats = file_handler.get_upload_statistics()
parsing_stats = fixture_parser.get_parsing_statistics()
from app.models import FileUpload, Match
# User-specific statistics
user_stats = {
'total_uploads': FileUpload.query.filter_by(uploaded_by=current_user.id).count(),
......@@ -212,6 +482,7 @@ def statistics():
# System statistics (admin only)
system_stats = {}
if current_user.is_admin:
from app.database import get_db_manager
db_manager = get_db_manager()
system_stats = db_manager.get_database_stats()
......@@ -233,9 +504,11 @@ def admin_panel():
"""Admin panel"""
try:
# Get system overview
from app.database import get_db_manager
db_manager = get_db_manager()
system_stats = db_manager.get_database_stats()
from app.models import SystemLog, User
# Get recent system logs
recent_logs = SystemLog.query.order_by(SystemLog.created_at.desc()).limit(20).all()
......@@ -278,6 +551,7 @@ def admin_users():
search_query = request.args.get('search', '').strip()
status_filter = request.args.get('status')
from app.models import User
# Base query
query = User.query
......@@ -327,6 +601,7 @@ def admin_logs():
level_filter = request.args.get('level')
module_filter = request.args.get('module')
from app.models import SystemLog
# Base query
query = SystemLog.query
......@@ -358,11 +633,274 @@ def admin_logs():
flash('Error loading logs', 'error')
return render_template('main/admin_logs.html', logs=[], pagination=None)
@bp.route('/admin/users/<int:user_id>/edit', methods=['POST'])
@login_required
@require_admin
def admin_edit_user(user_id):
"""Edit user details"""
try:
from app.models import User
user = User.query.get_or_404(user_id)
# Prevent editing the current admin user's admin status
if user.id == current_user.id and request.json.get('is_admin') == False:
return jsonify({'error': 'Cannot remove admin status from your own account'}), 400
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
# Update user fields
if 'username' in data:
# Check if username is already taken by another user
existing_user = User.query.filter_by(username=data['username']).first()
if existing_user and existing_user.id != user.id:
return jsonify({'error': 'Username already exists'}), 400
user.username = data['username']
if 'email' in data:
# Check if email is already taken by another user
existing_user = User.query.filter_by(email=data['email']).first()
if existing_user and existing_user.id != user.id:
return jsonify({'error': 'Email already exists'}), 400
user.email = data['email']
if 'is_active' in data:
user.is_active = bool(data['is_active'])
if 'is_admin' in data:
user.is_admin = bool(data['is_admin'])
user.updated_at = datetime.utcnow()
db.session.commit()
logger.info(f"User {user.username} updated by admin {current_user.username}")
return jsonify({'message': 'User updated successfully', 'user': user.to_dict()}), 200
except Exception as e:
logger.error(f"Admin edit user error: {str(e)}")
return jsonify({'error': 'Failed to update user'}), 500
@bp.route('/admin/users/<int:user_id>/delete', methods=['DELETE'])
@login_required
@require_admin
def admin_delete_user(user_id):
"""Delete user"""
try:
from app.models import User
user = User.query.get_or_404(user_id)
# Prevent deleting the current admin user
if user.id == current_user.id:
return jsonify({'error': 'Cannot delete your own account'}), 400
username = user.username
db.session.delete(user)
db.session.commit()
logger.info(f"User {username} deleted by admin {current_user.username}")
return jsonify({'message': 'User deleted successfully'}), 200
except Exception as e:
logger.error(f"Admin delete user error: {str(e)}")
return jsonify({'error': 'Failed to delete user'}), 500
@bp.route('/admin/users/create', methods=['POST'])
@login_required
@require_admin
def admin_create_user():
"""Create new user"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
# Validate required fields
required_fields = ['username', 'email', 'password']
for field in required_fields:
if field not in data or not data[field]:
return jsonify({'error': f'{field.title()} is required'}), 400
from app.models import User
# Check if username already exists
if User.query.filter_by(username=data['username']).first():
return jsonify({'error': 'Username already exists'}), 400
# Check if email already exists
if User.query.filter_by(email=data['email']).first():
return jsonify({'error': 'Email already exists'}), 400
# Validate password strength
from app.utils.security import validate_password_strength
if not validate_password_strength(data['password']):
return jsonify({'error': 'Password does not meet security requirements'}), 400
# Create new user
user = User(
username=data['username'],
email=data['email'],
is_active=data.get('is_active', True),
is_admin=data.get('is_admin', False)
)
user.set_password(data['password'])
db.session.add(user)
db.session.commit()
logger.info(f"User {user.username} created by admin {current_user.username}")
return jsonify({'message': 'User created successfully', 'user': user.to_dict()}), 201
except Exception as e:
logger.error(f"Admin create user error: {str(e)}")
return jsonify({'error': 'Failed to create user'}), 500
@bp.route('/admin/users/<int:user_id>/reset-password', methods=['POST'])
@login_required
@require_admin
def admin_reset_user_password(user_id):
"""Reset user password"""
try:
from app.models import User
user = User.query.get_or_404(user_id)
data = request.get_json()
if not data or not data.get('new_password'):
return jsonify({'error': 'New password is required'}), 400
# Validate password strength
from app.utils.security import validate_password_strength
if not validate_password_strength(data['new_password']):
return jsonify({'error': 'Password does not meet security requirements'}), 400
user.set_password(data['new_password'])
user.updated_at = datetime.utcnow()
db.session.commit()
logger.info(f"Password reset for user {user.username} by admin {current_user.username}")
return jsonify({'message': 'Password reset successfully'}), 200
except Exception as e:
logger.error(f"Admin reset password error: {str(e)}")
return jsonify({'error': 'Failed to reset password'}), 500
@bp.route('/admin/settings/registration', methods=['GET', 'POST'])
@login_required
@require_admin
def admin_registration_settings():
"""Manage registration settings"""
try:
if request.method == 'GET':
# Get current registration status
registration_disabled = current_app.config.get('REGISTRATION_DISABLED', False)
return jsonify({'registration_disabled': registration_disabled}), 200
elif request.method == 'POST':
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
# Update registration setting
registration_disabled = bool(data.get('registration_disabled', False))
# Note: In a production environment, you'd want to persist this to a database
# or configuration file. For now, we'll just update the runtime config.
current_app.config['REGISTRATION_DISABLED'] = registration_disabled
status = 'disabled' if registration_disabled else 'enabled'
logger.info(f"Registration {status} by admin {current_user.username}")
return jsonify({
'message': f'Registration {status} successfully',
'registration_disabled': registration_disabled
}), 200
except Exception as e:
logger.error(f"Admin registration settings error: {str(e)}")
return jsonify({'error': 'Failed to update registration settings'}), 500
@bp.route('/admin/database/migrations')
@login_required
@require_admin
def admin_database_migrations():
"""Database migrations management"""
try:
from app.database import get_migration_status
migration_status = get_migration_status()
return render_template('main/admin_migrations.html',
migration_status=migration_status)
except Exception as e:
logger.error(f"Admin migrations error: {str(e)}")
flash('Error loading migration status', 'error')
return render_template('main/admin_migrations.html', migration_status={})
@bp.route('/admin/database/migrations/run', methods=['POST'])
@login_required
@require_admin
def admin_run_migrations():
"""Run pending database migrations"""
try:
from app.database import run_migrations
result = run_migrations()
if result['status'] == 'success':
logger.info(f"Migrations run successfully by admin {current_user.username}: {result['message']}")
return jsonify({
'success': True,
'message': result['message'],
'applied_count': result['applied_count']
}), 200
elif result['status'] == 'partial':
logger.warning(f"Migrations partially completed by admin {current_user.username}: {result['message']}")
return jsonify({
'success': False,
'message': result['message'],
'applied_count': result['applied_count'],
'failed_count': result['failed_count'],
'failed_migrations': result.get('failed_migrations', [])
}), 207 # Multi-status
else:
logger.error(f"Migration failed by admin {current_user.username}: {result['message']}")
return jsonify({
'success': False,
'message': result['message']
}), 500
except Exception as e:
logger.error(f"Admin run migrations error: {str(e)}")
return jsonify({
'success': False,
'message': f'Failed to run migrations: {str(e)}'
}), 500
@bp.route('/admin/database/migrations/status')
@login_required
@require_admin
def admin_migration_status():
"""Get current migration status"""
try:
from app.database import get_migration_status
migration_status = get_migration_status()
return jsonify({
'success': True,
'status': migration_status
}), 200
except Exception as e:
logger.error(f"Admin migration status error: {str(e)}")
return jsonify({
'success': False,
'message': f'Failed to get migration status: {str(e)}'
}), 500
@bp.route('/health')
def health_check():
"""Health check endpoint"""
try:
# Test database connection
from app.database import get_db_manager
db_manager = get_db_manager()
db_healthy = db_manager.test_connection()
......@@ -393,6 +931,7 @@ def health_check():
def api_dashboard_data():
"""API endpoint for dashboard data"""
try:
from app.models import Match, FileUpload
# User statistics
user_stats = {
'total_matches': Match.query.filter_by(created_by=current_user.id).count(),
......
......@@ -71,7 +71,7 @@ class Match(db.Model):
result = db.Column(db.String(255))
filename = db.Column(db.String(1024), nullable=False)
file_sha1sum = db.Column(db.String(255), nullable=False, index=True)
fixture_id = db.Column(db.String(255), unique=True, nullable=False, index=True)
fixture_id = db.Column(db.String(255), nullable=False, index=True)
active_status = db.Column(db.Boolean, default=False, index=True)
# ZIP file related fields
......@@ -93,6 +93,7 @@ class Match(db.Model):
def __init__(self, **kwargs):
super(Match, self).__init__(**kwargs)
# Only generate fixture_id if not provided
if not self.fixture_id:
self.fixture_id = str(uuid.uuid4())
......@@ -154,6 +155,22 @@ class Match(db.Model):
return data
@classmethod
def get_matches_by_fixture(cls, fixture_id: str):
"""Get all matches for a specific fixture"""
return cls.query.filter_by(fixture_id=fixture_id).all()
@classmethod
def get_fixtures_summary(cls):
"""Get summary of all fixtures with match counts"""
from sqlalchemy import func
return db.session.query(
cls.fixture_id,
cls.filename,
func.count(cls.id).label('match_count'),
func.min(cls.created_at).label('created_at')
).group_by(cls.fixture_id, cls.filename).all()
def __repr__(self):
return f'<Match {self.match_number}: {self.fighter1_township} vs {self.fighter2_township}>'
......
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
max-width: 400px;
width: 90%;
}
.logo {
text-align: center;
font-size: 2rem;
color: #333;
margin-bottom: 1rem;
font-weight: bold;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: bold;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 1rem;
}
input[type="text"]:focus, input[type="password"]:focus {
outline: none;
border-color: #007bff;
}
.btn {
width: 100%;
padding: 12px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #0056b3;
}
.links {
text-align: center;
margin-top: 1rem;
}
.links a {
color: #007bff;
text-decoration: none;
}
.links a:hover {
text-decoration: underline;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🥊 Fixture Manager</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.username.label }}
{{ form.username(class="form-control") }}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(class="form-control") }}
</div>
<div class="form-group">
{{ form.submit(class="btn") }}
</div>
</form>
<div class="links">
<a href="{{ url_for('auth.register') }}">Don't have an account? Register here</a><br>
<a href="{{ url_for('main.index') }}">← Back to Home</a>
</div>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
max-width: 400px;
width: 90%;
}
.logo {
text-align: center;
font-size: 2rem;
color: #333;
margin-bottom: 1rem;
font-weight: bold;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: bold;
}
input[type="text"], input[type="email"], input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 1rem;
}
input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus {
outline: none;
border-color: #007bff;
}
.btn {
width: 100%;
padding: 12px;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #218838;
}
.links {
text-align: center;
margin-top: 1rem;
}
.links a {
color: #007bff;
text-decoration: none;
}
.links a:hover {
text-decoration: underline;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.password-requirements {
font-size: 0.9rem;
color: #666;
margin-top: 0.5rem;
}
.password-requirements ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🥊 Fixture Manager</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.username.label }}
{{ form.username(class="form-control") }}
</div>
<div class="form-group">
{{ form.email.label }}
{{ form.email(class="form-control") }}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(class="form-control") }}
<div class="password-requirements">
Password requirements:
<ul>
<li>At least 8 characters long</li>
<li>Contains uppercase and lowercase letters</li>
<li>Contains at least one number</li>
<li>Contains at least one special character</li>
</ul>
</div>
</div>
<div class="form-group">
{{ form.password2.label }}
{{ form.password2(class="form-control") }}
</div>
<div class="form-group">
{{ form.submit(class="btn") }}
</div>
</form>
<div class="links">
<a href="{{ url_for('auth.login') }}">Already have an account? Login here</a><br>
<a href="{{ url_for('main.index') }}">← Back to Home</a>
</div>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container { max-width: 1200px; margin: 2rem auto; padding: 0 2rem; background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.btn { padding: 8px 16px; background-color: #007bff; color: white; text-decoration: none; border-radius: 4px; display: inline-block; margin: 0.5rem 0; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin: 1rem 0; }
.stat-card { background: #f8f9fa; padding: 1.5rem; border-radius: 8px; text-align: center; }
.stat-number { font-size: 2rem; font-weight: bold; color: #dc3545; }
.stat-label { color: #666; font-size: 0.9rem; }
.admin-links { display: flex; gap: 1rem; flex-wrap: wrap; margin: 2rem 0; }
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
<h1>Admin Panel</h1>
<div class="admin-links">
<a href="{{ url_for('main.admin_users') }}" class="btn">Manage Users</a>
<a href="{{ url_for('main.admin_logs') }}" class="btn">View System Logs</a>
</div>
{% if system_stats %}
<h2>System Overview</h2>
<div class="stats-grid">
{% for key, value in system_stats.items() %}
<div class="stat-card">
<div class="stat-number">{{ value }}</div>
<div class="stat-label">{{ key.replace('_', ' ').title() }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if user_stats %}
<h2>User Statistics</h2>
<div class="stats-grid">
{% for key, value in user_stats.items() %}
<div class="stat-card">
<div class="stat-number">{{ value }}</div>
<div class="stat-label">{{ key.replace('_', ' ').title() }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if recent_logs %}
<h2>Recent System Logs</h2>
<div style="max-height: 400px; overflow-y: auto; background: #f8f9fa; padding: 1rem; border-radius: 4px;">
{% for log in recent_logs %}
<div style="margin-bottom: 0.5rem; padding: 0.5rem; background: white; border-radius: 4px;">
<strong>{{ log.level }}</strong> - {{ log.message }}
<small style="color: #666;">({{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') if log.created_at else 'N/A' }})</small>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Logs - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container { max-width: 1200px; margin: 2rem auto; padding: 0 2rem; background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.btn { padding: 8px 16px; background-color: #007bff; color: white; text-decoration: none; border-radius: 4px; display: inline-block; margin: 0.5rem 0; }
.log-container { max-height: 600px; overflow-y: auto; background: #f8f9fa; padding: 1rem; border-radius: 4px; margin-top: 1rem; }
.log-entry { margin-bottom: 1rem; padding: 1rem; background: white; border-radius: 4px; border-left: 4px solid #007bff; }
.log-error { border-left-color: #dc3545; }
.log-warning { border-left-color: #ffc107; }
.log-info { border-left-color: #17a2b8; }
.log-header { font-weight: bold; margin-bottom: 0.5rem; }
.log-time { color: #666; font-size: 0.9rem; }
.log-message { margin-top: 0.5rem; }
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
<h1>System Logs</h1>
<a href="{{ url_for('main.admin_panel') }}" class="btn">← Back to Admin Panel</a>
{% if logs %}
<div class="log-container">
{% for log in logs %}
<div class="log-entry log-{{ log.level.lower() if log.level else 'info' }}">
<div class="log-header">
<span style="text-transform: uppercase;">{{ log.level or 'INFO' }}</span>
{% if log.module %}
- {{ log.module }}
{% endif %}
</div>
<div class="log-time">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') if log.created_at else 'N/A' }}</div>
<div class="log-message">{{ log.message or 'No message' }}</div>
</div>
{% endfor %}
</div>
{% else %}
<p>No logs found.</p>
{% endif %}
</div>
</body>
</html>
\ No newline at end of file
{% extends "base.html" %}
{% block title %}Database Migrations - Admin{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Header with Navigation -->
<div class="row mb-4">
<div class="col-12">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary rounded">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
<i class="fas fa-fist-raised me-2"></i>Fixture Manager
</a>
<div class="navbar-nav ms-auto">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="fas fa-tachometer-alt me-1"></i>Dashboard
</a>
<a class="nav-link" href="{{ url_for('main.admin_panel') }}">
<i class="fas fa-cog me-1"></i>Admin Panel
</a>
<a class="nav-link" href="{{ url_for('main.admin_users') }}">
<i class="fas fa-users me-1"></i>Users
</a>
<a class="nav-link active" href="{{ url_for('main.admin_database_migrations') }}">
<i class="fas fa-database me-1"></i>Migrations
</a>
<a class="nav-link" href="{{ url_for('auth.logout') }}">
<i class="fas fa-sign-out-alt me-1"></i>Logout
</a>
</div>
</div>
</nav>
</div>
</div>
<!-- Page Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h3 mb-0">
<i class="fas fa-database text-primary me-2"></i>Database Migrations
</h1>
<p class="text-muted mb-0">Manage database schema versions and migrations</p>
</div>
<div>
<button type="button" class="btn btn-outline-primary me-2" onclick="refreshMigrationStatus()">
<i class="fas fa-sync-alt me-1"></i>Refresh Status
</button>
<button type="button" class="btn btn-primary" onclick="runMigrations()" id="runMigrationsBtn">
<i class="fas fa-play me-1"></i>Run Migrations
</button>
</div>
</div>
</div>
</div>
<!-- Migration Status Overview -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body text-center">
<div class="display-6 text-primary mb-2">
<span id="currentVersion">{{ migration_status.get('current_version', 'Unknown') }}</span>
</div>
<h6 class="card-title text-muted mb-0">Current Version</h6>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body text-center">
<div class="display-6 text-success mb-2">
<span id="appliedCount">{{ migration_status.get('applied_count', 0) }}</span>
</div>
<h6 class="card-title text-muted mb-0">Applied Migrations</h6>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body text-center">
<div class="display-6 text-warning mb-2">
<span id="pendingCount">{{ migration_status.get('pending_count', 0) }}</span>
</div>
<h6 class="card-title text-muted mb-0">Pending Migrations</h6>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body text-center">
<div class="display-6 text-info mb-2">
<span id="totalCount">{{ migration_status.get('total_migrations', 0) }}</span>
</div>
<h6 class="card-title text-muted mb-0">Total Migrations</h6>
</div>
</div>
</div>
</div>
<!-- Migration Details -->
<div class="row">
<div class="col-md-6">
<!-- Applied Migrations -->
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">
<i class="fas fa-check-circle me-2"></i>Applied Migrations
</h5>
</div>
<div class="card-body">
<div id="appliedMigrations">
{% if migration_status.get('applied_versions') %}
{% for version in migration_status.applied_versions %}
<div class="d-flex align-items-center mb-2">
<span class="badge bg-success me-2">{{ version }}</span>
<span class="text-muted">Migration {{ version }}</span>
</div>
{% endfor %}
{% else %}
<p class="text-muted mb-0">No migrations have been applied yet.</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="col-md-6">
<!-- Pending Migrations -->
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="card-title mb-0">
<i class="fas fa-clock me-2"></i>Pending Migrations
</h5>
</div>
<div class="card-body">
<div id="pendingMigrations">
{% if migration_status.get('pending_versions') %}
{% for version in migration_status.pending_versions %}
<div class="d-flex align-items-center mb-2">
<span class="badge bg-warning text-dark me-2">{{ version }}</span>
<span class="text-muted">Migration {{ version }}</span>
</div>
{% endfor %}
{% else %}
<p class="text-success mb-0">
<i class="fas fa-check me-1"></i>All migrations are up to date!
</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Migration Log -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-list me-2"></i>Migration Log
</h5>
</div>
<div class="card-body">
<div id="migrationLog" class="bg-dark text-light p-3 rounded" style="height: 300px; overflow-y: auto; font-family: monospace;">
<div class="text-muted">Migration log will appear here...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Migration Progress Modal -->
<div class="modal fade" id="migrationProgressModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-cog fa-spin me-2"></i>Running Migrations
</h5>
</div>
<div class="modal-body text-center">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mb-0">Please wait while migrations are being applied...</p>
<small class="text-muted">This may take a few moments.</small>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let migrationProgressModal;
document.addEventListener('DOMContentLoaded', function() {
migrationProgressModal = new bootstrap.Modal(document.getElementById('migrationProgressModal'));
});
function addToLog(message, type = 'info') {
const log = document.getElementById('migrationLog');
const timestamp = new Date().toLocaleTimeString();
const colorClass = {
'info': 'text-info',
'success': 'text-success',
'warning': 'text-warning',
'error': 'text-danger'
}[type] || 'text-light';
const logEntry = document.createElement('div');
logEntry.innerHTML = `<span class="text-muted">[${timestamp}]</span> <span class="${colorClass}">${message}</span>`;
log.appendChild(logEntry);
log.scrollTop = log.scrollHeight;
}
function refreshMigrationStatus() {
addToLog('Refreshing migration status...', 'info');
fetch('{{ url_for("main.admin_migration_status") }}')
.then(response => response.json())
.then(data => {
if (data.success) {
updateMigrationStatus(data.status);
addToLog('Migration status refreshed successfully', 'success');
} else {
addToLog(`Failed to refresh status: ${data.message}`, 'error');
}
})
.catch(error => {
addToLog(`Error refreshing status: ${error.message}`, 'error');
});
}
function updateMigrationStatus(status) {
// Update overview cards
document.getElementById('currentVersion').textContent = status.current_version || 'Unknown';
document.getElementById('appliedCount').textContent = status.applied_count || 0;
document.getElementById('pendingCount').textContent = status.pending_count || 0;
document.getElementById('totalCount').textContent = status.total_migrations || 0;
// Update applied migrations list
const appliedDiv = document.getElementById('appliedMigrations');
if (status.applied_versions && status.applied_versions.length > 0) {
appliedDiv.innerHTML = status.applied_versions.map(version => `
<div class="d-flex align-items-center mb-2">
<span class="badge bg-success me-2">${version}</span>
<span class="text-muted">Migration ${version}</span>
</div>
`).join('');
} else {
appliedDiv.innerHTML = '<p class="text-muted mb-0">No migrations have been applied yet.</p>';
}
// Update pending migrations list
const pendingDiv = document.getElementById('pendingMigrations');
if (status.pending_versions && status.pending_versions.length > 0) {
pendingDiv.innerHTML = status.pending_versions.map(version => `
<div class="d-flex align-items-center mb-2">
<span class="badge bg-warning text-dark me-2">${version}</span>
<span class="text-muted">Migration ${version}</span>
</div>
`).join('');
} else {
pendingDiv.innerHTML = '<p class="text-success mb-0"><i class="fas fa-check me-1"></i>All migrations are up to date!</p>';
}
}
function runMigrations() {
const btn = document.getElementById('runMigrationsBtn');
const originalText = btn.innerHTML;
// Show progress modal
migrationProgressModal.show();
// Disable button
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Running...';
addToLog('Starting migration process...', 'info');
fetch('{{ url_for("main.admin_run_migrations") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
migrationProgressModal.hide();
if (data.success) {
addToLog(`Migrations completed successfully: ${data.message}`, 'success');
if (data.applied_count > 0) {
addToLog(`Applied ${data.applied_count} migration(s)`, 'success');
}
// Refresh status after successful migration
setTimeout(refreshMigrationStatus, 1000);
} else {
addToLog(`Migration failed: ${data.message}`, 'error');
if (data.failed_migrations) {
data.failed_migrations.forEach(migration => {
addToLog(`Failed migration: ${migration}`, 'error');
});
}
}
})
.catch(error => {
migrationProgressModal.hide();
addToLog(`Error running migrations: ${error.message}`, 'error');
})
.finally(() => {
// Re-enable button
btn.disabled = false;
btn.innerHTML = originalText;
});
}
// Initialize log
addToLog('Migration management interface loaded', 'info');
</script>
{% endblock %}
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Management - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.btn {
padding: 8px 16px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
display: inline-block;
margin: 0.5rem 0;
border: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn:hover { background-color: #0056b3; }
.btn-success { background-color: #28a745; }
.btn-success:hover { background-color: #218838; }
.btn-danger { background-color: #dc3545; }
.btn-danger:hover { background-color: #c82333; }
.btn-warning { background-color: #ffc107; color: #212529; }
.btn-warning:hover { background-color: #e0a800; }
.btn-secondary { background-color: #6c757d; }
.btn-secondary:hover { background-color: #5a6268; }
.btn-sm { padding: 4px 8px; font-size: 0.8rem; margin: 0 2px; }
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
.search-filters {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.search-filters input, .search-filters select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: bold;
position: sticky;
top: 0;
}
.status-active { color: #28a745; font-weight: bold; }
.status-inactive { color: #dc3545; font-weight: bold; }
.admin-badge {
background-color: #007bff;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8rem;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 2rem;
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #ddd;
}
.close {
color: #aaa;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover { color: black; }
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.form-group input, .form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.form-group input[type="checkbox"] {
width: auto;
margin-right: 0.5rem;
}
.checkbox-group {
display: flex;
align-items: center;
margin-top: 0.5rem;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
display: none;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.settings-section {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.settings-section h3 {
margin-top: 0;
color: #495057;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #007bff;
}
input:checked + .slider:before {
transform: translateX(26px);
}
.loading {
display: none;
text-align: center;
padding: 2rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
<h1>User Management</h1>
<a href="{{ url_for('main.admin_panel') }}" class="btn">← Back to Admin Panel</a>
<div id="alert" class="alert"></div>
<!-- Registration Settings -->
<div class="settings-section">
<h3>Registration Settings</h3>
<div style="display: flex; align-items: center; gap: 1rem;">
<label class="toggle-switch">
<input type="checkbox" id="registrationToggle">
<span class="slider"></span>
</label>
<span id="registrationStatus">Loading...</span>
</div>
</div>
<!-- User Controls -->
<div class="controls">
<div class="search-filters">
<input type="text" id="searchInput" placeholder="Search users..." style="width: 200px;">
<select id="statusFilter">
<option value="">All Users</option>
<option value="active">Active Only</option>
<option value="inactive">Inactive Only</option>
<option value="admin">Admins Only</option>
</select>
<button class="btn" onclick="filterUsers()">Filter</button>
</div>
<button class="btn btn-success" onclick="showCreateUserModal()">+ Add New User</button>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Loading users...</p>
</div>
{% if users %}
<table id="usersTable">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Status</th>
<th>Admin</th>
<th>Created</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr data-user-id="{{ user.id }}">
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td class="{{ 'status-active' if user.is_active else 'status-inactive' }}">
{{ 'Active' if user.is_active else 'Inactive' }}
</td>
<td>
{% if user.is_admin %}
<span class="admin-badge">Admin</span>
{% else %}
User
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'N/A' }}</td>
<td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else 'Never' }}</td>
<td>
<button class="btn btn-sm" onclick="editUser({{ user.id }})">Edit</button>
<button class="btn btn-warning btn-sm" onclick="resetPassword({{ user.id }})">Reset Password</button>
{% if user.is_active %}
<button class="btn btn-secondary btn-sm" onclick="toggleUserStatus({{ user.id }}, false)">Suspend</button>
{% else %}
<button class="btn btn-success btn-sm" onclick="toggleUserStatus({{ user.id }}, true)">Activate</button>
{% endif %}
{% if user.id != current_user.id %}
<button class="btn btn-danger btn-sm" onclick="deleteUser({{ user.id }})">Delete</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No users found.</p>
{% endif %}
</div>
<!-- Create User Modal -->
<div id="createUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Create New User</h2>
<span class="close" onclick="closeModal('createUserModal')">&times;</span>
</div>
<form id="createUserForm">
<div class="form-group">
<label for="createUsername">Username:</label>
<input type="text" id="createUsername" name="username" required>
</div>
<div class="form-group">
<label for="createEmail">Email:</label>
<input type="email" id="createEmail" name="email" required>
</div>
<div class="form-group">
<label for="createPassword">Password:</label>
<input type="password" id="createPassword" name="password" required>
</div>
<div class="form-group">
<label for="createPassword2">Confirm Password:</label>
<input type="password" id="createPassword2" name="password2" required>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="createIsActive" name="is_active" checked>
<label for="createIsActive">Active</label>
</div>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="createIsAdmin" name="is_admin">
<label for="createIsAdmin">Administrator</label>
</div>
</div>
<div style="text-align: right; margin-top: 2rem;">
<button type="button" class="btn btn-secondary" onclick="closeModal('createUserModal')">Cancel</button>
<button type="submit" class="btn btn-success">Create User</button>
</div>
</form>
</div>
</div>
<!-- Edit User Modal -->
<div id="editUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Edit User</h2>
<span class="close" onclick="closeModal('editUserModal')">&times;</span>
</div>
<form id="editUserForm">
<input type="hidden" id="editUserId" name="user_id">
<div class="form-group">
<label for="editUsername">Username:</label>
<input type="text" id="editUsername" name="username" required>
</div>
<div class="form-group">
<label for="editEmail">Email:</label>
<input type="email" id="editEmail" name="email" required>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="editIsActive" name="is_active">
<label for="editIsActive">Active</label>
</div>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="editIsAdmin" name="is_admin">
<label for="editIsAdmin">Administrator</label>
</div>
</div>
<div style="text-align: right; margin-top: 2rem;">
<button type="button" class="btn btn-secondary" onclick="closeModal('editUserModal')">Cancel</button>
<button type="submit" class="btn btn-success">Update User</button>
</div>
</form>
</div>
</div>
<!-- Reset Password Modal -->
<div id="resetPasswordModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Reset User Password</h2>
<span class="close" onclick="closeModal('resetPasswordModal')">&times;</span>
</div>
<form id="resetPasswordForm">
<input type="hidden" id="resetUserId" name="user_id">
<div class="form-group">
<label for="newPassword">New Password:</label>
<input type="password" id="newPassword" name="new_password" required>
</div>
<div class="form-group">
<label for="newPassword2">Confirm New Password:</label>
<input type="password" id="newPassword2" name="new_password2" required>
</div>
<div style="text-align: right; margin-top: 2rem;">
<button type="button" class="btn btn-secondary" onclick="closeModal('resetPasswordModal')">Cancel</button>
<button type="submit" class="btn btn-warning">Reset Password</button>
</div>
</form>
</div>
</div>
<script>
// Load registration settings on page load
document.addEventListener('DOMContentLoaded', function() {
loadRegistrationSettings();
});
// Registration toggle functionality
function loadRegistrationSettings() {
fetch('/admin/settings/registration')
.then(response => response.json())
.then(data => {
const toggle = document.getElementById('registrationToggle');
const status = document.getElementById('registrationStatus');
toggle.checked = !data.registration_disabled;
status.textContent = data.registration_disabled ? 'Registration Disabled' : 'Registration Enabled';
toggle.addEventListener('change', function() {
toggleRegistration(this.checked);
});
})
.catch(error => {
console.error('Error loading registration settings:', error);
document.getElementById('registrationStatus').textContent = 'Error loading settings';
});
}
function toggleRegistration(enabled) {
const data = { registration_disabled: !enabled };
fetch('/admin/settings/registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.error) {
showAlert(data.error, 'error');
} else {
const status = document.getElementById('registrationStatus');
status.textContent = data.registration_disabled ? 'Registration Disabled' : 'Registration Enabled';
showAlert(data.message, 'success');
}
})
.catch(error => {
console.error('Error updating registration settings:', error);
showAlert('Failed to update registration settings', 'error');
});
}
// Modal functions
function showCreateUserModal() {
document.getElementById('createUserModal').style.display = 'block';
}
function closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
}
// User management functions
function editUser(userId) {
// Get user data from table row
const row = document.querySelector(`tr[data-user-id="${userId}"]`);
const cells = row.querySelectorAll('td');
document.getElementById('editUserId').value = userId;
document.getElementById('editUsername').value = cells[0].textContent;
document.getElementById('editEmail').value = cells[1].textContent;
document.getElementById('editIsActive').checked = cells[2].textContent.trim() === 'Active';
document.getElementById('editIsAdmin').checked = cells[3].textContent.includes('Admin');
document.getElementById('editUserModal').style.display = 'block';
}
function resetPassword(userId) {
document.getElementById('resetUserId').value = userId;
document.getElementById('resetPasswordModal').style.display = 'block';
}
function toggleUserStatus(userId, activate) {
const action = activate ? 'activate' : 'suspend';
if (confirm(`Are you sure you want to ${action} this user?`)) {
const data = { is_active: activate };
fetch(`/admin/users/${userId}/edit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.error) {
showAlert(data.error, 'error');
} else {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1500);
}
})
.catch(error => {
console.error('Error updating user status:', error);
showAlert('Failed to update user status', 'error');
});
}
}
function deleteUser(userId) {
if (confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
fetch(`/admin/users/${userId}/delete`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.error) {
showAlert(data.error, 'error');
} else {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1500);
}
})
.catch(error => {
console.error('Error deleting user:', error);
showAlert('Failed to delete user', 'error');
});
}
}
// Form submissions
document.getElementById('createUserForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = {
username: formData.get('username'),
email: formData.get('email'),
password: formData.get('password'),
is_active: formData.get('is_active') === 'on',
is_admin: formData.get('is_admin') === 'on'
};
// Validate password confirmation
if (data.password !== formData.get('password2')) {
showAlert('Passwords do not match', 'error');
return;
}
fetch('/admin/users/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.error) {
showAlert(data.error, 'error');
} else {
showAlert(data.message, 'success');
closeModal('createUserModal');
setTimeout(() => location.reload(), 1500);
}
})
.catch(error => {
console.error('Error creating user:', error);
showAlert('Failed to create user', 'error');
});
});
document.getElementById('editUserForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const userId = formData.get('user_id');
const data = {
username: formData.get('username'),
email: formData.get('email'),
is_active: formData.get('is_active') === 'on',
is_admin: formData.get('is_admin') === 'on'
};
fetch(`/admin/users/${userId}/edit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.error) {
showAlert(data.error, 'error');
} else {
showAlert(data.message, 'success');
closeModal('editUserModal');
setTimeout(() => location.reload(), 1500);
}
})
.catch(error => {
console.error('Error updating user:', error);
showAlert('Failed to update user', 'error');
});
});
document.getElementById('resetPasswordForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const userId = formData.get('user_id');
const newPassword = formData.get('new_password');
const newPassword2 = formData.get('new_password2');
// Validate password confirmation
if (newPassword !== newPassword2) {
showAlert('Passwords do not match', 'error');
return;
}
const data = { new_password: newPassword };
fetch(`/admin/users/${userId}/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.error) {
showAlert(data.error, 'error');
} else {
showAlert(data.message, 'success');
closeModal('resetPasswordModal');
}
})
.catch(error => {
console.error('Error resetting password:', error);
showAlert('Failed to reset password', 'error');
});
});
// Utility functions
function showAlert(message, type) {
const alert = document.getElementById('alert');
alert.textContent = message;
alert.className = `alert alert-${type}`;
alert.style.display = 'block';
setTimeout(() => {
alert.style.display = 'none';
}, 5000);
}
function filterUsers() {
const search = document.getElementById('searchInput').value.toLowerCase();
const status = document.getElementById('statusFilter').value;
const rows = document.querySelectorAll('#usersTable tbody tr');
rows.forEach(row => {
const username = row.cells[0].textContent.toLowerCase();
const email = row.cells[1].textContent.toLowerCase();
const userStatus = row.cells[2].textContent.trim().toLowerCase();
const isAdmin = row.cells[3].textContent.includes('Admin');
let showRow = true;
// Search filter
if (search && !username.includes(search) && !email.includes(search)) {
showRow = false;
}
// Status filter
if (status) {
if (status === 'active' && userStatus !== 'active') showRow = false;
if (status === 'inactive' && userStatus !== 'inactive') showRow = false;
if (status === 'admin' && !isAdmin) showRow = false;
}
row.style.display = showRow ? '' : 'none';
});
}
// Close modals when clicking outside
window.onclick = function(event) {
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
if (event.target === modal) {
modal.style.display = 'none';
}
});
}
</script>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
}
.welcome {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #007bff;
margin-bottom: 0.5rem;
}
.stat-label {
color: #666;
font-size: 0.9rem;
}
.recent-section {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.section-title {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 1rem;
color: #333;
}
.item-list {
list-style: none;
padding: 0;
margin: 0;
}
.item-list li {
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.item-list li:last-child {
border-bottom: none;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.btn {
display: inline-block;
padding: 8px 16px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 0.9rem;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="welcome">
<h1>Welcome back, {{ current_user.username }}!</h1>
<p>Here's an overview of your fixture management activity.</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number">{{ user_matches or 0 }}</div>
<div class="stat-label">Your Matches</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ user_uploads or 0 }}</div>
<div class="stat-label">Your Uploads</div>
</div>
{% if current_user.is_admin and system_stats %}
<div class="stat-card">
<div class="stat-number">{{ system_stats.get('total_matches', 0) }}</div>
<div class="stat-label">Total System Matches</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ system_stats.get('total_users', 0) }}</div>
<div class="stat-label">Total Users</div>
</div>
{% endif %}
</div>
{% if recent_fixtures %}
<div class="recent-section">
<div class="section-title">Recent Fixtures</div>
<ul class="item-list">
{% for fixture in recent_fixtures %}
<li>
<strong>{{ fixture.filename }}</strong>:
{{ fixture.match_count }} matches
<span style="float: right;">
<a href="{{ url_for('main.fixture_detail', fixture_id=fixture.fixture_id) }}" class="btn">View</a>
</span>
</li>
{% endfor %}
</ul>
<div style="margin-top: 1rem;">
<a href="{{ url_for('main.fixtures') }}" class="btn">View All Fixtures</a>
</div>
</div>
{% endif %}
<div class="recent-section">
<div class="section-title">Quick Actions</div>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<a href="{{ url_for('upload.upload_fixture') }}" class="btn">Upload Fixture File</a>
<a href="{{ url_for('main.fixtures') }}" class="btn">Browse Fixtures</a>
<a href="{{ url_for('main.statistics') }}" class="btn">View Statistics</a>
</div>
</div>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fixture Details - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
}
.content {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.btn {
padding: 8px 16px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
display: inline-block;
margin: 0.5rem 0;
border: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn:hover {
background-color: #0056b3;
}
.btn-danger {
background-color: #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-warning {
background-color: #ffc107;
color: #212529;
}
.btn-warning:hover {
background-color: #e0a800;
}
.btn-success {
background-color: #28a745;
}
.btn-success:hover {
background-color: #218838;
}
.btn-sm {
padding: 4px 8px;
font-size: 0.8rem;
margin: 0 2px;
}
.upload-form {
display: inline-block;
margin: 0 2px;
}
.upload-form input[type="file"] {
display: none;
}
.upload-label {
padding: 4px 8px;
font-size: 0.8rem;
background-color: #28a745;
color: white;
border-radius: 4px;
cursor: pointer;
display: inline-block;
margin: 0;
}
.upload-label:hover {
background-color: #218838;
}
.zip-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
}
.progress-container {
width: 200px;
margin: 2px;
display: none;
}
.progress-bar {
width: 100%;
height: 20px;
background-color: #f0f0f0;
border-radius: 10px;
overflow: hidden;
border: 1px solid #ddd;
}
.progress-fill {
height: 100%;
background-color: #28a745;
width: 0%;
transition: width 0.3s ease;
border-radius: 10px;
}
.progress-text {
font-size: 0.7rem;
text-align: center;
line-height: 20px;
color: white;
font-weight: bold;
text-shadow: 1px 1px 1px rgba(0,0,0,0.5);
}
.upload-status {
font-size: 0.7rem;
margin: 2px;
padding: 2px 4px;
border-radius: 3px;
display: none;
}
.upload-status.uploading {
background-color: #ffc107;
color: #212529;
}
.upload-status.success {
background-color: #28a745;
color: white;
}
.upload-status.error {
background-color: #dc3545;
color: white;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.fixture-info {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background-color: white;
border-radius: 6px;
border-left: 4px solid #007bff;
}
.info-label {
font-size: 1rem;
color: #666;
font-weight: 500;
margin: 0;
min-width: 120px;
}
.info-value {
font-size: 1.1rem;
font-weight: bold;
color: #333;
text-align: right;
word-break: break-all;
max-width: 70%;
}
.actions-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
.status-active {
color: #28a745;
font-weight: bold;
}
.status-inactive {
color: #dc3545;
font-weight: bold;
}
.match-row:hover {
background-color: #f8f9fa;
}
.section {
margin-bottom: 2rem;
}
.section h2 {
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="content">
<div class="actions-bar">
<div>
<h1>Fixture Details</h1>
<a href="{{ url_for('main.fixtures') }}" class="btn">← Back to Fixtures</a>
</div>
<div>
<form method="POST" action="{{ url_for('main.delete_fixture', fixture_id=fixture_info.fixture_id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this entire fixture and all {{ fixture_info.total_matches }} matches? This action cannot be undone.');">
<button type="submit" class="btn btn-danger">Delete Entire Fixture</button>
</form>
</div>
</div>
<!-- Fixture Information -->
<div class="fixture-info">
<div class="info-item">
<div class="info-label">Fixture ID</div>
<div class="info-value">{{ fixture_info.fixture_id[:8] }}...</div>
</div>
<div class="info-item">
<div class="info-label">Filename</div>
<div class="info-value">{{ fixture_info.filename }}</div>
</div>
<div class="info-item">
<div class="info-label">Upload Date</div>
<div class="info-value">{{ fixture_info.upload_date.strftime('%Y-%m-%d %H:%M') if fixture_info.upload_date else 'N/A' }}</div>
</div>
<div class="info-item">
<div class="info-label">Total Matches</div>
<div class="info-value">{{ fixture_info.total_matches }}</div>
</div>
<div class="info-item">
<div class="info-label">Active Matches</div>
<div class="info-value">{{ fixture_info.active_matches }}</div>
</div>
<div class="info-item">
<div class="info-label">Status</div>
<div class="info-value">
{% if fixture_info.active_matches == fixture_info.total_matches %}
<span class="status-active">All Active</span>
{% elif fixture_info.active_matches > 0 %}
<span style="color: #ffc107; font-weight: bold;">Partial</span>
{% else %}
<span class="status-inactive">Inactive</span>
{% endif %}
</div>
</div>
</div>
<!-- Matches Section -->
<div class="section">
<h2>Matches in this Fixture</h2>
{% if matches %}
<table>
<thead>
<tr>
<th>Match #</th>
<th>Fighter 1</th>
<th>Fighter 2</th>
<th>Venue</th>
<th>Status</th>
<th>ZIP Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for match in matches %}
<tr class="match-row">
<td>{{ match.match_number }}</td>
<td>{{ match.fighter1_township }}</td>
<td>{{ match.fighter2_township }}</td>
<td>{{ match.venue_kampala_township }}</td>
<td>
{% if match.active_status %}
<span class="status-active">Active</span>
{% else %}
<span class="status-inactive">Inactive</span>
{% endif %}
</td>
<td>
{% if match.zip_upload_status == 'completed' %}
<span class="status-active">Completed</span>
{% elif match.zip_upload_status == 'pending' %}
<span style="color: #ffc107;">Pending</span>
{% elif match.zip_upload_status == 'failed' %}
<span class="status-inactive">Failed</span>
{% else %}
<span style="color: #6c757d;">{{ match.zip_upload_status.title() }}</span>
{% endif %}
</td>
<td>
<div class="zip-actions" id="zip_actions_{{ match.id }}">
<a href="{{ url_for('main.match_detail', id=match.id) }}?fixture_id={{ fixture_info.fixture_id }}" class="btn btn-sm">View Details</a>
{% if match.zip_upload_status == 'completed' %}
<!-- ZIP file exists - show replace and delete options -->
<form class="upload-form" id="upload_form_{{ match.id }}" method="POST" action="{{ url_for('upload.upload_zip') }}" enctype="multipart/form-data">
<input type="hidden" name="match_id" value="{{ match.id }}">
<input type="file" id="zip_file_{{ match.id }}" name="zip_file" accept=".zip" onchange="startUpload({{ match.id }})">
<label for="zip_file_{{ match.id }}" class="upload-label btn-warning" title="Replace existing ZIP file">Replace ZIP</label>
</form>
<form class="upload-form" method="POST" action="{{ url_for('upload.delete_zip', match_id=match.id) }}" style="display: inline;">
<button type="submit" class="btn btn-danger btn-sm" title="Delete ZIP file" onclick="return confirm('Are you sure you want to delete the ZIP file for Match #{{ match.match_number }}?')">Delete ZIP</button>
</form>
{% else %}
<!-- No ZIP file or failed - show upload option -->
<form class="upload-form" id="upload_form_{{ match.id }}" method="POST" action="{{ url_for('upload.upload_zip') }}" enctype="multipart/form-data">
<input type="hidden" name="match_id" value="{{ match.id }}">
<input type="file" id="zip_file_{{ match.id }}" name="zip_file" accept=".zip" onchange="startUpload({{ match.id }})">
<label for="zip_file_{{ match.id }}" class="upload-label" title="Upload ZIP file for this match">Upload ZIP</label>
</form>
{% endif %}
<!-- Progress bar (hidden by default) -->
<div class="progress-container" id="progress_{{ match.id }}">
<div class="progress-bar">
<div class="progress-fill" id="progress_fill_{{ match.id }}">
<div class="progress-text" id="progress_text_{{ match.id }}">0%</div>
</div>
</div>
</div>
<!-- Upload status (hidden by default) -->
<div class="upload-status" id="status_{{ match.id }}"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No matches found in this fixture.</p>
{% endif %}
</div>
<!-- Associated Uploads Section -->
{% if uploads %}
<div class="section">
<h2>Associated File Uploads</h2>
<table>
<thead>
<tr>
<th>Filename</th>
<th>File Type</th>
<th>Size</th>
<th>Status</th>
<th>Upload Date</th>
</tr>
</thead>
<tbody>
{% for upload in uploads %}
<tr>
<td>{{ upload.original_filename }}</td>
<td>{{ upload.file_type.upper() }}</td>
<td>{{ "%.1f"|format(upload.file_size / 1024 / 1024) }} MB</td>
<td>
{% if upload.upload_status == 'completed' %}
<span class="status-active">Completed</span>
{% elif upload.upload_status == 'failed' %}
<span class="status-inactive">Failed</span>
{% else %}
<span style="color: #ffc107;">{{ upload.upload_status.title() }}</span>
{% endif %}
</td>
<td>{{ upload.created_at.strftime('%Y-%m-%d %H:%M') if upload.created_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Quick Actions -->
<div class="section">
<h2>Quick Actions</h2>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<a href="{{ url_for('upload.upload_fixture') }}" class="btn">Upload New Fixture</a>
<a href="{{ url_for('main.statistics') }}" class="btn">View Statistics</a>
<a href="{{ url_for('main.fixtures') }}" class="btn" style="background-color: #6c757d;">Back to All Fixtures</a>
</div>
</div>
</div>
</div>
<script>
let activeUploads = new Map();
function startUpload(matchId) {
const fileInput = document.getElementById(`zip_file_${matchId}`);
const file = fileInput.files[0];
if (!file) {
return;
}
// Show progress bar and hide upload form
const uploadForm = document.getElementById(`upload_form_${matchId}`);
const progressContainer = document.getElementById(`progress_${matchId}`);
const statusDiv = document.getElementById(`status_${matchId}`);
uploadForm.style.display = 'none';
progressContainer.style.display = 'block';
statusDiv.style.display = 'block';
statusDiv.className = 'upload-status uploading';
statusDiv.textContent = 'Uploading...';
// Create FormData
const formData = new FormData();
formData.append('zip_file', file);
formData.append('match_id', matchId);
// Create XMLHttpRequest for progress tracking
const xhr = new XMLHttpRequest();
activeUploads.set(matchId, xhr);
// Progress event handler
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
updateProgress(matchId, percentComplete);
}
});
// Load event handler (upload complete)
xhr.addEventListener('load', function() {
if (xhr.status === 200) {
// Success
updateProgress(matchId, 100);
statusDiv.className = 'upload-status success';
statusDiv.textContent = 'Upload successful!';
// Reload page after a short delay to show updated status
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
// Error
handleUploadError(matchId, 'Upload failed');
}
activeUploads.delete(matchId);
});
// Error event handler
xhr.addEventListener('error', function() {
handleUploadError(matchId, 'Network error');
activeUploads.delete(matchId);
});
// Abort event handler
xhr.addEventListener('abort', function() {
handleUploadError(matchId, 'Upload cancelled');
activeUploads.delete(matchId);
});
// Start upload
xhr.open('POST', '{{ url_for("upload.upload_zip") }}', true);
xhr.send(formData);
}
function updateProgress(matchId, percent) {
const progressFill = document.getElementById(`progress_fill_${matchId}`);
const progressText = document.getElementById(`progress_text_${matchId}`);
progressFill.style.width = percent + '%';
progressText.textContent = percent + '%';
// Change color based on progress
if (percent < 50) {
progressFill.style.backgroundColor = '#ffc107'; // Yellow
} else if (percent < 100) {
progressFill.style.backgroundColor = '#17a2b8'; // Blue
} else {
progressFill.style.backgroundColor = '#28a745'; // Green
}
}
function handleUploadError(matchId, errorMessage) {
const uploadForm = document.getElementById(`upload_form_${matchId}`);
const progressContainer = document.getElementById(`progress_${matchId}`);
const statusDiv = document.getElementById(`status_${matchId}`);
// Show error status
statusDiv.className = 'upload-status error';
statusDiv.textContent = errorMessage;
// Hide progress bar and show upload form again after delay
setTimeout(() => {
progressContainer.style.display = 'none';
statusDiv.style.display = 'none';
uploadForm.style.display = 'inline-block';
// Reset file input
const fileInput = document.getElementById(`zip_file_${matchId}`);
fileInput.value = '';
}, 3000);
}
// Cancel upload function (if needed)
function cancelUpload(matchId) {
const xhr = activeUploads.get(matchId);
if (xhr) {
xhr.abort();
}
}
// Prevent form submission on file change (we handle it with AJAX)
document.addEventListener('DOMContentLoaded', function() {
const uploadForms = document.querySelectorAll('.upload-form');
uploadForms.forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
return false;
});
});
});
</script>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fixture Manager - Welcome</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
max-width: 500px;
width: 90%;
}
.logo {
font-size: 2.5rem;
color: #333;
margin-bottom: 1rem;
font-weight: bold;
}
.subtitle {
color: #666;
margin-bottom: 2rem;
font-size: 1.1rem;
}
.btn {
display: inline-block;
padding: 12px 24px;
margin: 0.5rem;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.features {
margin-top: 2rem;
text-align: left;
}
.feature {
margin: 1rem 0;
padding: 0.5rem;
}
.feature-icon {
color: #007bff;
margin-right: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🥊 Fixture Manager</div>
<div class="subtitle">Manage your football fixtures and match data efficiently</div>
<div>
<a href="{{ url_for('auth.login') }}" class="btn btn-primary">Login</a>
<a href="{{ url_for('auth.register') }}" class="btn btn-secondary">Register</a>
</div>
<div class="features">
<div class="feature">
<span class="feature-icon">📊</span>
<strong>Match Management:</strong> Upload and manage fixture data
</div>
<div class="feature">
<span class="feature-icon">📁</span>
<strong>File Uploads:</strong> Support for CSV and Excel files
</div>
<div class="feature">
<span class="feature-icon">📈</span>
<strong>Statistics:</strong> Track match outcomes and performance
</div>
<div class="feature">
<span class="feature-icon">🔒</span>
<strong>Secure:</strong> User authentication and data protection
</div>
</div>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Match Detail - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
}
.content {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.btn {
padding: 8px 16px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
display: inline-block;
margin: 0.5rem 0;
border: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn:hover {
background-color: #0056b3;
}
.btn-danger {
background-color: #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-warning {
background-color: #ffc107;
color: #212529;
}
.btn-warning:hover {
background-color: #e0a800;
}
.btn-success {
background-color: #28a745;
}
.btn-success:hover {
background-color: #218838;
}
.btn-secondary {
background-color: #6c757d;
}
.btn-secondary:hover {
background-color: #545b62;
}
.btn-sm {
padding: 4px 8px;
font-size: 0.8rem;
margin: 0 2px;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.actions-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
.match-info {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background-color: white;
border-radius: 6px;
border-left: 4px solid #007bff;
}
.info-label {
font-size: 1rem;
color: #666;
font-weight: 500;
margin: 0;
min-width: 120px;
}
.info-value {
font-size: 1.1rem;
font-weight: bold;
color: #333;
text-align: right;
word-break: break-all;
max-width: 70%;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-active {
background-color: #d4edda;
color: #155724;
}
.status-inactive {
background-color: #f8d7da;
color: #721c24;
}
.section {
margin-bottom: 2rem;
}
.section h2 {
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.outcomes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.outcome-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
transition: all 0.3s ease;
}
.outcome-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-color: #007bff;
}
.outcome-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.outcome-name {
font-weight: 600;
color: #495057;
font-size: 0.95rem;
}
.outcome-input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.outcome-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
}
.no-outcomes {
text-align: center;
padding: 3rem;
color: #6c757d;
}
.no-outcomes-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.add-outcome-section {
background: #f8f9fa;
border-radius: 8px;
padding: 1.5rem;
margin-top: 1.5rem;
}
.add-outcome-form {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 1rem;
align-items: end;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 600;
color: #495057;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.form-input {
padding: 0.5rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 1rem;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
}
.loading {
display: none;
text-align: center;
padding: 1rem;
}
.spinner {
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
display: inline-block;
margin-right: 0.5rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.zip-section {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.zip-actions {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.upload-form {
display: inline-block;
}
.upload-form input[type="file"] {
display: none;
}
.upload-label {
padding: 8px 16px;
font-size: 0.9rem;
background-color: #28a745;
color: white;
border-radius: 4px;
cursor: pointer;
display: inline-block;
margin: 0;
}
.upload-label:hover {
background-color: #218838;
}
.progress-container {
width: 300px;
margin: 1rem 0;
display: none;
}
.progress-bar {
width: 100%;
height: 25px;
background-color: #f0f0f0;
border-radius: 12px;
overflow: hidden;
border: 1px solid #ddd;
}
.progress-fill {
height: 100%;
background-color: #28a745;
width: 0%;
transition: width 0.3s ease;
border-radius: 12px;
}
.progress-text {
font-size: 0.8rem;
text-align: center;
line-height: 25px;
color: white;
font-weight: bold;
text-shadow: 1px 1px 1px rgba(0,0,0,0.5);
}
.upload-status {
font-size: 0.9rem;
margin: 0.5rem 0;
padding: 0.5rem;
border-radius: 4px;
display: none;
}
.upload-status.uploading {
background-color: #ffc107;
color: #212529;
}
.upload-status.success {
background-color: #28a745;
color: white;
}
.upload-status.error {
background-color: #dc3545;
color: white;
}
@media (max-width: 768px) {
.outcomes-grid {
grid-template-columns: 1fr;
}
.add-outcome-form {
grid-template-columns: 1fr;
}
.zip-actions {
flex-direction: column;
align-items: stretch;
}
.progress-container {
width: 100%;
}
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="content">
{% if match %}
<div class="actions-bar">
<div>
<h1>Match #{{ match.match_number }}</h1>
<p style="margin: 0; color: #666;">{{ match.fighter1_township }} vs {{ match.fighter2_township }}</p>
</div>
<div>
<a href="{{ url_for('main.fixture_detail', fixture_id=match.fixture_id) }}" class="btn btn-secondary">← Back to Fixture</a>
<a href="{{ url_for('main.fixtures') }}" class="btn">View All Fixtures</a>
</div>
</div>
<!-- Match Information -->
<div class="match-info">
<div class="info-item">
<div class="info-label">Match Number</div>
<div class="info-value">#{{ match.match_number }}</div>
</div>
<div class="info-item">
<div class="info-label">Fighter 1</div>
<div class="info-value">{{ match.fighter1_township }}</div>
</div>
<div class="info-item">
<div class="info-label">Fighter 2</div>
<div class="info-value">{{ match.fighter2_township }}</div>
</div>
<div class="info-item">
<div class="info-label">Venue</div>
<div class="info-value">{{ match.venue_kampala_township }}</div>
</div>
<div class="info-item">
<div class="info-label">Status</div>
<div class="info-value">
<span class="status-badge {{ 'status-active' if match.active_status else 'status-inactive' }}">
{{ 'Active' if match.active_status else 'Inactive' }}
</span>
</div>
</div>
<div class="info-item">
<div class="info-label">Created</div>
<div class="info-value">{{ match.created_at.strftime('%Y-%m-%d %H:%M') if match.created_at else 'N/A' }}</div>
</div>
<div class="info-item">
<div class="info-label">Fixture ID</div>
<div class="info-value" style="font-family: monospace; font-size: 0.9rem;">{{ match.fixture_id[:8] }}...</div>
</div>
</div>
<!-- ZIP File Management Section -->
<div class="section">
<h2>ZIP File Management</h2>
<div class="zip-section">
<div class="zip-actions" id="zip_actions_{{ match.id }}">
{% if match.zip_upload_status == 'completed' %}
<div style="display: flex; align-items: center; gap: 1rem;">
<span class="status-badge status-active">ZIP File Uploaded</span>
<span style="font-size: 0.9rem; color: #666;">{{ match.zip_filename or 'Unknown filename' }}</span>
</div>
<!-- Replace ZIP option -->
<form class="upload-form" id="upload_form_{{ match.id }}" method="POST" action="{{ url_for('upload.upload_zip') }}" enctype="multipart/form-data">
<input type="hidden" name="match_id" value="{{ match.id }}">
<input type="file" id="zip_file_{{ match.id }}" name="zip_file" accept=".zip" data-match-id="{{ match.id }}">
<label for="zip_file_{{ match.id }}" class="upload-label btn-warning" title="Replace existing ZIP file">Replace ZIP</label>
</form>
<!-- Delete ZIP option -->
<form class="upload-form" method="POST" action="{{ url_for('upload.delete_zip', match_id=match.id) }}" style="display: inline;">
<button type="submit" class="btn btn-danger" title="Delete ZIP file" onclick="return confirm('Are you sure you want to delete the ZIP file for Match #{{ match.match_number }}?')">Delete ZIP</button>
</form>
{% else %}
<div style="display: flex; align-items: center; gap: 1rem;">
<span class="status-badge status-inactive">No ZIP File</span>
{% if match.zip_upload_status == 'failed' %}
<span style="font-size: 0.9rem; color: #dc3545;">Upload failed</span>
{% elif match.zip_upload_status == 'pending' %}
<span style="font-size: 0.9rem; color: #ffc107;">Upload pending</span>
{% endif %}
</div>
<!-- Upload ZIP option -->
<form class="upload-form" id="upload_form_{{ match.id }}" method="POST" action="{{ url_for('upload.upload_zip') }}" enctype="multipart/form-data">
<input type="hidden" name="match_id" value="{{ match.id }}">
<input type="file" id="zip_file_{{ match.id }}" name="zip_file" accept=".zip" data-match-id="{{ match.id }}">
<label for="zip_file_{{ match.id }}" class="upload-label" title="Upload ZIP file for this match">Upload ZIP</label>
</form>
{% endif %}
</div>
<!-- Progress bar (hidden by default) -->
<div class="progress-container" id="progress_{{ match.id }}">
<div class="progress-bar">
<div class="progress-fill" id="progress_fill_{{ match.id }}">
<div class="progress-text" id="progress_text_{{ match.id }}">0%</div>
</div>
</div>
</div>
<!-- Upload status (hidden by default) -->
<div class="upload-status" id="status_{{ match.id }}"></div>
</div>
</div>
<!-- Outcomes Section -->
<div class="section">
<h2>Match Outcomes</h2>
<!-- Alert Messages -->
<div id="alert-container"></div>
<!-- Loading Indicator -->
<div id="loading" class="loading">
<div class="spinner"></div>
Updating outcomes...
</div>
{% if outcomes %}
<div class="outcomes-grid" id="outcomes-grid">
{% for outcome in outcomes %}
<div class="outcome-card" data-outcome-id="{{ outcome.id }}">
<div class="outcome-header">
<div class="outcome-name">{{ outcome.column_name }}</div>
<div class="outcome-actions">
<button class="btn btn-danger btn-sm delete-outcome-btn"
data-outcome-id="{{ outcome.id }}"
data-column-name="{{ outcome.column_name }}">
Delete
</button>
</div>
</div>
<input type="number"
class="outcome-input"
value="{{ outcome.float_value }}"
data-column-name="{{ outcome.column_name }}"
data-original-value="{{ outcome.float_value }}"
step="0.01"
onchange="markOutcomeChanged(this)"
placeholder="Enter numeric value">
</div>
{% endfor %}
</div>
<div style="text-align: center; margin-top: 1.5rem;">
<button class="btn btn-success" onclick="saveAllChanges()" id="save-button" style="display: none;">
Save All Changes
</button>
</div>
{% else %}
<div class="no-outcomes">
<div class="no-outcomes-icon">📊</div>
<h4>No Outcomes Available</h4>
<p>This match doesn't have any outcomes recorded yet. Add some outcomes below to get started.</p>
</div>
{% endif %}
<!-- Add New Outcome -->
<div class="add-outcome-section">
<h4 style="margin-top: 0; color: #333;">Add New Outcome</h4>
<div class="add-outcome-form">
<div class="form-group">
<label class="form-label">Outcome Name</label>
<input type="text" class="form-input" id="new-outcome-name" placeholder="e.g., Score, Rating, Points">
</div>
<div class="form-group">
<label class="form-label">Value</label>
<input type="number" class="form-input" id="new-outcome-value" step="0.01" placeholder="0.00">
</div>
<button class="btn btn-success" onclick="addNewOutcome()">Add Outcome</button>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<script>
let changedOutcomes = new Set();
let activeUploads = new Map();
function markOutcomeChanged(input) {
const originalValue = parseFloat(input.dataset.originalValue);
const currentValue = parseFloat(input.value);
const columnName = input.dataset.columnName;
if (originalValue !== currentValue) {
changedOutcomes.add(columnName);
input.style.borderColor = '#ffc107';
input.style.backgroundColor = '#fff3cd';
} else {
changedOutcomes.delete(columnName);
input.style.borderColor = '#ced4da';
input.style.backgroundColor = 'white';
}
// Show/hide save button
const saveButton = document.getElementById('save-button');
if (changedOutcomes.size > 0) {
saveButton.style.display = 'inline-block';
} else {
saveButton.style.display = 'none';
}
}
function saveAllChanges() {
const outcomes = {};
const inputs = document.querySelectorAll('.outcome-input');
inputs.forEach(input => {
const columnName = input.dataset.columnName;
const value = input.value;
if (changedOutcomes.has(columnName)) {
outcomes[columnName] = value;
}
});
if (Object.keys(outcomes).length === 0) {
showAlert('No changes to save.', 'info');
return;
}
showLoading(true);
fetch(`/match/{{ match.id }}/outcomes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ outcomes: outcomes })
})
.then(response => response.json())
.then(data => {
showLoading(false);
if (data.error) {
showAlert(data.error, 'danger');
} else {
showAlert(data.message, 'success');
// Update original values and clear changes
inputs.forEach(input => {
const columnName = input.dataset.columnName;
if (changedOutcomes.has(columnName)) {
input.dataset.originalValue = input.value;
input.style.borderColor = '#ced4da';
input.style.backgroundColor = 'white';
}
});
changedOutcomes.clear();
document.getElementById('save-button').style.display = 'none';
}
})
.catch(error => {
showLoading(false);
showAlert('Failed to save changes: ' + error.message, 'danger');
});
}
function deleteOutcome(outcomeId, columnName) {
if (!confirm(`Are you sure you want to delete the outcome "${columnName}"?`)) {
return;
}
showLoading(true);
fetch(`/match/{{ match.id }}/outcomes/${outcomeId}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
showLoading(false);
if (data.error) {
showAlert(data.error, 'danger');
} else {
showAlert(data.message, 'success');
// Remove the outcome card from the DOM
const outcomeCard = document.querySelector(`[data-outcome-id="${outcomeId}"]`);
if (outcomeCard) {
outcomeCard.remove();
}
// Check if no outcomes left
const remainingOutcomes = document.querySelectorAll('.outcome-card');
if (remainingOutcomes.length === 0) {
location.reload(); // Reload to show "no outcomes" message
}
}
})
.catch(error => {
showLoading(false);
showAlert('Failed to delete outcome: ' + error.message, 'danger');
});
}
function addNewOutcome() {
const nameInput = document.getElementById('new-outcome-name');
const valueInput = document.getElementById('new-outcome-value');
const name = nameInput.value.trim();
const value = valueInput.value.trim();
if (!name || !value) {
showAlert('Please enter both outcome name and value.', 'danger');
return;
}
if (isNaN(parseFloat(value))) {
showAlert('Please enter a valid numeric value.', 'danger');
return;
}
const outcomes = {};
outcomes[name] = value;
showLoading(true);
fetch(`/match/{{ match.id }}/outcomes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ outcomes: outcomes })
})
.then(response => response.json())
.then(data => {
showLoading(false);
if (data.error) {
showAlert(data.error, 'danger');
} else {
showAlert(data.message, 'success');
// Clear the form
nameInput.value = '';
valueInput.value = '';
// Reload the page to show the new outcome
setTimeout(() => location.reload(), 1000);
}
})
.catch(error => {
showLoading(false);
showAlert('Failed to add outcome: ' + error.message, 'danger');
});
}
function showAlert(message, type) {
const alertContainer = document.getElementById('alert-container');
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type}`;
alertDiv.textContent = message;
alertContainer.innerHTML = '';
alertContainer.appendChild(alertDiv);
// Auto-hide success messages
if (type === 'success') {
setTimeout(() => {
alertDiv.remove();
}, 3000);
}
}
function showLoading(show) {
const loading = document.getElementById('loading');
loading.style.display = show ? 'block' : 'none';
}
// ZIP Upload Functions
function startUpload(matchId) {
const fileInput = document.getElementById(`zip_file_${matchId}`);
const file = fileInput.files[0];
if (!file) {
return;
}
// Show progress bar and hide upload form
const uploadForm = document.getElementById(`upload_form_${matchId}`);
const progressContainer = document.getElementById(`progress_${matchId}`);
const statusDiv = document.getElementById(`status_${matchId}`);
uploadForm.style.display = 'none';
progressContainer.style.display = 'block';
statusDiv.style.display = 'block';
statusDiv.className = 'upload-status uploading';
statusDiv.textContent = 'Uploading...';
// Create FormData
const formData = new FormData();
formData.append('zip_file', file);
formData.append('match_id', matchId);
// Create XMLHttpRequest for progress tracking
const xhr = new XMLHttpRequest();
activeUploads.set(matchId, xhr);
// Progress event handler
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
updateProgress(matchId, percentComplete);
}
});
// Load event handler (upload complete)
xhr.addEventListener('load', function() {
if (xhr.status === 200) {
// Success
updateProgress(matchId, 100);
statusDiv.className = 'upload-status success';
statusDiv.textContent = 'Upload successful!';
// Reload page after a short delay to show updated status
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
// Error
handleUploadError(matchId, 'Upload failed');
}
activeUploads.delete(matchId);
});
// Error event handler
xhr.addEventListener('error', function() {
handleUploadError(matchId, 'Network error');
activeUploads.delete(matchId);
});
// Abort event handler
xhr.addEventListener('abort', function() {
handleUploadError(matchId, 'Upload cancelled');
activeUploads.delete(matchId);
});
// Start upload
xhr.open('POST', '{{ url_for("upload.upload_zip") }}', true);
xhr.send(formData);
}
function updateProgress(matchId, percent) {
const progressFill = document.getElementById(`progress_fill_${matchId}`);
const progressText = document.getElementById(`progress_text_${matchId}`);
progressFill.style.width = percent + '%';
progressText.textContent = percent + '%';
// Change color based on progress
if (percent < 50) {
progressFill.style.backgroundColor = '#ffc107'; // Yellow
} else if (percent < 100) {
progressFill.style.backgroundColor = '#17a2b8'; // Blue
} else {
progressFill.style.backgroundColor = '#28a745'; // Green
}
}
function handleUploadError(matchId, errorMessage) {
const uploadForm = document.getElementById(`upload_form_${matchId}`);
const progressContainer = document.getElementById(`progress_${matchId}`);
const statusDiv = document.getElementById(`status_${matchId}`);
// Show error status
statusDiv.className = 'upload-status error';
statusDiv.textContent = errorMessage;
// Hide progress bar and show upload form again after delay
setTimeout(() => {
progressContainer.style.display = 'none';
statusDiv.style.display = 'none';
uploadForm.style.display = 'inline-block';
// Reset file input
const fileInput = document.getElementById(`zip_file_${matchId}`);
fileInput.value = '';
}, 3000);
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
// Delete outcome buttons
document.querySelectorAll('.delete-outcome-btn').forEach(button => {
button.addEventListener('click', function() {
const outcomeId = this.dataset.outcomeId;
const columnName = this.dataset.columnName;
deleteOutcome(outcomeId, columnName);
});
});
// File input change handlers
document.querySelectorAll('input[type="file"][data-match-id]').forEach(input => {
input.addEventListener('change', function() {
const matchId = this.dataset.matchId;
startUpload(matchId);
});
});
// Prevent form submission on file change (we handle it with AJAX)
const uploadForms = document.querySelectorAll('.upload-form');
uploadForms.forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
return false;
});
});
});
// Handle Enter key in add outcome form
document.getElementById('new-outcome-name').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
document.getElementById('new-outcome-value').focus();
}
});
document.getElementById('new-outcome-value').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
addNewOutcome();
}
});
</script>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fixtures - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
}
.content {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.btn {
padding: 8px 16px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
display: inline-block;
margin: 0.5rem 0;
border: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn:hover {
background-color: #0056b3;
}
.btn-danger {
background-color: #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-sm {
padding: 4px 8px;
font-size: 0.8rem;
margin: 0 2px;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
.search-filters {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.search-filters input, .search-filters select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
.status-active {
color: #28a745;
font-weight: bold;
}
.status-partial {
color: #ffc107;
font-weight: bold;
}
.status-inactive {
color: #dc3545;
font-weight: bold;
}
.fixture-row {
cursor: pointer;
transition: background-color 0.2s;
}
.fixture-row:hover {
background-color: #f8f9fa;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
margin-top: 2rem;
gap: 0.5rem;
}
.pagination a, .pagination span {
padding: 8px 12px;
text-decoration: none;
border: 1px solid #ddd;
border-radius: 4px;
}
.pagination a:hover {
background-color: #f8f9fa;
}
.pagination .current {
background-color: #007bff;
color: white;
border-color: #007bff;
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="content">
<h1>Fixtures</h1>
<div class="controls">
<a href="{{ url_for('main.dashboard') }}" class="btn">← Back to Dashboard</a>
<div class="search-filters">
<form method="GET" style="display: flex; gap: 1rem; align-items: center;">
<input type="text" name="search" placeholder="Search fixtures..."
value="{{ search_query or '' }}" style="width: 200px;">
<select name="status">
<option value="">All Fixtures</option>
<option value="active" {{ 'selected' if status_filter == 'active' }}>Has Active Matches</option>
<option value="inactive" {{ 'selected' if status_filter == 'inactive' }}>No Active Matches</option>
<option value="complete" {{ 'selected' if status_filter == 'complete' }}>All Matches Active</option>
</select>
<button type="submit" class="btn">Filter</button>
{% if search_query or status_filter %}
<a href="{{ url_for('main.fixtures') }}" class="btn" style="background-color: #6c757d;">Clear</a>
{% endif %}
</form>
</div>
</div>
{% if fixtures %}
<table>
<thead>
<tr>
<th>Fixture ID</th>
<th>Filename</th>
<th>Upload Date</th>
<th>Matches</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for fixture in fixtures %}
<tr class="fixture-row" onclick="window.location.href='{{ url_for('main.fixture_detail', fixture_id=fixture.fixture_id) }}'">
<td>{{ fixture.fixture_id[:8] }}...</td>
<td>{{ fixture.filename }}</td>
<td>{{ fixture.upload_date.strftime('%Y-%m-%d %H:%M') if fixture.upload_date else 'N/A' }}</td>
<td>{{ fixture.match_count }} matches</td>
<td>
{% if fixture.active_matches == fixture.match_count %}
<span class="status-active">All Active ({{ fixture.active_matches }}/{{ fixture.match_count }})</span>
{% elif fixture.active_matches > 0 %}
<span class="status-partial">Partial ({{ fixture.active_matches }}/{{ fixture.match_count }})</span>
{% else %}
<span class="status-inactive">Inactive (0/{{ fixture.match_count }})</span>
{% endif %}
</td>
<td onclick="event.stopPropagation();">
<a href="{{ url_for('main.fixture_detail', fixture_id=fixture.fixture_id) }}" class="btn btn-sm">View</a>
<form method="POST" action="{{ url_for('main.delete_fixture', fixture_id=fixture.fixture_id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this fixture and all {{ fixture.match_count }} matches? This action cannot be undone.');">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
{% if pagination and pagination.pages > 1 %}
<div class="pagination">
{% if pagination.has_prev %}
<a href="{{ url_for('main.matches', page=pagination.prev_num, search=search_query, status=status_filter) }}">« Previous</a>
{% endif %}
{% for page_num in range(1, pagination.pages + 1) %}
{% if page_num == pagination.page %}
<span class="current">{{ page_num }}</span>
{% else %}
<a href="{{ url_for('main.matches', page=page_num, search=search_query, status=status_filter) }}">{{ page_num }}</a>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="{{ url_for('main.matches', page=pagination.next_num, search=search_query, status=status_filter) }}">Next »</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div style="text-align: center; padding: 3rem;">
<h3>No fixtures found</h3>
<p>Upload a fixture file to get started.</p>
<a href="{{ url_for('upload.upload_fixture') }}" class="btn">Upload Fixture File</a>
</div>
{% endif %}
</div>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Statistics - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
}
.content {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.btn {
padding: 8px 16px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
display: inline-block;
margin: 0.5rem 0;
}
.btn:hover {
background-color: #0056b3;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.stat-card {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #007bff;
}
.stat-label {
color: #666;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="content">
<h1>Statistics</h1>
<a href="{{ url_for('main.dashboard') }}" class="btn">← Back to Dashboard</a>
<h2>Your Statistics</h2>
<div class="stats-grid">
{% if user_stats %}
<div class="stat-card">
<div class="stat-number">{{ user_stats.get('total_uploads', 0) }}</div>
<div class="stat-label">Total Uploads</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ user_stats.get('total_matches', 0) }}</div>
<div class="stat-label">Total Matches</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ user_stats.get('active_matches', 0) }}</div>
<div class="stat-label">Active Matches</div>
</div>
{% endif %}
</div>
{% if current_user.is_admin and system_stats %}
<h2>System Statistics</h2>
<div class="stats-grid">
{% for key, value in system_stats.items() %}
<div class="stat-card">
<div class="stat-number">{{ value }}</div>
<div class="stat-label">{{ key.replace('_', ' ').title() }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Uploads - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
}
.content {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.btn {
padding: 8px 16px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
display: inline-block;
margin: 0.5rem 0;
}
.btn:hover {
background-color: #0056b3;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="content">
<h1>File Uploads</h1>
<a href="{{ url_for('main.dashboard') }}" class="btn">← Back to Dashboard</a>
<a href="{{ url_for('upload.upload_fixture') }}" class="btn">Upload New File</a>
{% if uploads %}
<table>
<thead>
<tr><th>Filename</th><th>Type</th><th>Status</th><th>Upload Date</th></tr>
</thead>
<tbody>
{% for upload in uploads %}
<tr>
<td>{{ upload.filename }}</td>
<td>{{ upload.file_type }}</td>
<td>{{ upload.upload_status }}</td>
<td>{{ upload.created_at.strftime('%Y-%m-%d %H:%M') if upload.created_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No uploads found.</p>
{% endif %}
</div>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Database Setup - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.step {
background: #f8f9fa;
border-left: 4px solid #007bff;
padding: 15px;
margin: 15px 0;
}
.step h3 {
margin-top: 0;
color: #007bff;
}
.code {
background: #2d3748;
color: #e2e8f0;
padding: 15px;
border-radius: 5px;
font-family: 'Courier New', monospace;
overflow-x: auto;
margin: 10px 0;
}
.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
padding: 15px;
border-radius: 5px;
margin: 15px 0;
}
.btn {
background: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
display: inline-block;
margin: 10px 5px;
}
.btn:hover {
background: #0056b3;
}
.config-example {
background: #f1f3f4;
border: 1px solid #dadce0;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔧 Database Setup Required</h1>
<p>The Fixture Manager needs a MySQL database to function properly.</p>
</div>
<div class="warning">
<strong>⚠️ Database Not Connected</strong><br>
The application cannot connect to the database. Please follow the steps below to set up your database.
</div>
<div class="step">
<h3>Step 1: Install MySQL Server</h3>
<p>If you don't have MySQL installed, install it first:</p>
<div class="code">
# Ubuntu/Debian
sudo apt update
sudo apt install mysql-server
# CentOS/RHEL/Fedora
sudo yum install mysql-server
# or
sudo dnf install mysql-server
# Start MySQL service
sudo systemctl start mysql
sudo systemctl enable mysql
</div>
</div>
<div class="step">
<h3>Step 2: Create Database and User</h3>
<p>Log into MySQL and create the database:</p>
<div class="code">
# Log into MySQL as root
sudo mysql -u root -p
# Create database
CREATE DATABASE fixture_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
# Create user (replace 'your_password' with a secure password)
CREATE USER 'fixture_user'@'localhost' IDENTIFIED BY 'your_password';
# Grant privileges
GRANT ALL PRIVILEGES ON fixture_manager.* TO 'fixture_user'@'localhost';
FLUSH PRIVILEGES;
# Exit MySQL
EXIT;
</div>
</div>
<div class="step">
<h3>Step 3: Configure Environment Variables</h3>
<p>Create a <code>.env</code> file in the application directory with your database settings:</p>
<div class="config-example">
<strong>Example .env file:</strong>
<div class="code">
# Database Configuration
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=fixture_user
MYSQL_PASSWORD=your_password
MYSQL_DATABASE=fixture_manager
# Security
SECRET_KEY=your-secret-key-here
JWT_SECRET_KEY=your-jwt-secret-key-here
# Application Settings
HOST=0.0.0.0
PORT=5000
DEBUG=False
LOG_LEVEL=INFO
# File Upload Settings
UPLOAD_FOLDER=/var/lib/fixture-daemon/uploads
MAX_CONTENT_LENGTH=524288000
# Daemon Settings
DAEMON_PID_FILE=/var/run/fixture-daemon.pid
DAEMON_LOG_FILE=/var/log/fixture-daemon.log
DAEMON_WORKING_DIR=/var/lib/fixture-daemon
</div>
</div>
</div>
<div class="step">
<h3>Step 4: Initialize Database Schema</h3>
<p>The application will automatically create the necessary tables when it connects to the database.</p>
<p>Alternatively, you can manually run the schema file:</p>
<div class="code">
mysql -u fixture_user -p fixture_manager < database/schema.sql
</div>
</div>
<div class="step">
<h3>Step 5: Restart the Application</h3>
<p>After configuring the database, restart the Fixture Manager daemon:</p>
<div class="code">
# Stop the daemon
./fixture-manager stop
# Start the daemon
./fixture-manager start
# Or restart
./fixture-manager restart
</div>
</div>
<div class="success">
<strong>✅ Default Admin Account</strong><br>
Once the database is set up, a default admin account will be created:<br>
<strong>Username:</strong> admin<br>
<strong>Password:</strong> admin123<br>
<em>Please change this password immediately after first login!</em>
</div>
<div style="text-align: center; margin-top: 30px;">
<a href="/" class="btn">🔄 Check Connection</a>
<a href="/health" class="btn">📊 System Health</a>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p><strong>Need Help?</strong></p>
<ul>
<li>Check the application logs: <code>tail -f /var/log/fixture-daemon.log</code></li>
<li>Verify MySQL is running: <code>sudo systemctl status mysql</code></li>
<li>Test database connection: <code>mysql -u fixture_user -p fixture_manager</code></li>
</ul>
</div>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload Fixture - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 2rem;
}
.upload-section {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: bold;
}
input[type="file"], textarea {
width: 100%;
padding: 12px;
border: 2px dashed #ddd;
border-radius: 4px;
background-color: #fafafa;
cursor: pointer;
box-sizing: border-box;
}
textarea {
border: 1px solid #ddd;
cursor: text;
resize: vertical;
}
input[type="file"]:hover {
border-color: #007bff;
}
.btn {
padding: 12px 24px;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #218838;
}
.btn-secondary {
background-color: #6c757d;
margin-left: 1rem;
}
.btn-secondary:hover {
background-color: #545b62;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.info-box {
background-color: #e7f3ff;
border: 1px solid #b3d9ff;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
.info-box h3 {
margin-top: 0;
color: #0066cc;
}
.file-requirements {
font-size: 0.9rem;
color: #666;
}
.file-requirements ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
<h1>Upload Fixture File</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="info-box">
<h3>📋 File Requirements</h3>
<div class="file-requirements">
<p>Please ensure your fixture file meets the following requirements:</p>
<ul>
<li><strong>File Format:</strong> CSV, XLSX, or XLS</li>
<li><strong>Required Columns:</strong>
<ul>
<li>Match Number (match #, match_number, match no, etc.)</li>
<li>Fighter 1 (fighter1, fighter 1, etc.)</li>
<li>Fighter 2 (fighter2, fighter 2, etc.)</li>
<li>Venue (venue, location, kampala township, etc.)</li>
</ul>
</li>
<li><strong>Optional:</strong> Outcome columns with numeric data</li>
<li><strong>Encoding:</strong> UTF-8 recommended for CSV files</li>
</ul>
</div>
</div>
<div class="upload-section">
<form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.fixture_file.label }}
{{ form.fixture_file() }}
<div class="file-requirements">
<small>Supported formats: .csv, .xlsx, .xls (Max size: 10MB)</small>
</div>
</div>
{% if form.description %}
<div class="form-group">
{{ form.description.label }}
{{ form.description(class="form-control", rows="3", placeholder="Optional description for this fixture upload...") }}
</div>
{% endif %}
<div class="form-group">
{{ form.submit(class="btn") }}
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload ZIP - Fixture Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.header {
background-color: #007bff;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.nav {
display: flex;
gap: 1rem;
}
.nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255,255,255,0.1);
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 2rem;
}
.upload-section {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: bold;
}
input[type="file"], textarea {
width: 100%;
padding: 12px;
border: 2px dashed #ddd;
border-radius: 4px;
background-color: #fafafa;
cursor: pointer;
box-sizing: border-box;
}
textarea {
border: 1px solid #ddd;
cursor: text;
resize: vertical;
}
input[type="file"]:hover {
border-color: #007bff;
}
.btn {
padding: 12px 24px;
background-color: #007bff;
color: white;
text-decoration: none;
border: none;
border-radius: 4px;
display: inline-block;
margin: 1rem 0;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
margin-left: 1rem;
}
.btn-secondary:hover {
background-color: #545b62;
}
.alert {
padding: 12px;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.match-info {
background-color: #e7f3ff;
border: 1px solid #b3d9ff;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
.match-info h3 {
margin-top: 0;
color: #0066cc;
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🥊 Fixture Manager</div>
<div class="nav">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
</div>
<div class="container">
<h1>Upload ZIP File</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if match %}
<div class="match-info">
<h3>📁 Match Information</h3>
<p><strong>Match #{{ match.match_number }}:</strong> {{ match.fighter1_township }} vs {{ match.fighter2_township }}</p>
<p><strong>Venue:</strong> {{ match.venue_kampala_township }}</p>
</div>
{% endif %}
<div class="upload-section">
<form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.zip_file.label }}
{{ form.zip_file() }}
<div style="font-size: 0.9rem; color: #666; margin-top: 0.5rem;">
<small>Supported format: .zip (Max size: 50MB)</small>
</div>
</div>
{% if form.description %}
<div class="form-group">
{{ form.description.label }}
{{ form.description(rows="3", placeholder="Optional description for this ZIP upload...") }}
</div>
{% endif %}
<div class="form-group">
{{ form.submit(class="btn") }}
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</body>
</html>
\ No newline at end of file
......@@ -7,7 +7,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import current_app
from werkzeug.utils import secure_filename
from app import db
from app.models import FileUpload, Match
from app.utils.security import sanitize_filename, validate_file_type, validate_file_size, detect_malicious_content
from app.utils.logging import log_file_operation, log_upload_progress
import logging
......@@ -18,12 +17,24 @@ class FileUploadHandler:
"""Handle file uploads with progress tracking and security validation"""
def __init__(self):
self.upload_folder = current_app.config['UPLOAD_FOLDER']
self.upload_folder = None
self.chunk_size = 8192
self.max_concurrent_uploads = 5
self.executor = None
self.active_uploads = {}
self.upload_lock = threading.Lock()
self._initialized = False
def _ensure_initialized(self):
"""Ensure handler is initialized with Flask app config"""
if not self._initialized:
from flask import current_app
self.upload_folder = current_app.config.get('UPLOAD_FOLDER')
self.chunk_size = current_app.config.get('CHUNK_SIZE', 8192)
self.max_concurrent_uploads = current_app.config.get('MAX_CONCURRENT_UPLOADS', 5)
if self.executor is None:
self.executor = ThreadPoolExecutor(max_workers=self.max_concurrent_uploads)
self.active_uploads = {}
self.upload_lock = threading.Lock()
self._initialized = True
def validate_upload(self, file, file_type):
"""
......@@ -150,6 +161,12 @@ class FileUploadHandler:
tuple: (upload_record, error_message)
"""
try:
# Ensure handler is initialized
self._ensure_initialized()
if self.upload_folder is None:
logger.error("Upload folder is None after initialization")
return None, "Upload folder not configured"
# Validate upload
is_valid, error_message = self.validate_upload(file, file_type)
if not is_valid:
......@@ -162,8 +179,16 @@ class FileUploadHandler:
filename = f"{timestamp}_{sanitized_name}"
# Create upload directory if it doesn't exist
if self.upload_folder is None:
logger.error("Upload folder is None - cannot create directory")
return None, "Upload folder not configured"
try:
os.makedirs(self.upload_folder, exist_ok=True)
file_path = os.path.join(self.upload_folder, filename)
except OSError as e:
logger.error(f"Failed to create upload directory {self.upload_folder}: {str(e)}")
return None, f"Failed to create upload directory: {str(e)}"
# Get file size and MIME type
file.seek(0, os.SEEK_END)
......@@ -172,6 +197,7 @@ class FileUploadHandler:
mime_type = file.content_type or 'application/octet-stream'
# Create upload record
from app.models import FileUpload
upload_record = FileUpload(
filename=filename,
original_filename=original_filename,
......@@ -325,6 +351,7 @@ class FileUploadHandler:
def cleanup_failed_uploads(self):
"""Clean up failed upload files"""
try:
from app.models import FileUpload
failed_uploads = FileUpload.query.filter_by(upload_status='failed').all()
for upload in failed_uploads:
......@@ -341,6 +368,7 @@ class FileUploadHandler:
def get_upload_statistics(self):
"""Get upload statistics"""
try:
from app.models import FileUpload
stats = {
'total_uploads': FileUpload.query.count(),
'completed_uploads': FileUpload.query.filter_by(upload_status='completed').count(),
......@@ -362,9 +390,12 @@ class FileUploadHandler:
logger.error(f"Statistics calculation error: {str(e)}")
return {}
# Global file upload handler instance
file_upload_handler = FileUploadHandler()
# Global file upload handler instance (lazy initialization)
file_upload_handler = None
def get_file_upload_handler():
"""Get file upload handler instance"""
global file_upload_handler
if file_upload_handler is None:
file_upload_handler = FileUploadHandler()
return file_upload_handler
\ No newline at end of file
import pandas as pd
import logging
import re
import uuid
from datetime import datetime
from typing import Dict, List, Tuple, Optional
from app import db
......@@ -343,6 +344,10 @@ class FixtureParser:
try:
saved_match_ids = []
# Generate a single fixture_id for all matches from this file upload
fixture_id = str(uuid.uuid4())
logger.info(f"Generated fixture_id {fixture_id} for {len(parsed_matches)} matches")
for match_data in parsed_matches:
try:
# Check if match number already exists
......@@ -351,7 +356,7 @@ class FixtureParser:
logger.warning(f"Match number {match_data['match_number']} already exists, skipping")
continue
# Create match record
# Create match record with shared fixture_id
match = Match(
match_number=match_data['match_number'],
fighter1_township=match_data['fighter1_township'],
......@@ -359,6 +364,7 @@ class FixtureParser:
venue_kampala_township=match_data['venue_kampala_township'],
filename=match_data['filename'],
file_sha1sum=file_sha1sum,
fixture_id=fixture_id, # Use shared fixture_id
created_by=match_data['created_by']
)
......@@ -395,6 +401,7 @@ class FixtureParser:
def get_parsing_statistics(self) -> Dict:
"""Get fixture parsing statistics"""
try:
from app.models import Match, MatchOutcome
stats = {
'total_matches': Match.query.count(),
'active_matches': Match.query.filter_by(active_status=True).count(),
......
......@@ -6,7 +6,6 @@ from flask_jwt_extended import jwt_required, get_jwt_identity
from werkzeug.utils import secure_filename
from app.upload import bp
from app import db
from app.models import Match, FileUpload, User
from app.upload.file_handler import get_file_upload_handler
from app.upload.fixture_parser import get_fixture_parser
from app.utils.security import require_active_user, validate_file_type, hash_file_content
......@@ -67,11 +66,75 @@ def upload_fixture():
return render_template('upload/fixture.html', form=form)
@bp.route('/zip', methods=['POST'])
@login_required
@require_active_user
def upload_zip():
"""Upload ZIP file for specific match - Web interface (from fixture detail page)"""
try:
match_id = request.form.get('match_id')
if not match_id:
flash('Match ID is required', 'error')
return redirect(request.referrer or url_for('main.fixtures'))
from app.models import Match
match = Match.query.get_or_404(int(match_id))
# Check if user owns this match or is admin
if not current_user.is_admin and match.created_by != current_user.id:
flash('You can only upload ZIP files for your own matches', 'error')
return redirect(request.referrer or url_for('main.fixtures'))
if 'zip_file' not in request.files:
flash('No file selected', 'error')
return redirect(request.referrer or url_for('main.fixture_detail', fixture_id=match.fixture_id))
file = request.files['zip_file']
if not file or not file.filename:
flash('No file selected', 'error')
return redirect(request.referrer or url_for('main.fixture_detail', fixture_id=match.fixture_id))
# Update match status to uploading
match.zip_upload_status = 'uploading'
db.session.commit()
# Process upload
file_handler = get_file_upload_handler()
upload_record, error_message = file_handler.process_upload(
file, 'zip', current_user.id, int(match_id)
)
if error_message:
match.zip_upload_status = 'failed'
db.session.commit()
flash(f'Upload failed: {error_message}', 'error')
return redirect(request.referrer or url_for('main.fixture_detail', fixture_id=match.fixture_id))
# Update match with ZIP file information
match.zip_filename = upload_record.filename
match.zip_sha1sum = upload_record.sha1sum
match.zip_upload_status = 'completed'
match.zip_upload_progress = 100.00
# Set match as active (both fixture and ZIP uploaded)
match.set_active()
db.session.commit()
flash(f'ZIP file uploaded successfully for Match #{match.match_number}! Match is now active.', 'success')
return redirect(request.referrer or url_for('main.fixture_detail', fixture_id=match.fixture_id))
except Exception as e:
logger.error(f"ZIP upload error: {str(e)}")
flash('Upload processing failed', 'error')
return redirect(request.referrer or url_for('main.fixtures'))
@bp.route('/zip/<int:match_id>', methods=['GET', 'POST'])
@login_required
@require_active_user
def upload_zip(match_id):
"""Upload ZIP file for specific match - Web interface"""
def upload_zip_page(match_id):
"""Upload ZIP file for specific match - Web interface (dedicated page)"""
from app.models import Match
match = Match.query.get_or_404(match_id)
# Check if ZIP already uploaded
......@@ -126,12 +189,69 @@ def upload_zip(match_id):
return render_template('upload/zip.html', form=form, match=match)
@bp.route('/zip/<int:match_id>/delete', methods=['POST'])
@login_required
@require_active_user
def delete_zip(match_id):
"""Delete ZIP file for specific match"""
try:
from app.models import Match, FileUpload
match = Match.query.get_or_404(match_id)
# Check if user owns this match or is admin
if not current_user.is_admin and match.created_by != current_user.id:
flash('You can only delete ZIP files for your own matches', 'error')
return redirect(request.referrer or url_for('main.fixtures'))
if match.zip_upload_status != 'completed':
flash('No ZIP file to delete for this match', 'error')
return redirect(request.referrer or url_for('main.fixture_detail', fixture_id=match.fixture_id))
# Find and delete the associated file upload record
zip_upload = FileUpload.query.filter_by(
match_id=match_id,
file_type='zip'
).first()
if zip_upload:
# Delete physical file
file_handler = get_file_upload_handler()
try:
if os.path.exists(zip_upload.file_path):
os.remove(zip_upload.file_path)
logger.info(f"Deleted ZIP file: {zip_upload.file_path}")
except Exception as e:
logger.warning(f"Could not delete physical file {zip_upload.file_path}: {str(e)}")
# Delete upload record
db.session.delete(zip_upload)
# Reset match ZIP status
match.zip_filename = None
match.zip_sha1sum = None
match.zip_upload_status = 'pending'
match.zip_upload_progress = 0.0
# Set match as inactive since ZIP is removed
match.active_status = False
db.session.commit()
flash(f'ZIP file deleted successfully for Match #{match.match_number}. Match is now inactive.', 'success')
return redirect(request.referrer or url_for('main.fixture_detail', fixture_id=match.fixture_id))
except Exception as e:
logger.error(f"ZIP deletion error: {str(e)}")
flash('ZIP file deletion failed', 'error')
return redirect(request.referrer or url_for('main.fixtures'))
@bp.route('/api/fixture', methods=['POST'])
@jwt_required()
def api_upload_fixture():
"""Upload fixture file (CSV/XLSX) - API endpoint"""
try:
user_id = get_jwt_identity()
from app.models import User
user = User.query.get(user_id)
if not user or not user.is_active:
......@@ -187,6 +307,7 @@ def api_upload_zip(match_id):
"""Upload ZIP file for specific match - API endpoint"""
try:
user_id = get_jwt_identity()
from app.models import User, Match
user = User.query.get(user_id)
if not user or not user.is_active:
......@@ -252,6 +373,7 @@ def api_upload_async():
"""Start asynchronous file upload"""
try:
user_id = get_jwt_identity()
from app.models import User
user = User.query.get(user_id)
if not user or not user.is_active:
......@@ -329,6 +451,7 @@ def api_upload_progress(upload_id):
try:
user_id = get_jwt_identity()
from app.models import FileUpload
upload_record = FileUpload.query.filter_by(
id=upload_id,
uploaded_by=user_id
......@@ -361,6 +484,7 @@ def api_list_uploads():
file_type = request.args.get('file_type')
status = request.args.get('status')
from app.models import FileUpload
query = FileUpload.query.filter_by(uploaded_by=user_id)
if file_type:
......@@ -395,6 +519,7 @@ def api_upload_statistics():
"""Get upload statistics"""
try:
user_id = get_jwt_identity()
from app.models import User
user = User.query.get(user_id)
if not user:
......@@ -407,6 +532,7 @@ def api_upload_statistics():
parsing_stats = fixture_parser.get_parsing_statistics()
# User-specific statistics
from app.models import FileUpload, Match
user_uploads = FileUpload.query.filter_by(uploaded_by=user_id).count()
user_matches = Match.query.filter_by(created_by=user_id).count()
......@@ -433,6 +559,7 @@ def api_cleanup_uploads():
"""Clean up failed uploads (admin only)"""
try:
user_id = get_jwt_identity()
from app.models import User
user = User.query.get(user_id)
if not user or not user.is_admin:
......
......@@ -2,8 +2,6 @@ import logging
import json
from datetime import datetime
from flask import request, current_app, has_app_context
from app import db
from app.models import SystemLog
logger = logging.getLogger(__name__)
......@@ -37,6 +35,9 @@ def log_security_event(event_type, ip_address, user_id=None, username=None, extr
extra_info.update(extra_data)
# Log to database
if has_app_context():
try:
from app.models import SystemLog
SystemLog.log(
level='INFO' if event_type.endswith('_SUCCESS') else 'WARNING',
message=message,
......@@ -46,6 +47,8 @@ def log_security_event(event_type, ip_address, user_id=None, username=None, extr
user_agent=request.headers.get('User-Agent', '') if request else None,
extra_data=extra_info
)
except Exception as db_error:
logger.error(f"Failed to log security event to database: {str(db_error)}")
# Also log to application logger
logger.info(f"{message} - IP: {ip_address}")
......@@ -90,6 +93,9 @@ def log_file_operation(operation_type, filename, user_id=None, match_id=None, up
log_level = 'INFO' if status == 'SUCCESS' else 'ERROR'
# Log to database
if has_app_context():
try:
from app.models import SystemLog
SystemLog.log(
level=log_level,
message=message,
......@@ -100,6 +106,8 @@ def log_file_operation(operation_type, filename, user_id=None, match_id=None, up
ip_address=request.environ.get('HTTP_X_REAL_IP', request.remote_addr) if request else None,
extra_data=extra_info
)
except Exception as db_error:
logger.error(f"Failed to log file operation to database: {str(db_error)}")
# Also log to application logger
if status == 'SUCCESS':
......@@ -147,7 +155,9 @@ def log_database_operation(operation_type, table_name, record_id=None, user_id=N
log_level = 'INFO' if status == 'SUCCESS' else 'ERROR'
# Log to database (avoid recursion for SystemLog operations)
if table_name != 'system_logs':
if table_name != 'system_logs' and has_app_context():
try:
from app.models import SystemLog
SystemLog.log(
level=log_level,
message=message,
......@@ -156,6 +166,8 @@ def log_database_operation(operation_type, table_name, record_id=None, user_id=N
ip_address=request.environ.get('HTTP_X_REAL_IP', request.remote_addr) if request else None,
extra_data=extra_info
)
except Exception as db_error:
logger.error(f"Failed to log database operation to database: {str(db_error)}")
# Also log to application logger
if status == 'SUCCESS':
......@@ -215,6 +227,9 @@ def log_api_request(endpoint, method, user_id=None, status_code=200, response_ti
log_level = 'ERROR'
# Log to database
if has_app_context():
try:
from app.models import SystemLog
SystemLog.log(
level=log_level,
message=message,
......@@ -224,6 +239,8 @@ def log_api_request(endpoint, method, user_id=None, status_code=200, response_ti
user_agent=request.headers.get('User-Agent', '') if request else None,
extra_data=extra_info
)
except Exception as db_error:
logger.error(f"Failed to log API request to database: {str(db_error)}")
# Also log to application logger
if status_code < 400:
......@@ -264,6 +281,7 @@ def log_daemon_event(event_type, message, status='INFO', error_message=None, ext
# Only log to database if we have an application context
if has_app_context():
try:
from app.models import SystemLog
SystemLog.log(
level=status,
message=full_message,
......@@ -323,6 +341,9 @@ def log_upload_progress(upload_id, progress, status, user_id=None, match_id=None
log_level = 'WARNING'
# Log to database
if has_app_context():
try:
from app.models import SystemLog
SystemLog.log(
level=log_level,
message=message,
......@@ -332,6 +353,8 @@ def log_upload_progress(upload_id, progress, status, user_id=None, match_id=None
upload_id=upload_id,
extra_data=extra_info
)
except Exception as db_error:
logger.error(f"Failed to log upload progress to database: {str(db_error)}")
# Also log to application logger (only for significant events to avoid spam)
if progress % 25 == 0 or status in ['completed', 'failed']:
......
......@@ -24,7 +24,7 @@ class Config:
}
# File Upload Configuration
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or '/var/lib/fixture-daemon/uploads'
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads')
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH') or 500 * 1024 * 1024) # 500MB
ALLOWED_FIXTURE_EXTENSIONS = {'csv', 'xlsx', 'xls'}
ALLOWED_ZIP_EXTENSIONS = {'zip'}
......
# Database Configuration
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=fixture_user
MYSQL_PASSWORD=secure_password_here
MYSQL_DATABASE=fixture_manager
# Security Configuration
SECRET_KEY=your-secret-key-here-change-in-production
JWT_SECRET_KEY=your-jwt-secret-key-here
BCRYPT_LOG_ROUNDS=12
# File Upload Configuration
UPLOAD_FOLDER=/var/lib/fixture-daemon/uploads
MAX_CONTENT_LENGTH=524288000
CHUNK_SIZE=8192
MAX_CONCURRENT_UPLOADS=5
# Daemon Configuration
DAEMON_PID_FILE=/var/run/fixture-daemon.pid
DAEMON_LOG_FILE=/var/log/fixture-daemon.log
DAEMON_WORKING_DIR=/var/lib/fixture-daemon
# Web Server Configuration
HOST=0.0.0.0
PORT=5000
DEBUG=false
# Logging Configuration
LOG_LEVEL=INFO
# JWT Configuration
JWT_ACCESS_TOKEN_EXPIRES=3600
\ No newline at end of file
# Fixture Manager - Comprehensive Python Daemon System
A sophisticated Python daemon system for Linux servers with internet exposure, implementing a secure web dashboard and RESTful API with robust authentication mechanisms. The system provides advanced file upload capabilities with real-time progress tracking and a comprehensive fixture management system.
## Features
### Core Functionality
- **Secure Web Dashboard**: Modern web interface with authentication and authorization
- **RESTful API**: Comprehensive API with JWT authentication
- **MySQL Database Integration**: Robust database connectivity with connection pooling
- **Advanced File Upload System**: Real-time progress tracking with SHA1 checksum verification
- **Dual-Format Support**: Intelligent parsing of CSV/XLSX fixture files
- **Two-Stage Upload Workflow**: Fixture files followed by mandatory ZIP uploads
- **Daemon Process Management**: Full Linux daemon with systemd integration
### Security Features
- **Multi-layer Authentication**: Session-based and JWT token authentication
- **Rate Limiting**: Protection against brute force attacks
- **File Validation**: Comprehensive security checks and malicious content detection
- **SQL Injection Protection**: Parameterized queries and ORM usage
- **CSRF Protection**: Cross-site request forgery prevention
- **Security Headers**: Comprehensive HTTP security headers
- **Input Sanitization**: All user inputs are validated and sanitized
### Database Schema
- **Normalized Design**: Optimized relational database structure
- **Primary Matches Table**: Core fixture data with system fields
- **Secondary Outcomes Table**: Dynamic result columns with foreign key relationships
- **File Upload Tracking**: Complete upload lifecycle management
- **System Logging**: Comprehensive audit trail
- **Session Management**: Secure user session handling
## Installation
### Prerequisites
- Linux server (Ubuntu 18.04+, CentOS 7+, or similar)
- Python 3.8+
- MySQL 5.7+ or MariaDB 10.3+
- Root or sudo access
### Quick Installation
```bash
# Clone the repository
git clone <repository-url>
cd fixture-manager
# Make installation script executable
chmod +x install.sh
# Run installation (as root)
sudo ./install.sh
```
### Manual Installation
1. **Install System Dependencies**:
```bash
# Ubuntu/Debian
apt-get update
apt-get install python3 python3-pip python3-venv mysql-server nginx supervisor
# CentOS/RHEL
yum install python3 python3-pip mysql-server nginx supervisor
```
2. **Create System User**:
```bash
useradd --system --home-dir /var/lib/fixture-daemon fixture
```
3. **Install Python Dependencies**:
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
4. **Configure Database**:
```bash
mysql -u root -p < database/schema.sql
```
5. **Configure Environment**:
```bash
cp .env.example .env
# Edit .env with your configuration
```
## Configuration
### Environment Variables
The system uses environment variables for configuration. Key settings include:
```bash
# Database Configuration
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=fixture_user
MYSQL_PASSWORD=secure_password
MYSQL_DATABASE=fixture_manager
# Security Configuration
SECRET_KEY=your-secret-key-here
JWT_SECRET_KEY=your-jwt-secret-key
BCRYPT_LOG_ROUNDS=12
# File Upload Configuration
UPLOAD_FOLDER=/var/lib/fixture-daemon/uploads
MAX_CONTENT_LENGTH=524288000 # 500MB
MAX_CONCURRENT_UPLOADS=5
# Server Configuration
HOST=0.0.0.0
PORT=5000
DEBUG=false
```
### Database Schema
The system automatically creates the following tables:
- `users` - User authentication and management
- `matches` - Core fixture data with system fields
- `match_outcomes` - Dynamic outcome results
- `file_uploads` - Upload tracking and progress
- `system_logs` - Comprehensive logging
- `user_sessions` - Session management
## Usage
### Daemon Management
```bash
# Start the daemon
sudo systemctl start fixture-daemon
# Stop the daemon
sudo systemctl stop fixture-daemon
# Restart the daemon
sudo systemctl restart fixture-daemon
# Check status
sudo systemctl status fixture-daemon
# View logs
journalctl -u fixture-daemon -f
```
### Direct Daemon Control
```bash
# Start in foreground (for debugging)
python daemon.py start --foreground
# Start as daemon
python daemon.py start
# Stop daemon
python daemon.py stop
# Restart daemon
python daemon.py restart
# Check status
python daemon.py status
# Reload configuration
python daemon.py reload
```
### Web Interface
Access the web dashboard at `http://your-server-ip/`
**Default Credentials**:
- Username: `admin`
- Password: `admin123`
**⚠️ Important**: Change the default password immediately after installation!
### API Usage
#### Authentication
```bash
# Login and get JWT token
curl -X POST http://your-server/auth/api/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "admin123"}'
```
#### Upload Fixture File
```bash
# Upload CSV/XLSX fixture file
curl -X POST http://your-server/upload/api/fixture \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "file=@fixtures.csv"
```
#### Upload ZIP File
```bash
# Upload ZIP file for specific match
curl -X POST http://your-server/upload/api/zip/123 \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "file=@match_data.zip"
```
#### Get Matches
```bash
# Get all matches with pagination
curl -X GET "http://your-server/api/matches?page=1&per_page=20" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
## File Format Requirements
### Fixture Files (CSV/XLSX)
**Required Columns**:
- `Match #` (integer) - Unique match identifier
- `Fighter1 (Township)` (varchar255) - First fighter details
- `Fighter2 (Township)` (varchar255) - Second fighter details
- `Venue (Kampala Township)` (varchar255) - Match venue
**Optional Columns**:
- Any numeric columns will be automatically detected as outcome results
- Values must be numeric (float with 2-decimal precision)
**Example CSV**:
```csv
Match #,Fighter1 (Township),Fighter2 (Township),Venue (Kampala Township),Score1,Score2,Duration
1,John Doe (Central),Jane Smith (North),Stadium A (Kampala),85.5,92.3,12.5
2,Mike Johnson (East),Sarah Wilson (West),Arena B (Kampala),78.2,81.7,15.2
```
### ZIP Files
- Must be uploaded after fixture file processing
- Associated with specific match records
- Triggers match activation upon successful upload
- SHA1 checksum verification for integrity
## Architecture
### System Components
1. **Flask Web Application**: Core web framework with blueprints
2. **SQLAlchemy ORM**: Database abstraction and management
3. **JWT Authentication**: Stateless API authentication
4. **File Upload Handler**: Chunked uploads with progress tracking
5. **Fixture Parser**: Intelligent CSV/XLSX parsing
6. **Security Layer**: Multi-layer security implementation
7. **Logging System**: Comprehensive audit and monitoring
8. **Daemon Manager**: Linux daemon process management
### Security Architecture
- **Authentication**: Multi-factor with session and JWT support
- **Authorization**: Role-based access control (RBAC)
- **Input Validation**: Comprehensive sanitization and validation
- **File Security**: Malicious content detection and quarantine
- **Network Security**: Rate limiting and DDoS protection
- **Data Protection**: Encryption at rest and in transit
### Database Design
- **Normalized Schema**: Third normal form compliance
- **Foreign Key Constraints**: Referential integrity
- **Indexing Strategy**: Optimized query performance
- **Transaction Management**: ACID compliance
- **Connection Pooling**: Efficient resource utilization
## Monitoring and Maintenance
### Log Files
- **Application Logs**: `/var/log/fixture-daemon.log`
- **System Logs**: `journalctl -u fixture-daemon`
- **Database Logs**: MySQL error logs
- **Web Server Logs**: Nginx access/error logs
### Health Monitoring
```bash
# Check system health
curl http://your-server/health
# Get system statistics
curl -H "Authorization: Bearer TOKEN" http://your-server/api/statistics
```
### Backup and Recovery
```bash
# Manual backup
/opt/fixture-manager/backup.sh
# Restore from backup
mysql -u fixture_user -p fixture_manager < backup.sql
```
### Maintenance Tasks
The daemon automatically performs:
- **Session Cleanup**: Expired sessions removed hourly
- **Log Rotation**: Old logs archived daily
- **File Cleanup**: Failed uploads cleaned every 6 hours
- **Database Optimization**: Statistics updated nightly
## Troubleshooting
### Common Issues
1. **Database Connection Failed**
```bash
# Check MySQL service
systemctl status mysql
# Verify credentials
mysql -u fixture_user -p
```
2. **File Upload Errors**
```bash
# Check permissions
ls -la /var/lib/fixture-daemon/uploads
# Check disk space
df -h
```
3. **Daemon Won't Start**
```bash
# Check logs
journalctl -u fixture-daemon -n 50
# Test configuration
python daemon.py start --foreground
```
4. **Permission Denied**
```bash
# Fix ownership
chown -R fixture:fixture /var/lib/fixture-daemon
# Fix permissions
chmod 755 /opt/fixture-manager
```
### Debug Mode
```bash
# Run in debug mode
export DEBUG=true
python daemon.py start --foreground --config development
```
## API Documentation
### Authentication Endpoints
- `POST /auth/api/login` - User login
- `POST /auth/api/logout` - User logout
- `POST /auth/api/refresh` - Refresh JWT token
- `GET /auth/api/profile` - Get user profile
### Upload Endpoints
- `POST /upload/api/fixture` - Upload fixture file
- `POST /upload/api/zip/{match_id}` - Upload ZIP file
- `GET /upload/api/progress/{upload_id}` - Get upload progress
- `GET /upload/api/uploads` - List user uploads
### Match Management
- `GET /api/matches` - List matches with pagination
- `GET /api/matches/{id}` - Get match details
- `PUT /api/matches/{id}` - Update match
- `DELETE /api/matches/{id}` - Delete match (admin)
### Administration
- `GET /api/admin/users` - List users (admin)
- `PUT /api/admin/users/{id}` - Update user (admin)
- `GET /api/admin/logs` - System logs (admin)
- `GET /api/admin/system-info` - System information (admin)
## Performance Optimization
### Database Optimization
- Connection pooling with 10 connections
- Query optimization with proper indexing
- Prepared statements for security
- Transaction batching for bulk operations
### File Upload Optimization
- Chunked uploads for large files
- Concurrent upload support (configurable)
- Progress tracking with minimal overhead
- Automatic cleanup of failed uploads
### Caching Strategy
- Session caching with Redis (optional)
- Static file caching with Nginx
- Database query result caching
- API response caching for read-heavy endpoints
## Security Considerations
### Production Deployment
1. **Change Default Credentials**: Update admin password immediately
2. **SSL/TLS Configuration**: Enable HTTPS with valid certificates
3. **Firewall Configuration**: Restrict access to necessary ports only
4. **Regular Updates**: Keep system and dependencies updated
5. **Backup Strategy**: Implement regular automated backups
6. **Monitoring**: Set up comprehensive monitoring and alerting
### Security Best Practices
- Regular security audits
- Penetration testing
- Vulnerability scanning
- Access log monitoring
- Incident response procedures
## Building Single Executable
The project can be packaged as a single executable file for easy distribution:
### Quick Build
```bash
# Run the automated build script
python build.py
```
### Manual Build
```bash
# Install build dependencies
pip install -r requirements-build.txt
# Build with PyInstaller
pyinstaller --clean fixture-manager.spec
```
The executable will be created in the `dist/` directory and includes:
- All Python dependencies
- Complete Flask application
- Database utilities and models
- Web dashboard and API
- Configuration templates
**Executable Size**: ~80-120MB
**No Python Installation Required** on target systems
See [BUILD.md](BUILD.md) for detailed build instructions and troubleshooting.
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests for new functionality
5. Submit a pull request
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Support
For support and questions:
- Check the troubleshooting section
- Review system logs
- See BUILD.md for executable build issues
- Contact system administrator
---
**Version**: 1.0.0
**Last Updated**: 2025-08-18
**Minimum Requirements**: Python 3.8+, MySQL 5.7+, Linux Kernel 3.10+
\ No newline at end of file
#!/bin/bash
# Fixture Manager Installation Script
# Comprehensive installation script for Linux servers
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
PROJECT_NAME="fixture-manager"
SERVICE_NAME="fixture-daemon"
INSTALL_DIR="/opt/fixture-manager"
DATA_DIR="/var/lib/fixture-daemon"
LOG_DIR="/var/log"
CONFIG_DIR="/etc/fixture-manager"
USER="fixture"
GROUP="fixture"
# Functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
check_root() {
if [[ $EUID -ne 0 ]]; then
log_error "This script must be run as root"
exit 1
fi
}
detect_os() {
if [[ -f /etc/os-release ]]; then
. /etc/os-release
OS=$NAME
VER=$VERSION_ID
else
log_error "Cannot detect operating system"
exit 1
fi
log_info "Detected OS: $OS $VER"
}
install_dependencies() {
log_info "Installing system dependencies..."
if [[ "$OS" == *"Ubuntu"* ]] || [[ "$OS" == *"Debian"* ]]; then
apt-get update
apt-get install -y \
python3 \
python3-pip \
python3-venv \
python3-dev \
mysql-server \
mysql-client \
libmysqlclient-dev \
nginx \
supervisor \
git \
curl \
wget \
unzip \
build-essential \
pkg-config
elif [[ "$OS" == *"CentOS"* ]] || [[ "$OS" == *"Red Hat"* ]] || [[ "$OS" == *"Rocky"* ]]; then
yum update -y
yum install -y \
python3 \
python3-pip \
python3-devel \
mysql-server \
mysql-devel \
nginx \
supervisor \
git \
curl \
wget \
unzip \
gcc \
gcc-c++ \
make \
pkgconfig
else
log_error "Unsupported operating system: $OS"
exit 1
fi
log_success "System dependencies installed"
}
create_user() {
log_info "Creating system user and group..."
# Create group if it doesn't exist
if ! getent group $GROUP > /dev/null 2>&1; then
groupadd --system $GROUP
log_success "Created group: $GROUP"
fi
# Create user if it doesn't exist
if ! getent passwd $USER > /dev/null 2>&1; then
useradd --system --gid $GROUP --home-dir $DATA_DIR --shell /bin/false $USER
log_success "Created user: $USER"
fi
}
create_directories() {
log_info "Creating directories..."
# Create main directories
mkdir -p $INSTALL_DIR
mkdir -p $DATA_DIR/{uploads,backups,logs}
mkdir -p $CONFIG_DIR
mkdir -p $LOG_DIR
# Set ownership and permissions
chown -R $USER:$GROUP $INSTALL_DIR
chown -R $USER:$GROUP $DATA_DIR
chown -R $USER:$GROUP $CONFIG_DIR
chmod 755 $INSTALL_DIR
chmod 750 $DATA_DIR
chmod 750 $CONFIG_DIR
chmod 755 $DATA_DIR/uploads
log_success "Directories created and configured"
}
install_application() {
log_info "Installing application files..."
# Copy application files
cp -r . $INSTALL_DIR/
# Create Python virtual environment
cd $INSTALL_DIR
python3 -m venv venv
source venv/bin/activate
# Upgrade pip
pip install --upgrade pip
# Install Python dependencies
pip install -r requirements.txt
# Set ownership
chown -R $USER:$GROUP $INSTALL_DIR
# Make daemon script executable
chmod +x $INSTALL_DIR/daemon.py
log_success "Application installed"
}
configure_database() {
log_info "Configuring MySQL database..."
# Start MySQL service
systemctl start mysql || systemctl start mysqld
systemctl enable mysql || systemctl enable mysqld
# Generate random password
DB_PASSWORD=$(openssl rand -base64 32)
# Create database and user
mysql -u root <<EOF
CREATE DATABASE IF NOT EXISTS fixture_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'fixture_user'@'localhost' IDENTIFIED BY '$DB_PASSWORD';
GRANT ALL PRIVILEGES ON fixture_manager.* TO 'fixture_user'@'localhost';
FLUSH PRIVILEGES;
EOF
# Execute schema
mysql -u fixture_user -p$DB_PASSWORD fixture_manager < $INSTALL_DIR/database/schema.sql
# Save database credentials
cat > $CONFIG_DIR/database.conf <<EOF
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=fixture_user
MYSQL_PASSWORD=$DB_PASSWORD
MYSQL_DATABASE=fixture_manager
EOF
chmod 600 $CONFIG_DIR/database.conf
chown $USER:$GROUP $CONFIG_DIR/database.conf
log_success "Database configured"
}
create_config() {
log_info "Creating configuration files..."
# Generate secret keys
SECRET_KEY=$(openssl rand -base64 32)
JWT_SECRET_KEY=$(openssl rand -base64 32)
# Create main configuration
cat > $CONFIG_DIR/config.env <<EOF
# Database Configuration
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=fixture_user
MYSQL_PASSWORD=$(grep MYSQL_PASSWORD $CONFIG_DIR/database.conf | cut -d'=' -f2)
MYSQL_DATABASE=fixture_manager
# Security Configuration
SECRET_KEY=$SECRET_KEY
JWT_SECRET_KEY=$JWT_SECRET_KEY
BCRYPT_LOG_ROUNDS=12
# File Upload Configuration
UPLOAD_FOLDER=$DATA_DIR/uploads
MAX_CONTENT_LENGTH=524288000
CHUNK_SIZE=8192
MAX_CONCURRENT_UPLOADS=5
# Daemon Configuration
DAEMON_PID_FILE=/var/run/fixture-daemon.pid
DAEMON_LOG_FILE=$LOG_DIR/fixture-daemon.log
DAEMON_WORKING_DIR=$DATA_DIR
# Web Server Configuration
HOST=0.0.0.0
PORT=5000
DEBUG=false
# Logging Configuration
LOG_LEVEL=INFO
# JWT Configuration
JWT_ACCESS_TOKEN_EXPIRES=3600
EOF
chmod 600 $CONFIG_DIR/config.env
chown $USER:$GROUP $CONFIG_DIR/config.env
# Create symlink for application
ln -sf $CONFIG_DIR/config.env $INSTALL_DIR/.env
log_success "Configuration files created"
}
create_systemd_service() {
log_info "Creating systemd service..."
cat > /etc/systemd/system/$SERVICE_NAME.service <<EOF
[Unit]
Description=Fixture Manager Daemon
After=network.target mysql.service
Requires=mysql.service
[Service]
Type=forking
User=$USER
Group=$GROUP
WorkingDirectory=$INSTALL_DIR
Environment=PATH=$INSTALL_DIR/venv/bin
ExecStart=$INSTALL_DIR/venv/bin/python $INSTALL_DIR/daemon.py start --config production
ExecStop=$INSTALL_DIR/venv/bin/python $INSTALL_DIR/daemon.py stop --config production
ExecReload=$INSTALL_DIR/venv/bin/python $INSTALL_DIR/daemon.py reload --config production
PIDFile=/var/run/fixture-daemon.pid
Restart=always
RestartSec=10
# Security settings
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$DATA_DIR $LOG_DIR /var/run
[Install]
WantedBy=multi-user.target
EOF
# Reload systemd and enable service
systemctl daemon-reload
systemctl enable $SERVICE_NAME
log_success "Systemd service created"
}
configure_nginx() {
log_info "Configuring Nginx reverse proxy..."
cat > /etc/nginx/sites-available/$PROJECT_NAME <<EOF
server {
listen 80;
server_name _;
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
# File upload size limit
client_max_body_size 500M;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
# Timeout settings for large file uploads
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# Static files (if any)
location /static {
alias $INSTALL_DIR/app/static;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Health check endpoint
location /health {
proxy_pass http://127.0.0.1:5000/health;
access_log off;
}
}
EOF
# Enable site
ln -sf /etc/nginx/sites-available/$PROJECT_NAME /etc/nginx/sites-enabled/
# Remove default site
rm -f /etc/nginx/sites-enabled/default
# Test and reload nginx
nginx -t
systemctl enable nginx
systemctl restart nginx
log_success "Nginx configured"
}
setup_logrotate() {
log_info "Setting up log rotation..."
cat > /etc/logrotate.d/$SERVICE_NAME <<EOF
$LOG_DIR/fixture-daemon.log {
daily
missingok
rotate 30
compress
delaycompress
notifempty
create 644 $USER $GROUP
postrotate
systemctl reload $SERVICE_NAME > /dev/null 2>&1 || true
endscript
}
EOF
log_success "Log rotation configured"
}
setup_firewall() {
log_info "Configuring firewall..."
if command -v ufw &> /dev/null; then
# Ubuntu/Debian UFW
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
log_success "UFW firewall configured"
elif command -v firewall-cmd &> /dev/null; then
# CentOS/RHEL firewalld
firewall-cmd --permanent --add-service=ssh
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
log_success "Firewalld configured"
else
log_warning "No firewall detected. Please configure manually."
fi
}
create_backup_script() {
log_info "Creating backup script..."
cat > $INSTALL_DIR/backup.sh <<'EOF'
#!/bin/bash
# Fixture Manager Backup Script
BACKUP_DIR="/var/lib/fixture-daemon/backups"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="fixture_manager_backup_$DATE.tar.gz"
# Load database configuration
source /etc/fixture-manager/database.conf
# Create backup directory
mkdir -p $BACKUP_DIR
# Backup database
mysqldump -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE > $BACKUP_DIR/database_$DATE.sql
# Backup uploads
tar -czf $BACKUP_DIR/$BACKUP_FILE \
--exclude='*.log' \
--exclude='backups' \
/var/lib/fixture-daemon/uploads \
/etc/fixture-manager \
$BACKUP_DIR/database_$DATE.sql
# Remove temporary database dump
rm $BACKUP_DIR/database_$DATE.sql
# Keep only last 7 backups
find $BACKUP_DIR -name "fixture_manager_backup_*.tar.gz" -mtime +7 -delete
echo "Backup completed: $BACKUP_DIR/$BACKUP_FILE"
EOF
chmod +x $INSTALL_DIR/backup.sh
chown $USER:$GROUP $INSTALL_DIR/backup.sh
# Add to crontab for daily backups
(crontab -u $USER -l 2>/dev/null; echo "0 2 * * * $INSTALL_DIR/backup.sh") | crontab -u $USER -
log_success "Backup script created and scheduled"
}
start_services() {
log_info "Starting services..."
# Start and enable services
systemctl start $SERVICE_NAME
systemctl status $SERVICE_NAME --no-pager
log_success "Services started"
}
print_summary() {
log_success "Installation completed successfully!"
echo
echo "=== Installation Summary ==="
echo "Application Directory: $INSTALL_DIR"
echo "Data Directory: $DATA_DIR"
echo "Configuration Directory: $CONFIG_DIR"
echo "Log File: $LOG_DIR/fixture-daemon.log"
echo "Service Name: $SERVICE_NAME"
echo "User/Group: $USER:$GROUP"
echo
echo "=== Service Management ==="
echo "Start service: systemctl start $SERVICE_NAME"
echo "Stop service: systemctl stop $SERVICE_NAME"
echo "Restart service: systemctl restart $SERVICE_NAME"
echo "View logs: journalctl -u $SERVICE_NAME -f"
echo "View app logs: tail -f $LOG_DIR/fixture-daemon.log"
echo
echo "=== Web Interface ==="
echo "URL: http://$(hostname -I | awk '{print $1}')"
echo "Default admin credentials:"
echo " Username: admin"
echo " Password: admin123"
echo
log_warning "IMPORTANT: Change the default admin password immediately!"
echo
echo "=== Configuration Files ==="
echo "Main config: $CONFIG_DIR/config.env"
echo "Database config: $CONFIG_DIR/database.conf"
echo "Nginx config: /etc/nginx/sites-available/$PROJECT_NAME"
echo
echo "=== Backup ==="
echo "Backup script: $INSTALL_DIR/backup.sh"
echo "Backup directory: $DATA_DIR/backups"
echo "Automatic daily backups at 2:00 AM"
}
# Main installation process
main() {
log_info "Starting Fixture Manager installation..."
check_root
detect_os
install_dependencies
create_user
create_directories
install_application
configure_database
create_config
create_systemd_service
configure_nginx
setup_logrotate
setup_firewall
create_backup_script
start_services
print_summary
}
# Run installation
main "$@"
\ No newline at end of file
#!/bin/bash
# Fixture Manager Daemon Runner
# Set executable permissions
chmod +x ./fixture-manager
# Run the daemon
./fixture-manager "$@"
-- Fixture Manager Database Schema
-- MySQL DDL Script for automated database creation
-- Create database if it doesn't exist
CREATE DATABASE IF NOT EXISTS fixture_manager
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE fixture_manager;
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(80) NOT NULL UNIQUE,
email VARCHAR(120) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
is_admin BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL,
INDEX idx_username (username),
INDEX idx_email (email),
INDEX idx_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Primary matches table storing core fixture data
CREATE TABLE IF NOT EXISTS matches (
id INT AUTO_INCREMENT PRIMARY KEY,
match_number INT NOT NULL UNIQUE COMMENT 'Match # from fixture file',
fighter1_township VARCHAR(255) NOT NULL COMMENT 'Fighter1 (Township)',
fighter2_township VARCHAR(255) NOT NULL COMMENT 'Fighter2 (Township)',
venue_kampala_township VARCHAR(255) NOT NULL COMMENT 'Venue (Kampala Township)',
-- System fields
start_time DATETIME NULL COMMENT 'Match start time',
end_time DATETIME NULL COMMENT 'Match end time',
result VARCHAR(255) NULL COMMENT 'Match result/outcome',
filename VARCHAR(1024) NOT NULL COMMENT 'Original fixture filename',
file_sha1sum VARCHAR(255) NOT NULL COMMENT 'SHA1 checksum of fixture file',
fixture_id VARCHAR(255) NOT NULL UNIQUE COMMENT 'Unique fixture identifier',
active_status BOOLEAN DEFAULT FALSE COMMENT 'Active status flag',
-- ZIP file related fields
zip_filename VARCHAR(1024) NULL COMMENT 'Associated ZIP filename',
zip_sha1sum VARCHAR(255) NULL COMMENT 'SHA1 checksum of ZIP file',
zip_upload_status ENUM('pending', 'uploading', 'completed', 'failed') DEFAULT 'pending',
zip_upload_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT 'Upload progress percentage',
-- Metadata
created_by INT NULL COMMENT 'User who created this record',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_match_number (match_number),
INDEX idx_fixture_id (fixture_id),
INDEX idx_active_status (active_status),
INDEX idx_file_sha1sum (file_sha1sum),
INDEX idx_zip_sha1sum (zip_sha1sum),
INDEX idx_zip_upload_status (zip_upload_status),
INDEX idx_created_by (created_by),
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Secondary outcomes table with foreign key relationships
CREATE TABLE IF NOT EXISTS match_outcomes (
id INT AUTO_INCREMENT PRIMARY KEY,
match_id INT NOT NULL COMMENT 'Foreign key to matches table',
column_name VARCHAR(255) NOT NULL COMMENT 'Result column name from fixture file',
float_value DECIMAL(10,2) NOT NULL COMMENT 'Float value with 2-decimal precision',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_match_id (match_id),
INDEX idx_column_name (column_name),
INDEX idx_float_value (float_value),
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
UNIQUE KEY unique_match_column (match_id, column_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- File uploads tracking table
CREATE TABLE IF NOT EXISTS file_uploads (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(1024) NOT NULL,
original_filename VARCHAR(1024) NOT NULL,
file_path VARCHAR(2048) NOT NULL,
file_size BIGINT NOT NULL,
file_type ENUM('fixture', 'zip') NOT NULL,
mime_type VARCHAR(255) NOT NULL,
sha1sum VARCHAR(255) NOT NULL,
upload_status ENUM('uploading', 'completed', 'failed', 'processing') DEFAULT 'uploading',
upload_progress DECIMAL(5,2) DEFAULT 0.00,
error_message TEXT NULL,
-- Associated match (for ZIP files)
match_id INT NULL,
-- User tracking
uploaded_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_filename (filename),
INDEX idx_sha1sum (sha1sum),
INDEX idx_upload_status (upload_status),
INDEX idx_file_type (file_type),
INDEX idx_match_id (match_id),
INDEX idx_uploaded_by (uploaded_by),
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE SET NULL,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- System logs table for comprehensive logging
CREATE TABLE IF NOT EXISTS system_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
level ENUM('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') NOT NULL,
message TEXT NOT NULL,
module VARCHAR(255) NULL,
function_name VARCHAR(255) NULL,
line_number INT NULL,
-- Context information
user_id INT NULL,
match_id INT NULL,
upload_id INT NULL,
session_id VARCHAR(255) NULL,
ip_address VARCHAR(45) NULL,
user_agent TEXT NULL,
-- Additional metadata
extra_data JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_level (level),
INDEX idx_created_at (created_at),
INDEX idx_user_id (user_id),
INDEX idx_match_id (match_id),
INDEX idx_upload_id (upload_id),
INDEX idx_module (module),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE SET NULL,
FOREIGN KEY (upload_id) REFERENCES file_uploads(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Session management table
CREATE TABLE IF NOT EXISTS user_sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(255) NOT NULL UNIQUE,
user_id INT NOT NULL,
ip_address VARCHAR(45) NOT NULL,
user_agent TEXT NULL,
is_active BOOLEAN DEFAULT TRUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_session_id (session_id),
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at),
INDEX idx_is_active (is_active),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Create default admin user (password: admin123 - CHANGE IN PRODUCTION!)
INSERT INTO users (username, email, password_hash, is_admin)
VALUES (
'admin',
'admin@fixture-daemon.local',
'$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPj3bp.Gm.F5e', -- admin123
TRUE
) ON DUPLICATE KEY UPDATE username=username;
-- Create indexes for performance optimization
CREATE INDEX idx_matches_composite ON matches(active_status, zip_upload_status, created_at);
CREATE INDEX idx_outcomes_composite ON match_outcomes(match_id, column_name);
CREATE INDEX idx_uploads_composite ON file_uploads(upload_status, file_type, created_at);
CREATE INDEX idx_logs_composite ON system_logs(level, created_at, user_id);
-- Create views for common queries
CREATE OR REPLACE VIEW active_matches AS
SELECT
m.*,
COUNT(mo.id) as outcome_count,
GROUP_CONCAT(CONCAT(mo.column_name, ':', mo.float_value) SEPARATOR ';') as outcomes
FROM matches m
LEFT JOIN match_outcomes mo ON m.id = mo.match_id
WHERE m.active_status = TRUE
GROUP BY m.id;
CREATE OR REPLACE VIEW upload_summary AS
SELECT
DATE(created_at) as upload_date,
file_type,
upload_status,
COUNT(*) as count,
SUM(file_size) as total_size,
AVG(upload_progress) as avg_progress
FROM file_uploads
GROUP BY DATE(created_at), file_type, upload_status;
-- Set up proper permissions (adjust as needed for your environment)
-- GRANT SELECT, INSERT, UPDATE, DELETE ON fixture_manager.* TO 'fixture_user'@'localhost';
-- FLUSH PRIVILEGES;
-- Display schema creation completion
SELECT 'Database schema created successfully!' as status;
\ No newline at end of file
......@@ -68,20 +68,31 @@ class FixtureDaemon:
try:
self.app = create_app(self.config_name)
# Initialize database manager
with self.app.app_context():
# Initialize database manager (Flask app already created tables)
db_manager = init_database_manager(self.app)
# Initialize database if needed
schema_file = project_root / 'database' / 'schema.sql'
if schema_file.exists():
success = db_manager.initialize_database(str(schema_file))
if not success:
self.logger.error("Database initialization failed")
return False
# Test database connection and create database if needed
with self.app.app_context():
if not db_manager.test_connection():
self.logger.warning("Database connection test failed - attempting to create database")
if not db_manager.create_database_if_not_exists():
self.logger.error("Failed to create database - continuing without database")
# Continue without database - the app will show setup instructions
else:
# Test connection again after creating database
if db_manager.test_connection():
self.logger.info("Database created and connected successfully")
# Initialize database with tables
db_manager.initialize_database()
else:
self.logger.warning("Schema file not found, using SQLAlchemy create_all")
db.create_all()
self.logger.error("Database connection still failed after creation")
else:
self.logger.info("Database connection successful")
# Create default admin user if needed
try:
db_manager.create_default_admin()
except Exception as e:
self.logger.warning(f"Failed to create default admin user: {str(e)}")
self.logger.info("Flask application created successfully")
return True
......@@ -441,4 +452,4 @@ def main(action, config, foreground):
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
\ No newline at end of file
main() # pylint: disable=no-value-for-parameter
\ No newline at end of file
from PyInstaller.utils.hooks import collect_all
datas, binaries, hiddenimports = collect_all('flask_sqlalchemy')
hiddenimports += [
'sqlalchemy.dialects.mysql',
'sqlalchemy.dialects.mysql.pymysql',
'sqlalchemy.pool',
'sqlalchemy.engine.default',
]
from PyInstaller.utils.hooks import collect_all
datas, binaries, hiddenimports = collect_all('pandas')
hiddenimports += [
'pandas._libs.tslibs.base',
'pandas._libs.tslibs.ccalendar',
'pandas._libs.tslibs.dtypes',
'pandas._libs.tslibs.field_array',
'pandas._libs.tslibs.nattype',
'pandas._libs.tslibs.np_datetime',
'pandas._libs.tslibs.offsets',
'pandas._libs.tslibs.parsing',
'pandas._libs.tslibs.period',
'pandas._libs.tslibs.resolution',
'pandas._libs.tslibs.strptime',
'pandas._libs.tslibs.timedeltas',
'pandas._libs.tslibs.timestamps',
'pandas._libs.tslibs.timezones',
'pandas._libs.tslibs.tzconversion',
'pandas._libs.tslibs.vectorized',
]
#!/usr/bin/env python3
"""
Test script to verify file upload functionality
"""
import sys
import os
sys.path.insert(0, '.')
from app import create_app
from app.upload.file_handler import get_file_upload_handler
from werkzeug.datastructures import FileStorage
from io import BytesIO
def test_upload_configuration():
"""Test upload configuration and directory creation"""
print("Testing upload configuration...")
app = create_app('production')
with app.app_context():
print(f"✅ Upload folder configured as: {app.config['UPLOAD_FOLDER']}")
# Test file handler initialization
handler = get_file_upload_handler()
handler._ensure_initialized()
print(f"✅ Handler upload folder: {handler.upload_folder}")
# Check if directory exists or can be created
if os.path.exists(handler.upload_folder):
print("✅ Upload directory exists")
else:
print("📁 Upload directory will be created on first upload")
return handler
def test_file_upload():
"""Test actual file upload functionality"""
print("\nTesting file upload functionality...")
app = create_app('production')
with app.app_context():
handler = get_file_upload_handler()
# Create a mock CSV file
csv_content = b"Team A,Team B,Date,Time\nLiverpool,Arsenal,2024-01-15,15:00\nChelsea,ManU,2024-01-16,17:30"
mock_file = FileStorage(
stream=BytesIO(csv_content),
filename="test_fixture.csv",
content_type="text/csv"
)
# Test upload
upload_record, error_message = handler.process_upload(
mock_file, 'fixture', user_id=1
)
if error_message:
print(f"❌ Upload failed: {error_message}")
return False
else:
print(f"✅ Upload successful: {upload_record.filename}")
print(f" File path: {upload_record.file_path}")
print(f" File size: {upload_record.file_size} bytes")
return True
if __name__ == "__main__":
try:
handler = test_upload_configuration()
success = test_file_upload()
if success:
print("\n🎉 All tests passed! Upload functionality is working.")
else:
print("\n❌ Upload test failed.")
except Exception as e:
print(f"❌ Test error: {e}")
import traceback
traceback.print_exc()
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment