feat: Add extraction management page and server time digital clock

- Add comprehensive extraction management page with drag-and-drop interface
- Implement server time digital clock showing time from mbetterc machine
- Create database models for extraction associations and game configuration
- Add database migration for new tables with proper indexing
- Implement REST API endpoints for extraction management
- Add server time API endpoint for accurate time synchronization
- Update navigation to include extraction management page
- Add digital clock to all dashboard interfaces including cashier dashboard
- Implement default outcome associations (WIN1, X, WIN2)
- Add time limit configuration for UNDER/OVER outcomes
- Update documentation with comprehensive extraction system details
- Add troubleshooting section for extraction and clock features

Features:
- Drag-and-drop outcome association management
- Multi-association support for outcomes
- Trash bin removal functionality
- Real-time server time display (24-hour format)
- Cross-interface clock availability
- Database persistence for all settings
- Professional UI with responsive design
- Error handling and graceful fallbacks
parent 40636f79
...@@ -2,6 +2,42 @@ ...@@ -2,6 +2,42 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [1.2.5] - 2025-08-26
### Added
- **Extraction Management Page**: Complete drag-and-drop interface for managing outcome associations with extraction results
- **Server Time Digital Clock**: Prominent 24-hour format clock showing server time across all dashboard interfaces
- **Database Models for Extraction**: New `ExtractionAssociationModel` and `GameConfigModel` for storing outcome associations and game settings
- **Database Migration System**: Migration_014 for extraction associations and game configuration tables
- **API Endpoints for Extraction**: Complete REST API for managing outcome associations and game configuration
- **Drag & Drop Functionality**: Interactive outcome management with visual feedback and association persistence
- **Multi-Association Support**: Outcomes can be associated with multiple extraction results simultaneously
- **Trash Bin Removal**: Context-aware removal of associations based on drag source location
- **Game Configuration Panel**: Time limit settings for UNDER/OVER outcomes with database persistence
- **Server Time API**: `/api/server-time` endpoint providing server timestamp for accurate time display
- **Cross-Interface Clock**: Digital clock appears on all authenticated pages including cashier dashboard
### Enhanced
- **Web Dashboard Navigation**: Added extraction management page to main navigation menu
- **User Experience**: Silent drag-and-drop operations without alert interruptions
- **Time Synchronization**: Automatic server time sync every 30 seconds with offset calculation
- **Responsive Design**: Clock and extraction interface work seamlessly across all screen sizes
- **Error Handling**: Graceful fallbacks for server time API failures
### Fixed
- **Drag & Drop Detection**: Fixed trash bin drop zone detection with proper CSS classes
- **Multiple Associations**: Resolved issue preventing outcomes from being associated with multiple results
- **Alert Interruptions**: Removed popup alerts during drag-and-drop operations for smoother workflow
- **Clock Positioning**: Optimized clock placement in cashier dashboard navbar
### Technical Details
- **Database Schema**: Added extraction_associations and game_config tables with proper indexing
- **API Integration**: RESTful endpoints for CRUD operations on extraction data
- **JavaScript Architecture**: Modular drag-and-drop system with event delegation and state management
- **CSS Styling**: Professional clock design with hover effects and responsive breakpoints
- **Time Management**: Client-server time offset calculation for accurate server time display
- **Cross-Platform Compatibility**: Extraction system works consistently across different browsers and devices
## [1.2.4] - 2025-08-22 ## [1.2.4] - 2025-08-22
### Added ### Added
......
...@@ -490,6 +490,240 @@ Content-Type: application/json ...@@ -490,6 +490,240 @@ Content-Type: application/json
} }
``` ```
## Extraction Management System
The application includes a comprehensive extraction management system for managing outcome associations with extraction results, featuring an intuitive drag-and-drop interface and real-time server time display.
### Extraction Management Features
- **Drag & Drop Interface**: Interactive outcome management with visual feedback
- **Multi-Association Support**: Outcomes can be associated with multiple extraction results
- **Trash Bin Removal**: Context-aware removal of associations based on drag location
- **Real-Time Persistence**: Immediate database updates with association changes
- **Server Time Clock**: Prominent 24-hour format clock showing server time
- **Cross-Interface Display**: Clock appears on all authenticated dashboard pages
### Extraction Management Architecture
The extraction system consists of several integrated components:
1. **Database Models**: `ExtractionAssociationModel` and `GameConfigModel` for data persistence
2. **Web Interface**: Complete drag-and-drop interface with real-time updates
3. **API Endpoints**: RESTful endpoints for CRUD operations on extraction data
4. **Server Time Service**: `/api/server-time` endpoint for accurate time display
5. **Migration System**: Database migration for schema updates
### Extraction Management Usage
#### Accessing the Interface
Navigate to the extraction management interface:
1. Open web dashboard at `http://localhost:5001`
2. Login with your credentials
3. Click "Extraction" in the navigation menu
4. Access drag-and-drop interface and time configuration
#### Managing Outcome Associations
1. **View Available Outcomes**: All outcomes are displayed in the top area
2. **Drag to Associate**: Drag outcomes to WIN1, X, or WIN2 columns
3. **Multi-Association**: Outcomes can be associated with multiple results
4. **Remove Associations**: Drag associated outcomes to the trash bin
5. **Real-Time Updates**: Changes are saved immediately to the database
#### Configuring Game Settings
1. **Time Limits**: Configure time limits for UNDER/OVER outcomes
2. **Database Persistence**: Settings are stored in the game_config table
3. **Default Values**: System includes sensible default configurations
### Server Time Clock
#### Clock Features
- **Server Time Display**: Shows time from the machine running mbetterc
- **24-Hour Format**: HH:MM:SS format for professional display
- **Real-Time Updates**: Updates every second with server time offset
- **Auto-Sync**: Re-syncs with server every 30 seconds for accuracy
- **Cross-Interface**: Appears on all authenticated dashboard pages
#### Technical Implementation
The clock uses a client-server time offset calculation:
```javascript
// Fetch server time and calculate offset
const serverTimestamp = data.timestamp;
const clientTimestamp = Date.now();
serverTimeOffset = serverTimestamp - clientTimestamp;
// Display server time using offset
const now = Date.now() + serverTimeOffset;
const date = new Date(now);
```
### Extraction Management Configuration
#### Database Schema
**extraction_associations table**:
```sql
CREATE TABLE extraction_associations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
outcome_name VARCHAR(255) NOT NULL,
extraction_result VARCHAR(50) NOT NULL,
is_default BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(outcome_name, extraction_result)
);
```
**game_config table**:
```sql
CREATE TABLE game_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value TEXT NOT NULL,
value_type VARCHAR(20) DEFAULT 'string',
description VARCHAR(500),
is_system BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
#### Default Associations
The system includes default outcome associations:
- **WIN1**: WIN1, X1, K01, RET1, PTS1
- **X**: DRAW, 12, X1, X2, DKO
- **WIN2**: WIN2, X2, K02, RET2, PTS2
### Extraction Management API
#### Get All Associations
```http
GET /api/extraction/associations
Authorization: Bearer <token>
```
**Response:**
```json
{
"success": true,
"associations": [
{
"id": 1,
"outcome_name": "WIN1",
"extraction_result": "WIN1",
"is_default": true
}
]
}
```
#### Save Associations
```http
POST /api/extraction/associations
Authorization: Bearer <token>
Content-Type: application/json
{
"associations": [
{
"outcome_name": "WIN1",
"extraction_result": "WIN1"
}
]
}
```
#### Get Available Outcomes
```http
GET /api/extraction/outcomes
Authorization: Bearer <token>
```
**Response:**
```json
{
"success": true,
"outcomes": ["WIN1", "DRAW", "WIN2", "X1", "X2"]
}
```
#### Get Server Time
```http
GET /api/server-time
```
**Response:**
```json
{
"success": true,
"server_time": "2025-08-26T15:30:45.123456",
"timestamp": 1724683845123
}
```
#### Update Game Configuration
```http
POST /api/extraction/config
Authorization: Bearer <token>
Content-Type: application/json
{
"under_over_time_limit": 90
}
```
### Extraction Management Troubleshooting
#### Drag & Drop Issues
**Symptoms**: Outcomes won't drag, associations not saving
**Solutions**:
1. Verify JavaScript is enabled in browser
2. Check browser compatibility (Chrome, Firefox, Safari, Edge)
3. Clear browser cache and reload page
4. Check browser console for JavaScript errors
5. Ensure user has proper permissions
#### Clock Time Issues
**Symptoms**: Clock shows wrong time, not updating
**Solutions**:
1. Check server time API endpoint: `/api/server-time`
2. Verify network connectivity to server
3. Check browser time zone settings
4. Clear browser cache and reload
5. Check JavaScript console for errors
#### Database Issues
**Symptoms**: Associations not persisting, configuration not saving
**Solutions**:
1. Check database connectivity and permissions
2. Verify migration has been applied: `Migration_014`
3. Check database logs for errors
4. Ensure proper file permissions on database file
5. Verify SQLite installation and compatibility
#### Performance Issues
**Symptoms**: Interface slow, updates delayed
**Solutions**:
1. Check network latency to server
2. Reduce number of concurrent users
3. Optimize database queries and indexing
4. Consider browser resource usage
5. Check server CPU and memory usage
## Screen Casting System ## Screen Casting System
The application includes a comprehensive screen casting system with Chromecast integration, providing complete screen capture and streaming capabilities. The application includes a comprehensive screen casting system with Chromecast integration, providing complete screen capture and streaming capabilities.
......
...@@ -7,6 +7,8 @@ A cross-platform multimedia client application with video playback, web dashboar ...@@ -7,6 +7,8 @@ A cross-platform multimedia client application with video playback, web dashboar
- **PyQt Video Player**: Fullscreen video playback with dual overlay system (WebEngine and native Qt widgets) - **PyQt Video Player**: Fullscreen video playback with dual overlay system (WebEngine and native Qt widgets)
- **Screen Casting System**: Complete screen capture and Chromecast streaming with web-based controls and device discovery - **Screen Casting System**: Complete screen capture and Chromecast streaming with web-based controls and device discovery
- **Template Management System**: Upload, manage, and live-reload HTML overlay templates with persistent storage - **Template Management System**: Upload, manage, and live-reload HTML overlay templates with persistent storage
- **Extraction Management**: Complete drag-and-drop interface for managing outcome associations with extraction results
- **Server Time Digital Clock**: Prominent 24-hour format clock showing server time across all dashboard interfaces
- **Web Dashboard**: Authentication, user management, configuration interface, and admin system controls - **Web Dashboard**: Authentication, user management, configuration interface, and admin system controls
- **REST API Client**: Configurable external API integration with automatic retry - **REST API Client**: Configurable external API integration with automatic retry
- **Multi-threaded Architecture**: Five threads with Queue-based message passing and proper daemon thread management - **Multi-threaded Architecture**: Five threads with Queue-based message passing and proper daemon thread management
...@@ -33,6 +35,22 @@ A cross-platform multimedia client application with video playback, web dashboar ...@@ -33,6 +35,22 @@ A cross-platform multimedia client application with video playback, web dashboar
-**Real-Time Status Updates**: Live status monitoring with proper button state management and streaming feedback -**Real-Time Status Updates**: Live status monitoring with proper button state management and streaming feedback
-**Command-Line Control**: Screen casting enabled by default with `--no-screen-cast` flag for opt-out configuration -**Command-Line Control**: Screen casting enabled by default with `--no-screen-cast` flag for opt-out configuration
### Version 1.2.5 (August 2025)
-**Extraction Management Page**: Complete drag-and-drop interface for managing outcome associations with extraction results
-**Server Time Digital Clock**: Prominent 24-hour format clock showing server time across all dashboard interfaces
-**Database Models for Extraction**: New `ExtractionAssociationModel` and `GameConfigModel` for storing outcome associations and game settings
-**Database Migration System**: Migration_014 for extraction associations and game configuration tables
-**API Endpoints for Extraction**: Complete REST API for managing outcome associations and game configuration
-**Drag & Drop Functionality**: Interactive outcome management with visual feedback and association persistence
-**Multi-Association Support**: Outcomes can be associated with multiple extraction results simultaneously
-**Trash Bin Removal**: Context-aware removal of associations based on drag source location
-**Game Configuration Panel**: Time limit settings for UNDER/OVER outcomes with database persistence
-**Server Time API**: `/api/server-time` endpoint providing server timestamp for accurate time display
-**Cross-Interface Clock**: Digital clock appears on all authenticated pages including cashier dashboard
-**Silent Drag & Drop**: No alert interruptions during drag-and-drop operations for smoother workflow
-**Time Synchronization**: Automatic server time sync every 30 seconds with offset calculation
### Version 1.2.3 (August 2025) ### Version 1.2.3 (August 2025)
-**Boxing Match Database**: Added comprehensive `matches` and `match_outcomes` database tables adapted from mbetterd MySQL schema -**Boxing Match Database**: Added comprehensive `matches` and `match_outcomes` database tables adapted from mbetterd MySQL schema
...@@ -250,6 +268,13 @@ Threads communicate via Python Queues with structured messages: ...@@ -250,6 +268,13 @@ Threads communicate via Python Queues with structured messages:
- `POST /api/video/upload` - Upload video file for playback - `POST /api/video/upload` - Upload video file for playback
- `POST /api/overlay` - Update overlay content and switch templates - `POST /api/overlay` - Update overlay content and switch templates
#### Extraction Management
- `GET /api/extraction/associations` - Get all outcome associations
- `POST /api/extraction/associations` - Save outcome associations
- `GET /api/extraction/outcomes` - Get available outcomes for association
- `POST /api/extraction/config` - Update game configuration settings
- `GET /api/server-time` - Get current server time for clock synchronization
### Message Types ### Message Types
#### Video Control #### Video Control
......
...@@ -817,6 +817,171 @@ class Migration_012_RemoveFixtureIdUniqueConstraint(DatabaseMigration): ...@@ -817,6 +817,171 @@ class Migration_012_RemoveFixtureIdUniqueConstraint(DatabaseMigration):
return False return False
class Migration_013_AddStatusFieldToMatches(DatabaseMigration):
"""Add status field to matches table for match status tracking"""
def __init__(self):
super().__init__("013", "Add status field to matches table")
def up(self, db_manager) -> bool:
"""Add status column to matches table"""
try:
with db_manager.engine.connect() as conn:
# Check if status column already exists
result = conn.execute(text("PRAGMA table_info(matches)"))
columns = [row[1] for row in result.fetchall()]
if 'status' not in columns:
# Add status column with default value 'pending'
conn.execute(text("""
ALTER TABLE matches
ADD COLUMN status VARCHAR(20) DEFAULT 'pending' NOT NULL
"""))
# Add index for status column
conn.execute(text("""
CREATE INDEX IF NOT EXISTS ix_matches_status ON matches(status)
"""))
conn.commit()
logger.info("Status column added to matches table")
else:
logger.info("Status column already exists in matches table")
return True
except Exception as e:
logger.error(f"Failed to add status field to matches: {e}")
return False
def down(self, db_manager) -> bool:
"""Remove status column - SQLite doesn't support DROP COLUMN easily"""
logger.warning("SQLite doesn't support DROP COLUMN - status column will remain")
return True
class Migration_014_AddExtractionAndGameConfigTables(DatabaseMigration):
"""Add extraction_associations and game_config tables for extraction management"""
def __init__(self):
super().__init__("014", "Add extraction_associations and game_config tables")
def up(self, db_manager) -> bool:
"""Create extraction_associations and game_config tables"""
try:
with db_manager.engine.connect() as conn:
# Create extraction_associations table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS extraction_associations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
outcome_name VARCHAR(255) NOT NULL,
extraction_result VARCHAR(50) NOT NULL,
is_default BOOLEAN DEFAULT FALSE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(outcome_name, extraction_result)
)
"""))
# Create game_config table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS game_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value TEXT NOT NULL,
value_type VARCHAR(20) DEFAULT 'string',
description VARCHAR(500),
is_system BOOLEAN DEFAULT FALSE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
# Create indexes for extraction_associations table
indexes = [
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_outcome_name ON extraction_associations(outcome_name)",
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_extraction_result ON extraction_associations(extraction_result)",
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_composite ON extraction_associations(outcome_name, extraction_result)",
]
# Create indexes for game_config table
indexes.extend([
"CREATE INDEX IF NOT EXISTS ix_game_config_key ON game_config(config_key)",
])
for index_sql in indexes:
conn.execute(text(index_sql))
# Insert default extraction associations
default_associations = [
('WIN1', 'WIN1', True),
('X1', 'WIN1', True),
('K01', 'WIN1', True),
('KO1', 'WIN1', True),
('RET1', 'WIN1', True),
('PTS1', 'WIN1', True),
('DRAW', 'X', True),
('12', 'X', True),
('X1', 'X', True),
('X2', 'X', True),
('DKO', 'X', True),
('WIN2', 'WIN2', True),
('X2', 'WIN2', True),
('K02', 'WIN2', True),
('KO2', 'WIN2', True),
('RET2', 'WIN2', True),
('PTS2', 'WIN2', True),
]
for outcome_name, extraction_result, is_default in default_associations:
conn.execute(text("""
INSERT OR IGNORE INTO extraction_associations
(outcome_name, extraction_result, is_default, created_at, updated_at)
VALUES (:outcome_name, :extraction_result, :is_default, datetime('now'), datetime('now'))
"""), {
'outcome_name': outcome_name,
'extraction_result': extraction_result,
'is_default': is_default
})
# Insert default game config
conn.execute(text("""
INSERT OR IGNORE INTO game_config
(config_key, config_value, value_type, description, is_system, created_at, updated_at)
VALUES (:config_key, :config_value, :value_type, :description, :is_system, datetime('now'), datetime('now'))
"""), {
'config_key': 'under_over_time_limit',
'config_value': '90',
'value_type': 'int',
'description': 'Time limit in seconds between UNDER and OVER outcomes',
'is_system': False
})
conn.commit()
logger.info("Extraction associations and game config tables created successfully")
return True
except Exception as e:
logger.error(f"Failed to create extraction and game config tables: {e}")
return False
def down(self, db_manager) -> bool:
"""Drop extraction_associations and game_config tables"""
try:
with db_manager.engine.connect() as conn:
# Drop tables in reverse order (if there were foreign keys)
conn.execute(text("DROP TABLE IF EXISTS game_config"))
conn.execute(text("DROP TABLE IF EXISTS extraction_associations"))
conn.commit()
logger.info("Extraction associations and game config tables dropped")
return True
except Exception as e:
logger.error(f"Failed to drop extraction and game config tables: {e}")
return False
# Registry of all migrations in order # Registry of all migrations in order
MIGRATIONS: List[DatabaseMigration] = [ MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(), Migration_001_InitialSchema(),
...@@ -831,6 +996,8 @@ MIGRATIONS: List[DatabaseMigration] = [ ...@@ -831,6 +996,8 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_010_AddRunningFieldToMatches(), Migration_010_AddRunningFieldToMatches(),
Migration_011_AddFixtureActiveTimeToMatches(), Migration_011_AddFixtureActiveTimeToMatches(),
Migration_012_RemoveFixtureIdUniqueConstraint(), Migration_012_RemoveFixtureIdUniqueConstraint(),
Migration_013_AddStatusFieldToMatches(),
Migration_014_AddExtractionAndGameConfigTables(),
] ]
......
...@@ -7,13 +7,23 @@ import hashlib ...@@ -7,13 +7,23 @@ import hashlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from sqlalchemy import ( from sqlalchemy import (
Column, Integer, String, Text, DateTime, Boolean, Float, Column, Integer, String, Text, DateTime, Boolean, Float,
JSON, ForeignKey, UniqueConstraint, Index, create_engine JSON, ForeignKey, UniqueConstraint, Index, create_engine, Enum
) )
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, sessionmaker from sqlalchemy.orm import relationship, sessionmaker
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
# Enum for match status
class MatchStatus(str, Enum):
PENDING = "pending"
SCHEDULED = "scheduled"
BET = "bet"
INGAME = "ingame"
CANCELLED = "cancelled"
FAILED = "failed"
PAUSED = "paused"
Base = declarative_base() Base = declarative_base()
...@@ -472,6 +482,7 @@ class MatchModel(BaseModel): ...@@ -472,6 +482,7 @@ class MatchModel(BaseModel):
result = Column(String(255), comment='Match result/outcome') result = Column(String(255), comment='Match result/outcome')
done = Column(Boolean, default=False, nullable=False, comment='Match completion flag (0=pending, 1=done)') done = Column(Boolean, default=False, nullable=False, comment='Match completion flag (0=pending, 1=done)')
running = Column(Boolean, default=False, nullable=False, comment='Match running flag (0=not running, 1=running)') running = Column(Boolean, default=False, nullable=False, comment='Match running flag (0=not running, 1=running)')
status = Column(Enum('pending', 'scheduled', 'bet', 'ingame', 'cancelled', 'failed', 'paused'), default='pending', nullable=False, comment='Match status enum')
fixture_active_time = Column(Integer, nullable=True, comment='Unix timestamp when fixture became active on server') fixture_active_time = Column(Integer, nullable=True, comment='Unix timestamp when fixture became active on server')
# File metadata # File metadata
...@@ -569,13 +580,81 @@ class MatchOutcomeModel(BaseModel): ...@@ -569,13 +580,81 @@ class MatchOutcomeModel(BaseModel):
Index('ix_match_outcomes_composite', 'match_id', 'column_name'), Index('ix_match_outcomes_composite', 'match_id', 'column_name'),
UniqueConstraint('match_id', 'column_name', name='uq_match_outcomes_match_column'), UniqueConstraint('match_id', 'column_name', name='uq_match_outcomes_match_column'),
) )
match_id = Column(Integer, ForeignKey('matches.id', ondelete='CASCADE'), nullable=False, comment='Foreign key to matches table') match_id = Column(Integer, ForeignKey('matches.id', ondelete='CASCADE'), nullable=False, comment='Foreign key to matches table')
column_name = Column(String(255), nullable=False, comment='Result column name from fixture file') column_name = Column(String(255), nullable=False, comment='Result column name from fixture file')
float_value = Column(Float, nullable=False, comment='Float value with precision') float_value = Column(Float, nullable=False, comment='Float value with precision')
# Relationships # Relationships
match = relationship('MatchModel', back_populates='outcomes') match = relationship('MatchModel', back_populates='outcomes')
def __repr__(self):
return f'<MatchOutcome {self.column_name}={self.float_value} for Match {self.match_id}>'
class ExtractionAssociationModel(BaseModel):
"""Associations between match outcomes and extraction results"""
__tablename__ = 'extraction_associations'
__table_args__ = (
Index('ix_extraction_associations_outcome_name', 'outcome_name'),
Index('ix_extraction_associations_extraction_result', 'extraction_result'),
Index('ix_extraction_associations_composite', 'outcome_name', 'extraction_result'),
UniqueConstraint('outcome_name', 'extraction_result', name='uq_extraction_associations_outcome_result'),
)
outcome_name = Column(String(255), nullable=False, comment='Match outcome name (e.g., WIN1, DRAW, X1, etc.)')
extraction_result = Column(String(50), nullable=False, comment='Extraction result category (WIN1, X, WIN2)')
is_default = Column(Boolean, default=False, nullable=False, comment='Whether this is a default association')
def __repr__(self):
return f'<ExtractionAssociation {self.outcome_name} -> {self.extraction_result}>'
class GameConfigModel(BaseModel):
"""Game configuration settings"""
__tablename__ = 'game_config'
__table_args__ = (
Index('ix_game_config_key', 'config_key'),
UniqueConstraint('config_key', name='uq_game_config_key'),
)
config_key = Column(String(100), nullable=False, unique=True, comment='Configuration key')
config_value = Column(Text, nullable=False, comment='Configuration value as text')
value_type = Column(String(20), default='string', nullable=False, comment='Type of value: string, int, float, bool')
description = Column(String(500), comment='Description of the configuration setting')
is_system = Column(Boolean, default=False, nullable=False, comment='Whether this is a system setting')
def get_typed_value(self) -> Any:
"""Get value converted to proper type"""
if self.value_type == 'int':
try:
return int(self.config_value)
except (ValueError, TypeError):
return 0
elif self.value_type == 'float':
try:
return float(self.config_value)
except (ValueError, TypeError):
return 0.0
elif self.value_type == 'bool':
return self.config_value.lower() in ('true', '1', 'yes', 'on')
else:
return self.config_value
def set_typed_value(self, value: Any):
"""Set value with automatic type detection"""
if isinstance(value, bool):
self.config_value = str(value).lower()
self.value_type = 'bool'
elif isinstance(value, int):
self.config_value = str(value)
self.value_type = 'int'
elif isinstance(value, float):
self.config_value = str(value)
self.value_type = 'float'
else:
self.config_value = str(value)
self.value_type = 'string'
def __repr__(self): def __repr__(self):
return f'<MatchOutcome {self.column_name}={self.float_value} for Match {self.match_id}>' return f'<GameConfig {self.config_key}={self.config_value}>'
\ No newline at end of file \ No newline at end of file
...@@ -267,10 +267,10 @@ def fixtures(): ...@@ -267,10 +267,10 @@ def fixtures():
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
@main_bp.route('/fixtures/<int:match_id>') @main_bp.route('/fixtures/<fixture_id>')
@login_required @login_required
def fixture_details(match_id): def fixture_details(fixture_id):
"""Fixture details page showing match and outcomes""" """Fixture details page showing all matches in the fixture"""
try: try:
# Restrict cashier users from accessing fixture details page # Restrict cashier users from accessing fixture details page
if hasattr(current_user, 'role') and current_user.role == 'cashier': if hasattr(current_user, 'role') and current_user.role == 'cashier':
...@@ -279,17 +279,41 @@ def fixture_details(match_id): ...@@ -279,17 +279,41 @@ def fixture_details(match_id):
elif hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user(): elif hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user():
flash("Access denied", "error") flash("Access denied", "error")
return redirect(url_for('main.cashier_dashboard')) return redirect(url_for('main.cashier_dashboard'))
return render_template('dashboard/fixture_details.html', return render_template('dashboard/fixture_details.html',
user=current_user, user=current_user,
match_id=match_id, fixture_id=fixture_id,
page_title=f"Fixture Details - Match #{match_id}") page_title=f"Fixture Details - Fixture #{fixture_id}")
except Exception as e: except Exception as e:
logger.error(f"Fixture details page error: {e}") logger.error(f"Fixture details page error: {e}")
flash("Error loading fixture details", "error") flash("Error loading fixture details", "error")
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
@main_bp.route('/matches/<int:match_id>/<fixture_id>')
@login_required
def match_details(match_id, fixture_id):
"""Match details page showing match information and outcomes"""
try:
# Restrict cashier users from accessing match details page
if hasattr(current_user, 'role') and current_user.role == 'cashier':
flash("Access denied", "error")
return redirect(url_for('main.cashier_dashboard'))
elif hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user():
flash("Access denied", "error")
return redirect(url_for('main.cashier_dashboard'))
return render_template('dashboard/match_details.html',
user=current_user,
match_id=match_id,
fixture_id=fixture_id,
page_title=f"Match Details - Match #{match_id}")
except Exception as e:
logger.error(f"Match details page error: {e}")
flash("Error loading match details", "error")
return render_template('errors/500.html'), 500
@main_bp.route('/logs') @main_bp.route('/logs')
@login_required @login_required
def logs(): def logs():
...@@ -303,16 +327,38 @@ def logs(): ...@@ -303,16 +327,38 @@ def logs():
elif hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user(): elif hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user():
return redirect(url_for('main.cashier_dashboard')) return redirect(url_for('main.cashier_dashboard'))
return redirect(url_for('main.index')) return redirect(url_for('main.index'))
return render_template('dashboard/logs.html', return render_template('dashboard/logs.html',
user=current_user, user=current_user,
page_title="Application Logs") page_title="Application Logs")
except Exception as e: except Exception as e:
logger.error(f"Logs page error: {e}") logger.error(f"Logs page error: {e}")
flash("Error loading logs", "error") flash("Error loading logs", "error")
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
@main_bp.route('/extraction')
@login_required
def extraction():
"""Extraction management page"""
try:
# Restrict cashier users from accessing extraction page
if hasattr(current_user, 'role') and current_user.role == 'cashier':
flash("Access denied", "error")
return redirect(url_for('main.cashier_dashboard'))
elif hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user():
flash("Access denied", "error")
return redirect(url_for('main.cashier_dashboard'))
return render_template('dashboard/extraction.html',
user=current_user,
page_title="Extraction Management")
except Exception as e:
logger.error(f"Extraction page error: {e}")
flash("Error loading extraction page", "error")
return render_template('errors/500.html'), 500
# Auth routes # Auth routes
@auth_bp.route('/login', methods=['GET', 'POST']) @auth_bp.route('/login', methods=['GET', 'POST'])
def login(): def login():
...@@ -593,6 +639,78 @@ def update_configuration(): ...@@ -593,6 +639,78 @@ def update_configuration():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@api_bp.route('/config/match-interval')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_match_interval():
"""Get match interval configuration"""
try:
if api_bp.config_manager:
general_config = api_bp.config_manager.get_section_config("general") or {}
match_interval = general_config.get('match_interval', 20) # Default 20 minutes
else:
match_interval = 20 # Default fallback
return jsonify({
"success": True,
"match_interval": match_interval
})
except Exception as e:
logger.error(f"API get match interval error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/config/match-interval', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def set_match_interval():
"""Set match interval configuration"""
try:
data = request.get_json() or {}
match_interval = data.get('match_interval')
if match_interval is None:
return jsonify({"error": "match_interval is required"}), 400
# Validate range
if not isinstance(match_interval, int) or match_interval < 1 or match_interval > 60:
return jsonify({"error": "match_interval must be an integer between 1 and 60"}), 400
if api_bp.config_manager:
# Update configuration
result = api_bp.config_manager.update_section("general", {
"match_interval": match_interval
})
if result:
logger.info(f"Match interval updated to {match_interval} minutes")
# Send configuration update message to message bus
try:
from ..core.message_bus import MessageBuilder, MessageType
if api_bp.message_bus:
config_update_message = MessageBuilder.config_update(
sender="web_dashboard",
config_section="general",
config_data={"match_interval": match_interval}
)
api_bp.message_bus.publish(config_update_message)
logger.info("Match interval configuration update message sent to message bus")
except Exception as msg_e:
logger.warning(f"Failed to send match interval config update message: {msg_e}")
return jsonify({
"success": True,
"message": f"Match interval set to {match_interval} minutes"
})
else:
return jsonify({"error": "Failed to update configuration"}), 500
else:
return jsonify({"error": "Configuration manager not available"}), 500
except Exception as e:
logger.error(f"API set match interval error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/config/test-connection', methods=['POST']) @api_bp.route('/config/test-connection', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required @api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
@api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required @api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
...@@ -1034,61 +1152,304 @@ def delete_template(template_name): ...@@ -1034,61 +1152,304 @@ def delete_template(template_name):
@api_bp.route('/fixtures') @api_bp.route('/fixtures')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required @api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_fixtures(): def get_fixtures():
"""Get all fixtures/matches""" """Get all fixtures/matches grouped by fixture_id with calculated status"""
try: try:
from ..database.models import MatchModel from ..database.models import MatchModel
from datetime import datetime, date
from collections import defaultdict
session = api_bp.db_manager.get_session() session = api_bp.db_manager.get_session()
try: try:
matches = session.query(MatchModel).order_by(MatchModel.created_at.desc()).all() matches = session.query(MatchModel).order_by(MatchModel.created_at.desc()).all()
fixtures_data = []
# Group matches by fixture_id
fixtures_by_id = defaultdict(list)
for match in matches: for match in matches:
match_data = match.to_dict() fixtures_by_id[match.fixture_id].append(match)
fixtures_data.append(match_data)
fixtures_data = []
today = date.today()
for fixture_id, fixture_matches in fixtures_by_id.items():
# Sort matches by match_number for consistent ordering
fixture_matches.sort(key=lambda m: m.match_number)
# Calculate fixture-level status
fixture_status = calculate_fixture_status(fixture_matches, today)
# Use the first match as the representative for the fixture
first_match = fixture_matches[0]
# Create fixture data structure
fixture_data = {
'id': first_match.id,
'fixture_id': fixture_id,
'match_number': first_match.match_number,
'fighter1_township': first_match.fighter1_township,
'fighter2_township': first_match.fighter2_township,
'venue_kampala_township': first_match.venue_kampala_township,
'start_time': first_match.start_time.isoformat() if first_match.start_time else None,
'created_at': first_match.created_at.isoformat(),
'fixture_status': fixture_status,
'match_count': len(fixture_matches),
'matches': [match.to_dict() for match in fixture_matches]
}
fixtures_data.append(fixture_data)
# Sort fixtures by creation date (most recent first)
fixtures_data.sort(key=lambda f: f['created_at'], reverse=True)
return jsonify({ return jsonify({
"success": True, "success": True,
"fixtures": fixtures_data, "fixtures": fixtures_data,
"total": len(fixtures_data) "total": len(fixtures_data)
}) })
finally: finally:
session.close() session.close()
except Exception as e: except Exception as e:
logger.error(f"API get fixtures error: {e}") logger.error(f"API get fixtures error: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@api_bp.route('/fixtures/<int:match_id>') def calculate_fixture_status(matches, today):
"""
Calculate fixture status based on match statuses and timing.
Args:
matches: List of MatchModel objects for this fixture
today: Today's date
Returns:
str: Fixture status ('pending', 'running', 'scheduled', 'bet', 'ingame', 'end')
"""
if not matches:
return 'pending'
# Get all match statuses
match_statuses = [match.status for match in matches]
# Check if all matches are pending
if all(status == 'pending' for status in match_statuses):
return 'pending'
# Check if start time of first match is today and at least one match is pending
first_match = matches[0]
if (first_match.start_time and
first_match.start_time.date() == today and
any(status == 'pending' for status in match_statuses)):
return 'running'
# Otherwise, determine status based on the most advanced match status
status_priority = {
'pending': 0,
'scheduled': 1,
'bet': 2,
'ingame': 3,
'end': 4,
'cancelled': 5,
'failed': 5,
'paused': 5
}
# Find the highest priority status
highest_status = max(match_statuses, key=lambda s: status_priority.get(s, 0))
# Map to fixture status
if highest_status in ['cancelled', 'failed', 'paused']:
return 'end' # Treat cancelled/failed/paused as end state
else:
return highest_status
@api_bp.route('/cashier/pending-matches')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_cashier_pending_matches():
"""Get pending matches from the first fixture for cashier dashboard"""
try:
from ..database.models import MatchModel
from datetime import datetime, date
session = api_bp.db_manager.get_session()
try:
# Get today's date
today = date.today()
# First, try to find fixtures where the first match start_time is today
fixtures_with_today_start = session.query(MatchModel.fixture_id)\
.filter(MatchModel.start_time.isnot(None))\
.filter(MatchModel.start_time >= datetime.combine(today, datetime.min.time()))\
.filter(MatchModel.start_time < datetime.combine(today, datetime.max.time()))\
.order_by(MatchModel.created_at.asc())\
.first()
selected_fixture_id = None
if fixtures_with_today_start:
# Use fixture where first match starts today
selected_fixture_id = fixtures_with_today_start.fixture_id
logger.info(f"Selected fixture {selected_fixture_id} - has matches starting today")
else:
# Fallback: find fixtures where all matches are in pending status
all_fixtures = session.query(MatchModel.fixture_id).distinct().all()
for fixture_row in all_fixtures:
fixture_id = fixture_row.fixture_id
# Check if all matches in this fixture are pending
fixture_matches = session.query(MatchModel).filter_by(fixture_id=fixture_id).all()
if fixture_matches and all(match.status == 'pending' for match in fixture_matches):
selected_fixture_id = fixture_id
logger.info(f"Selected fixture {selected_fixture_id} - all matches are pending")
break
# If no fixture with all pending matches found, use the first fixture by creation date
if not selected_fixture_id:
first_fixture = session.query(MatchModel.fixture_id)\
.order_by(MatchModel.created_at.asc())\
.first()
if first_fixture:
selected_fixture_id = first_fixture.fixture_id
logger.info(f"Selected first fixture {selected_fixture_id} - fallback")
if not selected_fixture_id:
return jsonify({
"success": True,
"matches": [],
"total": 0,
"fixture_id": None
})
# Get all matches from the selected fixture (not just pending ones)
fixture_matches = session.query(MatchModel)\
.filter_by(fixture_id=selected_fixture_id)\
.order_by(MatchModel.match_number.asc())\
.all()
matches_data = []
for match in fixture_matches:
match_data = match.to_dict()
matches_data.append(match_data)
return jsonify({
"success": True,
"matches": matches_data,
"total": len(matches_data),
"fixture_id": selected_fixture_id
})
finally:
session.close()
except Exception as e:
logger.error(f"API get cashier pending matches error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/cashier/start-games', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required @api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_fixture_details(match_id): def start_games():
"""Get fixture details by match ID""" """Start games for the first fixture - send START_GAME message to message bus"""
try:
from ..core.message_bus import MessageBuilder, MessageType
# Send START_GAME message to the message bus
# The games thread will handle finding and activating the appropriate matches
start_game_message = MessageBuilder.start_game(
sender="web_dashboard",
fixture_id=None # Let the games thread find the first fixture with pending matches
)
# Publish the message to the message bus
if api_bp.message_bus:
success = api_bp.message_bus.publish(start_game_message)
if success:
logger.info("START_GAME message sent to message bus")
return jsonify({
"success": True,
"message": "Start games request sent to games thread",
"fixture_id": None # Will be determined by games thread
})
else:
logger.error("Failed to publish START_GAME message to message bus")
return jsonify({
"success": False,
"error": "Failed to send start games request"
}), 500
else:
logger.error("Message bus not available")
return jsonify({
"success": False,
"error": "Message bus not available"
}), 500
except Exception as e:
logger.error(f"API start games error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/fixtures/<fixture_id>')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_fixture_details(fixture_id):
"""Get all matches in a fixture by fixture_id"""
try: try:
from ..database.models import MatchModel, MatchOutcomeModel from ..database.models import MatchModel, MatchOutcomeModel
from datetime import datetime, date
session = api_bp.db_manager.get_session() session = api_bp.db_manager.get_session()
try: try:
match = session.query(MatchModel).filter_by(id=match_id).first() # Get all matches for this fixture
matches = session.query(MatchModel).filter_by(fixture_id=fixture_id).order_by(MatchModel.match_number.asc()).all()
if not match:
return jsonify({"error": "Match not found"}), 404 if not matches:
return jsonify({"error": "Fixture not found"}), 404
match_data = match.to_dict()
# Calculate fixture-level status
# Get outcomes today = date.today()
outcomes = session.query(MatchOutcomeModel).filter_by(match_id=match_id).all() fixture_status = calculate_fixture_status(matches, today)
match_data['outcomes'] = [outcome.to_dict() for outcome in outcomes]
# Get fixture data from first match
first_match = matches[0]
fixture_data = {
'id': first_match.id,
'fixture_id': fixture_id,
'match_number': first_match.match_number,
'fighter1_township': first_match.fighter1_township,
'fighter2_township': first_match.fighter2_township,
'venue_kampala_township': first_match.venue_kampala_township,
'start_time': first_match.start_time.isoformat() if first_match.start_time else None,
'created_at': first_match.created_at.isoformat(),
'fixture_status': fixture_status,
'match_count': len(matches)
}
# Get all matches with their outcomes
matches_data = []
for match in matches:
match_data = match.to_dict()
# Get outcomes for this match
outcomes = session.query(MatchOutcomeModel).filter_by(match_id=match.id).all()
match_data['outcomes'] = [outcome.to_dict() for outcome in outcomes]
match_data['outcome_count'] = len(outcomes)
matches_data.append(match_data)
return jsonify({ return jsonify({
"success": True, "success": True,
"match": match_data "fixture": fixture_data,
"matches": matches_data,
"total_matches": len(matches_data)
}) })
finally: finally:
session.close() session.close()
except Exception as e: except Exception as e:
logger.error(f"API get fixture details error: {e}") logger.error(f"API get fixture details error: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
...@@ -1219,19 +1580,19 @@ def trigger_api_request(): ...@@ -1219,19 +1580,19 @@ def trigger_api_request():
try: try:
data = request.get_json() or {} data = request.get_json() or {}
endpoint_name = data.get('endpoint', 'fastapi_main') endpoint_name = data.get('endpoint', 'fastapi_main')
# Send manual API request message # Send manual API request message
if api_bp.message_bus: if api_bp.message_bus:
from ..core.message_bus import MessageBuilder, MessageType from ..core.message_bus import MessageBuilder, MessageType
api_request_message = MessageBuilder.api_request( api_request_message = MessageBuilder.api_request(
sender="web_dashboard", sender="web_dashboard",
endpoint=endpoint_name endpoint=endpoint_name
) )
api_bp.message_bus.publish(api_request_message) api_bp.message_bus.publish(api_request_message)
logger.info(f"Manual API request triggered for endpoint: {endpoint_name}") logger.info(f"Manual API request triggered for endpoint: {endpoint_name}")
return jsonify({ return jsonify({
"success": True, "success": True,
"message": f"API request triggered for endpoint: {endpoint_name}" "message": f"API request triggered for endpoint: {endpoint_name}"
...@@ -1241,7 +1602,218 @@ def trigger_api_request(): ...@@ -1241,7 +1602,218 @@ def trigger_api_request():
"success": False, "success": False,
"error": "Message bus not available" "error": "Message bus not available"
}), 500 }), 500
except Exception as e: except Exception as e:
logger.error(f"API request trigger error: {e}") logger.error(f"API request trigger error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/server-time')
def get_server_time():
"""Get current server time"""
try:
from datetime import datetime
server_time = datetime.now()
return jsonify({
"success": True,
"server_time": server_time.isoformat(),
"timestamp": int(server_time.timestamp() * 1000) # milliseconds since epoch
})
except Exception as e:
logger.error(f"Server time API error: {e}")
return jsonify({"error": str(e)}), 500
# Extraction API routes
@api_bp.route('/extraction/outcomes')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_extraction_outcomes():
"""Get all available outcomes for extraction"""
try:
from ..database.models import MatchOutcomeModel
session = api_bp.db_manager.get_session()
try:
# Get distinct outcome names from match outcomes
outcomes_query = session.query(MatchOutcomeModel.column_name).distinct()
outcomes = [row[0] for row in outcomes_query.all()]
# Add UNDER and OVER outcomes if not present
if 'UNDER' not in outcomes:
outcomes.append('UNDER')
if 'OVER' not in outcomes:
outcomes.append('OVER')
# Sort outcomes for consistent display
outcomes.sort()
return jsonify({
"success": True,
"outcomes": outcomes,
"total": len(outcomes)
})
finally:
session.close()
except Exception as e:
logger.error(f"API get extraction outcomes error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/extraction/associations')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_extraction_associations():
"""Get current extraction associations"""
try:
from ..database.models import ExtractionAssociationModel
session = api_bp.db_manager.get_session()
try:
associations = session.query(ExtractionAssociationModel).all()
associations_data = [assoc.to_dict() for assoc in associations]
return jsonify({
"success": True,
"associations": associations_data,
"total": len(associations_data)
})
finally:
session.close()
except Exception as e:
logger.error(f"API get extraction associations error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/extraction/associations', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def save_extraction_associations():
"""Save extraction associations"""
try:
from ..database.models import ExtractionAssociationModel
data = request.get_json() or {}
associations_data = data.get('associations', [])
if not associations_data:
return jsonify({"error": "No associations provided"}), 400
session = api_bp.db_manager.get_session()
try:
# Clear existing associations
session.query(ExtractionAssociationModel).delete()
# Add new associations
for assoc_data in associations_data:
association = ExtractionAssociationModel(
outcome_name=assoc_data['outcome_name'],
extraction_result=assoc_data['extraction_result'],
is_default=False # User-created associations are not default
)
session.add(association)
session.commit()
logger.info(f"Saved {len(associations_data)} extraction associations")
return jsonify({
"success": True,
"message": f"Saved {len(associations_data)} associations",
"saved_count": len(associations_data)
})
finally:
session.close()
except Exception as e:
logger.error(f"API save extraction associations error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/extraction/config')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_extraction_config():
"""Get extraction configuration"""
try:
from ..database.models import GameConfigModel
session = api_bp.db_manager.get_session()
try:
# Get all game config entries
configs = session.query(GameConfigModel).all()
config_data = {}
for config in configs:
config_data[config.config_key] = config.get_typed_value()
return jsonify({
"success": True,
"config": config_data,
"total": len(config_data)
})
finally:
session.close()
except Exception as e:
logger.error(f"API get extraction config error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/extraction/config', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def update_extraction_config():
"""Update extraction configuration"""
try:
from ..database.models import GameConfigModel
data = request.get_json() or {}
config_key = data.get('config_key')
config_value = data.get('config_value')
value_type = data.get('value_type', 'string')
if not config_key or config_value is None:
return jsonify({"error": "config_key and config_value are required"}), 400
session = api_bp.db_manager.get_session()
try:
# Check if config exists
existing_config = session.query(GameConfigModel).filter_by(config_key=config_key).first()
if existing_config:
# Update existing config
existing_config.config_value = str(config_value)
existing_config.value_type = value_type
existing_config.updated_at = datetime.utcnow()
else:
# Create new config
new_config = GameConfigModel(
config_key=config_key,
config_value=str(config_value),
value_type=value_type,
description=f"Extraction configuration for {config_key}",
is_system=False
)
session.add(new_config)
session.commit()
logger.info(f"Updated extraction config: {config_key} = {config_value}")
return jsonify({
"success": True,
"message": f"Configuration {config_key} updated successfully",
"config_key": config_key,
"config_value": config_value
})
finally:
session.close()
except Exception as e:
logger.error(f"API update extraction config error: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
\ No newline at end of file
...@@ -10,6 +10,52 @@ ...@@ -10,6 +10,52 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
<style>
.navbar-clock {
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px 16px;
font-family: 'Courier New', monospace;
font-weight: bold;
font-size: 1.2rem;
color: #ffffff;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.navbar-clock:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.navbar-clock i {
color: #ffffff;
filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.5));
}
#clock-time {
font-size: 1.3rem;
letter-spacing: 2px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.navbar-clock {
font-size: 1rem;
padding: 6px 12px;
}
#clock-time {
font-size: 1.1rem;
letter-spacing: 1px;
}
}
</style>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
...@@ -50,6 +96,12 @@ ...@@ -50,6 +96,12 @@
<i class="fas fa-list-ul me-1"></i>Fixtures <i class="fas fa-list-ul me-1"></i>Fixtures
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.extraction' %}active{% endif %}"
href="{{ url_for('main.extraction') }}">
<i class="fas fa-cogs me-1"></i>Extraction
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.video_test' %}active{% endif %}" <a class="nav-link {% if request.endpoint == 'main.video_test' %}active{% endif %}"
href="{{ url_for('main.video_test') }}"> href="{{ url_for('main.video_test') }}">
...@@ -87,7 +139,15 @@ ...@@ -87,7 +139,15 @@
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
<!-- Digital Clock -->
<div class="d-flex align-items-center me-3">
<div id="digital-clock" class="navbar-clock">
<i class="fas fa-clock me-2"></i>
<span id="clock-time">--:--:--</span>
</div>
</div>
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
...@@ -179,7 +239,63 @@ ...@@ -179,7 +239,63 @@
var config = JSON.parse(document.getElementById('dashboard-config').textContent); var config = JSON.parse(document.getElementById('dashboard-config').textContent);
Dashboard.init(config); Dashboard.init(config);
} }
// Initialize digital clock
initializeClock();
}); });
function initializeClock() {
const clockElement = document.getElementById('clock-time');
if (!clockElement) return;
let serverTimeOffset = 0; // Offset between server and client time
let lastServerTime = null;
function fetchServerTime() {
return fetch('/api/server-time')
.then(response => response.json())
.then(data => {
if (data.success) {
const serverTimestamp = data.timestamp;
const clientTimestamp = Date.now();
serverTimeOffset = serverTimestamp - clientTimestamp;
lastServerTime = serverTimestamp;
return serverTimestamp;
} else {
throw new Error('Failed to get server time');
}
})
.catch(error => {
console.error('Error fetching server time:', error);
// Fallback to client time if server time is unavailable
return Date.now();
});
}
function updateClock() {
const now = Date.now() + serverTimeOffset;
const date = new Date(now);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timeString = `${hours}:${minutes}:${seconds}`;
clockElement.textContent = timeString;
}
// Fetch server time initially and set up updates
fetchServerTime().then(() => {
// Update immediately with server time
updateClock();
// Update display every second (using client time + offset)
setInterval(updateClock, 1000);
// Sync with server time every 30 seconds
setInterval(fetchServerTime, 30000);
});
}
</script> </script>
{% endif %} {% endif %}
......
...@@ -9,6 +9,52 @@ ...@@ -9,6 +9,52 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
<style>
.navbar-clock {
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px 16px;
font-family: 'Courier New', monospace;
font-weight: bold;
font-size: 1.2rem;
color: #ffffff;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.navbar-clock:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.navbar-clock i {
color: #ffffff;
filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.5));
}
#clock-time {
font-size: 1.3rem;
letter-spacing: 2px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.navbar-clock {
font-size: 1rem;
padding: 6px 12px;
}
#clock-time {
font-size: 1.1rem;
letter-spacing: 1px;
}
}
</style>
</head> </head>
<body> <body>
<!-- Simplified Navigation Bar for Cashier --> <!-- Simplified Navigation Bar for Cashier -->
...@@ -17,8 +63,15 @@ ...@@ -17,8 +63,15 @@
<a class="navbar-brand" href="#"> <a class="navbar-brand" href="#">
<i class="fas fa-cash-register me-2"></i>{{ app_name }} - Cashier <i class="fas fa-cash-register me-2"></i>{{ app_name }} - Cashier
</a> </a>
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
<!-- Digital Clock -->
<li class="nav-item d-flex align-items-center me-3">
<div id="digital-clock" class="navbar-clock">
<i class="fas fa-clock me-2"></i>
<span id="clock-time">--:--:--</span>
</div>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}"> <a class="nav-link" href="{{ url_for('auth.logout') }}">
<i class="fas fa-sign-out-alt me-1"></i>Logout <i class="fas fa-sign-out-alt me-1"></i>Logout
...@@ -67,12 +120,17 @@ ...@@ -67,12 +120,17 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-4 mb-3">
<button class="btn btn-primary w-100 fw-bold" id="btn-start-games">
<i class="fas fa-gamepad me-2"></i>Start Games
</button>
</div>
<div class="col-md-4 mb-3">
<button class="btn btn-outline-primary w-100" id="btn-play-video"> <button class="btn btn-outline-primary w-100" id="btn-play-video">
<i class="fas fa-play me-2"></i>Start Video Display <i class="fas fa-play me-2"></i>Start Video Display
</button> </button>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-4 mb-3">
<button class="btn btn-outline-success w-100" id="btn-update-overlay"> <button class="btn btn-outline-success w-100" id="btn-update-overlay">
<i class="fas fa-edit me-2"></i>Update Display Overlay <i class="fas fa-edit me-2"></i>Update Display Overlay
</button> </button>
...@@ -83,67 +141,90 @@ ...@@ -83,67 +141,90 @@
</div> </div>
</div> </div>
<!-- Current Display Status --> <!-- Main Content Layout -->
<div class="row"> <div class="row">
<div class="col-md-8"> <!-- Pending Matches from First Fixture - Left Side -->
<div class="card"> <div class="col-lg-9 col-md-8">
<div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
<i class="fas fa-desktop me-2"></i>Current Display Status <i class="fas fa-list me-2"></i>Pending Matches - First Fixture
<span class="badge bg-warning ms-2" id="pending-matches-count">0</span>
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row text-center"> <div id="pending-matches-container">
<div class="col-6"> <div class="text-center text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>Loading pending matches...
</div>
</div>
</div>
</div>
</div>
<!-- Right Column -->
<div class="col-lg-3 col-md-4">
<!-- Current Display Status - Smaller and Compact -->
<div class="card mb-4">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-desktop me-2"></i>Display Status
</h6>
</div>
<div class="card-body p-3">
<div class="d-flex flex-column">
<!-- Video Status -->
<div class="text-center mb-3">
<div class="d-flex flex-column align-items-center"> <div class="d-flex flex-column align-items-center">
<i class="fas fa-video text-primary mb-2" style="font-size: 2rem;"></i> <i class="fas fa-video text-primary mb-1" style="font-size: 1.5rem;"></i>
<h5 class="text-primary mb-1" id="video-status-text">Stopped</h5> <small class="text-primary fw-bold" id="video-status-text">Stopped</small>
<small class="text-muted">Video Status</small> <small class="text-muted">Video Status</small>
</div> </div>
</div> </div>
<div class="col-6">
<!-- Overlay Status -->
<div class="text-center">
<div class="d-flex flex-column align-items-center"> <div class="d-flex flex-column align-items-center">
<i class="fas fa-layer-group text-success mb-2" style="font-size: 2rem;"></i> <i class="fas fa-layer-group text-success mb-1" style="font-size: 1.5rem;"></i>
<h5 class="text-success mb-1" id="overlay-status-text">Ready</h5> <small class="text-success fw-bold" id="overlay-status-text">Ready</small>
<small class="text-muted">Overlay Status</small> <small class="text-muted">Overlay Status</small>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4"> <div class="small">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between mb-1">
<strong>Current Video:</strong> <span class="text-muted">Video:</span>
<span id="current-video-path" class="text-muted">No video loaded</span> <small id="current-video-path" class="text-truncate" style="max-width: 120px;">No video loaded</small>
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between">
<strong>Current Template:</strong> <span class="text-muted">Template:</span>
<span id="current-template-name" class="text-muted">default</span> <small id="current-template-name">default</small>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Session Information -->
<div class="col-md-4">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0"> <h6 class="card-title mb-0">
<i class="fas fa-user me-2"></i>Session Information <i class="fas fa-user me-2"></i>Session Info
</h5> </h6>
</div> </div>
<div class="card-body"> <div class="card-body p-3">
<dl class="mb-0"> <dl class="mb-0 small">
<dt class="text-muted">User</dt> <dt class="text-muted">User</dt>
<dd>{{ current_user.username }}</dd> <dd>{{ current_user.username }}</dd>
<dt class="text-muted">Role</dt> <dt class="text-muted">Role</dt>
<dd> <dd>
<span class="badge bg-info">Cashier</span> <span class="badge bg-info">Cashier</span>
</dd> </dd>
<dt class="text-muted">Login Time</dt> <dt class="text-muted">Login Time</dt>
<dd id="login-time">{{ current_user.last_login.strftime('%H:%M') if current_user.last_login else 'Just now' }}</dd> <dd id="login-time">{{ current_user.last_login.strftime('%H:%M') if current_user.last_login else 'Just now' }}</dd>
<dt class="text-muted">System Status</dt> <dt class="text-muted">System Status</dt>
<dd> <dd>
<span class="badge bg-success" id="system-status">Online</span> <span class="badge bg-success" id="system-status">Online</span>
...@@ -151,22 +232,23 @@ ...@@ -151,22 +232,23 @@
</dl> </dl>
</div> </div>
</div> </div>
<!-- Today's Activity -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0"> <h6 class="card-title mb-0">
<i class="fas fa-chart-bar me-2"></i>Today's Activity <i class="fas fa-chart-bar me-2"></i>Today's Activity
</h5> </h6>
</div> </div>
<div class="card-body text-center"> <div class="card-body p-3 text-center">
<div class="row"> <div class="row">
<div class="col-12 mb-3"> <div class="col-6">
<h4 class="text-primary" id="videos-played">0</h4> <h5 class="text-primary mb-1" id="videos-played">0</h5>
<small class="text-muted">Videos Played</small> <small class="text-muted">Videos</small>
</div> </div>
<div class="col-12"> <div class="col-6">
<h4 class="text-success" id="overlays-updated">0</h4> <h5 class="text-success mb-1" id="overlays-updated">0</h5>
<small class="text-muted">Overlays Updated</small> <small class="text-muted">Overlays</small>
</div> </div>
</div> </div>
</div> </div>
...@@ -295,7 +377,172 @@ ...@@ -295,7 +377,172 @@
var config = JSON.parse(document.getElementById('dashboard-config').textContent); var config = JSON.parse(document.getElementById('dashboard-config').textContent);
Dashboard.init(config); Dashboard.init(config);
} }
// Initialize digital clock
initializeClock();
// Load pending matches for cashier dashboard
loadPendingMatches();
}); });
function initializeClock() {
const clockElement = document.getElementById('clock-time');
if (!clockElement) return;
let serverTimeOffset = 0; // Offset between server and client time
let lastServerTime = null;
function fetchServerTime() {
return fetch('/api/server-time')
.then(response => response.json())
.then(data => {
if (data.success) {
const serverTimestamp = data.timestamp;
const clientTimestamp = Date.now();
serverTimeOffset = serverTimestamp - clientTimestamp;
lastServerTime = serverTimestamp;
return serverTimestamp;
} else {
throw new Error('Failed to get server time');
}
})
.catch(error => {
console.error('Error fetching server time:', error);
// Fallback to client time if server time is unavailable
return Date.now();
});
}
function updateClock() {
const now = Date.now() + serverTimeOffset;
const date = new Date(now);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timeString = `${hours}:${minutes}:${seconds}`;
clockElement.textContent = timeString;
}
// Fetch server time initially and set up updates
fetchServerTime().then(() => {
// Update immediately with server time
updateClock();
// Update display every second (using client time + offset)
setInterval(updateClock, 1000);
// Sync with server time every 30 seconds
setInterval(fetchServerTime, 30000);
});
}
// Function to load and display pending matches
function loadPendingMatches() {
fetch('/api/cashier/pending-matches')
.then(response => response.json())
.then(data => {
const container = document.getElementById('pending-matches-container');
const countBadge = document.getElementById('pending-matches-count');
if (data.success) {
// Update count badge
countBadge.textContent = data.total;
countBadge.className = data.total > 0 ? 'badge bg-warning ms-2' : 'badge bg-success ms-2';
if (data.total === 0) {
container.innerHTML = `
<div class="text-center text-muted">
<i class="fas fa-check-circle me-2"></i>No pending matches found
</div>
`;
} else {
// Create matches table
let tableHtml = `
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th><i class="fas fa-hashtag me-1"></i>Match #</th>
<th><i class="fas fa-user-friends me-1"></i>Fighter 1</th>
<th><i class="fas fa-user-friends me-1"></i>Fighter 2</th>
<th><i class="fas fa-map-marker-alt me-1"></i>Venue</th>
<th><i class="fas fa-clock me-1"></i>Start Time</th>
<th><i class="fas fa-info-circle me-1"></i>Status</th>
</tr>
</thead>
<tbody>
`;
data.matches.forEach(match => {
const startTime = match.start_time ?
new Date(match.start_time).toLocaleString() : 'Not scheduled';
// Get status from the match object (new status column)
const status = match.status || 'pending';
let statusBadge = '';
switch (status) {
case 'scheduled':
statusBadge = '<span class="badge bg-primary"><i class="fas fa-calendar-check me-1"></i>Scheduled</span>';
break;
case 'ingame':
statusBadge = '<span class="badge bg-info"><i class="fas fa-play me-1"></i>In Game</span>';
break;
case 'completed':
statusBadge = '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>';
break;
case 'cancelled':
statusBadge = '<span class="badge bg-secondary"><i class="fas fa-times me-1"></i>Cancelled</span>';
break;
case 'failed':
statusBadge = '<span class="badge bg-danger"><i class="fas fa-exclamation-triangle me-1"></i>Failed</span>';
break;
case 'paused':
statusBadge = '<span class="badge bg-warning"><i class="fas fa-pause me-1"></i>Paused</span>';
break;
default:
statusBadge = '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>';
}
tableHtml += `
<tr>
<td><strong>${match.match_number}</strong></td>
<td>${match.fighter1_township}</td>
<td>${match.fighter2_township}</td>
<td>${match.venue_kampala_township}</td>
<td>${startTime}</td>
<td>${statusBadge}</td>
</tr>
`;
});
tableHtml += `
</tbody>
</table>
</div>
`;
container.innerHTML = tableHtml;
}
} else {
container.innerHTML = `
<div class="text-center text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Error loading matches: ${data.error || 'Unknown error'}
</div>
`;
}
})
.catch(error => {
const container = document.getElementById('pending-matches-container');
container.innerHTML = `
<div class="text-center text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Error loading matches: ${error.message}
</div>
`;
});
}
</script> </script>
<script> <script>
...@@ -304,10 +551,40 @@ ...@@ -304,10 +551,40 @@
loadAvailableTemplates(); loadAvailableTemplates();
// Quick action buttons // Quick action buttons
document.getElementById('btn-start-games').addEventListener('click', function() {
// Show confirmation dialog for starting games
if (confirm('Are you sure you want to start the games? This will activate all pending matches.')) {
fetch('/api/cashier/start-games', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'start_games'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Games started successfully!');
// Refresh the pending matches list
loadPendingMatches();
// Show success message
showNotification('Games started successfully!', 'success');
} else {
alert('Failed to start games: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error starting games: ' + error.message);
});
}
});
document.getElementById('btn-play-video').addEventListener('click', function() { document.getElementById('btn-play-video').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('playVideoModal')).show(); new bootstrap.Modal(document.getElementById('playVideoModal')).show();
}); });
document.getElementById('btn-update-overlay').addEventListener('click', function() { document.getElementById('btn-update-overlay').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('updateOverlayModal')).show(); new bootstrap.Modal(document.getElementById('updateOverlayModal')).show();
}); });
......
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1><i class="fas fa-cogs me-2"></i>Extraction Management</h1>
<p class="mb-0">Configure outcome associations for extraction results and manage game settings.</p>
</div>
<div>
<button type="button" class="btn btn-primary" id="save-associations-btn">
<i class="fas fa-save me-1"></i>Save Associations
</button>
</div>
</div>
<!-- Game Configuration -->
<div class="card mb-4">
<div class="card-header">
<h5><i class="fas fa-cogs me-2"></i>Game Configuration</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label for="time-limit-input" class="form-label">Time Limit Between UNDER and OVER (seconds)</label>
<div class="input-group">
<input type="number" class="form-control" id="time-limit-input" min="1" max="300" value="90">
<button class="btn btn-outline-primary" type="button" id="update-time-limit-btn">
<i class="fas fa-save me-1"></i>Update
</button>
</div>
<small class="form-text text-muted">Default: 90 seconds. Range: 1-300 seconds.</small>
</div>
<div class="col-md-6">
<label for="cap-input" class="form-label">CAP of Redistributed Bets (%)</label>
<div class="input-group">
<input type="number" class="form-control" id="cap-input" min="20" max="90" step="1" value="70">
<button class="btn btn-outline-primary" type="button" id="update-cap-btn">
<i class="fas fa-save me-1"></i>Update
</button>
</div>
<small class="form-text text-muted">Default: 70%. Range: 20-90%.</small>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>UNDER/OVER Outcomes:</strong> These outcomes appear separately above the main extraction area and are not included in the drag-and-drop associations below.
</div>
</div>
</div>
</div>
</div>
<!-- UNDER/OVER Outcomes Display -->
<div class="card mb-4">
<div class="card-header">
<h5><i class="fas fa-layer-group me-2"></i>UNDER/OVER Outcomes</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="outcome-item under-over-item" data-outcome="UNDER">
<span class="badge bg-warning fs-6 p-2">UNDER</span>
</div>
</div>
<div class="col-md-6">
<div class="outcome-item under-over-item" data-outcome="OVER">
<span class="badge bg-warning fs-6 p-2">OVER</span>
</div>
</div>
</div>
</div>
</div>
<!-- Extraction Layout -->
<div class="row">
<!-- Available Outcomes Pool -->
<div class="col-md-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="fas fa-list me-2"></i>Available Outcomes</h5>
<span class="badge bg-secondary" id="available-count">0</span>
</div>
<div class="card-body">
<div id="available-outcomes" class="outcomes-container">
<!-- Available outcomes will be loaded here -->
</div>
<small class="text-muted">Outcomes not associated with all 3 results</small>
</div>
</div>
</div>
<!-- Extraction Result Columns -->
<div class="col-md-8">
<div class="row">
<!-- WIN1 Column -->
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-header text-center bg-primary text-white">
<h5 class="mb-0">
<i class="fas fa-trophy me-2"></i>WIN1
</h5>
</div>
<div class="card-body">
<div id="win1-column" class="extraction-column outcomes-container" data-result="WIN1">
<!-- WIN1 associated outcomes will be loaded here -->
</div>
</div>
</div>
</div>
<!-- X Column -->
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-header text-center bg-info text-white">
<h5 class="mb-0">
<i class="fas fa-balance-scale me-2"></i>X
</h5>
</div>
<div class="card-body">
<div id="x-column" class="extraction-column outcomes-container" data-result="X">
<!-- X associated outcomes will be loaded here -->
</div>
</div>
</div>
</div>
<!-- WIN2 Column -->
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-header text-center bg-success text-white">
<h5 class="mb-0">
<i class="fas fa-medal me-2"></i>WIN2
</h5>
</div>
<div class="card-body">
<div id="win2-column" class="extraction-column outcomes-container" data-result="WIN2">
<!-- WIN2 associated outcomes will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<!-- Trash Bin -->
<div class="row mt-3">
<div class="col-12">
<div class="card">
<div class="card-body text-center">
<div id="trash-bin" class="trash-bin outcomes-container">
<i class="fas fa-trash-alt fa-2x text-danger"></i>
<p class="mb-0 mt-2">Drop outcomes here to remove associations</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Statistics -->
<div class="row mt-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-primary">
<i class="fas fa-list me-2"></i>Total Outcomes
</h5>
<h3 id="total-outcomes" class="text-primary">0</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-info">
<i class="fas fa-link me-2"></i>Associated
</h5>
<h3 id="associated-outcomes" class="text-info">0</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-warning">
<i class="fas fa-clock me-2"></i>Available
</h5>
<h3 id="available-outcomes-count" class="text-warning">0</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-success">
<i class="fas fa-save me-2"></i>Last Saved
</h5>
<h6 id="last-saved" class="text-success">--</h6>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.outcomes-container {
min-height: 200px;
border: 2px dashed #dee2e6;
border-radius: 0.375rem;
padding: 1rem;
background-color: #f8f9fa;
transition: all 0.3s ease;
}
.outcomes-container:hover {
border-color: #adb5bd;
background-color: #e9ecef;
}
.outcome-item {
display: inline-block;
margin: 0.25rem;
cursor: move;
user-select: none;
transition: all 0.2s ease;
}
.outcome-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.outcome-item.dragging {
opacity: 0.5;
transform: rotate(5deg);
}
.extraction-column {
min-height: 300px;
}
.extraction-column .outcome-item {
display: block;
margin-bottom: 0.5rem;
}
.trash-bin {
border: 2px dashed #dc3545;
border-radius: 0.5rem;
padding: 2rem;
background-color: #f8d7da;
transition: all 0.3s ease;
}
.trash-bin:hover {
background-color: #f5c6cb;
border-color: #c82333;
}
.trash-bin.drag-over {
background-color: #dc3545;
border-color: #bd2130;
color: white;
}
.under-over-item {
text-align: center;
pointer-events: none;
}
.under-over-item .badge {
font-size: 1.1em;
padding: 0.5rem 1rem;
}
</style>
<script>
let allOutcomes = [];
let associations = {};
let draggedElement = null;
document.addEventListener('DOMContentLoaded', function() {
loadData();
setupEventListeners();
setupDragAndDrop();
});
function loadData() {
// Load outcomes and associations
Promise.all([
fetch('/api/extraction/outcomes').then(r => r.json()),
fetch('/api/extraction/associations').then(r => r.json()),
fetch('/api/extraction/config').then(r => r.json())
]).then(([outcomesData, associationsData, configData]) => {
if (outcomesData.success) {
allOutcomes = outcomesData.outcomes;
}
if (associationsData.success) {
associations = {};
associationsData.associations.forEach(assoc => {
if (!associations[assoc.extraction_result]) {
associations[assoc.extraction_result] = [];
}
associations[assoc.extraction_result].push(assoc.outcome_name);
});
}
if (configData.success && configData.config) {
document.getElementById('time-limit-input').value = configData.config.under_over_time_limit || 90;
document.getElementById('cap-input').value = configData.config.redistributed_bets_cap || 70;
}
renderOutcomes();
updateStatistics();
}).catch(error => {
console.error('Error loading data:', error);
alert('Failed to load extraction data');
});
}
function setupEventListeners() {
// Save associations button
document.getElementById('save-associations-btn').addEventListener('click', saveAssociations);
// Update time limit button
document.getElementById('update-time-limit-btn').addEventListener('click', updateTimeLimit);
// Update CAP button
document.getElementById('update-cap-btn').addEventListener('click', updateCap);
}
function setupDragAndDrop() {
// Make outcome items draggable
document.addEventListener('dragstart', handleDragStart);
document.addEventListener('dragend', handleDragEnd);
// Setup drop zones
const dropZones = document.querySelectorAll('.outcomes-container');
dropZones.forEach(zone => {
zone.addEventListener('dragover', handleDragOver);
zone.addEventListener('dragleave', handleDragLeave);
zone.addEventListener('drop', handleDrop);
});
}
function handleDragStart(e) {
if (e.target.classList.contains('outcome-item')) {
draggedElement = e.target;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', e.target.outerHTML);
e.dataTransfer.setData('text/plain', e.target.dataset.outcome);
}
}
function handleDragEnd(e) {
if (draggedElement) {
draggedElement.classList.remove('dragging');
draggedElement = null;
}
// Remove drag-over class from all zones
document.querySelectorAll('.outcomes-container').forEach(zone => {
zone.classList.remove('drag-over');
});
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (e.target.classList.contains('outcomes-container') || e.target.closest('.outcomes-container')) {
const container = e.target.closest('.outcomes-container');
container.classList.add('drag-over');
// Special handling for trash bin
if (container.id === 'trash-bin') {
container.classList.add('drag-over');
}
}
}
function handleDragLeave(e) {
if (e.target.classList.contains('outcomes-container') || e.target.closest('.outcomes-container')) {
const container = e.target.closest('.outcomes-container');
container.classList.remove('drag-over');
}
}
function handleDrop(e) {
e.preventDefault();
if (!draggedElement) return;
const dropZone = e.target.closest('.outcomes-container');
if (!dropZone) return;
const outcomeName = draggedElement.dataset.outcome;
const sourceContainer = draggedElement.closest('.outcomes-container');
// Determine source and destination types
const isFromResultColumn = sourceContainer && sourceContainer.classList.contains('extraction-column');
const isFromAvailable = sourceContainer && sourceContainer.id === 'available-outcomes';
const isToResultColumn = dropZone.classList.contains('extraction-column');
const isToAvailable = dropZone.id === 'available-outcomes';
const isToTrash = dropZone.id === 'trash-bin';
if (isToTrash) {
if (isFromResultColumn) {
// Remove from specific result when dragged from result column to trash
const sourceResult = sourceContainer.dataset.result;
removeAssociation(outcomeName, sourceResult);
} else {
// Remove from all associations when dragged from available to trash
removeAssociation(outcomeName);
}
renderOutcomes();
} else if (isToAvailable) {
// Remove all associations when dragged to available outcomes
removeAssociation(outcomeName);
renderOutcomes();
} else if (isToResultColumn) {
const destinationResult = dropZone.dataset.result;
// Always add the association - allow multiple associations
addAssociation(outcomeName, destinationResult);
renderOutcomes();
}
dropZone.classList.remove('drag-over');
updateStatistics();
// Auto-save associations after each drop (no alerts for drag & drop)
saveAssociations(false);
}
function addAssociation(outcomeName, extractionResult) {
// Add to association (allow multiple associations)
if (!associations[extractionResult]) {
associations[extractionResult] = [];
}
// Only add if not already associated with this result
if (!associations[extractionResult].includes(outcomeName)) {
associations[extractionResult].push(outcomeName);
}
}
function removeAssociation(outcomeName, specificResult = null) {
if (specificResult) {
// Remove from specific result only
if (associations[specificResult]) {
associations[specificResult] = associations[specificResult].filter(outcome => outcome !== outcomeName);
}
} else {
// Remove from all associations
Object.keys(associations).forEach(result => {
associations[result] = associations[result].filter(outcome => outcome !== outcomeName);
});
}
}
function renderOutcomes() {
const availableContainer = document.getElementById('available-outcomes');
const win1Container = document.getElementById('win1-column');
const xContainer = document.getElementById('x-column');
const win2Container = document.getElementById('win2-column');
// Clear containers
availableContainer.innerHTML = '';
win1Container.innerHTML = '';
xContainer.innerHTML = '';
win2Container.innerHTML = '';
// Separate UNDER/OVER from regular outcomes
const regularOutcomes = allOutcomes.filter(outcome => !['UNDER', 'OVER'].includes(outcome));
const underOverOutcomes = allOutcomes.filter(outcome => ['UNDER', 'OVER'].includes(outcome));
// Render regular outcomes
regularOutcomes.forEach(outcome => {
const outcomeElement = createOutcomeElement(outcome);
// Count how many results this outcome is associated with
const associatedResults = Object.keys(associations).filter(result =>
associations[result].includes(outcome)
);
// Always show outcome in available outcomes unless associated with ALL 3 results
if (associatedResults.length < 3) {
availableContainer.appendChild(outcomeElement.cloneNode(true));
}
// Show outcome in each associated result column
associatedResults.forEach(result => {
const container = getContainerForResult(result);
if (container) {
container.appendChild(outcomeElement.cloneNode(true));
}
});
});
}
function createOutcomeElement(outcome) {
const element = document.createElement('div');
element.className = 'outcome-item';
element.dataset.outcome = outcome;
element.draggable = true;
// All outcome labels should be red
let badgeClass = 'bg-danger';
element.innerHTML = `<span class="badge ${badgeClass}">${outcome}</span>`;
return element;
}
function getContainerForResult(result) {
switch (result) {
case 'WIN1': return document.getElementById('win1-column');
case 'X': return document.getElementById('x-column');
case 'WIN2': return document.getElementById('win2-column');
default: return null;
}
}
function updateStatistics() {
const totalOutcomes = allOutcomes.filter(outcome => !['UNDER', 'OVER'].includes(outcome)).length;
const associatedCount = Object.values(associations).reduce((sum, outcomes) => sum + outcomes.length, 0);
// Count outcomes that are NOT associated with all 3 results (these appear in available)
const availableCount = allOutcomes.filter(outcome => {
if (['UNDER', 'OVER'].includes(outcome)) return false;
const associatedResults = Object.keys(associations).filter(result =>
associations[result].includes(outcome)
);
return associatedResults.length < 3;
}).length;
document.getElementById('total-outcomes').textContent = totalOutcomes;
document.getElementById('associated-outcomes').textContent = associatedCount;
document.getElementById('available-outcomes-count').textContent = availableCount;
document.getElementById('available-count').textContent = availableCount;
}
function saveAssociations(showAlerts = true) {
const saveBtn = document.getElementById('save-associations-btn');
const originalText = saveBtn.innerHTML;
if (showAlerts) {
saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Saving...';
}
// Convert associations to API format
const associationsData = [];
Object.keys(associations).forEach(result => {
associations[result].forEach(outcome => {
associationsData.push({
outcome_name: outcome,
extraction_result: result
});
});
});
fetch('/api/extraction/associations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ associations: associationsData })
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('last-saved').textContent = new Date().toLocaleTimeString();
if (showAlerts) {
alert('Associations saved successfully!');
}
} else {
if (showAlerts) {
alert('Error saving associations: ' + (data.error || 'Unknown error'));
}
}
})
.catch(error => {
console.error('Error:', error);
if (showAlerts) {
alert('Failed to save associations: ' + error.message);
}
})
.finally(() => {
if (showAlerts) {
saveBtn.disabled = false;
saveBtn.innerHTML = originalText;
}
});
}
function updateTimeLimit() {
const timeLimitInput = document.getElementById('time-limit-input');
const timeLimit = parseInt(timeLimitInput.value);
if (timeLimit < 1 || timeLimit > 300) {
alert('Time limit must be between 1 and 300 seconds');
return;
}
const updateBtn = document.getElementById('update-time-limit-btn');
const originalText = updateBtn.innerHTML;
updateBtn.disabled = true;
updateBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Updating...';
fetch('/api/extraction/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_key: 'under_over_time_limit',
config_value: timeLimit.toString(),
value_type: 'int'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Time limit updated successfully!');
} else {
alert('Error updating time limit: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to update time limit: ' + error.message);
})
.finally(() => {
updateBtn.disabled = false;
updateBtn.innerHTML = originalText;
});
}
function updateCap() {
const capInput = document.getElementById('cap-input');
const cap = parseInt(capInput.value);
if (cap < 20 || cap > 90) {
alert('CAP must be between 20 and 90 percent');
return;
}
const updateBtn = document.getElementById('update-cap-btn');
const originalText = updateBtn.innerHTML;
updateBtn.disabled = true;
updateBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Updating...';
fetch('/api/extraction/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_key: 'redistributed_bets_cap',
config_value: cap.toString(),
value_type: 'int'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('CAP updated successfully!');
} else {
alert('Error updating CAP: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to update CAP: ' + error.message);
})
.finally(() => {
updateBtn.disabled = false;
updateBtn.innerHTML = originalText;
});
}
</script>
{% endblock %}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment