Compare commits

...

9 Commits

84 changed files with 11449 additions and 1059 deletions

3
motm_app/=21.0.0 Normal file
View File

@ -0,0 +1,3 @@
Defaulting to user installation because normal site-packages is not writeable
Requirement already satisfied: gunicorn in /home/jonny/.local/lib/python3.13/site-packages (21.2.0)
Requirement already satisfied: packaging in /usr/lib/python3.13/site-packages (from gunicorn) (24.2)

View File

@ -0,0 +1,261 @@
# Comments Management Feature
## Overview
Added a comprehensive comments management interface to the MOTM application, providing admin users with the ability to view, edit, delete, and manage match comments, similar to the existing MOTM management functionality.
## Features Implemented
### 1. View All Comments
- Display all comments in a card-based interface
- Show comment ID, match date, and full comment text
- Display statistics: total comments, unique match dates, and average comments per match
### 2. Edit Comments
- Inline edit functionality for each comment
- Click "Edit" to show an editable textarea
- Save or cancel changes without page reload
- Comments are properly escaped to handle special characters
### 3. Delete Comments
- **Individual Comment Deletion**: Delete specific comments one at a time
- **Match Date Deletion**: Delete all comments for a specific match date
- **Bulk Deletion**: Delete all comments in the database at once
- All deletions require confirmation dialogs for safety
### 4. Column Management
- View all columns in the `_motmcomments` table
- Drop unwanted columns (similar to MOTM management)
- Protected columns (`matchDate`, `comment`, `rowid`, `id`) cannot be dropped
### 5. Statistics Dashboard
- Total comment count
- Number of unique match dates with comments
- Average comments per match
## Files Modified/Created
### New Files
1. **`templates/comments_management.html`**
- Bootstrap 5-based responsive interface
- Card-based comment display
- Inline editing functionality
- Bulk operation controls
- Statistics dashboard
### Modified Files
1. **`main.py`**
- Added `/admin/comments/manage` route (lines 1385-1481)
- Handles GET and POST requests for all comment operations
- Actions supported:
- `delete_comment`: Delete individual comment
- `edit_comment`: Update comment text
- `delete_match_comments`: Delete all comments for a match
- `delete_all_comments`: Delete all comments
- `drop_column`: Drop a column from the table
2. **`templates/admin_dashboard.html`**
- Added "Comments Management" button (lines 68-76)
- Styled with yellow/warning theme (`btn-outline-warning`)
- Includes comments icon
## Usage
### Accessing Comments Management
1. Log in to the admin dashboard
2. Click the "Comments Management" button in the Quick Actions section
3. The page will display all match comments with management controls
### Managing Comments
#### Edit a Comment
1. Click the "Edit" button on any comment card
2. Modify the text in the textarea
3. Click "Save" to update or "Cancel" to discard changes
#### Delete a Comment
1. Click the "Delete" button on any comment card
2. Confirm the deletion in the dialog
3. Comment will be removed from the database
#### Delete Match Comments
1. In the "Bulk Operations" section, select a match date
2. Click "Delete Match Comments"
3. Confirm the deletion
4. All comments for that match will be removed
#### Delete All Comments
1. In the "Bulk Operations" section, click "Delete All Comments"
2. Confirm the deletion (requires explicit confirmation)
3. All comments in the database will be removed
#### Drop a Column
1. In the "Column Management" section, select a column
2. Click "Drop Column"
3. Confirm the action
4. The column will be removed from the table schema
## Database Compatibility
The implementation handles different database types:
### SQLite
- Uses `rowid` as the unique identifier for comments
- Fallback to `id` column if available
### PostgreSQL/MySQL
- Uses `information_schema` to query column names
- Uses `id` or `rowid` for unique identification
- Fallback logic ensures compatibility
## Security Features
1. **Authentication Required**: All routes require `@basic_auth.required`
2. **Confirmation Dialogs**: All destructive operations require user confirmation
3. **Protected Columns**: Core columns (`matchDate`, `comment`) are protected from dropping
4. **SQL Injection Prevention**: Uses parameterized queries with SQLAlchemy `text()`
5. **Special Character Handling**: Properly escapes single quotes in comments
## Technical Implementation
### Backend Route Pattern
```python
@app.route('/admin/comments/manage', methods=['GET', 'POST'])
@basic_auth.required
def comments_management():
# Handle POST actions (edit, delete, drop)
# Query database for comments and metadata
# Render template with data
```
### Key Query Examples
**Get all comments:**
```sql
SELECT rowid, matchDate, comment
FROM _motmcomments
ORDER BY matchDate DESC, rowid DESC
```
**Delete a comment:**
```sql
DELETE FROM _motmcomments
WHERE rowid = :comment_id
```
**Update a comment:**
```sql
UPDATE _motmcomments
SET comment = :comment
WHERE rowid = :comment_id
```
**Drop a column:**
```sql
ALTER TABLE _motmcomments
DROP COLUMN {column_name}
```
## UI/UX Features
### Responsive Design
- Bootstrap 5 grid system
- Mobile-friendly card layout
- Touch-friendly buttons and controls
### Visual Indicators
- Color-coded badges for match dates
- Card-based layout with left border accent
- FontAwesome icons for actions
- Success/error flash messages
### Interactive Elements
- Inline editing with JavaScript
- Confirmation dialogs for destructive actions
- Collapsible edit forms
- Real-time form validation
## Future Enhancements
Potential improvements for future versions:
1. **Search and Filter**
- Search comments by text
- Filter by match date range
- Sort by date, length, or other criteria
2. **Batch Operations**
- Select multiple comments for deletion
- Bulk edit with find/replace
3. **Comment Moderation**
- Flag inappropriate comments
- Approve/reject comments before display
4. **Export Functionality**
- Export comments to CSV/JSON
- Generate match reports with comments
5. **Pagination**
- Handle large numbers of comments efficiently
- Load more/infinite scroll
## Testing
To test the comments management feature:
1. **Setup**:
```bash
cd /home/jonny/Projects/gcp-hockey-results/motm_app
python main.py # or your preferred method
```
2. **Access**: Navigate to `/admin` and click "Comments Management"
3. **Test Cases**:
- View comments (should display all comments)
- Edit a comment (verify text updates correctly)
- Delete a comment (verify it's removed)
- Delete match comments (verify all for that date are removed)
- Try dropping a non-essential column (if any exist)
- Check statistics are calculated correctly
## Troubleshooting
### "No comments found"
- Ensure users have submitted comments during voting
- Check the `_motmcomments` table has data
### "Error deleting comment"
- Verify database permissions
- Check `rowid` or `id` column exists
- Review error message for specifics
### "Error dropping column"
- Ensure column name is correct
- Verify database supports `ALTER TABLE DROP COLUMN`
- SQLite has limitations on dropping columns
## Related Files
- `/admin/motm/manage` - Similar management interface for MOTM data
- `templates/motm_management.html` - Reference implementation
- `database.py` - Database models and configuration
- `db_setup.py` - Database initialization
## Comparison with MOTM Management
| Feature | MOTM Management | Comments Management |
|---------|----------------|---------------------|
| View Data | ✓ Table format | ✓ Card format |
| Edit Items | ✓ Reset counts | ✓ Edit text |
| Delete Items | ✓ Reset fixtures | ✓ Delete comments |
| Drop Columns | ✓ Yes | ✓ Yes |
| Bulk Operations | ✓ Reset all | ✓ Delete all |
| Statistics | ✓ Totals | ✓ Counts & averages |
## Conclusion
The Comments Management feature provides a complete administrative interface for managing match comments, matching the functionality pattern established by the MOTM Management system while being tailored to the specific needs of comment data.

View File

@ -76,8 +76,9 @@ echo "Database connection established!"\n\
# Initialize database if needed\n\
python -c "from db_setup import db_config_manager; db_config_manager.load_config(); db_config_manager._update_environment_variables()"\n\
\n\
# Start the application\n\
exec python main.py' > /app/start.sh && \
# Start the application with gunicorn\n\
echo "Starting application with gunicorn..."\n\
exec gunicorn --config gunicorn.conf.py main:app' > /app/start.sh && \
chmod +x /app/start.sh && \
chown appuser:appuser /app/start.sh

View File

@ -0,0 +1,209 @@
# Debugging URL Suffix Not Saving Issue
## Problem
The app is not saving the URL suffix to the `admin_settings` table.
## Debug Logging Added
I've added comprehensive debug logging to help diagnose the issue. The logs will show:
1. **When the form is submitted:**
- Which button was clicked (Save vs Activate)
- What form data was received
2. **When saving settings:**
- The generated URL suffix
- Whether the UPDATE query executed
- The result of the UPDATE query
- Verification of what's actually in the database
3. **When activating voting:**
- What URL suffix is retrieved from the database
- Whether a new suffix needs to be generated
- The final suffix being used
## How to Debug
### Step 1: Deploy the Updated Code
Deploy the updated code with debug logging to production:
```bash
# Build new image
docker build -t your-registry/motm-app:latest .
# Push to registry
docker push your-registry/motm-app:latest
# Deploy to Kubernetes
helm upgrade motm-app ./helm-chart/motm-app --namespace motm-app
```
### Step 2: Test the MOTM Admin Page
1. Go to https://motm.ervine.cloud/admin/motm
2. Fill in the form:
- Match date
- Opposition team
- Current MOTM (optional)
- Current DotD (optional)
- Voting deadline (optional)
3. Click **"Save Settings"** button
4. Check the logs
### Step 3: Check the Logs
```bash
# Get the pod name
kubectl get pods -n motm-app -l app.kubernetes.io/name=motm-app
# Watch the logs in real-time
kubectl logs -n motm-app -f <POD_NAME>
```
Or check recent logs:
```bash
kubectl logs -n motm-app -l app.kubernetes.io/name=motm-app --tail=100 | grep DEBUG
```
### Step 4: Analyze the Debug Output
Look for these debug messages:
#### When clicking "Save Settings":
```
DEBUG: POST request received
DEBUG: form.saveButton.data = True
DEBUG: form.activateButton.data = False
DEBUG: Save button clicked
DEBUG: Form data - team: [team name], date: [date]
DEBUG: Generated URL suffix: [suffix]
DEBUG: About to execute UPDATE query
DEBUG: UPDATE query result: True
DEBUG: Verification - URL suffix in DB: [suffix]
```
#### When clicking "Activate MotM Vote":
```
DEBUG: POST request received
DEBUG: form.saveButton.data = False
DEBUG: form.activateButton.data = True
DEBUG: Activate button clicked
DEBUG: Form data - team: [team name], date: [date]
DEBUG: Getting URL suffix from database
DEBUG: Query result: [result]
DEBUG: Using existing suffix: [suffix]
DEBUG: Final suffix: [suffix]
```
## Common Issues and Solutions
### Issue 1: Button Click Not Detected
**Symptoms:**
```
DEBUG: POST request received
DEBUG: form.saveButton.data = False
DEBUG: form.activateButton.data = False
DEBUG: Something went wrong - check with Smithers
```
**Cause:** The form button data isn't being submitted correctly.
**Solution:** Check the HTML form to ensure the buttons have the correct `name` attribute.
### Issue 2: UPDATE Query Fails
**Symptoms:**
```
DEBUG: UPDATE query result: False
```
**Cause:** The UPDATE query failed silently.
**Solution:** Check for SQL errors in the logs. The `sql_write_static` function prints errors.
### Issue 3: UPDATE Succeeds but Value Not Saved
**Symptoms:**
```
DEBUG: UPDATE query result: True
DEBUG: Verification - URL suffix in DB: None
```
**Cause:** The UPDATE query executed but didn't actually update the row.
**Solution:**
- Check if the WHERE clause is matching a row
- Verify the table structure
- Check if there's a transaction rollback happening
### Issue 4: Query Returns Empty Result
**Symptoms:**
```
DEBUG: Query result: []
```
**Cause:** No row matches the WHERE clause `userid = 'admin'`.
**Solution:**
- Check if the `userid` column has the value 'admin'
- Run the diagnostic script: `check_production_db.py`
## Manual Verification
After clicking "Save Settings", verify the URL suffix was saved:
```bash
kubectl exec -it <POD_NAME> -n motm-app -- python -c "
from db_config import sql_read_static
from sqlalchemy import text
result = sql_read_static(text('SELECT motm_url_suffix FROM admin_settings WHERE userid = \\'admin\\''))
print('URL suffix in database:', result[0]['motm_url_suffix'] if result else 'No data')
"
```
## Expected Behavior
### When clicking "Save Settings":
1. Form data is received ✅
2. URL suffix is generated ✅
3. UPDATE query executes ✅
4. URL suffix is saved to database ✅
5. Flash message shows the URL ✅
### When clicking "Activate MotM Vote":
1. Form data is received ✅
2. URL suffix is retrieved from database ✅
3. If missing, a new one is generated and saved ✅
4. Flash message shows the URL ✅
## Next Steps
1. **Deploy the updated code** with debug logging
2. **Test the MOTM admin page** and click "Save Settings"
3. **Check the logs** for debug messages
4. **Share the debug output** if the issue persists
5. **Verify the database** using the manual verification command
## Removing Debug Logging
Once the issue is resolved, you can remove the debug logging by searching for lines containing:
```python
print(f"DEBUG:
```
And removing them, or I can create a cleaned-up version for you.
## Support
If you continue to have issues after checking the debug logs:
1. Save the complete debug output
2. Check the application logs for any errors
3. Verify the database connection is working
4. Check if there are any database permission issues
5. Share the debug output and any error messages

View File

@ -0,0 +1,385 @@
# Helm Secrets and Gunicorn Updates
## Summary of Changes
This document describes the improvements made to support external Kubernetes secrets and production-ready gunicorn deployment.
## 1. External Secret Support
### Overview
The Helm chart now supports referencing an external Kubernetes secret instead of creating a managed one. This allows you to:
- Use existing secrets from secret management tools (e.g., External Secrets Operator, Sealed Secrets)
- Share secrets across multiple deployments
- Follow security best practices by not storing secrets in Helm values
### Configuration
#### values.yaml
```yaml
secrets:
# Use an existing external secret instead of creating one
useExternalSecret: false # Set to true to use external secret
externalSecretName: "" # Name of your existing secret
# Secret key names (consistent across both managed and external secrets)
dbPasswordKey: "db-password"
s3AccessKeyKey: "s3-access-key"
s3SecretKeyKey: "s3-secret-key"
# Values for managed secret (only used when useExternalSecret is false)
dbPassword: ""
s3AccessKey: ""
s3SecretKey: ""
```
#### Using an External Secret
**Example 1: Basic external secret**
```yaml
# values.yaml or values-production.yaml
secrets:
useExternalSecret: true
externalSecretName: "motm-app-credentials"
# The rest of the secret values are ignored when useExternalSecret is true
```
**Example 2: Your external secret should have these keys:**
```yaml
apiVersion: v1
kind: Secret
metadata:
name: motm-app-credentials
type: Opaque
data:
db-password: <base64-encoded-password>
s3-access-key: <base64-encoded-access-key>
s3-secret-key: <base64-encoded-secret-key>
```
**Example 3: Using with External Secrets Operator**
```yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: motm-app-credentials
spec:
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: motm-app-credentials
data:
- secretKey: db-password
remoteRef:
key: motm/database
property: password
- secretKey: s3-access-key
remoteRef:
key: motm/s3
property: access_key
- secretKey: s3-secret-key
remoteRef:
key: motm/s3
property: secret_key
```
### Files Modified
- `helm-chart/motm-app/values.yaml` - Added external secret configuration
- `helm-chart/motm-app/templates/secret.yaml` - Made conditional based on `useExternalSecret`
- `helm-chart/motm-app/templates/deployment.yaml` - Updated to reference external or managed secret
## 2. S3 Environment Variable Support
### Overview
The S3 configuration now prioritizes environment variables over the JSON configuration file, allowing seamless deployment in Kubernetes without managing config files.
### Behavior
1. **In Kubernetes** (when `S3_ENABLED` or `S3_ACCESS_KEY_ID` env vars are set): Reads from environment variables
2. **Locally** (no env vars): Falls back to `s3_config.json` file
### Environment Variables
The following environment variables are now supported:
- `S3_ENABLED` - Enable/disable S3 (true/false)
- `S3_ACCESS_KEY_ID` - S3/MinIO access key (from secret)
- `S3_SECRET_ACCESS_KEY` - S3/MinIO secret key (from secret)
- `S3_STORAGE_PROVIDER` - Storage provider (`aws` or `minio`, default: aws)
- `S3_REGION` - AWS region (default: us-east-1)
- `S3_BUCKET` - S3/MinIO bucket name
- `S3_BUCKET_PREFIX` - Key prefix/folder (default: assets/)
- `S3_ENDPOINT` - Custom endpoint for MinIO or S3-compatible storage
- `S3_USE_SIGNED_URLS` - Use signed URLs (default: true)
- `S3_SIGNED_URL_EXPIRY` - Signed URL expiry in seconds (default: 3600)
- `S3_FALLBACK_TO_STATIC` - Fallback to static files on error (default: true)
- `S3_USE_SSL` - Use SSL/TLS for connections (default: true)
### Files Modified
- `s3_config.py` - Added `_load_from_env()` method and prioritized env vars
- `helm-chart/motm-app/templates/deployment.yaml` - Added `S3_ENABLED` environment variable
## 3. Gunicorn Production Server
### Overview
The container now uses gunicorn instead of Flask's development server for production-ready deployment.
### Benefits
- **Production-ready**: Gunicorn is a WSGI HTTP server designed for production
- **Better performance**: Multi-worker support for handling concurrent requests
- **Stability**: Auto-restart workers after handling requests to prevent memory leaks
- **Proper process management**: Better signal handling and graceful shutdowns
### Configuration
The gunicorn configuration is defined in `gunicorn.conf.py`:
- **Workers**: `(CPU cores * 2) + 1` for optimal performance
- **Timeout**: 30 seconds
- **Max requests**: 1000 per worker (with jitter) to prevent memory leaks
- **Logging**: Access and error logs to stdout/stderr
- **Preload**: App is preloaded for better performance
### Files Modified
- `Containerfile` - Updated startup script to use `gunicorn --config gunicorn.conf.py main:app`
### Note on main.py
The `main.py` file still contains:
```python
if __name__ == "__main__":
app.run(host='0.0.0.0', port=5000, debug=True)
```
This is intentional and allows:
- **Local development**: Run with `python main.py` for development
- **Production**: Container uses gunicorn, which imports `app` from `main.py` directly
## S3/MinIO Configuration Examples
### AWS S3 Configuration
```yaml
# values-production.yaml
s3:
enabled: true
storageProvider: "aws"
endpoint: "" # Leave empty for AWS
region: "us-east-1"
bucket: "motm-assets-prod"
bucketPrefix: "assets/"
useSignedUrls: true
signedUrlExpiry: 3600
fallbackToStatic: true
useSSL: true
```
### MinIO Configuration (Self-Hosted)
```yaml
# values.yaml or values-production.yaml
s3:
enabled: true
storageProvider: "minio"
endpoint: "https://minio.yourdomain.com"
region: "us-east-1" # Required for boto3, but MinIO ignores it
bucket: "motm-assets"
bucketPrefix: "assets/"
useSignedUrls: false # Use public URLs if bucket is public
signedUrlExpiry: 3600
fallbackToStatic: true
useSSL: true
```
### MinIO Configuration (In-Cluster)
```yaml
# values-development.yaml
s3:
enabled: true
storageProvider: "minio"
endpoint: "http://minio.default.svc.cluster.local:9000"
region: "us-east-1"
bucket: "motm-assets-dev"
bucketPrefix: "dev/"
useSignedUrls: false
signedUrlExpiry: 3600
fallbackToStatic: true
useSSL: false # HTTP for internal service
```
### Digital Ocean Spaces / S3-Compatible Storage
```yaml
s3:
enabled: true
storageProvider: "minio" # Use minio provider for S3-compatible services
endpoint: "https://nyc3.digitaloceanspaces.com"
region: "nyc3"
bucket: "motm-assets"
bucketPrefix: "production/"
useSignedUrls: true
signedUrlExpiry: 3600
fallbackToStatic: true
useSSL: true
```
## Deployment Examples
### Example 1: Using Managed Secret
```bash
helm upgrade --install motm-app ./helm-chart/motm-app \
-f values-production.yaml \
--set secrets.useExternalSecret=false \
--set secrets.dbPassword="your-db-password" \
--set secrets.s3AccessKey="your-s3-key" \
--set secrets.s3SecretKey="your-s3-secret"
```
### Example 2: Using External Secret
```bash
# First create your external secret
kubectl create secret generic motm-app-credentials \
--from-literal=db-password="your-db-password" \
--from-literal=s3-access-key="your-s3-key" \
--from-literal=s3-secret-key="your-s3-secret"
# Then deploy with external secret reference
helm upgrade --install motm-app ./helm-chart/motm-app \
-f values-production.yaml \
--set secrets.useExternalSecret=true \
--set secrets.externalSecretName="motm-app-credentials"
```
### Example 3: Custom Secret Key Names
If your external secret uses different key names:
```bash
helm upgrade --install motm-app ./helm-chart/motm-app \
-f values-production.yaml \
--set secrets.useExternalSecret=true \
--set secrets.externalSecretName="my-secret" \
--set secrets.dbPasswordKey="database-password" \
--set secrets.s3AccessKeyKey="aws-access-key" \
--set secrets.s3SecretKeyKey="aws-secret-key"
```
## Backward Compatibility
All changes maintain backward compatibility:
1. **Database Configuration**:
- Still reads from `database_config.ini` for local deployments
- Environment variables take precedence in containers
2. **S3 Configuration**:
- Still reads from `s3_config.json` when no env vars are set
- Admin interface still allows configuration via web UI
3. **Helm Chart**:
- Default behavior (`useExternalSecret: false`) creates managed secret
- Existing deployments continue to work without changes
## Security Best Practices
1. **Never commit secrets** to version control
2. **Use external secrets** for production deployments
3. **Rotate credentials** regularly
4. **Use RBAC** to restrict access to secrets in Kubernetes
5. **Enable audit logging** for secret access
6. **Use secret management tools** (Vault, AWS Secrets Manager, etc.) with External Secrets Operator
## Testing
### Test Database Connection
```bash
kubectl exec -it deployment/motm-app -- env | grep DB_
# Should show DB_HOST, DB_PORT, DB_NAME, DB_USER
# DB_PASSWORD won't show (secret)
```
### Test S3 Configuration
```bash
kubectl exec -it deployment/motm-app -- env | grep S3_
# Should show S3_ENABLED, S3_REGION, S3_BUCKET, etc.
```
### Test Gunicorn
```bash
kubectl logs -f deployment/motm-app
# Should show: "Starting application with gunicorn..."
# And gunicorn worker logs
```
### Verify Secret is Used
```bash
kubectl get pods -l app=motm-app -o jsonpath='{.items[0].spec.containers[0].env}' | jq
# Check that secretKeyRef points to your external secret
```
## Troubleshooting
### Secret Not Found
```
Error: secret "motm-app-credentials" not found
```
**Solution**: Ensure the external secret exists before deploying:
```bash
kubectl get secret motm-app-credentials
```
### Wrong Secret Keys
```
Error: couldn't find key db-password in Secret
```
**Solution**: Verify your secret has the correct keys:
```bash
kubectl get secret motm-app-credentials -o jsonpath='{.data}' | jq 'keys'
```
### S3 Not Using Environment Variables
**Symptom**: S3 still reading from s3_config.json
**Solution**: Ensure `S3_ENABLED=true` is set in the deployment environment variables
### Gunicorn Not Starting
**Symptom**: Container keeps restarting
**Solution**: Check logs for import errors:
```bash
kubectl logs deployment/motm-app
```
## Migration Guide
### From Managed to External Secret
1. **Extract current secrets**:
```bash
kubectl get secret motm-app-secrets -o yaml > current-secret.yaml
```
2. **Create external secret** with same data but new name:
```bash
kubectl create secret generic motm-app-credentials \
--from-literal=db-password="$(kubectl get secret motm-app-secrets -o jsonpath='{.data.db-password}' | base64 -d)" \
--from-literal=s3-access-key="$(kubectl get secret motm-app-secrets -o jsonpath='{.data.s3-access-key}' | base64 -d)" \
--from-literal=s3-secret-key="$(kubectl get secret motm-app-secrets -o jsonpath='{.data.s3-secret-key}' | base64 -d)"
```
3. **Update Helm values**:
```yaml
secrets:
useExternalSecret: true
externalSecretName: "motm-app-credentials"
```
4. **Upgrade deployment**:
```bash
helm upgrade motm-app ./helm-chart/motm-app -f values-production.yaml
```
5. **Verify** and clean up old secret:
```bash
kubectl delete secret motm-app-secrets
```
## Summary
These updates provide:
- ✅ Flexible secret management (external or managed)
- ✅ Production-ready deployment with gunicorn
- ✅ Environment-based S3 configuration
- ✅ Backward compatibility with existing deployments
- ✅ Security best practices
- ✅ Easy migration path

View File

@ -0,0 +1,471 @@
# Implementation Summary
## Complete Overview of Recent Changes
This document summarizes all recent improvements to the MOTM application for production-ready Kubernetes deployment.
---
## 🎯 Changes Implemented
### 1. External Kubernetes Secret Support
### 2. S3/MinIO Full Configuration Support
### 3. Gunicorn Production Server
### 4. **Database-Backed S3 Configuration** ⭐ NEW
---
## 1. External Kubernetes Secret Support 🔐
### What Changed
- Helm chart now supports referencing external Kubernetes secrets
- Secrets can be managed by External Secrets Operator, Sealed Secrets, or manually
- Single secret contains DB password and S3 credentials
### Files Modified
- `helm-chart/motm-app/values.yaml`
- `helm-chart/motm-app/templates/secret.yaml`
- `helm-chart/motm-app/templates/deployment.yaml`
### Configuration
```yaml
secrets:
useExternalSecret: true
externalSecretName: "motm-credentials"
dbPasswordKey: "db-password"
s3AccessKeyKey: "s3-access-key"
s3SecretKeyKey: "s3-secret-key"
```
### Benefits
- ✅ Integration with secret management tools
- ✅ Centralized secret management
- ✅ No secrets in Helm values
- ✅ Kubernetes best practices
---
## 2. S3/MinIO Full Configuration Support 📦
### What Changed
- Complete MinIO support alongside AWS S3
- Helm values for all S3 configuration options
- Environment variables for all settings
### Files Modified
- `helm-chart/motm-app/values.yaml`
- `helm-chart/motm-app/templates/deployment.yaml`
- `helm-chart/motm-app/templates/configmap.yaml`
- `helm-chart/motm-app/values-production.yaml`
- `helm-chart/motm-app/values-development.yaml`
### Configuration Options
```yaml
s3:
enabled: true
storageProvider: "minio" # or "aws"
endpoint: "https://minio.example.com"
region: "us-east-1"
bucket: "motm-assets"
bucketPrefix: "assets/"
useSignedUrls: true
signedUrlExpiry: 3600
fallbackToStatic: true
useSSL: true
```
### Use Cases
- AWS S3 production deployments
- Self-hosted MinIO for on-premise
- Digital Ocean Spaces, Wasabi, etc.
- In-cluster MinIO for development
---
## 3. Gunicorn Production Server 🚀
### What Changed
- Container now uses Gunicorn instead of Flask development server
- Production-ready WSGI server with multi-worker support
- Proper signal handling and graceful shutdowns
### Files Modified
- `Containerfile`
### Configuration
- Auto-scales workers based on CPU cores: `(cores × 2) + 1`
- 30-second timeout
- 1000 requests per worker before restart (prevents memory leaks)
- Access and error logs to stdout/stderr
### Benefits
- ✅ Production-grade performance
- ✅ Better concurrency handling
- ✅ Auto-restart workers to prevent memory leaks
- ✅ Proper process management
---
## 4. Database-Backed S3 Configuration ⭐ NEW
### What Changed
**Three-tier configuration priority system:**
1. **Environment Variables** (Highest) - Kubernetes secrets
2. **Database Settings** (Medium) - Admin-configurable via web UI
3. **JSON File** (Lowest) - Local development
### Files Modified
- `database.py` - Added `S3Settings` model
- `s3_config.py` - Three-tier loading, database save/load
- `db_setup.py` - S3Settings initialization
- `templates/s3_config.html` - Security notice for credentials
### Architecture
#### S3Settings Database Table
```sql
CREATE TABLE s3_settings (
id INTEGER PRIMARY KEY,
userid VARCHAR(50) DEFAULT 'admin',
enabled BOOLEAN DEFAULT FALSE,
storage_provider VARCHAR(20) DEFAULT 'aws',
endpoint VARCHAR(255),
region VARCHAR(50) DEFAULT 'us-east-1',
bucket_name VARCHAR(255),
bucket_prefix VARCHAR(255) DEFAULT 'assets/',
use_signed_urls BOOLEAN DEFAULT TRUE,
signed_url_expiry INTEGER DEFAULT 3600,
fallback_to_static BOOLEAN DEFAULT TRUE,
use_ssl BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
```
#### Configuration Loading Priority
```
1. Environment Variables (S3_ENABLED, S3_ACCESS_KEY_ID, etc.)
↓ (if not set)
2. Database (s3_settings table)
↓ (if not exists)
3. JSON File (s3_config.json)
↓ (if not exists)
4. Defaults (S3 disabled)
```
### Security Model
**🔒 Credentials are NEVER stored in database**
- **Production**: Credentials from environment variables (Kubernetes secrets)
- **Local Dev**: Credentials from JSON file
- **Database**: Only stores configuration (bucket, region, provider, etc.)
### Key Benefits
- ✅ **Zero-downtime configuration**: Change S3 settings via web UI without redeployment
- ✅ **Secure**: Credentials always from secure sources (secrets/env vars)
- ✅ **Flexible**: Different config per environment
- ✅ **Admin-friendly**: Web UI for configuration
- ✅ **Backward compatible**: Existing JSON file configs still work
### Admin Interface
Navigate to: **Admin Dashboard → S3 Configuration**
Features:
- Enable/disable S3 storage
- Select provider (AWS S3 or MinIO)
- Configure bucket, region, endpoint
- Test connection before saving
- Visual security notice about credentials
---
## Complete Deployment Examples
### Example 1: Production with External Secret + Database Config
**Step 1: Create External Secret**
```bash
kubectl create secret generic motm-credentials \
--from-literal=db-password="prod-db-password" \
--from-literal=s3-access-key="AKIAIOSFODNN7EXAMPLE" \
--from-literal=s3-secret-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCY"
```
**Step 2: Deploy with Helm**
```bash
helm upgrade --install motm-app ./helm-chart/motm-app \
-f values-production.yaml \
--set secrets.useExternalSecret=true \
--set secrets.externalSecretName="motm-credentials"
```
**Step 3: Configure S3 via Web UI**
1. Navigate to S3 Configuration page
2. Enable S3, select provider (AWS/MinIO)
3. Enter bucket details
4. Test connection
5. Save to database
**Result:**
- ✅ Credentials from Kubernetes secret (secure)
- ✅ S3 config in database (admin-configurable)
- ✅ No redeployment needed for config changes
- ✅ Gunicorn serving requests
- ✅ Production-ready
---
### Example 2: Development with MinIO
**Step 1: Deploy MinIO to Cluster**
```bash
helm install minio bitnami/minio \
--set auth.rootUser=minioadmin \
--set auth.rootPassword=minioadmin \
--set defaultBuckets=motm-dev
```
**Step 2: Deploy App**
```bash
helm upgrade --install motm-app ./helm-chart/motm-app \
-f values-development.yaml
```
**Step 3: Configure MinIO via Web UI**
- Enable S3: ✓
- Provider: MinIO
- Endpoint: `http://minio.default.svc.cluster.local:9000`
- Bucket: `motm-dev`
- Access Key: `minioadmin` (from UI, saved to s3_config.json locally)
**Result:**
- ✅ Local MinIO for development
- ✅ S3 config in database
- ✅ Matches production architecture
- ✅ Fast iteration
---
### Example 3: Hybrid (Env Vars Override Database)
**Use Case:** Production with emergency override capability
**Normal Operation:**
- Database: Configuration managed by admins via web UI
- Secrets: Credentials from Kubernetes secrets
**Emergency Override:**
Set environment variable to force specific settings:
```yaml
env:
- name: S3_BUCKET
value: "emergency-backup-bucket"
```
Environment variables override database settings, allowing immediate changes without UI access.
---
## Migration Paths
### From File-Based to Database Config
**Current State:** Using `s3_config.json`
**Migration Steps:**
1. Deploy new version with database support
2. Open S3 Configuration page (current settings auto-populate from JSON)
3. Click "Save Configuration" → settings now in database
4. Remove `s3_config.json` (backup first)
5. Restart - settings load from database
**Rollback:** Keep `s3_config.json` as backup
---
### From Environment Variables to Database
**Current State:** All S3 config in environment variables
**Migration Steps:**
1. Keep credential env vars:
```yaml
env:
- name: S3_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: motm-credentials
key: s3-access-key
- name: S3_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: motm-credentials
key: s3-secret-key
```
2. Remove configuration env vars (bucket, region, etc.)
3. Configure via web UI → saves to database
4. Credentials from secrets, config from database
**Benefits:** Change config without redeployment
---
## Security Summary
### What's Secure
- ✅ DB passwords in Kubernetes secrets (never in ConfigMaps)
- ✅ S3 credentials in Kubernetes secrets (never in database)
- ✅ SSL/TLS for S3 connections
- ✅ Signed URLs for private S3 buckets
- ✅ Non-root container user
- ✅ Read-only root filesystem (option in Helm)
- ✅ No secrets in logs or version control
### Best Practices Followed
- Environment variables for secrets
- Database for configuration
- External secret management support
- Least privilege principle
- Defense in depth
---
## Testing Checklist
### Database Configuration
- [ ] S3 settings saved to database persist across restarts
- [ ] Web UI correctly displays current configuration
- [ ] Test connection button works for AWS S3
- [ ] Test connection button works for MinIO
- [ ] Credentials not saved to database (check DB directly)
- [ ] Configuration changes apply without restart
### Kubernetes Deployment
- [ ] External secret correctly referenced
- [ ] DB password loaded from secret
- [ ] S3 credentials loaded from secret
- [ ] Pod starts successfully with gunicorn
- [ ] Health checks pass
- [ ] Liveness and readiness probes work
### S3 Functionality
- [ ] Images load from S3/MinIO
- [ ] Fallback to static files works when S3 disabled
- [ ] Signed URLs generated correctly
- [ ] MinIO endpoint configuration works
- [ ] AWS S3 configuration works
- [ ] Multi-environment setup (dev with MinIO, prod with S3)
---
## Documentation
### Primary Documents
1. **HELM_SECRETS_GUNICORN_UPDATES.md** - Helm secrets, S3/MinIO in Helm, Gunicorn
2. **S3_DATABASE_CONFIG.md** ⭐ - Database-backed S3 configuration (detailed)
3. **IMPLEMENTATION_SUMMARY.md** (this file) - Complete overview
### Quick Reference
- **Helm Chart Values**: `helm-chart/motm-app/values.yaml`
- **Production Example**: `helm-chart/motm-app/values-production.yaml`
- **Development Example**: `helm-chart/motm-app/values-development.yaml`
- **S3 Config Code**: `s3_config.py`
- **Database Models**: `database.py`
- **Admin UI**: `templates/s3_config.html`
---
## Troubleshooting Quick Reference
| Issue | Solution |
|-------|----------|
| Secret not found | Verify external secret exists: `kubectl get secret motm-credentials` |
| S3 config not persisting | Check database connection, verify s3_settings table exists |
| Credentials not working | Check env vars are set: `kubectl exec pod -- env \| grep S3_` |
| Gunicorn not starting | Check logs: `kubectl logs deployment/motm-app` |
| Database migration failed | Run: `python -c "from database import init_database; init_database()"` |
| Settings not loading | Check priority: env vars > database > file |
---
## Summary
### What We Achieved
1. **Production-Ready Deployment**
- Kubernetes-native secret management
- Gunicorn WSGI server
- External secret operator support
2. **Flexible S3/MinIO Support**
- Full configuration options in Helm
- AWS S3 and MinIO support
- S3-compatible storage support
3. **Database-Backed Configuration**
- Admin-configurable without redeployment
- Secure credential management
- Three-tier priority system
- Backward compatible
4. **Security First**
- Credentials always from secure sources
- No secrets in database or ConfigMaps
- SSL/TLS support
- Kubernetes best practices
### Lines of Code Changed
- **Database**: +33 lines (S3Settings model)
- **S3 Config**: +75 lines (database integration)
- **Helm Chart**: +60 lines (external secrets, S3 config)
- **Container**: +3 lines (gunicorn)
- **UI**: +8 lines (security notice)
- **Setup**: +18 lines (S3 initialization)
- **Documentation**: +1500 lines
### Ready For
- ✅ Production Kubernetes deployment
- ✅ Multi-environment setups
- ✅ Self-hosted MinIO
- ✅ AWS S3 production use
- ✅ Zero-downtime configuration changes
- ✅ Enterprise secret management
- ✅ High-availability deployments
- ✅ CI/CD pipelines
---
## Next Steps (Optional Enhancements)
### Future Improvements
1. **Multi-Region S3**: Support failover between regions
2. **Backup Configuration**: Export/import S3 settings
3. **Audit Log**: Track configuration changes
4. **Per-Team Settings**: Different S3 configs per team
5. **CDN Integration**: CloudFront/CloudFlare in front of S3
6. **Metrics**: S3 usage statistics and monitoring
### Platform Enhancements
1. **Database Migrations**: Alembic for schema versioning
2. **Health Checks**: S3 connectivity in health endpoint
3. **Prometheus Metrics**: S3 request counts, errors
4. **Grafana Dashboard**: S3 performance visualization
---
## Conclusion
The MOTM application is now production-ready with:
- Secure, flexible secret management
- Database-backed S3 configuration for zero-downtime changes
- Full S3 and MinIO support
- Production-grade Gunicorn server
- Kubernetes-native architecture
All changes maintain backward compatibility while providing enterprise-grade features for production deployments.
🎉 **Ready to deploy!**

View File

@ -0,0 +1,107 @@
# Database Migration Summary
## Problem
Your production application is throwing SQL errors because:
1. The `votingdeadline` column is missing from the `admin_settings` table
2. The code was using wrong table/column names (fixed in code)
## What Was Fixed
### Code Changes (Already Applied)
✅ Fixed table name: `motmadminsettings``admin_settings`
✅ Fixed column names to use snake_case (e.g., `nextclub``next_club`)
✅ Added camelCase to snake_case conversion in `readSettings.py`
✅ Improved error handling for duplicate column creation
### Files Modified
- `main.py` - Updated all SQL queries
- `readSettings.py` - Added camelCase to snake_case conversion
## What You Need to Do
### Run the Migration on Production
**Option 1: Use the automated script (Easiest)**
```bash
cd /home/jonny/Projects/gcp-hockey-results/motm_app
./run_production_migration.sh motm-app
```
**Option 2: Manual steps**
```bash
# 1. Find your pod
kubectl get pods -n motm-app -l app.kubernetes.io/name=motm-app
# 2. Run the migration
kubectl exec -it <POD_NAME> -n motm-app -- python add_voting_deadline.py
# 3. Verify it worked
kubectl exec -it <POD_NAME> -n motm-app -- python -c "
from db_config import db_config
from sqlalchemy import text, inspect
engine = db_config.engine
inspector = inspect(engine)
columns = inspector.get_columns('admin_settings')
print('✓ votingdeadline exists' if any(c['name'] == 'votingdeadline' for c in columns) else '✗ missing')
"
# 4. Restart the pod
kubectl rollout restart deployment/motm-app -n motm-app
```
## Expected Results
After running the migration:
- ✅ The `votingdeadline` column will be added to `admin_settings` table
- ✅ The MOTM admin page will load without SQL errors
- ✅ You'll see the "Voting Deadline" field in the form
- ✅ You can set voting deadlines for matches
## Verification
Test the application:
1. Visit https://motm.ervine.cloud/admin/motm
2. Page should load without errors
3. You should see the "Voting Deadline" field
4. Set a deadline and activate voting
5. Visit the voting page - you should see a countdown timer
## Troubleshooting
### If the migration script isn't in the pod:
You need to rebuild and redeploy the Docker image:
```bash
# Build new image with migration script
docker build -t your-registry/motm-app:latest .
# Push to registry
docker push your-registry/motm-app:latest
# Deploy to Kubernetes
helm upgrade motm-app ./helm-chart/motm-app --namespace motm-app
```
### If you get "column already exists" error:
This is fine! The migration script is idempotent and will skip if the column already exists.
### If you get other errors:
Check the logs:
```bash
kubectl logs -n motm-app -l app.kubernetes.io/name=motm-app --tail=100
```
## Documentation
For detailed instructions, see:
- `PRODUCTION_MIGRATION_GUIDE.md` - Comprehensive migration guide
- `VOTING_DEADLINE_FEATURE.md` - Feature documentation
- `VOTING_DEADLINE_IMPLEMENTATION.md` - Implementation details
## Support
If you encounter issues:
1. Check pod logs for detailed error messages
2. Verify database connectivity
3. Ensure database user has ALTER TABLE permissions
4. Review the production migration guide

187
motm_app/MINIO_LOGO_FIX.md Normal file
View File

@ -0,0 +1,187 @@
# MinIO Logo Display Fix
## Problem
Club logos were not displaying when deployed to Kubernetes because the application was generating AWS S3 URLs instead of MinIO URLs:
```
https://hockey-apps.s3.amazonaws.com/assets/logos/HKFC_crest.png
```
## Root Cause
The Helm chart values had `storageProvider: "aws"` configured, which caused the `s3_config.py` module's `_get_public_url()` method to generate AWS S3 URLs instead of MinIO URLs.
## Solution
Updated the Helm chart values files to use MinIO configuration:
### Changes Made
1. **`helm-chart/motm-app/values.yaml`** (default values):
- Changed `storageProvider: "aws"``"minio"`
- Set `endpoint: "http://minio.default.svc.cluster.local:9000"`
- Set `bucket: "hockey-apps"`
- Changed `useSignedUrls: true``false`
- Changed `useSSL: true``false`
2. **`helm-chart/motm-app/values-production.yaml`**:
- Changed `storageProvider: "aws"``"minio"`
- Set `endpoint: "http://minio.default.svc.cluster.local:9000"`
- Set `bucket: "hockey-apps"`
- Changed `useSignedUrls: true``false`
- Changed `useSSL: true``false`
3. **`helm-chart/motm-app/values-development.yaml`**:
- Already correctly configured with MinIO
## Deployment Instructions
### Option 1: Upgrade Existing Deployment
If you have an existing deployment, upgrade it with the new values:
```bash
cd /home/jonny/Projects/gcp-hockey-results/motm_app/helm-chart/motm-app
# For development
helm upgrade motm-app . -f values-development.yaml --namespace default
# For production
helm upgrade motm-app . -f values-production.yaml --namespace default
```
### Option 2: Redeploy from Scratch
```bash
cd /home/jonny/Projects/gcp-hockey-results/motm_app/helm-chart/motm-app
# Delete existing deployment
helm uninstall motm-app --namespace default
# Reinstall with correct configuration
helm install motm-app . -f values-production.yaml --namespace default
```
### Option 3: Override Values During Deployment
If you don't want to modify the files, you can override during deployment:
```bash
helm upgrade --install motm-app . \
--set s3.storageProvider=minio \
--set s3.endpoint="http://minio.default.svc.cluster.local:9000" \
--set s3.bucket=hockey-apps \
--set s3.useSignedUrls=false \
--set s3.useSSL=false \
--namespace default
```
## Important Considerations
### 1. MinIO Service Name
The endpoint `http://minio.default.svc.cluster.local:9000` assumes:
- MinIO service is named `minio`
- MinIO is deployed in the `default` namespace
- MinIO is running on port `9000`
If your MinIO service has a different name or is in a different namespace, update the endpoint accordingly:
```
http://<service-name>.<namespace>.svc.cluster.local:<port>
```
### 2. External MinIO Access
If you need to access MinIO from outside the cluster (e.g., for direct browser access or CDN), you can configure an external endpoint:
```yaml
s3:
storageProvider: "minio"
endpoint: "https://minio.yourdomain.com" # External endpoint
useSSL: true # Enable SSL for external access
useSignedUrls: true # Optional: use signed URLs for security
```
### 3. Bucket Configuration
Ensure the MinIO bucket `hockey-apps` exists and has the correct permissions:
```bash
# Using MinIO CLI (mc)
mc alias set myminio http://minio.default.svc.cluster.local:9000 <access-key> <secret-key>
mc mb myminio/hockey-apps
mc anonymous set download myminio/hockey-apps # Make bucket publicly readable
```
### 4. Upload Logos to MinIO
Ensure club logos are uploaded to the correct path in MinIO:
```
hockey-apps/assets/logos/HKFC_crest.png
hockey-apps/assets/logos/<other-club-logos>
```
## Verification
After deployment, verify the fix:
1. **Check Pod Environment Variables**:
```bash
kubectl exec -it <pod-name> -- env | grep S3
```
You should see:
```
S3_ENABLED=true
S3_STORAGE_PROVIDER=minio
S3_ENDPOINT=http://minio.default.svc.cluster.local:9000
S3_BUCKET=hockey-apps
S3_USE_SIGNED_URLS=false
S3_USE_SSL=false
```
2. **Check Logo URLs**:
- Visit the application in a browser
- Inspect a club logo image
- Verify the URL now points to MinIO, e.g.:
```
http://minio.default.svc.cluster.local:9000/hockey-apps/assets/logos/HKFC_crest.png
```
3. **Test Logo Loading**:
- Open the application
- Navigate to pages displaying club logos
- Confirm logos are now displaying correctly
## Rollback
If you need to rollback to AWS S3:
```bash
helm upgrade motm-app . \
--set s3.storageProvider=aws \
--set s3.endpoint="" \
--set s3.bucket=motm-assets \
--set s3.useSignedUrls=true \
--set s3.useSSL=true \
--namespace default
```
## Related Files
- `s3_config.py` - S3/MinIO configuration and URL generation
- `helm-chart/motm-app/templates/deployment.yaml` - Environment variable injection
- `helm-chart/motm-app/values.yaml` - Default configuration
- `helm-chart/motm-app/values-production.yaml` - Production configuration
- `helm-chart/motm-app/values-development.yaml` - Development configuration
## Technical Details
The `s3_config.py` module's `_get_public_url()` method (lines 285-303) generates URLs based on the storage provider:
- **AWS S3**: `https://{bucket}.s3.{region}.amazonaws.com/{key}`
- **MinIO**: `{protocol}://{endpoint}/{bucket}/{key}`
When `storageProvider: "minio"`, the code correctly generates MinIO URLs with the configured endpoint.

View File

@ -0,0 +1,192 @@
# PostgreSQL Compatibility Fix for Comments Management
## Issue
The comments management page was showing "No comments found" even though comments existed in the database. This was because the original implementation used SQLite-specific features (like `rowid`) that don't exist in PostgreSQL.
## Root Cause
Your database is PostgreSQL (as configured in `database_config.ini`), but the comments management code was written with SQLite assumptions:
1. **SQLite has `rowid`** - An implicit row identifier column
2. **PostgreSQL uses `ctid`** - A system column that serves a similar purpose but has different syntax
3. **Different DELETE/UPDATE syntax** - PostgreSQL doesn't support `LIMIT` directly in DELETE/UPDATE statements
## Fixes Applied
### 1. SELECT Query Fix (Reading Comments)
**Before:**
```sql
SELECT rowid, matchDate, comment FROM _motmcomments
```
**After:**
```sql
-- First tries to find an explicit ID column
SELECT column_name FROM information_schema.columns
WHERE table_name = '_motmcomments' AND column_name IN ('id', 'rowid', 'oid')
-- If found, uses that column
SELECT id as comment_id, matchDate, comment FROM _motmcomments
-- Otherwise, uses PostgreSQL's ctid
SELECT ctid::text as comment_id, matchDate, comment FROM _motmcomments
-- Last resort: generates row numbers
SELECT ROW_NUMBER() OVER (ORDER BY matchDate DESC) as comment_id,
matchDate, comment FROM _motmcomments
```
### 2. DELETE Query Fix
**Before:**
```sql
DELETE FROM _motmcomments WHERE rowid = :comment_id
```
**After:**
```sql
-- Primary method: Use ctid
DELETE FROM _motmcomments WHERE ctid = :comment_id::tid
-- Fallback: Match on date and comment content
DELETE FROM _motmcomments
WHERE ctid IN (
SELECT ctid FROM _motmcomments
WHERE matchDate = :match_date AND comment = :comment
LIMIT 1
)
```
### 3. UPDATE Query Fix
**Before:**
```sql
UPDATE _motmcomments SET comment = :comment WHERE rowid = :comment_id
```
**After:**
```sql
-- Primary method: Use ctid
UPDATE _motmcomments SET comment = :comment WHERE ctid = :comment_id::tid
-- Fallback: Match on date and comment content
UPDATE _motmcomments
SET comment = :new_comment
WHERE ctid IN (
SELECT ctid FROM _motmcomments
WHERE matchDate = :match_date AND comment = :old_comment
LIMIT 1
)
```
### 4. Template Updates
Updated `comments_management.html` to:
- Use `comment.comment_id` instead of `comment.rowid`
- Pass additional hidden fields: `match_date` and `original_comment`
- Handle string-based IDs (ctid is text like `(0,1)`)
- Use `CSS.escape()` for safe selector handling
### 5. JavaScript Updates
Updated JavaScript functions to handle non-numeric IDs:
```javascript
function toggleEdit(commentId) {
const escapedId = CSS.escape(String(commentId)); // Handle any string format
// ...
}
```
## Database Compatibility Matrix
| Feature | SQLite | PostgreSQL | MySQL |
|---------|--------|------------|-------|
| Implicit Row ID | `rowid` | `ctid` | None (need explicit PK) |
| ID Type | Integer | Text (tuple) | Integer |
| DELETE with LIMIT | ✓ Supported | ✗ Need subquery | ✓ Supported |
| information_schema | ✗ Limited | ✓ Full support | ✓ Full support |
## PostgreSQL `ctid` Explained
`ctid` is a system column in PostgreSQL that stores the physical location of a row:
- Format: `(page_number, tuple_index)` - e.g., `(0,1)` or `(42,17)`
- Type: `tid` (tuple identifier)
- Must be cast to text for display: `ctid::text`
- Must be cast from text for comparison: `'(0,1)'::tid`
**Important Notes:**
- `ctid` can change after `VACUUM` operations
- Not suitable for long-term row references
- Perfect for temporary identification within a transaction
- Our implementation includes fallback methods for robustness
## Testing
After the fix, the comments management page should:
1. **Display all comments** from the database
2. **Show proper IDs** (either from ctid or generated row numbers)
3. **Allow editing** - Update specific comments
4. **Allow deletion** - Remove individual comments
5. **Bulk operations** - Delete by match date or all comments
6. **Column management** - Drop unwanted columns
## Verifying the Fix
### Check Comments Exist
```sql
SELECT COUNT(*) FROM _motmcomments;
SELECT * FROM _motmcomments LIMIT 5;
```
### Check ctid Values
```sql
SELECT ctid::text, matchDate, comment FROM _motmcomments LIMIT 5;
```
### Test in Application
1. Navigate to `/admin/comments/manage`
2. Verify comments are displayed
3. Try editing a comment
4. Try deleting a comment
5. Check that operations succeed
## Known Limitations
1. **ctid instability**: After database maintenance (VACUUM FULL), ctid values change. Our fallback methods handle this.
2. **Duplicate comments**: If two identical comments exist for the same match date, the fallback methods will affect only the first match.
3. **Performance**: The fallback queries using subqueries are slightly slower than direct ctid lookups, but acceptable for the expected data volume.
## Future Improvements
Consider adding an explicit `id` column to `_motmcomments`:
```sql
ALTER TABLE _motmcomments ADD COLUMN id SERIAL PRIMARY KEY;
```
This would provide:
- Stable, permanent row identifiers
- Better performance
- Database-agnostic code
- Easier troubleshooting
## Related Files Modified
1. **`main.py`** (lines 1385-1520)
- Updated `comments_management()` function
- Multi-database query strategy
- Fallback error handling
2. **`templates/comments_management.html`**
- Changed `rowid` to `comment_id`
- Added hidden form fields
- Updated JavaScript for string IDs
## Summary
The comments management feature now works correctly with PostgreSQL by:
- Using PostgreSQL-specific `ctid` for row identification
- Implementing robust fallback methods
- Handling different data types gracefully
- Maintaining compatibility with future database types
All functionality (view, edit, delete, bulk operations) now works as intended.

View File

@ -0,0 +1,160 @@
# Production Deployment Guide
## WSGI Server Setup
Your Flask application now uses **Gunicorn** as the production WSGI server instead of the Flask development server.
### Files Added:
- `gunicorn.conf.py` - Gunicorn configuration file
- `run_production.py` - WSGI entry point
- `start_production.sh` - Production startup script
- `motm-app.service` - Systemd service file (optional)
## Quick Start (Recommended)
### 1. Install Dependencies
```bash
pip install -r requirements.txt
```
### 2. Start Production Server
```bash
# Option A: Use the startup script (easiest)
./start_production.sh
# Option B: Start manually
gunicorn -c gunicorn.conf.py run_production:app
# Option C: Quick start with default settings
gunicorn --bind 0.0.0.0:5000 --workers 4 run_production:app
```
## Configuration Details
### Gunicorn Configuration (`gunicorn.conf.py`)
- **Workers**: `CPU_COUNT * 2 + 1` (automatically calculated)
- **Worker Class**: `sync` (good for Flask applications)
- **Timeout**: 30 seconds
- **Max Requests**: 1000 per worker (prevents memory leaks)
- **Logging**: Standard output (can be redirected)
- **Preload**: Enabled for better performance
### Environment Variables
- `FLASK_ENV=production` (automatically set)
## Alternative WSGI Servers
If you prefer different WSGI servers:
### 1. uWSGI
```bash
pip install uwsgi
uwsgi --http 0.0.0.0:5000 --module run_production:app --processes 4 --threads 2
```
### 2. Waitress (Windows-friendly)
```bash
pip install waitress
waitress-serve --host=0.0.0.0 --port=5000 run_production:app
```
### 3. Gevent (for async workloads)
```bash
pip install gunicorn[gevent]
gunicorn -c gunicorn.conf.py --worker-class gevent run_production:app
```
## Production Recommendations
### 1. Use a Reverse Proxy
Place Nginx or Apache in front of Gunicorn:
**Nginx Example:**
```nginx
server {
listen 80;
server_name your-domain.com;
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;
}
}
```
### 2. SSL/HTTPS
For production, always use HTTPS:
- Use Let's Encrypt for free SSL certificates
- Configure SSL in your reverse proxy
- Update Gunicorn config with SSL settings if needed
### 3. Process Management
Use systemd or supervisor to manage the Gunicorn process:
**Systemd (Linux):**
```bash
sudo cp motm-app.service /etc/systemd/system/
sudo systemctl enable motm-app
sudo systemctl start motm-app
sudo systemctl status motm-app
```
### 4. Monitoring
- Monitor worker processes and memory usage
- Set up log rotation
- Use tools like Prometheus + Grafana for metrics
## Performance Tuning
### Worker Count
- **CPU-bound**: `workers = CPU_COUNT * 2`
- **I/O-bound**: `workers = CPU_COUNT * 2 + 1` (current setting)
- **High concurrency**: Consider async workers (gevent/eventlet)
### Memory Management
- Current: `max_requests = 1000` (restarts workers periodically)
- Adjust based on your memory constraints
### Timeout Settings
- Current: `timeout = 30` seconds
- Increase if you have long-running requests
## Troubleshooting
### Common Issues:
1. **Permission denied**: Check file permissions and user/group settings
2. **Port already in use**: Change port in `gunicorn.conf.py` or kill existing process
3. **Memory issues**: Reduce worker count or increase `max_requests`
4. **Slow responses**: Increase timeout or worker count
### Logs:
- Gunicorn logs to stdout/stderr by default
- Check systemd logs: `journalctl -u motm-app`
- Redirect logs to files if needed
## Security Notes
1. **Never run as root** in production
2. **Use HTTPS** for all production traffic
3. **Set proper file permissions** on your application files
4. **Keep dependencies updated** regularly
5. **Use environment variables** for sensitive configuration
## Migration from Development
Your application is now ready for production! The key changes:
- ✅ Gunicorn WSGI server instead of Flask dev server
- ✅ Production-optimized configuration
- ✅ Proper worker management
- ✅ Security improvements
- ✅ Performance optimizations
**Next Steps:**
1. Test the production setup locally
2. Deploy to your production server
3. Set up reverse proxy (Nginx/Apache)
4. Configure SSL certificates
5. Set up monitoring and logging

View File

@ -0,0 +1,234 @@
# Production Database Diagnostic Guide
## Issue
Getting "Admin settings not found" error when activating MOTM vote, even after saving settings.
## Root Cause Analysis
The code is looking for the `motm_url_suffix` column in the `admin_settings` table. The error suggests one of these issues in production:
1. **Wrong table name**: Production might be using `motmadminsettings` instead of `admin_settings`
2. **Missing column**: The `motm_url_suffix` column might not exist
3. **NULL value**: The `motm_url_suffix` might be NULL or empty
4. **No rows**: The table might be empty
## Diagnostic Steps
### Step 1: Run the Diagnostic Script
Run this in your production Kubernetes pod:
```bash
# Find your pod
kubectl get pods -n motm-app -l app.kubernetes.io/name=motm-app
# Run the diagnostic script
kubectl exec -it <POD_NAME> -n motm-app -- python check_production_db.py
```
### Step 2: Analyze the Output
The script will show:
- Which tables exist (`admin_settings` vs `motmadminsettings`)
- What data is in the table
- Whether the `motm_url_suffix` column has a value
- The exact query that's failing
### Step 3: Based on the Results
#### Scenario A: Table is `motmadminsettings` (Old Name)
If the output shows `motmadminsettings` exists but `admin_settings` doesn't:
**Solution**: Your production database still has the old table name. You need to either:
1. **Rename the table** (recommended):
```bash
kubectl exec -it <POD_NAME> -n motm-app -- python -c "
from db_config import db_config
from sqlalchemy import text
engine = db_config.engine
conn = engine.connect()
conn.execute(text('ALTER TABLE motmadminsettings RENAME TO admin_settings'))
conn.commit()
conn.close()
print('Table renamed successfully')
"
```
2. **Or update the code** to use `motmadminsettings` (not recommended, but works as a quick fix)
#### Scenario B: `motm_url_suffix` is NULL or Empty
If the output shows the column exists but is NULL or empty:
**Solution**: The column needs to be populated. Run this:
```bash
kubectl exec -it <POD_NAME> -n motm-app -- python -c "
from db_config import db_config
from sqlalchemy import text
import random
import string
# Generate a random URL suffix
def randomUrlSuffix(length=8):
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
engine = db_config.engine
conn = engine.connect()
urlSuffix = randomUrlSuffix(8)
conn.execute(text('UPDATE admin_settings SET motm_url_suffix = :suffix WHERE userid = \\'admin\\''), {'suffix': urlSuffix})
conn.commit()
conn.close()
print(f'URL suffix set to: {urlSuffix}')
"
```
#### Scenario C: Table is Empty
If the output shows 0 rows:
**Solution**: You need to initialize the admin settings. Run this:
```bash
kubectl exec -it <POD_NAME> -n motm-app -- python -c "
from db_config import db_config
from sqlalchemy import text
import random
import string
def randomUrlSuffix(length=8):
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
engine = db_config.engine
conn = engine.connect()
urlSuffix = randomUrlSuffix(8)
# Insert default admin settings
conn.execute(text('''
INSERT INTO admin_settings (userid, motm_url_suffix, next_fixture, prev_fixture)
VALUES ('admin', :suffix, 1, 0)
ON CONFLICT (userid) DO UPDATE SET motm_url_suffix = :suffix
'''), {'suffix': urlSuffix})
conn.commit()
conn.close()
print(f'Admin settings initialized with URL suffix: {urlSuffix}')
"
```
#### Scenario D: Column Doesn't Exist
If the output shows the column is missing:
**Solution**: Add the missing column:
```bash
kubectl exec -it <POD_NAME> -n motm-app -- python -c "
from db_config import db_config
from sqlalchemy import text
engine = db_config.engine
conn = engine.connect()
# Add the column if it doesn't exist
try:
conn.execute(text('ALTER TABLE admin_settings ADD COLUMN motm_url_suffix VARCHAR(50)'))
conn.commit()
print('Column added successfully')
except Exception as e:
if 'already exists' in str(e):
print('Column already exists')
else:
print(f'Error: {e}')
conn.close()
"
```
## Quick Fix Script
If you want to try a comprehensive fix that handles all scenarios:
```bash
kubectl exec -it <POD_NAME> -n motm-app -- python << 'EOF'
from db_config import db_config
from sqlalchemy import text
import random
import string
def randomUrlSuffix(length=8):
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
engine = db_config.engine
conn = engine.connect()
# Step 1: Check if table exists
try:
result = conn.execute(text("SELECT 1 FROM admin_settings LIMIT 1"))
print("✓ admin_settings table exists")
except:
print("✗ admin_settings table does not exist")
print("Please check if you're using the old table name 'motmadminsettings'")
conn.close()
exit(1)
# Step 2: Check if motm_url_suffix column exists
try:
result = conn.execute(text("SELECT motm_url_suffix FROM admin_settings LIMIT 1"))
print("✓ motm_url_suffix column exists")
except:
print("✗ motm_url_suffix column does not exist, adding it...")
conn.execute(text("ALTER TABLE admin_settings ADD COLUMN motm_url_suffix VARCHAR(50)"))
conn.commit()
print("✓ Column added")
# Step 3: Check if URL suffix has a value
result = conn.execute(text("SELECT motm_url_suffix FROM admin_settings WHERE userid = 'admin'"))
row = result.fetchone()
if row and row[0]:
print(f"✓ URL suffix exists: {row[0]}")
else:
print("✗ URL suffix is NULL or empty, setting it...")
urlSuffix = randomUrlSuffix(8)
conn.execute(text("UPDATE admin_settings SET motm_url_suffix = :suffix WHERE userid = 'admin'"), {'suffix': urlSuffix})
conn.commit()
print(f"✓ URL suffix set to: {urlSuffix}")
conn.close()
print("\n✓ All checks passed!")
EOF
```
## After Running the Fix
1. **Restart the application** to clear any cached connections:
```bash
kubectl rollout restart deployment/motm-app -n motm-app
```
2. **Test the MOTM admin page**:
- Go to https://motm.ervine.cloud/admin/motm
- Fill in the match details
- Click "Activate MotM Vote"
- It should work without errors
## Prevention
To prevent this issue in the future:
1. **Run database migrations** before deploying new code
2. **Use the migration script** (`add_voting_deadline.py`) to ensure all columns exist
3. **Test in staging** before deploying to production
4. **Monitor logs** for SQL errors
## Support
If you continue to have issues after running these diagnostics:
1. Save the output from `check_production_db.py`
2. Check the application logs: `kubectl logs -n motm-app -l app.kubernetes.io/name=motm-app --tail=100`
3. Verify the database connection is working
4. Check if there are any database permission issues

View File

@ -0,0 +1,219 @@
# Production Database Migration Guide
## Issue
The production database is missing the `votingdeadline` column, causing SQL errors when accessing the MOTM admin page.
## Solution
Run the migration script on the production Kubernetes cluster to add the missing column.
## Step-by-Step Instructions
### Option 1: Run Migration via kubectl exec (Recommended)
1. **Find your production pod:**
```bash
kubectl get pods -n <your-namespace> -l app.kubernetes.io/name=motm-app
```
Replace `<your-namespace>` with your actual namespace (e.g., `motm-app`, `default`, etc.)
2. **Execute the migration script:**
```bash
kubectl exec -it <pod-name> -n <your-namespace> -- python add_voting_deadline.py
```
Example:
```bash
kubectl exec -it motm-app-7d8f9b4c5-xk2mn -n motm-app -- python add_voting_deadline.py
```
3. **Verify the migration:**
```bash
kubectl exec -it <pod-name> -n <your-namespace> -- python -c "
from db_config import db_config
from sqlalchemy import text, inspect
engine = db_config.engine
inspector = inspect(engine)
columns = inspector.get_columns('admin_settings')
voting_deadline_exists = any(col['name'] == 'votingdeadline' for col in columns)
print('✓ votingdeadline column exists' if voting_deadline_exists else '✗ votingdeadline column missing')
"
```
### Option 2: Run Migration via Helm Job (Alternative)
If you prefer to run the migration as a Kubernetes job:
1. **Create a migration job manifest:**
```yaml
# migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: motm-migration-voting-deadline
namespace: motm-app
spec:
template:
spec:
containers:
- name: migration
image: your-registry/motm-app:latest
command: ["python", "add_voting_deadline.py"]
envFrom:
- secretRef:
name: motm-app-secrets
- configMapRef:
name: motm-app-config
restartPolicy: Never
backoffLimit: 3
```
2. **Apply the job:**
```bash
kubectl apply -f migration-job.yaml
```
3. **Check job status:**
```bash
kubectl get jobs -n motm-app
kubectl logs -n motm-app job/motm-migration-voting-deadline
```
4. **Clean up after successful migration:**
```bash
kubectl delete job motm-migration-voting-deadline -n motm-app
```
### Option 3: Run Migration via Helm Hook (Advanced)
For automated migrations during deployments:
1. **Create a migration job template:**
```yaml
# templates/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "motm-app.fullname" . }}-migration
namespace: {{ .Release.Namespace }}
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
template:
spec:
containers:
- name: migration
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["python", "add_voting_deadline.py"]
envFrom:
- secretRef:
name: {{ include "motm-app.fullname" . }}-secrets
restartPolicy: Never
backoffLimit: 3
```
2. **The migration will run automatically during helm upgrade**
## Verification
After running the migration, verify it worked:
1. **Check the application logs:**
```bash
kubectl logs -n <your-namespace> -l app.kubernetes.io/name=motm-app --tail=50
```
2. **Test the admin page:**
- Navigate to `https://motm.ervine.cloud/admin/motm`
- The page should load without SQL errors
- You should see the "Voting Deadline" field in the form
3. **Check for the column in the database:**
```bash
kubectl exec -it <pod-name> -n <your-namespace> -- python -c "
from db_config import db_config
from sqlalchemy import text, inspect
engine = db_config.engine
inspector = inspect(engine)
columns = inspector.get_columns('admin_settings')
print('Columns in admin_settings:')
for col in columns:
print(f' - {col[\"name\"]}: {col[\"type\"]}')
"
```
You should see `votingdeadline` in the list.
## Troubleshooting
### Issue: "pod not found"
**Solution:** Make sure you're using the correct namespace and pod name.
### Issue: "permission denied"
**Solution:** The migration script needs database write permissions. Ensure your database user has ALTER TABLE permissions.
### Issue: "column already exists"
**Solution:** This is fine! The migration script is idempotent and will skip if the column already exists.
### Issue: Migration fails
**Solution:** Check the pod logs for detailed error messages:
```bash
kubectl logs -n <your-namespace> <pod-name>
```
## Rollback (If Needed)
If the migration causes issues, you can rollback:
1. **Remove the column:**
```bash
kubectl exec -it <pod-name> -n <your-namespace> -- python -c "
from db_config import db_config
from sqlalchemy import text
engine = db_config.engine
with engine.connect() as conn:
conn.execute(text('ALTER TABLE admin_settings DROP COLUMN IF EXISTS votingdeadline'))
conn.commit()
print('Column removed')
"
```
2. **Restart the application:**
```bash
kubectl rollout restart deployment/motm-app -n <your-namespace>
```
## Quick Reference
```bash
# Find pod name
kubectl get pods -n motm-app -l app.kubernetes.io/name=motm-app
# Run migration
kubectl exec -it <POD_NAME> -n motm-app -- python add_voting_deadline.py
# Verify migration
kubectl exec -it <POD_NAME> -n motm-app -- python -c "from db_config import db_config; from sqlalchemy import text, inspect; engine = db_config.engine; inspector = inspect(engine); columns = inspector.get_columns('admin_settings'); print('✓ votingdeadline exists' if any(c['name'] == 'votingdeadline' for c in columns) else '✗ missing')"
# Check logs
kubectl logs -n motm-app -l app.kubernetes.io/name=motm-app --tail=100
```
## Next Steps
After the migration is complete:
1. ✅ Restart the application pods to clear any cached connections
2. ✅ Test the MOTM admin page
3. ✅ Set a voting deadline for the next match
4. ✅ Test the voting page to see the countdown timer
## Support
If you encounter any issues:
1. Check the pod logs for detailed error messages
2. Verify database connectivity
3. Ensure the database user has proper permissions
4. Review the application logs for any additional errors

98
motm_app/QUICK_FIX_COMMANDS.sh Executable file
View File

@ -0,0 +1,98 @@
#!/bin/bash
# Quick Fix Commands for MinIO Logo Issue
# Run these commands to fix the club logo display issue in Kubernetes
set -e
echo "=== MinIO Logo Fix Deployment Script ==="
echo ""
# Step 1: Find MinIO service endpoint
echo "Step 1: Finding MinIO service endpoint..."
echo "Current services in default namespace:"
kubectl get svc -n default | grep -i minio || echo "No MinIO service found in default namespace"
echo ""
echo "To search in all namespaces:"
echo "kubectl get svc --all-namespaces | grep -i minio"
echo ""
# Step 2: Check current deployment configuration
echo "Step 2: Checking current deployment..."
DEPLOYMENT=$(kubectl get deployment -n default -l app.kubernetes.io/name=motm-app -o name 2>/dev/null || echo "")
if [ -n "$DEPLOYMENT" ]; then
echo "Found deployment: $DEPLOYMENT"
echo "Current S3 environment variables:"
POD=$(kubectl get pods -n default -l app.kubernetes.io/name=motm-app -o name | head -n 1)
if [ -n "$POD" ]; then
kubectl exec -n default "$POD" -- env | grep S3_ || echo "No S3 env vars found"
fi
else
echo "No motm-app deployment found"
fi
echo ""
# Step 3: Navigate to helm chart directory
cd "$(dirname "$0")/helm-chart/motm-app" || exit 1
echo "Step 3: Changed to Helm chart directory: $(pwd)"
echo ""
# Step 4: Show upgrade command (don't execute automatically)
echo "Step 4: Ready to upgrade deployment"
echo ""
echo "Choose one of the following commands to upgrade:"
echo ""
echo "For PRODUCTION (recommended):"
echo " helm upgrade motm-app . -f values-production.yaml --namespace default"
echo ""
echo "For DEVELOPMENT:"
echo " helm upgrade motm-app . -f values-development.yaml --namespace default"
echo ""
echo "Or with custom MinIO endpoint (replace <endpoint> with your actual endpoint):"
echo " helm upgrade motm-app . \\"
echo " --set s3.storageProvider=minio \\"
echo " --set s3.endpoint=http://<service-name>.<namespace>.svc.cluster.local:9000 \\"
echo " --set s3.bucket=hockey-apps \\"
echo " --set s3.useSignedUrls=false \\"
echo " --set s3.useSSL=false \\"
echo " --namespace default"
echo ""
# Step 5: Verification commands
echo "After upgrade, verify with these commands:"
echo ""
echo "1. Check pod status:"
echo " kubectl get pods -n default -l app.kubernetes.io/name=motm-app"
echo ""
echo "2. Check environment variables in pod:"
echo " kubectl exec -n default \$(kubectl get pods -n default -l app.kubernetes.io/name=motm-app -o name | head -n 1) -- env | grep S3"
echo ""
echo "3. Check logs for errors:"
echo " kubectl logs -n default \$(kubectl get pods -n default -l app.kubernetes.io/name=motm-app -o name | head -n 1) --tail=50"
echo ""
echo "4. Test connection to MinIO from pod:"
echo " kubectl exec -n default \$(kubectl get pods -n default -l app.kubernetes.io/name=motm-app -o name | head -n 1) -- curl -I http://minio.default.svc.cluster.local:9000/minio/health/ready"
echo ""
# Step 6: MinIO bucket setup (if needed)
echo "If MinIO bucket doesn't exist or needs configuration:"
echo ""
echo "1. Install MinIO client (if not already installed):"
echo " wget https://dl.min.io/client/mc/release/linux-amd64/mc"
echo " chmod +x mc"
echo " sudo mv mc /usr/local/bin/"
echo ""
echo "2. Configure MinIO alias:"
echo " mc alias set myminio http://minio.default.svc.cluster.local:9000 <ACCESS_KEY> <SECRET_KEY>"
echo ""
echo "3. Create bucket (if it doesn't exist):"
echo " mc mb myminio/hockey-apps"
echo ""
echo "4. Set bucket policy to public read:"
echo " mc anonymous set download myminio/hockey-apps"
echo ""
echo "5. Upload logos to bucket:"
echo " mc cp --recursive ./static/images/clubs/ myminio/hockey-apps/assets/logos/"
echo ""
echo "=== End of Quick Fix Script ==="

128
motm_app/REVERT_SUMMARY.md Normal file
View File

@ -0,0 +1,128 @@
# Revert to motmadminsettings - Summary
## What Was Reverted
All changes have been reverted to use the original table and column names that match your production database.
### Table Name
- **Before (my changes):** `admin_settings`
- **After (reverted):** `motmadminsettings`
### Column Names (camelCase)
All column names reverted from snake_case to camelCase:
| Old (snake_case) | New (camelCase) |
|------------------|-----------------|
| `next_club` | `nextclub` ✅ |
| `next_team` | `nextteam` ✅ |
| `next_date` | `nextdate` ✅ |
| `oppo_logo` | `oppologo` ✅ |
| `hkfc_logo` | `hkfclogo` ✅ |
| `curr_motm` | `currmotm` ✅ |
| `curr_dotd` | `currdotd` ✅ |
| `next_fixture` | `nextfixture` ✅ |
| `motm_url_suffix` | `motmurlsuffix` ✅ |
| `prev_fixture` | `prevfixture` ✅ |
### WHERE Clauses
Removed all `WHERE userid = 'admin'` clauses from queries since the `motmadminsettings` table doesn't have a `userid` column.
## Files Modified
1. **`main.py`**
- All table references: `admin_settings``motmadminsettings`
- All column names: snake_case → camelCase
- Removed `WHERE userid = 'admin'` from all queries
2. **`readSettings.py`**
- Table reference: `admin_settings``motmadminsettings`
- Removed `WHERE userid = 'admin'` from query
- Removed camelCase to snake_case conversion logic
## Key Changes
### SQL Queries
**SELECT queries:**
```sql
-- Before (my changes)
SELECT next_club, next_team FROM admin_settings WHERE userid = 'admin'
-- After (reverted)
SELECT nextclub, nextteam FROM motmadminsettings
```
**UPDATE queries:**
```sql
-- Before (my changes)
UPDATE admin_settings SET motm_url_suffix = :suffix WHERE userid = 'admin'
-- After (reverted)
UPDATE motmadminsettings SET motmurlsuffix = :suffix
```
**INSERT queries:**
```sql
-- Before (my changes)
INSERT INTO admin_settings (userid, next_club, next_team, ...)
-- After (reverted)
INSERT INTO motmadminsettings (nextclub, nextteam, ...)
```
## What Still Works
✅ All the bug fixes I made still work:
- Debug logging for URL suffix issues
- Fallback logic for UPDATE queries
- Voting deadline feature
- Error handling improvements
## What's Different
❌ No longer using:
- `admin_settings` table (reverted to `motmadminsettings`)
- snake_case column names (reverted to camelCase)
- `WHERE userid = 'admin'` clauses (removed)
## Production Compatibility
✅ **Now compatible with production database:**
- Uses `motmadminsettings` table
- Uses camelCase column names
- No `userid` column references
## Testing
The code should now work with your production database without any migration needed.
### Quick Test
```bash
# Test locally (if you have motmadminsettings table)
python -c "
from db_config import sql_read_static
from sqlalchemy import text
result = sql_read_static(text('SELECT * FROM motmadminsettings'))
print('Table exists:', len(result) > 0)
"
# Deploy to production
docker build -t your-registry/motm-app:latest .
docker push your-registry/motm-app:latest
helm upgrade motm-app ./helm-chart/motm-app --namespace motm-app
```
## Next Steps
1. ✅ Code is reverted to use `motmadminsettings`
2. 🚀 Deploy to production
3. 🧪 Test MOTM admin page
4. ✅ URL suffix should now save correctly
## Why This Happened
I initially assumed you were using the newer `admin_settings` table with snake_case columns (which is what the SQLAlchemy ORM model defines). However, your production database still uses the legacy `motmadminsettings` table with camelCase columns.
The revert ensures the code matches your actual production database schema.

View File

@ -0,0 +1,511 @@
# S3/MinIO Database Configuration
## Overview
The MOTM application now supports database-backed S3/MinIO configuration with a three-tier priority system for maximum flexibility and security.
## Configuration Priority
The application loads S3 configuration in the following order (highest priority first):
### 1. Environment Variables (Highest Priority) 🔐
- **Use Case**: Kubernetes/container deployments, CI/CD pipelines
- **Credentials**: ALWAYS from environment variables (never stored in database or files)
- **When**: Detected if `S3_ENABLED` or `S3_ACCESS_KEY_ID` environment variables are set
- **Security**: Most secure option - credentials from Kubernetes secrets
### 2. Database Settings (Medium Priority) 💾
- **Use Case**: Admin-configurable via web UI
- **Storage**: PostgreSQL/MySQL/SQLite database table `s3_settings`
- **Credentials**: NOT stored in database - only configuration settings
- **When**: Used if no environment variables are set
- **Benefits**:
- Configure without redeploying
- No code changes needed
- Settings persist across container restarts
- Admin UI for easy management
### 3. JSON File (Lowest Priority) 📄
- **Use Case**: Local development only
- **Storage**: `s3_config.json` file
- **Credentials**: Stored in JSON file (local dev only)
- **When**: Used if no environment variables or database settings exist
- **Benefits**: Simple local development setup
## Database Schema
### S3Settings Table
```sql
CREATE TABLE s3_settings (
id INTEGER PRIMARY KEY,
userid VARCHAR(50) DEFAULT 'admin',
enabled BOOLEAN DEFAULT FALSE,
storage_provider VARCHAR(20) DEFAULT 'aws', -- 'aws' or 'minio'
endpoint VARCHAR(255) DEFAULT '',
region VARCHAR(50) DEFAULT 'us-east-1',
bucket_name VARCHAR(255) DEFAULT '',
bucket_prefix VARCHAR(255) DEFAULT 'assets/',
use_signed_urls BOOLEAN DEFAULT TRUE,
signed_url_expiry INTEGER DEFAULT 3600,
fallback_to_static BOOLEAN DEFAULT TRUE,
use_ssl BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Fields
- **enabled**: Enable/disable S3 storage globally
- **storage_provider**: `aws` for AWS S3, `minio` for MinIO/S3-compatible storage
- **endpoint**: MinIO endpoint URL (e.g., `https://minio.example.com` or `http://minio.default.svc.cluster.local:9000`)
- **region**: AWS region (required by boto3, MinIO ignores this)
- **bucket_name**: S3/MinIO bucket name
- **bucket_prefix**: Folder/prefix for assets (e.g., `assets/`, `production/`, `logos/`)
- **use_signed_urls**: Generate signed URLs for private buckets
- **signed_url_expiry**: Signed URL expiry time in seconds (default: 1 hour)
- **fallback_to_static**: Fall back to local static files if S3 is unavailable
- **use_ssl**: Use SSL/TLS for connections
### Security Note
**🔒 Credentials (`aws_access_key_id`, `aws_secret_access_key`) are NEVER stored in the database for security reasons.**
## Admin Configuration
### Web UI
1. Navigate to **Admin Dashboard** → **S3 Configuration**
2. Configure settings via the form:
- Enable/disable S3
- Select storage provider (AWS S3 or MinIO)
- Enter bucket details
- Configure URL settings
3. **Test Connection** to verify settings
4. **Save Configuration** to database
### What Gets Saved
✅ **Saved to Database:**
- Enable/disable flag
- Storage provider
- Endpoint URL
- Region
- Bucket name and prefix
- URL configuration
- SSL settings
❌ **NOT Saved to Database:**
- Access Key ID
- Secret Access Key
### Credentials Management
#### Local Development
Credentials entered in the web UI are saved to `s3_config.json` file for convenience.
#### Production/Kubernetes
Credentials **MUST** be provided via environment variables:
```bash
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
```
Typically configured in Helm chart secrets or Kubernetes secrets.
## Deployment Scenarios
### Scenario 1: Production Kubernetes with External Secret
```yaml
# values-production.yaml
secrets:
useExternalSecret: true
externalSecretName: "motm-credentials"
s3:
enabled: false # Controlled via database, not Helm values
```
**How it works:**
1. Admin enables S3 via web UI → settings saved to database
2. Credentials loaded from external Kubernetes secret via env vars
3. Configuration loaded from database
4. Application uses S3 without redeployment
### Scenario 2: Development with MinIO
```yaml
# values-development.yaml
s3:
enabled: true
storageProvider: "minio"
endpoint: "http://minio.default.svc.cluster.local:9000"
bucket: "motm-dev"
```
**How it works:**
1. Environment variables set from Helm chart
2. Environment variables override database settings
3. Good for consistent dev environment
### Scenario 3: Local Development
**Option A: Web UI Configuration**
1. Start application locally
2. Configure S3 via web UI
3. Settings saved to database
4. Credentials saved to `s3_config.json`
**Option B: JSON File**
1. Edit `s3_config.json` directly
2. Settings loaded from file
3. No database needed
## Migration Guide
### From JSON File to Database
**Step 1: Current JSON Config**
Your existing `s3_config.json`:
```json
{
"enable_s3": true,
"storage_provider": "aws",
"bucket_name": "motm-assets",
"aws_region": "us-east-1",
...
}
```
**Step 2: Web UI Import**
1. Navigate to S3 Configuration page
2. Current settings from JSON will pre-populate the form
3. Click "Save Configuration"
4. Settings now saved to database
**Step 3: Verify**
- Settings persist after restart
- Can delete `s3_config.json` (backup first!)
- Configuration now in database
### From Environment Variables to Database
If you're currently using environment variables for configuration (not just credentials):
**Step 1: Current Setup**
```yaml
# deployment.yaml
env:
- name: S3_ENABLED
value: "true"
- name: S3_BUCKET
value: "motm-assets"
# etc...
```
**Step 2: Move to Database**
1. Keep only credential environment variables:
```yaml
env:
- name: S3_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: motm-credentials
key: s3-access-key
- name: S3_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: motm-credentials
key: s3-secret-key
```
2. Configure settings via web UI
3. Remove configuration env vars from deployment
**Step 3: Benefits**
- Change bucket/region without redeployment
- Zero-downtime configuration updates
- Credentials still secure in Kubernetes secrets
## API Usage
### Loading Configuration
```python
from s3_config import s3_config_manager
# Get current config (respects priority: env > db > file)
config = s3_config_manager.get_config_dict()
# Check if S3 is enabled
if config['enable_s3']:
bucket = config['bucket_name']
provider = config['storage_provider']
```
### Saving Configuration
```python
from s3_config import s3_config_manager
config_data = {
'enable_s3': True,
'storage_provider': 'minio',
'minio_endpoint': 'https://minio.example.com',
'aws_region': 'us-east-1',
'bucket_name': 'motm-assets',
'bucket_prefix': 'production/',
'use_signed_urls': True,
'signed_url_expiry': 3600,
'fallback_to_static': True,
'minio_use_ssl': True,
# Credentials (not saved to database)
'aws_access_key_id': 'optional-for-local-dev',
'aws_secret_access_key': 'optional-for-local-dev'
}
# Save to database (or file as fallback)
success = s3_config_manager.save_config(config_data)
```
### Direct Database Access
```python
from database import get_db_session, S3Settings
session = get_db_session()
try:
# Get settings
settings = session.query(S3Settings).filter_by(userid='admin').first()
if settings:
print(f"S3 Enabled: {settings.enabled}")
print(f"Provider: {settings.storage_provider}")
print(f"Bucket: {settings.bucket_name}")
# Update settings
settings.enabled = True
settings.bucket_name = 'new-bucket'
session.commit()
finally:
session.close()
```
## Environment Variables Reference
### Required for Production
| Variable | Description | Example |
|----------|-------------|---------|
| `S3_ACCESS_KEY_ID` | S3/MinIO access key | `AKIAIOSFODNN7EXAMPLE` |
| `S3_SECRET_ACCESS_KEY` | S3/MinIO secret key | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` |
### Optional (Override Database Settings)
| Variable | Description | Default |
|----------|-------------|---------|
| `S3_ENABLED` | Enable S3 | `false` |
| `S3_STORAGE_PROVIDER` | Storage provider | `aws` |
| `S3_ENDPOINT` | Custom endpoint | `` |
| `S3_REGION` | AWS region | `us-east-1` |
| `S3_BUCKET` | Bucket name | `` |
| `S3_BUCKET_PREFIX` | Object prefix | `assets/` |
| `S3_USE_SIGNED_URLS` | Use signed URLs | `true` |
| `S3_SIGNED_URL_EXPIRY` | Expiry in seconds | `3600` |
| `S3_FALLBACK_TO_STATIC` | Fallback to static | `true` |
| `S3_USE_SSL` | Use SSL/TLS | `true` |
## Troubleshooting
### Settings Not Loading from Database
**Symptom:** Web UI configuration not being used
**Solutions:**
1. Check if environment variables are set (they override database)
```bash
env | grep S3_
```
2. Verify database table exists:
```sql
SELECT * FROM s3_settings WHERE userid='admin';
```
3. Check application logs for database connection errors
### Credentials Not Working
**Symptom:** "Access Denied" or authentication errors
**Solutions:**
1. **Kubernetes**: Verify secret exists and is mounted:
```bash
kubectl get secret motm-credentials
kubectl describe pod motm-app-xxx
```
2. **Local**: Check `s3_config.json` has credentials
3. **Environment**: Verify env vars are set:
```bash
echo $S3_ACCESS_KEY_ID
echo $S3_SECRET_ACCESS_KEY
```
### Database Migration Issues
**Symptom:** Table `s3_settings` doesn't exist
**Solution:**
```python
# Run database initialization
from database import init_database
init_database()
# Or via CLI
python -c "from database import init_database; init_database()"
```
### Configuration Not Persisting
**Symptom:** Settings reset after restart
**Causes & Solutions:**
1. **Using environment variables**: Env vars always override database
- Solution: Remove config env vars, keep only credential env vars
2. **Database not writable**: Check permissions
3. **Using ephemeral database**: In containers, use persistent volume
## Security Best Practices
### ✅ DO
- ✅ Use Kubernetes secrets for credentials in production
- ✅ Store only configuration in database
- ✅ Use environment variables for credentials
- ✅ Enable SSL/TLS for S3 connections
- ✅ Use signed URLs for private buckets
- ✅ Rotate credentials regularly
- ✅ Use IAM roles when possible (AWS)
- ✅ Restrict bucket permissions to minimum required
### ❌ DON'T
- ❌ Store credentials in database
- ❌ Commit `s3_config.json` to version control
- ❌ Share credentials in application logs
- ❌ Use root/admin credentials
- ❌ Disable SSL in production
- ❌ Make buckets public unless necessary
- ❌ Hard-code credentials in code
## Examples
### Example 1: AWS S3 Production
**Database Settings** (via Web UI):
- Enable S3: ✓
- Provider: AWS S3
- Region: us-east-1
- Bucket: motm-prod-assets
- Prefix: assets/
- Signed URLs: ✓
**Kubernetes Secret:**
```yaml
apiVersion: v1
kind: Secret
metadata:
name: motm-credentials
stringData:
s3-access-key: AKIAIOSFODNN7EXAMPLE
s3-secret-key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
```
### Example 2: MinIO In-Cluster
**Database Settings:**
- Enable S3: ✓
- Provider: MinIO
- Endpoint: http://minio.default.svc.cluster.local:9000
- Region: us-east-1 (ignored by MinIO)
- Bucket: motm-dev
- Prefix: dev/
- Signed URLs: ✗ (public bucket)
- Use SSL: ✗ (internal HTTP service)
**Kubernetes Secret:**
```yaml
apiVersion: v1
kind: Secret
metadata:
name: motm-credentials
stringData:
s3-access-key: minio-access-key
s3-secret-key: minio-secret-key
```
### Example 3: Digital Ocean Spaces
**Database Settings:**
- Enable S3: ✓
- Provider: MinIO (S3-compatible)
- Endpoint: https://nyc3.digitaloceanspaces.com
- Region: nyc3
- Bucket: motm-assets
- Prefix: production/
- Signed URLs: ✓
- Use SSL: ✓
## Summary
### Key Benefits
1. **Flexibility**: Three-tier priority system adapts to any deployment scenario
2. **Security**: Credentials never in database, always from secure sources
3. **Convenience**: Admin UI for configuration without redeployment
4. **Compatibility**: Backward compatible with existing file-based configuration
5. **Production-Ready**: Kubernetes-native with secret management
### Architecture Diagram
```
┌─────────────────────────────────────────┐
│ Application Startup │
└──────────────┬──────────────────────────┘
┌────────────────┐
│ Load S3 Config │
└────────┬───────┘
┌───────────────────────┐
│ 1. Check Env Vars? │
│ (S3_ENABLED set) │───YES──→ Use Environment Variables
└──────────┬────────────┘
│ NO
┌───────────────────────┐
│ 2. Check Database? │
│ (s3_settings table) │───YES──→ Use Database Settings
└──────────┬────────────┘ + Env Var Credentials
│ NO
┌───────────────────────┐
│ 3. Check JSON File? │
│ (s3_config.json) │───YES──→ Use JSON File
└──────────┬────────────┘
│ NO
┌───────────────────────┐
│ Use Defaults │
│ (S3 Disabled) │
└───────────────────────┘
```
## Conclusion
The database-backed S3 configuration provides a robust, secure, and flexible solution for managing object storage settings across different deployment scenarios while maintaining the highest security standards for credential management.
For questions or issues, refer to the troubleshooting section or check application logs for detailed error messages.

View File

@ -0,0 +1,137 @@
# Squad History Feature
## Overview
The squad history feature preserves historical match squad data when resetting for a new match. Previously, when the squad was reset, all player data was lost. Now it's automatically archived to a dedicated history table.
## What Changed
### Before
- Squad reset would delete or overwrite previous squad data
- No way to view who played in previous matches
- Historical squad information was lost forever
### After
- Squad data is **automatically archived** before being cleared
- Complete historical record preserved with match date and fixture number
- New admin interface to view all historical squads
- Easy lookup of who played in any previous match
## How It Works
### When You Reset the Squad
1. **Automatic Archival**: Before clearing the current squad, the system:
- Copies all players to the `squad_history` table
- Stores the match date and fixture number
- Records when the archive was created
2. **Clean Reset**: The current squad table is cleared for the new match
3. **Confirmation**: Shows how many players were archived
### Database Structure
**New Table: `squad_history`**
```sql
- id (primary key)
- player_number
- player_forenames
- player_surname
- player_nickname
- match_date
- fixture_number
- archived_at (timestamp)
```
## Using the Feature
### Resetting the Squad (with History)
1. Go to **Admin Dashboard** → **Match Squad Management**
2. Click **"Reset Squad"**
3. System automatically:
- Archives current squad with match details
- Clears squad for new match
- Shows confirmation message
### Viewing Squad History
1. Go to **Admin Dashboard****Squad History**
2. See summary of all historical squads
3. Click **"View Details"** on any fixture to see the full squad
4. Scroll to see detailed player lists for each match
## Migration
The migration has been **automatically completed**! ✅
The `squad_history` table was created successfully.
For future databases or if needed again:
```bash
python add_squad_history.py
```
## Features
**Automatic Archival**: No manual steps needed
**Complete Records**: All player details preserved
**Easy Navigation**: Summary view with drill-down details
**Match Context**: Linked to match date and fixture number
**Safe Reset**: Squad clearing only happens after successful archive
## Admin Interface
### Squad History Page Shows:
**Summary Table:**
- Fixture Number
- Match Date
- Player Count
- Quick view button
**Detailed View:**
- Full squad rosters grouped by match
- Player numbers, nicknames, and full names
- Archive timestamp for audit trail
## Benefits
1. **Historical Reference**: See who played in any match
2. **Team Analysis**: Track player participation over time
3. **Data Integrity**: No more lost squad data
4. **Audit Trail**: Know when squads were archived
5. **Reporting**: Export or analyze squad patterns
## Technical Details
### Files Modified
1. **`database.py`** - Added `SquadHistory` model
2. **`main.py`** - Updated reset function and added history route
3. **`templates/squad_history.html`** - New history viewing interface
4. **`add_squad_history.py`** - Migration script
### Safety Features
- Transaction-based: Archive completes before deletion
- Error handling: If archive fails, squad is not cleared
- Flash messages: Clear feedback on what happened
- Rollback capable: Can restore from history if needed
## Future Enhancements
Potential additions for future versions:
- Export squad history to CSV/Excel
- Compare squads between matches
- Player participation statistics
- Squad restoration from history
- Search/filter historical squads
- Visual squad timeline
---
**Implementation Date**: October 2025
**Status**: ✅ Active and Working

View File

@ -0,0 +1,121 @@
# Voting Deadline Feature
## Overview
The voting deadline feature adds a countdown timer to the MOTM (Man of the Match) voting page and prevents votes from being cast after the specified deadline.
## Features
### 1. Admin Configuration
- Admins can set a voting deadline when configuring match details
- The deadline field is optional - leave it empty for no deadline
- Uses a datetime-local input for easy date and time selection
- The deadline is automatically saved with the match settings
### 2. Countdown Timer
- Displays a prominent countdown timer on the voting page
- Shows time remaining in a user-friendly format:
- Days, hours, minutes, seconds (when days remaining)
- Hours, minutes, seconds (when hours remaining)
- Minutes, seconds (when minutes remaining)
- Seconds only (when less than 1 minute remaining)
- Color-coded alerts:
- Blue (info) - More than 5 minutes remaining
- Yellow (warning) - Less than 5 minutes remaining
- Red (danger) - Less than 1 minute remaining
### 3. Vote Protection
- **Client-side**: Form inputs are disabled when the deadline is reached
- **Server-side**: Backend validation prevents vote submission after deadline
- Shows a clear "Voting has closed" message when deadline passes
- Both frontend and backend validation ensure votes cannot bypass the deadline
## Usage
### For Administrators
1. Navigate to **Admin Dashboard** → **MOTM Settings**
2. Fill in the match details (date, opponent, etc.)
3. Set the **Voting Deadline** using the date/time picker
- Example: `2025-10-15 18:00` (6 PM on October 15, 2025)
4. Click **Save Settings** or **Activate MotM Vote**
### For Voters
When a deadline is set:
- The voting page will display a countdown timer at the top
- The timer updates every second
- When the deadline is reached:
- The countdown is replaced with "Voting has closed"
- All form fields are disabled
- The submit button shows "Voting Closed"
- Attempting to submit after the deadline shows an error message
## Database Migration
### For Existing Installations
If you're upgrading from a version without the voting deadline feature, run the migration script:
```bash
python add_voting_deadline.py
```
This script will:
- Add the `votingdeadline` column to the `motmadminsettings` table
- Check if the column already exists (safe to run multiple times)
- Support both PostgreSQL and SQLite databases
### For New Installations
New installations will automatically include the `voting_deadline` column when creating the database.
## Technical Details
### Database Schema
**Table**: `motmadminsettings` (or `admin_settings` in ORM)
New column:
- `votingdeadline` (TIMESTAMP/DATETIME, nullable)
### Files Modified
1. **database.py** - Added `voting_deadline` column to `AdminSettings` model
2. **forms.py** - Added `votingDeadline` field to `adminSettingsForm2`
3. **main.py** - Updated routes:
- `/admin/motm` - Save and load deadline
- `/motm/<randomUrlSuffix>` - Pass deadline to template
- `/motm/vote-thanks` - Validate deadline on submission
4. **templates/motm_admin.html** - Added deadline input field
5. **templates/motm_vote.html** - Added countdown timer UI and JavaScript
### Security Considerations
- Client-side validation provides immediate feedback
- Server-side validation in `vote_thanks()` route is the authoritative check
- Users cannot bypass the deadline by manipulating JavaScript
- Timezone-aware datetime handling ensures consistency
## Troubleshooting
### Issue: Countdown timer not appearing
- **Cause**: No deadline set in admin settings
- **Solution**: Set a deadline in the MOTM settings page
### Issue: "Column doesn't exist" error
- **Cause**: Database needs migration
- **Solution**: Run `python add_voting_deadline.py`
### Issue: Votes still accepted after deadline
- **Cause**: Server timezone mismatch
- **Solution**: Ensure server datetime is correctly configured
## Future Enhancements
Potential improvements for future versions:
- Timezone selection for deadline
- Email notifications when voting closes
- Automatic deadline based on match date (e.g., 24 hours after match)
- Grace period for late votes with visual indicator

View File

@ -0,0 +1,147 @@
# Voting Deadline Implementation Summary
## ✅ Feature Complete
The voting deadline feature has been successfully implemented! This feature adds a countdown timer to the MOTM voting page and prevents votes from being cast after a specified deadline.
## What's New
### 1. **Admin Interface**
- Added a "Voting Deadline" field in the MOTM Settings page
- Admins can set when voting should close using a datetime picker
- The field is optional - leave it blank for unlimited voting
### 2. **Voting Page**
- **Countdown Timer**: Shows time remaining in real-time
- Updates every second
- Color-coded alerts (blue → yellow → red as deadline approaches)
- Displays in human-friendly format (days, hours, minutes, seconds)
- **Automatic Lockout**: When deadline hits zero:
- Timer is replaced with "Voting has closed" message
- All form inputs are disabled
- Submit button shows "Voting Closed"
### 3. **Security**
- **Client-side validation**: Immediate feedback to users
- **Server-side validation**: Authoritative check (cannot be bypassed)
- Returns error page if someone tries to submit after deadline
## Files Changed
### Backend (Python)
1. **`database.py`** - Added `voting_deadline` column to `AdminSettings` model
2. **`forms.py`** - Added `votingDeadline` field to admin form
3. **`main.py`** - Updated three routes:
- `/admin/motm` - Save and load deadline
- `/motm/<randomUrlSuffix>` - Pass deadline to template
- `/motm/vote-thanks` - Validate deadline on submission
### Frontend (Templates)
4. **`templates/motm_admin.html`** - Added deadline input field with datetime picker
5. **`templates/motm_vote.html`** - Added countdown timer UI and JavaScript logic
### Migration
6. **`add_voting_deadline.py`** - Database migration script (auto-run)
7. **`VOTING_DEADLINE_FEATURE.md`** - User documentation
## How to Use
### As an Admin
1. Go to **Admin Dashboard** → **MOTM Settings**
2. Fill in match details (date, opponent, etc.)
3. Set the **Voting Deadline**:
- Click the datetime picker field
- Select date and time when voting should close
- Example: `2025-10-15 18:00` (6 PM on Oct 15)
4. Click **Save Settings** or **Activate MotM Vote**
### As a Voter
- If a deadline is set, you'll see a countdown timer at the top of the voting page
- The timer shows exactly how much time is left
- When time runs out, you won't be able to vote
- Try to vote before the deadline! ⏰
## Database Migration
The migration has been **automatically completed** for your database! ✅
The `votingdeadline` column has been added to the `admin_settings` table.
If you need to run it again on another database:
```bash
python add_voting_deadline.py
```
## Testing the Feature
### Test Scenario 1: Set a deadline 5 minutes in the future
1. Go to MOTM Settings
2. Set deadline to 5 minutes from now
3. Save and activate voting
4. Open the voting page
5. Watch the countdown timer
6. Timer should change color as it gets closer to zero
7. When it hits zero, form should be disabled
### Test Scenario 2: Try to vote after deadline
1. Wait for deadline to pass
2. Try to submit a vote
3. Should show error: "Voting has closed. The deadline has passed."
### Test Scenario 3: No deadline
1. Leave the deadline field empty
2. Save settings
3. Voting page should NOT show countdown timer
4. Voting should work indefinitely
## Browser Compatibility
The countdown timer uses standard JavaScript and should work on:
- ✅ Chrome/Edge (all recent versions)
- ✅ Firefox (all recent versions)
- ✅ Safari (all recent versions)
- ✅ Mobile browsers
## Color Coding
The countdown timer changes color based on urgency:
| Time Remaining | Color | Meaning |
|---------------|-------|---------|
| > 5 minutes | Blue (Info) | Plenty of time |
| 1-5 minutes | Yellow (Warning) | Getting close! |
| < 1 minute | Red (Danger) | Hurry! |
## Tips
1. **Set realistic deadlines**: Give voters enough time to participate
2. **Consider timezones**: The deadline uses server time
3. **Test first**: Try with a short deadline to see how it works
4. **No deadline needed**: Leave blank for unlimited voting
## Troubleshooting
### Issue: Countdown not showing
**Solution**: Make sure you've set a deadline in the admin settings
### Issue: "Voting has closed" but should be open
**Solution**: Check the deadline in admin settings - may be set incorrectly
### Issue: Time seems wrong
**Solution**: Server timezone may differ from your local time
## Next Steps
The feature is ready to use! Here's what you can do:
1. ✅ Set up your next match in MOTM Settings
2. ✅ Add a voting deadline
3. ✅ Share the voting URL with your team
4. ✅ Watch the votes come in before the deadline!
---
**Need help?** Check `VOTING_DEADLINE_FEATURE.md` for detailed documentation.

View File

@ -0,0 +1,109 @@
# SQL WHERE Clause Fixes
## Problem
When activating the MOTM vote, the application was throwing an error: "Database not initialized. Please go to Database Setup to initialize the database."
## Root Cause
Multiple SQL queries on the `admin_settings` table were missing `WHERE userid = 'admin'` clauses, causing:
1. UPDATE queries to update ALL rows instead of just the admin row
2. SELECT queries to return unexpected results
3. The application to think the database wasn't initialized
## Fixes Applied
### 1. UPDATE Queries Fixed
**Line 599** - Main settings update:
```sql
-- BEFORE:
UPDATE admin_settings SET next_date = :next_date, next_club = :next_club, ...
-- AFTER:
UPDATE admin_settings SET next_date = :next_date, next_club = :next_club, ... WHERE userid = 'admin'
```
**Line 613** - Opponent logo update:
```sql
-- BEFORE:
UPDATE admin_settings SET oppo_logo = :logo_url
-- AFTER:
UPDATE admin_settings SET oppo_logo = :logo_url WHERE userid = 'admin'
```
### 2. SELECT Queries Fixed
**Line 256** - MOTM vote page:
```sql
-- BEFORE:
SELECT next_club, next_team, next_date, ... FROM admin_settings
-- AFTER:
SELECT next_club, next_team, next_date, ... FROM admin_settings WHERE userid = 'admin'
```
**Line 348** - Match comments page:
```sql
-- BEFORE:
SELECT next_club, next_team, next_date, oppo_logo, hkfc_logo FROM admin_settings
-- AFTER:
SELECT next_club, next_team, next_date, oppo_logo, hkfc_logo FROM admin_settings WHERE userid = 'admin'
```
**Line 683** - Admin settings page:
```sql
-- BEFORE:
SELECT next_club, oppo_logo FROM admin_settings
-- AFTER:
SELECT next_club, oppo_logo FROM admin_settings WHERE userid = 'admin'
```
## Why This Matters
Without the WHERE clause:
- **UPDATE** queries would modify all rows in the table (even if there's only one row, this is bad practice)
- **SELECT** queries might return multiple rows when only one is expected
- The application logic assumes only one admin settings row exists
## Testing
After these fixes:
1. ✅ The MOTM admin page should save settings correctly
2. ✅ Activating the MOTM vote should work without errors
3. ✅ The voting page should load correctly
4. ✅ All admin settings queries will target only the admin row
## Deployment
To apply these fixes to production:
1. Commit the changes to your repository
2. Rebuild and redeploy the Docker image
3. Restart the application pods
```bash
# Build new image
docker build -t your-registry/motm-app:latest .
# Push to registry
docker push your-registry/motm-app:latest
# Deploy to Kubernetes
helm upgrade motm-app ./helm-chart/motm-app --namespace motm-app
```
## Related Issues
These fixes are related to:
- The voting deadline feature implementation
- The table name migration from `motmadminsettings` to `admin_settings`
- The column name migration to snake_case
## Summary
All SQL queries on the `admin_settings` table now properly filter by `userid = 'admin'`, ensuring:
- Data integrity
- Predictable query results
- Proper application functionality

91
motm_app/add_squad_history.py Executable file
View File

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Database migration script to add squad_history table for historical squad tracking.
This script creates the squad_history table to preserve squad data when resetting for a new match.
"""
from db_config import db_config
from sqlalchemy import text
import sys
def add_squad_history_table():
"""Create squad_history table for historical squad records."""
try:
engine = db_config.engine
with engine.connect() as connection:
# Check if table already exists
try:
result = connection.execute(text("SELECT COUNT(*) FROM squad_history LIMIT 1"))
print("✓ Table 'squad_history' already exists")
return True
except Exception:
pass
# Create the squad_history table
try:
# PostgreSQL/SQLite compatible syntax
create_table_sql = text("""
CREATE TABLE squad_history (
id SERIAL PRIMARY KEY,
player_number INTEGER,
player_forenames VARCHAR(50),
player_surname VARCHAR(30),
player_nickname VARCHAR(30),
match_date DATE,
fixture_number VARCHAR(20),
archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
connection.execute(create_table_sql)
connection.commit()
print("✓ Successfully created 'squad_history' table (PostgreSQL)")
return True
except Exception as e:
# Try SQLite syntax
try:
connection.rollback()
create_table_sql = text("""
CREATE TABLE squad_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_number INTEGER,
player_forenames VARCHAR(50),
player_surname VARCHAR(30),
player_nickname VARCHAR(30),
match_date DATE,
fixture_number VARCHAR(20),
archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
connection.execute(create_table_sql)
connection.commit()
print("✓ Successfully created 'squad_history' table (SQLite)")
return True
except Exception as e2:
print(f"✗ Error creating table: {str(e2)}")
return False
except Exception as e:
print(f"✗ Error connecting to database: {str(e)}")
return False
if __name__ == "__main__":
print("=" * 60)
print("Squad History Table - Database Migration")
print("=" * 60)
print("\nThis script will create the 'squad_history' table to preserve")
print("historical squad data when resetting for new matches.\n")
result = add_squad_history_table()
if result:
print("\n✓ Migration completed successfully!")
print("\nSquad data will now be preserved when you reset the squad.")
print("Historical squads are stored with match date and fixture number.")
sys.exit(0)
else:
print("\n✗ Migration failed!")
print("Please check the error messages above.")
sys.exit(1)

80
motm_app/add_voting_deadline.py Executable file
View File

@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Database migration script to add voting_deadline column to motmadminsettings table.
This script adds the voting_deadline column to support countdown timers for MOTM voting.
It's safe to run multiple times - it will skip if the column already exists.
"""
from db_config import db_config
from sqlalchemy import text
import sys
def add_voting_deadline_column():
"""Add voting_deadline column to motmadminsettings or admin_settings table."""
try:
# Get database connection
engine = db_config.engine
# Try both possible table names
table_names = ['motmadminsettings', 'admin_settings']
for table_name in table_names:
try:
with engine.connect() as connection:
# Check if table exists and column already exists
try:
result = connection.execute(text(f"SELECT votingdeadline FROM {table_name} LIMIT 1"))
print(f"✓ Column 'votingdeadline' already exists in {table_name} table")
return True
except Exception as e:
# Column or table doesn't exist
pass
# Try to add the column
try:
# PostgreSQL syntax
connection.execute(text(f"ALTER TABLE {table_name} ADD COLUMN votingdeadline TIMESTAMP"))
connection.commit()
print(f"✓ Successfully added 'votingdeadline' column to {table_name} table (PostgreSQL)")
return True
except Exception as e:
# Try SQLite syntax
try:
connection.rollback()
connection.execute(text(f"ALTER TABLE {table_name} ADD COLUMN votingdeadline DATETIME"))
connection.commit()
print(f"✓ Successfully added 'votingdeadline' column to {table_name} table (SQLite)")
return True
except Exception as e2:
# This table doesn't exist, try next one
continue
except Exception as e:
continue
print(f"✗ Error: Could not find admin settings table (tried: {', '.join(table_names)})")
print(" The database may not be initialized yet. Please run database setup first.")
return False
except Exception as e:
print(f"✗ Error connecting to database: {str(e)}")
return False
if __name__ == "__main__":
print("=" * 60)
print("MOTM Voting Deadline - Database Migration")
print("=" * 60)
print("\nThis script will add the 'votingdeadline' column to the")
print("motmadminsettings table to support countdown timers.\n")
result = add_voting_deadline_column()
if result:
print("\n✓ Migration completed successfully!")
print("\nYou can now set voting deadlines in the admin interface.")
sys.exit(0)
else:
print("\n✗ Migration failed!")
print("Please check the error messages above.")
sys.exit(1)

81
motm_app/check_production_db.py Executable file
View File

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
Diagnostic script to check production database tables and data.
Run this in your production Kubernetes pod to diagnose the issue.
"""
from db_config import db_config, sql_read_static
from sqlalchemy import text, inspect
def check_database():
"""Check database tables and data"""
print("=" * 60)
print("Production Database Diagnostic")
print("=" * 60)
print()
engine = db_config.engine
inspector = inspect(engine)
tables = inspector.get_table_names()
print("1. Checking tables...")
print(f" Total tables: {len(tables)}")
print(f" Has 'admin_settings': {'admin_settings' in tables}")
print(f" Has 'motmadminsettings': {'motmadminsettings' in tables}")
print()
# Check admin_settings table
if 'admin_settings' in tables:
print("2. Checking 'admin_settings' table...")
try:
result = sql_read_static(text("SELECT * FROM admin_settings"))
print(f" Rows: {len(result)}")
if result:
row = result[0]
print(f" Columns: {list(row.keys())}")
print(f" userid: {row.get('userid')}")
print(f" next_date: {row.get('next_date')}")
print(f" next_club: {row.get('next_club')}")
print(f" next_team: {row.get('next_team')}")
print(f" motm_url_suffix: {row.get('motm_url_suffix')}")
print(f" votingdeadline: {row.get('votingdeadline')}")
except Exception as e:
print(f" Error: {e}")
print()
# Check motmadminsettings table
if 'motmadminsettings' in tables:
print("3. Checking 'motmadminsettings' table...")
try:
result = sql_read_static(text("SELECT * FROM motmadminsettings"))
print(f" Rows: {len(result)}")
if result:
row = result[0]
print(f" Columns: {list(row.keys())}")
except Exception as e:
print(f" Error: {e}")
print()
# Test the specific query that's failing
print("4. Testing the failing query...")
try:
sql5 = text("SELECT motm_url_suffix FROM admin_settings WHERE userid = 'admin'")
tempSuffix = sql_read_static(sql5)
print(f" Query result: {tempSuffix}")
print(f" Result length: {len(tempSuffix) if tempSuffix else 0}")
if tempSuffix:
print(f" motm_url_suffix value: {tempSuffix[0].get('motm_url_suffix')}")
print(f" Is None or empty: {not tempSuffix[0].get('motm_url_suffix')}")
else:
print(" ERROR: No results returned!")
except Exception as e:
print(f" Error: {e}")
print()
print("=" * 60)
print("Diagnostic Complete")
print("=" * 60)
if __name__ == "__main__":
check_database()

View File

@ -25,7 +25,7 @@ class DatabaseConfig:
def _get_database_url(self):
"""Get database URL from environment variables or configuration."""
db_type = os.getenv('DATABASE_TYPE', 'sqlite').lower()
db_type = os.getenv('DB_TYPE', os.getenv('DATABASE_TYPE', 'sqlite')).lower()
if db_type == 'postgresql':
return self._get_postgresql_url()
@ -38,28 +38,28 @@ class DatabaseConfig:
def _get_postgresql_url(self):
"""Get PostgreSQL connection URL."""
host = os.getenv('POSTGRES_HOST', 'localhost')
port = os.getenv('POSTGRES_PORT', '5432')
database = os.getenv('POSTGRES_DATABASE', 'hockey_results')
username = os.getenv('POSTGRES_USER', 'postgres')
password = os.getenv('POSTGRES_PASSWORD', '')
host = os.getenv('DB_HOST', os.getenv('POSTGRES_HOST', 'localhost'))
port = os.getenv('DB_PORT', os.getenv('POSTGRES_PORT', '5432'))
database = os.getenv('DB_NAME', os.getenv('POSTGRES_DATABASE', 'hockey_results'))
username = os.getenv('DB_USER', os.getenv('POSTGRES_USER', 'postgres'))
password = os.getenv('DB_PASSWORD', os.getenv('POSTGRES_PASSWORD', ''))
return f"postgresql://{username}:{password}@{host}:{port}/{database}"
def _get_mysql_url(self):
"""Get MySQL/MariaDB connection URL."""
host = os.getenv('MYSQL_HOST', 'localhost')
port = os.getenv('MYSQL_PORT', '3306')
database = os.getenv('MYSQL_DATABASE', 'hockey_results')
username = os.getenv('MYSQL_USER', 'root')
password = os.getenv('MYSQL_PASSWORD', '')
host = os.getenv('DB_HOST', os.getenv('MYSQL_HOST', 'localhost'))
port = os.getenv('DB_PORT', os.getenv('MYSQL_PORT', '3306'))
database = os.getenv('DB_NAME', os.getenv('MYSQL_DATABASE', 'hockey_results'))
username = os.getenv('DB_USER', os.getenv('MYSQL_USER', 'root'))
password = os.getenv('DB_PASSWORD', os.getenv('MYSQL_PASSWORD', ''))
charset = os.getenv('MYSQL_CHARSET', 'utf8mb4')
return f"mysql+pymysql://{username}:{password}@{host}:{port}/{database}?charset={charset}"
def _get_sqlite_url(self):
"""Get SQLite connection URL."""
database_path = os.getenv('SQLITE_DATABASE_PATH', 'hockey_results.db')
database_path = os.getenv('DB_NAME', os.getenv('SQLITE_DATABASE_PATH', 'hockey_results.db'))
return f"sqlite:///{database_path}"
def get_session(self):
@ -124,6 +124,19 @@ class MatchSquad(Base):
match_date = Column(Date)
created_at = Column(DateTime, default=datetime.utcnow)
class SquadHistory(Base):
"""Historical match squad records."""
__tablename__ = 'squad_history'
id = Column(Integer, primary_key=True)
player_number = Column(Integer, ForeignKey('players.player_number'))
player_forenames = Column(String(50))
player_surname = Column(String(30))
player_nickname = Column(String(30))
match_date = Column(Date)
fixture_number = Column(String(20))
archived_at = Column(DateTime, default=datetime.utcnow)
class HockeyFixture(Base):
"""Hockey fixture model."""
__tablename__ = 'hockey_fixtures'
@ -158,6 +171,7 @@ class AdminSettings(Base):
hkfc_logo = Column(String(255))
motm_url_suffix = Column(String(50))
prev_fixture = Column(Integer)
voting_deadline = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@ -201,6 +215,29 @@ class HockeyUser(Base):
created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime)
class S3Settings(Base):
"""S3/MinIO storage configuration model.
Note: Credentials (access_key_id, secret_access_key) are NEVER stored in the database.
They must be provided via environment variables for security.
"""
__tablename__ = 's3_settings'
id = Column(Integer, primary_key=True)
userid = Column(String(50), default='admin')
enabled = Column(Boolean, default=False)
storage_provider = Column(String(20), default='aws') # 'aws' or 'minio'
endpoint = Column(String(255), default='') # MinIO endpoint or custom S3 endpoint
region = Column(String(50), default='us-east-1')
bucket_name = Column(String(255), default='')
bucket_prefix = Column(String(255), default='assets/')
use_signed_urls = Column(Boolean, default=True)
signed_url_expiry = Column(Integer, default=3600) # seconds
fallback_to_static = Column(Boolean, default=True)
use_ssl = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Database utility functions
def get_db_session():
"""Get database session."""

View File

@ -13,7 +13,7 @@ from sqlalchemy.exc import SQLAlchemyError
from database import Base, db_config, init_database
from database import (
Player, Club, Team, MatchSquad, HockeyFixture,
AdminSettings, MotmVote, MatchComment, HockeyUser
AdminSettings, MotmVote, MatchComment, HockeyUser, S3Settings
)
class DatabaseConfigManager:
@ -102,29 +102,54 @@ class DatabaseConfigManager:
self.config.write(f)
def _update_environment_variables(self):
"""Update environment variables based on configuration."""
"""Update environment variables based on configuration.
Only updates environment variables if they're not already set.
This allows Kubernetes secrets to take precedence.
"""
db_type = self.config['DATABASE']['type']
if db_type == 'sqlite':
os.environ['DATABASE_TYPE'] = 'sqlite'
os.environ['SQLITE_DATABASE_PATH'] = self.config['DATABASE']['sqlite_database_path']
if 'DATABASE_TYPE' not in os.environ and 'DB_TYPE' not in os.environ:
os.environ['DATABASE_TYPE'] = 'sqlite'
if 'SQLITE_DATABASE_PATH' not in os.environ and 'DB_NAME' not in os.environ:
os.environ['SQLITE_DATABASE_PATH'] = self.config['DATABASE']['sqlite_database_path']
elif db_type == 'mysql':
os.environ['DATABASE_TYPE'] = 'mysql'
os.environ['MYSQL_HOST'] = self.config['MYSQL']['host']
os.environ['MYSQL_PORT'] = self.config['MYSQL']['port']
os.environ['MYSQL_DATABASE'] = self.config['MYSQL']['database']
os.environ['MYSQL_USER'] = self.config['MYSQL']['username']
os.environ['MYSQL_PASSWORD'] = self.config['MYSQL']['password']
os.environ['MYSQL_CHARSET'] = self.config['MYSQL']['charset']
if 'DATABASE_TYPE' not in os.environ and 'DB_TYPE' not in os.environ:
os.environ['DATABASE_TYPE'] = 'mysql'
if 'MYSQL_HOST' not in os.environ and 'DB_HOST' not in os.environ:
os.environ['MYSQL_HOST'] = self.config['MYSQL']['host']
if 'MYSQL_PORT' not in os.environ and 'DB_PORT' not in os.environ:
os.environ['MYSQL_PORT'] = self.config['MYSQL']['port']
if 'MYSQL_DATABASE' not in os.environ and 'DB_NAME' not in os.environ:
os.environ['MYSQL_DATABASE'] = self.config['MYSQL']['database']
if 'MYSQL_USER' not in os.environ and 'DB_USER' not in os.environ:
os.environ['MYSQL_USER'] = self.config['MYSQL']['username']
if 'MYSQL_PASSWORD' not in os.environ and 'DB_PASSWORD' not in os.environ:
# Only set password from config if it exists and no env var is set
password = self.config['MYSQL'].get('password', '')
if password:
os.environ['MYSQL_PASSWORD'] = password
if 'MYSQL_CHARSET' not in os.environ:
os.environ['MYSQL_CHARSET'] = self.config['MYSQL']['charset']
elif db_type == 'postgresql':
os.environ['DATABASE_TYPE'] = 'postgresql'
os.environ['POSTGRES_HOST'] = self.config['POSTGRESQL']['host']
os.environ['POSTGRES_PORT'] = self.config['POSTGRESQL']['port']
os.environ['POSTGRES_DATABASE'] = self.config['POSTGRESQL']['database']
os.environ['POSTGRES_USER'] = self.config['POSTGRESQL']['username']
os.environ['POSTGRES_PASSWORD'] = self.config['POSTGRESQL']['password']
if 'DATABASE_TYPE' not in os.environ and 'DB_TYPE' not in os.environ:
os.environ['DATABASE_TYPE'] = 'postgresql'
if 'POSTGRES_HOST' not in os.environ and 'DB_HOST' not in os.environ:
os.environ['POSTGRES_HOST'] = self.config['POSTGRESQL']['host']
if 'POSTGRES_PORT' not in os.environ and 'DB_PORT' not in os.environ:
os.environ['POSTGRES_PORT'] = self.config['POSTGRESQL']['port']
if 'POSTGRES_DATABASE' not in os.environ and 'DB_NAME' not in os.environ:
os.environ['POSTGRES_DATABASE'] = self.config['POSTGRESQL']['database']
if 'POSTGRES_USER' not in os.environ and 'DB_USER' not in os.environ:
os.environ['POSTGRES_USER'] = self.config['POSTGRESQL']['username']
if 'POSTGRES_PASSWORD' not in os.environ and 'DB_PASSWORD' not in os.environ:
# Only set password from config if it exists and no env var is set
password = self.config['POSTGRESQL'].get('password', '')
if password:
os.environ['POSTGRES_PASSWORD'] = password
def test_connection(self, form_data):
"""Test database connection with provided settings."""
@ -259,11 +284,29 @@ class DatabaseConfigManager:
)
session.add(admin_settings)
# Create S3 settings (only if they don't exist)
existing_s3 = session.query(S3Settings).filter_by(userid='admin').first()
if not existing_s3:
s3_settings = S3Settings(
userid='admin',
enabled=False, # Disabled by default
storage_provider='aws',
endpoint='',
region='us-east-1',
bucket_name='',
bucket_prefix='assets/',
use_signed_urls=True,
signed_url_expiry=3600,
fallback_to_static=True,
use_ssl=True
)
session.add(s3_settings)
# Create sample fixtures (only if they don't exist)
fixtures_data = [
{'fixture_number': 1, 'date': datetime(2024, 1, 15), 'home_team': 'HKFC C', 'away_team': 'KCC A', 'venue': 'HKFC'},
{'fixture_number': 2, 'date': datetime(2024, 1, 22), 'home_team': 'USRC A', 'away_team': 'HKFC C', 'venue': 'USRC'},
{'fixture_number': 3, 'date': datetime(2024, 1, 29), 'home_team': 'HKFC C', 'away_team': 'Valley A', 'venue': 'HKFC'},
{'fixture_number': 1, 'date': datetime(2025, 1, 15), 'home_team': 'HKFC C', 'away_team': 'KCC A', 'venue': 'HKFC'},
{'fixture_number': 2, 'date': datetime(2025, 1, 22), 'home_team': 'USRC A', 'away_team': 'HKFC C', 'venue': 'USRC'},
{'fixture_number': 3, 'date': datetime(2025, 1, 29), 'home_team': 'HKFC C', 'away_team': 'Valley A', 'venue': 'HKFC'},
]
for fixture_data in fixtures_data:

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
Fix the URL suffix UPDATE query to work regardless of userid value.
This script updates the query to not use WHERE userid = 'admin' if that's causing issues.
"""
# The issue is that the WHERE clause might not be matching
# Let's update the query to update ALL rows or use a different approach
# Option 1: Update all rows (if there's only one row)
# UPDATE admin_settings SET motm_url_suffix = :url_suffix
# Option 2: Update by id (if id is always 1)
# UPDATE admin_settings SET motm_url_suffix = :url_suffix WHERE id = 1
# Option 3: Use INSERT ... ON CONFLICT (PostgreSQL specific)
# INSERT INTO admin_settings (userid, motm_url_suffix)
# VALUES ('admin', :url_suffix)
# ON CONFLICT (userid) DO UPDATE SET motm_url_suffix = :url_suffix
print("This script documents the possible fixes for the URL suffix UPDATE issue.")
print()
print("The problem: UPDATE query returns True but doesn't actually update any rows")
print("Likely cause: WHERE userid = 'admin' is not matching any rows")
print()
print("Possible solutions:")
print("1. Update all rows: UPDATE admin_settings SET motm_url_suffix = :url_suffix")
print("2. Update by id: UPDATE admin_settings SET motm_url_suffix = :url_suffix WHERE id = 1")
print("3. Use UPSERT: INSERT ... ON CONFLICT (PostgreSQL)")
print()
print("Run the diagnostic script to see what's in the database first.")

View File

@ -21,8 +21,9 @@ class adminSettingsForm2(FlaskForm):
nextMatchDate = DateField('Match Date', format='%Y-%m-%d')
nextOppoClub = StringField('Next Opposition Club:')
nextOppoTeam = StringField("Next Opposition Team:")
currMotM = SelectField('Current Man of the Match:', choices=[])
currDotD = SelectField('Current Dick of the Day:', choices=[])
votingDeadline = StringField('Voting Deadline')
currMotM = SelectField('Previous Man of the Match:', choices=[])
currDotD = SelectField('Previous Dick of the Day:', choices=[])
saveButton = SubmitField('Save Settings')
activateButton = SubmitField('Activate MotM Vote')

52
motm_app/gunicorn.conf.py Normal file
View File

@ -0,0 +1,52 @@
# Gunicorn configuration file for production deployment
import multiprocessing
import os
# Server socket
bind = "0.0.0.0:5000"
backlog = 2048
# Worker processes
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
worker_connections = 1000
timeout = 30
keepalive = 2
# Restart workers after this many requests, to prevent memory leaks
max_requests = 1000
max_requests_jitter = 50
# Logging
accesslog = "-"
errorlog = "-"
loglevel = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
# Process naming
proc_name = "motm_app"
# Server mechanics
daemon = False
pidfile = "/tmp/motm_app.pid"
user = None
group = None
tmp_upload_dir = None
# SSL (uncomment and configure if using HTTPS)
# keyfile = "/path/to/keyfile"
# certfile = "/path/to/certfile"
# Preload app for better performance
preload_app = True
# Environment variables
raw_env = [
'FLASK_ENV=production',
]
# Security
limit_request_line = 4094
limit_request_fields = 100
limit_request_field_size = 8190

View File

@ -0,0 +1,21 @@
apiVersion: v2
name: motm-app
description: A Helm chart for MOTM (Man of the Match) Hockey Voting Application
type: application
version: 1.0.0
appVersion: "1.0.0"
home: https://github.com/your-org/motm-app
sources:
- https://github.com/your-org/motm-app
maintainers:
- name: Your Name
email: your.email@example.com
keywords:
- flask
- hockey
- voting
- web-application
annotations:
category: Sports
licenses: MIT

View File

@ -0,0 +1,369 @@
# MOTM App Kubernetes Deployment Guide
This guide provides step-by-step instructions for deploying the MOTM (Man of the Match) Hockey Voting Application to a Kubernetes cluster using Helm.
## Prerequisites
### Required Tools
- **Kubernetes Cluster** (version 1.19+)
- **Helm** (version 3.0+)
- **kubectl** (configured for your cluster)
- **Docker** (for building images)
### Required Services
- **PostgreSQL Database** (or MySQL/SQLite)
- **S3-compatible Storage** (optional, for asset management)
## Quick Start
### 1. Build and Push Docker Image
```bash
# Navigate to the application directory
cd /home/jonny/Projects/gcp-hockey-results/motm_app
# Build the Docker image
docker build -t your-registry/motm-app:latest .
# Push to your container registry
docker push your-registry/motm-app:latest
```
### 2. Deploy to Development
```bash
# Navigate to the helm chart directory
cd helm-chart/motm-app
# Update the image repository in values-development.yaml
sed -i 's/your-registry\/motm-app/your-actual-registry\/motm-app/g' values-development.yaml
# Deploy using the deployment script
./scripts/deploy.sh development install
```
### 3. Deploy to Production
```bash
# Update production values
cp values-production.yaml my-production-values.yaml
# Edit my-production-values.yaml with your production settings
# Deploy to production
./scripts/deploy.sh production install
```
## Manual Deployment
### 1. Customize Values
Edit the appropriate values file:
```bash
# For development
vim values-development.yaml
# For production
vim values-production.yaml
```
Key values to update:
- `image.repository`: Your container registry
- `database.host`: Database service name
- `ingress.hosts[0].host`: Your domain name
- `secrets.*`: Database and S3 credentials
### 2. Install with Helm
```bash
# Development
helm install motm-app ./motm-app \
--namespace motm-app \
--values values-development.yaml \
--create-namespace
# Production
helm install motm-app ./motm-app \
--namespace motm-app \
--values values-production.yaml \
--create-namespace
```
## Configuration
### Database Setup
The application supports multiple database types:
#### PostgreSQL (Recommended)
```yaml
database:
type: "postgresql"
host: "postgresql-service"
port: 5432
name: "motm"
username: "motm_user"
```
#### MySQL
```yaml
database:
type: "mysql"
host: "mysql-service"
port: 3306
name: "motm"
username: "motm_user"
```
#### SQLite (Development only)
```yaml
database:
type: "sqlite"
# Other fields ignored for SQLite
```
### S3 Configuration
For asset management (logos, images):
```yaml
s3:
enabled: true
endpoint: "https://s3.amazonaws.com"
region: "us-east-1"
bucket: "motm-assets"
# Credentials set via secrets
```
### Security Configuration
#### Secrets Management
Set secrets via Helm values or external secret management:
```yaml
secrets:
dbPassword: "your-database-password"
s3AccessKey: "your-s3-access-key"
s3SecretKey: "your-s3-secret-key"
```
Or use external secret management:
```bash
# Create secrets manually
kubectl create secret generic motm-app-secrets \
--from-literal=db-password=your-password \
--from-literal=s3-access-key=your-key \
--from-literal=s3-secret-key=your-secret \
--namespace motm-app
```
#### Network Policies
For enhanced security, create network policies:
```yaml
# network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: motm-app-netpol
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: motm-app
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
egress:
- to:
- namespaceSelector:
matchLabels:
name: postgresql
```
## Monitoring and Observability
### Health Checks
The application includes built-in health checks:
- **Liveness Probe**: Checks if the application is running
- **Readiness Probe**: Checks if the application is ready to serve traffic
### Logging
Configure log levels in values:
```yaml
logging:
level: "INFO" # DEBUG, INFO, WARNING, ERROR
format: "json" # json, text
```
### Metrics (Optional)
Add Prometheus metrics endpoint:
```yaml
monitoring:
enabled: true
serviceMonitor:
enabled: true
interval: 30s
```
## Scaling
### Horizontal Pod Autoscaling
Enable HPA for production:
```yaml
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
```
### Resource Limits
Adjust based on your cluster capacity:
```yaml
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 200m
memory: 512Mi
```
## Troubleshooting
### Common Issues
#### 1. Pod Not Starting
```bash
# Check pod status
kubectl get pods -n motm-app -l app.kubernetes.io/name=motm-app
# Check pod logs
kubectl logs -n motm-app -l app.kubernetes.io/name=motm-app
```
#### 2. Database Connection Issues
```bash
# Test database connectivity
kubectl exec -n motm-app -it deployment/motm-app -- python -c "
from database import sql_read_static
from sqlalchemy import text
try:
result = sql_read_static(text('SELECT 1'))
print('Database connection successful')
except Exception as e:
print(f'Database connection failed: {e}')
"
```
#### 3. S3 Connection Issues
```bash
# Check S3 configuration
kubectl exec -n motm-app -it deployment/motm-app -- cat /app/s3_config.json
```
#### 4. Ingress Issues
```bash
# Check ingress status
kubectl get ingress -n motm-app
# Check ingress controller logs
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx
```
### Debugging Commands
```bash
# Get all resources
kubectl get all -n motm-app
# Describe deployment
kubectl describe deployment -n motm-app motm-app
# Check events
kubectl get events -n motm-app --sort-by='.lastTimestamp'
# Port forward for local testing
kubectl port-forward -n motm-app svc/motm-app 8080:80
```
## Maintenance
### Updates
```bash
# Update application
helm upgrade motm-app ./motm-app \
--namespace motm-app \
--values values-production.yaml
# Rollback if needed
helm rollback motm-app 1 --namespace motm-app
```
### Backup
```bash
# Backup database (PostgreSQL)
kubectl exec -n postgresql postgresql-0 -- pg_dump -U motm_user motm > backup.sql
# Backup application data
kubectl exec -n motm-app deployment/motm-app -- tar -czf /tmp/data-backup.tar.gz /app/data
kubectl cp motm-app/deployment/motm-app:/tmp/data-backup.tar.gz ./data-backup.tar.gz
```
### Cleanup
```bash
# Uninstall application
helm uninstall motm-app --namespace motm-app
# Delete namespace (if no other resources)
kubectl delete namespace motm-app
```
## Security Best Practices
1. **Use Non-Root Containers**: The application runs as non-root user (UID 1000)
2. **Read-Only Root Filesystem**: Enable in production values
3. **Network Policies**: Implement to restrict pod-to-pod communication
4. **RBAC**: Use dedicated service accounts with minimal permissions
5. **Secret Management**: Use external secret management solutions
6. **Image Security**: Scan images for vulnerabilities
7. **TLS**: Enable TLS for all ingress traffic
8. **Resource Limits**: Set appropriate CPU and memory limits
## Support
For issues and questions:
1. Check the application logs
2. Review Kubernetes events
3. Consult the Helm chart documentation
4. Create an issue in the repository

View File

@ -0,0 +1,249 @@
# MOTM App Helm Chart
This Helm chart deploys the MOTM (Man of the Match) Hockey Voting Application to a Kubernetes cluster.
## Prerequisites
- Kubernetes 1.19+
- Helm 3.0+
- PostgreSQL database (or MySQL/SQLite)
- S3-compatible storage (optional)
## Installation
### 1. Build and Push Docker Image
First, build and push your Docker image to a registry:
```bash
# Build the image
docker build -t your-registry/motm-app:latest .
# Push to registry
docker push your-registry/motm-app:latest
```
### 2. Configure Values
Copy the default values file and customize it:
```bash
cp values.yaml my-values.yaml
```
Key values to update in `my-values.yaml`:
```yaml
# Image configuration
image:
repository: your-registry/motm-app
tag: "latest"
# Database configuration
database:
host: "your-postgresql-service"
name: "motm"
username: "motm_user"
# S3 configuration (if using S3)
s3:
enabled: true
endpoint: "https://s3.amazonaws.com"
bucket: "your-bucket-name"
# Ingress configuration
ingress:
enabled: true
hosts:
- host: motm.yourdomain.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: motm-app-tls
hosts:
- motm.yourdomain.com
# Secrets (set these via --set or separate secret management)
secrets:
dbPassword: "your-db-password"
s3AccessKey: "your-s3-access-key"
s3SecretKey: "your-s3-secret-key"
```
### 3. Deploy with Helm
#### Option A: Using values file
```bash
helm install motm-app ./motm-app -f my-values.yaml
```
#### Option B: Using command line parameters
```bash
helm install motm-app ./motm-app \
--set image.repository=your-registry/motm-app \
--set database.host=your-postgresql-service \
--set ingress.hosts[0].host=motm.yourdomain.com \
--set secrets.dbPassword=your-db-password
```
#### Option C: Using external secret management
If using external secret management (e.g., Sealed Secrets, External Secrets Operator), create the secrets separately and set:
```yaml
secrets:
dbPassword: "" # Will be managed externally
s3AccessKey: "" # Will be managed externally
s3SecretKey: "" # Will be managed externally
```
## Configuration
### Database Setup
The application supports PostgreSQL, MySQL, and SQLite. Configure your database connection in the values file:
```yaml
database:
type: "postgresql" # postgresql, mysql, or sqlite
host: "postgresql-service"
port: 5432
name: "motm"
username: "motm_user"
```
### S3 Configuration
Configure S3-compatible storage for asset management:
```yaml
s3:
enabled: true
endpoint: "https://s3.amazonaws.com"
region: "us-east-1"
bucket: "motm-assets"
```
### Resource Limits
Adjust resource limits based on your cluster capacity:
```yaml
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
```
### Autoscaling
Enable horizontal pod autoscaling:
```yaml
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 80
```
## Upgrading
To upgrade the application:
```bash
helm upgrade motm-app ./motm-app -f my-values.yaml
```
## Uninstalling
To uninstall the application:
```bash
helm uninstall motm-app
```
## Troubleshooting
### Check Pod Status
```bash
kubectl get pods -l app.kubernetes.io/name=motm-app
```
### View Logs
```bash
kubectl logs -l app.kubernetes.io/name=motm-app
```
### Check Service
```bash
kubectl get svc -l app.kubernetes.io/name=motm-app
```
### Debug Database Connection
```bash
kubectl exec -it deployment/motm-app -- python -c "
from database import sql_read_static
from sqlalchemy import text
try:
result = sql_read_static(text('SELECT 1'))
print('Database connection successful')
except Exception as e:
print(f'Database connection failed: {e}')
"
```
## Values Reference
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `image.repository` | string | `"your-registry/motm-app"` | Image repository |
| `image.tag` | string | `"latest"` | Image tag |
| `service.type` | string | `"ClusterIP"` | Service type |
| `ingress.enabled` | bool | `true` | Enable ingress |
| `database.type` | string | `"postgresql"` | Database type |
| `database.host` | string | `"postgresql-service"` | Database host |
| `s3.enabled` | bool | `true` | Enable S3 storage |
| `resources.limits.cpu` | string | `"500m"` | CPU limit |
| `resources.limits.memory` | string | `"512Mi"` | Memory limit |
## Security Considerations
1. **Secrets Management**: Use proper secret management solutions (e.g., Sealed Secrets, External Secrets Operator)
2. **Network Policies**: Implement network policies to restrict pod-to-pod communication
3. **RBAC**: Configure proper RBAC for service accounts
4. **Image Security**: Use non-root containers and scan images for vulnerabilities
5. **TLS**: Enable TLS for ingress and internal communication
## Monitoring
The chart includes basic health checks. For production deployments, consider adding:
- Prometheus metrics endpoint
- ServiceMonitor for Prometheus Operator
- Grafana dashboards
- Alerting rules
## Support
For issues and questions, please refer to the application documentation or create an issue in the repository.

View File

@ -0,0 +1,266 @@
#!/bin/bash
# MOTM App Helm Deployment Script
# Usage: ./deploy.sh [environment] [action]
# Environment: development, staging, production
# Action: install, upgrade, uninstall, template
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default values
ENVIRONMENT=${1:-development}
ACTION=${2:-install}
RELEASE_NAME="motm-app"
CHART_PATH="./motm-app"
NAMESPACE="motm-app"
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to check prerequisites
check_prerequisites() {
print_status "Checking prerequisites..."
# Check if helm is installed
if ! command -v helm &> /dev/null; then
print_error "Helm is not installed. Please install Helm 3.0+"
exit 1
fi
# Check if kubectl is installed
if ! command -v kubectl &> /dev/null; then
print_error "kubectl is not installed. Please install kubectl"
exit 1
fi
# Check if we can connect to Kubernetes cluster
if ! kubectl cluster-info &> /dev/null; then
print_error "Cannot connect to Kubernetes cluster"
exit 1
fi
print_success "Prerequisites check passed"
}
# Function to create namespace
create_namespace() {
print_status "Creating namespace: $NAMESPACE"
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
print_success "Namespace created/verified"
}
# Function to validate values file
validate_values() {
local values_file="values-${ENVIRONMENT}.yaml"
if [[ ! -f "$values_file" ]]; then
print_error "Values file $values_file not found"
exit 1
fi
print_status "Validating values file: $values_file"
# Check for required values
if ! grep -q "repository:" "$values_file"; then
print_error "Image repository not specified in $values_file"
exit 1
fi
if ! grep -q "host:" "$values_file"; then
print_error "Database host not specified in $values_file"
exit 1
fi
print_success "Values file validation passed"
}
# Function to template the chart
template_chart() {
local values_file="values-${ENVIRONMENT}.yaml"
print_status "Generating Kubernetes manifests..."
helm template $RELEASE_NAME $CHART_PATH \
--namespace $NAMESPACE \
--values $values_file \
--output-dir ./generated-manifests
print_success "Manifests generated in ./generated-manifests/"
}
# Function to install the chart
install_chart() {
local values_file="values-${ENVIRONMENT}.yaml"
print_status "Installing MOTM App in $ENVIRONMENT environment..."
helm install $RELEASE_NAME $CHART_PATH \
--namespace $NAMESPACE \
--values $values_file \
--create-namespace \
--wait \
--timeout 10m
print_success "MOTM App installed successfully"
# Show status
kubectl get pods -n $NAMESPACE -l app.kubernetes.io/name=motm-app
}
# Function to upgrade the chart
upgrade_chart() {
local values_file="values-${ENVIRONMENT}.yaml"
print_status "Upgrading MOTM App in $ENVIRONMENT environment..."
helm upgrade $RELEASE_NAME $CHART_PATH \
--namespace $NAMESPACE \
--values $values_file \
--wait \
--timeout 10m
print_success "MOTM App upgraded successfully"
# Show status
kubectl get pods -n $NAMESPACE -l app.kubernetes.io/name=motm-app
}
# Function to uninstall the chart
uninstall_chart() {
print_warning "Uninstalling MOTM App from $ENVIRONMENT environment..."
read -p "Are you sure you want to uninstall? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
helm uninstall $RELEASE_NAME --namespace $NAMESPACE
print_success "MOTM App uninstalled successfully"
else
print_status "Uninstall cancelled"
fi
}
# Function to show status
show_status() {
print_status "Showing MOTM App status..."
echo "=== Helm Release Status ==="
helm status $RELEASE_NAME --namespace $NAMESPACE
echo -e "\n=== Pods Status ==="
kubectl get pods -n $NAMESPACE -l app.kubernetes.io/name=motm-app
echo -e "\n=== Services ==="
kubectl get svc -n $NAMESPACE -l app.kubernetes.io/name=motm-app
echo -e "\n=== Ingress ==="
kubectl get ingress -n $NAMESPACE -l app.kubernetes.io/name=motm-app
}
# Function to show logs
show_logs() {
print_status "Showing MOTM App logs..."
kubectl logs -n $NAMESPACE -l app.kubernetes.io/name=motm-app --tail=100 -f
}
# Function to show help
show_help() {
echo "MOTM App Helm Deployment Script"
echo ""
echo "Usage: $0 [environment] [action]"
echo ""
echo "Environments:"
echo " development Deploy to development environment"
echo " staging Deploy to staging environment"
echo " production Deploy to production environment"
echo ""
echo "Actions:"
echo " install Install the application (default)"
echo " upgrade Upgrade the application"
echo " uninstall Uninstall the application"
echo " template Generate Kubernetes manifests"
echo " status Show application status"
echo " logs Show application logs"
echo " help Show this help message"
echo ""
echo "Examples:"
echo " $0 development install"
echo " $0 production upgrade"
echo " $0 staging template"
echo " $0 development status"
}
# Main execution
main() {
case $ACTION in
install)
check_prerequisites
create_namespace
validate_values
install_chart
;;
upgrade)
check_prerequisites
create_namespace
validate_values
upgrade_chart
;;
uninstall)
check_prerequisites
uninstall_chart
;;
template)
check_prerequisites
validate_values
template_chart
;;
status)
check_prerequisites
show_status
;;
logs)
check_prerequisites
show_logs
;;
help|--help|-h)
show_help
;;
*)
print_error "Unknown action: $ACTION"
show_help
exit 1
;;
esac
}
# Run main function
main "$@"

View File

@ -0,0 +1,75 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "motm-app.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "motm-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "motm-app.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "motm-app.labels" -}}
helm.sh/chart: {{ include "motm-app.chart" . }}
{{ include "motm-app.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- with .Values.labels }}
{{- toYaml . | nindent 0 }}
{{- end }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "motm-app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "motm-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "motm-app.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "motm-app.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,50 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "motm-app.fullname" . }}-config
labels:
{{- include "motm-app.labels" . | nindent 4 }}
data:
database_config.ini: |
[DATABASE]
type = {{ .Values.database.type }}
sqlite_database_path = hockey_results.db
[MYSQL]
host = {{ .Values.database.host }}
port = {{ .Values.database.port }}
database = {{ .Values.database.name }}
username = {{ .Values.database.username }}
charset = utf8mb4
[POSTGRESQL]
host = {{ .Values.database.host }}
port = {{ .Values.database.port }}
database = {{ .Values.database.name }}
username = {{ .Values.database.username }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "motm-app.fullname" . }}-s3-config
labels:
{{- include "motm-app.labels" . | nindent 4 }}
data:
s3_config.json: |
{
"enable_s3": {{ .Values.s3.enabled }},
"aws_access_key_id": "",
"aws_secret_access_key": "",
"aws_region": "{{ .Values.s3.region }}",
"bucket_name": "{{ .Values.s3.bucket }}",
"bucket_prefix": "{{ .Values.s3.bucketPrefix }}",
"endpoint_url": "{{ .Values.s3.endpoint }}",
"storage_provider": "{{ .Values.s3.storageProvider }}",
"minio_endpoint": "{{ .Values.s3.endpoint }}",
"minio_use_ssl": {{ .Values.s3.useSSL }},
"use_signed_urls": {{ .Values.s3.useSignedUrls }},
"signed_url_expiry": {{ .Values.s3.signedUrlExpiry }},
"fallback_to_static": {{ .Values.s3.fallbackToStatic }},
"use_ssl": {{ .Values.s3.useSSL }},
"verify_ssl": true
}

View File

@ -0,0 +1,164 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "motm-app.fullname" . }}
labels:
{{- include "motm-app.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount | default 1 }}
{{- end }}
selector:
matchLabels:
{{- include "motm-app.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "motm-app.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "motm-app.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 5000
protocol: TCP
env:
# Application environment variables
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
# Database configuration
- name: DB_HOST
value: {{ .Values.database.host | quote }}
- name: DB_PORT
value: {{ .Values.database.port | quote }}
- name: DB_NAME
value: {{ .Values.database.name | quote }}
- name: DB_USER
value: {{ .Values.database.username | quote }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
{{- if .Values.secrets.useExternalSecret }}
name: {{ .Values.secrets.externalSecretName | quote }}
{{- else }}
name: {{ include "motm-app.fullname" . }}-secrets
{{- end }}
key: {{ .Values.secrets.dbPasswordKey }}
# S3/MinIO configuration
{{- if .Values.s3.enabled }}
- name: S3_ENABLED
value: "true"
- name: S3_STORAGE_PROVIDER
value: {{ .Values.s3.storageProvider | quote }}
- name: S3_ENDPOINT
value: {{ .Values.s3.endpoint | quote }}
- name: S3_REGION
value: {{ .Values.s3.region | quote }}
- name: S3_BUCKET
value: {{ .Values.s3.bucket | quote }}
- name: S3_BUCKET_PREFIX
value: {{ .Values.s3.bucketPrefix | quote }}
- name: S3_USE_SIGNED_URLS
value: {{ .Values.s3.useSignedUrls | quote }}
- name: S3_SIGNED_URL_EXPIRY
value: {{ .Values.s3.signedUrlExpiry | quote }}
- name: S3_FALLBACK_TO_STATIC
value: {{ .Values.s3.fallbackToStatic | quote }}
- name: S3_USE_SSL
value: {{ .Values.s3.useSSL | quote }}
- name: S3_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
{{- if .Values.secrets.useExternalSecret }}
name: {{ .Values.secrets.externalSecretName | quote }}
{{- else }}
name: {{ include "motm-app.fullname" . }}-secrets
{{- end }}
key: {{ .Values.secrets.s3AccessKeyKey }}
- name: S3_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
{{- if .Values.secrets.useExternalSecret }}
name: {{ .Values.secrets.externalSecretName | quote }}
{{- else }}
name: {{ include "motm-app.fullname" . }}-secrets
{{- end }}
key: {{ .Values.secrets.s3SecretKeyKey }}
{{- else }}
- name: S3_ENABLED
value: "false"
{{- end }}
livenessProbe:
httpGet:
path: {{ .Values.healthCheck.path }}
port: http
initialDelaySeconds: {{ .Values.healthCheck.initialDelaySeconds }}
periodSeconds: {{ .Values.healthCheck.periodSeconds }}
timeoutSeconds: {{ .Values.healthCheck.timeoutSeconds }}
failureThreshold: {{ .Values.healthCheck.failureThreshold }}
readinessProbe:
httpGet:
path: {{ .Values.healthCheck.path }}
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: config-volume
mountPath: /app/database_config.ini
subPath: database_config.ini
- name: s3-config-volume
mountPath: /app/s3_config.json
subPath: s3_config.json
{{- if .Values.persistence.enabled }}
- name: data-volume
mountPath: /app/data
{{- end }}
volumes:
- name: config-volume
configMap:
name: {{ include "motm-app.fullname" . }}-config
- name: s3-config-volume
configMap:
name: {{ include "motm-app.fullname" . }}-s3-config
{{- if .Values.persistence.enabled }}
- name: data-volume
persistentVolumeClaim:
claimName: {{ include "motm-app.fullname" . }}-pvc
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@ -0,0 +1,42 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "motm-app.fullname" . }}
labels:
{{- include "motm-app.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "motm-app.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,60 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "motm-app.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class")) }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "motm-app.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- else }}
serviceName: {{ $fullName }}
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,28 @@
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "motm-app.fullname" . }}
labels:
{{- include "motm-app.labels" . | nindent 4 }}
spec:
{{- if .Values.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "motm-app.selectorLabels" . | nindent 6 }}
{{- end }}

View File

@ -0,0 +1,31 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "motm-app.fullname" . }}-pvc
labels:
{{- include "motm-app.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- if .Values.persistence.storageClass }}
{{- if (eq "-" .Values.persistence.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.storageClass }}"
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,32 @@
{{- if not .Values.secrets.useExternalSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "motm-app.fullname" . }}-secrets
labels:
{{- include "motm-app.labels" . | nindent 4 }}
type: Opaque
data:
# Database password
{{- if .Values.secrets.dbPassword }}
{{ .Values.secrets.dbPasswordKey }}: {{ .Values.secrets.dbPassword | b64enc | quote }}
{{- else }}
{{ .Values.secrets.dbPasswordKey }}: {{ "changeme" | b64enc | quote }}
{{- end }}
{{- if .Values.s3.enabled }}
# S3 credentials
{{- if .Values.secrets.s3AccessKey }}
{{ .Values.secrets.s3AccessKeyKey }}: {{ .Values.secrets.s3AccessKey | b64enc | quote }}
{{- else }}
{{ .Values.secrets.s3AccessKeyKey }}: {{ "changeme" | b64enc | quote }}
{{- end }}
{{- if .Values.secrets.s3SecretKey }}
{{ .Values.secrets.s3SecretKeyKey }}: {{ .Values.secrets.s3SecretKey | b64enc | quote }}
{{- else }}
{{ .Values.secrets.s3SecretKeyKey }}: {{ "changeme" | b64enc | quote }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "motm-app.fullname" . }}
labels:
{{- include "motm-app.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
protocol: TCP
name: http
selector:
{{- include "motm-app.selectorLabels" . | nindent 4 }}

View File

@ -0,0 +1,22 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "motm-app.serviceAccountName" . }}
labels:
{{- include "motm-app.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,143 @@
# Development values for MOTM App
# Use this file for development/staging environments
# Application Configuration
app:
name: motm-app-dev
version: "dev"
# Image Configuration
image:
repository: your-registry/motm-app
tag: "dev" # Use dev tag for development
pullPolicy: Always # Always pull latest dev image
# Resource Limits for Development (lighter)
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 50m
memory: 128Mi
# No autoscaling for development
autoscaling:
enabled: false
# Pod Disruption Budget
podDisruptionBudget:
enabled: false
# Security Context (more permissive for development)
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false # Allow writing for development
runAsNonRoot: true
runAsUser: 1000
# Service Configuration
service:
type: ClusterIP
port: 80
targetPort: 5000
# Ingress Configuration for Development
ingress:
enabled: true
className: "nginx"
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "false" # No SSL for dev
cert-manager.io/cluster-issuer: "letsencrypt-staging"
hosts:
- host: motm-dev.yourdomain.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: motm-app-dev-tls
hosts:
- motm-dev.yourdomain.com
# Database Configuration for Development
database:
type: "postgresql"
host: "postgresql-dev-service"
port: 5432
name: "motm_dev"
username: "motm_user"
# S3/MinIO Configuration for Development
# Example: Using MinIO for local development
s3:
enabled: true
storageProvider: "minio" # Use MinIO for development
endpoint: "http://minio.default.svc.cluster.local:9000" # Internal MinIO service
region: "us-east-1" # MinIO ignores this but required for boto3
bucket: "motm-assets-dev"
bucketPrefix: "assets/"
useSignedUrls: false # Use public URLs in development
signedUrlExpiry: 3600
fallbackToStatic: true
useSSL: false # HTTP for local MinIO
# Alternative AWS S3 configuration for development:
# storageProvider: "aws"
# endpoint: ""
# region: "us-east-1"
# bucket: "motm-assets-dev"
# useSSL: true
# Environment Variables for Development
env:
FLASK_ENV: "development"
FLASK_APP: "main.py"
FLASK_RUN_HOST: "0.0.0.0"
FLASK_RUN_PORT: "5000"
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
FLASK_DEBUG: "1" # Enable debug mode for development
# Health Checks
healthCheck:
enabled: true
path: "/"
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 5
# Persistence for Development
persistence:
enabled: true
storageClass: "standard" # Use standard storage class
accessMode: ReadWriteOnce
size: 1Gi
# Monitoring (disabled for development)
monitoring:
enabled: false
# Logging
logging:
level: "DEBUG"
format: "text"
# Labels and Annotations
labels:
environment: "development"
team: "development"
annotations:
deployment.kubernetes.io/revision: "dev"
podLabels:
environment: "development"
podAnnotations:
debug: "true"

View File

@ -0,0 +1,181 @@
# Production values for MOTM App
# Use this file as a template for production deployment
# Application Configuration
app:
name: motm-app
version: "1.0.0"
# Image Configuration
image:
repository: your-registry/motm-app
tag: "v1.0.0" # Use specific version tags in production
pullPolicy: IfNotPresent
# Resource Limits for Production
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 200m
memory: 512Mi
# Autoscaling for Production
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
# Pod Disruption Budget
podDisruptionBudget:
enabled: true
minAvailable: 1
# Security Context
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
# Service Configuration
service:
type: ClusterIP
port: 80
targetPort: 5000
# Ingress Configuration for Production
ingress:
enabled: true
className: "nginx"
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/rate-limit: "100"
nginx.ingress.kubernetes.io/rate-limit-window: "1m"
hosts:
- host: motm.yourdomain.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: motm-app-tls
hosts:
- motm.yourdomain.com
# Database Configuration
database:
type: "postgresql"
host: "postgresql-primary-service"
port: 5432
name: "motm_prod"
username: "motm_user"
# S3/MinIO Configuration for Production
s3:
enabled: true
storageProvider: "minio" # Use "aws" for AWS S3 or "minio" for MinIO
endpoint: "http://minio.default.svc.cluster.local:9000" # MinIO internal service endpoint
region: "us-east-1" # Required for boto3 even with MinIO
bucket: "hockey-apps"
bucketPrefix: "assets/"
useSignedUrls: false # Use public URLs (MinIO bucket should be public read)
signedUrlExpiry: 3600
fallbackToStatic: true
useSSL: false # Use HTTP for internal cluster communication
# Alternative external MinIO configuration (for external access):
# storageProvider: "minio"
# endpoint: "https://minio.yourdomain.com" # External MinIO endpoint
# region: "us-east-1"
# bucket: "hockey-apps"
# useSignedUrls: false
# useSSL: true # Use HTTPS for external access
# Environment Variables
env:
FLASK_ENV: "production"
FLASK_APP: "main.py"
FLASK_RUN_HOST: "0.0.0.0"
FLASK_RUN_PORT: "5000"
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
# Health Checks
healthCheck:
enabled: true
path: "/"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Persistence for Production
persistence:
enabled: true
storageClass: "fast-ssd" # Use fast storage class
accessMode: ReadWriteOnce
size: 10Gi
# Monitoring
monitoring:
enabled: true
serviceMonitor:
enabled: true
interval: 30s
scrapeTimeout: 10s
# Logging
logging:
level: "INFO"
format: "json"
# Node Selector for Production
nodeSelector:
node-type: "production"
# Tolerations
tolerations:
- key: "production"
operator: "Equal"
value: "true"
effect: "NoSchedule"
# Affinity Rules
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- motm-app
topologyKey: kubernetes.io/hostname
# Labels and Annotations
labels:
environment: "production"
team: "platform"
annotations:
deployment.kubernetes.io/revision: "1"
podLabels:
environment: "production"
podAnnotations:
prometheus.io/scrape: "true"
prometheus.io/port: "5000"
prometheus.io/path: "/metrics"

View File

@ -0,0 +1,236 @@
# Default values for motm-app
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
# Application Configuration
app:
name: motm-app
version: "1.0.0"
description: "MOTM Hockey Voting Application"
# Image Configuration
image:
repository: harbor.ervine.dev/pubblic/hockey/motm-app
tag: "0.1.1"
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
# tag: ""
# Image pull secrets
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# Service Account
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
# Pod Security Context
podSecurityContext:
fsGroup: 1000
runAsNonRoot: true
runAsUser: 1000
# Container Security Context
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
runAsNonRoot: true
runAsUser: 1000
# Service Configuration
service:
type: ClusterIP
port: 80
targetPort: 5000
annotations: {}
# Ingress Configuration
ingress:
enabled: true
className: ""
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
hosts:
- host: motm.yourdomain.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: motm-app-tls
hosts:
- motm.yourdomain.com
# Resource Limits and Requests
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
# Autoscaling
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: 80
# Node Selector
nodeSelector: {}
# Tolerations
tolerations: []
# Affinity
affinity: {}
# Pod Disruption Budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# Database Configuration
database:
type: "postgresql" # postgresql, mysql, sqlite
host: "postgresql-service"
port: 5432
name: "motm"
username: "motm_user"
# Password should be set via secret
# password: ""
# S3/MinIO Configuration
s3:
# Enable S3 storage (if false, uses local static files)
enabled: true
# Storage provider: "aws" or "minio"
storageProvider: "minio"
# S3/MinIO endpoint
# For AWS: "https://s3.amazonaws.com" or leave empty to use default
# For MinIO: "https://minio.example.com" or "http://minio.default.svc.cluster.local:9000"
endpoint: "http://minio.default.svc.cluster.local:9000"
# AWS region (used for AWS S3, required for boto3 even with MinIO)
region: "us-east-1"
# S3 bucket name
bucket: "hockey-apps"
# Bucket prefix/folder for assets (e.g., "assets/", "motm/", etc.)
bucketPrefix: "assets/"
# Use signed URLs for asset access (recommended for private buckets)
useSignedUrls: false
# Signed URL expiry time in seconds (default: 1 hour)
signedUrlExpiry: 3600
# Fallback to local static files if S3 access fails
fallbackToStatic: true
# SSL/TLS configuration
useSSL: false
# Credentials (should be set via secrets in production)
# These are ignored if secrets.useExternalSecret is true
accessKeyId: ""
secretAccessKey: ""
# Environment Variables
env:
FLASK_ENV: "production"
FLASK_APP: "main.py"
FLASK_RUN_HOST: "0.0.0.0"
FLASK_RUN_PORT: "5000"
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
# ConfigMap for application configuration
configMap:
databaseConfig: |
[DATABASE]
type = {{ .Values.database.type }}
[MYSQL]
host = {{ .Values.database.host }}
port = {{ .Values.database.port }}
database = {{ .Values.database.name }}
username = {{ .Values.database.username }}
[POSTGRESQL]
host = {{ .Values.database.host }}
port = {{ .Values.database.port }}
database = {{ .Values.database.name }}
username = {{ .Values.database.username }}
# Secrets Configuration
secrets:
# Use an existing external secret instead of creating one
# If useExternalSecret is true, the chart will reference the external secret
# If false, the chart will create a secret with the provided values
useExternalSecret: false
externalSecretName: "" # Name of the existing secret to reference
# Secret key names (used for both external and managed secrets)
dbPasswordKey: "db-password"
s3AccessKeyKey: "s3-access-key"
s3SecretKeyKey: "s3-secret-key"
# Values for managed secret (only used when useExternalSecret is false)
# Database password
dbPassword: ""
# S3 credentials
s3AccessKey: ""
s3SecretKey: ""
# Health Checks
healthCheck:
enabled: true
path: "/"
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Persistence
persistence:
enabled: false
# storageClass: ""
accessMode: ReadWriteOnce
size: 1Gi
# Monitoring
monitoring:
enabled: false
serviceMonitor:
enabled: false
interval: 30s
scrapeTimeout: 10s
# Logging
logging:
level: "INFO"
format: "json"
# Labels and Annotations
labels: {}
annotations: {}
podLabels: {}
podAnnotations: {}

View File

@ -19,7 +19,7 @@ importlib.reload(database)
importlib.reload(db_config)
from app import app, randomUrlSuffix
from flask import Flask, flash, render_template, request, redirect, url_for, jsonify
from flask import Flask, flash, render_template, request, redirect, url_for, jsonify, make_response
from sqlalchemy import text
from flask_wtf import FlaskForm
from flask_bootstrap import Bootstrap
@ -36,6 +36,9 @@ from fixture_scraper import FixtureScraper, get_next_hkfc_c_fixture, get_opponen
from club_scraper import ClubScraper, get_hk_hockey_clubs, expand_club_abbreviation
from s3_config import s3_config_manager, s3_asset_service
# Persistent device ID cookie name
DEVICE_COOKIE_NAME = 'motm_device_id'
# Custom authentication class that uses database
class DatabaseBasicAuth(BasicAuth):
def check_credentials(self, username, password):
@ -146,6 +149,23 @@ def generate_device_id(request):
return device_id
def get_or_create_device_id(request):
"""
Return a persistent device identifier using a long-lived cookie.
Falls back to a header/IP fingerprint only if absolutely necessary.
Returns a tuple of (device_id, created) where created indicates whether
a new cookie needs to be set on the response.
"""
# Prefer existing cookie to uniquely identify a device/browser
cookie_device_id = request.cookies.get(DEVICE_COOKIE_NAME)
if cookie_device_id:
return cookie_device_id, False
# Create a new random UUID (more stable than header/IP fingerprints)
new_device_id = uuid.uuid4().hex
return new_device_id, True
def is_admin_authenticated(request):
"""Check if the current request is authenticated as admin"""
@ -178,13 +198,82 @@ def is_admin_authenticated(request):
return False
def get_previous_match_winners():
"""
Automatically determine the MOTM and DotD winners from the most recent completed fixture.
Returns a tuple of (motm_player_number, dotd_player_number) or (None, None) if not found.
"""
try:
# Get all fixture columns from _hkfc_c_motm table
sql_columns = text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = '_hkfc_c_motm'
AND (column_name LIKE 'motm_%' OR column_name LIKE 'dotd_%')
AND column_name NOT LIKE '%total'
ORDER BY column_name DESC
""")
columns = sql_read(sql_columns)
if not columns:
return None, None
# Extract unique fixture dates from column names
fixture_dates = set()
for col in columns:
col_name = col['column_name']
if col_name.startswith('motm_'):
fixture_dates.add(col_name.replace('motm_', ''))
elif col_name.startswith('dotd_'):
fixture_dates.add(col_name.replace('dotd_', ''))
# Sort fixture dates in descending order (most recent first)
sorted_dates = sorted(list(fixture_dates), reverse=True)
if not sorted_dates:
return None, None
# Get the most recent fixture date
latest_fixture = sorted_dates[0]
motm_col = f'motm_{latest_fixture}'
dotd_col = f'dotd_{latest_fixture}'
# Find the MOTM winner (player with most votes)
sql_motm = text(f"""
SELECT playernumber, {motm_col} as votes
FROM _hkfc_c_motm
WHERE {motm_col} > 0
ORDER BY {motm_col} DESC
LIMIT 1
""")
motm_result = sql_read(sql_motm)
motm_winner = motm_result[0]['playernumber'] if motm_result else None
# Find the DotD winner (player with most votes)
sql_dotd = text(f"""
SELECT playernumber, {dotd_col} as votes
FROM _hkfc_c_motm
WHERE {dotd_col} > 0
ORDER BY {dotd_col} DESC
LIMIT 1
""")
dotd_result = sql_read(sql_dotd)
dotd_winner = dotd_result[0]['playernumber'] if dotd_result else None
return motm_winner, dotd_winner
except Exception as e:
print(f"Error getting previous match winners: {e}")
return None, None
# ==================== PUBLIC VOTING SECTION ====================
@app.route('/motm/<randomUrlSuffix>')
def motm_vote(randomUrlSuffix):
"""Public voting page for Man of the Match and Dick of the Day"""
sql = text("SELECT playernumber, playerforenames, playersurname, playernickname FROM _hkfcc_matchsquad ORDER BY RANDOM()")
sql2 = text("SELECT nextclub, nextteam, nextdate, oppologo, hkfclogo, currmotm, currdotd, nextfixture, motmurlsuffix FROM motmadminsettings")
sql2 = text("SELECT nextclub, nextteam, nextdate, oppologo, hkfclogo, currmotm, currdotd, nextfixture, motmurlsuffix, votingdeadline FROM motmadminsettings")
rows = sql_read(sql)
nextInfo = sql_read_static(sql2)
@ -196,11 +285,11 @@ def motm_vote(randomUrlSuffix):
nextTeam = nextInfo[0]['nextteam']
nextFixture = nextInfo[0]['nextfixture']
# Get HKFC logo from clubs table using signed URLs (with authentication)
hkfcLogo = s3_asset_service.get_asset_url('images/hkfc_logo.png') # Default fallback
sql_hkfc_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = 'Hong Kong Football Club'")
hkfc_logo_result = sql_read(sql_hkfc_logo)
if hkfc_logo_result and hkfc_logo_result[0]['logo_url']:
hkfcLogo = s3_asset_service.get_logo_url(hkfc_logo_result[0]['logo_url'], 'Hong Kong Football Club')
hkfcLogo = s3_asset_service.get_asset_url('images/hkfclogo.png') # Default fallback
sql_hkfclogo = text("SELECT logo_url FROM clubs WHERE hockey_club = 'Hong Kong Football Club'")
hkfclogo_result = sql_read(sql_hkfclogo)
if hkfclogo_result and hkfclogo_result[0]['logo_url']:
hkfcLogo = s3_asset_service.get_logo_url(hkfclogo_result[0]['logo_url'], 'Hong Kong Football Club')
# Get opponent club logo from clubs table using signed URLs (with authentication)
oppoLogo = s3_asset_service.get_asset_url('images/default_logo.png') # Default fallback
@ -210,8 +299,6 @@ def motm_vote(randomUrlSuffix):
if club_logo_result and club_logo_result[0]['logo_url']:
oppoLogo = s3_asset_service.get_logo_url(club_logo_result[0]['logo_url'], nextClub)
currMotM = nextInfo[0]['currmotm']
currDotD = nextInfo[0]['currdotd']
oppo = nextTeam
# Get match date from admin settings
if nextInfo and nextInfo[0]['nextdate']:
@ -222,19 +309,20 @@ def motm_vote(randomUrlSuffix):
else:
return render_template('error.html', message="No match date found. Please set up the next match in admin settings.")
# Get current MOTM and DotD player pictures
if nextInfo and nextInfo[0]['currmotm'] and nextInfo[0]['currdotd']:
sql3 = text("SELECT playernickname FROM _hkfc_players WHERE playernumber = :curr_motm")
sql4 = text("SELECT playernickname FROM _hkfc_players WHERE playernumber = :curr_dotd")
motm = sql_read(sql3, {'curr_motm': nextInfo[0]['currmotm']})
dotd = sql_read(sql4, {'curr_dotd': nextInfo[0]['currdotd']})
else:
motm = []
dotd = []
# Get current MOTM and DotD player nicknames (if they exist)
currMotM = None
currDotD = None
if nextInfo and nextInfo[0]['currmotm']:
sql3 = text("SELECT playernickname FROM _hkfc_players WHERE playernumber = :currmotm")
motm_result = sql_read(sql3, {'currmotm': nextInfo[0]['currmotm']})
if motm_result:
currMotM = motm_result[0]['playernickname']
# Handle empty results
if not motm or not dotd:
return render_template('error.html', message="Player data not found. Please set up current MOTM and DotD players in admin settings.")
if nextInfo and nextInfo[0]['currdotd']:
sql4 = text("SELECT playernickname FROM _hkfc_players WHERE playernumber = :currdotd")
dotd_result = sql_read(sql4, {'currdotd': nextInfo[0]['currdotd']})
if dotd_result:
currDotD = dotd_result[0]['playernickname']
# Use default player images since playerPictureURL column doesn't exist
motmURL = s3_asset_service.get_asset_url('images/default_player.png')
@ -247,13 +335,27 @@ def motm_vote(randomUrlSuffix):
form = motmForm()
# Get voting deadline
voting_deadline = None
voting_deadline_iso = None
if nextInfo and nextInfo[0].get('votingdeadline'):
voting_deadline = nextInfo[0]['votingdeadline']
if isinstance(voting_deadline, str):
try:
from datetime import datetime
voting_deadline = datetime.strptime(voting_deadline, '%Y-%m-%d %H:%M:%S')
except ValueError:
pass
if hasattr(voting_deadline, 'isoformat'):
voting_deadline_iso = voting_deadline.isoformat()
# Verify URL suffix
if nextInfo and nextInfo[0].get('motmurlsuffix'):
randomSuff = nextInfo[0]['motmurlsuffix']
if randomSuff == randomUrlSuffix:
# Use nextdate to generate proper match number instead of nextfixture
match_number = nextInfo[0]['nextdate'].strftime('%Y-%m-%d') if nextInfo[0]['nextdate'] else ''
return render_template('motm_vote.html', data=rows, comment=comment, formatDate=formatDate, matchNumber=match_number, oppo=oppo, hkfcLogo=hkfcLogo, oppoLogo=oppoLogo, dotdURL=dotdURL, motmURL=motmURL, form=form)
return render_template('motm_vote.html', data=rows, comment=comment, formatDate=formatDate, matchNumber=match_number, oppo=oppo, hkfcLogo=hkfcLogo, oppoLogo=oppoLogo, dotdURL=dotdURL, motmURL=motmURL, currMotM=currMotM, currDotD=currDotD, form=form, votingDeadline=voting_deadline_iso)
else:
return render_template('error.html', message="Invalid voting URL. Please use the correct URL provided by the admin.")
else:
@ -269,15 +371,28 @@ def match_comments():
return render_template('error.html', message="Database not initialized. Please go to Database Setup to initialize the database.")
_oppo = row[0]['nextclub']
commentDate = row[0]['nextdate'].strftime('%Y-%m-%d')
_matchDate = row[0]['nextdate'].strftime('%Y_%m_%d')
# Handle case where nextdate is None - use most recent comment date
if row[0]['nextdate']:
commentDate = row[0]['nextdate'].strftime('%Y-%m-%d')
_matchDate = row[0]['nextdate'].strftime('%Y-%m-%d')
else:
# Get the most recent comment date
sql_recent = text("SELECT matchDate FROM _motmcomments ORDER BY matchDate DESC LIMIT 1")
recent_result = sql_read(sql_recent)
if recent_result:
commentDate = recent_result[0]['matchDate']
_matchDate = recent_result[0]['matchDate']
else:
commentDate = '2025-01-15' # Fallback
_matchDate = '2025-01-15'
# Get HKFC logo from clubs table using signed URLs (with authentication)
hkfcLogo = s3_asset_service.get_asset_url('images/hkfc_logo.png') # Default fallback
sql_hkfc_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = 'Hong Kong Football Club'")
hkfc_logo_result = sql_read(sql_hkfc_logo)
if hkfc_logo_result and hkfc_logo_result[0]['logo_url']:
hkfcLogo = s3_asset_service.get_logo_url(hkfc_logo_result[0]['logo_url'], 'Hong Kong Football Club')
hkfcLogo = s3_asset_service.get_asset_url('images/hkfclogo.png') # Default fallback
sql_hkfclogo = text("SELECT logo_url FROM clubs WHERE hockey_club = 'Hong Kong Football Club'")
hkfclogo_result = sql_read(sql_hkfclogo)
if hkfclogo_result and hkfclogo_result[0]['logo_url']:
hkfcLogo = s3_asset_service.get_logo_url(hkfclogo_result[0]['logo_url'], 'Hong Kong Football Club')
# Get opponent club logo from clubs table using signed URLs (with authentication)
oppoLogo = s3_asset_service.get_asset_url('images/default_logo.png') # Default fallback
@ -301,6 +416,23 @@ def match_comments():
def vote_thanks():
"""Process MOTM/DotD votes and comments"""
try:
# Check voting deadline
sql_deadline = text("SELECT votingdeadline FROM motmadminsettings")
deadline_result = sql_read_static(sql_deadline)
if deadline_result and deadline_result[0].get('votingdeadline'):
from datetime import datetime
voting_deadline = deadline_result[0]['votingdeadline']
if isinstance(voting_deadline, str):
try:
voting_deadline = datetime.strptime(voting_deadline, '%Y-%m-%d %H:%M:%S')
except ValueError:
pass
# Check if deadline has passed
if hasattr(voting_deadline, 'year'): # Check if it's a datetime object
if datetime.now() >= voting_deadline:
return render_template('error.html', message="Voting has closed. The deadline has passed.")
_motm = request.form['motmVote']
_dotd = request.form['dotdVote']
_comments = request.form['motmComment']
@ -334,7 +466,7 @@ def vote_thanks():
motm_name = motm_player[0]['playernickname'] if motm_player else f'Player {_motm}'
dotd_name = dotd_player[0]['playernickname'] if dotd_player else f'Player {_dotd}'
# Update MOTM vote - use PostgreSQL UPSERT syntax (don't update totals)
# Update MOTM vote - use PostgreSQL UPSERT syntax
sql_motm = text(f"""
INSERT INTO _hkfc_c_motm (playernumber, playername, {motm_col})
VALUES (:player_num, :player_name, 1)
@ -343,7 +475,7 @@ def vote_thanks():
""")
sql_write(sql_motm, {'player_num': _motm, 'player_name': motm_name})
# Update DotD vote - use PostgreSQL UPSERT syntax (don't update totals)
# Update DotD vote - use PostgreSQL UPSERT syntax
sql_dotd = text(f"""
INSERT INTO _hkfc_c_motm (playernumber, playername, {dotd_col})
VALUES (:player_num, :player_name, 1)
@ -352,8 +484,34 @@ def vote_thanks():
""")
sql_write(sql_dotd, {'player_num': _dotd, 'player_name': dotd_name})
# Generate device identifier and record vote for tracking
device_id = generate_device_id(request)
# Recalculate totals for both players
def update_player_totals(player_num):
# Get player data
sql_player = text("SELECT * FROM _hkfc_c_motm WHERE playernumber = :player_num")
player_data = sql_read(sql_player, {'player_num': player_num})
if player_data:
player = player_data[0]
motm_total = 0
dotd_total = 0
# Calculate totals from fixture columns
for col_name in player.keys():
if col_name.startswith('motm_') and col_name != 'motmtotal':
motm_total += player[col_name] or 0
elif col_name.startswith('dotd_') and col_name != 'dotdtotal':
dotd_total += player[col_name] or 0
# Update stored totals
sql_update = text("UPDATE _hkfc_c_motm SET motmtotal = :motm_total, dotdtotal = :dotd_total WHERE playernumber = :player_num")
sql_write(sql_update, {'motm_total': motm_total, 'dotd_total': dotd_total, 'player_num': player_num})
# Update totals for both players
update_player_totals(_motm)
update_player_totals(_dotd)
# Generate or retrieve persistent device identifier and record vote for tracking
device_id, device_created = get_or_create_device_id(request)
sql_device = text("""
INSERT INTO device_votes (device_id, fixture_date, motm_player_number, dotd_player_number,
motm_player_name, dotd_player_name, ip_address, user_agent)
@ -376,7 +534,37 @@ def vote_thanks():
sql3 = text("INSERT INTO _motmcomments (matchDate, comment) VALUES (:match_date, :comment)")
sql_write(sql3, {'match_date': _matchDate, 'comment': _fixed_comments})
return render_template('vote_thanks.html')
# Get Simpsons monkeys image URL with fallback
try:
# First try to get from S3
simpsons_url = s3_asset_service.get_asset_url('images/simpsons-monkeys.jpg')
print(f"DEBUG: Simpsons image URL: {simpsons_url}")
# If S3 is disabled or URL is fallback, use static
if simpsons_url.startswith('/static/'):
print("DEBUG: Using fallback static URL")
else:
print("DEBUG: Using S3 URL")
except Exception as e:
print(f"DEBUG: Error getting Simpsons image: {e}")
# Fallback to static URL
simpsons_url = "/static/images/simpsons-monkeys.jpg"
# Build response and set device ID cookie if newly created
response = make_response(render_template('vote_thanks.html', simpsons_image_url=simpsons_url))
if device_created:
# Two years in seconds
max_age_seconds = 60 * 60 * 24 * 730
response.set_cookie(
DEVICE_COOKIE_NAME,
device_id,
max_age=max_age_seconds,
httponly=True,
samesite='Lax',
secure=bool(request.is_secure)
)
return response
else:
return 'Ouch ... something went wrong here'
except Exception as e:
@ -398,14 +586,19 @@ def motm_admin():
else:
prevFixture = str(prevFixture)
if request.method == 'POST':
print(f"DEBUG: POST request received")
print(f"DEBUG: form.saveButton.data = {form.saveButton.data}")
print(f"DEBUG: form.activateButton.data = {form.activateButton.data}")
if form.saveButton.data:
print('Saved')
print('DEBUG: Save button clicked')
else:
print('Activated')
print('DEBUG: Activate button clicked')
_nextTeam = request.form.get('nextOppoTeam', '')
_nextMatchDate = request.form.get('nextMatchDate', '')
_votingDeadline = request.form.get('votingDeadline', '')
_currMotM = request.form.get('currMotM', '0')
_currDotD = request.form.get('currDotD', '0')
print(f"DEBUG: Form data - team: {_nextTeam}, date: {_nextMatchDate}")
# Validate required fields
if not _nextTeam or not _nextMatchDate:
@ -425,24 +618,30 @@ def motm_admin():
return redirect(url_for('motm_admin'))
_nextClub = _nextClubName[0]['club']
# Only update currMotM and currDotD if they were provided
if _currMotM and _currMotM != '0' and _currDotD and _currDotD != '0':
sql = text("UPDATE motmadminsettings SET nextdate = :next_date, nextclub = :next_club, nextteam = :next_team, currmotm = :curr_motm, currdotd = :curr_dotd")
sql_write_static(sql, {
'next_date': _nextMatchDate,
'next_club': _nextClub,
'next_team': _nextTeam,
'curr_motm': _currMotM,
'curr_dotd': _currDotD
})
else:
# Don't update currMotM and currDotD if not provided
sql = text("UPDATE motmadminsettings SET nextdate = :next_date, nextclub = :next_club, nextteam = :next_team")
sql_write_static(sql, {
'next_date': _nextMatchDate,
'next_club': _nextClub,
'next_team': _nextTeam
})
# Get the form values for previous MOTM and DotD
# If user selected '0' (No Previous), use None
# Otherwise use the selected player number
currmotm_value = None if _currMotM == '0' else _currMotM
currdotd_value = None if _currDotD == '0' else _currDotD
# Parse voting deadline if provided
voting_deadline_value = None
if _votingDeadline:
try:
from datetime import datetime
voting_deadline_value = datetime.strptime(_votingDeadline, '%Y-%m-%dT%H:%M')
except ValueError:
flash('Warning: Invalid voting deadline format. Deadline not set.', 'warning')
sql = text("UPDATE motmadminsettings SET nextdate = :nextdate, nextclub = :nextclub, nextteam = :nextteam, currmotm = :currmotm, currdotd = :currdotd, votingdeadline = :voting_deadline")
sql_write_static(sql, {
'nextdate': _nextMatchDate,
'nextclub': _nextClub,
'nextteam': _nextTeam,
'currmotm': currmotm_value,
'currdotd': currdotd_value,
'voting_deadline': voting_deadline_value
})
# Update the opponent logo using the matched club information
if opponent_club_info and opponent_club_info.get('logo_url'):
@ -452,14 +651,40 @@ def motm_admin():
sql_write_static(sql2, {'logo_url': logo_url})
else:
# Fallback to old method
sql2 = text("UPDATE motmadminsettings SET oppologo = (SELECT logo FROM menshockeyclubs WHERE hockeyclub = :next_club) WHERE nextclub = :next_club")
sql_write_static(sql2, {'next_club': _nextClub})
sql2 = text("UPDATE motmadminsettings SET oppologo = (SELECT logo FROM menshockeyclubs WHERE hockeyclub = :nextclub) WHERE nextclub = :nextclub")
sql_write_static(sql2, {'nextclub': _nextClub})
if form.saveButton.data:
flash('Settings saved!')
urlSuffix = randomUrlSuffix(8)
print(urlSuffix)
sql3 = text("UPDATE motmadminsettings SET motmurlsuffix = :url_suffix WHERE userid = 'admin'")
sql_write_static(sql3, {'url_suffix': urlSuffix})
print(f"DEBUG: Generated URL suffix: {urlSuffix}")
# Check if row exists before update
check_row = sql_read_static(text("SELECT userid, motmurlsuffix FROM motmadminsettings"))
print(f"DEBUG: Rows in motmadminsettings: {check_row}")
# Try to update with WHERE userid = 'admin'
sql3 = text("UPDATE motmadminsettings SET motmurlsuffix = :url_suffix")
print(f"DEBUG: About to execute UPDATE query with WHERE userid='admin'")
result = sql_write_static(sql3, {'url_suffix': urlSuffix})
print(f"DEBUG: UPDATE query result: {result}")
# Verify the update
verify = sql_read_static(text("SELECT motmurlsuffix FROM motmadminsettings"))
print(f"DEBUG: Verification with WHERE userid='admin': {verify}")
# If the update didn't work, try updating all rows (if there's only one row)
if not verify or not verify[0]['motmurlsuffix']:
print(f"DEBUG: First UPDATE didn't work, trying UPDATE without WHERE clause")
sql3_all = text("UPDATE motmadminsettings SET motmurlsuffix = :url_suffix")
result_all = sql_write_static(sql3_all, {'url_suffix': urlSuffix})
print(f"DEBUG: UPDATE all rows result: {result_all}")
# Verify again
verify_all = sql_read_static(text("SELECT userid, motmurlsuffix FROM motmadminsettings"))
print(f"DEBUG: Verification after UPDATE all: {verify_all}")
if verify_all and verify_all[0]['motmurlsuffix']:
print(f"DEBUG: SUCCESS! URL suffix updated to: {verify_all[0]['motmurlsuffix']}")
flash('MotM URL https://motm.ervine.cloud/motm/'+urlSuffix)
elif form.activateButton.data:
# Generate a fixture number based on the date
@ -468,23 +693,42 @@ def motm_admin():
sql4 = text(f"ALTER TABLE _hkfc_c_motm ADD COLUMN motm_{_nextFixture} smallint DEFAULT 0, ADD COLUMN dotd_{_nextFixture} smallint DEFAULT 0, ADD COLUMN assists_{_nextFixture} smallint DEFAULT 0, ADD COLUMN goals_{_nextFixture} smallint DEFAULT 0")
sql_write(sql4)
except Exception as e:
# Columns already exist, which is fine
print(f"Columns already exist for fixture {_nextFixture}: {e}")
pass
sql5 = text("SELECT motmurlsuffix FROM motmadminsettings WHERE userid = 'admin'")
# Columns already exist, which is fine - just log at debug level
error_msg = str(e)
if 'already exists' in error_msg or 'duplicate' in error_msg.lower():
# This is expected if columns already exist, don't log as error
print(f"Columns for fixture {_nextFixture} already exist (this is normal)")
else:
# Unexpected error, log it
print(f"Error adding columns for fixture {_nextFixture}: {e}")
# Get or generate the URL suffix
print("DEBUG: Getting URL suffix from database")
sql5 = text("SELECT motmurlsuffix FROM motmadminsettings")
tempSuffix = sql_read_static(sql5)
if not tempSuffix:
flash('Error: Admin settings not found', 'error')
return redirect(url_for('motm_admin'))
currSuffix = tempSuffix[0]['motmurlsuffix']
print(currSuffix)
print(f"DEBUG: Query result: {tempSuffix}")
if not tempSuffix or not tempSuffix[0]['motmurlsuffix']:
# Generate a new URL suffix if one doesn't exist
print("DEBUG: URL suffix is None or empty, generating new one")
urlSuffix = randomUrlSuffix(8)
print(f"DEBUG: Generated new suffix: {urlSuffix}")
sql6 = text("UPDATE motmadminsettings SET motmurlsuffix = :url_suffix")
result = sql_write_static(sql6, {'url_suffix': urlSuffix})
print(f"DEBUG: UPDATE result: {result}")
currSuffix = urlSuffix
flash('New voting URL generated')
else:
currSuffix = tempSuffix[0]['motmurlsuffix']
print(f"DEBUG: Using existing suffix: {currSuffix}")
print(f"DEBUG: Final suffix: {currSuffix}")
flash('Man of the Match vote is now activated')
flash('MotM URL https://motm.ervine.cloud/motm/'+currSuffix)
else:
flash('Something went wrong - check with Smithers')
# Load current settings to populate the form
sql_current = text("SELECT nextdate FROM motmadminsettings WHERE userid = 'admin'")
sql_current = text("SELECT nextdate, nextteam, currmotm, currdotd, votingdeadline FROM motmadminsettings")
current_settings = sql_read_static(sql_current)
if current_settings:
from datetime import datetime
@ -495,9 +739,28 @@ def motm_admin():
form.nextMatchDate.data = current_date
except:
pass
# Pre-populate the next team field
if current_settings[0].get('nextteam'):
form.nextOppoTeam.data = current_settings[0]['nextteam']
# Pre-populate the voting deadline field
if current_settings[0].get('votingdeadline'):
deadline = current_settings[0]['votingdeadline']
if isinstance(deadline, str):
try:
deadline = datetime.strptime(deadline, '%Y-%m-%d %H:%M:%S')
except ValueError:
pass
if hasattr(deadline, 'strftime'):
form.votingDeadline.data = deadline.strftime('%Y-%m-%dT%H:%M')
# Get automatically determined previous winners
auto_prev_motm, auto_prev_dotd = get_previous_match_winners()
sql4 = text("SELECT hockeyclub FROM menshockeyclubs ORDER BY hockeyclub")
sql5 = text("SELECT nextclub, oppologo FROM motmadminsettings")
sql6 = text(f"SELECT playernumber, playerforenames, playersurname FROM _hkfcc_matchsquad_{prevFixture} ORDER BY playerforenames")
# Get all players for the dropdown
sql6 = text("SELECT playernumber, playerforenames, playersurname, playernickname FROM _hkfc_players ORDER BY playernickname")
clubs = sql_read_static(sql4)
settings = sql_read_static(sql5)
players = sql_read(sql6)
@ -511,9 +774,40 @@ def motm_admin():
players = []
form.nextOppoClub.choices = [(oppo['hockeyclub'], oppo['hockeyclub']) for oppo in clubs]
form.currMotM.choices = [(player['playernumber'], player['playerforenames'] + " " + player['playersurname']) for player in players]
form.currDotD.choices = [(player['playernumber'], player['playerforenames'] + " " + player['playersurname']) for player in players]
clubLogo = settings[0]['oppologo']
# Build player choices with nickname for better identification
form.currMotM.choices = [('0', '-- No Previous MOTM --')] + [(str(player['playernumber']), f"{player['playernickname']} ({player['playerforenames']} {player['playersurname']})") for player in players]
form.currDotD.choices = [('0', '-- No Previous DotD --')] + [(str(player['playernumber']), f"{player['playernickname']} ({player['playerforenames']} {player['playersurname']})") for player in players]
# Pre-select values: use database value if exists, otherwise auto-determine from previous fixture
if current_settings:
# If database has a value set, use it; otherwise use auto-determined winner
if current_settings[0].get('currmotm'):
form.currMotM.data = str(current_settings[0]['currmotm'])
elif auto_prev_motm:
form.currMotM.data = str(auto_prev_motm)
else:
form.currMotM.data = '0'
if current_settings[0].get('currdotd'):
form.currDotD.data = str(current_settings[0]['currdotd'])
elif auto_prev_dotd:
form.currDotD.data = str(auto_prev_dotd)
else:
form.currDotD.data = '0'
else:
# No settings in database, use auto-determined or default to '0'
form.currMotM.data = str(auto_prev_motm) if auto_prev_motm else '0'
form.currDotD.data = str(auto_prev_dotd) if auto_prev_dotd else '0'
# Get the opposition logo using S3 service
clubLogo = s3_asset_service.get_asset_url('images/default_logo.png') # Default fallback
if settings and settings[0]['nextclub']:
nextClub = settings[0]['nextclub']
# Get the club logo from the clubs table
sql_club_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = :club_name")
club_logo_result = sql_read(sql_club_logo, {'club_name': nextClub})
if club_logo_result and club_logo_result[0]['logo_url']:
clubLogo = s3_asset_service.get_logo_url(club_logo_result[0]['logo_url'], nextClub)
return render_template('motm_admin.html', form=form, nextOppoLogo=clubLogo)
@ -827,7 +1121,7 @@ def data_import():
if form.import_clubs.data:
# Import clubs based on Hong Kong Hockey Association data
clubs_data = [
{'hockey_club': 'HKFC', 'logo_url': '/static/images/hkfc_logo.png'},
{'hockey_club': 'HKFC', 'logo_url': '/static/images/hkfclogo.png'},
{'hockey_club': 'KCC', 'logo_url': '/static/images/kcc_logo.png'},
{'hockey_club': 'USRC', 'logo_url': '/static/images/usrc_logo.png'},
{'hockey_club': 'Valley', 'logo_url': '/static/images/valley_logo.png'},
@ -1318,6 +1612,180 @@ def motm_management():
motm_data=motm_data)
@app.route('/admin/comments/manage', methods=['GET', 'POST'])
@basic_auth.required
def comments_management():
"""Manage match comments with edit and delete functionality"""
if request.method == 'POST':
action = request.form.get('action')
if action == 'delete_comment':
comment_id = request.form.get('comment_id')
match_date = request.form.get('match_date')
original_comment = request.form.get('original_comment')
if comment_id and match_date and original_comment:
try:
# For PostgreSQL, use ctid if available, otherwise match on date and comment
try:
# Try using ctid (PostgreSQL)
sql_delete = text("DELETE FROM _motmcomments WHERE ctid = :comment_id::tid")
sql_write(sql_delete, {'comment_id': comment_id})
flash('Comment deleted successfully', 'success')
except:
# Fallback: delete by matching matchDate and comment
# PostgreSQL doesn't support LIMIT in DELETE without using ctid
sql_delete = text("""
DELETE FROM _motmcomments
WHERE ctid IN (
SELECT ctid FROM _motmcomments
WHERE matchDate = :match_date AND comment = :comment
LIMIT 1
)
""")
sql_write(sql_delete, {'match_date': match_date, 'comment': original_comment})
flash('Comment deleted successfully', 'success')
except Exception as e:
flash(f'Error deleting comment: {str(e)}', 'error')
elif action == 'edit_comment':
comment_id = request.form.get('comment_id')
new_comment = request.form.get('comment')
match_date = request.form.get('match_date')
original_comment = request.form.get('original_comment')
if new_comment and match_date and original_comment:
try:
# Don't escape single quotes - parameterized queries handle that
try:
# Try using ctid (PostgreSQL)
sql_update = text("UPDATE _motmcomments SET comment = :comment WHERE ctid = :comment_id::tid")
sql_write(sql_update, {'comment': new_comment, 'comment_id': comment_id})
flash('Comment updated successfully', 'success')
except:
# Fallback: update by matching matchDate and original comment
# Update only the first matching row using ctid
sql_update = text("""
UPDATE _motmcomments
SET comment = :new_comment
WHERE ctid IN (
SELECT ctid FROM _motmcomments
WHERE matchDate = :match_date AND comment = :old_comment
LIMIT 1
)
""")
sql_write(sql_update, {'new_comment': new_comment, 'match_date': match_date, 'old_comment': original_comment})
flash('Comment updated successfully', 'success')
except Exception as e:
flash(f'Error updating comment: {str(e)}', 'error')
elif action == 'delete_match_comments':
match_date = request.form.get('match_date')
if match_date:
try:
sql_delete = text("DELETE FROM _motmcomments WHERE matchDate = :match_date")
sql_write(sql_delete, {'match_date': match_date})
flash(f'Deleted all comments for match {match_date}', 'success')
except Exception as e:
flash(f'Error deleting match comments: {str(e)}', 'error')
elif action == 'delete_all_comments':
try:
sql_delete = text("DELETE FROM _motmcomments")
sql_write(sql_delete)
flash('All comments deleted successfully', 'success')
except Exception as e:
flash(f'Error deleting all comments: {str(e)}', 'error')
elif action == 'drop_column':
column_name = request.form.get('column_name')
if column_name:
try:
sql_drop = text(f"ALTER TABLE _motmcomments DROP COLUMN {column_name}")
sql_write(sql_drop)
flash(f'Successfully dropped column {column_name}', 'success')
except Exception as e:
flash(f'Error dropping column: {str(e)}', 'error')
# Get all columns from the table
try:
sql_columns = text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = '_motmcomments'
ORDER BY ordinal_position
""")
columns_result = sql_read(sql_columns)
table_columns = [col['column_name'] for col in columns_result] if columns_result else ['matchDate', 'comment']
except:
# Fallback for SQLite or if information_schema is not available
table_columns = ['matchDate', 'comment']
# Get all comments with row IDs
# Try different approaches based on database type
comments = []
try:
# First, try to get the actual table structure to find a primary key
sql_pk = text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = '_motmcomments'
AND column_name IN ('id', 'rowid', 'oid')
LIMIT 1
""")
pk_result = sql_read(sql_pk)
if pk_result:
# Use the found primary key column
pk_column = pk_result[0]['column_name']
sql_comments = text(f"SELECT {pk_column} as comment_id, matchDate, comment FROM _motmcomments ORDER BY matchDate DESC")
comments = sql_read(sql_comments)
else:
# No explicit ID column, use PostgreSQL's ctid or generate row numbers
try:
# PostgreSQL: Use ctid (physical row identifier)
sql_comments = text("SELECT ctid::text as comment_id, matchDate, comment FROM _motmcomments ORDER BY matchDate DESC")
comments = sql_read(sql_comments)
except:
# If that fails, use ROW_NUMBER()
sql_comments = text("""
SELECT ROW_NUMBER() OVER (ORDER BY matchDate DESC) as comment_id,
matchDate, comment
FROM _motmcomments
ORDER BY matchDate DESC
""")
comments = sql_read(sql_comments)
except Exception as e:
print(f"Error fetching comments: {e}")
# Last resort: Get comments without IDs and generate them in Python
try:
sql_comments = text("SELECT matchDate, comment FROM _motmcomments ORDER BY matchDate DESC")
raw_comments = sql_read(sql_comments)
if raw_comments:
# Add sequential IDs
comments = [
{'comment_id': idx + 1, 'matchDate': c['matchDate'], 'comment': c['comment']}
for idx, c in enumerate(raw_comments)
]
except:
comments = []
# Get unique match dates
match_dates = []
if comments:
unique_dates = set()
for comment in comments:
if comment.get('matchDate'):
unique_dates.add(str(comment['matchDate']))
match_dates = sorted(unique_dates, reverse=True)
return render_template('comments_management.html',
comments=comments,
match_dates=match_dates,
table_columns=table_columns)
@app.route('/admin/squad/submit', methods=['POST'])
@basic_auth.required
def match_squad_submit():
@ -1354,6 +1822,37 @@ def match_squad_list():
return render_template('match_squad_selected.html', table=table)
@app.route('/admin/squad/history')
@basic_auth.required
def squad_history():
"""View historical squad data"""
try:
# Get all historical squads grouped by fixture
sql = text("""
SELECT fixture_number, match_date,
COUNT(*) as player_count,
STRING_AGG(player_nickname, ', ') as players
FROM squad_history
GROUP BY fixture_number, match_date
ORDER BY match_date DESC
""")
history = sql_read(sql)
# Get detailed squad for each fixture
sql_details = text("""
SELECT fixture_number, match_date, player_number,
player_forenames, player_surname, player_nickname, archived_at
FROM squad_history
ORDER BY match_date DESC, player_nickname
""")
details = sql_read(sql_details)
return render_template('squad_history.html', history=history, details=details)
except Exception as e:
flash(f'Error loading squad history: {str(e)}', 'error')
return redirect(url_for('admin_dashboard'))
@app.route('/admin/squad/remove', methods=['POST'])
@basic_auth.required
def delPlayerFromSquad():
@ -1372,34 +1871,47 @@ def delPlayerFromSquad():
@app.route('/admin/squad/reset')
@basic_auth.required
def matchSquadReset():
"""Reset squad for new match"""
_matchNumber = str(mySettings('fixture'))
print(_matchNumber)
"""Reset squad for new match - archives current squad to history before clearing"""
try:
# First, check if there are any players in the current squad
check_sql = text("SELECT COUNT(*) as count FROM _hkfcC_matchSquad")
# Get current match date and fixture number from admin settings
sql_settings = text("SELECT nextdate, nextfixture FROM motmadminsettings")
settings = sql_read_static(sql_settings)
if not settings:
flash('Error: Admin settings not found. Please configure match settings first.', 'error')
return render_template('match_squad_reset.html')
match_date = settings[0]['nextdate']
fixture_number = match_date.strftime('%Y%m%d') if match_date else 'unknown'
# Check if there are any players in the current squad
check_sql = text("SELECT COUNT(*) as count FROM _hkfcc_matchsquad")
result = sql_read(check_sql)
squad_count = result[0]['count'] if result else 0
if squad_count > 0:
# Rename current squad table
sql1 = text(f"RENAME TABLE _hkfcC_matchSquad TO _hkfcC_matchSquad_{_matchNumber}")
sql_write(sql1)
# Archive current squad to history table
archive_sql = text("""
INSERT INTO squad_history (player_number, player_forenames, player_surname, player_nickname, match_date, fixture_number)
SELECT playernumber, playerforenames, playersurname, playernickname, :match_date, :fixture_number
FROM _hkfcc_matchsquad
""")
sql_write(archive_sql, {'match_date': match_date, 'fixture_number': fixture_number})
# Create new empty squad table
sql2 = text("CREATE TABLE _hkfcC_matchSquad (playerNumber smallint UNIQUE, playerForenames varchar(50), playerSurname varchar(30), playerNickname varchar(30) NOT NULL, PRIMARY KEY (playerNumber))")
sql_write(sql2)
# Clear current squad table
clear_sql = text("DELETE FROM _hkfcc_matchsquad")
sql_write(clear_sql)
# Update fixture number
sql3 = text("UPDATE motmAdminSettings SET prevFixture = :match_number")
sql_write_static(sql3, {'match_number': _matchNumber})
# Update previous fixture number in settings
update_sql = text("UPDATE motmadminsettings SET prevfixture = :fixture_number")
sql_write_static(update_sql, {'fixture_number': fixture_number})
flash(f'Squad reset successfully! {squad_count} players archived for match {_matchNumber}', 'success')
flash(f'Squad reset successfully! {squad_count} players archived for match {fixture_number} ({match_date})', 'success')
else:
flash('No players in current squad to reset', 'info')
except Exception as e:
print(f"Error in squad reset: {str(e)}")
flash(f'Error resetting squad: {str(e)}', 'error')
return render_template('match_squad_reset.html')
@ -1442,7 +1954,7 @@ def goalsAssistsSubmit():
@app.route('/admin/api/next-fixture')
@basic_auth.required
def get_next_fixture():
def get_nextfixture():
"""API endpoint to fetch the next HKFC C fixture from Hockey Hong Kong website"""
try:
fixture = get_next_hkfc_c_fixture()
@ -1451,6 +1963,11 @@ def get_next_fixture():
# Get opponent club information
opponent_club_info = get_opponent_club_info(fixture['opponent'])
# Get the opponent logo URL using S3 service
opponent_logo_url = s3_asset_service.get_asset_url('images/default_logo.png') # Default fallback
if opponent_club_info and opponent_club_info.get('logo_url'):
opponent_logo_url = s3_asset_service.get_logo_url(opponent_club_info['logo_url'], opponent_club_info['club_name'])
# Format the fixture data for JSON response
fixture_data = {
'success': True,
@ -1461,6 +1978,7 @@ def get_next_fixture():
'opponent': fixture['opponent'],
'opponent_club': get_opponent_club_name(fixture['opponent']),
'opponent_club_info': opponent_club_info,
'opponent_logo_url': opponent_logo_url,
'is_home': fixture['is_home'],
'home_team': fixture['home_team'],
'away_team': fixture['away_team'],
@ -1633,7 +2151,7 @@ def admin_fixture_logo_lookup(fixture):
def vote_results():
"""API endpoint for voting results"""
# Get the current match date from admin settings
sql_date = text("SELECT nextdate FROM motmadminsettings WHERE userid = 'admin'")
sql_date = text("SELECT nextdate FROM motmadminsettings")
date_result = sql_read_static(sql_date)
if not date_result:
@ -1683,9 +2201,9 @@ def poty_results():
# Only include players with votes
if motm_total > 0 or dotd_total > 0:
results.append({
'playername': player['playername'],
'motmtotal': motm_total,
'dotdtotal': dotd_total
'playerName': player['playername'], # Fixed field name to match JavaScript
'motmTotal': motm_total, # Fixed field name to match JavaScript
'dotdTotal': dotd_total # Fixed field name to match JavaScript
})
print(f"Dynamic POTY Results: {results}")
@ -1697,13 +2215,13 @@ def poty_results():
def voting_chart():
"""Admin page for viewing voting charts"""
# Get the current match date from admin settings
sql_date = text("SELECT nextdate FROM motmadminsettings WHERE userid = 'admin'")
sql_date = text("SELECT nextdate FROM motmadminsettings")
date_result = sql_read_static(sql_date)
if date_result:
matchDate = str(date_result[0]['nextdate']).replace('-', '')
else:
matchDate = '20251012' # Default fallback
matchDate = '20251015' # Default fallback
return render_template('vote_chart.html', _matchDate=matchDate)

18
motm_app/motm-app.service Normal file
View File

@ -0,0 +1,18 @@
[Unit]
Description=MOTM App - Man of the Match Voting System
After=network.target
[Service]
Type=exec
User=www-data
Group=www-data
WorkingDirectory=/home/jonny/Projects/gcp-hockey-results/motm_app
Environment=PATH=/home/jonny/Projects/gcp-hockey-results/motm_app/venv/bin
Environment=FLASK_ENV=production
ExecStart=/home/jonny/Projects/gcp-hockey-results/motm_app/venv/bin/gunicorn -c gunicorn.conf.py run_production:app
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@ -8,7 +8,7 @@ def mySettings(setting):
try:
# Convert setting to lowercase for PostgreSQL compatibility
setting_lower = setting.lower()
sql = text("SELECT " + setting_lower + " FROM motmadminsettings WHERE userid='admin'")
sql = text("SELECT " + setting_lower + " FROM motmadminsettings")
rows = sql_read_static(sql)
if rows:
return rows[0][setting_lower]

View File

@ -29,3 +29,6 @@ boto3>=1.34.0
# Legacy support (can be removed after migration)
flask-mysql
# Production WSGI server
gunicorn>=21.0.0

View File

@ -0,0 +1,13 @@
#!/bin/bash
# Script to revert all table and column name changes back to motmadminsettings
echo "This script will help you revert the table name changes."
echo "The issue is that production uses 'motmadminsettings' with camelCase columns,"
echo "but I changed everything to 'admin_settings' with snake_case columns."
echo ""
echo "You have two options:"
echo "1. Revert the code to use motmadminsettings (old table)"
echo "2. Migrate production database to use admin_settings (new table)"
echo ""
echo "Which would you prefer?"

View File

@ -0,0 +1,27 @@
#!/bin/bash
# Script to run the voting deadline migration on production
echo "=========================================="
echo "Running Voting Deadline Migration"
echo "=========================================="
echo ""
# Check if we're in a Kubernetes environment
if [ -n "$KUBERNETES_SERVICE_HOST" ]; then
echo "Running in Kubernetes environment"
python add_voting_deadline.py
else
echo "Running locally"
echo ""
echo "To run on production Kubernetes cluster:"
echo "1. kubectl get pods -n <your-namespace>"
echo "2. kubectl exec -it <pod-name> -n <your-namespace> -- python add_voting_deadline.py"
echo ""
echo "Or if using Helm:"
echo "helm upgrade motm-app ./helm-chart/motm-app --namespace <your-namespace>"
echo ""
python add_voting_deadline.py
fi
echo ""
echo "Migration complete!"

View File

@ -0,0 +1,12 @@
#!/usr/bin/env python3
"""
Production WSGI entry point for MOTM App
This file is used by Gunicorn to run the application in production
"""
from main import app
if __name__ == "__main__":
# This should not be called directly in production
# Use: gunicorn -c gunicorn.conf.py run_production:app
app.run(host='0.0.0.0', port=5000, debug=False)

View File

@ -0,0 +1,102 @@
#!/bin/bash
# Quick script to run the voting deadline migration on production Kubernetes
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}MOTM App - Production Migration${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
# Default namespace
NAMESPACE="${1:-motm-app}"
echo -e "${YELLOW}Using namespace: ${NAMESPACE}${NC}"
echo ""
# Step 1: Find the pod
echo -e "${YELLOW}Step 1: Finding production pod...${NC}"
POD_NAME=$(kubectl get pods -n "$NAMESPACE" -l app.kubernetes.io/name=motm-app -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
if [ -z "$POD_NAME" ]; then
echo -e "${RED}✗ Error: Could not find motm-app pod in namespace '$NAMESPACE'${NC}"
echo ""
echo "Available namespaces:"
kubectl get namespaces
echo ""
echo "Usage: $0 [namespace]"
echo "Example: $0 motm-app"
exit 1
fi
echo -e "${GREEN}✓ Found pod: ${POD_NAME}${NC}"
echo ""
# Step 2: Check if migration script exists in pod
echo -e "${YELLOW}Step 2: Checking migration script...${NC}"
if kubectl exec -n "$NAMESPACE" "$POD_NAME" -- test -f /app/add_voting_deadline.py 2>/dev/null; then
echo -e "${GREEN}✓ Migration script found${NC}"
else
echo -e "${RED}✗ Error: Migration script not found in pod${NC}"
echo "The add_voting_deadline.py script needs to be in the Docker image."
echo "Please rebuild and redeploy the application."
exit 1
fi
echo ""
# Step 3: Run the migration
echo -e "${YELLOW}Step 3: Running migration...${NC}"
if kubectl exec -n "$NAMESPACE" "$POD_NAME" -- python /app/add_voting_deadline.py; then
echo -e "${GREEN}✓ Migration completed successfully!${NC}"
else
echo -e "${RED}✗ Migration failed!${NC}"
exit 1
fi
echo ""
# Step 4: Verify the migration
echo -e "${YELLOW}Step 4: Verifying migration...${NC}"
VERIFY_CMD="from db_config import db_config; from sqlalchemy import text, inspect; engine = db_config.engine; inspector = inspect(engine); columns = inspector.get_columns('admin_settings'); voting_deadline_exists = any(col['name'] == 'votingdeadline' for col in columns); print('✓ votingdeadline column exists' if voting_deadline_exists else '✗ votingdeadline column missing')"
if kubectl exec -n "$NAMESPACE" "$POD_NAME" -- python -c "$VERIFY_CMD" 2>&1 | grep -q "✓"; then
echo -e "${GREEN}✓ Verification successful!${NC}"
else
echo -e "${RED}✗ Verification failed!${NC}"
echo "The column may not have been created properly."
exit 1
fi
echo ""
# Step 5: Restart the pod to clear cached connections
echo -e "${YELLOW}Step 5: Restarting pod to clear cached connections...${NC}"
kubectl rollout restart deployment/motm-app -n "$NAMESPACE" 2>/dev/null || \
kubectl delete pod "$POD_NAME" -n "$NAMESPACE"
echo -e "${GREEN}✓ Pod restart initiated${NC}"
echo ""
# Wait for pod to be ready
echo -e "${YELLOW}Waiting for pod to be ready...${NC}"
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=motm-app -n "$NAMESPACE" --timeout=120s
echo ""
# Summary
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}Migration Complete!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo "Next steps:"
echo "1. Visit https://motm.ervine.cloud/admin/motm"
echo "2. The page should load without SQL errors"
echo "3. You should see the 'Voting Deadline' field"
echo "4. Set a deadline for your next match"
echo ""
echo "To check logs:"
echo " kubectl logs -n $NAMESPACE -l app.kubernetes.io/name=motm-app --tail=50"
echo ""

View File

@ -13,22 +13,109 @@ from typing import Optional, Dict, Any
class S3ConfigManager:
"""Manages S3 configuration settings."""
"""Manages S3 configuration settings.
Configuration priority (highest to lowest):
1. Environment variables (from Kubernetes secrets)
2. Database settings (admin-configurable via web UI)
3. JSON file (local development fallback)
Note: Credentials are ALWAYS from environment variables for security.
"""
def __init__(self):
self.config_file = 's3_config.json'
self.config = self.load_config()
def load_config(self) -> Dict[str, Any]:
"""Load S3 configuration from file."""
"""Load S3 configuration from environment variables, database, or file.
Priority:
1. Environment variables (for Kubernetes/container deployments)
2. Database settings (admin-configurable via web UI)
3. Configuration file (for local/legacy deployments)
"""
# Priority 1: Check if running in Kubernetes/container mode with env vars
if os.getenv('S3_ENABLED') or os.getenv('S3_ACCESS_KEY_ID'):
return self._load_from_env()
# Priority 2: Try loading from database
db_config = self._load_from_database()
if db_config:
return db_config
# Priority 3: Fall back to file-based configuration
if os.path.exists(self.config_file):
try:
with open(self.config_file, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return self.get_default_config()
# Default: Disabled, use local static files
return self.get_default_config()
def _load_from_env(self) -> Dict[str, Any]:
"""Load S3 configuration from environment variables."""
return {
'aws_access_key_id': os.getenv('S3_ACCESS_KEY_ID', ''),
'aws_secret_access_key': os.getenv('S3_SECRET_ACCESS_KEY', ''),
'aws_region': os.getenv('S3_REGION', 'us-east-1'),
'bucket_name': os.getenv('S3_BUCKET', ''),
'bucket_prefix': os.getenv('S3_BUCKET_PREFIX', 'assets/'),
'enable_s3': os.getenv('S3_ENABLED', 'false').lower() in ('true', '1', 'yes'),
'use_signed_urls': os.getenv('S3_USE_SIGNED_URLS', 'true').lower() in ('true', '1', 'yes'),
'signed_url_expiry': int(os.getenv('S3_SIGNED_URL_EXPIRY', '3600')),
'fallback_to_static': os.getenv('S3_FALLBACK_TO_STATIC', 'true').lower() in ('true', '1', 'yes'),
'storage_provider': os.getenv('S3_STORAGE_PROVIDER', 'aws'),
'minio_endpoint': os.getenv('S3_ENDPOINT', ''),
'minio_use_ssl': os.getenv('S3_USE_SSL', 'true').lower() in ('true', '1', 'yes'),
'endpoint_url': os.getenv('S3_ENDPOINT', '') # For compatibility
}
def _load_from_database(self) -> Optional[Dict[str, Any]]:
"""Load S3 configuration from database.
Returns None if database is not available or no settings exist.
Credentials are always loaded from environment variables.
"""
try:
# Import here to avoid circular dependency
from database import get_db_session, S3Settings
session = get_db_session()
try:
# Get settings for admin user
settings = session.query(S3Settings).filter_by(userid='admin').first()
if not settings:
return None
# Build configuration from database settings
# Credentials ALWAYS come from environment variables
return {
'enable_s3': settings.enabled,
'aws_access_key_id': os.getenv('S3_ACCESS_KEY_ID', ''),
'aws_secret_access_key': os.getenv('S3_SECRET_ACCESS_KEY', ''),
'storage_provider': settings.storage_provider,
'aws_region': settings.region,
'bucket_name': settings.bucket_name,
'bucket_prefix': settings.bucket_prefix,
'use_signed_urls': settings.use_signed_urls,
'signed_url_expiry': settings.signed_url_expiry,
'fallback_to_static': settings.fallback_to_static,
'minio_endpoint': settings.endpoint,
'minio_use_ssl': settings.use_ssl,
'endpoint_url': settings.endpoint # For compatibility
}
finally:
session.close()
except Exception as e:
# Database not available or table doesn't exist yet
# This is normal during initial setup
return None
def get_default_config(self) -> Dict[str, Any]:
"""Get default S3 configuration."""
return {
@ -47,16 +134,60 @@ class S3ConfigManager:
}
def save_config(self, config_data: Dict[str, Any]) -> bool:
"""Save S3 configuration to file."""
"""Save S3 configuration to database.
Note: Credentials are NOT saved to database for security.
They should be provided via environment variables.
"""
try:
# Update environment variables
os.environ['AWS_ACCESS_KEY_ID'] = config_data.get('aws_access_key_id', '')
os.environ['AWS_SECRET_ACCESS_KEY'] = config_data.get('aws_secret_access_key', '')
os.environ['AWS_DEFAULT_REGION'] = config_data.get('aws_region', 'us-east-1')
from database import get_db_session, S3Settings
session = get_db_session()
try:
# Get or create settings for admin user
settings = session.query(S3Settings).filter_by(userid='admin').first()
if not settings:
settings = S3Settings(userid='admin')
session.add(settings)
# Update settings from config_data
# NOTE: We do NOT save credentials to database
settings.enabled = config_data.get('enable_s3', False)
settings.storage_provider = config_data.get('storage_provider', 'aws')
settings.endpoint = config_data.get('minio_endpoint', config_data.get('endpoint_url', ''))
settings.region = config_data.get('aws_region', 'us-east-1')
settings.bucket_name = config_data.get('bucket_name', '')
settings.bucket_prefix = config_data.get('bucket_prefix', 'assets/')
settings.use_signed_urls = config_data.get('use_signed_urls', True)
settings.signed_url_expiry = config_data.get('signed_url_expiry', 3600)
settings.fallback_to_static = config_data.get('fallback_to_static', True)
settings.use_ssl = config_data.get('minio_use_ssl', True)
session.commit()
# Reload config from database
self.config = self.load_config()
return True
except Exception as e:
session.rollback()
print(f"Error saving S3 config to database: {e}")
# Fall back to file-based save for local development
return self._save_to_file(config_data)
finally:
session.close()
except Exception as e:
print(f"Database not available, falling back to file: {e}")
# Fall back to file-based save for local development
return self._save_to_file(config_data)
def _save_to_file(self, config_data: Dict[str, Any]) -> bool:
"""Save S3 configuration to JSON file (fallback for local development)."""
try:
with open(self.config_file, 'w') as f:
json.dump(config_data, f, indent=2)
self.config = config_data
return True
except IOError:
@ -203,7 +334,13 @@ class S3AssetService:
region_name=config.get('aws_region', 'us-east-1'),
endpoint_url=endpoint_url,
use_ssl=use_ssl,
verify=True # Enable SSL certificate verification
verify=True, # Enable SSL certificate verification
config=boto3.session.Config(
s3={
'addressing_style': 'path'
},
signature_version='s3v4'
)
)
else:
# Create AWS S3 client

26
motm_app/start_production.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
# Production startup script for MOTM App
echo "Starting MOTM App in production mode..."
# Check if virtual environment exists
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python3 -m venv venv
fi
# Activate virtual environment
echo "Activating virtual environment..."
source venv/bin/activate
# Install/upgrade dependencies
echo "Installing dependencies..."
pip install --upgrade pip
pip install -r requirements.txt
# Set production environment
export FLASK_ENV=production
# Start Gunicorn
echo "Starting Gunicorn WSGI server..."
gunicorn -c gunicorn.conf.py run_production:app

View File

@ -10,35 +10,37 @@ class matchSquadTable:
def __html__(self):
"""Generate HTML table from items"""
if not self.items:
return Markup('<p>No players in squad</p>')
return Markup('<div class="alert alert-info text-center"><i class="fas fa-info-circle me-2"></i>No players in squad</div>')
# Start table
classes_str = ' '.join(self.classes) if self.classes else ''
border_attr = 'border="1"' if self.border else ''
html = f'<table class="table {classes_str}" {border_attr}>\n'
# Start table with Bootstrap 5 classes
classes_str = ' '.join(self.classes) if self.classes else 'table table-striped table-hover'
html = f'<div class="table-responsive"><table class="{classes_str} mb-0">\n'
# Table header
html += ' <thead>\n <tr>\n'
html += ' <th>Player Number</th>\n'
html += ' <th>Nickname</th>\n'
html += ' <th>Surname</th>\n'
html += ' <th>Forenames</th>\n'
html += ' <th>Delete</th>\n'
# Table header with modern styling
html += ' <thead class="table-dark">\n <tr>\n'
html += ' <th><i class="fas fa-hashtag me-1"></i>Player Number</th>\n'
html += ' <th><i class="fas fa-user me-1"></i>Nickname</th>\n'
html += ' <th><i class="fas fa-id-card me-1"></i>Surname</th>\n'
html += ' <th><i class="fas fa-id-card me-1"></i>Forenames</th>\n'
html += ' <th class="text-center"><i class="fas fa-cog me-1"></i>Actions</th>\n'
html += ' </tr>\n </thead>\n'
# Table body
# Table body with enhanced styling
html += ' <tbody>\n'
for item in self.items:
html += ' <tr>\n'
html += f' <td>{item.get("playernumber", "")}</td>\n'
html += f' <td>{item.get("playernickname", "")}</td>\n'
html += f' <td><span class="badge bg-primary fs-6">#{item.get("playernumber", "")}</span></td>\n'
html += f' <td><strong>{item.get("playernickname", "")}</strong></td>\n'
html += f' <td>{item.get("playersurname", "")}</td>\n'
html += f' <td>{item.get("playerforenames", "")}</td>\n'
html += f' <td><form method="post" action="/admin/squad/remove?playerNumber={item.get("playernumber", "")}"><button type="submit" class="btn btn-danger">Delete</button></form></td>\n'
html += f' <td class="text-center">'
html += f'<form method="post" action="/admin/squad/remove?playerNumber={item.get("playernumber", "")}" class="d-inline">'
html += f'<button type="submit" class="btn btn-danger btn-sm" data-confirm="Are you sure you want to remove player #{item.get("playernumber", "")} from the squad?">'
html += f'<i class="fas fa-trash me-1"></i>Remove</button></form></td>\n'
html += ' </tr>\n'
html += ' </tbody>\n'
# End table
html += '</table>\n'
html += '</table></div>\n'
return Markup(html)

View File

@ -1,219 +1,371 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - HKFC Men's C Team MOTM System</title>
<link rel="stylesheet" media="screen" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<style>
.admin-section {
margin-bottom: 30px;
}
.section-header {
background-color: #f5f5f5;
padding: 15px;
border-left: 4px solid #337ab7;
margin-bottom: 15px;
}
.card-custom {
transition: transform 0.2s;
}
.card-custom:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-12">
<div class="page-header">
<h1>HKFC Men's C Team - Admin Dashboard</h1>
<p class="lead">Central hub for all administrative functions</p>
</div>
<div class="mb-3">
<a href="/" class="btn btn-default">Back to Main Page</a>
<a href="/admin/profile" class="btn btn-outline-secondary">Admin Profile</a>
</div>
<!-- Data Management Section -->
<div class="admin-section">
<div class="section-header">
<h3>Data Management</h3>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/players" class="list-group-item">
<h4 class="list-group-item-heading">Player Management</h4>
<p class="list-group-item-text">Add, edit, and manage players</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/clubs" class="list-group-item">
<h4 class="list-group-item-heading">Club Management</h4>
<p class="list-group-item-text">Manage hockey clubs</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/teams" class="list-group-item">
<h4 class="list-group-item-heading">Team Management</h4>
<p class="list-group-item-text">Manage hockey teams</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/import" class="list-group-item">
<h4 class="list-group-item-heading">Data Import</h4>
<p class="list-group-item-text">Import clubs and teams</p>
</a>
</div>
</div>
</div>
</div>
<!-- Match Management Section -->
<div class="admin-section">
<div class="section-header">
<h3>Match Management</h3>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/squad" class="list-group-item">
<h4 class="list-group-item-heading">Squad Selection</h4>
<p class="list-group-item-text">Select match squad</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/squad/list" class="list-group-item">
<h4 class="list-group-item-heading">View Squad</h4>
<p class="list-group-item-text">View current squad</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/squad/reset" class="list-group-item">
<h4 class="list-group-item-heading">Reset Squad</h4>
<p class="list-group-item-text">Reset for new match</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/stats" class="list-group-item">
<h4 class="list-group-item-heading">Goals & Assists</h4>
<p class="list-group-item-text">Record statistics</p>
</a>
</div>
</div>
</div>
</div>
<!-- MOTM Management Section -->
<div class="admin-section">
<div class="section-header">
<h3>MOTM Management</h3>
</div>
<div class="row">
<div class="col-md-4">
<div class="list-group card-custom">
<a href="/admin/motm" class="list-group-item">
<h4 class="list-group-item-heading">MOTM Admin</h4>
<p class="list-group-item-text">Manage match settings and activate voting</p>
</a>
</div>
</div>
<div class="col-md-4">
<div class="list-group card-custom">
<a href="/admin/voting" class="list-group-item">
<h4 class="list-group-item-heading">Voting Results</h4>
<p class="list-group-item-text">View current match results</p>
</a>
</div>
</div>
<div class="col-md-4">
<div class="list-group card-custom">
<a href="/admin/motm/manage" class="list-group-item">
<h4 class="list-group-item-heading">MOTM Management</h4>
<p class="list-group-item-text">Reset MOTM/DotD counts for specific fixtures</p>
</a>
</div>
</div>
<div class="col-md-4">
<div class="list-group card-custom">
<a href="/admin/device-tracking" class="list-group-item">
<h4 class="list-group-item-heading">Device Tracking</h4>
<p class="list-group-item-text">Monitor voting patterns and detect duplicate votes</p>
</a>
</div>
</div>
<div class="col-md-4">
<div class="list-group card-custom">
<a href="/admin/poty" class="list-group-item">
<h4 class="list-group-item-heading">Player of the Year</h4>
<p class="list-group-item-text">View season standings</p>
</a>
</div>
</div>
</div>
</div>
<!-- System Management Section -->
<div class="admin-section">
<div class="section-header">
<h3>System Management</h3>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/database-setup" class="list-group-item">
<h4 class="list-group-item-heading">Database Setup</h4>
<p class="list-group-item-text">Configure and initialize database</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/database-status" class="list-group-item">
<h4 class="list-group-item-heading">Database Status</h4>
<p class="list-group-item-text">View database configuration</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/s3-config" class="list-group-item">
<h4 class="list-group-item-heading">S3 Configuration</h4>
<p class="list-group-item-text">Configure S3/MinIO storage</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/s3-status" class="list-group-item">
<h4 class="list-group-item-heading">S3 Status</h4>
<p class="list-group-item-text">View S3/MinIO status</p>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% extends "base.html" %}
{% block title %}Admin Dashboard - HKFC Men's C Team MOTM System{% endblock %}
{% block content %}
<!-- Dashboard Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h1 class="card-title">
<i class="fas fa-tachometer-alt text-primary me-2"></i>
Admin Dashboard
</h1>
<p class="lead text-muted">Central hub for all administrative functions</p>
</div>
</div>
</body>
</html>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-bolt me-2"></i>Quick Actions
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 mb-3">
<a href="/admin/import" class="btn btn-outline-primary w-100">
<i class="fas fa-download me-2"></i>
<div class="text-start">
<div class="fw-bold">Import Data</div>
<small class="text-muted">Import from HKHA</small>
</div>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/squad" class="btn btn-outline-success w-100">
<i class="fas fa-list-check me-2"></i>
<div class="text-start">
<div class="fw-bold">Select Squad</div>
<small class="text-muted">Choose match squad</small>
</div>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/motm" class="btn btn-outline-warning w-100">
<i class="fas fa-trophy me-2"></i>
<div class="text-start">
<div class="fw-bold">MOTM Settings</div>
<small class="text-muted">Manage voting</small>
</div>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/motm/manage" class="btn btn-outline-danger w-100">
<i class="fas fa-database me-2"></i>
<div class="text-start">
<div class="fw-bold">MOTM Management</div>
<small class="text-muted">Reset & drop columns</small>
</div>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/comments/manage" class="btn btn-outline-warning w-100">
<i class="fas fa-comments me-2"></i>
<div class="text-start">
<div class="fw-bold">Comments Management</div>
<small class="text-muted">Edit & delete comments</small>
</div>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/profile" class="btn btn-outline-secondary w-100">
<i class="fas fa-user-cog me-2"></i>
<div class="text-start">
<div class="fw-bold">Admin Profile</div>
<small class="text-muted">Account settings</small>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Data Management Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">
<i class="fas fa-database me-2"></i>Data Management
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-lg-4 mb-3">
<div class="card h-100 border-success">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-users text-success me-2"></i>Club Management
</h6>
</div>
<div class="card-body">
<p class="card-text">Manage hockey clubs, logos, and club information.</p>
<div class="d-grid gap-2">
<a href="/admin/clubs" class="btn btn-success">
<i class="fas fa-eye me-2"></i>View Clubs
</a>
<a href="/admin/clubs/add" class="btn btn-outline-success">
<i class="fas fa-plus me-2"></i>Add Club
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card h-100 border-primary">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-user text-primary me-2"></i>Player Management
</h6>
</div>
<div class="card-body">
<p class="card-text">Add, edit, and manage player information and squads.</p>
<div class="d-grid gap-2">
<a href="/admin/players" class="btn btn-primary">
<i class="fas fa-eye me-2"></i>View Players
</a>
<a href="/admin/players/add" class="btn btn-outline-primary">
<i class="fas fa-plus me-2"></i>Add Player
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card h-100 border-info">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-layer-group text-info me-2"></i>Team Management
</h6>
</div>
<div class="card-body">
<p class="card-text">Manage hockey teams and their associations with clubs.</p>
<div class="d-grid gap-2">
<a href="/admin/teams" class="btn btn-info">
<i class="fas fa-eye me-2"></i>View Teams
</a>
<a href="/admin/teams/add" class="btn btn-outline-info">
<i class="fas fa-plus me-2"></i>Add Team
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Analytics & Reports Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="card-title mb-0">
<i class="fas fa-chart-bar me-2"></i>Analytics & Reports
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-lg-4 mb-3">
<div class="card h-100 border-info">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-poll text-info me-2"></i>Vote Charts
</h6>
</div>
<div class="card-body">
<p class="card-text">View current vote counts and MOTM/DotD results for the current match.</p>
<div class="d-grid gap-2">
<a href="/admin/voting" class="btn btn-info">
<i class="fas fa-chart-pie me-2"></i>View Vote Charts
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card h-100 border-success">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-trophy text-success me-2"></i>Player of the Year
</h6>
</div>
<div class="card-body">
<p class="card-text">View Player of the Year standings and cumulative MOTM votes.</p>
<div class="d-grid gap-2">
<a href="/admin/poty" class="btn btn-success">
<i class="fas fa-medal me-2"></i>View POTY Chart
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card h-100 border-warning">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-futbol text-warning me-2"></i>Goals & Assists
</h6>
</div>
<div class="card-body">
<p class="card-text">Manage goals and assists statistics for matches.</p>
<div class="d-grid gap-2">
<a href="/admin/stats" class="btn btn-warning">
<i class="fas fa-plus me-2"></i>Add Stats
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- System Management Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="card-title mb-0">
<i class="fas fa-cogs me-2"></i>System Management
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-lg-4 mb-3">
<div class="card h-100 border-warning">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-cloud text-warning me-2"></i>S3 Storage
</h6>
</div>
<div class="card-body">
<p class="card-text">Configure and manage S3 storage for assets and logos.</p>
<div class="d-grid gap-2">
<a href="/admin/s3-config" class="btn btn-warning">
<i class="fas fa-cog me-2"></i>Configure S3
</a>
<a href="/admin/s3-status" class="btn btn-outline-warning">
<i class="fas fa-info-circle me-2"></i>View Status
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card h-100 border-secondary">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-database text-secondary me-2"></i>Database
</h6>
</div>
<div class="card-body">
<p class="card-text">Manage database configuration and status.</p>
<div class="d-grid gap-2">
<a href="/admin/database-status" class="btn btn-secondary">
<i class="fas fa-info-circle me-2"></i>Database Status
</a>
<a href="/admin/database-setup" class="btn btn-outline-secondary">
<i class="fas fa-wrench me-2"></i>Database Setup
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card h-100 border-dark">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-list-check text-dark me-2"></i>Squad Management
</h6>
</div>
<div class="card-body">
<p class="card-text">Manage match squads and player selections.</p>
<div class="d-grid gap-2">
<a href="/admin/squad/list" class="btn btn-dark">
<i class="fas fa-eye me-2"></i>View Squad
</a>
<a href="/admin/squad/history" class="btn btn-outline-dark">
<i class="fas fa-history me-2"></i>Squad History
</a>
<a href="/admin/squad/reset" class="btn btn-outline-dark">
<i class="fas fa-refresh me-2"></i>Reset Squad
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- System Status -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="card-title mb-0">
<i class="fas fa-heartbeat me-2"></i>System Status
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 text-center">
<div class="mb-2">
<i class="fas fa-server text-success fs-1"></i>
</div>
<h6>Database</h6>
<span class="badge bg-success">
<span class="status-indicator status-online"></span>Online
</span>
</div>
<div class="col-md-3 text-center">
<div class="mb-2">
<i class="fas fa-cloud text-info fs-1"></i>
</div>
<h6>S3 Storage</h6>
<span class="badge bg-info">
<span class="status-indicator status-online"></span>Configured
</span>
</div>
<div class="col-md-3 text-center">
<div class="mb-2">
<i class="fas fa-users text-primary fs-1"></i>
</div>
<h6>Active Users</h6>
<span class="badge bg-primary">1</span>
</div>
<div class="col-md-3 text-center">
<div class="mb-2">
<i class="fas fa-calendar text-warning fs-1"></i>
</div>
<h6>Next Match</h6>
<span class="badge bg-warning">Pending</span>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,281 @@
{% extends "base.html" %}
{% block title %}Admin Dashboard - HKFC Men's C Team MOTM System{% endblock %}
{% block content %}
<!-- Dashboard Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h1 class="card-title">
<i class="fas fa-tachometer-alt text-primary me-2"></i>
Admin Dashboard
</h1>
<p class="lead text-muted">Central hub for all administrative functions</p>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-bolt me-2"></i>Quick Actions
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 mb-3">
<a href="/admin/import" class="btn btn-outline-primary w-100">
<i class="fas fa-download me-2"></i>
<div class="text-start">
<div class="fw-bold">Import Data</div>
<small class="text-muted">Import from HKHA</small>
</div>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/squad" class="btn btn-outline-success w-100">
<i class="fas fa-list-check me-2"></i>
<div class="text-start">
<div class="fw-bold">Select Squad</div>
<small class="text-muted">Choose match squad</small>
</div>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/motm" class="btn btn-outline-warning w-100">
<i class="fas fa-trophy me-2"></i>
<div class="text-start">
<div class="fw-bold">MOTM Settings</div>
<small class="text-muted">Manage voting</small>
</div>
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/profile" class="btn btn-outline-secondary w-100">
<i class="fas fa-user-cog me-2"></i>
<div class="text-start">
<div class="fw-bold">Admin Profile</div>
<small class="text-muted">Account settings</small>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Data Management Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">
<i class="fas fa-database me-2"></i>Data Management
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-lg-4 mb-3">
<div class="card h-100 border-success">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-users text-success me-2"></i>Club Management
</h6>
</div>
<div class="card-body">
<p class="card-text">Manage hockey clubs, logos, and club information.</p>
<div class="d-grid gap-2">
<a href="/admin/clubs" class="btn btn-success">
<i class="fas fa-eye me-2"></i>View Clubs
</a>
<a href="/admin/clubs/add" class="btn btn-outline-success">
<i class="fas fa-plus me-2"></i>Add Club
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card h-100 border-primary">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-user text-primary me-2"></i>Player Management
</h6>
</div>
<div class="card-body">
<p class="card-text">Add, edit, and manage player information and squads.</p>
<div class="d-grid gap-2">
<a href="/admin/players" class="btn btn-primary">
<i class="fas fa-eye me-2"></i>View Players
</a>
<a href="/admin/players/add" class="btn btn-outline-primary">
<i class="fas fa-plus me-2"></i>Add Player
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card h-100 border-info">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-layer-group text-info me-2"></i>Team Management
</h6>
</div>
<div class="card-body">
<p class="card-text">Manage hockey teams and their associations with clubs.</p>
<div class="d-grid gap-2">
<a href="/admin/teams" class="btn btn-info">
<i class="fas fa-eye me-2"></i>View Teams
</a>
<a href="/admin/teams/add" class="btn btn-outline-info">
<i class="fas fa-plus me-2"></i>Add Team
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- System Management Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="card-title mb-0">
<i class="fas fa-cogs me-2"></i>System Management
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-lg-4 mb-3">
<div class="card h-100 border-warning">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-cloud text-warning me-2"></i>S3 Storage
</h6>
</div>
<div class="card-body">
<p class="card-text">Configure and manage S3 storage for assets and logos.</p>
<div class="d-grid gap-2">
<a href="/admin/s3-config" class="btn btn-warning">
<i class="fas fa-cog me-2"></i>Configure S3
</a>
<a href="/admin/s3-status" class="btn btn-outline-warning">
<i class="fas fa-info-circle me-2"></i>View Status
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card h-100 border-secondary">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-database text-secondary me-2"></i>Database
</h6>
</div>
<div class="card-body">
<p class="card-text">Manage database configuration and status.</p>
<div class="d-grid gap-2">
<a href="/admin/database-status" class="btn btn-secondary">
<i class="fas fa-info-circle me-2"></i>Database Status
</a>
<a href="/admin/database-setup" class="btn btn-outline-secondary">
<i class="fas fa-wrench me-2"></i>Database Setup
</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card h-100 border-dark">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="fas fa-list-check text-dark me-2"></i>Squad Management
</h6>
</div>
<div class="card-body">
<p class="card-text">Manage match squads and player selections.</p>
<div class="d-grid gap-2">
<a href="/admin/squad/list" class="btn btn-dark">
<i class="fas fa-eye me-2"></i>View Squad
</a>
<a href="/admin/squad/reset" class="btn btn-outline-dark">
<i class="fas fa-refresh me-2"></i>Reset Squad
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- System Status -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="card-title mb-0">
<i class="fas fa-heartbeat me-2"></i>System Status
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 text-center">
<div class="mb-2">
<i class="fas fa-server text-success fs-1"></i>
</div>
<h6>Database</h6>
<span class="badge bg-success">
<span class="status-indicator status-online"></span>Online
</span>
</div>
<div class="col-md-3 text-center">
<div class="mb-2">
<i class="fas fa-cloud text-info fs-1"></i>
</div>
<h6>S3 Storage</h6>
<span class="badge bg-info">
<span class="status-indicator status-online"></span>Configured
</span>
</div>
<div class="col-md-3 text-center">
<div class="mb-2">
<i class="fas fa-users text-primary fs-1"></i>
</div>
<h6>Active Users</h6>
<span class="badge bg-primary">1</span>
</div>
<div class="col-md-3 text-center">
<div class="mb-2">
<i class="fas fa-calendar text-warning fs-1"></i>
</div>
<h6>Next Match</h6>
<span class="badge bg-warning">Pending</span>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - HKFC Men's C Team MOTM System</title>
<link rel="stylesheet" media="screen" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<style>
.admin-section {
margin-bottom: 30px;
}
.section-header {
background-color: #f5f5f5;
padding: 15px;
border-left: 4px solid #337ab7;
margin-bottom: 15px;
}
.card-custom {
transition: transform 0.2s;
}
.card-custom:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-12">
<div class="page-header">
<h1>HKFC Men's C Team - Admin Dashboard</h1>
<p class="lead">Central hub for all administrative functions</p>
</div>
<div class="mb-3">
<a href="/" class="btn btn-default">Back to Main Page</a>
<a href="/admin/profile" class="btn btn-outline-secondary">Admin Profile</a>
</div>
<!-- Data Management Section -->
<div class="admin-section">
<div class="section-header">
<h3>Data Management</h3>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/players" class="list-group-item">
<h4 class="list-group-item-heading">Player Management</h4>
<p class="list-group-item-text">Add, edit, and manage players</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/clubs" class="list-group-item">
<h4 class="list-group-item-heading">Club Management</h4>
<p class="list-group-item-text">Manage hockey clubs</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/teams" class="list-group-item">
<h4 class="list-group-item-heading">Team Management</h4>
<p class="list-group-item-text">Manage hockey teams</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/import" class="list-group-item">
<h4 class="list-group-item-heading">Data Import</h4>
<p class="list-group-item-text">Import clubs and teams</p>
</a>
</div>
</div>
</div>
</div>
<!-- Match Management Section -->
<div class="admin-section">
<div class="section-header">
<h3>Match Management</h3>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/squad" class="list-group-item">
<h4 class="list-group-item-heading">Squad Selection</h4>
<p class="list-group-item-text">Select match squad</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/squad/list" class="list-group-item">
<h4 class="list-group-item-heading">View Squad</h4>
<p class="list-group-item-text">View current squad</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/squad/reset" class="list-group-item">
<h4 class="list-group-item-heading">Reset Squad</h4>
<p class="list-group-item-text">Reset for new match</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/stats" class="list-group-item">
<h4 class="list-group-item-heading">Goals & Assists</h4>
<p class="list-group-item-text">Record statistics</p>
</a>
</div>
</div>
</div>
</div>
<!-- MOTM Management Section -->
<div class="admin-section">
<div class="section-header">
<h3>MOTM Management</h3>
</div>
<div class="row">
<div class="col-md-4">
<div class="list-group card-custom">
<a href="/admin/motm" class="list-group-item">
<h4 class="list-group-item-heading">MOTM Admin</h4>
<p class="list-group-item-text">Manage match settings and activate voting</p>
</a>
</div>
</div>
<div class="col-md-4">
<div class="list-group card-custom">
<a href="/admin/voting" class="list-group-item">
<h4 class="list-group-item-heading">Voting Results</h4>
<p class="list-group-item-text">View current match results</p>
</a>
</div>
</div>
<div class="col-md-4">
<div class="list-group card-custom">
<a href="/admin/motm/manage" class="list-group-item">
<h4 class="list-group-item-heading">MOTM Management</h4>
<p class="list-group-item-text">Reset MOTM/DotD counts for specific fixtures</p>
</a>
</div>
</div>
<div class="col-md-4">
<div class="list-group card-custom">
<a href="/admin/device-tracking" class="list-group-item">
<h4 class="list-group-item-heading">Device Tracking</h4>
<p class="list-group-item-text">Monitor voting patterns and detect duplicate votes</p>
</a>
</div>
</div>
<div class="col-md-4">
<div class="list-group card-custom">
<a href="/admin/poty" class="list-group-item">
<h4 class="list-group-item-heading">Player of the Year</h4>
<p class="list-group-item-text">View season standings</p>
</a>
</div>
</div>
</div>
</div>
<!-- System Management Section -->
<div class="admin-section">
<div class="section-header">
<h3>System Management</h3>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/database-setup" class="list-group-item">
<h4 class="list-group-item-heading">Database Setup</h4>
<p class="list-group-item-text">Configure and initialize database</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/database-status" class="list-group-item">
<h4 class="list-group-item-heading">Database Status</h4>
<p class="list-group-item-text">View database configuration</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/s3-config" class="list-group-item">
<h4 class="list-group-item-heading">S3 Configuration</h4>
<p class="list-group-item-text">Configure S3/MinIO storage</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/s3-status" class="list-group-item">
<h4 class="list-group-item-heading">S3 Status</h4>
<p class="list-group-item-text">View S3/MinIO status</p>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,349 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}HKFC Men's C Team - MOTM System{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome Icons -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- Custom Styles -->
<style>
:root {
--primary-color: #0d6efd;
--secondary-color: #6c757d;
--success-color: #198754;
--danger-color: #dc3545;
--warning-color: #ffc107;
--info-color: #0dcaf0;
--light-color: #f8f9fa;
--dark-color: #212529;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
}
.navbar-brand {
font-weight: bold;
font-size: 1.5rem;
}
.page-header {
background: linear-gradient(135deg, var(--primary-color) 0%, #0056b3 100%);
color: white;
padding: 2rem 0;
margin-bottom: 2rem;
}
.page-header h1 {
margin: 0;
font-size: 2.5rem;
font-weight: 300;
}
.page-header p {
margin: 0.5rem 0 0 0;
opacity: 0.9;
font-size: 1.1rem;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.card-header {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-bottom: 1px solid #dee2e6;
border-radius: 10px 10px 0 0 !important;
font-weight: 600;
}
.btn {
border-radius: 6px;
font-weight: 500;
transition: all 0.2s ease-in-out;
}
.btn:hover {
transform: translateY(-1px);
}
.alert {
border: none;
border-radius: 8px;
border-left: 4px solid;
}
.alert-success {
border-left-color: var(--success-color);
background-color: #d1e7dd;
}
.alert-danger {
border-left-color: var(--danger-color);
background-color: #f8d7da;
}
.alert-warning {
border-left-color: var(--warning-color);
background-color: #fff3cd;
}
.alert-info {
border-left-color: var(--info-color);
background-color: #cff4fc;
}
.table {
border-radius: 8px;
overflow: hidden;
}
.table thead th {
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
}
.form-control, .form-select {
border-radius: 6px;
border: 1px solid #ced4da;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.form-control:focus, .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
.footer {
background-color: var(--dark-color);
color: white;
padding: 2rem 0;
margin-top: 4rem;
}
.loading-spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 0.125em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spinner-border 0.75s linear infinite;
}
@keyframes spinner-border {
to { transform: rotate(360deg); }
}
.breadcrumb {
background-color: transparent;
padding: 0;
margin-bottom: 1rem;
}
.breadcrumb-item + .breadcrumb-item::before {
content: "";
color: #6c757d;
}
.status-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.5rem;
}
.status-online { background-color: var(--success-color); }
.status-offline { background-color: var(--danger-color); }
.status-warning { background-color: var(--warning-color); }
</style>
{% block extra_head %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="/">
<i class="fas fa-trophy me-2"></i>HKFC MOTM System
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/">
<i class="fas fa-home me-1"></i>Home
</a>
</li>
{% if is_admin %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="adminDropdown" role="button" data-bs-toggle="dropdown">
<i class="fas fa-cog me-1"></i>Admin
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/admin">
<i class="fas fa-tachometer-alt me-2"></i>Dashboard
</a></li>
<li><a class="dropdown-item" href="/admin/clubs">
<i class="fas fa-users me-2"></i>Club Management
</a></li>
<li><a class="dropdown-item" href="/admin/players">
<i class="fas fa-user me-2"></i>Player Management
</a></li>
<li><a class="dropdown-item" href="/admin/teams">
<i class="fas fa-layer-group me-2"></i>Team Management
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/admin/s3-config">
<i class="fas fa-cloud me-2"></i>S3 Configuration
</a></li>
<li><a class="dropdown-item" href="/admin/profile">
<i class="fas fa-user-cog me-2"></i>Profile
</a></li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
{% if is_admin %}
<li class="nav-item">
<span class="navbar-text">
<i class="fas fa-user-shield me-1"></i>Admin
</span>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- Page Header -->
{% if page_title or page_subtitle %}
<div class="page-header">
<div class="container">
<div class="row">
<div class="col-12">
{% if breadcrumbs %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
{% for breadcrumb in breadcrumbs %}
<li class="breadcrumb-item {% if loop.last %}active{% endif %}">
{% if not loop.last and breadcrumb.url %}
<a href="{{ breadcrumb.url }}">{{ breadcrumb.title }}</a>
{% else %}
{{ breadcrumb.title }}
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
{% endif %}
{% if page_title %}
<h1>{{ page_title }}</h1>
{% endif %}
{% if page_subtitle %}
<p>{{ page_subtitle }}</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- Main Content -->
<div class="container">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="row">
<div class="col-12">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
<i class="fas fa-{{ 'exclamation-triangle' if category == 'error' else 'info-circle' if category == 'info' else 'check-circle' if category == 'success' else 'exclamation-triangle' }} me-2"></i>
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endwith %}
<!-- Page Content -->
{% block content %}{% endblock %}
</div>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="row">
<div class="col-md-6">
<h5>HKFC Men's C Team MOTM System</h5>
<p class="mb-0">Man of the Match and Dick of the Day voting system</p>
</div>
<div class="col-md-6 text-md-end">
<p class="mb-0">
<i class="fas fa-calendar-alt me-1"></i>
{% if current_year %}{{ current_year }}{% else %}2025{% endif %}
</p>
</div>
</div>
</div>
</footer>
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery (for compatibility with existing scripts) -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<!-- Common JavaScript -->
<script>
// Common utility functions
function showLoading(element) {
element.html('<span class="loading-spinner"></span> Loading...');
}
function hideLoading(element, originalText) {
element.html(originalText);
}
// Auto-dismiss alerts after 5 seconds
$(document).ready(function() {
setTimeout(function() {
$('.alert:not(.alert-permanent)').fadeOut();
}, 5000);
});
// Confirm dialogs for destructive actions
$('[data-confirm]').on('click', function(e) {
if (!confirm($(this).data('confirm'))) {
e.preventDefault();
}
});
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Comments Management - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.comment-card {
margin-bottom: 1rem;
border-left: 3px solid #0d6efd;
}
.comment-text {
white-space: pre-wrap;
word-wrap: break-word;
}
.reset-section {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 2rem;
}
.data-section {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 2rem;
}
.edit-form {
display: none;
margin-top: 0.5rem;
}
.action-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
</style>
</head>
<body>
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h1>Match Comments Management</h1>
<p class="lead">Manage, edit, and delete match comments</p>
<div class="mb-3">
<a href="/admin" class="btn btn-outline-primary">Back to Admin Dashboard</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Bulk Delete Controls -->
<div class="reset-section">
<h3>Bulk Operations</h3>
<p class="text-muted">Delete comments for specific matches or all comments at once.</p>
<div class="row">
<div class="col-md-6">
<h5>Delete Match Comments</h5>
<form method="POST" onsubmit="return confirm('Are you sure you want to delete all comments for this match?')">
<input type="hidden" name="action" value="delete_match_comments">
<div class="mb-3">
<select name="match_date" class="form-select" required>
<option value="">Select match date...</option>
{% for date in match_dates %}
<option value="{{ date }}">{{ date }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-warning">Delete Match Comments</button>
</form>
</div>
<div class="col-md-6">
<h5>Delete All Comments</h5>
<p class="text-muted">Warning: This will permanently delete all comments in the database.</p>
<form method="POST" onsubmit="return confirm('Are you sure you want to delete ALL comments? This action cannot be undone.')">
<input type="hidden" name="action" value="delete_all_comments">
<button type="submit" class="btn btn-danger">Delete All Comments</button>
</form>
</div>
</div>
</div>
<!-- Column Management -->
{% if table_columns|length > 2 %}
<div class="reset-section">
<h3>Column Management</h3>
<p class="text-muted">Drop unwanted columns from the _motmcomments table.</p>
<div class="row">
<div class="col-md-6">
<h5>Drop Column</h5>
<form method="POST" onsubmit="return confirm('Are you sure you want to drop this column? This action cannot be undone!')">
<input type="hidden" name="action" value="drop_column">
<div class="mb-3">
<select name="column_name" class="form-select" required>
<option value="">Select column to drop...</option>
{% for column in table_columns %}
{% if column not in ['matchDate', 'comment', 'rowid', 'id'] %}
<option value="{{ column }}">{{ column }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-danger">Drop Column</button>
</form>
</div>
<div class="col-md-6">
<h5>Available Columns</h5>
<p>Current columns in the table:</p>
<ul>
{% for column in table_columns %}
{% if column not in ['rowid', 'id'] %}
<li><code>{{ column }}</code></li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
<!-- Comments Display -->
<div class="data-section">
<h3>All Comments ({{ comments|length }})</h3>
{% if comments %}
<div class="row">
{% for comment in comments %}
<div class="col-md-12 mb-3">
<div class="card comment-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h5 class="card-title">
<span class="badge bg-primary">{{ comment.matchDate }}</span>
<small class="text-muted">#{{ comment.comment_id }}</small>
</h5>
<div class="comment-display-{{ comment.comment_id }}">
<p class="card-text comment-text">{{ comment.comment }}</p>
</div>
<div class="edit-form comment-edit-{{ comment.comment_id }}">
<form method="POST" id="editForm{{ comment.comment_id }}">
<input type="hidden" name="action" value="edit_comment">
<input type="hidden" name="comment_id" value="{{ comment.comment_id }}">
<input type="hidden" name="match_date" value="{{ comment.matchDate }}">
<input type="hidden" name="original_comment" value="{{ comment.comment }}">
<div class="mb-2">
<textarea name="comment" class="form-control" rows="3" required>{{ comment.comment }}</textarea>
</div>
<button type="submit" class="btn btn-success btn-sm">Save</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelEdit('{{ comment.comment_id }}')">Cancel</button>
</form>
</div>
</div>
<div class="action-buttons">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="toggleEdit('{{ comment.comment_id }}')" title="Edit comment">
<i class="fas fa-edit"></i> Edit
</button>
<form method="POST" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this comment?')">
<input type="hidden" name="action" value="delete_comment">
<input type="hidden" name="comment_id" value="{{ comment.comment_id }}">
<input type="hidden" name="match_date" value="{{ comment.matchDate }}">
<input type="hidden" name="original_comment" value="{{ comment.comment }}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete comment">
<i class="fas fa-trash"></i> Delete
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">
<h5>No comments found</h5>
<p>There are no comments in the database. Comments will appear here after users submit them during voting.</p>
</div>
{% endif %}
</div>
<!-- Statistics -->
{% if comments %}
<div class="data-section">
<h3>Statistics</h3>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h2 class="text-primary">{{ comments|length }}</h2>
<p class="text-muted">Total Comments</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h2 class="text-success">{{ match_dates|length }}</h2>
<p class="text-muted">Unique Match Dates</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h2 class="text-info">{{ (comments|length / match_dates|length)|round(1) if match_dates|length > 0 else 0 }}</h2>
<p class="text-muted">Avg Comments per Match</p>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
<script>
function toggleEdit(commentId) {
// Escape special characters in commentId for CSS selector
const escapedId = CSS.escape(String(commentId));
const displayDiv = document.querySelector('.comment-display-' + escapedId);
const editDiv = document.querySelector('.comment-edit-' + escapedId);
if (displayDiv && editDiv) {
displayDiv.style.display = 'none';
editDiv.style.display = 'block';
}
}
function cancelEdit(commentId) {
// Escape special characters in commentId for CSS selector
const escapedId = CSS.escape(String(commentId));
const displayDiv = document.querySelector('.comment-display-' + escapedId);
const editDiv = document.querySelector('.comment-edit-' + escapedId);
if (displayDiv && editDiv) {
displayDiv.style.display = 'block';
editDiv.style.display = 'none';
}
}
</script>
</body>
</html>

View File

@ -1,19 +1,35 @@
<html>
<head>
<title>Error - Invalid URL</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Error</h1>
<p>{{ message or "Invalid voting URL. Please check the link and try again." }}</p>
<a class="btn btn-primary" href="/" role="button">Home</a>
{% extends "base.html" %}
{% block title %}Error - HKFC MOTM System{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-danger">
<div class="card-body text-center">
<div class="mb-4">
<i class="fas fa-exclamation-triangle text-danger" style="font-size: 4rem;"></i>
</div>
<h1 class="card-title text-danger">Error</h1>
<div class="alert alert-danger">
<h5 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>Something went wrong
</h5>
<p class="mb-0">{{ message or "Invalid voting URL. Please check the link and try again." }}</p>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>Back to Home
</a>
<button onclick="history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Go Back
</button>
</div>
</div>
</div>
</body>
</html>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Error - HKFC MOTM System{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-danger">
<div class="card-body text-center">
<div class="mb-4">
<i class="fas fa-exclamation-triangle text-danger" style="font-size: 4rem;"></i>
</div>
<h1 class="card-title text-danger">Error</h1>
<div class="alert alert-danger">
<h5 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>Something went wrong
</h5>
<p class="mb-0">{{ message or "Invalid voting URL. Please check the link and try again." }}</p>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>Back to Home
</a>
<button onclick="history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Go Back
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,19 @@
<html>
<head>
<title>Error - Invalid URL</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Error</h1>
<p>{{ message or "Invalid voting URL. Please check the link and try again." }}</p>
<a class="btn btn-primary" href="/" role="button">Home</a>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,115 +1,237 @@
<html>
<head>
<title>HKFC Men's C Team - MOTM System</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>HKFC Men's C Team - Man of the Match System</h1>
<div class="jumbotron">
<h2>Welcome to the MOTM Voting System</h2>
<p>This system allows players to vote for Man of the Match and Dick of the Day, while providing admin tools for managing matches and squads.</p>
{% extends "base.html" %}
{% block title %}HKFC Men's C Team - MOTM System{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<!-- Welcome Section -->
<div class="card mb-4">
<div class="card-body text-center">
<h2 class="card-title">
<i class="fas fa-trophy text-warning me-2"></i>
Welcome to the MOTM Voting System
</h2>
<p class="card-text lead">
This system allows players to vote for Man of the Match and Dick of the Day,
while providing admin tools for managing matches and squads.
</p>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Player Section -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header bg-primary text-white">
<h4 class="card-title mb-0">
<i class="fas fa-users me-2"></i>Player Section
</h4>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="/motm/comments" class="btn btn-outline-primary btn-lg">
<i class="fas fa-comments me-2"></i>
<div class="text-start">
<div class="fw-bold">Match Comments</div>
<small class="text-muted">View comments from recent matches</small>
</div>
</a>
</div>
</div>
</div>
</div>
<!-- Admin Section -->
{% if is_admin %}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header bg-success text-white">
<h4 class="card-title mb-0">
<i class="fas fa-user-shield me-2"></i>Admin Section
</h4>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="/admin" class="btn btn-outline-success">
<i class="fas fa-tachometer-alt me-2"></i>
<div class="text-start">
<div class="fw-bold">Admin Dashboard</div>
<small class="text-muted">Access all administrative functions</small>
</div>
</a>
<a href="/admin/clubs" class="btn btn-outline-success">
<i class="fas fa-users me-2"></i>
<div class="text-start">
<div class="fw-bold">Club Management</div>
<small class="text-muted">Add, edit, and manage hockey clubs</small>
</div>
</a>
<a href="/admin/players" class="btn btn-outline-success">
<i class="fas fa-user me-2"></i>
<div class="text-start">
<div class="fw-bold">Player Management</div>
<small class="text-muted">Add, edit, and manage players</small>
</div>
</a>
<a href="/admin/teams" class="btn btn-outline-success">
<i class="fas fa-layer-group me-2"></i>
<div class="text-start">
<div class="fw-bold">Team Management</div>
<small class="text-muted">Add, edit, and manage hockey teams</small>
</div>
</a>
<a href="/admin/s3-config" class="btn btn-outline-info">
<i class="fas fa-cloud me-2"></i>
<div class="text-start">
<div class="fw-bold">S3 Configuration</div>
<small class="text-muted">Configure AWS S3 storage for logos and assets</small>
</div>
</a>
<a href="/admin/s3-status" class="btn btn-outline-info">
<i class="fas fa-cloud-upload-alt me-2"></i>
<div class="text-start">
<div class="fw-bold">S3 Status</div>
<small class="text-muted">View current S3 configuration and connection status</small>
</div>
</a>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header bg-secondary text-white">
<h4 class="card-title mb-0">
<i class="fas fa-lock me-2"></i>Admin Access
</h4>
</div>
<div class="card-body">
<div class="alert alert-info">
<h5 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>Authentication Required
</h5>
<p class="mb-0">Admin functions require authentication. Please contact the system administrator for access.</p>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Additional Admin Functions (if admin) -->
{% if is_admin %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-warning text-dark">
<h4 class="card-title mb-0">
<i class="fas fa-cogs me-2"></i>System Management
</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
<a href="/admin/import" class="btn btn-outline-warning w-100">
<i class="fas fa-download me-2"></i>
<div class="text-start">
<div class="fw-bold">Data Import</div>
<small class="text-muted">Import clubs and teams from HKHA</small>
</div>
</a>
</div>
<div class="row">
<div class="col-md-6">
<h3>Player Section</h3>
<div class="list-group">
<a href="/motm/comments" class="list-group-item">
<h4 class="list-group-item-heading">Match Comments</h4>
<p class="list-group-item-text">View comments from recent matches</p>
</a>
<div class="col-md-4 mb-3">
<a href="/admin/squad" class="btn btn-outline-warning w-100">
<i class="fas fa-list-check me-2"></i>
<div class="text-start">
<div class="fw-bold">Squad Selection</div>
<small class="text-muted">Select players for match squad</small>
</div>
</div>
{% if is_admin %}
<div class="col-md-6">
<h3>Admin Section</h3>
<div class="list-group">
<a href="/admin" class="list-group-item">
<h4 class="list-group-item-heading">Admin Dashboard</h4>
<p class="list-group-item-text">Access all administrative functions</p>
</a>
<a href="/admin/players" class="list-group-item">
<h4 class="list-group-item-heading">Player Management</h4>
<p class="list-group-item-text">Add, edit, and manage players in the database</p>
</a>
<a href="/admin/clubs" class="list-group-item">
<h4 class="list-group-item-heading">Club Management</h4>
<p class="list-group-item-text">Add, edit, and manage hockey clubs</p>
</a>
<a href="/admin/teams" class="list-group-item">
<h4 class="list-group-item-heading">Team Management</h4>
<p class="list-group-item-text">Add, edit, and manage hockey teams</p>
</a>
<a href="/admin/import" class="list-group-item">
<h4 class="list-group-item-heading">Data Import</h4>
<p class="list-group-item-text">Import clubs and teams from Hong Kong Hockey Association</p>
</a>
<a href="/admin/squad" class="list-group-item">
<h4 class="list-group-item-heading">Match Squad Selection</h4>
<p class="list-group-item-text">Select players for the match squad</p>
</a>
<a href="/admin/squad/list" class="list-group-item">
<h4 class="list-group-item-heading">View Current Squad</h4>
<p class="list-group-item-text">View current match squad</p>
</a>
<a href="/admin/squad/reset" class="list-group-item">
<h4 class="list-group-item-heading">Reset Squad</h4>
<p class="list-group-item-text">Reset squad for new match</p>
</a>
<a href="/admin/motm" class="list-group-item">
<h4 class="list-group-item-heading">MOTM Admin</h4>
<p class="list-group-item-text">Manage match settings and activate voting</p>
</a>
<a href="/admin/stats" class="list-group-item">
<h4 class="list-group-item-heading">Goals & Assists</h4>
<p class="list-group-item-text">Record goals and assists statistics</p>
</a>
<a href="/admin/voting" class="list-group-item">
<h4 class="list-group-item-heading">Voting Results</h4>
<p class="list-group-item-text">View current match voting results</p>
</a>
<a href="/admin/poty" class="list-group-item">
<h4 class="list-group-item-heading">Player of the Year</h4>
<p class="list-group-item-text">View season totals and Player of the Year standings</p>
</a>
<a href="/admin/database-setup" class="list-group-item">
<h4 class="list-group-item-heading">Database Setup</h4>
<p class="list-group-item-text">Configure and initialize the database</p>
</a>
<a href="/admin/database-status" class="list-group-item">
<h4 class="list-group-item-heading">Database Status</h4>
<p class="list-group-item-text">View current database configuration and status</p>
</a>
<a href="/admin/s3-config" class="list-group-item">
<h4 class="list-group-item-heading">S3 Configuration</h4>
<p class="list-group-item-text">Configure AWS S3 storage for logos and assets</p>
</a>
<a href="/admin/s3-status" class="list-group-item">
<h4 class="list-group-item-heading">S3 Status</h4>
<p class="list-group-item-text">View current S3 configuration and connection status</p>
</a>
</a>
</div>
<div class="col-md-4 mb-3">
<a href="/admin/motm" class="btn btn-outline-warning w-100">
<i class="fas fa-trophy me-2"></i>
<div class="text-start">
<div class="fw-bold">MOTM Management</div>
<small class="text-muted">Manage Man of the Match settings</small>
</div>
</div>
{% else %}
<div class="col-md-6">
<h3>Admin Access</h3>
<div class="alert alert-info">
<h4 class="alert-heading">Authentication Required</h4>
<p>Admin functions require authentication. Please contact the system administrator for access.</p>
</a>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<a href="/admin/squad/list" class="btn btn-outline-secondary w-100">
<i class="fas fa-eye me-2"></i>
<div class="text-start">
<div class="fw-bold">View Current Squad</div>
<small class="text-muted">View current match squad</small>
</div>
</div>
{% endif %}
</a>
</div>
<div class="col-md-4 mb-3">
<a href="/admin/squad/reset" class="btn btn-outline-danger w-100">
<i class="fas fa-refresh me-2"></i>
<div class="text-start">
<div class="fw-bold">Reset Squad</div>
<small class="text-muted">Reset squad for new match</small>
</div>
</a>
</div>
<div class="col-md-4 mb-3">
<a href="/admin/profile" class="btn btn-outline-secondary w-100">
<i class="fas fa-user-cog me-2"></i>
<div class="text-start">
<div class="fw-bold">Admin Profile</div>
<small class="text-muted">Manage admin settings</small>
</div>
</a>
</div>
</div>
</div>
</div>
</body>
</html>
</div>
</div>
{% endif %}
<!-- System Status Card -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h4 class="card-title mb-0">
<i class="fas fa-info-circle me-2"></i>System Information
</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6><i class="fas fa-server me-2"></i>System Status</h6>
<p class="text-muted">
<span class="status-indicator status-online"></span>
System Online
</p>
</div>
<div class="col-md-6">
<h6><i class="fas fa-calendar-alt me-2"></i>Current Season</h6>
<p class="text-muted">2025-2026 Hockey Season</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,238 @@
{% extends "base.html" %}
{% block title %}HKFC Men's C Team - MOTM System{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<!-- Welcome Section -->
<div class="card mb-4">
<div class="card-body text-center">
<h2 class="card-title">
<i class="fas fa-trophy text-warning me-2"></i>
Welcome to the MOTM Voting System
</h2>
<p class="card-text lead">
This system allows players to vote for Man of the Match and Dick of the Day,
while providing admin tools for managing matches and squads.
</p>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Player Section -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header bg-primary text-white">
<h4 class="card-title mb-0">
<i class="fas fa-users me-2"></i>Player Section
</h4>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="/motm/comments" class="btn btn-outline-primary btn-lg">
<i class="fas fa-comments me-2"></i>
<div class="text-start">
<div class="fw-bold">Match Comments</div>
<small class="text-muted">View comments from recent matches</small>
</div>
</a>
</div>
</div>
</div>
</div>
<!-- Admin Section -->
{% if is_admin %}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header bg-success text-white">
<h4 class="card-title mb-0">
<i class="fas fa-user-shield me-2"></i>Admin Section
</h4>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="/admin" class="btn btn-outline-success">
<i class="fas fa-tachometer-alt me-2"></i>
<div class="text-start">
<div class="fw-bold">Admin Dashboard</div>
<small class="text-muted">Access all administrative functions</small>
</div>
</a>
<a href="/admin/clubs" class="btn btn-outline-success">
<i class="fas fa-users me-2"></i>
<div class="text-start">
<div class="fw-bold">Club Management</div>
<small class="text-muted">Add, edit, and manage hockey clubs</small>
</div>
</a>
<a href="/admin/players" class="btn btn-outline-success">
<i class="fas fa-user me-2"></i>
<div class="text-start">
<div class="fw-bold">Player Management</div>
<small class="text-muted">Add, edit, and manage players</small>
</div>
</a>
<a href="/admin/teams" class="btn btn-outline-success">
<i class="fas fa-layer-group me-2"></i>
<div class="text-start">
<div class="fw-bold">Team Management</div>
<small class="text-muted">Add, edit, and manage hockey teams</small>
</div>
</a>
<a href="/admin/s3-config" class="btn btn-outline-info">
<i class="fas fa-cloud me-2"></i>
<div class="text-start">
<div class="fw-bold">S3 Configuration</div>
<small class="text-muted">Configure AWS S3 storage for logos and assets</small>
</div>
</a>
<a href="/admin/s3-status" class="btn btn-outline-info">
<i class="fas fa-cloud-upload-alt me-2"></i>
<div class="text-start">
<div class="fw-bold">S3 Status</div>
<small class="text-muted">View current S3 configuration and connection status</small>
</div>
</a>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header bg-secondary text-white">
<h4 class="card-title mb-0">
<i class="fas fa-lock me-2"></i>Admin Access
</h4>
</div>
<div class="card-body">
<div class="alert alert-info">
<h5 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>Authentication Required
</h5>
<p class="mb-0">Admin functions require authentication. Please contact the system administrator for access.</p>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Additional Admin Functions (if admin) -->
{% if is_admin %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-warning text-dark">
<h4 class="card-title mb-0">
<i class="fas fa-cogs me-2"></i>System Management
</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
<a href="/admin/import" class="btn btn-outline-warning w-100">
<i class="fas fa-download me-2"></i>
<div class="text-start">
<div class="fw-bold">Data Import</div>
<small class="text-muted">Import clubs and teams from HKHA</small>
</div>
</a>
</div>
<div class="col-md-4 mb-3">
<a href="/admin/squad" class="btn btn-outline-warning w-100">
<i class="fas fa-list-check me-2"></i>
<div class="text-start">
<div class="fw-bold">Squad Selection</div>
<small class="text-muted">Select players for match squad</small>
</div>
</a>
</div>
<div class="col-md-4 mb-3">
<a href="/admin/motm" class="btn btn-outline-warning w-100">
<i class="fas fa-trophy me-2"></i>
<div class="text-start">
<div class="fw-bold">MOTM Management</div>
<small class="text-muted">Manage Man of the Match settings</small>
</div>
</a>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<a href="/admin/squad/list" class="btn btn-outline-secondary w-100">
<i class="fas fa-eye me-2"></i>
<div class="text-start">
<div class="fw-bold">View Current Squad</div>
<small class="text-muted">View current match squad</small>
</div>
</a>
</div>
<div class="col-md-4 mb-3">
<a href="/admin/squad/reset" class="btn btn-outline-danger w-100">
<i class="fas fa-refresh me-2"></i>
<div class="text-start">
<div class="fw-bold">Reset Squad</div>
<small class="text-muted">Reset squad for new match</small>
</div>
</a>
</div>
<div class="col-md-4 mb-3">
<a href="/admin/profile" class="btn btn-outline-secondary w-100">
<i class="fas fa-user-cog me-2"></i>
<div class="text-start">
<div class="fw-bold">Admin Profile</div>
<small class="text-muted">Manage admin settings</small>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- System Status Card -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h4 class="card-title mb-0">
<i class="fas fa-info-circle me-2"></i>System Information
</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6><i class="fas fa-server me-2"></i>System Status</h6>
<p class="text-muted">
<span class="status-indicator status-online"></span>
System Online
</p>
</div>
<div class="col-md-6">
<h6><i class="fas fa-calendar-alt me-2"></i>Current Season</h6>
<p class="text-muted">2025-2026 Hockey Season</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block title %}HKFC Men's C Team - MOTM System{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<!-- Welcome Section -->
<div class="card mb-4">
<div class="card-body text-center">
<h2 class="card-title">
<i class="fas fa-trophy text-warning me-2"></i>
Welcome to the MOTM Voting System
</h2>
<p class="card-text lead">
This system allows players to vote for Man of the Match and Dick of the Day,
while providing admin tools for managing matches and squads.
</p>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h3>Player Section</h3>
<div class="list-group">
<a href="/motm/comments" class="list-group-item">
<h4 class="list-group-item-heading">Match Comments</h4>
<p class="list-group-item-text">View comments from recent matches</p>
</a>
</div>
</div>
{% if is_admin %}
<div class="col-md-6">
<h3>Admin Section</h3>
<div class="list-group">
<a href="/admin" class="list-group-item">
<h4 class="list-group-item-heading">Admin Dashboard</h4>
<p class="list-group-item-text">Access all administrative functions</p>
</a>
<a href="/admin/players" class="list-group-item">
<h4 class="list-group-item-heading">Player Management</h4>
<p class="list-group-item-text">Add, edit, and manage players in the database</p>
</a>
<a href="/admin/clubs" class="list-group-item">
<h4 class="list-group-item-heading">Club Management</h4>
<p class="list-group-item-text">Add, edit, and manage hockey clubs</p>
</a>
<a href="/admin/teams" class="list-group-item">
<h4 class="list-group-item-heading">Team Management</h4>
<p class="list-group-item-text">Add, edit, and manage hockey teams</p>
</a>
<a href="/admin/import" class="list-group-item">
<h4 class="list-group-item-heading">Data Import</h4>
<p class="list-group-item-text">Import clubs and teams from Hong Kong Hockey Association</p>
</a>
<a href="/admin/squad" class="list-group-item">
<h4 class="list-group-item-heading">Match Squad Selection</h4>
<p class="list-group-item-text">Select players for the match squad</p>
</a>
<a href="/admin/squad/list" class="list-group-item">
<h4 class="list-group-item-heading">View Current Squad</h4>
<p class="list-group-item-text">View current match squad</p>
</a>
<a href="/admin/squad/reset" class="list-group-item">
<h4 class="list-group-item-heading">Reset Squad</h4>
<p class="list-group-item-text">Reset squad for new match</p>
</a>
<a href="/admin/motm" class="list-group-item">
<h4 class="list-group-item-heading">MOTM Admin</h4>
<p class="list-group-item-text">Manage match settings and activate voting</p>
</a>
<a href="/admin/stats" class="list-group-item">
<h4 class="list-group-item-heading">Goals & Assists</h4>
<p class="list-group-item-text">Record goals and assists statistics</p>
</a>
<a href="/admin/voting" class="list-group-item">
<h4 class="list-group-item-heading">Voting Results</h4>
<p class="list-group-item-text">View current match voting results</p>
</a>
<a href="/admin/poty" class="list-group-item">
<h4 class="list-group-item-heading">Player of the Year</h4>
<p class="list-group-item-text">View season totals and Player of the Year standings</p>
</a>
<a href="/admin/database-setup" class="list-group-item">
<h4 class="list-group-item-heading">Database Setup</h4>
<p class="list-group-item-text">Configure and initialize the database</p>
</a>
<a href="/admin/database-status" class="list-group-item">
<h4 class="list-group-item-heading">Database Status</h4>
<p class="list-group-item-text">View current database configuration and status</p>
</a>
<a href="/admin/s3-config" class="list-group-item">
<h4 class="list-group-item-heading">S3 Configuration</h4>
<p class="list-group-item-text">Configure AWS S3 storage for logos and assets</p>
</a>
<a href="/admin/s3-status" class="list-group-item">
<h4 class="list-group-item-heading">S3 Status</h4>
<p class="list-group-item-text">View current S3 configuration and connection status</p>
</a>
</div>
</div>
{% else %}
<div class="col-md-6">
<h3>Admin Access</h3>
<div class="alert alert-info">
<h4 class="alert-heading">Authentication Required</h4>
<p>Admin functions require authentication. Please contact the system administrator for access.</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,31 +1,76 @@
<html>
<head>
<title>HKFC Men's C Team - Match Comments</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
</head>
<body>
<h3>Match Comments</h3>
<div class="row">
<div class="col-sm-6">
<img src="{{ hkfcLogo }}" height="100"></img>
<img src="{{ oppoLogo }}" height="100"></img>
</div>
</div>
<div class="row">
<div class="col-sm-8">
{% for comment in comments %}
<div class="panel panel-default">
<div class="panel-body">
{{ comment.comment }}
{% extends "base.html" %}
{% block title %}HKFC Men's C Team - Match Comments{% endblock %}
{% block content %}
<!-- Match Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h2 class="card-title">
<i class="fas fa-comments me-2"></i>Match Comments
</h2>
<!-- Team Logos -->
<div class="d-flex justify-content-center align-items-center mt-3">
<div class="text-center me-4">
<img src="{{ hkfcLogo }}" alt="HKFC Logo" class="img-fluid" style="max-height: 100px;">
<div class="mt-2">
<strong>HKFC</strong>
</div>
</div>
{% endfor %}
<div class="text-center mx-3">
<h4 class="text-primary">VS</h4>
</div>
<div class="text-center ms-4">
<img src="{{ oppoLogo }}" alt="Opponent Logo" class="img-fluid" style="max-height: 100px;">
<div class="mt-2">
<strong>Opponent</strong>
</div>
</div>
</div>
</div>
</div>
<a class="btn btn-primary" href="/" role="button">Home</a>
</body>
</html>
</div>
</div>
<!-- Comments Section -->
<div class="row">
<div class="col-lg-8 mx-auto">
{% if comments %}
{% for comment in comments %}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex align-items-start">
<div class="flex-shrink-0">
<i class="fas fa-comment-dots text-primary fs-4"></i>
</div>
<div class="flex-grow-1 ms-3">
<p class="mb-0">{{ comment.comment }}</p>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="card-body text-center">
<i class="fas fa-comment-slash text-muted fs-1 mb-3"></i>
<h5 class="text-muted">No comments yet</h5>
<p class="text-muted">Comments for this match will appear here once they are added.</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Navigation -->
<div class="row mt-4">
<div class="col-12 text-center">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>Back to Home
</a>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}HKFC Men's C Team - Match Comments{% endblock %}
{% block content %}
<!-- Match Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h2 class="card-title">
<i class="fas fa-comments me-2"></i>Match Comments
</h2>
<!-- Team Logos -->
<div class="d-flex justify-content-center align-items-center mt-3">
<div class="text-center me-4">
<img src="{{ hkfcLogo }}" alt="HKFC Logo" class="img-fluid" style="max-height: 100px;">
<div class="mt-2">
<strong>HKFC</strong>
</div>
</div>
<div class="text-center mx-3">
<h4 class="text-primary">VS</h4>
</div>
<div class="text-center ms-4">
<img src="{{ oppoLogo }}" alt="Opponent Logo" class="img-fluid" style="max-height: 100px;">
<div class="mt-2">
<strong>Opponent</strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Comments Section -->
<div class="row">
<div class="col-lg-8 mx-auto">
{% if comments %}
{% for comment in comments %}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex align-items-start">
<div class="flex-shrink-0">
<i class="fas fa-comment-dots text-primary fs-4"></i>
</div>
<div class="flex-grow-1 ms-3">
<p class="mb-0">{{ comment.comment }}</p>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="card-body text-center">
<i class="fas fa-comment-slash text-muted fs-1 mb-3"></i>
<h5 class="text-muted">No comments yet</h5>
<p class="text-muted">Comments for this match will appear here once they are added.</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Navigation -->
<div class="row mt-4">
<div class="col-12 text-center">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>Back to Home
</a>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,31 @@
<html>
<head>
<title>HKFC Men's C Team - Match Comments</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
</head>
<body>
<h3>Match Comments</h3>
<div class="row">
<div class="col-sm-6">
<img src="{{ hkfcLogo }}" height="100"></img>
<img src="{{ oppoLogo }}" height="100"></img>
</div>
</div>
<div class="row">
<div class="col-sm-8">
{% for comment in comments %}
<div class="panel panel-default">
<div class="panel-body">
{{ comment.comment }}
</div>
</div>
{% endfor %}
</div>
</div>
<a class="btn btn-primary" href="/" role="button">Home</a>
</body>
</html>

View File

@ -1,73 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Match Squad Selection - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h1>Match Squad Selection</h1>
<p class="lead">Select players for the match squad from the available players</p>
<div class="mb-3">
<a href="/admin/players" class="btn btn-outline-primary">Manage Players</a>
<a href="/admin" class="btn btn-secondary">Back to Admin</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if players %}
<form action="/admin/squad/submit" method="post">
<div class="card">
<div class="card-header">
<h5>Available Players</h5>
<small class="text-muted">Select players to add to the match squad</small>
</div>
<div class="card-body">
<div class="row">
{% for player in players %}
<div class="col-md-4 mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="playerNumber" value="{{ player.playernumber }}" id="player{{ player.playernumber }}">
<label class="form-check-label" for="player{{ player.playernumber }}">
<strong>#{{ player.playernumber }}</strong> {{ player.playerforenames }} {{ player.playersurname }}
<br>
<small class="text-muted">{{ player.playernickname }} - {{ player.playerteam }}</small>
</label>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary">Add Selected Players to Squad</button>
<a href="/admin/squad/list" class="btn btn-outline-secondary">View Current Squad</a>
</div>
</div>
</form>
{% else %}
<div class="alert alert-warning">
<h5>No players available</h5>
<p>There are no players in the database. <a href="/admin/players/add">Add some players</a> before selecting a squad.</p>
</div>
{% endif %}
</div>
</div>
{% extends "base.html" %}
{% block title %}Match Squad Selection - HKFC Men's C Team{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h1 class="card-title">
<i class="fas fa-users text-primary me-2"></i>
Match Squad Selection
</h1>
<p class="lead text-muted">Select players for the match squad from the available players</p>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-bolt me-2"></i>Quick Actions
</h5>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2">
<a href="/admin/players" class="btn btn-outline-primary">
<i class="fas fa-user-plus me-2"></i>Manage Players
</a>
<a href="/admin/squad/list" class="btn btn-outline-info">
<i class="fas fa-list me-2"></i>View Current Squad
</a>
<a href="/admin/squad/history" class="btn btn-outline-secondary">
<i class="fas fa-history me-2"></i>Squad History
</a>
<a href="/admin" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>Back to Admin
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Squad Selection Form -->
{% if players %}
<div class="row">
<div class="col-12">
<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>Available Players
</h5>
<small class="opacity-75">Select players to add to the match squad</small>
</div>
<div class="card-body">
<form action="/admin/squad/submit" method="post">
<div class="row">
{% for player in players %}
<div class="col-md-4 col-lg-3 mb-3">
<div class="card h-100">
<div class="card-body p-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="playerNumber" value="{{ player.playernumber }}" id="player{{ player.playernumber }}">
<label class="form-check-label w-100" for="player{{ player.playernumber }}">
<div class="d-flex align-items-center">
<div class="player-number me-3">
<span class="badge bg-primary fs-6">#{{ player.playernumber }}</span>
</div>
<div class="player-info flex-grow-1">
<div class="fw-bold">{{ player.playerforenames }} {{ player.playersurname }}</div>
<small class="text-muted">{{ player.playernickname }}</small>
<br>
<small class="text-info">
<i class="fas fa-users me-1"></i>{{ player.playerteam }}
</small>
</div>
</div>
</label>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="mt-4 text-center">
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-plus-circle me-2"></i>Add Selected Players to Squad
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% else %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle fa-3x mb-3 text-warning"></i>
<h4>No Players Available</h4>
<p class="mb-3">There are no players in the database. You need to add some players before selecting a squad.</p>
<a href="/admin/players/add" class="btn btn-primary">
<i class="fas fa-user-plus me-2"></i>Add Players
</a>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}
<script>
$(document).ready(function() {
// Select all functionality
$('#selectAll').on('change', function() {
$('input[name="playerNumber"]').prop('checked', this.checked);
});
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
// Update select all checkbox when individual checkboxes change
$('input[name="playerNumber"]').on('change', function() {
var totalCheckboxes = $('input[name="playerNumber"]').length;
var checkedCheckboxes = $('input[name="playerNumber"]:checked').length;
$('#selectAll').prop('checked', totalCheckboxes === checkedCheckboxes);
});
});
</script>
{% endblock %}

View File

@ -1,19 +1,49 @@
<html>
<head>
<title>Squad Reset</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-12">
<h3>Match squad has been reset for the next match</h3>
<a class="btn btn-primary" href="/admin/squad" role="button">Add Players to New Squad</a>
<a class="btn btn-danger" href="/" role="button">Home</a>
{% extends "base.html" %}
{% block title %}Squad Reset - HKFC Men's C Team{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h1 class="card-title">
<i class="fas fa-refresh text-warning me-2"></i>
Squad Reset Complete
</h1>
<p class="lead text-muted">The match squad has been reset for the next match</p>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="card-title mb-0">
<i class="fas fa-bolt me-2"></i>Next Steps
</h5>
</div>
<div class="card-body text-center">
<div class="d-flex flex-wrap justify-content-center gap-3">
<a href="/admin/squad" class="btn btn-success btn-lg">
<i class="fas fa-plus-circle me-2"></i>Add Players to New Squad
</a>
<a href="/admin/squad/history" class="btn btn-info btn-lg">
<i class="fas fa-history me-2"></i>View Squad History
</a>
<a href="/admin" class="btn btn-primary btn-lg">
<i class="fas fa-tachometer-alt me-2"></i>Admin Dashboard
</a>
<a href="/" class="btn btn-secondary btn-lg">
<i class="fas fa-home me-2"></i>Home
</a>
</div>
</div>
</div>
</body>
</html>
</div>
</div>
{% endblock %}

View File

@ -1,40 +1,72 @@
<html>
<head>
<title>HKFC Men's C Team - Match Squad Selected</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-12">
<h3>Match Squad</h3>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' }} alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="mb-3">
<a class="btn btn-primary" href="/admin/squad" role="button">Add More Players</a>
<a class="btn btn-info" href="/admin/squad/list" role="button">View Squad List</a>
<a class="btn btn-warning" href="/admin/squad/reset" role="button">Reset Squad</a>
<a class="btn btn-secondary" href="/admin" role="button">Back to Admin</a>
<a class="btn btn-danger" href="/" role="button">Cancel</a>
</div>
{{ table }}
{% extends "base.html" %}
{% block title %}Match Squad - HKFC Men's C Team{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h1 class="card-title">
<i class="fas fa-users text-success me-2"></i>
Current Match Squad
</h1>
<p class="lead text-muted">Manage the current match squad players</p>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-bolt me-2"></i>Squad Management
</h5>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2">
<a href="/admin/squad" class="btn btn-success">
<i class="fas fa-plus-circle me-2"></i>Add More Players
</a>
<a href="/admin/squad/list" class="btn btn-info">
<i class="fas fa-list me-2"></i>Refresh Squad List
</a>
<a href="/admin/squad/history" class="btn btn-outline-secondary">
<i class="fas fa-history me-2"></i>Squad History
</a>
<a href="/admin/squad/reset" class="btn btn-warning" data-confirm="Are you sure you want to reset the squad? This will archive the current squad and clear it for the next match.">
<i class="fas fa-refresh me-2"></i>Reset Squad
</a>
<a href="/admin" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>Back to Admin
</a>
<a href="/" class="btn btn-outline-danger">
<i class="fas fa-home me-2"></i>Home
</a>
</div>
</div>
</div>
</body>
</html>
</div>
</div>
<!-- Squad Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">
<i class="fas fa-table me-2"></i>Squad Players
</h5>
<small class="opacity-75">Current players selected for the match</small>
</div>
<div class="card-body p-0">
{{ table }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,176 +1,229 @@
<html>
<head>
<title>HKFC Men's C Team - MotM and DotD vote admin</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
</head>
<h2>HKFC Men's C Team MotM and DotD online vote admin page</h2>
<div style="margin-bottom: 15px;">
<a href="/admin" class="btn btn-default btn-sm">
<span class="glyphicon glyphicon-arrow-left"></span> Back to Admin Dashboard
</a>
{% extends "base.html" %}
{% block title %}MOTM Management - HKFC Men's C Team MOTM System{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h1 class="card-title">
<i class="fas fa-trophy text-warning me-2"></i>
MOTM Management
</h1>
<p class="lead text-muted">Manage Man of the Match and Dick of the Day settings</p>
<a href="/admin" class="btn btn-outline-primary">
<i class="fas fa-arrow-left me-2"></i>Back to Admin Dashboard
</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-warning alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<body onload="myFunction()">
<dl>
<p>
</div>
<!-- Match Configuration Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-calendar-alt me-2"></i>Next Match Configuration
</h5>
</div>
<div class="card-body">
{{ form.csrf_token }}
<b>HKFC C Next Opponent:</b>
<br/>
<div class="row">
<div class="col-xs-12">
<form class="col-sm-6" method="post" action="/admin/motm">
<!-- Load Next Fixture Button -->
<div class="row">
<div class="col-sm-12">
<div class="alert alert-info" style="margin-bottom: 15px;">
<button type="button" class="btn btn-info btn-sm" id="loadFixtureBtn" onclick="loadNextFixture()">
<span class="glyphicon glyphicon-download-alt"></span> Load Next HKFC C Fixture
<form method="post" action="/admin/motm">
<!-- Load Next Fixture Section -->
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-info">
<div class="d-flex align-items-center justify-content-between flex-wrap">
<div class="d-flex align-items-center">
<button type="button" class="btn btn-info me-3" id="loadFixtureBtn" onclick="loadNextFixture()">
<i class="fas fa-download me-2"></i>Load Next HKFC C Fixture
</button>
<a href="https://hockey.org.hk/MenFixture.asp" target="_blank" class="btn btn-default btn-sm" style="margin-left: 5px;">
<span class="glyphicon glyphicon-new-window"></span> View HK Hockey Fixtures
<a href="https://hockey.org.hk/MenFixture.asp" target="_blank" class="btn btn-outline-info">
<i class="fas fa-external-link-alt me-2"></i>View HK Hockey Fixtures
</a>
<span id="fixtureStatus" style="margin-left: 10px;"></span>
<div id="fixtureInfo" style="margin-top: 10px; display: none;"></div>
</div>
<span id="fixtureStatus" class="ms-3"></span>
</div>
<div id="fixtureInfo" class="mt-3" style="display: none;"></div>
</div>
<div class = "row">
<div class = "col-sm-6">
<div class="input-group">
<span class="input-group-addon" id="basic-addon1">Date:</span>
{{ form.nextMatchDate(class_="form-control", id="nextMatchDate") }}
</div>
</div>
</div>
</br>
<div class = "row">
<div class = "col-sm-9">
<div class="input-group">
<span class="input-group-addon" id="basic-addon1">Opposition</span>
{{ form.nextOppoTeam(class_="form-control", id="nextOppoTeam") }}
</div>
</div>
</div>
<div class = "row">
<div class = "col-sm-6">
<div class="input-group">
<span class="input-group-addon" id="basic-addon1">Current Man of the Match:</span>
{{ form.currMotM(class_="form-control") }}
</div>
</div>
<div class = "col-sm-6">
<div class="input-group">
<span class="input-group-addon" id="basic-addon1">Current Dick of the Day:</span>
{{ form.currDotD(class_="form-control") }}
</div>
</div>
</div>
{% if not form.currMotM.choices or form.currMotM.choices|length == 0 %}
<div class="row">
<div class="col-sm-12">
<div class="alert alert-warning" style="margin-top: 10px;">
<small><strong>Note:</strong> No players available for previous MOTM/DotD. This is normal if you haven't set up a match squad yet. You can still save the match details.</small>
</div>
</div>
</div>
{% endif %}
<p>
{{ form.saveButton(class_="btn btn-success") }}
{{ form.activateButton(class_="btn btn-primary") }}
<a class="btn btn-danger" href="/" role="button">Cancel</a>
</p>
</form>
<div class="col-sm-4">
<img src="{{ nextOppoLogo }}" height="90" id="nextOppoLogo"/>
</div>
</div>
</div>
</p>
</dl>
<!-- Match Details Form -->
<div class="row mb-4">
<div class="col-md-6 mb-3">
<label for="nextMatchDate" class="form-label">
<i class="fas fa-calendar me-2"></i>Match Date
</label>
{{ form.nextMatchDate(class_="form-control", **{"id": "nextMatchDate"}) }}
</div>
<div class="col-md-6 mb-3">
<label for="nextOppoTeam" class="form-label">
<i class="fas fa-users me-2"></i>Opposition Team
</label>
{{ form.nextOppoTeam(class_="form-control", **{"id": "nextOppoTeam"}) }}
</div>
</div>
<!-- Voting Deadline Section -->
<div class="row mb-4">
<div class="col-md-6 mb-3">
<label for="votingDeadline" class="form-label">
<i class="fas fa-clock me-2"></i>Voting Deadline
</label>
{{ form.votingDeadline(class_="form-control", **{"id": "votingDeadline", "type": "datetime-local"}) }}
<small class="form-text text-muted">Set when voting should close (leave empty for no deadline)</small>
</div>
</div>
<!-- Previous Winners Section -->
<div class="row mb-4">
<div class="col-md-6 mb-3">
<label for="currMotM" class="form-label">
<i class="fas fa-trophy me-2 text-warning"></i>Previous Man of the Match
</label>
{{ form.currMotM(class_="form-select") }}
<small class="form-text text-muted">
<i class="fas fa-magic me-1"></i>Auto-selected from previous vote. Choose "No Previous" to override.
</small>
</div>
<div class="col-md-6 mb-3">
<label for="currDotD" class="form-label">
<i class="fas fa-user-times me-2 text-danger"></i>Previous Dick of the Day
</label>
{{ form.currDotD(class_="form-select") }}
<small class="form-text text-muted">
<i class="fas fa-magic me-1"></i>Auto-selected from previous vote. Choose "No Previous" to override.
</small>
</div>
</div>
<!-- Warning Message -->
{% if not form.currMotM.choices or form.currMotM.choices|length == 0 %}
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Note:</strong> No players available for previous MOTM/DotD. This is normal if you haven't set up a match squad yet. You can still save the match details.
</div>
</div>
</div>
{% endif %}
<!-- Action Buttons -->
<div class="row">
<div class="col-12">
<div class="d-flex gap-2 flex-wrap">
{{ form.saveButton(class_="btn btn-success") }}
{{ form.activateButton(class_="btn btn-primary") }}
<a class="btn btn-outline-secondary" href="/admin" role="button">
<i class="fas fa-times me-2"></i>Cancel
</a>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
function loadNextFixture() {
// Show loading status
var statusElement = document.getElementById('fixtureStatus');
var infoElement = document.getElementById('fixtureInfo');
var loadBtn = document.getElementById('loadFixtureBtn');
statusElement.innerHTML = '<span class="text-info">Loading...</span>';
loadBtn.disabled = true;
// Fetch the next fixture from the API
fetch('/admin/api/next-fixture')
.then(response => response.json())
.then(data => {
if (data.success) {
// Update the form fields
document.getElementById('nextMatchDate').value = data.date;
document.getElementById('nextOppoTeam').value = data.opponent;
// Show fixture information
let clubInfo = '';
if (data.opponent_club_info) {
const club = data.opponent_club_info;
const confidence = club.match_result ? club.match_result.confidence : 'unknown';
const matchType = club.match_result ? club.match_result.match_type : 'unknown';
clubInfo = '<br><small class="text-muted">';
clubInfo += 'Club: ' + club.club_name;
if (club.logo_url) {
clubInfo += ' | <a href="' + club.logo_url + '" target="_blank">Logo</a>';
}
clubInfo += ' | Match: ' + matchType + ' (' + confidence + ')';
clubInfo += '</small>';
}
infoElement.innerHTML = '<strong>Next Match:</strong> ' +
data.date_formatted + ' vs ' + data.opponent +
' (' + (data.is_home ? 'Home' : 'Away') + ' - ' + data.venue + ')' +
'<br><small>Division: ' + data.division + ' | Time: ' + data.time + '</small>' +
clubInfo;
infoElement.style.display = 'block';
statusElement.innerHTML = '<span class="text-success">✓ Fixture loaded!</span>';
// Clear status message after 3 seconds
setTimeout(function() {
statusElement.innerHTML = '';
}, 3000);
} else {
statusElement.innerHTML = '<span class="text-danger">✗ ' + data.message + '</span>';
infoElement.style.display = 'none';
<!-- Opposition Logo Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="card-title mb-0">
<i class="fas fa-image me-2"></i>Opposition Team Logo
</h5>
</div>
<div class="card-body text-center">
<img src="{{ nextOppoLogo }}" height="120" id="nextOppoLogo" class="img-fluid rounded" alt="Opposition Team Logo"/>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function loadNextFixture() {
// Show loading status
const statusElement = document.getElementById('fixtureStatus');
const infoElement = document.getElementById('fixtureInfo');
const loadBtn = document.getElementById('loadFixtureBtn');
statusElement.innerHTML = '<span class="text-info"><i class="fas fa-spinner fa-spin me-2"></i>Loading...</span>';
loadBtn.disabled = true;
// Fetch the next fixture from the API
fetch('/admin/api/next-fixture')
.then(response => response.json())
.then(data => {
if (data.success) {
// Update the form fields
document.getElementById('nextMatchDate').value = data.date;
document.getElementById('nextOppoTeam').value = data.opponent;
// Update the opponent logo if provided
if (data.opponent_logo_url) {
const logoElement = document.getElementById('nextOppoLogo');
if (logoElement) {
logoElement.src = data.opponent_logo_url;
}
loadBtn.disabled = false;
})
.catch(error => {
console.error('Error:', error);
statusElement.innerHTML = '<span class="text-danger">✗ Error loading fixture</span>';
infoElement.style.display = 'none';
loadBtn.disabled = false;
});
}
// Auto-load fixture on page load
function myFunction() {
// Optional: Auto-load the next fixture when the page loads
// Uncomment the next line if you want this behavior
// loadNextFixture();
}
</script>
</body>
</html>
}
// Show fixture information
let clubInfo = '';
if (data.opponent_club_info) {
const club = data.opponent_club_info;
const confidence = club.match_result ? club.match_result.confidence : 'unknown';
const matchType = club.match_result ? club.match_result.match_type : 'unknown';
clubInfo = '<br><small class="text-muted">';
clubInfo += 'Club: ' + club.club_name;
if (club.logo_url) {
clubInfo += ' | <a href="' + club.logo_url + '" target="_blank">Logo</a>';
}
clubInfo += ' | Match: ' + matchType + ' (' + confidence + ')';
clubInfo += '</small>';
}
infoElement.innerHTML = '<strong>Next Match:</strong> ' +
data.date_formatted + ' vs ' + data.opponent +
' (' + (data.is_home ? 'Home' : 'Away') + ' - ' + data.venue + ')' +
'<br><small>Division: ' + data.division + ' | Time: ' + data.time + '</small>' +
clubInfo;
infoElement.style.display = 'block';
statusElement.innerHTML = '<span class="text-success"><i class="fas fa-check me-2"></i>Fixture loaded!</span>';
// Clear status message after 3 seconds
setTimeout(function() {
statusElement.innerHTML = '';
}, 3000);
} else {
statusElement.innerHTML = '<span class="text-danger"><i class="fas fa-times me-2"></i>' + data.message + '</span>';
infoElement.style.display = 'none';
}
loadBtn.disabled = false;
})
.catch(error => {
console.error('Error:', error);
statusElement.innerHTML = '<span class="text-danger"><i class="fas fa-exclamation-triangle me-2"></i>Error loading fixture</span>';
infoElement.style.display = 'none';
loadBtn.disabled = false;
});
}
// Auto-load fixture on page load (optional)
document.addEventListener('DOMContentLoaded', function() {
// Uncomment the next line if you want to auto-load the fixture when the page loads
// loadNextFixture();
});
</script>
{% endblock %}

View File

@ -1,83 +1,288 @@
<html>
<head>
<title>HKFC Men's C Team - MotM and DotD online vote</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
</head>
<h3>HKFC Men's C Team MotM and DotD online vote</h3>
<h5>{{ formatDate }}</h5>
<h4><img src="{{ hkfcLogo }}" height="150"></img> <b> </b> <img src="{{ oppoLogo }}" height="140"></img></h4>
<body>
<p><b>Randomly selected comment from the match:</b>
<br/>
{% for item in comment %}
<i>{{ item.comment }}</i>
{% endfor %}
</p>
<dl>
{{ form.csrf_token }}
<div class="row">
<div class="col-xs-12">
<form class="col-sm-6" method="post" action="/motm/vote-thanks" id="motmForm" accept-charset="utf-8">
<input type="hidden" id="matchNumber" name="matchNumber" value="{{ matchNumber }}">
<input type="hidden" id="oppo" name="oppo" value="{{ oppo }}">
<div class = "row">
<div class = "col-sm-6">
<div class="input-group">
<span class="input-group-addon" id="basic-addon1">Man of the Match</span>
<select class="form-control" name="motmVote" required>
{% for item in data %}
{% if item.playernickname != "" %}
<option value={{ item.playernumber }}>{{ item.playernickname }}</option>
{% else %}
<option value={{ item.playernumber }}>{{ item.playersurname }}, {{ item.playerforenames }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
<div class = "col-sm-6">
<div class="input-group">
<span class="input-group-addon" id="basic-addon1">Dick of the Day</span>
<select class="form-control" name="dotdVote" required>
{% for item in data %}
{% if item.playernickname != "" %}
<option value={{ item.playernumber }}>{{ item.playernickname }}</option>
{% else %}
<option value={{ item.playernumber }}>{{ item.playersurname }}, {{ item.playerforenames }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
{% extends "base.html" %}
{% block title %}HKFC Men's C Team - MotM and DotD Vote{% endblock %}
{% block content %}
<!-- Match Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h2 class="card-title">
<i class="fas fa-trophy text-warning me-2"></i>
HKFC Men's C Team MotM and DotD Vote
</h2>
<h5 class="text-muted">{{ formatDate }}</h5>
<!-- Team Logos -->
<div class="d-flex justify-content-center align-items-center mt-3">
<div class="text-center me-4">
<img src="{{ hkfcLogo }}" alt="HKFC Logo" class="img-fluid" style="max-height: 120px;">
<div class="mt-2">
<strong>HKFC</strong>
</div>
<div class = "row">
<div class = "col-sm-6">
<div class = "input-group">
<span class = "input-group-addon" id = "basic-addon1">Match comments</span>
<textarea rows = "4" cols = "80" name = "motmComment" form = "motmForm">Optional comments added here</textarea>
</div>
</div>
</div>
<div class="text-center mx-3">
<h3 class="text-primary">VS</h3>
</div>
<div class="text-center ms-4">
<img src="{{ oppoLogo }}" alt="Opponent Logo" class="img-fluid" style="max-height: 120px;">
<div class="mt-2">
<strong>{{ oppo }}</strong>
</div>
<div class = "row">
<h3>Rogues Gallery</h3>
<div class = "col-sm-4">
<h4>Current Man of the Match</h4>
<img src="{{ motmURL }}" height="200"></img>
</div>
<div class = "col-sm-4">
<h4>Current Dick of the Day</h4>
<img src="{{ dotdURL }}" height="200"></img>
</div>
</div>
</div>
<!-- Voting Deadline Countdown -->
{% if votingDeadline %}
<div class="mt-4">
<div id="countdown-container" class="alert alert-info">
<h5 class="mb-2"><i class="fas fa-clock me-2"></i>Voting Closes In:</h5>
<div id="countdown-timer" class="display-6 fw-bold"></div>
</div>
<div id="voting-closed" class="alert alert-danger" style="display: none;">
<h5 class="mb-0"><i class="fas fa-lock me-2"></i>Voting has closed</h5>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Random Comment -->
{% if comment and comment != "No comments added yet" %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="card-title mb-0">
<i class="fas fa-comment-dots me-2"></i>Random Match Comment
</h5>
</div>
<div class="card-body">
<blockquote class="blockquote text-center">
<p class="mb-0">
<i class="fas fa-quote-left text-muted me-2"></i>
<em>{{ comment }}</em>
<i class="fas fa-quote-right text-muted ms-2"></i>
</p>
</blockquote>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Voting Forms -->
<div class="row">
<div class="col-lg-8 mx-auto mb-4">
<!-- Vote for MOTM and DotD -->
<div class="card h-100">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">
<i class="fas fa-vote-yea me-2"></i>Vote for MOTM and DotD
</h5>
</div>
<div class="card-body">
<form method="post" action="/motm/vote-thanks" id="motmForm">
{{ form.csrf_token }}
<input type="hidden" id="matchNumber" name="matchNumber" value="{{ matchNumber }}">
<input type="hidden" id="oppo" name="oppo" value="{{ oppo }}">
<div class="mb-3">
<label for="motmSelect" class="form-label">Select Player:</label>
<select class="form-select" id="motmSelect" name="motmVote" required>
<option value="">Choose a player...</option>
{% for player in data %}
<option value="{{ player.playernumber }}">{{ player.playerforenames }} {{ player.playersurname }}
{% if player.playernickname %}"{{ player.playernickname }}"{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="dotdSelect" class="form-label">Dick of the Day:</label>
<select class="form-select" id="dotdSelect" name="dotdVote" required>
<option value="">Choose a player...</option>
{% for player in data %}
<option value="{{ player.playernumber }}">{{ player.playerforenames }} {{ player.playersurname }}
{% if player.playernickname %}"{{ player.playernickname }}"{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="motmComment" class="form-label">Optional Comment:</label>
<textarea class="form-control" id="motmComment" name="motmComment" rows="3" placeholder="Share your thoughts about the match..."></textarea>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-vote-yea me-2"></i>Submit Votes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Current Results (if available) -->
{% if currMotM or currDotD %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-secondary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-chart-bar me-2"></i>Current Results
</h5>
</div>
<div class="card-body">
<div class="row">
{% if currMotM %}
<div class="col-md-6">
<div class="alert alert-success">
<h6 class="alert-heading">
<i class="fas fa-trophy me-2"></i>Current Man of the Match
</h6>
<p class="mb-0">{{ currMotM }}</p>
</div>
<button type="submit" class="btn btn-success">Submit</button>
<a class="btn btn-danger" href="/" role="button">Cancel</a>
</form>
</div>
{% endif %}
{% if currDotD %}
<div class="col-md-6">
<div class="alert alert-warning">
<h6 class="alert-heading">
<i class="fas fa-award me-2"></i>Current Dick of the Day
</h6>
<p class="mb-0">{{ currDotD }}</p>
</div>
</div>
{% endif %}
</div>
</div>
</dl>
</body>
</html>
</div>
</div>
</div>
{% endif %}
<!-- Instructions -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<h6><i class="fas fa-info-circle me-2"></i>Voting Instructions</h6>
<ul class="list-unstyled">
<li><i class="fas fa-check text-success me-2"></i>Select one player for Man of the Match</li>
<li><i class="fas fa-check text-success me-2"></i>Select one player for Dick of the Day</li>
<li><i class="fas fa-check text-success me-2"></i>You can vote for the same player for both categories</li>
<li><i class="fas fa-check text-success me-2"></i>Votes are submitted independently</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
$(document).ready(function() {
{% if votingDeadline %}
// Countdown timer functionality
const votingDeadline = new Date('{{ votingDeadline }}');
const countdownTimer = $('#countdown-timer');
const countdownContainer = $('#countdown-container');
const votingClosedDiv = $('#voting-closed');
const votingForm = $('#motmForm');
const submitButton = votingForm.find('button[type="submit"]');
function updateCountdown() {
const now = new Date();
const timeRemaining = votingDeadline - now;
if (timeRemaining <= 0) {
// Voting has closed
countdownContainer.hide();
votingClosedDiv.show();
votingForm.find('select, textarea, button').prop('disabled', true);
submitButton.html('<i class="fas fa-lock me-2"></i>Voting Closed');
return;
}
// Calculate time components
const days = Math.floor(timeRemaining / (1000 * 60 * 60 * 24));
const hours = Math.floor((timeRemaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((timeRemaining % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((timeRemaining % (1000 * 60)) / 1000);
// Format and display countdown
let countdownText = '';
if (days > 0) {
countdownText = `${days}d ${hours}h ${minutes}m ${seconds}s`;
} else if (hours > 0) {
countdownText = `${hours}h ${minutes}m ${seconds}s`;
} else if (minutes > 0) {
countdownText = `${minutes}m ${seconds}s`;
} else {
countdownText = `${seconds}s`;
}
countdownTimer.text(countdownText);
// Update alert color based on time remaining
if (timeRemaining < 60000) { // Less than 1 minute
countdownContainer.removeClass('alert-info alert-warning').addClass('alert-danger');
} else if (timeRemaining < 300000) { // Less than 5 minutes
countdownContainer.removeClass('alert-info alert-danger').addClass('alert-warning');
}
}
// Update countdown every second
updateCountdown();
setInterval(updateCountdown, 1000);
{% endif %}
// Form validation
$('#motmForm').on('submit', function(e) {
const motmSelect = $('#motmSelect');
const dotdSelect = $('#dotdSelect');
{% if votingDeadline %}
// Check if voting is still open
const now = new Date();
const votingDeadline = new Date('{{ votingDeadline }}');
if (now >= votingDeadline) {
e.preventDefault();
alert('Sorry, voting has closed.');
return false;
}
{% endif %}
if (!motmSelect.val()) {
e.preventDefault();
alert('Please select a player for Man of the Match.');
motmSelect.focus();
return false;
}
if (!dotdSelect.val()) {
e.preventDefault();
alert('Please select a player for Dick of the Day.');
dotdSelect.focus();
return false;
}
});
// Add loading state to button
$('#motmForm').on('submit', function() {
const button = $(this).find('button[type="submit"]');
const originalText = button.html();
button.html('<span class="loading-spinner"></span> Submitting...').prop('disabled', true);
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,210 @@
{% extends "base.html" %}
{% block title %}HKFC Men's C Team - MotM and DotD Vote{% endblock %}
{% block content %}
<!-- Match Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h2 class="card-title">
<i class="fas fa-trophy text-warning me-2"></i>
HKFC Men's C Team MotM and DotD Vote
</h2>
<h5 class="text-muted">{{ formatDate }}</h5>
<!-- Team Logos -->
<div class="d-flex justify-content-center align-items-center mt-3">
<div class="text-center me-4">
<img src="{{ hkfcLogo }}" alt="HKFC Logo" class="img-fluid" style="max-height: 120px;">
<div class="mt-2">
<strong>HKFC</strong>
</div>
</div>
<div class="text-center mx-3">
<h3 class="text-primary">VS</h3>
</div>
<div class="text-center ms-4">
<img src="{{ oppoLogo }}" alt="Opponent Logo" class="img-fluid" style="max-height: 120px;">
<div class="mt-2">
<strong>{{ oppo }}</strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Random Comment -->
{% if comment and comment != "No comments added yet" %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="card-title mb-0">
<i class="fas fa-comment-dots me-2"></i>Random Match Comment
</h5>
</div>
<div class="card-body">
<blockquote class="blockquote text-center">
<p class="mb-0">
<i class="fas fa-quote-left text-muted me-2"></i>
<em>{{ comment }}</em>
<i class="fas fa-quote-right text-muted ms-2"></i>
</p>
</blockquote>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Voting Forms -->
<div class="row">
<div class="col-lg-8 mx-auto mb-4">
<!-- Vote for MOTM and DotD -->
<div class="card h-100">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">
<i class="fas fa-vote-yea me-2"></i>Vote for MOTM and DotD
</h5>
</div>
<div class="card-body">
<form method="post" action="/motm/vote-thanks" id="motmForm">
{{ form.csrf_token }}
<input type="hidden" id="matchNumber" name="matchNumber" value="{{ matchNumber }}">
<input type="hidden" id="oppo" name="oppo" value="{{ oppo }}">
<div class="mb-3">
<label for="motmSelect" class="form-label">Select Player:</label>
<select class="form-select" id="motmSelect" name="motmVote" required>
<option value="">Choose a player...</option>
{% for player in data %}
<option value="{{ player.playernumber }}">{{ player.playerforenames }} {{ player.playersurname }}
{% if player.playernickname %}"{{ player.playernickname }}"{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="dotdSelect" class="form-label">Dick of the Day:</label>
<select class="form-select" id="dotdSelect" name="dotdVote" required>
<option value="">Choose a player...</option>
{% for player in data %}
<option value="{{ player.playernumber }}">{{ player.playerforenames }} {{ player.playersurname }}
{% if player.playernickname %}"{{ player.playernickname }}"{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="motmComment" class="form-label">Optional Comment:</label>
<textarea class="form-control" id="motmComment" name="motmComment" rows="3" placeholder="Share your thoughts about the match..."></textarea>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-vote-yea me-2"></i>Submit Votes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Current Results (if available) -->
{% if currMotM or currDotD %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-secondary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-chart-bar me-2"></i>Current Results
</h5>
</div>
<div class="card-body">
<div class="row">
{% if currMotM %}
<div class="col-md-6">
<div class="alert alert-success">
<h6 class="alert-heading">
<i class="fas fa-trophy me-2"></i>Current Man of the Match
</h6>
<p class="mb-0">{{ currMotM }}</p>
</div>
</div>
{% endif %}
{% if currDotD %}
<div class="col-md-6">
<div class="alert alert-warning">
<h6 class="alert-heading">
<i class="fas fa-award me-2"></i>Current Dick of the Day
</h6>
<p class="mb-0">{{ currDotD }}</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Instructions -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<h6><i class="fas fa-info-circle me-2"></i>Voting Instructions</h6>
<ul class="list-unstyled">
<li><i class="fas fa-check text-success me-2"></i>Select one player for Man of the Match</li>
<li><i class="fas fa-check text-success me-2"></i>Select one player for Dick of the Day</li>
<li><i class="fas fa-check text-success me-2"></i>You can vote for the same player for both categories</li>
<li><i class="fas fa-check text-success me-2"></i>Votes are submitted independently</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
$(document).ready(function() {
// Form validation
$('#motmForm').on('submit', function(e) {
const motmSelect = $('#motmSelect');
const dotdSelect = $('#dotdSelect');
if (!motmSelect.val()) {
e.preventDefault();
alert('Please select a player for Man of the Match.');
motmSelect.focus();
return false;
}
if (!dotdSelect.val()) {
e.preventDefault();
alert('Please select a player for Dick of the Day.');
dotdSelect.focus();
return false;
}
});
// Add loading state to button
$('#motmForm').on('submit', function() {
const button = $(this).find('button[type="submit"]');
const originalText = button.html();
button.html('<span class="loading-spinner"></span> Submitting...').prop('disabled', true);
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,83 @@
<html>
<head>
<title>HKFC Men's C Team - MotM and DotD online vote</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
</head>
<h3>HKFC Men's C Team MotM and DotD online vote</h3>
<h5>{{ formatDate }}</h5>
<h4><img src="{{ hkfcLogo }}" height="150"></img> <b> </b> <img src="{{ oppoLogo }}" height="140"></img></h4>
<body>
<p><b>Randomly selected comment from the match:</b>
<br/>
{% for item in comment %}
<i>{{ item.comment }}</i>
{% endfor %}
</p>
<dl>
{{ form.csrf_token }}
<div class="row">
<div class="col-xs-12">
<form class="col-sm-6" method="post" action="/motm/vote-thanks" id="motmForm" accept-charset="utf-8">
<input type="hidden" id="matchNumber" name="matchNumber" value="{{ matchNumber }}">
<input type="hidden" id="oppo" name="oppo" value="{{ oppo }}">
<div class = "row">
<div class = "col-sm-6">
<div class="input-group">
<span class="input-group-addon" id="basic-addon1">Man of the Match</span>
<select class="form-control" name="motmVote" required>
{% for item in data %}
{% if item.playernickname != "" %}
<option value={{ item.playernumber }}>{{ item.playernickname }}</option>
{% else %}
<option value={{ item.playernumber }}>{{ item.playersurname }}, {{ item.playerforenames }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
<div class = "col-sm-6">
<div class="input-group">
<span class="input-group-addon" id="basic-addon1">Dick of the Day</span>
<select class="form-control" name="dotdVote" required>
{% for item in data %}
{% if item.playernickname != "" %}
<option value={{ item.playernumber }}>{{ item.playernickname }}</option>
{% else %}
<option value={{ item.playernumber }}>{{ item.playersurname }}, {{ item.playerforenames }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
</div>
<div class = "row">
<div class = "col-sm-6">
<div class = "input-group">
<span class = "input-group-addon" id = "basic-addon1">Match comments</span>
<textarea rows = "4" cols = "80" name = "motmComment" form = "motmForm">Optional comments added here</textarea>
</div>
</div>
</div>
<div class = "row">
<h3>Rogues Gallery</h3>
<div class = "col-sm-4">
<h4>Current Man of the Match</h4>
<img src="{{ motmURL }}" height="200"></img>
</div>
<div class = "col-sm-4">
<h4>Current Dick of the Day</h4>
<img src="{{ dotdURL }}" height="200"></img>
</div>
</div>
<button type="submit" class="btn btn-success">Submit</button>
<a class="btn btn-danger" href="/" role="button">Cancel</a>
</form>
</div>
</div>
</dl>
</body>
</html>

View File

@ -1,56 +1,319 @@
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
<script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div id="chart_div" style="width: 800px; height: 1000px;"></div>
{% extends "base.html" %}
<script type="text/javascript">
{% block title %}Player of the Year - HKFC MOTM System{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h1 class="card-title">
<i class="fas fa-trophy text-warning me-2"></i>Player of the Year
</h1>
<p class="lead text-muted">MOTM and DotD vote standings</p>
</div>
</div>
</div>
</div>
google.charts.load('current', {
packages: ['corechart']
}).then(function () {
// create chart
var container = $('#chart_div').get(0);
var chart = new google.visualization.ColumnChart(container);
var options = {
legend: {
position: 'top'
}
};
<!-- Chart Container -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-chart-bar me-2"></i>Current Standings
</h5>
</div>
<div class="card-body">
<div id="poty-chart-container">
<!-- Chart will be loaded here -->
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading player data...</p>
</div>
</div>
</div>
</div>
</div>
</div>
// create data table
var data = new google.visualization.DataTable();
data.addColumn('string', 'Player');
data.addColumn('number', 'MotM Total');
data.addColumn('number', 'DotD Total');
<!-- Legend -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<h6 class="card-title">
<i class="fas fa-info-circle me-2"></i>Legend
</h6>
<div class="row">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<div class="motm-bar me-3" style="width: 20px; height: 20px; background: linear-gradient(135deg, #28a745, #20c997); border-radius: 4px;"></div>
<span><strong>MOTM Votes</strong> - Man of the Match</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<div class="dotd-bar me-3" style="width: 20px; height: 20px; background: linear-gradient(135deg, #dc3545, #fd7e14); border-radius: 4px;"></div>
<span><strong>DotD Votes</strong> - Dick of the Day</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
// get data
$.ajax({
url: '/api/poty-results',
dataType: 'json'
}).done(function (jsonData) {
loadData(jsonData);
}).fail(function (jqXHR, textStatus, errorThrown) {
var jsonData = [{"motmTotal": 5, "playerName": "ERVINE Jonathan Desmond", "dotdTotal": 2}, {"motmTotal": 3, "playerName": "MCDONAGH Jerome Michael", "dotdTotal": 1}];
loadData(jsonData);
});
<!-- Navigation -->
<div class="row mt-4">
<div class="col-12 text-center">
<a href="/admin" class="btn btn-primary">
<i class="fas fa-arrow-left me-2"></i>Back to Admin
</a>
</div>
</div>
// load json data
function loadData(jsonData) {
$.each(jsonData, function(index, row) {
data.addRow([
row.playerName,
row.motmTotal,
row.dotdTotal
]);
});
drawChart();
}
<style>
.poty-player-card {
background: #fff;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
// draw chart
$(window).resize(drawChart);
function drawChart() {
chart.draw(data, options);
}
.poty-player-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.poty-player-name {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.5rem;
}
.poty-stats {
display: flex;
gap: 1rem;
align-items: center;
}
.poty-stat {
display: flex;
align-items: center;
gap: 0.5rem;
}
.poty-stat-value {
font-size: 1.5rem;
font-weight: 700;
min-width: 2rem;
text-align: center;
}
.poty-stat-label {
font-size: 0.9rem;
color: #6c757d;
font-weight: 500;
}
.poty-bar-container {
flex: 1;
margin-left: 1rem;
}
.poty-bar {
height: 8px;
border-radius: 4px;
margin-bottom: 4px;
position: relative;
overflow: hidden;
background-color: #e9ecef; /* Light gray background for empty bars */
}
.poty-bar-motm {
background-color: rgba(40, 167, 69, 0.1); /* Very light green background */
}
.poty-bar-dotd {
background-color: rgba(220, 53, 69, 0.1); /* Very light red background */
}
.poty-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.8s ease-in-out;
}
.poty-bar-motm .poty-bar-fill {
background: linear-gradient(90deg, #28a745, #20c997);
}
.poty-bar-dotd .poty-bar-fill {
background: linear-gradient(90deg, #dc3545, #fd7e14);
}
.poty-no-data {
text-align: center;
padding: 3rem;
color: #6c757d;
}
.poty-no-data i {
font-size: 3rem;
margin-bottom: 1rem;
color: #dee2e6;
}
.poty-ranking {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: linear-gradient(135deg, #007bff, #0056b3);
color: white;
border-radius: 50%;
font-weight: 700;
font-size: 0.9rem;
margin-right: 1rem;
}
.poty-ranking.gold {
background: linear-gradient(135deg, #ffd700, #ffb347);
}
.poty-ranking.silver {
background: linear-gradient(135deg, #c0c0c0, #a8a8a8);
}
.poty-ranking.bronze {
background: linear-gradient(135deg, #cd7f32, #b8860b);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
loadPOTYData();
});
function loadPOTYData() {
fetch('/api/poty-results')
.then(response => {
console.log('Response status:', response.status);
return response.json();
})
.then(data => {
console.log('Raw API data:', data);
renderPOTYChart(data);
})
.catch(error => {
console.error('Error loading POTY data:', error);
renderNoData();
});
}
function renderPOTYChart(data) {
const container = document.getElementById('poty-chart-container');
console.log('POTY Data received:', data);
if (!data || data.length === 0) {
renderNoData();
return;
}
// Sort by MOTM votes (descending), then by DotD votes (ascending for better ranking)
data.sort((a, b) => {
if (b.motmTotal !== a.motmTotal) {
return b.motmTotal - a.motmTotal;
}
return a.dotdTotal - b.dotdTotal;
});
// Find max values for independent scaling - each bar type scales to its own maximum
const maxMotm = Math.max(...data.map(p => p.motmTotal || 0));
const maxDotd = Math.max(...data.map(p => p.dotdTotal || 0));
let html = '';
data.forEach((player, index) => {
console.log('Processing player:', player);
const ranking = index + 1;
const rankingClass = ranking === 1 ? 'gold' : ranking === 2 ? 'silver' : ranking === 3 ? 'bronze' : '';
const motmVotes = player.motmTotal || 0;
const dotdVotes = player.dotdTotal || 0;
html += '<div class="poty-player-card">';
html += '<div class="d-flex align-items-center">';
html += '<div class="poty-ranking ' + rankingClass + '">' + ranking + '</div>';
html += '<div class="flex-grow-1">';
html += '<div class="poty-player-name">' + (player.playerName || 'Unknown Player') + '</div>';
html += '<div class="poty-stats">';
html += '<div class="poty-stat">';
html += '<div class="poty-stat-value text-success">' + motmVotes + '</div>';
html += '<div class="poty-stat-label">MOTM</div>';
html += '</div>';
html += '<div class="poty-stat">';
html += '<div class="poty-stat-value text-danger">' + dotdVotes + '</div>';
html += '<div class="poty-stat-label">DotD</div>';
html += '</div>';
html += '<div class="poty-bar-container">';
html += '<div class="poty-bar poty-bar-motm">';
html += '<div class="poty-bar-fill" style="width: ' + (maxMotm > 0 ? (motmVotes / maxMotm * 100) : 0) + '%"></div>';
html += '</div>';
html += '<div class="poty-bar poty-bar-dotd">';
html += '<div class="poty-bar-fill" style="width: ' + (maxDotd > 0 ? (dotdVotes / maxDotd * 100) : 0) + '%"></div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
});
container.innerHTML = html;
// Animate bars after a short delay
setTimeout(() => {
const bars = container.querySelectorAll('.poty-bar-fill');
bars.forEach(bar => {
const width = bar.style.width;
bar.style.width = '0%';
setTimeout(() => {
bar.style.width = width;
}, 100);
});
}, 200);
}
function renderNoData() {
const container = document.getElementById('poty-chart-container');
container.innerHTML = '<div class="poty-no-data">' +
'<i class="fas fa-chart-line"></i>' +
'<h4>No Vote Data Available</h4>' +
'<p>No players have received MOTM or DotD votes yet.</p>' +
'<p class="text-muted">Votes will appear here once players start receiving votes.</p>' +
'</div>';
}
// Test function to verify basic functionality
function testPOTYChart() {
const testData = [
{playerName: 'Test Player 1', motmTotal: 5, dotdTotal: 1},
{playerName: 'Test Player 2', motmTotal: 3, dotdTotal: 2}
];
console.log('Testing with sample data:', testData);
renderPOTYChart(testData);
}
</script>
{% endblock %}

View File

@ -1,33 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>S3 Configuration - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.config-section {
background-color: #f8f9fa;
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 1rem;
}
.help-text {
font-size: 0.875rem;
color: #6c757d;
}
</style>
</head>
<body>
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h1>S3 Configuration</h1>
<p class="lead">Configure AWS S3 storage for logos and assets</p>
<div class="mb-3">
<a href="/admin" class="btn btn-secondary">Back to Admin</a>
</div>
{% extends "base.html" %}
{% block title %}S3 Configuration - HKFC Men's C Team{% endblock %}
{% block extra_head %}
<style>
.config-section {
background-color: #f8f9fa;
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 1rem;
}
.help-text {
font-size: 0.875rem;
color: #6c757d;
}
</style>
{% endblock %}
{% block content %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
@ -72,6 +62,14 @@
<!-- Credentials Section -->
<div class="config-section">
<h4>Access Credentials</h4>
<div class="alert alert-warning" role="alert">
<strong>🔒 Security Notice:</strong> For security reasons, credentials are <strong>NOT</strong> saved to the database.
<ul class="mb-0 mt-2">
<li><strong>Local Development:</strong> Credentials entered here are saved to the <code>s3_config.json</code> file.</li>
<li><strong>Production/Kubernetes:</strong> Credentials MUST be provided via environment variables (<code>S3_ACCESS_KEY_ID</code> and <code>S3_SECRET_ACCESS_KEY</code>), typically from Kubernetes secrets.</li>
<li>Only bucket settings and configuration options are saved to the database.</li>
</ul>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
@ -251,5 +249,4 @@
}
});
</script>
</body>
</html>
{% endblock %}

View File

@ -1,22 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>S3 Status - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h1>S3 Storage Status</h1>
<p class="lead">Current S3 configuration and connection status</p>
<div class="mb-3">
<a href="/admin/s3-config" class="btn btn-primary">Configure S3</a>
<a href="/admin" class="btn btn-secondary">Back to Admin</a>
</div>
{% extends "base.html" %}
{% block title %}S3 Status - HKFC Men's C Team{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1><i class="fas fa-cloud text-info me-2"></i>S3 Storage Status</h1>
<p class="lead text-muted">Current S3 configuration and connection status</p>
</div>
<div>
<a href="/admin/s3-config" class="btn btn-primary me-2">
<i class="fas fa-cog me-1"></i>Configure S3
</a>
<a href="/admin" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to Admin
</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
@ -149,6 +153,4 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
{% endblock %}

View File

@ -0,0 +1,150 @@
{% extends "base.html" %}
{% block title %}Squad History{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h2><i class="fas fa-history me-2"></i>Squad History</h2>
<a href="/admin" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>Back to Admin
</a>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Historical Squads Summary -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-list me-2"></i>Historical Squads Summary
</h5>
</div>
<div class="card-body">
{% if history %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Fixture Number</th>
<th>Match Date</th>
<th>Players</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for fixture in history %}
<tr>
<td>{{ fixture.fixture_number }}</td>
<td>{{ fixture.match_date }}</td>
<td>{{ fixture.player_count }} players</td>
<td>
<button class="btn btn-sm btn-info" onclick="showSquadDetails('{{ fixture.fixture_number }}')">
<i class="fas fa-eye me-1"></i>View Details
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>No historical squad data available yet.
Squad data will be saved here when you reset the squad for a new match.
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Detailed Squad View -->
{% if details %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-secondary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-users me-2"></i>Detailed Squad Records
</h5>
</div>
<div class="card-body">
{% set current_fixture = '' %}
{% for player in details %}
{% if player.fixture_number != current_fixture %}
{% if current_fixture != '' %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% set current_fixture = player.fixture_number %}
<div class="squad-detail mb-4" id="squad-{{ player.fixture_number }}">
<h6 class="border-bottom pb-2">
<i class="fas fa-calendar me-2"></i>Fixture {{ player.fixture_number }} - {{ player.match_date }}
<small class="text-muted">(Archived: {{ player.archived_at }})</small>
</h6>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Number</th>
<th>Nickname</th>
<th>Full Name</th>
</tr>
</thead>
<tbody>
{% endif %}
<tr>
<td>{{ player.player_number }}</td>
<td><strong>{{ player.player_nickname }}</strong></td>
<td>{{ player.player_forenames }} {{ player.player_surname }}</td>
</tr>
{% endfor %}
{% if current_fixture != '' %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}
<script>
function showSquadDetails(fixtureNumber) {
const element = document.getElementById('squad-' + fixtureNumber);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
element.classList.add('highlight-squad');
setTimeout(() => element.classList.remove('highlight-squad'), 2000);
}
}
</script>
<style>
.highlight-squad {
background-color: #fff3cd;
transition: background-color 0.3s ease;
}
</style>
{% endblock %}

View File

@ -1,56 +1,356 @@
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
<script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div id="chart_div" style="width: 800px; height: 1000px;"></div>
{% extends "base.html" %}
<script type="text/javascript">
{% block title %}Vote Results - HKFC MOTM System{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<h1 class="card-title">
<i class="fas fa-chart-bar text-primary me-2"></i>Current Match Vote Results
</h1>
<p class="lead text-muted">MOTM and DotD votes for match on {{ _matchDate[:4] }}-{{ _matchDate[4:6] }}-{{ _matchDate[6:8] }}</p>
</div>
</div>
</div>
</div>
google.charts.load('current', {
packages: ['corechart']
}).then(function () {
// create chart
var container = $('#chart_div').get(0);
var chart = new google.visualization.ColumnChart(container);
var options = {
legend: {
position: 'top'
}
};
<!-- Chart Container -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">
<i class="fas fa-poll me-2"></i>Vote Counts
</h5>
</div>
<div class="card-body">
<div id="vote-chart-container">
<!-- Chart will be loaded here -->
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading vote data...</p>
</div>
</div>
</div>
</div>
</div>
</div>
// create data table
var data = new google.visualization.DataTable();
data.addColumn('string', 'Player');
data.addColumn('number', 'MotM');
data.addColumn('number', 'DotD');
<!-- Legend -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<h6 class="card-title">
<i class="fas fa-info-circle me-2"></i>Legend
</h6>
<div class="row">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<div class="motm-bar me-3" style="width: 20px; height: 20px; background: linear-gradient(135deg, #28a745, #20c997); border-radius: 4px;"></div>
<span><strong>MOTM Votes</strong> - Man of the Match</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<div class="dotd-bar me-3" style="width: 20px; height: 20px; background: linear-gradient(135deg, #dc3545, #fd7e14); border-radius: 4px;"></div>
<span><strong>DotD Votes</strong> - Dick of the Day</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
// get data
$.ajax({
url: '/api/vote-results',
dataType: 'json'
}).done(function (jsonData) {
loadData(jsonData);
}).fail(function (jqXHR, textStatus, errorThrown) {
var jsonData = [{"motm_{{ _matchDate }}": 1, "playerName": "ERVINE Jonathan Desmond", "dotd_{{ _matchDate }}": 0}, {"motm_{{ _matchDate }}": 0, "playerName": "MCDONAGH Jerome Michael", "dotd_{{ _matchDate }}": 1}];
loadData(jsonData);
});
<!-- Navigation -->
<div class="row mt-4">
<div class="col-12 text-center">
<a href="/admin" class="btn btn-primary">
<i class="fas fa-arrow-left me-2"></i>Back to Admin
</a>
</div>
</div>
// load json data
function loadData(jsonData) {
$.each(jsonData, function(index, row) {
data.addRow([
row.playerName,
row.motm_{{ _matchDate }},
row.dotd_{{ _matchDate }}
]);
});
drawChart();
}
<style>
.vote-player-card {
background: #fff;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
// draw chart
$(window).resize(drawChart);
function drawChart() {
chart.draw(data, options);
}
.vote-player-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.vote-player-name {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.5rem;
}
.vote-stats {
display: flex;
gap: 1rem;
align-items: center;
}
.vote-stat {
display: flex;
align-items: center;
gap: 0.5rem;
}
.vote-stat-value {
font-size: 1.5rem;
font-weight: 700;
min-width: 2rem;
text-align: center;
}
.vote-stat-label {
font-size: 0.9rem;
color: #6c757d;
font-weight: 500;
}
.vote-bar-container {
flex: 1;
margin-left: 1rem;
}
.vote-bar {
height: 8px;
border-radius: 4px;
margin-bottom: 4px;
position: relative;
overflow: hidden;
background-color: #e9ecef; /* Light gray background for empty bars */
}
.vote-bar-motm {
background-color: rgba(40, 167, 69, 0.1); /* Very light green background */
}
.vote-bar-dotd {
background-color: rgba(220, 53, 69, 0.1); /* Very light red background */
}
.vote-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.8s ease-in-out;
}
.vote-bar-motm .vote-bar-fill {
background: linear-gradient(90deg, #28a745, #20c997);
}
.vote-bar-dotd .vote-bar-fill {
background: linear-gradient(90deg, #dc3545, #fd7e14);
}
.vote-no-data {
text-align: center;
padding: 3rem;
color: #6c757d;
}
.vote-no-data i {
font-size: 3rem;
margin-bottom: 1rem;
color: #dee2e6;
}
.vote-ranking {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: linear-gradient(135deg, #007bff, #0056b3);
color: white;
border-radius: 50%;
font-weight: 700;
font-size: 0.9rem;
margin-right: 1rem;
}
.vote-ranking.gold {
background: linear-gradient(135deg, #ffd700, #ffb347);
}
.vote-ranking.silver {
background: linear-gradient(135deg, #c0c0c0, #a8a8a8);
}
.vote-ranking.bronze {
background: linear-gradient(135deg, #cd7f32, #b8860b);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
loadVoteData();
});
function loadVoteData() {
fetch('/api/vote-results')
.then(response => {
console.log('Vote response status:', response.status);
return response.json();
})
.then(data => {
console.log('Raw vote API data:', data);
renderVoteChart(data);
})
.catch(error => {
console.error('Error loading vote data:', error);
renderNoData();
});
}
function renderVoteChart(data) {
const container = document.getElementById('vote-chart-container');
console.log('Vote Data received:', data);
if (!data || data.length === 0) {
renderNoData();
return;
}
// Get the match date from the first item's keys
const matchDate = '{{ _matchDate }}';
const motmKey = 'motm_' + matchDate;
const dotdKey = 'dotd_' + matchDate;
console.log('Using keys:', motmKey, dotdKey);
// Sort by MOTM votes (descending), then by DotD votes (ascending for better ranking)
data.sort((a, b) => {
const aMotm = a[motmKey] || 0;
const bMotm = b[motmKey] || 0;
const aDotd = a[dotdKey] || 0;
const bDotd = b[dotdKey] || 0;
if (bMotm !== aMotm) {
return bMotm - aMotm;
}
return aDotd - bDotd;
});
// Find max values for independent scaling - each bar type scales to its own maximum
const maxMotm = Math.max(...data.map(p => p[motmKey] || 0));
const maxDotd = Math.max(...data.map(p => p[dotdKey] || 0));
let html = '';
console.log('Max values calculated:', {maxMotm, maxDotd});
data.forEach((player, index) => {
console.log('Processing vote player:', player);
const ranking = index + 1;
const rankingClass = ranking === 1 ? 'gold' : ranking === 2 ? 'silver' : ranking === 3 ? 'bronze' : '';
const motmVotes = player[motmKey] || 0;
const dotdVotes = player[dotdKey] || 0;
console.log(`${player.playerName}: MOTM=${motmVotes}, DotD=${dotdVotes}`);
html += '<div class="vote-player-card">';
html += '<div class="d-flex align-items-center">';
html += '<div class="vote-ranking ' + rankingClass + '">' + ranking + '</div>';
html += '<div class="flex-grow-1">';
html += '<div class="vote-player-name">' + (player.playerName || 'Unknown Player') + '</div>';
html += '<div class="vote-stats">';
html += '<div class="vote-stat">';
html += '<div class="vote-stat-value text-success">' + motmVotes + '</div>';
html += '<div class="vote-stat-label">MOTM</div>';
html += '</div>';
html += '<div class="vote-stat">';
html += '<div class="vote-stat-value text-danger">' + dotdVotes + '</div>';
html += '<div class="vote-stat-label">DotD</div>';
html += '</div>';
// Calculate percentages with debugging
const motmPercent = maxMotm > 0 ? (motmVotes / maxMotm * 100) : 0;
const dotdPercent = maxDotd > 0 ? (dotdVotes / maxDotd * 100) : 0;
console.log(`${player.playerName} percentages: MOTM=${motmPercent.toFixed(1)}%, DotD=${dotdPercent.toFixed(1)}%`);
html += '<div class="vote-bar-container">';
html += '<div class="vote-bar vote-bar-motm">';
html += '<div class="vote-bar-fill" style="width: ' + motmPercent + '%"></div>';
html += '</div>';
html += '<div class="vote-bar vote-bar-dotd">';
html += '<div class="vote-bar-fill" style="width: ' + dotdPercent + '%"></div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
});
container.innerHTML = html;
// Verify bar widths are set correctly
setTimeout(() => {
verifyBarWidths();
}, 100);
// Animate bars after a short delay
setTimeout(() => {
const bars = container.querySelectorAll('.vote-bar-fill');
bars.forEach(bar => {
const width = bar.style.width;
bar.style.width = '0%';
setTimeout(() => {
bar.style.width = width;
}, 100);
});
}, 200);
}
function renderNoData() {
const container = document.getElementById('vote-chart-container');
container.innerHTML = '<div class="vote-no-data">' +
'<i class="fas fa-chart-line"></i>' +
'<h4>No Vote Data Available</h4>' +
'<p>No votes have been cast for this match yet.</p>' +
'<p class="text-muted">Votes will appear here once players start voting.</p>' +
'</div>';
}
// Test function to verify basic functionality
function testVoteChart() {
const testData = [
{playerName: 'Test Player 1', motm_{{ _matchDate }}: 3, dotd_{{ _matchDate }}: 1},
{playerName: 'Test Player 2', motm_{{ _matchDate }}: 2, dotd_{{ _matchDate }}: 2}
];
console.log('Testing with sample vote data:', testData);
renderVoteChart(testData);
}
// Add a simple test to verify bar widths are being set
function verifyBarWidths() {
const bars = document.querySelectorAll('.vote-bar-fill');
console.log('Found bars:', bars.length);
bars.forEach((bar, index) => {
const width = bar.style.width;
console.log(`Bar ${index}: width = ${width}`);
});
}
</script>
{% endblock %}

View File

@ -1,19 +1,45 @@
<html>
<head>
<title>HKFC Men's C Team - MotM and DotD vote</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
</head>
<h2>Thanks for submitting the MotM and DotD votes</h2>
<body>
Smithers' army of Internet monkeys will now go about adding up the votes ...
<p>
<img src="http://icarus.ipa.champion:9000/hockey-app/assets/simpsons-monkeys.jpg"></img>
</p>
<a class="btn btn-primary" href="/" role="button">Home</a>
<a class="btn btn-info" href="/motm/comments" role="button">Comments</a>
</body>
</html>
{% extends "base.html" %}
{% block title %}Vote Submitted - HKFC MOTM System{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-success">
<div class="card-body text-center">
<div class="mb-4">
<i class="fas fa-check-circle text-success" style="font-size: 4rem;"></i>
</div>
<h1 class="card-title text-success">Vote Submitted Successfully!</h1>
<div class="alert alert-success">
<h5 class="alert-heading">
<i class="fas fa-thumbs-up me-2"></i>Thank you for voting
</h5>
<p class="mb-0">
Smithers' army of Internet monkeys will now go about adding up the votes...
</p>
</div>
<div class="mb-4">
<img src="{{ simpsons_image_url }}"
alt="Counting votes"
class="img-fluid rounded"
style="max-height: 300px;"
onerror="this.src='/static/images/simpsons-monkeys.jpg';">
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>Back to Home
</a>
<a href="/motm/comments" class="btn btn-info">
<i class="fas fa-comments me-2"></i>View Comments
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Vote Submitted - HKFC MOTM System{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-success">
<div class="card-body text-center">
<div class="mb-4">
<i class="fas fa-check-circle text-success" style="font-size: 4rem;"></i>
</div>
<h1 class="card-title text-success">Vote Submitted Successfully!</h1>
<div class="alert alert-success">
<h5 class="alert-heading">
<i class="fas fa-thumbs-up me-2"></i>Thank you for voting
</h5>
<p class="mb-0">
Smithers' army of Internet monkeys will now go about adding up the votes...
</p>
</div>
<div class="mb-4">
<img src="{{ simpsons_image_url }}"
alt="Counting votes"
class="img-fluid rounded"
style="max-height: 300px;"
onerror="this.src='/static/images/simpsons-monkeys.jpg';">
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-2"></i>Back to Home
</a>
<a href="/motm/comments" class="btn btn-info">
<i class="fas fa-comments me-2"></i>View Comments
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,19 @@
<html>
<head>
<title>HKFC Men's C Team - MotM and DotD vote</title>
<link rel="stylesheet" media="screen" href ="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-theme.min.css">
<meta name="viewport" content = "width=device-width, initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
</head>
<h2>Thanks for submitting the MotM and DotD votes</h2>
<body>
Smithers' army of Internet monkeys will now go about adding up the votes ...
<p>
<img src="http://icarus.ipa.champion:9000/hockey-app/assets/simpsons-monkeys.jpg"></img>
</p>
<a class="btn btn-primary" href="/" role="button">Home</a>
<a class="btn btn-info" href="/motm/comments" role="button">Comments</a>
</body>
</html>