Add voting deadline function
This commit is contained in:
parent
afd5ecd89a
commit
6a50c9fe90
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
|
||||
|
||||
|
||||
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!**
|
||||
|
||||
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.
|
||||
|
||||
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 ==="
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
@ -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):
|
||||
@ -158,6 +158,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 +202,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':
|
||||
os.environ['DATABASE_TYPE'] = 'sqlite'
|
||||
os.environ['SQLITE_DATABASE_PATH'] = self.config['DATABASE']['sqlite_database_path']
|
||||
if 'DATABASE_TYPE' not in os.environ and 'DB_TYPE' not in os.environ:
|
||||
os.environ['DATABASE_TYPE'] = 'sqlite'
|
||||
if 'SQLITE_DATABASE_PATH' not in os.environ and 'DB_NAME' not in os.environ:
|
||||
os.environ['SQLITE_DATABASE_PATH'] = self.config['DATABASE']['sqlite_database_path']
|
||||
|
||||
elif db_type == 'mysql':
|
||||
os.environ['DATABASE_TYPE'] = 'mysql'
|
||||
os.environ['MYSQL_HOST'] = self.config['MYSQL']['host']
|
||||
os.environ['MYSQL_PORT'] = self.config['MYSQL']['port']
|
||||
os.environ['MYSQL_DATABASE'] = self.config['MYSQL']['database']
|
||||
os.environ['MYSQL_USER'] = self.config['MYSQL']['username']
|
||||
os.environ['MYSQL_PASSWORD'] = self.config['MYSQL']['password']
|
||||
os.environ['MYSQL_CHARSET'] = self.config['MYSQL']['charset']
|
||||
if 'DATABASE_TYPE' not in os.environ and 'DB_TYPE' not in os.environ:
|
||||
os.environ['DATABASE_TYPE'] = 'mysql'
|
||||
if 'MYSQL_HOST' not in os.environ and 'DB_HOST' not in os.environ:
|
||||
os.environ['MYSQL_HOST'] = self.config['MYSQL']['host']
|
||||
if 'MYSQL_PORT' not in os.environ and 'DB_PORT' not in os.environ:
|
||||
os.environ['MYSQL_PORT'] = self.config['MYSQL']['port']
|
||||
if 'MYSQL_DATABASE' not in os.environ and 'DB_NAME' not in os.environ:
|
||||
os.environ['MYSQL_DATABASE'] = self.config['MYSQL']['database']
|
||||
if 'MYSQL_USER' not in os.environ and 'DB_USER' not in os.environ:
|
||||
os.environ['MYSQL_USER'] = self.config['MYSQL']['username']
|
||||
if 'MYSQL_PASSWORD' not in os.environ and 'DB_PASSWORD' not in os.environ:
|
||||
# Only set password from config if it exists and no env var is set
|
||||
password = self.config['MYSQL'].get('password', '')
|
||||
if password:
|
||||
os.environ['MYSQL_PASSWORD'] = password
|
||||
if 'MYSQL_CHARSET' not in os.environ:
|
||||
os.environ['MYSQL_CHARSET'] = self.config['MYSQL']['charset']
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
os.environ['DATABASE_TYPE'] = 'postgresql'
|
||||
os.environ['POSTGRES_HOST'] = self.config['POSTGRESQL']['host']
|
||||
os.environ['POSTGRES_PORT'] = self.config['POSTGRESQL']['port']
|
||||
os.environ['POSTGRES_DATABASE'] = self.config['POSTGRESQL']['database']
|
||||
os.environ['POSTGRES_USER'] = self.config['POSTGRESQL']['username']
|
||||
os.environ['POSTGRES_PASSWORD'] = self.config['POSTGRESQL']['password']
|
||||
if 'DATABASE_TYPE' not in os.environ and 'DB_TYPE' not in os.environ:
|
||||
os.environ['DATABASE_TYPE'] = 'postgresql'
|
||||
if 'POSTGRES_HOST' not in os.environ and 'DB_HOST' not in os.environ:
|
||||
os.environ['POSTGRES_HOST'] = self.config['POSTGRESQL']['host']
|
||||
if 'POSTGRES_PORT' not in os.environ and 'DB_PORT' not in os.environ:
|
||||
os.environ['POSTGRES_PORT'] = self.config['POSTGRESQL']['port']
|
||||
if 'POSTGRES_DATABASE' not in os.environ and 'DB_NAME' not in os.environ:
|
||||
os.environ['POSTGRES_DATABASE'] = self.config['POSTGRESQL']['database']
|
||||
if 'POSTGRES_USER' not in os.environ and 'DB_USER' not in os.environ:
|
||||
os.environ['POSTGRES_USER'] = self.config['POSTGRESQL']['username']
|
||||
if 'POSTGRES_PASSWORD' not in os.environ and 'DB_PASSWORD' not in os.environ:
|
||||
# Only set password from config if it exists and no env var is set
|
||||
password = self.config['POSTGRESQL'].get('password', '')
|
||||
if password:
|
||||
os.environ['POSTGRES_PASSWORD'] = password
|
||||
|
||||
def test_connection(self, form_data):
|
||||
"""Test database connection with provided settings."""
|
||||
@ -259,6 +284,24 @@ 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(2025, 1, 15), 'home_team': 'HKFC C', 'away_team': 'KCC A', 'venue': 'HKFC'},
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -358,3 +358,6 @@ For issues and questions:
|
||||
3. Consult the Helm chart documentation
|
||||
4. Create an issue in the repository
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -238,3 +238,6 @@ The chart includes basic health checks. For production deployments, consider add
|
||||
|
||||
For issues and questions, please refer to the application documentation or create an issue in the repository.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -255,3 +255,6 @@ main() {
|
||||
# Run main function
|
||||
main "$@"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -64,3 +64,6 @@ Create the name of the service account to use
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -37,7 +37,14 @@ data:
|
||||
"aws_secret_access_key": "",
|
||||
"aws_region": "{{ .Values.s3.region }}",
|
||||
"bucket_name": "{{ .Values.s3.bucket }}",
|
||||
"bucket_prefix": "{{ .Values.s3.bucketPrefix }}",
|
||||
"endpoint_url": "{{ .Values.s3.endpoint }}",
|
||||
"use_ssl": true,
|
||||
"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
|
||||
}
|
||||
|
||||
@ -58,26 +58,55 @@ spec:
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
{{- if .Values.secrets.useExternalSecret }}
|
||||
name: {{ .Values.secrets.externalSecretName | quote }}
|
||||
{{- else }}
|
||||
name: {{ include "motm-app.fullname" . }}-secrets
|
||||
key: db-password
|
||||
# S3 configuration
|
||||
{{- 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
|
||||
key: s3-access-key
|
||||
{{- 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
|
||||
key: s3-secret-key
|
||||
{{- end }}
|
||||
key: {{ .Values.secrets.s3SecretKeyKey }}
|
||||
{{- else }}
|
||||
- name: S3_ENABLED
|
||||
value: "false"
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
|
||||
@ -31,3 +31,6 @@ spec:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -17,3 +17,6 @@ spec:
|
||||
{{- include "motm-app.selectorLabels" . | nindent 6 }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -20,3 +20,6 @@ spec:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
{{- if not .Values.secrets.useExternalSecret }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
@ -8,23 +9,24 @@ type: Opaque
|
||||
data:
|
||||
# Database password
|
||||
{{- if .Values.secrets.dbPassword }}
|
||||
db-password: {{ .Values.secrets.dbPassword | b64enc | quote }}
|
||||
{{ .Values.secrets.dbPasswordKey }}: {{ .Values.secrets.dbPassword | b64enc | quote }}
|
||||
{{- else }}
|
||||
db-password: {{ "changeme" | b64enc | quote }}
|
||||
{{ .Values.secrets.dbPasswordKey }}: {{ "changeme" | b64enc | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.s3.enabled }}
|
||||
# S3 credentials
|
||||
{{- if .Values.secrets.s3AccessKey }}
|
||||
s3-access-key: {{ .Values.secrets.s3AccessKey | b64enc | quote }}
|
||||
{{ .Values.secrets.s3AccessKeyKey }}: {{ .Values.secrets.s3AccessKey | b64enc | quote }}
|
||||
{{- else }}
|
||||
s3-access-key: {{ "changeme" | b64enc | quote }}
|
||||
{{ .Values.secrets.s3AccessKeyKey }}: {{ "changeme" | b64enc | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.secrets.s3SecretKey }}
|
||||
s3-secret-key: {{ .Values.secrets.s3SecretKey | b64enc | quote }}
|
||||
{{ .Values.secrets.s3SecretKeyKey }}: {{ .Values.secrets.s3SecretKey | b64enc | quote }}
|
||||
{{- else }}
|
||||
s3-secret-key: {{ "changeme" | b64enc | quote }}
|
||||
{{ .Values.secrets.s3SecretKeyKey }}: {{ "changeme" | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
@ -11,3 +11,6 @@ metadata:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -71,12 +71,26 @@ database:
|
||||
name: "motm_dev"
|
||||
username: "motm_user"
|
||||
|
||||
# S3 Configuration for Development
|
||||
# S3/MinIO Configuration for Development
|
||||
# Example: Using MinIO for local development
|
||||
s3:
|
||||
enabled: true
|
||||
endpoint: "https://s3.amazonaws.com"
|
||||
region: "us-east-1"
|
||||
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:
|
||||
|
||||
@ -79,12 +79,26 @@ database:
|
||||
name: "motm_prod"
|
||||
username: "motm_user"
|
||||
|
||||
# S3 Configuration for Production
|
||||
# S3/MinIO Configuration for Production
|
||||
s3:
|
||||
enabled: true
|
||||
endpoint: "https://s3.amazonaws.com"
|
||||
region: "us-east-1"
|
||||
bucket: "motm-assets-prod"
|
||||
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:
|
||||
|
||||
@ -113,15 +113,44 @@ database:
|
||||
# Password should be set via secret
|
||||
# password: ""
|
||||
|
||||
# S3 Configuration
|
||||
# S3/MinIO Configuration
|
||||
s3:
|
||||
# Enable S3 storage (if false, uses local static files)
|
||||
enabled: true
|
||||
endpoint: "https://s3.amazonaws.com"
|
||||
|
||||
# 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"
|
||||
bucket: "motm-assets"
|
||||
|
||||
# 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: ""
|
||||
# These should be set via secret
|
||||
|
||||
# Environment Variables
|
||||
env:
|
||||
@ -150,8 +179,20 @@ configMap:
|
||||
database = {{ .Values.database.name }}
|
||||
username = {{ .Values.database.username }}
|
||||
|
||||
# Secrets
|
||||
# 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
|
||||
|
||||
302
motm_app/main.py
302
motm_app/main.py
@ -184,7 +184,7 @@ def is_admin_authenticated(request):
|
||||
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)
|
||||
|
||||
@ -210,8 +210,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 +220,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']:
|
||||
# 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 = :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 = []
|
||||
motm_result = sql_read(sql3, {'curr_motm': 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 = :curr_dotd")
|
||||
dotd_result = sql_read(sql4, {'curr_dotd': 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 +246,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:
|
||||
@ -314,6 +327,23 @@ def match_comments():
|
||||
def vote_thanks():
|
||||
"""Process MOTM/DotD votes and comments"""
|
||||
try:
|
||||
# Check voting deadline
|
||||
sql_deadline = text("SELECT votingdeadline FROM motmadminsettings WHERE userid = 'admin'")
|
||||
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']
|
||||
@ -460,6 +490,7 @@ def motm_admin():
|
||||
print('Activated')
|
||||
_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')
|
||||
|
||||
@ -481,24 +512,28 @@ def motm_admin():
|
||||
return redirect(url_for('motm_admin'))
|
||||
_nextClub = _nextClubName[0]['club']
|
||||
|
||||
# Only update currMotM and currDotD if they were provided
|
||||
if _currMotM and _currMotM != '0' and _currDotD and _currDotD != '0':
|
||||
sql = text("UPDATE motmadminsettings SET nextdate = :next_date, nextclub = :next_club, nextteam = :next_team, currmotm = :curr_motm, currdotd = :curr_dotd")
|
||||
sql_write_static(sql, {
|
||||
'next_date': _nextMatchDate,
|
||||
'next_club': _nextClub,
|
||||
'next_team': _nextTeam,
|
||||
'curr_motm': _currMotM,
|
||||
'curr_dotd': _currDotD
|
||||
})
|
||||
else:
|
||||
# Don't update currMotM and currDotD if not provided
|
||||
sql = text("UPDATE motmadminsettings SET nextdate = :next_date, nextclub = :next_club, nextteam = :next_team")
|
||||
sql_write_static(sql, {
|
||||
'next_date': _nextMatchDate,
|
||||
'next_club': _nextClub,
|
||||
'next_team': _nextTeam
|
||||
})
|
||||
# Update currMotM and currDotD - set to None if '0' (No Previous) is selected
|
||||
curr_motm_value = None if _currMotM == '0' else _currMotM
|
||||
curr_dotd_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 = :next_date, nextclub = :next_club, nextteam = :next_team, currmotm = :curr_motm, currdotd = :curr_dotd, votingdeadline = :voting_deadline")
|
||||
sql_write_static(sql, {
|
||||
'next_date': _nextMatchDate,
|
||||
'next_club': _nextClub,
|
||||
'next_team': _nextTeam,
|
||||
'curr_motm': curr_motm_value,
|
||||
'curr_dotd': curr_dotd_value,
|
||||
'voting_deadline': voting_deadline_value
|
||||
})
|
||||
|
||||
# Update the opponent logo using the matched club information
|
||||
if opponent_club_info and opponent_club_info.get('logo_url'):
|
||||
@ -540,7 +575,7 @@ def motm_admin():
|
||||
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 WHERE userid = 'admin'")
|
||||
current_settings = sql_read_static(sql_current)
|
||||
if current_settings:
|
||||
from datetime import datetime
|
||||
@ -551,6 +586,20 @@ 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')
|
||||
|
||||
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")
|
||||
@ -567,8 +616,15 @@ 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]
|
||||
# Add "No Previous" option at the beginning of the list
|
||||
form.currMotM.choices = [('0', '-- No Previous MOTM --')] + [(player['playernumber'], player['playerforenames'] + " " + player['playersurname']) for player in players]
|
||||
form.currDotD.choices = [('0', '-- No Previous DotD --')] + [(player['playernumber'], player['playerforenames'] + " " + player['playersurname']) for player in players]
|
||||
|
||||
# Pre-select current MOTM and DotD values (default to '0' if NULL)
|
||||
if current_settings:
|
||||
form.currMotM.data = str(current_settings[0]['currmotm']) if current_settings[0].get('currmotm') else '0'
|
||||
form.currDotD.data = str(current_settings[0]['currdotd']) if current_settings[0].get('currdotd') 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']:
|
||||
@ -1382,6 +1438,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():
|
||||
|
||||
@ -13,22 +13,109 @@ from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class S3ConfigManager:
|
||||
"""Manages S3 configuration settings."""
|
||||
"""Manages S3 configuration settings.
|
||||
|
||||
Configuration priority (highest to lowest):
|
||||
1. Environment variables (from Kubernetes secrets)
|
||||
2. Database settings (admin-configurable via web UI)
|
||||
3. JSON file (local development fallback)
|
||||
|
||||
Note: Credentials are ALWAYS from environment variables for security.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config_file = 's3_config.json'
|
||||
self.config = self.load_config()
|
||||
|
||||
def load_config(self) -> Dict[str, Any]:
|
||||
"""Load S3 configuration from file."""
|
||||
"""Load S3 configuration from environment variables, database, or file.
|
||||
|
||||
Priority:
|
||||
1. Environment variables (for Kubernetes/container deployments)
|
||||
2. Database settings (admin-configurable via web UI)
|
||||
3. Configuration file (for local/legacy deployments)
|
||||
"""
|
||||
# Priority 1: Check if running in Kubernetes/container mode with env vars
|
||||
if os.getenv('S3_ENABLED') or os.getenv('S3_ACCESS_KEY_ID'):
|
||||
return self._load_from_env()
|
||||
|
||||
# Priority 2: Try loading from database
|
||||
db_config = self._load_from_database()
|
||||
if db_config:
|
||||
return db_config
|
||||
|
||||
# Priority 3: Fall back to file-based configuration
|
||||
if os.path.exists(self.config_file):
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return self.get_default_config()
|
||||
|
||||
# Default: Disabled, use local static files
|
||||
return self.get_default_config()
|
||||
|
||||
def _load_from_env(self) -> Dict[str, Any]:
|
||||
"""Load S3 configuration from environment variables."""
|
||||
return {
|
||||
'aws_access_key_id': os.getenv('S3_ACCESS_KEY_ID', ''),
|
||||
'aws_secret_access_key': os.getenv('S3_SECRET_ACCESS_KEY', ''),
|
||||
'aws_region': os.getenv('S3_REGION', 'us-east-1'),
|
||||
'bucket_name': os.getenv('S3_BUCKET', ''),
|
||||
'bucket_prefix': os.getenv('S3_BUCKET_PREFIX', 'assets/'),
|
||||
'enable_s3': os.getenv('S3_ENABLED', 'false').lower() in ('true', '1', 'yes'),
|
||||
'use_signed_urls': os.getenv('S3_USE_SIGNED_URLS', 'true').lower() in ('true', '1', 'yes'),
|
||||
'signed_url_expiry': int(os.getenv('S3_SIGNED_URL_EXPIRY', '3600')),
|
||||
'fallback_to_static': os.getenv('S3_FALLBACK_TO_STATIC', 'true').lower() in ('true', '1', 'yes'),
|
||||
'storage_provider': os.getenv('S3_STORAGE_PROVIDER', 'aws'),
|
||||
'minio_endpoint': os.getenv('S3_ENDPOINT', ''),
|
||||
'minio_use_ssl': os.getenv('S3_USE_SSL', 'true').lower() in ('true', '1', 'yes'),
|
||||
'endpoint_url': os.getenv('S3_ENDPOINT', '') # For compatibility
|
||||
}
|
||||
|
||||
def _load_from_database(self) -> Optional[Dict[str, Any]]:
|
||||
"""Load S3 configuration from database.
|
||||
|
||||
Returns None if database is not available or no settings exist.
|
||||
Credentials are always loaded from environment variables.
|
||||
"""
|
||||
try:
|
||||
# Import here to avoid circular dependency
|
||||
from database import get_db_session, S3Settings
|
||||
|
||||
session = get_db_session()
|
||||
try:
|
||||
# Get settings for admin user
|
||||
settings = session.query(S3Settings).filter_by(userid='admin').first()
|
||||
|
||||
if not settings:
|
||||
return None
|
||||
|
||||
# Build configuration from database settings
|
||||
# Credentials ALWAYS come from environment variables
|
||||
return {
|
||||
'enable_s3': settings.enabled,
|
||||
'aws_access_key_id': os.getenv('S3_ACCESS_KEY_ID', ''),
|
||||
'aws_secret_access_key': os.getenv('S3_SECRET_ACCESS_KEY', ''),
|
||||
'storage_provider': settings.storage_provider,
|
||||
'aws_region': settings.region,
|
||||
'bucket_name': settings.bucket_name,
|
||||
'bucket_prefix': settings.bucket_prefix,
|
||||
'use_signed_urls': settings.use_signed_urls,
|
||||
'signed_url_expiry': settings.signed_url_expiry,
|
||||
'fallback_to_static': settings.fallback_to_static,
|
||||
'minio_endpoint': settings.endpoint,
|
||||
'minio_use_ssl': settings.use_ssl,
|
||||
'endpoint_url': settings.endpoint # For compatibility
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
# Database not available or table doesn't exist yet
|
||||
# This is normal during initial setup
|
||||
return None
|
||||
|
||||
def get_default_config(self) -> Dict[str, Any]:
|
||||
"""Get default S3 configuration."""
|
||||
return {
|
||||
@ -47,16 +134,60 @@ class S3ConfigManager:
|
||||
}
|
||||
|
||||
def save_config(self, config_data: Dict[str, Any]) -> bool:
|
||||
"""Save S3 configuration to file."""
|
||||
"""Save S3 configuration to database.
|
||||
|
||||
Note: Credentials are NOT saved to database for security.
|
||||
They should be provided via environment variables.
|
||||
"""
|
||||
try:
|
||||
# Update environment variables
|
||||
os.environ['AWS_ACCESS_KEY_ID'] = config_data.get('aws_access_key_id', '')
|
||||
os.environ['AWS_SECRET_ACCESS_KEY'] = config_data.get('aws_secret_access_key', '')
|
||||
os.environ['AWS_DEFAULT_REGION'] = config_data.get('aws_region', 'us-east-1')
|
||||
from database import get_db_session, S3Settings
|
||||
|
||||
session = get_db_session()
|
||||
try:
|
||||
# Get or create settings for admin user
|
||||
settings = session.query(S3Settings).filter_by(userid='admin').first()
|
||||
|
||||
if not settings:
|
||||
settings = S3Settings(userid='admin')
|
||||
session.add(settings)
|
||||
|
||||
# Update settings from config_data
|
||||
# NOTE: We do NOT save credentials to database
|
||||
settings.enabled = config_data.get('enable_s3', False)
|
||||
settings.storage_provider = config_data.get('storage_provider', 'aws')
|
||||
settings.endpoint = config_data.get('minio_endpoint', config_data.get('endpoint_url', ''))
|
||||
settings.region = config_data.get('aws_region', 'us-east-1')
|
||||
settings.bucket_name = config_data.get('bucket_name', '')
|
||||
settings.bucket_prefix = config_data.get('bucket_prefix', 'assets/')
|
||||
settings.use_signed_urls = config_data.get('use_signed_urls', True)
|
||||
settings.signed_url_expiry = config_data.get('signed_url_expiry', 3600)
|
||||
settings.fallback_to_static = config_data.get('fallback_to_static', True)
|
||||
settings.use_ssl = config_data.get('minio_use_ssl', True)
|
||||
|
||||
session.commit()
|
||||
|
||||
# Reload config from database
|
||||
self.config = self.load_config()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
print(f"Error saving S3 config to database: {e}")
|
||||
# Fall back to file-based save for local development
|
||||
return self._save_to_file(config_data)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Database not available, falling back to file: {e}")
|
||||
# Fall back to file-based save for local development
|
||||
return self._save_to_file(config_data)
|
||||
|
||||
def _save_to_file(self, config_data: Dict[str, Any]) -> bool:
|
||||
"""Save S3 configuration to JSON file (fallback for local development)."""
|
||||
try:
|
||||
with open(self.config_file, 'w') as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
|
||||
self.config = config_data
|
||||
return True
|
||||
except IOError:
|
||||
|
||||
@ -65,6 +65,15 @@
|
||||
</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>
|
||||
|
||||
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>
|
||||
|
||||
@ -68,19 +68,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Winners Section -->
|
||||
<!-- 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>Current Man of the Match
|
||||
<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">Select "No Previous MOTM" for the first match or if no previous winner</small>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="currDotD" class="form-label">
|
||||
<i class="fas fa-user-times me-2 text-danger"></i>Current Dick of the Day
|
||||
<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">Select "No Previous DotD" for the first match or if no previous winner</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -32,6 +32,19 @@
|
||||
</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>
|
||||
@ -178,11 +191,77 @@
|
||||
{% 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.');
|
||||
|
||||
@ -62,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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user