updated pages
This commit is contained in:
@@ -7,5 +7,6 @@ from .player import Player
|
||||
from .content import Content
|
||||
from .group import Group, group_player
|
||||
from .server_log import ServerLog
|
||||
from .scheduled_task import ScheduledTask
|
||||
|
||||
__all__ = ['User', 'Player', 'Content', 'Group', 'ServerLog', 'group_player']
|
||||
__all__ = ['User', 'Player', 'Content', 'Group', 'group_player', 'ServerLog', 'ScheduledTask']
|
||||
|
||||
46
app/models/scheduled_task.py
Normal file
46
app/models/scheduled_task.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Scheduled Task Model
|
||||
"""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
# Get local timezone
|
||||
LOCAL_TZ = pytz.timezone('Europe/Bucharest') # Adjust this to your local timezone
|
||||
|
||||
def get_local_time():
|
||||
"""Get current local time"""
|
||||
return datetime.now(LOCAL_TZ).replace(tzinfo=None)
|
||||
|
||||
class ScheduledTask(db.Model):
|
||||
"""Model for scheduled maintenance tasks"""
|
||||
|
||||
__tablename__ = 'scheduled_task'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
task_type = db.Column(db.String(50), nullable=False) # cleanup_files, clear_logs, optimize_db, backup_db
|
||||
schedule = db.Column(db.String(100), nullable=False) # Cron expression
|
||||
enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=get_local_time, nullable=False)
|
||||
last_run = db.Column(db.DateTime, nullable=True)
|
||||
next_run = db.Column(db.DateTime, nullable=True)
|
||||
run_count = db.Column(db.Integer, default=0, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ScheduledTask {self.name}>'
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'task_type': self.task_type,
|
||||
'schedule': self.schedule,
|
||||
'enabled': self.enabled,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_run': self.last_run.isoformat() if self.last_run else None,
|
||||
'next_run': self.next_run.isoformat() if self.next_run else None,
|
||||
'run_count': self.run_count
|
||||
}
|
||||
@@ -4,6 +4,14 @@ Server log model for audit trail
|
||||
|
||||
from app.extensions import db
|
||||
import datetime
|
||||
import pytz
|
||||
|
||||
# Get local timezone
|
||||
LOCAL_TZ = pytz.timezone('Europe/Bucharest') # Adjust this to your local timezone
|
||||
|
||||
def get_local_time():
|
||||
"""Get current local time"""
|
||||
return datetime.datetime.now(LOCAL_TZ).replace(tzinfo=None)
|
||||
|
||||
class ServerLog(db.Model):
|
||||
"""Server log model for tracking system actions"""
|
||||
@@ -13,7 +21,7 @@ class ServerLog(db.Model):
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
ip_address = db.Column(db.String(45), nullable=True) # Support IPv6
|
||||
user_agent = db.Column(db.Text, nullable=True)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow, index=True)
|
||||
timestamp = db.Column(db.DateTime, default=get_local_time, index=True)
|
||||
level = db.Column(db.String(20), default='INFO') # INFO, WARNING, ERROR, DEBUG
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -89,13 +89,18 @@ def create_user():
|
||||
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
@bp.route('/delete_user/<int:user_id>', methods=['POST'])
|
||||
@bp.route('/delete_user', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
"""Delete a user"""
|
||||
def delete_user():
|
||||
"""Delete a user using POST form data"""
|
||||
user_id = request.form.get('user_id')
|
||||
if not user_id:
|
||||
flash('User ID is required.', 'danger')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
# Prevent self-deletion
|
||||
if user_id == current_user.id:
|
||||
if int(user_id) == current_user.id:
|
||||
flash('You cannot delete your own account.', 'danger')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
@@ -206,8 +211,8 @@ def upload_assets():
|
||||
@login_required
|
||||
@admin_required
|
||||
def clean_unused_files():
|
||||
"""Clean unused files from uploads folder"""
|
||||
from flask import current_app
|
||||
"""Clean unused files from uploads folder - API endpoint"""
|
||||
from flask import current_app, jsonify
|
||||
from app.models.content import Content
|
||||
|
||||
try:
|
||||
@@ -217,6 +222,7 @@ def clean_unused_files():
|
||||
content_files = {content.file_name for content in Content.query.all()}
|
||||
|
||||
# Get all files in upload folder
|
||||
deleted_count = 0
|
||||
if os.path.exists(upload_folder):
|
||||
all_files = set(os.listdir(upload_folder))
|
||||
|
||||
@@ -224,7 +230,6 @@ def clean_unused_files():
|
||||
unused_files = all_files - content_files
|
||||
|
||||
# Delete unused files
|
||||
deleted_count = 0
|
||||
for file_name in unused_files:
|
||||
file_path = os.path.join(upload_folder, file_name)
|
||||
if os.path.isfile(file_path):
|
||||
@@ -233,13 +238,243 @@ def clean_unused_files():
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Error deleting {file_path}: {e}")
|
||||
|
||||
flash(f'Cleaned {deleted_count} unused files.', 'success')
|
||||
log_action(f'Cleaned {deleted_count} unused files')
|
||||
else:
|
||||
flash('Upload folder does not exist.', 'info')
|
||||
|
||||
log_action(f'Cleaned {deleted_count} unused files')
|
||||
return jsonify({'success': True, 'deleted_count': deleted_count})
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Error cleaning files: {str(e)}', 'danger')
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@bp.route('/optimize_database', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def optimize_database():
|
||||
"""Optimize database performance"""
|
||||
from flask import jsonify
|
||||
|
||||
try:
|
||||
# SQLite optimization commands
|
||||
db.session.execute(db.text('VACUUM;'))
|
||||
db.session.execute(db.text('ANALYZE;'))
|
||||
db.session.commit()
|
||||
|
||||
log_action('Database optimized')
|
||||
return jsonify({'success': True, 'message': 'Database optimized successfully'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@bp.route('/scheduled_tasks', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_scheduled_tasks():
|
||||
"""Get all scheduled tasks"""
|
||||
from flask import jsonify
|
||||
from app.models.scheduled_task import ScheduledTask
|
||||
|
||||
try:
|
||||
tasks = ScheduledTask.query.order_by(ScheduledTask.created_at.desc()).all()
|
||||
tasks_data = []
|
||||
|
||||
for task in tasks:
|
||||
task_dict = task.to_dict()
|
||||
# Format next run time
|
||||
if task.next_run:
|
||||
task_dict['next_run'] = task.next_run.strftime('%Y-%m-%d %H:%M')
|
||||
else:
|
||||
task_dict['next_run'] = calculate_next_run(task.schedule)
|
||||
|
||||
tasks_data.append(task_dict)
|
||||
|
||||
return jsonify({'success': True, 'tasks': tasks_data})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@bp.route('/create_scheduled_task', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_scheduled_task():
|
||||
"""Create a new scheduled task"""
|
||||
from app.models.scheduled_task import ScheduledTask
|
||||
|
||||
name = request.form.get('name', '').strip()
|
||||
task_type = request.form.get('task_type', '').strip()
|
||||
schedule = request.form.get('schedule', '').strip()
|
||||
enabled = 'enabled' in request.form or request.form.get('enabled') == 'true'
|
||||
|
||||
# Handle time and frequency form data for quick setup
|
||||
time_str = request.form.get('time')
|
||||
frequency = request.form.get('frequency')
|
||||
|
||||
if time_str and frequency:
|
||||
# Convert time and frequency to cron expression
|
||||
hour, minute = time_str.split(':')
|
||||
if frequency == 'daily':
|
||||
schedule = f"{minute} {hour} * * *"
|
||||
elif frequency == 'weekly':
|
||||
schedule = f"{minute} {hour} * * 0" # Sunday
|
||||
elif frequency == 'monthly':
|
||||
schedule = f"{minute} {hour} 1 * *" # 1st of month
|
||||
|
||||
# Generate name if not provided
|
||||
if not name:
|
||||
name = f"{task_type.replace('_', ' ').title()} - {frequency.title()}"
|
||||
|
||||
# Validation
|
||||
if not task_type or not schedule:
|
||||
flash('Task type and schedule are required.', 'danger')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
if not name:
|
||||
name = f"{task_type.replace('_', ' ').title()} Task"
|
||||
|
||||
try:
|
||||
# Create new scheduled task
|
||||
task = ScheduledTask(
|
||||
name=name,
|
||||
task_type=task_type,
|
||||
schedule=schedule,
|
||||
enabled=enabled
|
||||
)
|
||||
|
||||
# Calculate next run time
|
||||
task.next_run = calculate_next_run_datetime(schedule)
|
||||
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
|
||||
log_action(f"Scheduled task '{name}' created")
|
||||
flash(f'Scheduled task "{name}" created successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error creating scheduled task: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
@bp.route('/toggle_task/<int:task_id>', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def toggle_task(task_id):
|
||||
"""Toggle a scheduled task on/off"""
|
||||
from flask import jsonify
|
||||
from app.models.scheduled_task import ScheduledTask
|
||||
|
||||
try:
|
||||
task = ScheduledTask.query.get_or_404(task_id)
|
||||
task.enabled = not task.enabled
|
||||
db.session.commit()
|
||||
|
||||
status = 'enabled' if task.enabled else 'disabled'
|
||||
log_action(f"Scheduled task '{task.name}' {status}")
|
||||
return jsonify({'success': True, 'enabled': task.enabled})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@bp.route('/delete_task/<int:task_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_task(task_id):
|
||||
"""Delete a scheduled task"""
|
||||
from flask import jsonify
|
||||
from app.models.scheduled_task import ScheduledTask
|
||||
|
||||
try:
|
||||
task = ScheduledTask.query.get_or_404(task_id)
|
||||
task_name = task.name
|
||||
db.session.delete(task)
|
||||
db.session.commit()
|
||||
|
||||
log_action(f"Scheduled task '{task_name}' deleted")
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@bp.route('/edit_user', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_user():
|
||||
"""Edit a user"""
|
||||
user_id = request.form.get('user_id')
|
||||
if not user_id:
|
||||
flash('User ID is required.', 'danger')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
# Prevent self-editing
|
||||
if int(user_id) == current_user.id:
|
||||
flash('You cannot edit your own account.', 'danger')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
# Get form data
|
||||
role = request.form.get('role', 'user')
|
||||
is_active = 'is_active' in request.form
|
||||
password = request.form.get('password', '').strip()
|
||||
|
||||
if role not in ['user', 'admin']:
|
||||
flash('Invalid role specified.', 'danger')
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
try:
|
||||
# Update user
|
||||
user.role = role
|
||||
user.is_active_user = is_active
|
||||
|
||||
# Update password if provided
|
||||
if password:
|
||||
if len(password) < 6:
|
||||
flash('Password must be at least 6 characters long.', 'danger')
|
||||
return redirect(url_for('admin.index'))
|
||||
user.set_password(password)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action(f"User '{user.username}' updated - Role: {role}, Active: {is_active}")
|
||||
flash(f'User "{user.username}" updated successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error updating user: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
def calculate_next_run(cron_expression):
|
||||
"""Calculate next run time from cron expression (simplified)"""
|
||||
try:
|
||||
parts = cron_expression.split()
|
||||
if len(parts) >= 2:
|
||||
minute, hour = parts[0], parts[1]
|
||||
return f"Next run: {hour}:{minute.zfill(2)}"
|
||||
return "Invalid schedule"
|
||||
except:
|
||||
return "Invalid schedule"
|
||||
|
||||
def calculate_next_run_datetime(cron_expression):
|
||||
"""Calculate next run datetime from cron expression (basic implementation)"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
parts = cron_expression.split()
|
||||
if len(parts) >= 2:
|
||||
minute = int(parts[0])
|
||||
hour = int(parts[1])
|
||||
|
||||
now = datetime.now()
|
||||
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
|
||||
# If the time has passed today, schedule for tomorrow
|
||||
if next_run <= now:
|
||||
next_run += timedelta(days=1)
|
||||
|
||||
return next_run
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@@ -207,6 +207,55 @@ def get_player_content_status(player_id):
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Failed to get content status: {str(e)}'}), 500
|
||||
|
||||
@bp.route('/content/<int:content_id>/edit', methods=['POST'])
|
||||
def edit_content_duration(content_id):
|
||||
"""Edit content duration"""
|
||||
from flask_login import current_user
|
||||
|
||||
# Require authentication for this operation
|
||||
if not current_user.is_authenticated:
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
|
||||
data = request.get_json()
|
||||
if not data or 'duration' not in data:
|
||||
return jsonify({'error': 'Duration required'}), 400
|
||||
|
||||
new_duration = data.get('duration')
|
||||
|
||||
# Validate duration
|
||||
try:
|
||||
new_duration = int(new_duration)
|
||||
if new_duration < 1 or new_duration > 300:
|
||||
return jsonify({'error': 'Duration must be between 1 and 300 seconds'}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'Invalid duration value'}), 400
|
||||
|
||||
# Find the content item
|
||||
content = Content.query.get(content_id)
|
||||
if not content:
|
||||
return jsonify({'error': 'Content not found'}), 404
|
||||
|
||||
# Update the content duration
|
||||
try:
|
||||
old_duration = content.duration
|
||||
content.duration = new_duration
|
||||
|
||||
# Update player's playlist version to trigger refresh
|
||||
player = Player.query.get(content.player_id)
|
||||
if player:
|
||||
player.increment_playlist_version()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Content duration updated from {old_duration}s to {new_duration}s',
|
||||
'new_duration': new_duration
|
||||
})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': f'Failed to update content: {str(e)}'}), 500
|
||||
|
||||
@bp.errorhandler(404)
|
||||
def api_not_found(error):
|
||||
"""API 404 handler"""
|
||||
|
||||
1015
app/templates/admin/index.html
Normal file
1015
app/templates/admin/index.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -105,13 +105,12 @@
|
||||
{% if content %}
|
||||
{% for item in content %}
|
||||
<div class="content-item" data-duration="{{ item.duration }}" data-id="{{ item.id }}">
|
||||
{% if item.content_type.startswith('image/') %}
|
||||
{% if item.content_type == 'image' %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + item.file_name) }}"
|
||||
alt="{{ item.original_name or item.file_name }}">
|
||||
{% elif item.content_type.startswith('video/') %}
|
||||
{% elif item.content_type == 'video' %}
|
||||
<video muted autoplay>
|
||||
<source src="{{ url_for('static', filename='uploads/' + item.file_name) }}"
|
||||
type="{{ item.content_type }}">
|
||||
<source src="{{ url_for('static', filename='uploads/' + item.file_name) }}">
|
||||
</video>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
337
app/templates/player/index.html
Normal file
337
app/templates/player/index.html
Normal file
@@ -0,0 +1,337 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Player Management - SKE Digital Signage{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Page Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="bi bi-display"></i> Player Management</h1>
|
||||
<p class="text-muted">Manage digital signage players and their configurations</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('player.add') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus"></i> Add Player
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-white bg-primary">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h5 class="card-title">Total Players</h5>
|
||||
<h2>{{ players|length }}</h2>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="bi bi-display" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-white bg-success">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h5 class="card-title">Active Players</h5>
|
||||
<h2>{{ players|selectattr('is_active')|list|length }}</h2>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="bi bi-play-circle" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-white bg-warning">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h5 class="card-title">Online Players</h5>
|
||||
<h2>{{ players|selectattr('last_seen')|list|length }}</h2>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="bi bi-wifi" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-white bg-info">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h5 class="card-title">Locked Players</h5>
|
||||
<h2>{{ players|selectattr('locked_to_group_id')|list|length }}</h2>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="bi bi-lock" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Players Table -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5><i class="bi bi-list"></i> Players</h5>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="refreshPlayers()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" onclick="bulkDelete()" disabled id="bulkDeleteBtn">
|
||||
<i class="bi bi-trash"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if players %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
|
||||
</th>
|
||||
<th>Player Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>Status</th>
|
||||
<th>Group</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Version</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for player in players %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="player-checkbox" value="{{ player.id }}" onchange="updateBulkActions()">
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ player.username }}</strong>
|
||||
{% if player.locked_to_group_id %}
|
||||
<span class="badge bg-warning" title="Locked to group">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ player.hostname or 'Not set' }}</code>
|
||||
</td>
|
||||
<td>
|
||||
{% if player.is_active %}
|
||||
{% if player.last_seen and (now() - player.last_seen).total_seconds() < 300 %}
|
||||
<span class="badge bg-success">Online</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Active</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if player.locked_to_group_id %}
|
||||
<span class="badge bg-primary">Group {{ player.locked_to_group_id }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">No group</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if player.last_seen %}
|
||||
<span title="{{ player.last_seen }}">
|
||||
{{ player.last_seen.strftime('%Y-%m-%d %H:%M') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">Never</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">v{{ player.playlist_version or 0 }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('player.view', id=player.id) }}" class="btn btn-outline-primary" title="View Player">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('player.edit', id=player.id) }}" class="btn btn-outline-warning" title="Edit Player">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-success" onclick="openPlayerWindow('{{ player.id }}')" title="Open Player View">
|
||||
<i class="bi bi-window"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" onclick="deletePlayer('{{ player.id }}', '{{ player.username }}')" title="Delete Player">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-display text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="text-muted mt-3">No Players Found</h4>
|
||||
<p class="text-muted">Get started by adding your first digital signage player.</p>
|
||||
<a href="{{ url_for('player.add') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus"></i> Add First Player
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Player Modal -->
|
||||
<div class="modal fade" id="deletePlayerModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Player</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete player <strong id="deletePlayerName"></strong>?</p>
|
||||
<p class="text-danger small">This action cannot be undone and will remove all player data.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form method="POST" action="{{ url_for('player.delete') }}" style="display:inline;">
|
||||
<input type="hidden" id="deletePlayerId" name="player_id">
|
||||
<button type="submit" class="btn btn-danger">Delete Player</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Modal -->
|
||||
<div class="modal fade" id="bulkDeleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Selected Players</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <span id="selectedCount">0</span> selected players?</p>
|
||||
<p class="text-danger small">This action cannot be undone and will remove all player data.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" onclick="confirmBulkDelete()">Delete Players</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function deletePlayer(playerId, playerName) {
|
||||
document.getElementById('deletePlayerId').value = playerId;
|
||||
document.getElementById('deletePlayerName').textContent = playerName;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('deletePlayerModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const checkboxes = document.querySelectorAll('.player-checkbox');
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = selectAll.checked;
|
||||
});
|
||||
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const selectedCheckboxes = document.querySelectorAll('.player-checkbox:checked');
|
||||
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
|
||||
|
||||
if (selectedCheckboxes.length > 0) {
|
||||
bulkDeleteBtn.disabled = false;
|
||||
bulkDeleteBtn.textContent = `Delete ${selectedCheckboxes.length} Selected`;
|
||||
} else {
|
||||
bulkDeleteBtn.disabled = true;
|
||||
bulkDeleteBtn.innerHTML = '<i class="bi bi-trash"></i> Delete Selected';
|
||||
}
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
const selectedCheckboxes = document.querySelectorAll('.player-checkbox:checked');
|
||||
|
||||
if (selectedCheckboxes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('selectedCount').textContent = selectedCheckboxes.length;
|
||||
const modal = new bootstrap.Modal(document.getElementById('bulkDeleteModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const selectedCheckboxes = document.querySelectorAll('.player-checkbox:checked');
|
||||
const playerIds = Array.from(selectedCheckboxes).map(cb => cb.value);
|
||||
|
||||
// Create a form and submit it
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{{ url_for("player.bulk_delete") }}';
|
||||
|
||||
playerIds.forEach(id => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'player_ids';
|
||||
input.value = id;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function refreshPlayers() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function openPlayerWindow(playerId) {
|
||||
const url = `{{ url_for('player.fullscreen', id='PLAYER_ID') }}`.replace('PLAYER_ID', playerId);
|
||||
window.open(url, '_blank', 'width=1920,height=1080,fullscreen=yes');
|
||||
}
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(function() {
|
||||
const visibilitySupported = typeof document.hidden !== 'undefined';
|
||||
if (!visibilitySupported || !document.hidden) {
|
||||
// Only refresh if page is visible
|
||||
fetch(window.location.href)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
// Update only the stats if different
|
||||
const parser = new DOMParser();
|
||||
const newDoc = parser.parseFromString(html, 'text/html');
|
||||
const currentStats = document.querySelector('.row.mb-4').innerHTML;
|
||||
const newStats = newDoc.querySelector('.row.mb-4').innerHTML;
|
||||
|
||||
if (currentStats !== newStats) {
|
||||
document.querySelector('.row.mb-4').innerHTML = newStats;
|
||||
}
|
||||
})
|
||||
.catch(error => console.log('Auto-refresh failed:', error));
|
||||
}
|
||||
}, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -130,9 +130,9 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.content_type.startswith('image/') %}
|
||||
{% if item.content_type == 'image' %}
|
||||
<span class="badge bg-success"><i class="bi bi-image"></i> Image</span>
|
||||
{% elif item.content_type.startswith('video/') %}
|
||||
{% elif item.content_type == 'video' %}
|
||||
<span class="badge bg-info"><i class="bi bi-play-circle"></i> Video</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ item.content_type }}</span>
|
||||
@@ -146,6 +146,10 @@
|
||||
onclick="previewContent('{{ item.id }}', '{{ item.file_name }}', '{{ item.content_type }}')">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning"
|
||||
onclick="editContent('{{ item.id }}', '{{ item.file_name }}', {{ item.duration }})">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="removeContent('{{ item.id }}', '{{ item.file_name }}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
@@ -208,6 +212,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Content Modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Content Duration</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Edit display duration for <strong id="editFilename"></strong></p>
|
||||
<div class="mb-3">
|
||||
<label for="editDuration" class="form-label">Duration (seconds)</label>
|
||||
<input type="number" class="form-control" id="editDuration" min="1" max="300" required>
|
||||
<div class="form-text">How long should this content be displayed? (1-300 seconds)</div>
|
||||
</div>
|
||||
</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" onclick="confirmEditContent()">Update Duration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentContentId = null;
|
||||
|
||||
@@ -217,17 +245,26 @@ function refreshPlayer() {
|
||||
}
|
||||
|
||||
function previewContent(contentId, filename, contentType) {
|
||||
console.log('Preview function called:', {contentId, filename, contentType});
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
const previewDiv = document.getElementById('previewContent');
|
||||
|
||||
if (contentType.startsWith('image/')) {
|
||||
previewDiv.innerHTML = `<img src="/static/uploads/${filename}" class="img-fluid" alt="${filename}">`;
|
||||
} else if (contentType.startsWith('video/')) {
|
||||
if (contentType === 'image') {
|
||||
const imgSrc = `/static/uploads/${filename}`;
|
||||
console.log('Loading image from:', imgSrc);
|
||||
previewDiv.innerHTML = `<img src="${imgSrc}" class="img-fluid" alt="${filename}"
|
||||
onload="console.log('Image loaded successfully')"
|
||||
onerror="console.error('Failed to load image:', this.src)">`;
|
||||
} else if (contentType === 'video') {
|
||||
const videoSrc = `/static/uploads/${filename}`;
|
||||
console.log('Loading video from:', videoSrc);
|
||||
previewDiv.innerHTML = `<video controls class="w-100" style="max-height: 400px;">
|
||||
<source src="/static/uploads/${filename}" type="${contentType}">
|
||||
<source src="${videoSrc}">
|
||||
Your browser does not support the video tag.
|
||||
</video>`;
|
||||
} else {
|
||||
console.log('Unsupported content type:', contentType);
|
||||
previewDiv.innerHTML = `<p>Preview not available for this content type: ${contentType}</p>`;
|
||||
}
|
||||
|
||||
@@ -241,6 +278,52 @@ function removeContent(contentId, filename) {
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function editContent(contentId, filename, currentDuration) {
|
||||
currentContentId = contentId;
|
||||
document.getElementById('editFilename').textContent = filename;
|
||||
document.getElementById('editDuration').value = currentDuration;
|
||||
const modal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function confirmEditContent() {
|
||||
if (currentContentId) {
|
||||
const newDuration = parseInt(document.getElementById('editDuration').value);
|
||||
|
||||
if (!newDuration || newDuration < 1 || newDuration > 300) {
|
||||
alert('Please enter a valid duration between 1 and 300 seconds.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Make API call to update content duration
|
||||
fetch(`/api/content/${currentContentId}/edit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
duration: newDuration
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error updating content: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error updating content');
|
||||
});
|
||||
|
||||
// Close modal
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('editModal'));
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemoveContent() {
|
||||
if (currentContentId) {
|
||||
// Make API call to remove content from player
|
||||
|
||||
@@ -3,11 +3,15 @@ Logging utilities for server actions
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import pytz
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
from app.extensions import db
|
||||
from app.models.server_log import ServerLog
|
||||
|
||||
# Get local timezone
|
||||
LOCAL_TZ = pytz.timezone('Europe/Bucharest') # Adjust this to your local timezone
|
||||
|
||||
def log_action(action, level='INFO', user_id=None, ip_address=None, user_agent=None):
|
||||
"""
|
||||
Log an action to the server log database
|
||||
|
||||
5
cookies.txt
Normal file
5
cookies.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
#HttpOnly_localhost FALSE / FALSE 0 session .eJwlzj0OwjAMQOG7ZGZw4jixe5nKfxGsLZ0Qd6cSw1ufvk_Z15Hns2zv48pH2V9RtmKAo6OyUqXUrCBjpNEUCTBpbHVpcmgaGCii3S0koxUzQYJm12bIHqgLPQktrI8ejaP5PSXEMbuzAU83ndFFbWlzl0ia5YZcZx5_TS3fHxMgMKY.aHeCCg.7OIBpOpeNf7DdekBD8uk66K1N30
|
||||
Reference in New Issue
Block a user