Compare commits
9 Commits
master
...
web-framew
| Author | SHA1 | Date | |
|---|---|---|---|
| 00f6c6576a | |||
| 0cf8cf7fc0 | |||
| c199979eb9 | |||
| 6a50c9fe90 | |||
| afd5ecd89a | |||
| 6886f94888 | |||
| 09d5c79e8d | |||
| a6febae8a8 | |||
| d5350aa4cb |
3
motm_app/=21.0.0
Normal file
3
motm_app/=21.0.0
Normal 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)
|
||||
261
motm_app/COMMENTS_MANAGEMENT_FEATURE.md
Normal file
261
motm_app/COMMENTS_MANAGEMENT_FEATURE.md
Normal 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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
209
motm_app/DEBUG_URL_SUFFIX_ISSUE.md
Normal file
209
motm_app/DEBUG_URL_SUFFIX_ISSUE.md
Normal 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
|
||||
|
||||
385
motm_app/HELM_SECRETS_GUNICORN_UPDATES.md
Normal file
385
motm_app/HELM_SECRETS_GUNICORN_UPDATES.md
Normal 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
|
||||
|
||||
471
motm_app/IMPLEMENTATION_SUMMARY.md
Normal file
471
motm_app/IMPLEMENTATION_SUMMARY.md
Normal 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!**
|
||||
|
||||
107
motm_app/MIGRATION_SUMMARY.md
Normal file
107
motm_app/MIGRATION_SUMMARY.md
Normal 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
187
motm_app/MINIO_LOGO_FIX.md
Normal 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.
|
||||
|
||||
192
motm_app/POSTGRESQL_COMMENTS_FIX.md
Normal file
192
motm_app/POSTGRESQL_COMMENTS_FIX.md
Normal 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.
|
||||
|
||||
160
motm_app/PRODUCTION_DEPLOYMENT.md
Normal file
160
motm_app/PRODUCTION_DEPLOYMENT.md
Normal 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
|
||||
234
motm_app/PRODUCTION_DIAGNOSTIC_GUIDE.md
Normal file
234
motm_app/PRODUCTION_DIAGNOSTIC_GUIDE.md
Normal 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
|
||||
|
||||
219
motm_app/PRODUCTION_MIGRATION_GUIDE.md
Normal file
219
motm_app/PRODUCTION_MIGRATION_GUIDE.md
Normal 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
98
motm_app/QUICK_FIX_COMMANDS.sh
Executable 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
128
motm_app/REVERT_SUMMARY.md
Normal 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.
|
||||
|
||||
511
motm_app/S3_DATABASE_CONFIG.md
Normal file
511
motm_app/S3_DATABASE_CONFIG.md
Normal 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.
|
||||
|
||||
137
motm_app/SQUAD_HISTORY_FEATURE.md
Normal file
137
motm_app/SQUAD_HISTORY_FEATURE.md
Normal 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
|
||||
|
||||
121
motm_app/VOTING_DEADLINE_FEATURE.md
Normal file
121
motm_app/VOTING_DEADLINE_FEATURE.md
Normal 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
|
||||
|
||||
147
motm_app/VOTING_DEADLINE_IMPLEMENTATION.md
Normal file
147
motm_app/VOTING_DEADLINE_IMPLEMENTATION.md
Normal 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.
|
||||
|
||||
109
motm_app/WHERE_CLAUSE_FIXES.md
Normal file
109
motm_app/WHERE_CLAUSE_FIXES.md
Normal 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
91
motm_app/add_squad_history.py
Executable 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
80
motm_app/add_voting_deadline.py
Executable 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
81
motm_app/check_production_db.py
Executable 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()
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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':
|
||||
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':
|
||||
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']
|
||||
os.environ['MYSQL_PASSWORD'] = self.config['MYSQL']['password']
|
||||
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':
|
||||
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']
|
||||
os.environ['POSTGRES_PASSWORD'] = self.config['POSTGRESQL']['password']
|
||||
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:
|
||||
|
||||
32
motm_app/fix_url_suffix_update.py
Normal file
32
motm_app/fix_url_suffix_update.py
Normal 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.")
|
||||
|
||||
@ -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
52
motm_app/gunicorn.conf.py
Normal 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
|
||||
21
motm_app/helm-chart/motm-app/Chart.yaml
Normal file
21
motm_app/helm-chart/motm-app/Chart.yaml
Normal 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
|
||||
|
||||
369
motm_app/helm-chart/motm-app/DEPLOYMENT.md
Normal file
369
motm_app/helm-chart/motm-app/DEPLOYMENT.md
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
249
motm_app/helm-chart/motm-app/README.md
Normal file
249
motm_app/helm-chart/motm-app/README.md
Normal 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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
266
motm_app/helm-chart/motm-app/scripts/deploy.sh
Executable file
266
motm_app/helm-chart/motm-app/scripts/deploy.sh
Executable 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 "$@"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
75
motm_app/helm-chart/motm-app/templates/_helpers.tpl
Normal file
75
motm_app/helm-chart/motm-app/templates/_helpers.tpl
Normal 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 }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
50
motm_app/helm-chart/motm-app/templates/configmap.yaml
Normal file
50
motm_app/helm-chart/motm-app/templates/configmap.yaml
Normal 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
|
||||
}
|
||||
164
motm_app/helm-chart/motm-app/templates/deployment.yaml
Normal file
164
motm_app/helm-chart/motm-app/templates/deployment.yaml
Normal 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 }}
|
||||
|
||||
42
motm_app/helm-chart/motm-app/templates/hpa.yaml
Normal file
42
motm_app/helm-chart/motm-app/templates/hpa.yaml
Normal 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 }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
60
motm_app/helm-chart/motm-app/templates/ingress.yaml
Normal file
60
motm_app/helm-chart/motm-app/templates/ingress.yaml
Normal 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 }}
|
||||
|
||||
28
motm_app/helm-chart/motm-app/templates/pdb.yaml
Normal file
28
motm_app/helm-chart/motm-app/templates/pdb.yaml
Normal 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 }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
31
motm_app/helm-chart/motm-app/templates/pvc.yaml
Normal file
31
motm_app/helm-chart/motm-app/templates/pvc.yaml
Normal 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 }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
32
motm_app/helm-chart/motm-app/templates/secret.yaml
Normal file
32
motm_app/helm-chart/motm-app/templates/secret.yaml
Normal 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 }}
|
||||
|
||||
20
motm_app/helm-chart/motm-app/templates/service.yaml
Normal file
20
motm_app/helm-chart/motm-app/templates/service.yaml
Normal 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 }}
|
||||
|
||||
22
motm_app/helm-chart/motm-app/templates/serviceaccount.yaml
Normal file
22
motm_app/helm-chart/motm-app/templates/serviceaccount.yaml
Normal 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 }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
143
motm_app/helm-chart/motm-app/values-development.yaml
Normal file
143
motm_app/helm-chart/motm-app/values-development.yaml
Normal 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"
|
||||
|
||||
181
motm_app/helm-chart/motm-app/values-production.yaml
Normal file
181
motm_app/helm-chart/motm-app/values-production.yaml
Normal 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"
|
||||
|
||||
236
motm_app/helm-chart/motm-app/values.yaml
Normal file
236
motm_app/helm-chart/motm-app/values.yaml
Normal 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: {}
|
||||
|
||||
704
motm_app/main.py
704
motm_app/main.py
@ -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']
|
||||
|
||||
# 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')
|
||||
_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,23 +618,29 @@ 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")
|
||||
# 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, {
|
||||
'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
|
||||
'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
|
||||
@ -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'))
|
||||
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(currSuffix)
|
||||
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
18
motm_app/motm-app.service
Normal 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
|
||||
@ -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]
|
||||
|
||||
@ -29,3 +29,6 @@ boto3>=1.34.0
|
||||
|
||||
# Legacy support (can be removed after migration)
|
||||
flask-mysql
|
||||
|
||||
# Production WSGI server
|
||||
gunicorn>=21.0.0
|
||||
|
||||
13
motm_app/revert_to_motmadminsettings.sh
Normal file
13
motm_app/revert_to_motmadminsettings.sh
Normal 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?"
|
||||
|
||||
27
motm_app/run_migration_production.sh
Executable file
27
motm_app/run_migration_production.sh
Executable 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!"
|
||||
12
motm_app/run_production.py
Normal file
12
motm_app/run_production.py
Normal 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)
|
||||
102
motm_app/run_production_migration.sh
Executable file
102
motm_app/run_production_migration.sh
Executable 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 ""
|
||||
|
||||
@ -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."""
|
||||
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')
|
||||
"""Save S3 configuration to database.
|
||||
|
||||
Note: Credentials are NOT saved to database for security.
|
||||
They should be provided via environment variables.
|
||||
"""
|
||||
try:
|
||||
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
26
motm_app/start_production.sh
Executable 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
|
||||
@ -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)
|
||||
|
||||
@ -1,212 +1,314 @@
|
||||
<!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>
|
||||
{% extends "base.html" %}
|
||||
|
||||
<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>
|
||||
{% block title %}Admin Dashboard - HKFC Men's C Team MOTM System{% endblock %}
|
||||
|
||||
<!-- Data Management Section -->
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
<h3>Data Management</h3>
|
||||
{% 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">
|
||||
<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>
|
||||
<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 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>
|
||||
</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>
|
||||
</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 href="/admin/clubs/add" class="btn btn-outline-success">
|
||||
<i class="fas fa-plus me-2"></i>Add Club
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Match Management Section -->
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
<h3>Match Management</h3>
|
||||
<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="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>
|
||||
<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>
|
||||
</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 href="/admin/players/add" class="btn btn-outline-primary">
|
||||
<i class="fas fa-plus me-2"></i>Add Player
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOTM Management Section -->
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
<h3>MOTM Management</h3>
|
||||
<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-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 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- System Management Section -->
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
<h3>System Management</h3>
|
||||
<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-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>
|
||||
<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 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>
|
||||
</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 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>
|
||||
</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>
|
||||
</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 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>
|
||||
@ -215,5 +317,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</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 %}
|
||||
|
||||
281
motm_app/templates/admin_dashboard_new.html
Normal file
281
motm_app/templates/admin_dashboard_new.html
Normal 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 %}
|
||||
|
||||
219
motm_app/templates/admin_dashboard_old.html
Normal file
219
motm_app/templates/admin_dashboard_old.html
Normal 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>
|
||||
349
motm_app/templates/base.html
Normal file
349
motm_app/templates/base.html
Normal 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>
|
||||
263
motm_app/templates/comments_management.html
Normal file
263
motm_app/templates/comments_management.html
Normal 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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
36
motm_app/templates/error_new.html
Normal file
36
motm_app/templates/error_new.html
Normal 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 %}
|
||||
|
||||
19
motm_app/templates/error_old.html
Normal file
19
motm_app/templates/error_old.html
Normal 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>
|
||||
@ -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>
|
||||
</div>
|
||||
{% extends "base.html" %}
|
||||
|
||||
<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>
|
||||
{% 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-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>
|
||||
<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/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 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/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 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="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 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/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 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/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 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-md-6">
|
||||
<h3>Admin Access</h3>
|
||||
<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">
|
||||
<h4 class="alert-heading">Authentication Required</h4>
|
||||
<p>Admin functions require authentication. Please contact the system administrator for access.</p>
|
||||
<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>
|
||||
</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 %}
|
||||
|
||||
238
motm_app/templates/index_new.html
Normal file
238
motm_app/templates/index_new.html
Normal 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 %}
|
||||
|
||||
117
motm_app/templates/index_old.html
Normal file
117
motm_app/templates/index_old.html
Normal 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>
|
||||
@ -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>
|
||||
{% 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="row">
|
||||
<div class="col-sm-8">
|
||||
<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="panel panel-default">
|
||||
<div class="panel-body">
|
||||
{{ comment.comment }}
|
||||
<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>
|
||||
<a class="btn btn-primary" href="/" role="button">Home</a>
|
||||
</body>
|
||||
</html>
|
||||
{% 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 %}
|
||||
|
||||
77
motm_app/templates/match_comments_new.html
Normal file
77
motm_app/templates/match_comments_new.html
Normal 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 %}
|
||||
|
||||
31
motm_app/templates/match_comments_old.html
Normal file
31
motm_app/templates/match_comments_old.html
Normal 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>
|
||||
@ -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>
|
||||
{% extends "base.html" %}
|
||||
|
||||
<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>
|
||||
{% block title %}Match Squad Selection - HKFC Men's C Team{% endblock %}
|
||||
|
||||
{% 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">
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<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 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 mb-3">
|
||||
<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" for="player{{ player.playernumber }}">
|
||||
<strong>#{{ player.playernumber }}</strong> {{ player.playerforenames }} {{ player.playersurname }}
|
||||
<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-muted">{{ player.playernickname }} - {{ player.playerteam }}</small>
|
||||
<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>
|
||||
<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 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>
|
||||
{% else %}
|
||||
</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">
|
||||
<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 %}
|
||||
<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 %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Select all functionality
|
||||
$('#selectAll').on('change', function() {
|
||||
$('input[name="playerNumber"]').prop('checked', this.checked);
|
||||
});
|
||||
|
||||
// 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 %}
|
||||
|
||||
@ -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>
|
||||
</body>
|
||||
</html>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -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>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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 }}
|
||||
{% 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>
|
||||
{% 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>
|
||||
</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>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,116 +1,163 @@
|
||||
<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
|
||||
{% 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>
|
||||
{% 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>
|
||||
</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>
|
||||
</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") }}
|
||||
<!-- 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>
|
||||
</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") }}
|
||||
|
||||
<!-- 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 = "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 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">
|
||||
<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 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 %}
|
||||
<p>
|
||||
|
||||
<!-- 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-danger" href="/" role="button">Cancel</a>
|
||||
</p>
|
||||
<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 class="col-sm-4">
|
||||
<img src="{{ nextOppoLogo }}" height="90" id="nextOppoLogo"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<!-- 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
|
||||
var statusElement = document.getElementById('fixtureStatus');
|
||||
var infoElement = document.getElementById('fixtureInfo');
|
||||
var loadBtn = document.getElementById('loadFixtureBtn');
|
||||
const statusElement = document.getElementById('fixtureStatus');
|
||||
const infoElement = document.getElementById('fixtureInfo');
|
||||
const loadBtn = document.getElementById('loadFixtureBtn');
|
||||
|
||||
statusElement.innerHTML = '<span class="text-info">Loading...</span>';
|
||||
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
|
||||
@ -122,6 +169,14 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Show fixture information
|
||||
let clubInfo = '';
|
||||
if (data.opponent_club_info) {
|
||||
@ -145,32 +200,30 @@
|
||||
clubInfo;
|
||||
infoElement.style.display = 'block';
|
||||
|
||||
statusElement.innerHTML = '<span class="text-success">✓ Fixture loaded!</span>';
|
||||
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">✗ ' + data.message + '</span>';
|
||||
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">✗ Error loading fixture</span>';
|
||||
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
|
||||
function myFunction() {
|
||||
// Optional: Auto-load the next fixture when the page loads
|
||||
// Uncomment the next line if you want this behavior
|
||||
// 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>
|
||||
</body>
|
||||
</html>
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -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 %}
|
||||
{% 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>
|
||||
|
||||
<!-- 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>
|
||||
<dl>
|
||||
</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 }}
|
||||
<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 %}
|
||||
|
||||
<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>
|
||||
<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 %}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</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() {
|
||||
{% 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 %}
|
||||
|
||||
210
motm_app/templates/motm_vote_new.html
Normal file
210
motm_app/templates/motm_vote_new.html
Normal 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 %}
|
||||
|
||||
83
motm_app/templates/motm_vote_old.html
Normal file
83
motm_app/templates/motm_vote_old.html
Normal 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>
|
||||
@ -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 %}
|
||||
@ -1,11 +1,9 @@
|
||||
<!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>
|
||||
{% 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;
|
||||
@ -16,18 +14,10 @@
|
||||
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>
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
<div class="mb-3">
|
||||
<a href="/admin" class="btn btn-secondary">Back to Admin</a>
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
||||
@ -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>
|
||||
{% extends "base.html" %}
|
||||
|
||||
<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>
|
||||
{% 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 %}
|
||||
|
||||
150
motm_app/templates/squad_history.html
Normal file
150
motm_app/templates/squad_history.html
Normal 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 %}
|
||||
|
||||
@ -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 %}
|
||||
@ -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>
|
||||
{% 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>
|
||||
<a class="btn btn-primary" href="/" role="button">Home</a>
|
||||
<a class="btn btn-info" href="/motm/comments" role="button">Comments</a>
|
||||
</body>
|
||||
</html>
|
||||
</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 %}
|
||||
|
||||
46
motm_app/templates/vote_thanks_new.html
Normal file
46
motm_app/templates/vote_thanks_new.html
Normal 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 %}
|
||||
|
||||
19
motm_app/templates/vote_thanks_old.html
Normal file
19
motm_app/templates/vote_thanks_old.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user