Add matches and match_outcomes database tables with cross-platform PyInstaller persistence (v1.2.3)

- Added MatchModel and MatchOutcomeModel SQLAlchemy models adapted from mbetterd MySQL schema to SQLite
- Created Migration_008_AddMatchTables with comprehensive indexing and foreign key constraints
- Enhanced cross-platform directory handling for Windows (%APPDATA%), macOS (~/Library/Application Support), and Linux (~/.local/share)
- Implemented persistent user data/config directories for PyInstaller executable compatibility
- Added comprehensive test suite (test_persistent_dirs.py) for cross-platform directory functionality
- Updated settings.py with robust error handling and fallback mechanisms for directory creation
- Modified application.py and main.py to use consistent persistent directory approach
- Updated documentation (README.md, CHANGELOG.md, DOCUMENTATION.md) with new features and cross-platform persistence details
- Cleaned up test files and upload artifacts
parent c344a65f
Collecting loguru
Downloading loguru-0.7.3-py3-none-any.whl.metadata (22 kB)
Downloading loguru-0.7.3-py3-none-any.whl (61 kB)
Installing collected packages: loguru
Successfully installed loguru-0.7.3
...@@ -2,6 +2,44 @@ ...@@ -2,6 +2,44 @@
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.3] - 2025-08-21
### Added
- **Boxing Match Database Tables**: Complete `matches` and `match_outcomes` database tables adapted from mbetterd MySQL schema to SQLite
- **Cross-Platform Persistent Storage**: Comprehensive PyInstaller executable persistence with platform-specific user directories
- **Match Data Models**: Full SQLAlchemy models for boxing match management including:
- Match tracking with fighter townships, venues, and timing information
- Match outcomes with float values and column-based storage
- ZIP file upload management with progress tracking and status monitoring
- User association and creation tracking
- **Database Migration System**: Migration_008_AddMatchTables with comprehensive indexing and foreign key relationships
- **Platform-Specific Directory Handling**:
- Windows: `%APPDATA%\MbetterClient\` for all user data
- macOS: `~/Library/Application Support/MbetterClient/` (unified location)
- Linux: `~/.local/share/MbetterClient/` (data) & `~/.config/MbetterClient/` (config)
- **Cross-Platform Test Suite**: Comprehensive test script verifying directory creation and database persistence
- **Enhanced Error Handling**: Fallback directory creation with permission testing and write verification
### Fixed
- **PyInstaller Compatibility**: Database and user data now persist correctly when running as single executable
- **Path Resolution**: All paths now resolve to persistent user directories instead of temporary executable locations
- **Directory Creation**: Robust cross-platform directory creation with proper error handling and fallbacks
- **Database Location**: SQLite database now stored in persistent user data directory across all platforms
### Enhanced
- **User Data Management**: Automatic creation of logs/, data/, uploads/, and templates/ subdirectories
- **Permission Validation**: Write permission testing before using directories with graceful fallbacks
- **Environment Detection**: Automatic PyInstaller execution detection for appropriate path handling
- **Database Schema**: Added comprehensive indexing for optimal query performance on match data
### Technical Details
- Implemented `get_user_data_dir()`, `get_user_config_dir()`, and `get_user_cache_dir()` functions
- Added `is_pyinstaller_executable()` detection for runtime environment awareness
- Enhanced `DatabaseConfig` class to automatically use persistent user data directory
- Created `Migration_008_AddMatchTables` with proper SQLite syntax and comprehensive indexing
- Updated application initialization to call `ensure_directories()` for persistent storage setup
- Added cross-platform test script `test_persistent_dirs.py` for validation
## [1.2.2] - 2025-08-21 ## [1.2.2] - 2025-08-21
### Added ### Added
......
...@@ -47,19 +47,42 @@ ...@@ -47,19 +47,42 @@
### Directory Structure After Installation ### Directory Structure After Installation
#### Persistent User Directories (PyInstaller Compatible)
The application automatically creates platform-appropriate persistent directories:
**Linux**:
```
~/.local/share/MbetterClient/ # Data directory
├── mbetterclient.db # SQLite database with matches & outcomes
├── logs/
│ ├── mbetterclient.log # Application logs
│ └── error.log # Error logs
├── data/ # Application data
└── uploads/ # File uploads
~/.config/MbetterClient/ # Config directory
└── templates/ # User uploaded overlay templates
```
**Windows**:
```
%APPDATA%\MbetterClient\ # Unified location
├── mbetterclient.db # SQLite database
├── logs/ # Log files
├── data/ # Application data
├── uploads/ # File uploads
└── templates/ # User templates
``` ```
~/.config/MbetterClient/ # Linux
~/Library/Application Support/MbetterClient/ # macOS **macOS**:
%APPDATA%\MbetterClient\ # Windows ```
├── mbetterclient.db # SQLite database ~/Library/Application Support/MbetterClient/ # Unified location
├── config/ ├── mbetterclient.db # SQLite database
│ ├── app.json # Application settings ├── logs/ # Log files
│ ├── api_endpoints.json # API client configuration ├── data/ # Application data
│ └── templates.json # Overlay template settings ├── uploads/ # File uploads
└── logs/ └── templates/ # User templates
├── app.log # Application logs
├── web.log # Web dashboard logs
└── api.log # API client logs
``` ```
## Configuration ## Configuration
...@@ -335,6 +358,82 @@ python main.py --overlay-type native ...@@ -335,6 +358,82 @@ python main.py --overlay-type native
- **sports**: Processes game scores and team information - **sports**: Processes game scores and team information
- **custom**: User-defined processing logic - **custom**: User-defined processing logic
### Match Data Management
The application includes comprehensive boxing match data management with database tables adapted from the mbetterd system:
#### Match Database Structure
**matches table**: Core match information
- Match numbers, fighter townships, venues
- Start/end times and results
- File metadata and SHA1 checksums
- ZIP upload tracking with progress
- User association and timestamps
**match_outcomes table**: Detailed match results
- Foreign key relationships to matches
- Column-based outcome storage with float values
- Unique constraints preventing duplicate outcomes
#### Match Data API
Access match data through the web dashboard or API:
```http
GET /api/matches
Authorization: Bearer <token>
```
**Response:**
```json
{
"matches": [
{
"id": 1,
"match_number": 101,
"fighter1_township": "Kampala Central",
"fighter2_township": "Nakawa",
"venue_kampala_township": "Kololo",
"start_time": "2025-08-21T14:00:00Z",
"end_time": "2025-08-21T14:45:00Z",
"result": "Winner: Fighter 1",
"active_status": true,
"outcomes": {
"round_1_score": 10.5,
"round_2_score": 9.8,
"total_score": 20.3
}
}
]
}
```
#### Creating Match Records
```http
POST /api/matches
Authorization: Bearer <token>
Content-Type: application/json
{
"match_number": 102,
"fighter1_township": "Rubaga",
"fighter2_township": "Makindye",
"venue_kampala_township": "Lugogo",
"outcomes": [
{
"column_name": "round_1_score",
"float_value": 9.5
},
{
"column_name": "round_2_score",
"float_value": 8.7
}
]
}
```
## API Reference ## API Reference
### Authentication ### Authentication
...@@ -990,11 +1089,76 @@ class WeatherResponseHandler(ResponseHandler): ...@@ -990,11 +1089,76 @@ class WeatherResponseHandler(ResponseHandler):
### Database Schema Extensions ### Database Schema Extensions
Add custom tables for application-specific data: The application includes comprehensive match data models adapted from mbetterd:
#### Match Models
```python ```python
# In database/models.py # In database/models.py
class MatchModel(BaseModel):
"""Boxing matches from fixture files"""
__tablename__ = 'matches'
# Core match data
match_number = Column(Integer, nullable=False, unique=True)
fighter1_township = Column(String(255), nullable=False)
fighter2_township = Column(String(255), nullable=False)
venue_kampala_township = Column(String(255), nullable=False)
# Match timing and results
start_time = Column(DateTime)
end_time = Column(DateTime)
result = Column(String(255))
# File metadata
filename = Column(String(1024), nullable=False)
file_sha1sum = Column(String(255), nullable=False)
fixture_id = Column(String(255), nullable=False, unique=True)
active_status = Column(Boolean, default=False)
# ZIP upload tracking
zip_filename = Column(String(1024))
zip_sha1sum = Column(String(255))
zip_upload_status = Column(String(20), default='pending')
zip_upload_progress = Column(Float, default=0.0)
# Relationships
outcomes = relationship('MatchOutcomeModel', cascade='all, delete-orphan')
class MatchOutcomeModel(BaseModel):
"""Match outcome values from fixture files"""
__tablename__ = 'match_outcomes'
match_id = Column(Integer, ForeignKey('matches.id', ondelete='CASCADE'))
column_name = Column(String(255), nullable=False)
float_value = Column(Float, nullable=False)
# Unique constraint on match_id + column_name
__table_args__ = (
UniqueConstraint('match_id', 'column_name'),
)
```
#### Database Migration
```python
# Migration_008_AddMatchTables
class Migration_008_AddMatchTables(DatabaseMigration):
def up(self, db_manager) -> bool:
# Creates matches and match_outcomes tables with:
# - Comprehensive indexing for performance
# - Foreign key relationships with CASCADE DELETE
# - Unique constraints for data integrity
# - SQLite-compatible syntax
pass
```
#### Custom Data Extensions
Add application-specific tables:
```python
class CustomData(Base): class CustomData(Base):
__tablename__ = 'custom_data' __tablename__ = 'custom_data'
...@@ -1057,8 +1221,19 @@ class MbetterPlugin: ...@@ -1057,8 +1221,19 @@ class MbetterPlugin:
- Restrict video file access paths - Restrict video file access paths
- Validate file types and sizes - Validate file types and sizes
- Use sandboxed directories - Use sandboxed directories with persistent user data locations
- Regular backup of configuration and database - Regular backup of configuration and database
- Cross-platform directory permission validation
- Secure PyInstaller executable data persistence
#### Cross-Platform Data Persistence
- **Windows**: All data stored in `%APPDATA%\MbetterClient\`
- **macOS**: Unified location at `~/Library/Application Support/MbetterClient/`
- **Linux**: Data in `~/.local/share/MbetterClient/`, config in `~/.config/MbetterClient/`
- **PyInstaller Detection**: Automatic runtime environment detection
- **Fallback Handling**: Graceful degradation to home directory if standard paths fail
- **Permission Testing**: Write verification before using directories
### Performance Monitoring ### Performance Monitoring
......
...@@ -20,6 +20,17 @@ A cross-platform multimedia client application with video playback, web dashboar ...@@ -20,6 +20,17 @@ A cross-platform multimedia client application with video playback, web dashboar
## Recent Improvements ## Recent Improvements
### Version 1.2.3 (August 2025)
-**Boxing Match Database**: Added comprehensive `matches` and `match_outcomes` database tables adapted from mbetterd MySQL schema
-**Cross-Platform Persistence**: Complete PyInstaller executable persistence with platform-specific user directories
-**Match Data Management**: Full SQLAlchemy models for boxing match tracking with fighter townships, venues, and outcomes
-**File Upload Tracking**: ZIP file upload management with progress tracking and status monitoring
-**Database Migration System**: Migration_008_AddMatchTables with comprehensive indexing and foreign key relationships
-**User Data Directories**: Automatic creation of persistent directories on Windows (%APPDATA%), macOS (~/Library/Application Support), and Linux (~/.local/share)
-**Robust Error Handling**: Fallback directory creation with permission testing and write verification
-**Cross-Platform Testing**: Comprehensive test suite verifying directory creation and database persistence across all platforms
### Version 1.2.2 (August 2025) ### Version 1.2.2 (August 2025)
-**Template Management System**: Complete HTML overlay template management with upload, delete, and real-time editing capabilities -**Template Management System**: Complete HTML overlay template management with upload, delete, and real-time editing capabilities
...@@ -160,10 +171,10 @@ mbetterc/ ...@@ -160,10 +171,10 @@ mbetterc/
├── tests/ # Unit tests ├── tests/ # Unit tests
└── docs/ # Documentation └── docs/ # Documentation
# User Data Directories (Created automatically) # Persistent User Data Directories (Created automatically for PyInstaller compatibility)
# Windows: %APPDATA%\MbetterClient\templates\ # Windows: %APPDATA%\MbetterClient\ (data, config, templates, logs)
# macOS: ~/Library/Application Support/MbetterClient/templates/ # macOS: ~/Library/Application Support/MbetterClient/ (unified location)
# Linux: ~/.config/MbetterClient/templates/ # Linux: ~/.local/share/MbetterClient/ (data) & ~/.config/MbetterClient/ (config)
``` ```
### Message System ### Message System
......
...@@ -163,24 +163,9 @@ def validate_arguments(args): ...@@ -163,24 +163,9 @@ def validate_arguments(args):
print("Error: Web port must be between 1 and 65535") print("Error: Web port must be between 1 and 65535")
sys.exit(1) sys.exit(1)
# Create necessary directories # Directory creation is handled by AppSettings.ensure_directories()
project_root = Path(__file__).parent # which uses persistent user directories for PyInstaller compatibility
pass
# Data directory
data_dir = project_root / 'data'
data_dir.mkdir(exist_ok=True)
# Logs directory
logs_dir = project_root / 'logs'
logs_dir.mkdir(exist_ok=True)
# Assets directory
assets_dir = project_root / 'assets'
assets_dir.mkdir(exist_ok=True)
# Templates directory
templates_dir = project_root / 'templates'
templates_dir.mkdir(exist_ok=True)
def main(): def main():
"""Main entry point""" """Main entry point"""
......
...@@ -289,7 +289,51 @@ class APIClient(ThreadedComponent): ...@@ -289,7 +289,51 @@ class APIClient(ThreadedComponent):
def _get_default_endpoints(self) -> Dict[str, Dict[str, Any]]: def _get_default_endpoints(self) -> Dict[str, Dict[str, Any]]:
"""Get default API endpoints configuration""" """Get default API endpoints configuration"""
# Get FastAPI server URL and token from configuration
fastapi_url = "https://mbetter.nexlab.net/api/updates"
api_token = ""
try:
api_config = self.config_manager.get_section_config("api") or {}
fastapi_url = api_config.get("fastapi_url", fastapi_url)
api_token = api_config.get("api_token", api_token)
except Exception as e:
logger.warning(f"Could not load API configuration, using defaults: {e}")
# Prepare authentication if token is provided
auth_config = None
headers = {"Content-Type": "application/json"}
# Configure based on token availability
if api_token and api_token.strip():
auth_config = {
"type": "bearer",
"token": api_token.strip()
}
# When token is provided: 30-minute intervals, 10 retries every 30 seconds
interval = 1800 # 30 minutes
retry_attempts = 10
retry_delay = 30 # 30 seconds
enabled = True
else:
# When no token: longer intervals, fewer retries, disabled by default
interval = 600 # 10 minutes
retry_attempts = 3
retry_delay = 5
enabled = False # Disabled when no authentication
return { return {
"fastapi_main": {
"url": f"{fastapi_url.rstrip('/')}/status" if not fastapi_url.endswith(('/status', '/api/status')) else fastapi_url,
"method": "GET",
"headers": headers,
"auth": auth_config,
"interval": interval,
"enabled": enabled,
"timeout": 30,
"retry_attempts": retry_attempts,
"retry_delay": retry_delay,
"response_handler": "default"
},
"news_example": { "news_example": {
"url": "https://newsapi.org/v2/top-headlines", "url": "https://newsapi.org/v2/top-headlines",
"method": "GET", "method": "GET",
...@@ -385,7 +429,7 @@ class APIClient(ThreadedComponent): ...@@ -385,7 +429,7 @@ class APIClient(ThreadedComponent):
self._execute_endpoint_request(endpoint) self._execute_endpoint_request(endpoint)
def _execute_endpoint_request(self, endpoint: APIEndpoint): def _execute_endpoint_request(self, endpoint: APIEndpoint):
"""Execute a single API request""" """Execute a single API request with custom retry logic for token-based endpoints"""
try: try:
endpoint.last_request = datetime.utcnow() endpoint.last_request = datetime.utcnow()
endpoint.total_requests += 1 endpoint.total_requests += 1
...@@ -397,7 +441,7 @@ class APIClient(ThreadedComponent): ...@@ -397,7 +441,7 @@ class APIClient(ThreadedComponent):
request_kwargs = { request_kwargs = {
'method': endpoint.method, 'method': endpoint.method,
'url': endpoint.url, 'url': endpoint.url,
'headers': endpoint.headers, 'headers': endpoint.headers.copy(),
'timeout': endpoint.timeout 'timeout': endpoint.timeout
} }
...@@ -413,63 +457,138 @@ class APIClient(ThreadedComponent): ...@@ -413,63 +457,138 @@ class APIClient(ThreadedComponent):
elif endpoint.auth.get('type') == 'basic': elif endpoint.auth.get('type') == 'basic':
request_kwargs['auth'] = (endpoint.auth.get('username'), endpoint.auth.get('password')) request_kwargs['auth'] = (endpoint.auth.get('username'), endpoint.auth.get('password'))
# Execute request # Check if this is a token-based endpoint that needs custom retry logic
response = self.session.request(**request_kwargs) is_token_endpoint = (endpoint.auth and
response.raise_for_status() endpoint.auth.get('type') == 'bearer' and
endpoint.auth.get('token'))
# Handle successful response
handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default'])
processed_data = handler.handle_response(endpoint, response)
# Update endpoint status
endpoint.last_success = datetime.utcnow()
endpoint.last_error = None
endpoint.consecutive_failures = 0
endpoint.successful_requests += 1
self.stats['successful_requests'] += 1
# Send response data via message bus
if processed_data:
response_message = Message(
type=MessageType.API_RESPONSE,
sender=self.name,
data={
'endpoint': endpoint.name,
'success': True,
'data': processed_data,
'timestamp': datetime.utcnow().isoformat()
}
)
self.message_bus.publish(response_message)
logger.debug(f"API request successful: {endpoint.name}") if is_token_endpoint and endpoint.consecutive_failures > 0:
# For token-based endpoints with failures, use custom retry logic
success = self._execute_with_custom_retry(endpoint, request_kwargs)
if not success:
return # Custom retry logic handles error reporting
else:
# Standard execution for first attempt or non-token endpoints
response = self.session.request(**request_kwargs)
response.raise_for_status()
# Handle successful response
handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default'])
processed_data = handler.handle_response(endpoint, response)
# Update endpoint status
endpoint.last_success = datetime.utcnow()
endpoint.last_error = None
endpoint.consecutive_failures = 0
endpoint.successful_requests += 1
self.stats['successful_requests'] += 1
# Send response data via message bus
if processed_data:
response_message = Message(
type=MessageType.API_RESPONSE,
sender=self.name,
data={
'endpoint': endpoint.name,
'success': True,
'data': processed_data,
'timestamp': datetime.utcnow().isoformat()
}
)
self.message_bus.publish(response_message)
logger.debug(f"API request successful: {endpoint.name}")
except Exception as e: except Exception as e:
# Handle request failure # Handle request failure
endpoint.last_error = str(e) self._handle_request_failure(endpoint, e)
endpoint.consecutive_failures += 1
self.stats['failed_requests'] += 1 def _execute_with_custom_retry(self, endpoint: APIEndpoint, request_kwargs: dict) -> bool:
"""Execute request with custom retry logic for token-based endpoints"""
logger.error(f"API request failed: {endpoint.name} - {e}") max_retries = min(endpoint.retry_attempts, 10) # Cap at 10 retries as requested
retry_delay = 30 # 30 seconds between retries as requested
# Send error message
error_message = Message( for attempt in range(max_retries):
type=MessageType.API_RESPONSE, try:
sender=self.name, logger.info(f"Retry attempt {attempt + 1}/{max_retries} for endpoint {endpoint.name}")
data={
'endpoint': endpoint.name, response = self.session.request(**request_kwargs)
'success': False, response.raise_for_status()
'error': str(e),
'consecutive_failures': endpoint.consecutive_failures, # Handle successful response
'timestamp': datetime.utcnow().isoformat() handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default'])
} processed_data = handler.handle_response(endpoint, response)
)
self.message_bus.publish(error_message) # Update endpoint status - success!
endpoint.last_success = datetime.utcnow()
# Disable endpoint if too many consecutive failures endpoint.last_error = None
if endpoint.consecutive_failures >= self.settings.max_consecutive_failures: endpoint.consecutive_failures = 0
endpoint.enabled = False endpoint.successful_requests += 1
logger.warning(f"Endpoint disabled due to consecutive failures: {endpoint.name}") self.stats['successful_requests'] += 1
# Send response data via message bus
if processed_data:
response_message = Message(
type=MessageType.API_RESPONSE,
sender=self.name,
data={
'endpoint': endpoint.name,
'success': True,
'data': processed_data,
'timestamp': datetime.utcnow().isoformat(),
'retry_attempt': attempt + 1
}
)
self.message_bus.publish(response_message)
logger.info(f"API request successful on retry {attempt + 1}: {endpoint.name}")
return True
except Exception as e:
endpoint.last_error = str(e)
endpoint.consecutive_failures += 1
self.stats['failed_requests'] += 1
if attempt < max_retries - 1:
logger.warning(f"API retry {attempt + 1} failed for {endpoint.name}: {e}. Waiting {retry_delay}s before next retry.")
time.sleep(retry_delay)
else:
logger.error(f"All {max_retries} retries failed for {endpoint.name}: {e}")
# All retries failed
self._handle_request_failure(endpoint, Exception(f"All {max_retries} retries failed. Last error: {endpoint.last_error}"))
return False
def _handle_request_failure(self, endpoint: APIEndpoint, error: Exception):
"""Handle request failure and send error message"""
endpoint.last_error = str(error)
endpoint.consecutive_failures += 1
self.stats['failed_requests'] += 1
logger.error(f"API request failed: {endpoint.name} - {error}")
# Send error message
error_message = Message(
type=MessageType.API_RESPONSE,
sender=self.name,
data={
'endpoint': endpoint.name,
'success': False,
'error': str(error),
'consecutive_failures': endpoint.consecutive_failures,
'timestamp': datetime.utcnow().isoformat()
}
)
self.message_bus.publish(error_message)
# For token-based endpoints, don't disable after failures - keep retrying per user requirements
is_token_endpoint = (endpoint.auth and
endpoint.auth.get('type') == 'bearer' and
endpoint.auth.get('token'))
if not is_token_endpoint and endpoint.consecutive_failures >= self.settings.max_consecutive_failures:
endpoint.enabled = False
logger.warning(f"Endpoint disabled due to consecutive failures: {endpoint.name}")
def _process_message(self, message: Message): def _process_message(self, message: Message):
"""Process received message""" """Process received message"""
...@@ -483,14 +602,158 @@ class APIClient(ThreadedComponent): ...@@ -483,14 +602,158 @@ class APIClient(ThreadedComponent):
"""Handle configuration update message""" """Handle configuration update message"""
try: try:
config_section = message.data.get("config_section") config_section = message.data.get("config_section")
config_data = message.data.get("config_data", {})
if config_section == "api_endpoints": if config_section == "api_endpoints":
logger.info("API endpoints configuration updated") logger.info("API endpoints configuration updated")
self._load_endpoints() self._load_endpoints()
elif config_section == "api":
logger.info("API configuration updated, reloading endpoints")
# Check for token changes to enable/disable dynamic scheduling
new_token = config_data.get("api_token", "").strip()
old_token = ""
try:
old_api_config = self.config_manager.get_section_config("api") or {}
old_token = old_api_config.get("api_token", "").strip()
except Exception:
pass
# Reload endpoints to pick up new configuration
self._load_endpoints()
# Handle dynamic timer start/stop based on token changes
self._handle_token_change(old_token, new_token)
except Exception as e: except Exception as e:
logger.error(f"Failed to handle config update: {e}") logger.error(f"Failed to handle config update: {e}")
def _handle_token_change(self, old_token: str, new_token: str):
"""Handle dynamic timer start/stop based on token changes"""
try:
fastapi_endpoint = self.endpoints.get("fastapi_main")
if not fastapi_endpoint:
return
old_has_token = bool(old_token and old_token.strip())
new_has_token = bool(new_token and new_token.strip())
if not old_has_token and new_has_token:
# Token was added - start timer
fastapi_endpoint.enabled = True
fastapi_endpoint.interval = 1800 # 30 minutes
fastapi_endpoint.retry_attempts = 10
fastapi_endpoint.retry_delay = 30
fastapi_endpoint.consecutive_failures = 0 # Reset failure count
fastapi_endpoint.last_request = None # Reset to trigger immediate first request
logger.info("FastAPI timer started - token configured, 30-minute intervals enabled")
# Send immediate status update
status_message = Message(
type=MessageType.SYSTEM_STATUS,
sender=self.name,
data={
"status": "timer_started",
"endpoint": "fastapi_main",
"reason": "token_configured",
"interval_minutes": 30
}
)
self.message_bus.publish(status_message)
elif old_has_token and not new_has_token:
# Token was removed - stop timer
fastapi_endpoint.enabled = False
fastapi_endpoint.interval = 600 # Reset to 10 minutes (default)
fastapi_endpoint.retry_attempts = 3
fastapi_endpoint.retry_delay = 5
logger.info("FastAPI timer stopped - token removed, automatic requests disabled")
# Send status update
status_message = Message(
type=MessageType.SYSTEM_STATUS,
sender=self.name,
data={
"status": "timer_stopped",
"endpoint": "fastapi_main",
"reason": "token_removed"
}
)
self.message_bus.publish(status_message)
elif old_has_token and new_has_token and old_token != new_token:
# Token was changed - keep timer running but reset failure count
fastapi_endpoint.consecutive_failures = 0
fastapi_endpoint.last_request = None # Trigger immediate request with new token
logger.info("FastAPI token updated - timer continues with new authentication")
except Exception as e:
logger.error(f"Failed to handle token change: {e}")
def update_fastapi_url(self, new_url: str) -> bool:
"""Update FastAPI server URL and reload endpoints"""
try:
# Update configuration
api_config = self.config_manager.get_section_config("api") or {}
api_config["fastapi_url"] = new_url
self.config_manager.update_section("api", api_config)
# Reload endpoints to pick up new URL
self._load_endpoints()
logger.info(f"FastAPI URL updated to: {new_url}")
return True
except Exception as e:
logger.error(f"Failed to update FastAPI URL: {e}")
return False
def update_api_token(self, new_token: str) -> bool:
"""Update API authentication token and reload endpoints"""
try:
# Update configuration
api_config = self.config_manager.get_section_config("api") or {}
api_config["api_token"] = new_token
self.config_manager.update_section("api", api_config)
# Reload endpoints to pick up new token
self._load_endpoints()
logger.info("API token updated successfully")
return True
except Exception as e:
logger.error(f"Failed to update API token: {e}")
return False
def update_api_config(self, new_url: str = None, new_token: str = None) -> bool:
"""Update FastAPI URL and/or token configuration"""
try:
# Update configuration
api_config = self.config_manager.get_section_config("api") or {}
if new_url is not None:
api_config["fastapi_url"] = new_url
if new_token is not None:
api_config["api_token"] = new_token
self.config_manager.update_section("api", api_config)
# Reload endpoints to pick up new configuration
self._load_endpoints()
logger.info(f"API configuration updated - URL: {new_url}, Token: {'*' * len(new_token) if new_token else 'unchanged'}")
return True
except Exception as e:
logger.error(f"Failed to update API configuration: {e}")
return False
def _handle_api_request(self, message: Message): def _handle_api_request(self, message: Message):
"""Handle manual API request message""" """Handle manual API request message"""
try: try:
......
...@@ -49,6 +49,10 @@ class MbetterClientApplication: ...@@ -49,6 +49,10 @@ class MbetterClientApplication:
try: try:
logger.info("Initializing MbetterClient application...") logger.info("Initializing MbetterClient application...")
# Ensure persistent directories exist first
logger.info("Creating persistent directories...")
self.settings.ensure_directories()
# Initialize database manager # Initialize database manager
if not self._initialize_database(): if not self._initialize_database():
return False return False
...@@ -245,26 +249,8 @@ class MbetterClientApplication: ...@@ -245,26 +249,8 @@ class MbetterClientApplication:
def _get_persistent_templates_dir(self) -> Path: def _get_persistent_templates_dir(self) -> Path:
"""Get persistent templates directory for user uploads""" """Get persistent templates directory for user uploads"""
try: try:
import platform # Use the consistent user config directory from settings
import os return self.settings.get_user_config_dir() / "templates"
from pathlib import Path
system = platform.system()
if system == "Windows":
# Use AppData/Roaming on Windows
app_data = os.getenv('APPDATA', os.path.expanduser('~'))
templates_dir = Path(app_data) / "MbetterClient" / "templates"
elif system == "Darwin": # macOS
# Use ~/Library/Application Support on macOS
templates_dir = Path.home() / "Library" / "Application Support" / "MbetterClient" / "templates"
else: # Linux and other Unix-like systems
# Use ~/.config on Linux
config_home = os.getenv('XDG_CONFIG_HOME', str(Path.home() / ".config"))
templates_dir = Path(config_home) / "MbetterClient" / "templates"
logger.debug(f"Persistent templates directory: {templates_dir}")
return templates_dir
except Exception as e: except Exception as e:
logger.error(f"Failed to determine persistent templates directory: {e}") logger.error(f"Failed to determine persistent templates directory: {e}")
......
...@@ -325,7 +325,7 @@ class DatabaseManager: ...@@ -325,7 +325,7 @@ class DatabaseManager:
session.close() session.close()
# User management methods # User management methods
def create_user(self, username: str, email: str, password: str, is_admin: bool = False) -> Optional[Dict[str, Any]]: def create_user(self, username: str, email: str, password: str, is_admin: bool = False, role: str = 'normal') -> Optional[Dict[str, Any]]:
"""Create new user""" """Create new user"""
try: try:
session = self.get_session() session = self.get_session()
...@@ -347,6 +347,13 @@ class DatabaseManager: ...@@ -347,6 +347,13 @@ class DatabaseManager:
) )
user.set_password(password) user.set_password(password)
# Set role (handle backward compatibility)
if hasattr(user, 'set_role'):
user.set_role(role)
elif hasattr(user, 'role'):
user.role = role
user.is_admin = (role == 'admin')
session.add(user) session.add(user)
session.commit() session.commit()
...@@ -356,6 +363,7 @@ class DatabaseManager: ...@@ -356,6 +363,7 @@ class DatabaseManager:
'username': user.username, 'username': user.username,
'email': user.email, 'email': user.email,
'is_admin': user.is_admin, 'is_admin': user.is_admin,
'role': getattr(user, 'role', 'normal'),
'created_at': user.created_at, 'created_at': user.created_at,
'updated_at': user.updated_at, 'updated_at': user.updated_at,
'last_login': user.last_login 'last_login': user.last_login
...@@ -418,6 +426,7 @@ class DatabaseManager: ...@@ -418,6 +426,7 @@ class DatabaseManager:
'username': user.username, 'username': user.username,
'email': user.email, 'email': user.email,
'is_admin': user.is_admin, 'is_admin': user.is_admin,
'role': getattr(user, 'role', 'normal'),
'created_at': user.created_at, 'created_at': user.created_at,
'updated_at': user.updated_at, 'updated_at': user.updated_at,
'last_login': user.last_login 'last_login': user.last_login
...@@ -446,6 +455,7 @@ class DatabaseManager: ...@@ -446,6 +455,7 @@ class DatabaseManager:
'username': merged_user.username, 'username': merged_user.username,
'email': merged_user.email, 'email': merged_user.email,
'is_admin': merged_user.is_admin, 'is_admin': merged_user.is_admin,
'role': getattr(merged_user, 'role', 'normal'),
'created_at': merged_user.created_at, 'created_at': merged_user.created_at,
'updated_at': merged_user.updated_at, 'updated_at': merged_user.updated_at,
'last_login': merged_user.last_login 'last_login': merged_user.last_login
...@@ -577,6 +587,11 @@ class DatabaseManager: ...@@ -577,6 +587,11 @@ class DatabaseManager:
user = session.query(UserModel).get(user_id) user = session.query(UserModel).get(user_id)
if user: if user:
# Prevent deletion of default admin user
if user.username == 'admin':
logger.warning(f"Cannot delete default admin user: {user.username} (ID: {user_id})")
return False
# Delete user's API tokens first # Delete user's API tokens first
session.query(ApiTokenModel).filter_by(user_id=user_id).delete() session.query(ApiTokenModel).filter_by(user_id=user_id).delete()
...@@ -677,28 +692,71 @@ class DatabaseManager: ...@@ -677,28 +692,71 @@ class DatabaseManager:
session.close() session.close()
def _create_default_admin(self): def _create_default_admin(self):
"""Create default admin user if none exists""" """Create default admin and cashier users if they don't exist
Note: This method primarily serves as a fallback. The preferred method
for creating default users is through database migrations (Migration_005 and Migration_007).
This method only creates users if migrations haven't already created them.
"""
try: try:
session = self.get_session() session = self.get_session()
admin_user = session.query(UserModel).filter_by(is_admin=True).first() # Check if admin user already exists by username (more specific than is_admin check)
admin_user = session.query(UserModel).filter_by(username='admin').first()
if not admin_user: if not admin_user:
# Create default admin # Only create if no admin user exists at all
admin = UserModel( any_admin = session.query(UserModel).filter_by(is_admin=True).first()
username='admin',
email='admin@mbetterclient.local', if not any_admin:
is_admin=True # Create default admin - migrations should handle this, but fallback just in case
admin = UserModel(
username='admin',
email='admin@mbetterclient.local',
is_admin=True
)
admin.set_password('admin123')
# Set admin role (handle backward compatibility)
if hasattr(admin, 'set_role'):
admin.set_role('admin')
elif hasattr(admin, 'role'):
admin.role = 'admin'
session.add(admin)
logger.info("Default admin user created via fallback method (admin/admin123)")
else:
logger.info("Admin users exist, skipping default admin creation")
else:
logger.info("Admin user 'admin' already exists, skipping creation")
# Check if default cashier exists (this should be handled by Migration_007)
cashier_user = session.query(UserModel).filter_by(username='cashier').first()
if not cashier_user:
# Create default cashier - migrations should handle this, but fallback just in case
cashier = UserModel(
username='cashier',
email='cashier@mbetterclient.local',
is_admin=False
) )
admin.set_password('admin123') cashier.set_password('cashier123')
session.add(admin) # Set cashier role (handle backward compatibility)
session.commit() if hasattr(cashier, 'set_role'):
cashier.set_role('cashier')
elif hasattr(cashier, 'role'):
cashier.role = 'cashier'
logger.info("Default admin user created (admin/admin123)") session.add(cashier)
logger.info("Default cashier user created via fallback method (cashier/cashier123)")
else:
logger.info("Cashier user 'cashier' already exists, skipping creation")
session.commit()
except Exception as e: except Exception as e:
logger.error(f"Failed to create default admin: {e}") logger.error(f"Failed to create default users: {e}")
session.rollback() session.rollback()
finally: finally:
session.close() session.close()
......
...@@ -249,15 +249,16 @@ class Migration_005_CreateDefaultAdminUser(DatabaseMigration): ...@@ -249,15 +249,16 @@ class Migration_005_CreateDefaultAdminUser(DatabaseMigration):
stored_hash = f"{salt}:{password_hash}" stored_hash = f"{salt}:{password_hash}"
# Insert admin user using raw SQL (consistent with other migrations) # Insert admin user using raw SQL (consistent with other migrations)
# Include all NOT NULL columns with proper defaults # Include all NOT NULL columns with proper defaults, including role
conn.execute(text(""" conn.execute(text("""
INSERT INTO users INSERT INTO users
(username, email, password_hash, is_active, is_admin, login_attempts, created_at, updated_at) (username, email, password_hash, is_active, is_admin, login_attempts, role, created_at, updated_at)
VALUES (:username, :email, :password_hash, 1, 1, 0, datetime('now'), datetime('now')) VALUES (:username, :email, :password_hash, 1, 1, 0, :role, datetime('now'), datetime('now'))
"""), { """), {
'username': username, 'username': username,
'email': email, 'email': email,
'password_hash': stored_hash 'password_hash': stored_hash,
'role': 'admin'
}) })
conn.commit() conn.commit()
...@@ -292,6 +293,232 @@ class Migration_005_CreateDefaultAdminUser(DatabaseMigration): ...@@ -292,6 +293,232 @@ class Migration_005_CreateDefaultAdminUser(DatabaseMigration):
return False return False
class Migration_006_AddUserRoles(DatabaseMigration):
"""Add role-based access control to users"""
def __init__(self):
super().__init__("006", "Add role-based access control (admin, normal, cashier)")
def up(self, db_manager) -> bool:
"""Add role column to users table"""
try:
with db_manager.engine.connect() as conn:
# Check if role column already exists
result = conn.execute(text("PRAGMA table_info(users)"))
columns = [row[1] for row in result.fetchall()]
if 'role' not in columns:
# Add role column with default value 'normal'
conn.execute(text("""
ALTER TABLE users
ADD COLUMN role VARCHAR(20) DEFAULT 'normal' NOT NULL
"""))
# Update existing users: set role based on is_admin field
conn.execute(text("""
UPDATE users
SET role = CASE
WHEN is_admin = 1 THEN 'admin'
ELSE 'normal'
END
"""))
# Add index for role column
conn.execute(text("""
CREATE INDEX IF NOT EXISTS ix_users_role ON users(role)
"""))
conn.commit()
logger.info("Role column added to users table")
else:
logger.info("Role column already exists in users table")
return True
except Exception as e:
logger.error(f"Failed to add user roles: {e}")
return False
def down(self, db_manager) -> bool:
"""Remove role column - SQLite doesn't support DROP COLUMN easily"""
logger.warning("SQLite doesn't support DROP COLUMN - role column will remain")
return True
class Migration_007_CreateDefaultCashierUser(DatabaseMigration):
"""Create default cashier user for immediate testing"""
def __init__(self):
super().__init__("007", "Create default cashier user")
def up(self, db_manager) -> bool:
"""Create default cashier user"""
try:
with db_manager.engine.connect() as conn:
# Check if cashier user already exists
result = conn.execute(text("SELECT COUNT(*) FROM users WHERE username = 'cashier'"))
cashier_count = result.scalar()
if cashier_count == 0:
# No cashier user exists, create default cashier
import hashlib
import secrets
username = "cashier"
email = "cashier@mbetterclient.local"
password = "cashier123" # Correct default password
# Use AuthManager's password hashing method (SHA-256 + salt)
# This matches what authenticate_user expects
salt = secrets.token_hex(16)
password_hash = hashlib.sha256((password + salt).encode()).hexdigest()
stored_hash = f"{salt}:{password_hash}"
# Insert cashier user using raw SQL (consistent with other migrations)
# Include all NOT NULL columns with proper defaults
conn.execute(text("""
INSERT INTO users
(username, email, password_hash, is_active, is_admin, login_attempts, role, created_at, updated_at)
VALUES (:username, :email, :password_hash, 1, 0, 0, :role, datetime('now'), datetime('now'))
"""), {
'username': username,
'email': email,
'password_hash': stored_hash,
'role': 'cashier'
})
conn.commit()
logger.info(f"Default cashier user created - Username: {username}, Password: {password}")
else:
logger.info(f"Cashier user already exists, skipping default cashier creation")
return True
except Exception as e:
logger.error(f"Failed to create default cashier user: {e}")
return False
def down(self, db_manager) -> bool:
"""Remove default cashier user if it still exists with default credentials"""
try:
with db_manager.engine.connect() as conn:
# Only remove if username is 'cashier' and email is the default
conn.execute(text("""
DELETE FROM users
WHERE username = 'cashier'
AND email = 'cashier@mbetterclient.local'
"""))
conn.commit()
logger.info("Default cashier user removed")
return True
except Exception as e:
logger.error(f"Failed to remove default cashier user: {e}")
return False
class Migration_008_AddMatchTables(DatabaseMigration):
"""Add matches and match_outcomes tables for fixture data"""
def __init__(self):
super().__init__("008", "Add matches and match_outcomes tables")
def up(self, db_manager) -> bool:
"""Create matches and match_outcomes tables"""
try:
with db_manager.engine.connect() as conn:
# Create matches table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_number INTEGER NOT NULL UNIQUE,
fighter1_township VARCHAR(255) NOT NULL,
fighter2_township VARCHAR(255) NOT NULL,
venue_kampala_township VARCHAR(255) NOT NULL,
start_time DATETIME NULL,
end_time DATETIME NULL,
result VARCHAR(255) NULL,
filename VARCHAR(1024) NOT NULL,
file_sha1sum VARCHAR(255) NOT NULL,
fixture_id VARCHAR(255) NOT NULL UNIQUE,
active_status BOOLEAN DEFAULT FALSE,
zip_filename VARCHAR(1024) NULL,
zip_sha1sum VARCHAR(255) NULL,
zip_upload_status VARCHAR(20) DEFAULT 'pending',
zip_upload_progress REAL DEFAULT 0.0,
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
# Create match_outcomes table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS match_outcomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
column_name VARCHAR(255) NOT NULL,
float_value REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(match_id, column_name)
)
"""))
# Create indexes for matches table
indexes = [
"CREATE INDEX IF NOT EXISTS ix_matches_match_number ON matches(match_number)",
"CREATE INDEX IF NOT EXISTS ix_matches_fixture_id ON matches(fixture_id)",
"CREATE INDEX IF NOT EXISTS ix_matches_active_status ON matches(active_status)",
"CREATE INDEX IF NOT EXISTS ix_matches_file_sha1sum ON matches(file_sha1sum)",
"CREATE INDEX IF NOT EXISTS ix_matches_zip_sha1sum ON matches(zip_sha1sum)",
"CREATE INDEX IF NOT EXISTS ix_matches_zip_upload_status ON matches(zip_upload_status)",
"CREATE INDEX IF NOT EXISTS ix_matches_created_by ON matches(created_by)",
"CREATE INDEX IF NOT EXISTS ix_matches_composite ON matches(active_status, zip_upload_status, created_at)",
]
# Create indexes for match_outcomes table
indexes.extend([
"CREATE INDEX IF NOT EXISTS ix_match_outcomes_match_id ON match_outcomes(match_id)",
"CREATE INDEX IF NOT EXISTS ix_match_outcomes_column_name ON match_outcomes(column_name)",
"CREATE INDEX IF NOT EXISTS ix_match_outcomes_float_value ON match_outcomes(float_value)",
"CREATE INDEX IF NOT EXISTS ix_match_outcomes_composite ON match_outcomes(match_id, column_name)",
])
for index_sql in indexes:
conn.execute(text(index_sql))
conn.commit()
logger.info("Matches and match_outcomes tables created successfully")
return True
except Exception as e:
logger.error(f"Failed to create match tables: {e}")
return False
def down(self, db_manager) -> bool:
"""Drop matches and match_outcomes tables"""
try:
with db_manager.engine.connect() as conn:
# Drop tables in reverse order (child first due to foreign keys)
conn.execute(text("DROP TABLE IF EXISTS match_outcomes"))
conn.execute(text("DROP TABLE IF EXISTS matches"))
conn.commit()
logger.info("Matches and match_outcomes tables dropped")
return True
except Exception as e:
logger.error(f"Failed to drop match 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(),
...@@ -299,6 +526,9 @@ MIGRATIONS: List[DatabaseMigration] = [ ...@@ -299,6 +526,9 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_003_AddTemplateVersioning(), Migration_003_AddTemplateVersioning(),
Migration_004_AddUserPreferences(), Migration_004_AddUserPreferences(),
Migration_005_CreateDefaultAdminUser(), Migration_005_CreateDefaultAdminUser(),
Migration_006_AddUserRoles(),
Migration_007_CreateDefaultCashierUser(),
Migration_008_AddMatchTables(),
] ]
......
...@@ -69,13 +69,15 @@ class UserModel(BaseModel): ...@@ -69,13 +69,15 @@ class UserModel(BaseModel):
__table_args__ = ( __table_args__ = (
Index('ix_users_username', 'username'), Index('ix_users_username', 'username'),
Index('ix_users_email', 'email'), Index('ix_users_email', 'email'),
Index('ix_users_role', 'role'),
) )
username = Column(String(80), unique=True, nullable=False) username = Column(String(80), unique=True, nullable=False)
email = Column(String(120), unique=True, nullable=False) email = Column(String(120), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False) is_admin = Column(Boolean, default=False, nullable=False) # Keep for backward compatibility
role = Column(String(20), default='normal', nullable=False) # admin, normal, cashier
last_login = Column(DateTime) last_login = Column(DateTime)
login_attempts = Column(Integer, default=0, nullable=False) login_attempts = Column(Integer, default=0, nullable=False)
locked_until = Column(DateTime) locked_until = Column(DateTime)
...@@ -85,12 +87,21 @@ class UserModel(BaseModel): ...@@ -85,12 +87,21 @@ class UserModel(BaseModel):
log_entries = relationship('LogEntryModel', back_populates='user') log_entries = relationship('LogEntryModel', back_populates='user')
def set_password(self, password: str): def set_password(self, password: str):
"""Set password hash""" """Set password hash using SHA-256 with salt (consistent with AuthManager)"""
self.password_hash = generate_password_hash(password) import hashlib
import secrets
salt = secrets.token_hex(16)
password_hash = hashlib.sha256((password + salt).encode()).hexdigest()
self.password_hash = f"{salt}:{password_hash}"
def check_password(self, password: str) -> bool: def check_password(self, password: str) -> bool:
"""Check password against hash""" """Check password against hash (consistent with AuthManager)"""
return check_password_hash(self.password_hash, password) try:
salt, password_hash = self.password_hash.split(':', 1)
expected_hash = hashlib.sha256((password + salt).encode()).hexdigest()
return password_hash == expected_hash
except (ValueError, AttributeError):
return False
def is_locked(self) -> bool: def is_locked(self) -> bool:
"""Check if account is locked""" """Check if account is locked"""
...@@ -117,6 +128,28 @@ class UserModel(BaseModel): ...@@ -117,6 +128,28 @@ class UserModel(BaseModel):
self.login_attempts = 0 self.login_attempts = 0
self.last_login = datetime.utcnow() self.last_login = datetime.utcnow()
def is_admin_user(self) -> bool:
"""Check if user has admin role"""
return self.role == 'admin' or self.is_admin
def is_cashier_user(self) -> bool:
"""Check if user has cashier role"""
return self.role == 'cashier'
def is_normal_user(self) -> bool:
"""Check if user has normal role"""
return self.role == 'normal'
def set_role(self, role: str):
"""Set user role and update is_admin for backward compatibility"""
valid_roles = ['admin', 'normal', 'cashier']
if role not in valid_roles:
raise ValueError(f"Invalid role: {role}. Must be one of {valid_roles}")
self.role = role
self.is_admin = (role == 'admin')
self.updated_at = datetime.utcnow()
def to_dict(self, exclude_fields: Optional[List[str]] = None) -> Dict[str, Any]: def to_dict(self, exclude_fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Convert to dictionary, excluding sensitive data""" """Convert to dictionary, excluding sensitive data"""
if exclude_fields is None: if exclude_fields is None:
...@@ -408,4 +441,138 @@ class SessionModel(BaseModel): ...@@ -408,4 +441,138 @@ class SessionModel(BaseModel):
self.is_active = False self.is_active = False
def __repr__(self): def __repr__(self):
return f'<Session {self.session_id} for User {self.user_id}>' return f'<Session {self.session_id} for User {self.user_id}>'
\ No newline at end of file
class MatchModel(BaseModel):
"""Boxing matches from fixture files"""
__tablename__ = 'matches'
__table_args__ = (
Index('ix_matches_match_number', 'match_number'),
Index('ix_matches_fixture_id', 'fixture_id'),
Index('ix_matches_active_status', 'active_status'),
Index('ix_matches_file_sha1sum', 'file_sha1sum'),
Index('ix_matches_zip_sha1sum', 'zip_sha1sum'),
Index('ix_matches_zip_upload_status', 'zip_upload_status'),
Index('ix_matches_created_by', 'created_by'),
Index('ix_matches_composite', 'active_status', 'zip_upload_status', 'created_at'),
UniqueConstraint('match_number', name='uq_matches_match_number'),
UniqueConstraint('fixture_id', name='uq_matches_fixture_id'),
)
# Core match data from fixture file
match_number = Column(Integer, nullable=False, unique=True, comment='Match # from fixture file')
fighter1_township = Column(String(255), nullable=False, comment='Fighter1 (Township)')
fighter2_township = Column(String(255), nullable=False, comment='Fighter2 (Township)')
venue_kampala_township = Column(String(255), nullable=False, comment='Venue (Kampala Township)')
# Match timing and results
start_time = Column(DateTime, comment='Match start time')
end_time = Column(DateTime, comment='Match end time')
result = Column(String(255), comment='Match result/outcome')
# File metadata
filename = Column(String(1024), nullable=False, comment='Original fixture filename')
file_sha1sum = Column(String(255), nullable=False, comment='SHA1 checksum of fixture file')
fixture_id = Column(String(255), nullable=False, unique=True, comment='Unique fixture identifier')
active_status = Column(Boolean, default=False, nullable=False, comment='Active status flag')
# ZIP file related fields
zip_filename = Column(String(1024), comment='Associated ZIP filename')
zip_sha1sum = Column(String(255), comment='SHA1 checksum of ZIP file')
zip_upload_status = Column(String(20), default='pending', comment='Upload status: pending, uploading, completed, failed')
zip_upload_progress = Column(Float, default=0.0, comment='Upload progress percentage (0.0-100.0)')
# User tracking
created_by = Column(Integer, ForeignKey('users.id'), comment='User who created this record')
# Relationships
creator = relationship('UserModel', foreign_keys=[created_by])
outcomes = relationship('MatchOutcomeModel', back_populates='match', cascade='all, delete-orphan')
def is_upload_pending(self) -> bool:
"""Check if ZIP upload is pending"""
return self.zip_upload_status == 'pending'
def is_upload_in_progress(self) -> bool:
"""Check if ZIP upload is in progress"""
return self.zip_upload_status == 'uploading'
def is_upload_completed(self) -> bool:
"""Check if ZIP upload is completed"""
return self.zip_upload_status == 'completed'
def is_upload_failed(self) -> bool:
"""Check if ZIP upload failed"""
return self.zip_upload_status == 'failed'
def set_upload_status(self, status: str, progress: float = None):
"""Set upload status and optionally progress"""
valid_statuses = ['pending', 'uploading', 'completed', 'failed']
if status not in valid_statuses:
raise ValueError(f"Invalid status: {status}. Must be one of {valid_statuses}")
self.zip_upload_status = status
if progress is not None:
self.zip_upload_progress = min(100.0, max(0.0, progress))
self.updated_at = datetime.utcnow()
def activate(self):
"""Activate this match"""
self.active_status = True
self.updated_at = datetime.utcnow()
def deactivate(self):
"""Deactivate this match"""
self.active_status = False
self.updated_at = datetime.utcnow()
def get_outcomes_dict(self) -> Dict[str, float]:
"""Get match outcomes as a dictionary"""
return {outcome.column_name: outcome.float_value for outcome in self.outcomes}
def add_outcome(self, column_name: str, float_value: float):
"""Add or update match outcome"""
# Check if outcome already exists
existing = next((o for o in self.outcomes if o.column_name == column_name), None)
if existing:
existing.float_value = float_value
existing.updated_at = datetime.utcnow()
else:
outcome = MatchOutcomeModel(
column_name=column_name,
float_value=float_value
)
self.outcomes.append(outcome)
def to_dict(self, exclude_fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Convert to dictionary with outcomes"""
result = super().to_dict(exclude_fields)
result['outcomes'] = self.get_outcomes_dict()
result['outcome_count'] = len(self.outcomes)
return result
def __repr__(self):
return f'<Match #{self.match_number}: {self.fighter1_township} vs {self.fighter2_township}>'
class MatchOutcomeModel(BaseModel):
"""Match outcome values from fixture files"""
__tablename__ = 'match_outcomes'
__table_args__ = (
Index('ix_match_outcomes_match_id', 'match_id'),
Index('ix_match_outcomes_column_name', 'column_name'),
Index('ix_match_outcomes_float_value', 'float_value'),
Index('ix_match_outcomes_composite', 'match_id', 'column_name'),
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')
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')
# Relationships
match = relationship('MatchModel', back_populates='outcomes')
def __repr__(self):
return f'<MatchOutcome {self.column_name}={self.float_value} for Match {self.match_id}>'
\ No newline at end of file
...@@ -372,6 +372,7 @@ class DashboardAPI: ...@@ -372,6 +372,7 @@ class DashboardAPI:
"username": user["username"], "username": user["username"],
"email": user["email"], "email": user["email"],
"is_admin": user["is_admin"], "is_admin": user["is_admin"],
"role": user.get("role", "normal"), # Include role field
"created_at": user["created_at"].isoformat() if user["created_at"] else None, "created_at": user["created_at"].isoformat() if user["created_at"] else None,
"last_login": user["last_login"].isoformat() if user["last_login"] else None "last_login": user["last_login"].isoformat() if user["last_login"] else None
} }
...@@ -384,8 +385,8 @@ class DashboardAPI: ...@@ -384,8 +385,8 @@ class DashboardAPI:
logger.error(f"Failed to get users: {e}") logger.error(f"Failed to get users: {e}")
return {"error": str(e)} return {"error": str(e)}
def create_user(self, username: str, email: str, password: str, def create_user(self, username: str, email: str, password: str,
is_admin: bool = False) -> Dict[str, Any]: is_admin: bool = False, role: str = 'normal') -> Dict[str, Any]:
"""Create new user (admin only)""" """Create new user (admin only)"""
try: try:
from .auth import AuthManager from .auth import AuthManager
...@@ -395,7 +396,7 @@ class DashboardAPI: ...@@ -395,7 +396,7 @@ class DashboardAPI:
if not auth_manager: if not auth_manager:
return {"error": "Auth manager not available"} return {"error": "Auth manager not available"}
user = auth_manager.create_user(username, email, password, is_admin) user = auth_manager.create_user(username, email, password, is_admin, role)
if user: if user:
return { return {
...@@ -404,7 +405,8 @@ class DashboardAPI: ...@@ -404,7 +405,8 @@ class DashboardAPI:
"id": user["id"], "id": user["id"],
"username": user["username"], "username": user["username"],
"email": user["email"], "email": user["email"],
"is_admin": user["is_admin"] "is_admin": user["is_admin"],
"role": user.get("role", "normal")
} }
} }
else: else:
...@@ -415,7 +417,7 @@ class DashboardAPI: ...@@ -415,7 +417,7 @@ class DashboardAPI:
return {"error": str(e)} return {"error": str(e)}
def update_user(self, user_id: int, username: str = None, email: str = None, def update_user(self, user_id: int, username: str = None, email: str = None,
password: str = None, is_admin: bool = None) -> Dict[str, Any]: password: str = None, is_admin: bool = None, role: str = None) -> Dict[str, Any]:
"""Update user (admin only)""" """Update user (admin only)"""
try: try:
from .auth import AuthManager from .auth import AuthManager
...@@ -425,7 +427,7 @@ class DashboardAPI: ...@@ -425,7 +427,7 @@ class DashboardAPI:
if not auth_manager: if not auth_manager:
return {"error": "Auth manager not available"} return {"error": "Auth manager not available"}
user = auth_manager.update_user(user_id, username, email, password, is_admin) user = auth_manager.update_user(user_id, username, email, password, is_admin, role)
if user: if user:
return { return {
...@@ -434,7 +436,8 @@ class DashboardAPI: ...@@ -434,7 +436,8 @@ class DashboardAPI:
"id": user["id"], "id": user["id"],
"username": user["username"], "username": user["username"],
"email": user["email"], "email": user["email"],
"is_admin": user["is_admin"] "is_admin": user["is_admin"],
"role": user.get("role", "normal")
} }
} }
else: else:
......
...@@ -115,7 +115,8 @@ class WebDashboard(ThreadedComponent): ...@@ -115,7 +115,8 @@ class WebDashboard(ThreadedComponent):
user_id=user_model.id, user_id=user_model.id,
username=user_model.username, username=user_model.username,
email=user_model.email, email=user_model.email,
is_admin=user_model.is_admin is_admin=user_model.is_admin,
role=getattr(user_model, 'role', 'normal')
) )
return None return None
......
...@@ -6,7 +6,7 @@ import hashlib ...@@ -6,7 +6,7 @@ import hashlib
import secrets import secrets
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any, Tuple from typing import Optional, Dict, Any, Tuple, List
from flask import Flask, request, session from flask import Flask, request, session
from flask_login import UserMixin from flask_login import UserMixin
from flask_jwt_extended import create_access_token, decode_token from flask_jwt_extended import create_access_token, decode_token
...@@ -21,11 +21,12 @@ logger = logging.getLogger(__name__) ...@@ -21,11 +21,12 @@ logger = logging.getLogger(__name__)
class AuthenticatedUser(UserMixin): class AuthenticatedUser(UserMixin):
"""User class for Flask-Login""" """User class for Flask-Login"""
def __init__(self, user_id: int, username: str, email: str, is_admin: bool = False): def __init__(self, user_id: int, username: str, email: str, is_admin: bool = False, role: str = 'normal'):
self.id = user_id self.id = user_id
self.username = username self.username = username
self.email = email self.email = email
self.is_admin = is_admin self.is_admin = is_admin
self.role = role
# Don't set Flask-Login properties - they are handled by UserMixin # Don't set Flask-Login properties - they are handled by UserMixin
def get_id(self): def get_id(self):
...@@ -51,8 +52,21 @@ class AuthenticatedUser(UserMixin): ...@@ -51,8 +52,21 @@ class AuthenticatedUser(UserMixin):
'id': self.id, 'id': self.id,
'username': self.username, 'username': self.username,
'email': self.email, 'email': self.email,
'is_admin': self.is_admin 'is_admin': self.is_admin,
'role': self.role
} }
def is_admin_user(self) -> bool:
"""Check if user has admin role"""
return self.role == 'admin' or self.is_admin
def is_cashier_user(self) -> bool:
"""Check if user has cashier role"""
return self.role == 'cashier'
def is_normal_user(self) -> bool:
"""Check if user has normal role"""
return self.role == 'normal'
class AuthManager: class AuthManager:
...@@ -101,7 +115,8 @@ class AuthManager: ...@@ -101,7 +115,8 @@ class AuthManager:
user_id=user.id, user_id=user.id,
username=user.username, username=user.username,
email=user.email, email=user.email,
is_admin=user.is_admin is_admin=user.is_admin,
role=getattr(user, 'role', 'normal') # Default to normal if role field doesn't exist yet
) )
logger.info(f"User authenticated successfully: {username}") logger.info(f"User authenticated successfully: {username}")
...@@ -112,7 +127,7 @@ class AuthManager: ...@@ -112,7 +127,7 @@ class AuthManager:
return None return None
def create_user(self, username: str, email: str, password: str, def create_user(self, username: str, email: str, password: str,
is_admin: bool = False) -> Optional[Dict[str, Any]]: is_admin: bool = False, role: str = 'normal') -> Optional[Dict[str, Any]]:
"""Create new user""" """Create new user"""
try: try:
# Check if user already exists # Check if user already exists
...@@ -136,6 +151,13 @@ class AuthManager: ...@@ -136,6 +151,13 @@ class AuthManager:
created_at=datetime.utcnow() created_at=datetime.utcnow()
) )
# Set role (handle backward compatibility)
if hasattr(user, 'set_role'):
user.set_role(role)
elif hasattr(user, 'role'):
user.role = role
user.is_admin = (role == 'admin')
saved_user_data = self.db_manager.save_user(user) saved_user_data = self.db_manager.save_user(user)
logger.info(f"User created successfully: {username}") logger.info(f"User created successfully: {username}")
return saved_user_data return saved_user_data
...@@ -145,7 +167,7 @@ class AuthManager: ...@@ -145,7 +167,7 @@ class AuthManager:
return None return None
def update_user(self, user_id: int, username: str = None, email: str = None, def update_user(self, user_id: int, username: str = None, email: str = None,
password: str = None, is_admin: bool = None) -> Optional[Dict[str, Any]]: password: str = None, is_admin: bool = None, role: str = None) -> Optional[Dict[str, Any]]:
"""Update user information""" """Update user information"""
try: try:
user = self.db_manager.get_user_by_id(user_id) user = self.db_manager.get_user_by_id(user_id)
...@@ -177,6 +199,14 @@ class AuthManager: ...@@ -177,6 +199,14 @@ class AuthManager:
if is_admin is not None: if is_admin is not None:
user.is_admin = is_admin user.is_admin = is_admin
# Update role if provided
if role is not None:
if hasattr(user, 'set_role'):
user.set_role(role)
elif hasattr(user, 'role'):
user.role = role
user.is_admin = (role == 'admin')
# Update timestamp # Update timestamp
user.updated_at = datetime.utcnow() user.updated_at = datetime.utcnow()
...@@ -378,6 +408,7 @@ class AuthManager: ...@@ -378,6 +408,7 @@ class AuthManager:
'user_id': user.id, 'user_id': user.id,
'username': user.username, 'username': user.username,
'is_admin': user.is_admin, 'is_admin': user.is_admin,
'role': getattr(user, 'role', 'normal'),
'token_name': api_token.name, 'token_name': api_token.name,
'token_id': api_token.id 'token_id': api_token.id
} }
...@@ -476,9 +507,32 @@ class AuthManager: ...@@ -476,9 +507,32 @@ class AuthManager:
if not hasattr(request, 'current_user'): if not hasattr(request, 'current_user'):
return {'error': 'Authentication required'}, 401 return {'error': 'Authentication required'}, 401
if not request.current_user.get('is_admin', False): user_role = request.current_user.get('role', 'normal')
is_admin = request.current_user.get('is_admin', False)
if user_role != 'admin' and not is_admin:
return {'error': 'Admin access required'}, 403 return {'error': 'Admin access required'}, 403
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
\ No newline at end of file
def require_role(self, allowed_roles: List[str]):
"""Decorator for routes requiring specific roles"""
from functools import wraps
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not hasattr(request, 'current_user'):
return {'error': 'Authentication required'}, 401
user_role = request.current_user.get('role', 'normal')
if user_role not in allowed_roles:
return {'error': f'Access denied. Required roles: {", ".join(allowed_roles)}'}, 403
return f(*args, **kwargs)
return decorated_function
return decorator
\ No newline at end of file
...@@ -37,9 +37,15 @@ api_bp.message_bus = None ...@@ -37,9 +37,15 @@ api_bp.message_bus = None
@main_bp.route('/') @main_bp.route('/')
@login_required @login_required
def index(): def index():
"""Dashboard home page""" """Dashboard home page - redirects cashier users to cashier dashboard"""
try: try:
return render_template('dashboard/index.html', # Check if user is cashier and redirect them to cashier dashboard
if hasattr(current_user, 'role') and current_user.role == 'cashier':
return redirect(url_for('main.cashier_dashboard'))
elif hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user():
return redirect(url_for('main.cashier_dashboard'))
return render_template('dashboard/index.html',
user=current_user, user=current_user,
page_title="Dashboard") page_title="Dashboard")
except Exception as e: except Exception as e:
...@@ -48,6 +54,26 @@ def index(): ...@@ -48,6 +54,26 @@ def index():
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
@main_bp.route('/cashier')
@login_required
def cashier_dashboard():
"""Cashier-specific dashboard page"""
try:
# Verify user is cashier
if not (hasattr(current_user, 'role') and current_user.role == 'cashier'):
if not (hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user()):
flash("Cashier access required", "error")
return redirect(url_for('main.index'))
return render_template('dashboard/cashier.html',
user=current_user,
page_title="Cashier Dashboard")
except Exception as e:
logger.error(f"Cashier dashboard error: {e}")
flash("Error loading cashier dashboard", "error")
return render_template('errors/500.html'), 500
@main_bp.route('/video') @main_bp.route('/video')
@login_required @login_required
def video_control_page(): def video_control_page():
...@@ -67,6 +93,14 @@ def video_control_page(): ...@@ -67,6 +93,14 @@ def video_control_page():
def templates(): def templates():
"""Template management page""" """Template management page"""
try: try:
# Restrict cashier users from accessing templates 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/templates.html', return render_template('dashboard/templates.html',
user=current_user, user=current_user,
page_title="Templates") page_title="Templates")
...@@ -83,10 +117,63 @@ def configuration(): ...@@ -83,10 +117,63 @@ def configuration():
try: try:
if not current_user.is_admin: if not current_user.is_admin:
flash("Admin access required", "error") flash("Admin access required", "error")
# Redirect cashier users to their dashboard
if hasattr(current_user, 'role') and current_user.role == 'cashier':
return redirect(url_for('main.cashier_dashboard'))
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.index')) return redirect(url_for('main.index'))
# Get current configuration values
config_data = {}
try:
if main_bp.config_manager:
# Load configuration values from database/config manager
general_config = main_bp.config_manager.get_section_config("general") or {}
video_config = main_bp.config_manager.get_section_config("video") or {}
database_config = main_bp.config_manager.get_section_config("database") or {}
api_config = main_bp.config_manager.get_section_config("api") or {}
config_data.update({
# General settings
'app_name': general_config.get('app_name', 'MbetterClient'),
'log_level': general_config.get('log_level', 'INFO'),
'enable_qt': general_config.get('enable_qt', True),
# Video settings
'video_width': video_config.get('video_width', 1920),
'video_height': video_config.get('video_height', 1080),
'fullscreen': video_config.get('fullscreen', False),
# Database settings
'db_path': database_config.get('db_path', 'data/mbetterclient.db'),
# API settings
'fastapi_url': api_config.get('fastapi_url', 'https://mbetter.nexlab.net/api/updates'),
'api_token': api_config.get('api_token', ''),
'api_timeout': api_config.get('api_timeout', 30),
'api_enabled': api_config.get('api_enabled', True)
})
except Exception as e:
logger.warning(f"Error loading configuration values: {e}")
# Use defaults if config loading fails
config_data = {
'app_name': 'MbetterClient',
'log_level': 'INFO',
'enable_qt': True,
'video_width': 1920,
'video_height': 1080,
'fullscreen': False,
'db_path': 'data/mbetterclient.db',
'fastapi_url': 'https://mbetter.nexlab.net/api/updates',
'api_token': '',
'api_timeout': 30,
'api_enabled': True
}
return render_template('dashboard/config.html', return render_template('dashboard/config.html',
user=current_user, user=current_user,
config=config_data,
page_title="Configuration") page_title="Configuration")
except Exception as e: except Exception as e:
logger.error(f"Configuration page error: {e}") logger.error(f"Configuration page error: {e}")
...@@ -101,6 +188,11 @@ def users(): ...@@ -101,6 +188,11 @@ def users():
try: try:
if not current_user.is_admin: if not current_user.is_admin:
flash("Admin access required", "error") flash("Admin access required", "error")
# Redirect cashier users to their dashboard
if hasattr(current_user, 'role') and current_user.role == 'cashier':
return redirect(url_for('main.cashier_dashboard'))
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.index')) return redirect(url_for('main.index'))
return render_template('dashboard/users.html', return render_template('dashboard/users.html',
...@@ -117,6 +209,14 @@ def users(): ...@@ -117,6 +209,14 @@ def users():
def api_tokens(): def api_tokens():
"""API token management page""" """API token management page"""
try: try:
# Restrict cashier users from accessing API tokens 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/tokens.html', return render_template('dashboard/tokens.html',
user=current_user, user=current_user,
page_title="API Tokens") page_title="API Tokens")
...@@ -133,6 +233,11 @@ def logs(): ...@@ -133,6 +233,11 @@ def logs():
try: try:
if not current_user.is_admin: if not current_user.is_admin:
flash("Admin access required", "error") flash("Admin access required", "error")
# Redirect cashier users to their dashboard
if hasattr(current_user, 'role') and current_user.role == 'cashier':
return redirect(url_for('main.cashier_dashboard'))
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.index')) return redirect(url_for('main.index'))
return render_template('dashboard/logs.html', return render_template('dashboard/logs.html',
...@@ -168,12 +273,18 @@ def login(): ...@@ -168,12 +273,18 @@ def login():
login_user(authenticated_user, remember=remember_me) login_user(authenticated_user, remember=remember_me)
logger.info(f"User logged in: {username}") logger.info(f"User logged in: {username}")
# Redirect to next page or dashboard # Redirect to next page or appropriate dashboard
next_page = request.args.get('next') next_page = request.args.get('next')
if next_page: if next_page:
return redirect(next_page) return redirect(next_page)
else: else:
return redirect(url_for('main.index')) # Redirect cashier users to their specific dashboard
if hasattr(authenticated_user, 'role') and authenticated_user.role == 'cashier':
return redirect(url_for('main.cashier_dashboard'))
elif hasattr(authenticated_user, 'is_cashier_user') and authenticated_user.is_cashier_user():
return redirect(url_for('main.cashier_dashboard'))
else:
return redirect(url_for('main.index'))
else: else:
flash("Invalid username or password", "error") flash("Invalid username or password", "error")
return render_template('auth/login.html') return render_template('auth/login.html')
...@@ -191,6 +302,14 @@ def login(): ...@@ -191,6 +302,14 @@ def login():
def video_test(): def video_test():
"""Video upload test page""" """Video upload test page"""
try: try:
# Restrict cashier users from accessing video test 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/video_test.html', return render_template('dashboard/video_test.html',
user=current_user, user=current_user,
page_title="Video Upload Test") page_title="Video Upload Test")
...@@ -367,6 +486,22 @@ def update_config_section(section): ...@@ -367,6 +486,22 @@ def update_config_section(section):
try: try:
data = request.get_json() or {} data = request.get_json() or {}
result = api_bp.api.update_configuration(section, data) result = api_bp.api.update_configuration(section, data)
# If updating API configuration, notify API client to reload
if section == "api" and result.get("success", False):
try:
from ..core.message_bus import MessageBuilder, MessageType
if api_bp.message_bus:
config_update_message = MessageBuilder.config_update(
sender="web_dashboard",
config_section="api",
config_data=data
)
api_bp.message_bus.publish(config_update_message)
logger.info("API configuration update message sent to message bus")
except Exception as msg_e:
logger.warning(f"Failed to send config update message: {msg_e}")
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
logger.error(f"API update config section error: {e}") logger.error(f"API update config section error: {e}")
...@@ -394,6 +529,86 @@ def update_configuration(): ...@@ -394,6 +529,86 @@ def update_configuration():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@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_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def test_api_connection():
"""Test connection to FastAPI server using request data or configured values"""
try:
data = request.get_json() or {}
# Get URL and token from request first, fallback to configuration
url = data.get('url', '').strip()
token = data.get('token', '').strip()
# If no URL/token in request, load from configuration
if not url or not token:
try:
if api_bp.config_manager:
api_config = api_bp.config_manager.get_section_config("api") or {}
if not url:
url = api_config.get('fastapi_url', '').strip()
if not token:
token = api_config.get('api_token', '').strip()
else:
# Fallback to default values if config manager not available
if not url:
url = 'https://mbetter.nexlab.net/api/updates'
except Exception as e:
logger.warning(f"Failed to load API configuration for test: {e}")
# Fallback to default values
if not url:
url = 'https://mbetter.nexlab.net/api/updates'
if not url:
return jsonify({"error": "URL is required"}), 400
# Test the connection
import requests
from urllib.parse import urljoin
# Normalize URL
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
# Call the exact configured URL without any modifications
session = requests.Session()
session.timeout = 10
# Prepare request data
request_data = {}
if token:
# Add authentication in header and request body
session.headers.update({
'Authorization': f'Bearer {token}'
})
request_data['token'] = token
try:
# Call the exact URL as configured with POST request
response = session.post(url, json=request_data, timeout=10)
auth_status = "with authentication" if token else "without authentication"
return jsonify({
"success": True,
"message": f"Connection successful {auth_status}! Server responded with status {response.status_code}",
"url": url,
"status_code": response.status_code,
"authenticated": bool(token)
})
except Exception as e:
auth_info = " (with authentication)" if token else " (without authentication)"
return jsonify({
"success": False,
"error": f"Connection failed{auth_info}. Error: {str(e)}"
}), 400
except Exception as e:
logger.error(f"API connection test error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/users') @api_bp.route('/users')
@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
...@@ -418,11 +633,16 @@ def create_user(): ...@@ -418,11 +633,16 @@ def create_user():
email = data.get('email', '').strip() email = data.get('email', '').strip()
password = data.get('password', '') password = data.get('password', '')
is_admin = data.get('is_admin', False) is_admin = data.get('is_admin', False)
role = data.get('role', 'normal')
if not all([username, email, password]): if not all([username, email, password]):
return jsonify({"error": "Username, email, and password are required"}), 400 return jsonify({"error": "Username, email, and password are required"}), 400
result = api_bp.api.create_user(username, email, password, is_admin) # Validate role
if role not in ['admin', 'normal', 'cashier']:
return jsonify({"error": "Invalid role. Must be admin, normal, or cashier"}), 400
result = api_bp.api.create_user(username, email, password, is_admin, role)
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
...@@ -441,8 +661,13 @@ def update_user(user_id): ...@@ -441,8 +661,13 @@ def update_user(user_id):
email = data.get('email') email = data.get('email')
password = data.get('password') password = data.get('password')
is_admin = data.get('is_admin') is_admin = data.get('is_admin')
role = data.get('role')
# Validate role if provided
if role and role not in ['admin', 'normal', 'cashier']:
return jsonify({"error": "Invalid role. Must be admin, normal, or cashier"}), 400
result = api_bp.api.update_user(user_id, username, email, password, is_admin) result = api_bp.api.update_user(user_id, username, email, password, is_admin, role)
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
......
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cashier Dashboard - {{ app_name }}</title>
<!-- CSS from CDN -->
<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="{{ url_for('static', filename='css/dashboard.css') }}">
</head>
<body>
<!-- Simplified Navigation Bar for Cashier -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="#">
<i class="fas fa-cash-register me-2"></i>{{ app_name }} - Cashier
</a>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">
<i class="fas fa-sign-out-alt me-1"></i>Logout
</a>
</li>
</ul>
</div>
</nav>
<main class="container-fluid mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{% if category == 'error' %}
<i class="fas fa-exclamation-triangle me-2"></i>
{% elif category == 'success' %}
<i class="fas fa-check-circle me-2"></i>
{% elif category == 'info' %}
<i class="fas fa-info-circle me-2"></i>
{% endif %}
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-cash-register me-2"></i>Cashier Dashboard
<small class="text-muted">Welcome, {{ current_user.username }}</small>
</h1>
</div>
</div>
<!-- Quick Actions for Cashier -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-bolt me-2"></i>Cashier Controls
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<button class="btn btn-outline-primary w-100" id="btn-play-video">
<i class="fas fa-play me-2"></i>Start Video Display
</button>
</div>
<div class="col-md-6 mb-3">
<button class="btn btn-outline-success w-100" id="btn-update-overlay">
<i class="fas fa-edit me-2"></i>Update Display Overlay
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Current Display Status -->
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-desktop me-2"></i>Current Display Status
</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<div class="d-flex flex-column align-items-center">
<i class="fas fa-video text-primary mb-2" style="font-size: 2rem;"></i>
<h5 class="text-primary mb-1" id="video-status-text">Stopped</h5>
<small class="text-muted">Video Status</small>
</div>
</div>
<div class="col-6">
<div class="d-flex flex-column align-items-center">
<i class="fas fa-layer-group text-success mb-2" style="font-size: 2rem;"></i>
<h5 class="text-success mb-1" id="overlay-status-text">Ready</h5>
<small class="text-muted">Overlay Status</small>
</div>
</div>
</div>
<div class="mt-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Current Video:</strong>
<span id="current-video-path" class="text-muted">No video loaded</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<strong>Current Template:</strong>
<span id="current-template-name" class="text-muted">default</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-user me-2"></i>Session Information
</h5>
</div>
<div class="card-body">
<dl class="mb-0">
<dt class="text-muted">User</dt>
<dd>{{ current_user.username }}</dd>
<dt class="text-muted">Role</dt>
<dd>
<span class="badge bg-info">Cashier</span>
</dd>
<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>
<dt class="text-muted">System Status</dt>
<dd>
<span class="badge bg-success" id="system-status">Online</span>
</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-chart-bar me-2"></i>Today's Activity
</h5>
</div>
<div class="card-body text-center">
<div class="row">
<div class="col-12 mb-3">
<h4 class="text-primary" id="videos-played">0</h4>
<small class="text-muted">Videos Played</small>
</div>
<div class="col-12">
<h4 class="text-success" id="overlays-updated">0</h4>
<small class="text-muted">Overlays Updated</small>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Video Control Modal -->
<div class="modal fade" id="playVideoModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Start Video Display</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="play-video-form">
<div class="mb-3">
<label class="form-label">Video File Path</label>
<input type="text" class="form-control" id="video-file-path"
placeholder="/path/to/video.mp4">
<div class="form-text">Enter the full path to the video file you want to display</div>
</div>
<div class="mb-3">
<label class="form-label">Display Template</label>
<select class="form-select" id="video-template">
<option value="">Loading templates...</option>
</select>
<div class="form-text">Choose the template for displaying information over the video</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm-play-video">
<i class="fas fa-play me-1"></i>Start Display
</button>
</div>
</div>
</div>
</div>
<!-- Overlay Update Modal -->
<div class="modal fade" id="updateOverlayModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Update Display Overlay</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="update-overlay-form">
<div class="mb-3">
<label class="form-label">Display Template</label>
<select class="form-select" id="overlay-template">
<option value="">Loading templates...</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Main Message</label>
<input type="text" class="form-control" id="overlay-headline"
placeholder="Special Offer">
<div class="form-text">The main headline or title to display</div>
</div>
<div class="mb-3">
<label class="form-label">Additional Information</label>
<textarea class="form-control" id="overlay-text" rows="3"
placeholder="20% off all items today only!"></textarea>
<div class="form-text">Additional text or details to show</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="confirm-update-overlay">
<i class="fas fa-edit me-1"></i>Update Display
</button>
</div>
</div>
</div>
</div>
<!-- System Status Bar -->
<div id="status-bar" class="fixed-bottom bg-light border-top p-2 d-none d-lg-block">
<div class="container-fluid">
<div class="row align-items-center text-small">
<div class="col-auto">
<span class="text-muted">Status:</span>
<span id="system-status" class="badge bg-success">Online</span>
</div>
<div class="col-auto">
<span class="text-muted">Video:</span>
<span id="video-status" class="badge bg-secondary">Stopped</span>
</div>
<div class="col-auto">
<span class="text-muted">Last Updated:</span>
<span id="last-updated" class="text-muted">--</span>
</div>
<div class="col text-end">
<small class="text-muted">{{ app_name }} v{{ app_version }}</small>
</div>
</div>
</div>
</div>
<!-- JavaScript from CDN and local -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
<script id="dashboard-config" type="application/json">
{
"statusUpdateInterval": 30000,
"apiEndpoint": "/api",
"user": {
"id": {{ current_user.id | tojson }},
"username": {{ current_user.username | tojson }},
"is_admin": {{ current_user.is_admin | tojson }}
}
}
</script>
<script>
// Initialize dashboard
document.addEventListener('DOMContentLoaded', function() {
if (typeof Dashboard !== 'undefined') {
var config = JSON.parse(document.getElementById('dashboard-config').textContent);
Dashboard.init(config);
}
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load available templates on page load
loadAvailableTemplates();
// Quick action buttons
document.getElementById('btn-play-video').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('playVideoModal')).show();
});
document.getElementById('btn-update-overlay').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('updateOverlayModal')).show();
});
// Confirm actions
document.getElementById('confirm-play-video').addEventListener('click', function() {
const filePath = document.getElementById('video-file-path').value;
const template = document.getElementById('video-template').value;
if (!filePath) {
alert('Please enter a video file path');
return;
}
fetch('/api/video/control', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'play',
file_path: filePath,
template: template
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('playVideoModal')).hide();
updateVideoStatus();
updateCurrentVideoPath(filePath);
updateCurrentTemplate(template || 'default');
// Increment counter
const videosCount = parseInt(document.getElementById('videos-played').textContent) + 1;
document.getElementById('videos-played').textContent = videosCount;
} else {
alert('Failed to start video: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
});
document.getElementById('confirm-update-overlay').addEventListener('click', function() {
const template = document.getElementById('overlay-template').value;
const headline = document.getElementById('overlay-headline').value;
const text = document.getElementById('overlay-text').value;
fetch('/api/overlay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: template,
data: {
headline: headline,
ticker_text: text,
title: headline,
text: text
}
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('updateOverlayModal')).hide();
updateCurrentTemplate(template || 'default');
// Increment counter
const overlaysCount = parseInt(document.getElementById('overlays-updated').textContent) + 1;
document.getElementById('overlays-updated').textContent = overlaysCount;
// Show success message
showNotification('Overlay updated successfully!', 'success');
} else {
alert('Failed to update overlay: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
});
// Status update functions
function updateVideoStatus() {
fetch('/api/video/status')
.then(response => response.json())
.then(data => {
const status = data.player_status || 'stopped';
document.getElementById('video-status-text').textContent =
status.charAt(0).toUpperCase() + status.slice(1);
})
.catch(error => {
document.getElementById('video-status-text').textContent = 'Unknown';
});
}
function updateCurrentVideoPath(path) {
const fileName = path.split('/').pop() || path;
document.getElementById('current-video-path').textContent = fileName;
}
function updateCurrentTemplate(template) {
document.getElementById('current-template-name').textContent = template;
}
function showNotification(message, type = 'info') {
// Simple notification system - could be enhanced with toast notifications
const alertClass = type === 'success' ? 'alert-success' : 'alert-info';
const notification = document.createElement('div');
notification.className = `alert ${alertClass} alert-dismissible fade show position-fixed`;
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 3 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
}
// Initial status update
updateVideoStatus();
// Periodic status updates
setInterval(updateVideoStatus, 10000); // Every 10 seconds
});
function loadAvailableTemplates() {
fetch('/api/templates')
.then(response => response.json())
.then(data => {
const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template');
// Clear loading options
videoTemplateSelect.innerHTML = '';
overlayTemplateSelect.innerHTML = '';
if (data.templates && Array.isArray(data.templates)) {
data.templates.forEach(template => {
// Add to video template select
const videoOption = document.createElement('option');
videoOption.value = template.name;
videoOption.textContent = template.display_name || template.name;
videoTemplateSelect.appendChild(videoOption);
// Add to overlay template select
const overlayOption = document.createElement('option');
overlayOption.value = template.name;
overlayOption.textContent = template.display_name || template.name;
overlayTemplateSelect.appendChild(overlayOption);
});
// Select default template if available
const defaultVideoOption = videoTemplateSelect.querySelector('option[value="default"]');
if (defaultVideoOption) {
defaultVideoOption.selected = true;
}
const defaultOverlayOption = overlayTemplateSelect.querySelector('option[value="default"]');
if (defaultOverlayOption) {
defaultOverlayOption.selected = true;
}
} else {
// Fallback if no templates found
const videoOption = document.createElement('option');
videoOption.value = 'default';
videoOption.textContent = 'Default';
videoTemplateSelect.appendChild(videoOption);
const overlayOption = document.createElement('option');
overlayOption.value = 'default';
overlayOption.textContent = 'Default';
overlayTemplateSelect.appendChild(overlayOption);
}
})
.catch(error => {
console.error('Error loading templates:', error);
// Fallback template options
const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template');
videoTemplateSelect.innerHTML = '<option value="default">Default</option>';
overlayTemplateSelect.innerHTML = '<option value="default">Default</option>';
});
}
</script>
</body>
</html>
\ No newline at end of file
...@@ -66,6 +66,49 @@ ...@@ -66,6 +66,49 @@
</div> </div>
</div> </div>
<!-- API Settings -->
<div class="card mb-4">
<div class="card-header">
<h5>API Settings</h5>
</div>
<div class="card-body">
<form id="api-config-form">
<div class="mb-3">
<label for="fastapi-url" class="form-label">FastAPI Server URL</label>
<input type="url" class="form-control" id="fastapi-url"
value="{{ config.fastapi_url or 'https://mbetter.nexlab.net/api/updates' }}"
placeholder="https://mbetter.nexlab.net/api/updates">
<div class="form-text">Base URL for FastAPI server requests (include https:// or http://)</div>
</div>
<div class="mb-3">
<label for="api-token" class="form-label">API Access Token</label>
<input type="password" class="form-control" id="api-token"
value="{{ config.api_token or '' }}"
placeholder="Enter your API access token">
<div class="form-text">Authentication token for FastAPI server access</div>
</div>
<div class="mb-3">
<label for="api-timeout" class="form-label">Request Timeout (seconds)</label>
<input type="number" class="form-control" id="api-timeout"
value="{{ config.api_timeout or 30 }}" min="5" max="300">
<div class="form-text">Timeout for API requests (5-300 seconds)</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="api-enabled"
{% if config.api_enabled != false %}checked{% endif %}>
<label class="form-check-label" for="api-enabled">
Enable API Client
</label>
<div class="form-text">Enable/disable automatic API requests</div>
</div>
<button type="submit" class="btn btn-primary">Save API Settings</button>
<button type="button" class="btn btn-outline-secondary ms-2" id="test-api-connection">
Test Connection
</button>
</form>
</div>
</div>
<!-- Database Settings --> <!-- Database Settings -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
...@@ -123,6 +166,61 @@ ...@@ -123,6 +166,61 @@
saveConfig('database', config); saveConfig('database', config);
}); });
// Save API configuration
document.getElementById('api-config-form').addEventListener('submit', function(e) {
e.preventDefault();
const config = {
fastapi_url: document.getElementById('fastapi-url').value,
api_token: document.getElementById('api-token').value,
api_timeout: parseInt(document.getElementById('api-timeout').value),
api_enabled: document.getElementById('api-enabled').checked
};
saveConfig('api', config);
});
// Test API connection
document.getElementById('test-api-connection').addEventListener('click', function() {
const url = document.getElementById('fastapi-url').value;
const token = document.getElementById('api-token').value;
if (!url) {
alert('Please enter a FastAPI URL first');
return;
}
this.disabled = true;
this.textContent = 'Testing...';
fetch('/api/config/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: url,
token: token
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Connection successful! Response: ' + (data.message || 'OK'));
} else {
alert('Connection failed: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Connection test failed: ' + error.message);
})
.finally(() => {
this.disabled = false;
this.textContent = 'Test Connection';
});
});
// Generic config save function // Generic config save function
function saveConfig(section, config) { function saveConfig(section, config) {
fetch('/api/config/' + section, { fetch('/api/config/' + section, {
......
...@@ -10,72 +10,6 @@ ...@@ -10,72 +10,6 @@
</div> </div>
</div> </div>
<!-- System Status Cards -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">System Status</h6>
<h4 id="system-status-text">Online</h4>
</div>
<div class="align-self-center">
<i class="fas fa-server fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">Video Player</h6>
<h4 id="video-status-text">Ready</h4>
</div>
<div class="align-self-center">
<i class="fas fa-video fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">Active Template</h6>
<h4 id="active-template">News</h4>
</div>
<div class="align-self-center">
<i class="fas fa-layer-group fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card bg-warning text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-title">API Tokens</h6>
<h4 id="token-count">0</h4>
</div>
<div class="align-self-center">
<i class="fas fa-key fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="row mb-4"> <div class="row mb-4">
...@@ -525,4 +459,4 @@ function loadAvailableTemplates() { ...@@ -525,4 +459,4 @@ function loadAvailableTemplates() {
}); });
} }
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
...@@ -66,11 +66,18 @@ ...@@ -66,11 +66,18 @@
<label for="confirm-password" class="form-label">Confirm Password</label> <label for="confirm-password" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirm-password" required> <input type="password" class="form-control" id="confirm-password" required>
</div> </div>
<div class="form-check mb-3"> <div class="mb-3">
<input class="form-check-input" type="checkbox" id="is-admin"> <label for="user-role" class="form-label">User Role</label>
<label class="form-check-label" for="is-admin"> <select class="form-select" id="user-role" required>
Administrator <option value="normal">Normal User</option>
</label> <option value="cashier">Cashier</option>
<option value="admin">Administrator</option>
</select>
<div class="form-text">
<strong>Normal User:</strong> Full dashboard access<br>
<strong>Cashier:</strong> Limited access to video/overlay controls only<br>
<strong>Administrator:</strong> Full system access including user management
</div>
</div> </div>
</form> </form>
</div> </div>
...@@ -98,15 +105,43 @@ ...@@ -98,15 +105,43 @@
const users = data.users || []; const users = data.users || [];
users.forEach(user => { users.forEach(user => {
// Determine role display
let roleDisplay = 'Normal User';
let roleBadgeClass = 'bg-primary';
if (user.role) {
if (user.role === 'admin') {
roleDisplay = 'Administrator';
roleBadgeClass = 'bg-danger';
} else if (user.role === 'cashier') {
roleDisplay = 'Cashier';
roleBadgeClass = 'bg-info';
} else {
roleDisplay = 'Normal User';
roleBadgeClass = 'bg-primary';
}
} else if (user.is_admin) {
// Backward compatibility
roleDisplay = 'Administrator';
roleBadgeClass = 'bg-danger';
}
const row = document.createElement('tr'); const row = document.createElement('tr');
// Disable delete for the default admin user
const isDefaultAdmin = user.username === 'admin';
const deleteButton = isDefaultAdmin
? `<button class="btn btn-sm btn-danger" disabled title="Cannot delete default admin user">Delete</button>`
: `<button class="btn btn-sm btn-danger delete-user" data-id="${user.id}">Delete</button>`;
row.innerHTML = ` row.innerHTML = `
<td>${user.username}</td> <td>${user.username}</td>
<td>${user.email}</td> <td>${user.email}</td>
<td>${user.is_admin ? 'Administrator' : 'User'}</td> <td><span class="badge ${roleBadgeClass}">${roleDisplay}</span></td>
<td>${user.last_login || 'Never'}</td> <td>${user.last_login || 'Never'}</td>
<td> <td>
<button class="btn btn-sm btn-primary edit-user" data-id="${user.id}">Edit</button> <button class="btn btn-sm btn-primary edit-user" data-id="${user.id}" data-role="${user.role || (user.is_admin ? 'admin' : 'normal')}">Edit</button>
<button class="btn btn-sm btn-danger delete-user" data-id="${user.id}">Delete</button> ${deleteButton}
</td> </td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
...@@ -135,11 +170,12 @@ ...@@ -135,11 +170,12 @@
// Edit user // Edit user
function editUser(userId) { function editUser(userId) {
// Get user data first // Get user data first
const userRow = document.querySelector(`[data-id="${userId}"]`).closest('tr'); const editButton = document.querySelector(`[data-id="${userId}"].edit-user`);
const userRow = editButton.closest('tr');
const cells = userRow.querySelectorAll('td'); const cells = userRow.querySelectorAll('td');
const currentUsername = cells[0].textContent; const currentUsername = cells[0].textContent;
const currentEmail = cells[1].textContent; const currentEmail = cells[1].textContent;
const currentIsAdmin = cells[2].textContent === 'Administrator'; const currentRole = editButton.getAttribute('data-role') || 'normal';
// Create edit modal dynamically // Create edit modal dynamically
const editModal = ` const editModal = `
...@@ -164,11 +200,18 @@ ...@@ -164,11 +200,18 @@
<label for="edit-password" class="form-label">New Password (leave empty to keep current)</label> <label for="edit-password" class="form-label">New Password (leave empty to keep current)</label>
<input type="password" class="form-control" id="edit-password"> <input type="password" class="form-control" id="edit-password">
</div> </div>
<div class="form-check mb-3"> <div class="mb-3">
<input class="form-check-input" type="checkbox" id="edit-is-admin" ${currentIsAdmin ? 'checked' : ''}> <label for="edit-user-role" class="form-label">User Role</label>
<label class="form-check-label" for="edit-is-admin"> <select class="form-select" id="edit-user-role" required>
Administrator <option value="normal" ${currentRole === 'normal' ? 'selected' : ''}>Normal User</option>
</label> <option value="cashier" ${currentRole === 'cashier' ? 'selected' : ''}>Cashier</option>
<option value="admin" ${currentRole === 'admin' ? 'selected' : ''}>Administrator</option>
</select>
<div class="form-text">
<strong>Normal User:</strong> Full dashboard access<br>
<strong>Cashier:</strong> Limited access to video/overlay controls only<br>
<strong>Administrator:</strong> Full system access including user management
</div>
</div> </div>
</form> </form>
</div> </div>
...@@ -199,17 +242,18 @@ ...@@ -199,17 +242,18 @@
const username = document.getElementById('edit-username').value; const username = document.getElementById('edit-username').value;
const email = document.getElementById('edit-email').value; const email = document.getElementById('edit-email').value;
const password = document.getElementById('edit-password').value; const password = document.getElementById('edit-password').value;
const isAdmin = document.getElementById('edit-is-admin').checked; const role = document.getElementById('edit-user-role').value;
if (!username || !email) { if (!username || !email || !role) {
alert('Username and email are required'); alert('Username, email and role are required');
return; return;
} }
const updateData = { const updateData = {
username: username, username: username,
email: email, email: email,
is_admin: isAdmin role: role,
is_admin: role === 'admin' // For backward compatibility
}; };
// Only include password if it's provided // Only include password if it's provided
...@@ -276,9 +320,9 @@ ...@@ -276,9 +320,9 @@
const email = document.getElementById('email').value; const email = document.getElementById('email').value;
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirm-password').value; const confirmPassword = document.getElementById('confirm-password').value;
const isAdmin = document.getElementById('is-admin').checked; const role = document.getElementById('user-role').value;
if (!username || !email || !password || !confirmPassword) { if (!username || !email || !password || !confirmPassword || !role) {
alert('All fields are required'); alert('All fields are required');
return; return;
} }
...@@ -298,7 +342,8 @@ ...@@ -298,7 +342,8 @@
username: username, username: username,
email: email, email: email,
password: password, password: password,
is_admin: isAdmin role: role,
is_admin: role === 'admin' // For backward compatibility
}) })
}) })
.then(response => response.json()) .then(response => response.json())
......
#!/usr/bin/env python3
from PyQt6.QtWidgets import QApplication, QMainWindow
import sys
app = QApplication(sys.argv)
window = QMainWindow()
window.show()
sys.exit(app.exec())
#!/usr/bin/env python3
"""
Cross-platform test script for persistent directory functionality
Tests MbetterClient directory creation on Windows, macOS, and Linux
"""
import sys
import platform
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent))
from mbetterclient.config.settings import get_user_data_dir, get_user_config_dir, get_user_cache_dir, is_pyinstaller_executable
def test_directory_creation():
"""Test directory creation and permissions across platforms"""
print("=" * 60)
print(f"MbetterClient Cross-Platform Directory Test")
print("=" * 60)
print(f"Platform: {platform.system()} {platform.release()}")
print(f"Python: {sys.version}")
print(f"PyInstaller mode: {is_pyinstaller_executable()}")
print()
# Test each directory type
directories = {
"Data Directory": get_user_data_dir,
"Config Directory": get_user_config_dir,
"Cache Directory": get_user_cache_dir
}
results = {}
for dir_name, dir_func in directories.items():
print(f"Testing {dir_name}...")
try:
# Get directory path
dir_path = dir_func()
print(f" Path: {dir_path}")
# Check if directory exists
if dir_path.exists():
print(f" ✅ Directory exists")
else:
print(f" ❌ Directory does not exist")
continue
# Check if directory is writable
test_file = dir_path / f'.test_write_{dir_name.lower().replace(" ", "_")}'
try:
test_file.write_text('MbetterClient test file')
test_file.unlink()
print(f" ✅ Directory is writable")
writable = True
except (OSError, PermissionError) as e:
print(f" ❌ Directory not writable: {e}")
writable = False
# Test subdirectory creation
test_subdir = dir_path / 'test_subdir'
try:
test_subdir.mkdir(exist_ok=True)
test_subdir.rmdir()
print(f" ✅ Can create subdirectories")
can_create_subdirs = True
except (OSError, PermissionError) as e:
print(f" ❌ Cannot create subdirectories: {e}")
can_create_subdirs = False
results[dir_name] = {
'path': str(dir_path),
'exists': dir_path.exists(),
'writable': writable,
'can_create_subdirs': can_create_subdirs
}
except Exception as e:
print(f" ❌ Error testing {dir_name}: {e}")
results[dir_name] = {
'path': 'ERROR',
'exists': False,
'writable': False,
'can_create_subdirs': False,
'error': str(e)
}
print()
return results
def test_database_path():
"""Test database path resolution"""
print("Testing Database Path Resolution...")
try:
from mbetterclient.config.settings import DatabaseConfig
# Test default database config
db_config = DatabaseConfig()
db_path = db_config.get_absolute_path()
print(f" Database path: {db_path}")
print(f" Parent directory: {db_path.parent}")
print(f" Parent exists: {db_path.parent.exists()}")
# Try to create parent directory
try:
db_path.parent.mkdir(parents=True, exist_ok=True)
print(f" ✅ Can create database parent directory")
except Exception as e:
print(f" ❌ Cannot create database parent directory: {e}")
return str(db_path)
except Exception as e:
print(f" ❌ Error testing database path: {e}")
return None
def test_application_directories():
"""Test actual application directory structure"""
print("Testing Application Directory Structure...")
try:
from mbetterclient.config.settings import AppSettings
settings = AppSettings()
settings.ensure_directories()
# Check directories that should be created
data_dir = get_user_data_dir()
config_dir = get_user_config_dir()
required_dirs = [
data_dir / "logs",
data_dir / "data",
data_dir / "uploads",
config_dir / "templates"
]
all_good = True
for req_dir in required_dirs:
if req_dir.exists():
print(f" ✅ {req_dir}")
else:
print(f" ❌ Missing: {req_dir}")
all_good = False
return all_good
except Exception as e:
print(f" ❌ Error testing application directories: {e}")
return False
def main():
"""Main test function"""
print("Starting MbetterClient cross-platform directory tests...")
print()
# Test basic directory creation
dir_results = test_directory_creation()
# Test database path
db_path = test_database_path()
print()
# Test application directory structure
app_dirs_ok = test_application_directories()
print()
# Summary
print("=" * 60)
print("TEST SUMMARY")
print("=" * 60)
all_tests_passed = True
for dir_name, result in dir_results.items():
status = "✅ PASS" if (result['exists'] and result['writable'] and result['can_create_subdirs']) else "❌ FAIL"
print(f"{dir_name}: {status}")
if 'error' in result:
print(f" Error: {result['error']}")
else:
print(f" Path: {result['path']}")
if status == "❌ FAIL":
all_tests_passed = False
print()
print(f"Database Path: {'✅ OK' if db_path else '❌ ERROR'}")
if db_path:
print(f" {db_path}")
print(f"Application Structure: {'✅ OK' if app_dirs_ok else '❌ ERROR'}")
print()
if all_tests_passed and db_path and app_dirs_ok:
print("🎉 ALL TESTS PASSED - Cross-platform persistence ready!")
return 0
else:
print("⚠️ SOME TESTS FAILED - Check errors above")
return 1
if __name__ == "__main__":
sys.exit(main())
\ No newline at end of file
#!/usr/bin/env python3
"""
Standalone test application for PyQt6 Video Player with QWebEngineView overlay
"""
import sys
import logging
import time
from pathlib import Path
from dataclasses import dataclass
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import QTimer
# Add project path for imports
project_path = Path(__file__).parent
sys.path.insert(0, str(project_path))
from mbetterclient.qt_player.qt6_player import Qt6VideoPlayer, PlayerWindow
from mbetterclient.core.message_bus import MessageBus, MessageBuilder
from mbetterclient.config.settings import QtConfig
@dataclass
class TestQtConfig:
"""Test configuration for Qt player"""
fullscreen: bool = False
window_width: int = 1280
window_height: int = 720
always_on_top: bool = False
auto_play: bool = True
volume: float = 0.8
mute: bool = False
def setup_logging():
"""Setup logging for the test application"""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler('qt6_player_test.log')
]
)
def test_standalone_player():
"""Test the standalone PyQt6 player window"""
print("Testing Standalone PyQt6 Player...")
app = QApplication(sys.argv)
config = TestQtConfig()
# Create player window directly
window = PlayerWindow(config)
# Show window
window.show()
# Test overlay updates
overlay_view = window.video_widget.get_overlay_view()
def update_overlay_demo():
"""Demo function to update overlay periodically"""
current_time = time.strftime("%H:%M:%S")
overlay_data = {
'title': f'PyQt6 Demo - {current_time}',
'subtitle': 'Multi-threaded Video Player with WebEngine Overlay',
'ticker': 'Real-time JavaScript ↔ Python Communication • Hardware Accelerated Video • Professional Animations'
}
overlay_view.update_overlay_data(overlay_data)
print(f"Updated overlay at {current_time}")
# Setup periodic overlay updates
timer = QTimer()
timer.timeout.connect(update_overlay_demo)
timer.start(2000) # Update every 2 seconds
# Initial overlay update
update_overlay_demo()
print("PyQt6 Player Window created successfully!")
print("Features demonstrated:")
print("- QMediaPlayer + QVideoWidget for hardware-accelerated video")
print("- QWebEngineView overlay with transparent background")
print("- QWebChannel bidirectional Python ↔ JavaScript communication")
print("- CSS3 animations with GSAP integration")
print("- Thread-safe signal/slot mechanisms")
print("- QTimer integration for real-time updates")
print("- Professional UI with responsive design")
print("- Cross-platform compatibility")
print("\nControls:")
print("- Space: Play/Pause")
print("- F11: Toggle Fullscreen")
print("- S: Toggle Stats Panel")
print("- M: Toggle Mute")
print("- Escape: Exit")
print("\nClose the window to exit the test.")
return app.exec()
def test_threaded_player():
"""Test the full threaded PyQt6 player component"""
print("Testing Threaded PyQt6 Player Component...")
# Create message bus
message_bus = MessageBus()
# Create Qt config
config = TestQtConfig()
# Create Qt6 player component
player = Qt6VideoPlayer(message_bus, config)
# Initialize player
if not player.initialize():
print("Failed to initialize Qt6VideoPlayer!")
return 1
# Start player in separate thread (simulation)
print("Qt6VideoPlayer initialized successfully!")
# Test sending messages
def send_test_messages():
"""Send test messages to player"""
time.sleep(2)
# Test overlay update
overlay_message = MessageBuilder.template_change(
sender="test_app",
template_name="demo_template",
template_data={
'title': 'Threaded Player Demo',
'subtitle': 'Message Bus Communication Test',
'ticker': 'Successfully communicating via MessageBus • Multi-threaded Architecture • Real-time Updates'
}
)
overlay_message.recipient = "qt6_player"
message_bus.publish(overlay_message)
print("Sent overlay update message")
# Test video info update
time.sleep(2)
video_info_message = MessageBuilder.system_status(
sender="test_app",
status="demo",
details={
'videoInfo': {
'resolution': '1920x1080',
'bitrate': '8.5 Mbps',
'codec': 'H.265/HEVC',
'fps': '60.0'
}
}
)
video_info_message.recipient = "qt6_player"
message_bus.publish(video_info_message)
print("Sent video info update")
# Setup test message timer
timer = QTimer()
timer.timeout.connect(send_test_messages)
timer.setSingleShot(True)
timer.start(1000) # Start after 1 second
print("Threaded player test started. Close the player window to exit.")
# Run the player (this would normally be in a separate thread)
try:
player.run()
except KeyboardInterrupt:
print("Stopping player...")
player.shutdown()
return 0
def main():
"""Main test function"""
setup_logging()
print("PyQt6 Multi-threaded Video Player Test Suite")
print("=" * 50)
if len(sys.argv) > 1:
test_mode = sys.argv[1]
else:
print("Available test modes:")
print("1. standalone - Test standalone player window")
print("2. threaded - Test full threaded player component")
print()
test_mode = input("Select test mode (1 or 2): ").strip()
if test_mode == "1":
test_mode = "standalone"
elif test_mode == "2":
test_mode = "threaded"
else:
test_mode = "standalone"
try:
if test_mode == "standalone":
return test_standalone_player()
elif test_mode == "threaded":
return test_threaded_player()
else:
print(f"Unknown test mode: {test_mode}")
return 1
except Exception as e:
print(f"Test failed with error: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())
\ No newline at end of file
#!/usr/bin/env python3
"""
Test script for Qt player functionality
"""
import sys
import os
import logging
import time
import threading
from pathlib import Path
# Add the project root to Python path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from mbetterclient.config.settings import AppSettings
from mbetterclient.core.message_bus import MessageBus, MessageBuilder, MessageType
from mbetterclient.qt_player.player import QtVideoPlayer
def setup_logging():
"""Setup logging for the test"""
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
return logging.getLogger(__name__)
def test_qt_player_standalone():
"""Test Qt player in standalone mode"""
logger = setup_logging()
logger.info("Starting Qt player standalone test")
# Create settings
settings = AppSettings()
settings.qt.fullscreen = False
settings.qt.window_width = 800
settings.qt.window_height = 600
# Create message bus
message_bus = MessageBus()
# Create Qt player
qt_player = QtVideoPlayer(message_bus, settings.qt)
# Initialize Qt player
if not qt_player.initialize():
logger.error("Failed to initialize Qt player")
return 1
logger.info("Qt player initialized successfully")
# Start message processing in a separate thread
qt_player.start_message_processing()
# Send a test message to display default overlay
test_message = MessageBuilder.template_change(
sender="test",
template_data={
"title": "Qt Player Test",
"subtitle": "Standalone Mode Test",
"ticker": "This is a test of the Qt player in standalone mode"
}
)
message_bus.publish(test_message)
# Run Qt event loop (this will block until window is closed)
logger.info("Running Qt event loop - close the window to exit")
exit_code = qt_player.run()
# Cleanup
qt_player.shutdown()
logger.info("Qt player test completed")
return exit_code
def test_qt_player_with_message_bus():
"""Test Qt player with message bus communication"""
logger = setup_logging()
logger.info("Starting Qt player message bus test")
# Create settings
settings = AppSettings()
settings.qt.fullscreen = False
settings.qt.window_width = 800
settings.qt.window_height = 600
# Create message bus
message_bus = MessageBus()
# Create Qt player
qt_player = QtVideoPlayer(message_bus, settings.qt)
# Initialize Qt player
if not qt_player.initialize():
logger.error("Failed to initialize Qt player")
return 1
logger.info("Qt player initialized successfully")
# Start message processing in a separate thread
qt_player.start_message_processing()
# Send test messages
def send_test_messages():
time.sleep(2) # Wait for window to be ready
# Send overlay update
overlay_message = MessageBuilder.overlay_update(
sender="test",
overlay_data={
"title": "Message Bus Test",
"subtitle": "Testing message bus communication",
"showStats": True
}
)
message_bus.publish(overlay_message)
time.sleep(3)
# Send another overlay update
overlay_message2 = MessageBuilder.overlay_update(
sender="test",
overlay_data={
"title": "Message Bus Test Continued",
"subtitle": "Second message bus test",
"ticker": "Testing continuous updates through message bus"
}
)
message_bus.publish(overlay_message2)
logger.info("Test messages sent")
# Start message sending in a separate thread
message_thread = threading.Thread(target=send_test_messages)
message_thread.start()
# Run Qt event loop (this will block until window is closed)
logger.info("Running Qt event loop with message bus test - close the window to exit")
exit_code = qt_player.run()
# Wait for message thread to finish
message_thread.join()
# Cleanup
qt_player.shutdown()
logger.info("Qt player message bus test completed")
return exit_code
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "standalone":
exit_code = test_qt_player_standalone()
elif len(sys.argv) > 1 and sys.argv[1] == "message_bus":
exit_code = test_qt_player_with_message_bus()
else:
print("Usage: python test_qt_player.py [standalone|message_bus]")
print(" standalone: Test Qt player in standalone mode")
print(" message_bus: Test Qt player with message bus communication")
sys.exit(1)
sys.exit(exit_code)
\ No newline at end of file
#!/usr/bin/env python3
"""
Debug script specifically for testing video playback visibility in Qt player
Tests both native and WebEngine overlays to isolate the video blocking issue
"""
import sys
import logging
import time
from pathlib import Path
from dataclasses import dataclass
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import QTimer
# Add project path for imports
project_path = Path(__file__).parent
sys.path.insert(0, str(project_path))
from mbetterclient.qt_player.player import PlayerWindow
@dataclass
class DebugQtConfig:
"""Debug configuration for Qt player"""
fullscreen: bool = False
window_width: int = 800
window_height: int = 600
always_on_top: bool = False
auto_play: bool = True
volume: float = 0.8
mute: bool = False
use_native_overlay: bool = True # Start with native overlay for testing
def setup_debug_logging():
"""Setup debug logging"""
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler('video_debug.log')
]
)
def test_video_playback_native():
"""Test video playback with native overlay (should not block video)"""
print("Testing Video Playback with NATIVE overlay...")
app = QApplication(sys.argv)
config = DebugQtConfig()
config.use_native_overlay = True # Force native overlay
# Create player window with native overlay
window = PlayerWindow(config)
window.show()
# Test with our generated test video
test_video_path = "test_video.mp4"
def play_test_video():
"""Play the test video after a short delay"""
print(f"Playing test video: {test_video_path}")
window.play_video(test_video_path)
# Update overlay to confirm it's working
overlay_data = {
'title': 'DEBUG: Native Overlay Test',
'subtitle': 'Video should be VISIBLE underneath this overlay',
'ticker': 'If you can see moving colors/patterns, video is working! Native overlay should not block video.'
}
# Use the new separate window overlay
if hasattr(window, 'window_overlay') and window.window_overlay:
window.window_overlay.update_overlay_data(overlay_data)
else:
print("Warning: No window overlay available")
# Play video after 2 seconds
QTimer.singleShot(2000, play_test_video)
print("Native Overlay Test Window created!")
print("Expected behavior:")
print("- You should see a test pattern video (moving colors/gradients)")
print("- Native overlay text should appear ON TOP of the video")
print("- If video is NOT visible, the issue is deeper than overlay blocking")
print("\nControls:")
print("- Space: Play/Pause")
print("- Escape: Exit")
return app.exec()
def test_video_playback_webengine():
"""Test video playback with WebEngine overlay (may block video)"""
print("Testing Video Playback with WEBENGINE overlay...")
app = QApplication(sys.argv)
config = DebugQtConfig()
config.use_native_overlay = False # Force WebEngine overlay
# Create player window with WebEngine overlay
window = PlayerWindow(config)
window.show()
# Test with our generated test video
test_video_path = "test_video.mp4"
def play_test_video():
"""Play the test video after a short delay"""
print(f"Playing test video: {test_video_path}")
window.play_video(test_video_path)
# Update overlay to confirm it's working
overlay_data = {
'title': 'DEBUG: WebEngine Overlay Test',
'subtitle': 'Video may be BLOCKED by this overlay',
'ticker': 'If you CANNOT see moving colors/patterns, WebEngine overlay is blocking the video!'
}
# Wait for WebEngine to be ready before updating
def update_overlay_when_ready():
# Use the new separate window overlay
if hasattr(window, 'window_overlay') and window.window_overlay:
overlay_view = window.window_overlay
if hasattr(overlay_view, 'overlay_channel') and overlay_view.overlay_channel:
if window._is_webengine_ready(overlay_view):
overlay_view.update_overlay_data(overlay_data)
print("WebEngine overlay updated")
else:
print("WebEngine not ready, retrying...")
QTimer.singleShot(1000, update_overlay_when_ready)
else:
overlay_view.update_overlay_data(overlay_data)
else:
print("Warning: No window overlay available")
QTimer.singleShot(3000, update_overlay_when_ready)
# Play video after 2 seconds
QTimer.singleShot(2000, play_test_video)
print("WebEngine Overlay Test Window created!")
print("Expected behavior:")
print("- You should see a test pattern video (moving colors/gradients)")
print("- WebEngine overlay text should appear ON TOP of the video")
print("- If video is NOT visible, WebEngine overlay is blocking it")
print("\nControls:")
print("- Space: Play/Pause")
print("- Escape: Exit")
return app.exec()
def test_uploaded_video():
"""Test with an actual uploaded video file"""
print("Testing with uploaded video files...")
# Look for uploaded videos
uploads_dir = Path("uploads")
if uploads_dir.exists():
video_files = list(uploads_dir.glob("*.mp4"))
if video_files:
video_path = video_files[0] # Use first video found
print(f"Found uploaded video: {video_path}")
app = QApplication(sys.argv)
config = DebugQtConfig()
config.use_native_overlay = True # Start with native
window = PlayerWindow(config)
window.show()
def play_uploaded_video():
print(f"Playing uploaded video: {video_path}")
window.play_video(str(video_path))
overlay_data = {
'title': f'Playing: {video_path.name}',
'subtitle': 'Testing uploaded video with native overlay',
'ticker': 'This is a real uploaded video file. Video should be visible with native overlay.'
}
# Use the new separate window overlay
if hasattr(window, 'window_overlay') and window.window_overlay:
window.window_overlay.update_overlay_data(overlay_data)
else:
print("Warning: No window overlay available")
QTimer.singleShot(2000, play_uploaded_video)
print(f"Testing uploaded video: {video_path.name}")
print("This tests with a real uploaded video file")
return app.exec()
else:
print("No video files found in uploads directory")
return 1
else:
print("Uploads directory not found")
return 1
def main():
"""Main debug function"""
setup_debug_logging()
print("Qt Video Player Debug Suite")
print("=" * 40)
if len(sys.argv) > 1:
test_mode = sys.argv[1]
else:
print("Available test modes:")
print("1. native - Test with native Qt overlay (should show video)")
print("2. webengine - Test with WebEngine overlay (may block video)")
print("3. uploaded - Test with uploaded video file")
print()
choice = input("Select test mode (1, 2, or 3): ").strip()
if choice == "1":
test_mode = "native"
elif choice == "2":
test_mode = "webengine"
elif choice == "3":
test_mode = "uploaded"
else:
test_mode = "native"
try:
if test_mode == "native":
return test_video_playback_native()
elif test_mode == "webengine":
return test_video_playback_webengine()
elif test_mode == "uploaded":
return test_uploaded_video()
else:
print(f"Unknown test mode: {test_mode}")
return 1
except Exception as e:
print(f"Test failed with error: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())
\ No newline at end of file
#!/usr/bin/env python3
"""
Minimal video test - NO overlays at all to test pure QVideoWidget rendering
"""
import sys
import logging
from pathlib import Path
from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
from PyQt6.QtCore import QUrl
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtMultimediaWidgets import QVideoWidget
# Add project path for imports
project_path = Path(__file__).parent
sys.path.insert(0, str(project_path))
def setup_logging():
"""Setup basic logging"""
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
class MinimalVideoWindow(QMainWindow):
"""Absolute minimal video player - NO overlays, just pure video"""
def __init__(self):
super().__init__()
self.setup_ui()
self.setup_media_player()
def setup_ui(self):
"""Setup minimal UI - just video widget"""
self.setWindowTitle("MINIMAL Video Test - NO Overlays")
self.setGeometry(100, 100, 800, 600)
# PURE BLACK BACKGROUND - no transparency anywhere
self.setStyleSheet("QMainWindow { background-color: black; }")
# Central widget - completely opaque
central_widget = QWidget()
central_widget.setStyleSheet("background-color: black;")
self.setCentralWidget(central_widget)
# Layout
layout = QVBoxLayout(central_widget)
layout.setContentsMargins(0, 0, 0, 0)
# ONLY QVideoWidget - no overlays at all
self.video_widget = QVideoWidget()
self.video_widget.setStyleSheet("QVideoWidget { background-color: black; }")
layout.addWidget(self.video_widget)
print("Minimal video window created - PURE QVideoWidget only")
def setup_media_player(self):
"""Setup media player"""
self.media_player = QMediaPlayer()
self.audio_output = QAudioOutput()
self.media_player.setAudioOutput(self.audio_output)
self.media_player.setVideoOutput(self.video_widget)
# Connect signals for debugging
self.media_player.playbackStateChanged.connect(self.on_state_changed)
self.media_player.mediaStatusChanged.connect(self.on_status_changed)
self.media_player.errorOccurred.connect(self.on_error)
print("Media player setup completed")
def play_video(self, file_path):
"""Play video file"""
path_obj = Path(file_path)
if not path_obj.exists():
print(f"ERROR: File not found: {file_path}")
return
print(f"Loading video: {file_path}")
print(f"File size: {path_obj.stat().st_size} bytes")
url = QUrl.fromLocalFile(str(path_obj.absolute()))
print(f"QUrl: {url.toString()}")
self.media_player.setSource(url)
self.media_player.play()
print("Video play command sent")
def on_state_changed(self, state):
"""Debug state changes"""
print(f"MEDIA STATE: {state}")
def on_status_changed(self, status):
"""Debug status changes"""
print(f"MEDIA STATUS: {status}")
def on_error(self, error):
"""Debug errors"""
print(f"MEDIA ERROR: {error}")
def keyPressEvent(self, event):
"""Handle keys"""
if event.key() == 32: # Space
if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self.media_player.pause()
print("PAUSED")
else:
self.media_player.play()
print("PLAYING")
def main():
"""Test minimal video rendering"""
setup_logging()
print("MINIMAL VIDEO TEST - NO OVERLAYS")
print("=" * 40)
print("This test uses ONLY QVideoWidget with NO overlays")
print("If video is not visible here, the issue is with QVideoWidget itself")
print("")
app = QApplication(sys.argv)
window = MinimalVideoWindow()
window.show()
# Play test video after delay
from PyQt6.QtCore import QTimer
QTimer.singleShot(1000, lambda: window.play_video("test_video.mp4"))
print("Window shown. Video should start playing in 1 second.")
print("Expected: You should see moving test pattern (countdown)")
print("Controls: Space = Play/Pause, Escape = Exit")
return app.exec()
if __name__ == "__main__":
sys.exit(main())
\ 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