updated first commit

This commit is contained in:
2025-07-16 08:03:57 +03:00
parent 78641b633a
commit c36ba9dc64
34 changed files with 3938 additions and 0 deletions

69
.dockerignore Normal file
View File

@@ -0,0 +1,69 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Database
*.db
*.sqlite
*.sqlite3
# Environment
.env
.env.local
.env.production
# Uploads and instance data
instance/
static/uploads/
static/assets/
# Git
.git/
.gitignore
# Documentation
*.md
docs/
# Docker
Dockerfile
.dockerignore
docker-compose*.yml

25
.env.example Normal file
View File

@@ -0,0 +1,25 @@
# SKE Digital Signage Environment Configuration
# Copy this file to .env and customize for your environment
# Secret key for Flask sessions (CHANGE THIS IN PRODUCTION!)
SECRET_KEY=ske-signage-production-secret-change-me
# Admin user credentials
ADMIN_USER=admin
ADMIN_PASSWORD=ChangeMe123!
# Database configuration
DATABASE_URL=sqlite:///./instance/ske_signage.db
# Server configuration
HOST=0.0.0.0
PORT=5000
FLASK_CONFIG=production
FLASK_DEBUG=false
# Logging configuration
LOG_LEVEL=INFO
# File processing paths (optional, defaults to system paths)
FFMPEG_PATH=ffmpeg
LIBREOFFICE_PATH=libreoffice

92
.gitignore vendored Normal file
View File

@@ -0,0 +1,92 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Virtual environments
venv/
env/
ENV/
.venv/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
*.log
logs/
# Flask
instance/
.flask_session
# Database
*.db
*.sqlite
*.sqlite3
# Environment variables
.env
.env.local
.env.development
.env.production
# Application specific
static/uploads/
static/assets/
backups/
# Temporary files
*.tmp
*.temp

63
Dockerfile Normal file
View File

@@ -0,0 +1,63 @@
FROM python:3.11-slim
LABEL maintainer="SKE Digital Signage"
LABEL version="2.0.0"
LABEL description="SKE Digital Signage Server"
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
libreoffice \
poppler-utils \
ffmpeg \
libpoppler-cpp-dev \
libmagic1 \
libffi-dev \
libssl-dev \
g++ \
curl \
libjpeg-dev \
zlib1g-dev \
libxml2-dev \
libxslt-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user for security
RUN useradd -r -s /bin/bash -m -d /app -u 1001 appuser
# Copy requirements first to leverage Docker cache
COPY requirements.txt .
# Upgrade pip and install Python dependencies
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create necessary directories
RUN mkdir -p instance static/uploads static/assets logs
# Set ownership to non-root user
RUN chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 5000
# Environment variables
ENV FLASK_APP=main.py
ENV FLASK_ENV=production
ENV PYTHONPATH=/app
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5000/api/health || exit 1
# Run the application
CMD ["python", "main.py"]

242
README.md Normal file
View File

@@ -0,0 +1,242 @@
# SKE Digital Signage Server
A modern, web-based digital signage management system built with Flask. This is a completely restructured and improved version of the original digiserver application.
## Features
- **Modern Architecture**: Clean, modular Flask application structure
- **User Management**: Role-based authentication (admin/user)
- **Player Management**: Individual digital signage displays
- **Group Management**: Organize players into groups for synchronized content
- **Content Management**: Upload and manage images, videos, and documents
- **RESTful API**: For player clients to fetch playlists
- **Docker Support**: Easy deployment with Docker and Docker Compose
- **Responsive UI**: Bootstrap-based modern interface
- **File Processing**: Automatic conversion of PDFs, PowerPoint, and videos
- **Audit Logging**: Track all system actions
## Quick Start with Docker
1. **Clone and Navigate**:
```bash
cd /home/pi/Ske_Signage
```
2. **Configure Environment** (optional):
```bash
cp .env.example .env
# Edit .env file with your preferred settings
```
3. **Build and Start**:
```bash
docker-compose up -d
```
4. **Access the Application**:
- Open your browser to `http://localhost:8880`
- Default login: `admin` / `ChangeMe123!`
## Manual Installation
### Prerequisites
- Python 3.11+
- SQLite3
- FFmpeg
- LibreOffice
- Poppler-utils
### Installation Steps
1. **Install System Dependencies** (Ubuntu/Debian):
```bash
sudo apt-get update
sudo apt-get install -y libreoffice poppler-utils ffmpeg \
libpoppler-cpp-dev libmagic1 libffi-dev libssl-dev \
g++ libjpeg-dev zlib1g-dev libxml2-dev libxslt-dev
```
2. **Create Virtual Environment**:
```bash
python3 -m venv venv
source venv/bin/activate
```
3. **Install Python Dependencies**:
```bash
pip install -r requirements.txt
```
4. **Configure Environment**:
```bash
cp .env.example .env
# Edit .env with your settings
```
5. **Initialize Database**:
```bash
python main.py
# This will create the database and default admin user
```
6. **Run Application**:
```bash
python main.py
```
## Application Structure
```
Ske_Signage/
├── app/ # Main application package
│ ├── models/ # Database models
│ ├── routes/ # Flask blueprints/routes
│ ├── utils/ # Utility functions
│ ├── templates/ # Jinja2 templates
│ ├── static/ # Static files (uploads, assets)
│ ├── __init__.py # Application factory
│ └── extensions.py # Flask extensions
├── config.py # Configuration classes
├── main.py # Application entry point
├── requirements.txt # Python dependencies
├── Dockerfile # Docker image definition
├── docker-compose.yml # Docker Compose configuration
└── .env.example # Environment template
```
## Configuration
The application uses environment variables for configuration. Key settings:
- `SECRET_KEY`: Flask secret key for sessions
- `ADMIN_USER` / `ADMIN_PASSWORD`: Default admin credentials
- `DATABASE_URL`: Database connection string
- `FLASK_CONFIG`: Environment (development/production)
- `HOST` / `PORT`: Server binding
## API Endpoints
### Player API
- `GET /api/playlists`: Get playlist for a player
- `GET /api/playlist_version`: Check playlist version
- `POST /api/player_status`: Update player status
- `GET /api/health`: Health check
### Authentication
All API endpoints require player authentication via hostname and quickconnect code.
## Usage
### 1. Create Players
1. Login as admin
2. Go to Dashboard
3. Click "Add Player"
4. Fill in player details (username, hostname, passwords)
### 2. Upload Content
1. Click "Upload Content"
2. Select target (player or group)
3. Choose files (images, videos, PDFs, PowerPoint)
4. Set display duration
5. Upload
### 3. Manage Groups
1. Create groups to synchronize content across multiple players
2. Add players to groups
3. Upload content to groups for synchronized playlists
### 4. Player Client
Players can connect using the API:
```bash
curl "http://server:8880/api/playlists?hostname=PLAYER_HOSTNAME&quickconnect_code=CODE"
```
## File Processing
The application automatically processes uploaded files:
- **Images**: Resized and optimized
- **Videos**: Converted to web-compatible MP4 format
- **PDFs**: Converted to individual page images
- **PowerPoint**: Converted to images via LibreOffice
## Development
### Running in Development Mode
```bash
export FLASK_CONFIG=development
export FLASK_DEBUG=true
python main.py
```
### Database Migrations
```bash
flask db init # Initialize migrations (first time)
flask db migrate # Create migration
flask db upgrade # Apply migration
```
## Production Deployment
### Docker Compose (Recommended)
1. Use the provided `docker-compose.yml`
2. Set strong passwords in environment variables
3. Configure reverse proxy (nginx/traefik) for HTTPS
4. Set up backup for persistent volumes
### Manual Deployment
1. Use a production WSGI server (gunicorn included)
2. Set `FLASK_CONFIG=production`
3. Configure proper database (PostgreSQL recommended for production)
4. Set up log rotation
5. Configure firewall and security
## Security Considerations
- Change default admin credentials
- Use strong secret keys
- Enable HTTPS in production
- Regular backups
- Monitor logs for suspicious activity
- Keep dependencies updated
## Troubleshooting
### Common Issues
1. **File Upload Errors**: Check file permissions and disk space
2. **Video Conversion Fails**: Ensure FFmpeg is installed and accessible
3. **PDF Processing Issues**: Verify poppler-utils installation
4. **Database Errors**: Check database file permissions
### Logs
- Application logs: Check console output or `logs/` directory
- Docker logs: `docker-compose logs -f`
## Migrating from Original digiserver
The new application uses a different database schema. To migrate:
1. Export content from old system
2. Create players and groups in new system
3. Re-upload content through the new interface
## License
This project is proprietary software for SKE Digital Signage.
## Support
For issues and support, please contact the development team.

85
app/__init__.py Normal file
View File

@@ -0,0 +1,85 @@
"""
SKE Digital Signage Server Application Factory
"""
import os
from flask import Flask
from flask_migrate import Migrate
def create_app(config_name=None):
"""Application factory function"""
# Create Flask application
app = Flask(__name__, instance_relative_config=True)
# Load configuration
if config_name is None:
config_name = os.environ.get('FLASK_CONFIG', 'default')
from config import config
app.config.from_object(config[config_name])
# Ensure instance folder exists
os.makedirs(app.instance_path, exist_ok=True)
# Initialize extensions
from app.extensions import db, bcrypt, login_manager
db.init_app(app)
bcrypt.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
# Initialize Flask-Migrate
migrate = Migrate(app, db)
# Register user loader for Flask-Login
from app.models.user import User
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
# Register blueprints
from app.routes.auth import bp as auth_bp
from app.routes.dashboard import bp as dashboard_bp
from app.routes.admin import bp as admin_bp
from app.routes.player import bp as player_bp
from app.routes.group import bp as group_bp
from app.routes.content import bp as content_bp
from app.routes.api import bp as api_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(dashboard_bp)
app.register_blueprint(admin_bp, url_prefix='/admin')
app.register_blueprint(player_bp, url_prefix='/player')
app.register_blueprint(group_bp, url_prefix='/group')
app.register_blueprint(content_bp, url_prefix='/content')
app.register_blueprint(api_bp, url_prefix='/api')
# Create upload folders
upload_path = os.path.join(app.static_folder, 'uploads')
assets_path = os.path.join(app.static_folder, 'assets')
os.makedirs(upload_path, exist_ok=True)
os.makedirs(assets_path, exist_ok=True)
# Context processor for theme injection
@app.context_processor
def inject_theme():
from flask_login import current_user
if current_user.is_authenticated:
theme = getattr(current_user, 'theme', 'light')
else:
theme = 'light'
return dict(theme=theme)
# Context processor for server info
@app.context_processor
def inject_server_info():
return dict(
server_version=app.config['SERVER_VERSION'],
build_date=app.config['BUILD_DATE']
)
return app

12
app/extensions.py Normal file
View File

@@ -0,0 +1,12 @@
"""
Flask Extensions initialization
"""
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from flask_login import LoginManager
# Initialize extensions
db = SQLAlchemy()
bcrypt = Bcrypt()
login_manager = LoginManager()

11
app/models/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
"""
Database models package
"""
from .user import User
from .player import Player
from .content import Content
from .group import Group, group_player
from .server_log import ServerLog
__all__ = ['User', 'Player', 'Content', 'Group', 'ServerLog', 'group_player']

38
app/models/content.py Normal file
View File

@@ -0,0 +1,38 @@
"""
Content model for media files
"""
from app.extensions import db
class Content(db.Model):
"""Content model representing media files for players"""
id = db.Column(db.Integer, primary_key=True)
file_name = db.Column(db.String(255), nullable=False, index=True)
original_name = db.Column(db.String(255), nullable=True)
duration = db.Column(db.Integer, nullable=False, default=10) # Duration in seconds
position = db.Column(db.Integer, default=0) # Position in playlist
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=False, index=True)
content_type = db.Column(db.String(50), nullable=False, default='image') # image, video, document
file_size = db.Column(db.Integer, nullable=True) # File size in bytes
uploaded_at = db.Column(db.DateTime, default=db.func.current_timestamp())
is_active = db.Column(db.Boolean, default=True)
# Metadata for different content types
width = db.Column(db.Integer, nullable=True)
height = db.Column(db.Integer, nullable=True)
format = db.Column(db.String(10), nullable=True)
@property
def file_path(self):
"""Get the full file path for this content"""
return f"uploads/{self.file_name}"
@property
def url(self):
"""Get the URL for this content"""
from flask import url_for
return url_for('static', filename=self.file_path)
def __repr__(self):
return f'<Content {self.file_name} (Player: {self.player_id})>'

48
app/models/group.py Normal file
View File

@@ -0,0 +1,48 @@
"""
Group model for managing collections of players
"""
from app.extensions import db
# Association table for many-to-many relationship between Group and Player
group_player = db.Table('group_player',
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
db.Column('player_id', db.Integer, db.ForeignKey('player.id'), primary_key=True)
)
class Group(db.Model):
"""Group model for managing collections of players"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
description = db.Column(db.Text, nullable=True)
playlist_version = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
is_active = db.Column(db.Boolean, default=True)
# Relationships
players = db.relationship('Player', secondary=group_player, backref='groups')
def increment_playlist_version(self):
"""Increment playlist version for all players in group"""
self.playlist_version += 1
for player in self.players:
player.increment_playlist_version()
db.session.commit()
@property
def player_count(self):
"""Get the number of players in this group"""
return len(self.players)
def get_content(self):
"""Get all content from players in this group"""
from app.models.content import Content
if not self.players:
return []
player_ids = [player.id for player in self.players]
return Content.query.filter(Content.player_id.in_(player_ids)).order_by(Content.position).all()
def __repr__(self):
return f'<Group {self.name} ({self.player_count} players)>'

54
app/models/player.py Normal file
View File

@@ -0,0 +1,54 @@
"""
Player model for digital signage displays
"""
from app.extensions import db, bcrypt
class Player(db.Model):
"""Player model representing digital signage displays"""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(255), nullable=False, index=True)
hostname = db.Column(db.String(255), nullable=False, unique=True, index=True)
password = db.Column(db.String(255), nullable=False)
quickconnect_password = db.Column(db.String(255), nullable=True)
playlist_version = db.Column(db.Integer, default=1)
locked_to_group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True)
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
last_seen = db.Column(db.DateTime)
is_active = db.Column(db.Boolean, default=True)
# Relationships
content = db.relationship('Content', backref='player', lazy=True, cascade='all, delete-orphan')
locked_to_group = db.relationship('Group', foreign_keys=[locked_to_group_id], backref='locked_players')
def set_password(self, password):
"""Hash and set player password"""
self.password = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
"""Check if provided password matches player's password"""
return bcrypt.check_password_hash(self.password, password)
def set_quickconnect_password(self, password):
"""Hash and set quickconnect password"""
self.quickconnect_password = bcrypt.generate_password_hash(password).decode('utf-8')
def verify_quickconnect_code(self, code):
"""Verify quickconnect code"""
if not self.quickconnect_password:
return False
return bcrypt.check_password_hash(self.quickconnect_password, code)
def increment_playlist_version(self):
"""Increment playlist version to notify clients of changes"""
self.playlist_version += 1
db.session.commit()
@property
def is_locked_to_group(self):
"""Check if player is locked to a group"""
return self.locked_to_group_id is not None
def __repr__(self):
return f'<Player {self.username} ({self.hostname})>'

42
app/models/server_log.py Normal file
View File

@@ -0,0 +1,42 @@
"""
Server log model for audit trail
"""
from app.extensions import db
import datetime
class ServerLog(db.Model):
"""Server log model for tracking system actions"""
id = db.Column(db.Integer, primary_key=True)
action = db.Column(db.String(255), nullable=False, index=True)
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)
level = db.Column(db.String(20), default='INFO') # INFO, WARNING, ERROR, DEBUG
# Relationships
user = db.relationship('User', backref='logs')
@staticmethod
def log(action, user_id=None, ip_address=None, user_agent=None, level='INFO'):
"""Create a new log entry"""
log_entry = ServerLog(
action=action,
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
level=level
)
db.session.add(log_entry)
db.session.commit()
return log_entry
@staticmethod
def get_recent_logs(limit=50):
"""Get recent log entries"""
return ServerLog.query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
def __repr__(self):
return f'<ServerLog {self.action} at {self.timestamp}>'

53
app/models/user.py Normal file
View File

@@ -0,0 +1,53 @@
"""
User model for authentication and authorization
"""
from flask_login import UserMixin
from app.extensions import db, bcrypt
class User(db.Model, UserMixin):
"""User model for authentication and role management"""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
password = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(80), nullable=False, default='user')
theme = db.Column(db.String(80), default='light')
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
last_login = db.Column(db.DateTime)
is_active_user = db.Column(db.Boolean, default=True)
def set_password(self, password):
"""Hash and set user password"""
self.password = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
"""Check if provided password matches user's password"""
return bcrypt.check_password_hash(self.password, password)
@property
def is_admin(self):
"""Check if user has admin role"""
return self.role == 'admin'
@property
def is_active(self):
"""Required by Flask-Login"""
return self.is_active_user
@property
def is_authenticated(self):
"""Required by Flask-Login"""
return True
@property
def is_anonymous(self):
"""Required by Flask-Login"""
return False
def get_id(self):
"""Required by Flask-Login"""
return str(self.id)
def __repr__(self):
return f'<User {self.username}>'

14
app/routes/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
"""
Routes package
"""
# Import all blueprints to make them available
from .auth import bp as auth_bp
from .dashboard import bp as dashboard_bp
from .admin import bp as admin_bp
from .player import bp as player_bp
from .group import bp as group_bp
from .content import bp as content_bp
from .api import bp as api_bp
__all__ = ['auth_bp', 'dashboard_bp', 'admin_bp', 'player_bp', 'group_bp', 'content_bp', 'api_bp']

245
app/routes/admin.py Normal file
View File

@@ -0,0 +1,245 @@
"""
Admin routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from functools import wraps
from app.models.user import User
from app.extensions import db
from app.utils.logger import log_user_created, log_user_deleted, log_action
import os
bp = Blueprint('admin', __name__)
def admin_required(f):
"""Decorator to require admin role"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
flash('Admin access required.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_function
@bp.route('/')
@login_required
@admin_required
def index():
"""Admin dashboard"""
from flask import current_app
# Check if assets exist
logo_path = os.path.join(current_app.static_folder, 'assets', 'logo.png')
login_picture_path = os.path.join(current_app.static_folder, 'assets', 'login_picture.png')
logo_exists = os.path.exists(logo_path)
login_picture_exists = os.path.exists(login_picture_path)
# Get all users
users = User.query.order_by(User.username).all()
return render_template(
'admin/index.html',
users=users,
logo_exists=logo_exists,
login_picture_exists=login_picture_exists
)
@bp.route('/create_user', methods=['POST'])
@login_required
@admin_required
def create_user():
"""Create a new user"""
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
role = request.form.get('role', 'user')
# Validation
if not username or not password:
flash('Username and password are required.', 'danger')
return redirect(url_for('admin.index'))
if len(password) < 6:
flash('Password must be at least 6 characters long.', 'danger')
return redirect(url_for('admin.index'))
if role not in ['user', 'admin']:
flash('Invalid role specified.', 'danger')
return redirect(url_for('admin.index'))
# Check if user already exists
if User.query.filter_by(username=username).first():
flash(f'User "{username}" already exists.', 'danger')
return redirect(url_for('admin.index'))
try:
# Create new user
user = User(username=username, role=role)
user.set_password(password)
db.session.add(user)
db.session.commit()
log_user_created(username, role)
flash(f'User "{username}" created successfully.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error creating user: {str(e)}', 'danger')
return redirect(url_for('admin.index'))
@bp.route('/delete_user/<int:user_id>', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id):
"""Delete a user"""
# Prevent self-deletion
if user_id == current_user.id:
flash('You cannot delete your own account.', 'danger')
return redirect(url_for('admin.index'))
user = User.query.get_or_404(user_id)
username = user.username
try:
db.session.delete(user)
db.session.commit()
log_user_deleted(username)
flash(f'User "{username}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting user: {str(e)}', 'danger')
return redirect(url_for('admin.index'))
@bp.route('/change_role/<int:user_id>', methods=['POST'])
@login_required
@admin_required
def change_role(user_id):
"""Change user role"""
# Prevent changing own role
if user_id == current_user.id:
flash('You cannot change your own role.', 'danger')
return redirect(url_for('admin.index'))
user = User.query.get_or_404(user_id)
new_role = request.form.get('role')
if new_role not in ['user', 'admin']:
flash('Invalid role specified.', 'danger')
return redirect(url_for('admin.index'))
try:
old_role = user.role
user.role = new_role
db.session.commit()
log_action(f"User '{user.username}' role changed from '{old_role}' to '{new_role}'")
flash(f'User "{user.username}" role changed to "{new_role}".', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error changing user role: {str(e)}', 'danger')
return redirect(url_for('admin.index'))
@bp.route('/change_theme', methods=['POST'])
@login_required
def change_theme():
"""Change user theme"""
theme = request.form.get('theme', 'light')
if theme not in ['light', 'dark']:
flash('Invalid theme specified.', 'danger')
return redirect(request.referrer or url_for('admin.index'))
try:
current_user.theme = theme
db.session.commit()
flash(f'Theme changed to "{theme}".', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error changing theme: {str(e)}', 'danger')
return redirect(request.referrer or url_for('admin.index'))
@bp.route('/upload_assets', methods=['POST'])
@login_required
@admin_required
def upload_assets():
"""Upload logo and login picture"""
from flask import current_app
from werkzeug.utils import secure_filename
assets_folder = os.path.join(current_app.static_folder, 'assets')
os.makedirs(assets_folder, exist_ok=True)
# Handle logo upload
logo_file = request.files.get('logo')
if logo_file and logo_file.filename:
try:
logo_path = os.path.join(assets_folder, 'logo.png')
logo_file.save(logo_path)
flash('Logo uploaded successfully.', 'success')
log_action('Logo uploaded')
except Exception as e:
flash(f'Error uploading logo: {str(e)}', 'danger')
# Handle login picture upload
login_picture_file = request.files.get('login_picture')
if login_picture_file and login_picture_file.filename:
try:
login_picture_path = os.path.join(assets_folder, 'login_picture.png')
login_picture_file.save(login_picture_path)
flash('Login picture uploaded successfully.', 'success')
log_action('Login picture uploaded')
except Exception as e:
flash(f'Error uploading login picture: {str(e)}', 'danger')
return redirect(url_for('admin.index'))
@bp.route('/clean_unused_files', methods=['POST'])
@login_required
@admin_required
def clean_unused_files():
"""Clean unused files from uploads folder"""
from flask import current_app
from app.models.content import Content
try:
upload_folder = os.path.join(current_app.static_folder, 'uploads')
# Get all file names from database
content_files = {content.file_name for content in Content.query.all()}
# Get all files in upload folder
if os.path.exists(upload_folder):
all_files = set(os.listdir(upload_folder))
# Find 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):
try:
os.remove(file_path)
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')
except Exception as e:
flash(f'Error cleaning files: {str(e)}', 'danger')
return redirect(url_for('admin.index'))

153
app/routes/api.py Normal file
View File

@@ -0,0 +1,153 @@
"""
API routes for player clients
"""
from flask import Blueprint, request, jsonify, url_for
from app.models.player import Player
from app.models.content import Content
from app.extensions import bcrypt, db
bp = Blueprint('api', __name__)
@bp.route('/playlists', methods=['GET'])
def get_playlists():
"""Get playlist for a player"""
hostname = request.args.get('hostname')
quickconnect_code = request.args.get('quickconnect_code')
# Validate parameters
if not hostname or not quickconnect_code:
return jsonify({'error': 'Hostname and quick connect code are required'}), 400
# Find player and verify credentials
player = Player.query.filter_by(hostname=hostname).first()
if not player or not player.verify_quickconnect_code(quickconnect_code):
return jsonify({'error': 'Invalid hostname or quick connect code'}), 404
# Update last seen
player.last_seen = db.func.current_timestamp()
db.session.commit()
# Get content based on player's group status
if player.is_locked_to_group:
# Player is locked to a group - get shared content
group = player.locked_to_group
player_ids = [p.id for p in group.players]
# Get unique content by filename (first occurrence)
content_query = (
db.session.query(
Content.file_name,
db.func.min(Content.id).label('id'),
db.func.min(Content.duration).label('duration'),
db.func.min(Content.position).label('position'),
db.func.min(Content.content_type).label('content_type')
)
.filter(Content.player_id.in_(player_ids))
.group_by(Content.file_name)
)
content = db.session.query(Content).filter(
Content.id.in_([c.id for c in content_query])
).order_by(Content.position).all()
else:
# Individual player content
content = Content.query.filter_by(player_id=player.id).order_by(Content.position).all()
# Build playlist
playlist = []
for media in content:
playlist.append({
'file_name': media.file_name,
'url': url_for('content.media', filename=media.file_name, _external=True),
'duration': media.duration,
'content_type': media.content_type,
'position': media.position
})
return jsonify({
'playlist': playlist,
'playlist_version': player.playlist_version,
'hashed_quickconnect': player.quickconnect_password,
'player_id': player.id,
'player_name': player.username
})
@bp.route('/playlist_version', methods=['GET'])
def get_playlist_version():
"""Get playlist version for a player (for checking updates)"""
hostname = request.args.get('hostname')
quickconnect_code = request.args.get('quickconnect_code')
# Validate parameters
if not hostname or not quickconnect_code:
return jsonify({'error': 'Hostname and quick connect code are required'}), 400
# Find player and verify credentials
player = Player.query.filter_by(hostname=hostname).first()
if not player or not player.verify_quickconnect_code(quickconnect_code):
return jsonify({'error': 'Invalid hostname or quick connect code'}), 404
# Update last seen
player.last_seen = db.func.current_timestamp()
db.session.commit()
return jsonify({
'playlist_version': player.playlist_version,
'hashed_quickconnect': player.quickconnect_password
})
@bp.route('/player_status', methods=['POST'])
def update_player_status():
"""Update player status (heartbeat)"""
data = request.get_json()
if not data:
return jsonify({'error': 'JSON data required'}), 400
hostname = data.get('hostname')
quickconnect_code = data.get('quickconnect_code')
if not hostname or not quickconnect_code:
return jsonify({'error': 'Hostname and quick connect code are required'}), 400
# Find player and verify credentials
player = Player.query.filter_by(hostname=hostname).first()
if not player or not player.verify_quickconnect_code(quickconnect_code):
return jsonify({'error': 'Invalid hostname or quick connect code'}), 404
# Update player status
player.last_seen = db.func.current_timestamp()
player.is_active = True
# Optional: Update additional status info if provided
if 'status' in data:
# Could store additional status information in the future
pass
db.session.commit()
return jsonify({
'success': True,
'playlist_version': player.playlist_version,
'message': 'Status updated successfully'
})
@bp.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint"""
return jsonify({
'status': 'healthy',
'service': 'SKE Digital Signage Server',
'version': '2.0.0'
})
@bp.errorhandler(404)
def api_not_found(error):
"""API 404 handler"""
return jsonify({'error': 'API endpoint not found'}), 404
@bp.errorhandler(500)
def api_internal_error(error):
"""API 500 handler"""
return jsonify({'error': 'Internal server error'}), 500

111
app/routes/auth.py Normal file
View File

@@ -0,0 +1,111 @@
"""
Authentication routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_user, logout_user, login_required, current_user
from app.models.user import User
from app.extensions import db
from app.utils.logger import log_action
import os
bp = Blueprint('auth', __name__)
@bp.route('/login', methods=['GET', 'POST'])
def login():
"""User login"""
if current_user.is_authenticated:
return redirect(url_for('dashboard.index'))
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
if not username or not password:
flash('Please enter both username and password.', 'danger')
return render_template('auth/login.html')
user = User.query.filter_by(username=username).first()
if user and user.check_password(password) and user.is_active:
login_user(user)
user.last_login = db.func.current_timestamp()
db.session.commit()
log_action(f"User '{username}' logged in")
# Redirect to next page or dashboard
next_page = request.args.get('next')
if next_page:
return redirect(next_page)
return redirect(url_for('dashboard.index'))
else:
flash('Invalid username or password.', 'danger')
log_action(f"Failed login attempt for username '{username}'", level='WARNING')
# Check if login picture exists
from flask import current_app
login_picture_path = os.path.join(current_app.static_folder, 'assets', 'login_picture.png')
login_picture_exists = os.path.exists(login_picture_path)
return render_template('auth/login.html', login_picture_exists=login_picture_exists)
@bp.route('/logout')
@login_required
def logout():
"""User logout"""
username = current_user.username
logout_user()
log_action(f"User '{username}' logged out")
flash('You have been logged out successfully.', 'info')
return redirect(url_for('auth.login'))
@bp.route('/register', methods=['GET', 'POST'])
def register():
"""User registration (for development only)"""
from flask import current_app
# Only allow registration in development mode
if not current_app.config.get('DEBUG', False):
flash('Registration is disabled.', 'danger')
return redirect(url_for('auth.login'))
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
confirm_password = request.form.get('confirm_password', '')
# Validation
if not username or not password:
flash('Please enter both username and password.', 'danger')
return render_template('auth/register.html')
if password != confirm_password:
flash('Passwords do not match.', 'danger')
return render_template('auth/register.html')
if len(password) < 6:
flash('Password must be at least 6 characters long.', 'danger')
return render_template('auth/register.html')
# Check if user already exists
if User.query.filter_by(username=username).first():
flash('Username already exists.', 'danger')
return render_template('auth/register.html')
# Create new user
try:
user = User(username=username, role='user')
user.set_password(password)
db.session.add(user)
db.session.commit()
log_action(f"New user '{username}' registered")
flash('Registration successful! Please log in.', 'success')
return redirect(url_for('auth.login'))
except Exception as e:
db.session.rollback()
flash(f'Registration failed: {str(e)}', 'danger')
return render_template('auth/register.html')

232
app/routes/content.py Normal file
View File

@@ -0,0 +1,232 @@
"""
Content management routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_from_directory, current_app
from flask_login import login_required
from app.models.content import Content
from app.models.player import Player
from app.models.group import Group
from app.extensions import db
from app.utils.uploads import process_uploaded_files
from app.routes.admin import admin_required
import os
bp = Blueprint('content', __name__)
@bp.route('/upload', methods=['GET', 'POST'])
@login_required
@admin_required
def upload():
"""Upload content to players or groups"""
if request.method == 'POST':
target_type = request.form.get('target_type')
target_id = request.form.get('target_id')
files = request.files.getlist('files')
duration = request.form.get('duration', 10, type=int)
return_url = request.form.get('return_url', url_for('dashboard.index'))
# Validation
if not target_type or not target_id:
flash('Please select a target type and target.', 'danger')
return redirect(url_for('content.upload'))
if not files or all(not file.filename for file in files):
flash('Please select at least one file to upload.', 'danger')
return redirect(url_for('content.upload'))
if duration < 1:
flash('Duration must be at least 1 second.', 'danger')
return redirect(url_for('content.upload'))
try:
target_id = int(target_id)
except ValueError:
flash('Invalid target ID.', 'danger')
return redirect(url_for('content.upload'))
# Process files
results = process_uploaded_files(
app=current_app,
files=files,
duration=duration,
target_type=target_type,
target_id=target_id
)
# Show results
if results['success']:
flash(f'Successfully uploaded {len(results["success"])} files.', 'success')
if results['errors']:
for error in results['errors']:
flash(f'Error: {error}', 'danger')
return redirect(return_url)
# GET request - show upload form
target_type = request.args.get('target_type')
target_id = request.args.get('target_id')
return_url = request.args.get('return_url', url_for('dashboard.index'))
players = Player.query.order_by(Player.username).all()
groups = Group.query.order_by(Group.name).all()
return render_template(
'content/upload.html',
target_type=target_type,
target_id=target_id,
players=players,
groups=groups,
return_url=return_url
)
@bp.route('/<int:content_id>/edit', methods=['POST'])
@login_required
def edit(content_id):
"""Edit content duration"""
content = Content.query.get_or_404(content_id)
new_duration = request.form.get('duration', type=int)
if not new_duration or new_duration < 1:
flash('Duration must be at least 1 second.', 'danger')
return redirect(request.referrer or url_for('dashboard.index'))
try:
content.duration = new_duration
# Update playlist version for the player
content.player.increment_playlist_version()
db.session.commit()
flash(f'Content duration updated to {new_duration} seconds.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error updating content: {str(e)}', 'danger')
return redirect(request.referrer or url_for('dashboard.index'))
@bp.route('/<int:content_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete(content_id):
"""Delete content"""
content = Content.query.get_or_404(content_id)
player_id = content.player_id
file_name = content.file_name
try:
# Delete file from disk
file_path = os.path.join(current_app.static_folder, 'uploads', content.file_name)
if os.path.exists(file_path):
# Check if file is used by other content
other_content = Content.query.filter(
Content.file_name == content.file_name,
Content.id != content_id
).first()
if not other_content:
os.remove(file_path)
# Delete from database
db.session.delete(content)
# Update playlist version for the player
player = Player.query.get(player_id)
if player:
player.increment_playlist_version()
db.session.commit()
flash(f'Content "{file_name}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting content: {str(e)}', 'danger')
return redirect(request.referrer or url_for('dashboard.index'))
@bp.route('/media/<path:filename>')
def media(filename):
"""Serve media files"""
upload_folder = os.path.join(current_app.static_folder, 'uploads')
return send_from_directory(upload_folder, filename)
@bp.route('/group/<int:group_id>/media/<int:content_id>/edit', methods=['POST'])
@login_required
@admin_required
def edit_group_media(group_id, content_id):
"""Edit media duration for group content"""
group = Group.query.get_or_404(group_id)
content = Content.query.get_or_404(content_id)
new_duration = request.form.get('duration', type=int)
if not new_duration or new_duration < 1:
flash('Duration must be at least 1 second.', 'danger')
return redirect(url_for('group.manage', group_id=group_id))
try:
# Update duration for all content with the same filename in the group
player_ids = [player.id for player in group.players]
Content.query.filter(
Content.player_id.in_(player_ids),
Content.file_name == content.file_name
).update({Content.duration: new_duration})
# Update playlist version for group
group.increment_playlist_version()
db.session.commit()
flash('Media duration updated successfully.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error updating media duration: {str(e)}', 'danger')
return redirect(url_for('group.manage', group_id=group_id))
@bp.route('/group/<int:group_id>/media/<int:content_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_group_media(group_id, content_id):
"""Delete media from group"""
group = Group.query.get_or_404(group_id)
content = Content.query.get_or_404(content_id)
file_name = content.file_name
try:
player_ids = [player.id for player in group.players]
# Get all content with the same filename in the group
group_content = Content.query.filter(
Content.player_id.in_(player_ids),
Content.file_name == file_name
).all()
# Delete all instances
for content_item in group_content:
db.session.delete(content_item)
# Check if file is used elsewhere
other_content = Content.query.filter(
~Content.player_id.in_(player_ids),
Content.file_name == file_name
).first()
# Delete file if not used elsewhere
if not other_content:
file_path = os.path.join(current_app.static_folder, 'uploads', file_name)
if os.path.exists(file_path):
os.remove(file_path)
# Update playlist version for group
group.increment_playlist_version()
db.session.commit()
flash('Media deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting media: {str(e)}', 'danger')
return redirect(url_for('group.manage', group_id=group_id))

35
app/routes/dashboard.py Normal file
View File

@@ -0,0 +1,35 @@
"""
Dashboard routes
"""
from flask import Blueprint, render_template
from flask_login import login_required
from app.models.player import Player
from app.models.group import Group
from app.utils.logger import get_recent_logs
import os
bp = Blueprint('dashboard', __name__)
@bp.route('/')
@login_required
def index():
"""Main dashboard"""
players = Player.query.order_by(Player.username).all()
groups = Group.query.order_by(Group.name).all()
# Check if logo exists
from flask import current_app
logo_path = os.path.join(current_app.static_folder, 'assets', 'logo.png')
logo_exists = os.path.exists(logo_path)
# Get recent server logs
server_logs = get_recent_logs(20)
return render_template(
'dashboard/index.html',
players=players,
groups=groups,
logo_exists=logo_exists,
server_logs=server_logs
)

152
app/routes/group.py Normal file
View File

@@ -0,0 +1,152 @@
"""
Group management routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required
from app.models.group import Group
from app.models.player import Player
from app.utils.group_management import (
create_group, edit_group, delete_group,
get_group_content, update_group_content_order
)
from app.routes.admin import admin_required
bp = Blueprint('group', __name__)
@bp.route('/')
@login_required
@admin_required
def index():
"""List all groups"""
groups = Group.query.order_by(Group.name).all()
return render_template('group/index.html', groups=groups)
@bp.route('/create', methods=['GET', 'POST'])
@login_required
@admin_required
def create():
"""Create new group"""
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
player_ids = request.form.getlist('players')
# Validation
if not name:
flash('Group name is required.', 'danger')
players = Player.query.order_by(Player.username).all()
return render_template('group/create.html', players=players)
# Convert player IDs to integers
try:
player_ids = [int(pid) for pid in player_ids if pid]
except ValueError:
flash('Invalid player selection.', 'danger')
players = Player.query.order_by(Player.username).all()
return render_template('group/create.html', players=players)
# Create group
success, result = create_group(
name=name,
player_ids=player_ids,
description=description if description else None
)
if success:
flash(f'Group "{name}" created successfully.', 'success')
return redirect(url_for('group.manage', group_id=result.id))
else:
flash(f'Error creating group: {result}', 'danger')
players = Player.query.order_by(Player.username).all()
return render_template('group/create.html', players=players)
@bp.route('/<int:group_id>')
@login_required
@admin_required
def manage(group_id):
"""Manage group content"""
group = Group.query.get_or_404(group_id)
content = get_group_content(group_id)
return render_template('group/manage.html', group=group, content=content)
@bp.route('/<int:group_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def edit(group_id):
"""Edit group"""
group = Group.query.get_or_404(group_id)
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
player_ids = request.form.getlist('players')
# Convert player IDs to integers
try:
player_ids = [int(pid) for pid in player_ids if pid]
except ValueError:
flash('Invalid player selection.', 'danger')
players = Player.query.order_by(Player.username).all()
return render_template('group/edit.html', group=group, players=players)
# Update group
success, result = edit_group(
group_id=group_id,
name=name if name else None,
player_ids=player_ids,
description=description
)
if success:
flash(f'Group "{result.name}" updated successfully.', 'success')
return redirect(url_for('group.manage', group_id=group_id))
else:
flash(f'Error updating group: {result}', 'danger')
players = Player.query.order_by(Player.username).all()
return render_template('group/edit.html', group=group, players=players)
@bp.route('/<int:group_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete(group_id):
"""Delete group"""
group = Group.query.get_or_404(group_id)
group_name = group.name
success, error = delete_group(group_id)
if success:
flash(f'Group "{group_name}" deleted successfully.', 'success')
else:
flash(f'Error deleting group: {error}', 'danger')
return redirect(url_for('dashboard.index'))
@bp.route('/<int:group_id>/fullscreen')
@login_required
def fullscreen(group_id):
"""Group fullscreen view"""
group = Group.query.get_or_404(group_id)
content = get_group_content(group_id)
return render_template('group/fullscreen.html', group=group, content=content)
@bp.route('/<int:group_id>/update_order', methods=['POST'])
@login_required
@admin_required
def update_order(group_id):
"""Update content order for group"""
if not request.is_json:
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
items = request.json.get('items', [])
success, error = update_group_content_order(group_id, items)
if success:
return jsonify({'success': True})
else:
return jsonify({'success': False, 'error': error}), 500

167
app/routes/player.py Normal file
View File

@@ -0,0 +1,167 @@
"""
Player management routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from app.models.player import Player
from app.models.content import Content
from app.utils.player_management import (
create_player, edit_player, delete_player,
get_player_content, update_player_content_order,
verify_player_credentials
)
from app.routes.admin import admin_required
bp = Blueprint('player', __name__)
@bp.route('/')
@login_required
@admin_required
def index():
"""List all players"""
players = Player.query.order_by(Player.username).all()
return render_template('player/index.html', players=players)
@bp.route('/add', methods=['GET', 'POST'])
@login_required
@admin_required
def add():
"""Add new player"""
if request.method == 'POST':
username = request.form.get('username', '').strip()
hostname = request.form.get('hostname', '').strip()
password = request.form.get('password', '')
quickconnect_password = request.form.get('quickconnect_password', '')
# Validation
if not username or not hostname or not password:
flash('Username, hostname, and password are required.', 'danger')
return render_template('player/add.html')
# Create player
success, result = create_player(
username=username,
hostname=hostname,
password=password,
quickconnect_password=quickconnect_password if quickconnect_password else None
)
if success:
flash(f'Player "{username}" created successfully.', 'success')
return redirect(url_for('player.view', player_id=result.id))
else:
flash(f'Error creating player: {result}', 'danger')
return render_template('player/add.html')
@bp.route('/<int:player_id>')
@login_required
def view(player_id):
"""View player details and content"""
player = Player.query.get_or_404(player_id)
content = get_player_content(player_id)
return render_template('player/view.html', player=player, content=content)
@bp.route('/<int:player_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def edit(player_id):
"""Edit player"""
player = Player.query.get_or_404(player_id)
if request.method == 'POST':
username = request.form.get('username', '').strip()
hostname = request.form.get('hostname', '').strip()
password = request.form.get('password', '')
quickconnect_password = request.form.get('quickconnect_password', '')
# Update player
success, result = edit_player(
player_id=player_id,
username=username if username else None,
hostname=hostname if hostname else None,
password=password if password else None,
quickconnect_password=quickconnect_password if quickconnect_password else None
)
if success:
flash(f'Player "{result.username}" updated successfully.', 'success')
return redirect(url_for('player.view', player_id=player_id))
else:
flash(f'Error updating player: {result}', 'danger')
return_url = request.args.get('return_url', url_for('player.view', player_id=player_id))
return render_template('player/edit.html', player=player, return_url=return_url)
@bp.route('/<int:player_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete(player_id):
"""Delete player"""
player = Player.query.get_or_404(player_id)
username = player.username
success, error = delete_player(player_id)
if success:
flash(f'Player "{username}" deleted successfully.', 'success')
else:
flash(f'Error deleting player: {error}', 'danger')
return redirect(url_for('dashboard.index'))
@bp.route('/<int:player_id>/fullscreen', methods=['GET', 'POST'])
def fullscreen(player_id):
"""Player fullscreen view with authentication"""
player = Player.query.get_or_404(player_id)
if request.method == 'POST':
hostname = request.form.get('hostname', '')
password = request.form.get('password', '')
quickconnect_code = request.form.get('quickconnect_password', '')
# Verify credentials
if quickconnect_code:
success, result = verify_player_credentials(hostname, None, quickconnect_code)
else:
success, result = verify_player_credentials(hostname, password)
if success and result.id == player_id:
authenticated = True
else:
authenticated = False
flash('Invalid credentials.', 'danger')
else:
authenticated = current_user.is_authenticated
if authenticated:
content = get_player_content(player_id)
return render_template('player/fullscreen.html', player=player, content=content)
else:
return render_template('player/auth.html', player=player)
@bp.route('/<int:player_id>/update_order', methods=['POST'])
@login_required
def update_order(player_id):
"""Update content order for player"""
if not request.is_json:
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
player = Player.query.get_or_404(player_id)
# Check if player is locked to a group (only admin can reorder)
if player.is_locked_to_group and not current_user.is_admin:
return jsonify({
'success': False,
'error': 'Cannot reorder playlist for players locked to groups'
}), 403
items = request.json.get('items', [])
success, error, new_version = update_player_content_order(player_id, items)
if success:
return jsonify({'success': True, 'new_version': new_version})
else:
return jsonify({'success': False, 'error': error}), 500

View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Login - SKE Digital Signage{% endblock %}
{% block content %}
<div class="container-fluid vh-100 d-flex align-items-center justify-content-center">
<div class="row w-100">
<div class="col-md-6 d-flex align-items-center justify-content-center">
{% if login_picture_exists %}
<img src="{{ url_for('static', filename='assets/login_picture.png') }}"
class="img-fluid rounded shadow" alt="Login Picture" style="max-height: 400px;">
{% else %}
<div class="text-center">
<i class="bi bi-display display-1 text-primary"></i>
<h2 class="mt-3 text-muted">SKE Digital Signage</h2>
</div>
{% endif %}
</div>
<div class="col-md-6 d-flex align-items-center justify-content-center">
<div class="card shadow" style="max-width: 400px; width: 100%;">
<div class="card-body p-5">
<div class="text-center mb-4">
<h3><i class="bi bi-lock"></i> Login</h3>
<p class="text-muted">Sign in to your account</p>
</div>
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" class="form-control" id="username" name="username"
required autofocus placeholder="Enter your username">
</div>
</div>
<div class="mb-4">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" id="password" name="password"
required placeholder="Enter your password">
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-box-arrow-in-right"></i> Sign In
</button>
</div>
</form>
{% if config.DEBUG %}
<div class="text-center mt-3">
<small>
<a href="{{ url_for('auth.register') }}" class="text-decoration-none">
Don't have an account? Register here
</a>
</small>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>
{% endblock %}

173
app/templates/base.html Normal file
View File

@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="{{ theme }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SKE Digital Signage{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<!-- Custom CSS -->
<style>
.navbar-brand img {
height: 40px;
width: auto;
}
.content-card {
transition: transform 0.2s;
}
.content-card:hover {
transform: translateY(-2px);
}
.log-entry {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
}
.sortable {
list-style: none;
padding: 0;
}
.sortable li {
margin: 0 0 5px 0;
padding: 10px;
background: var(--bs-light);
border: 1px solid var(--bs-border-color);
border-radius: 5px;
cursor: move;
}
.sortable li:hover {
background: var(--bs-secondary-bg);
}
.fullscreen-container {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #000;
}
.media-display {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Navigation -->
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
{% if logo_exists %}
<img src="{{ url_for('static', filename='assets/logo.png') }}" alt="SKE Digital Signage">
{% else %}
<i class="bi bi-display"></i> SKE Digital Signage
{% endif %}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-house"></i> Dashboard
</a>
</li>
{% if current_user.is_admin %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('content.upload') }}">
<i class="bi bi-cloud-upload"></i> Upload Content
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.index') }}">
<i class="bi bi-gear"></i> Admin
</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person"></i> {{ current_user.username }}
</a>
<ul class="dropdown-menu">
<li>
<form method="POST" action="{{ url_for('admin.change_theme') }}" class="d-inline">
<input type="hidden" name="theme" value="{{ 'light' if theme == 'dark' else 'dark' }}">
<button class="dropdown-item" type="submit">
<i class="bi bi-{{ 'sun' if theme == 'dark' else 'moon' }}"></i>
{{ 'Light' if theme == 'dark' else 'Dark' }} Theme
</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right"></i> Logout
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="container-fluid mt-3">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Main Content -->
<main>
{% block content %}{% endblock %}
</main>
<!-- Footer -->
{% if current_user.is_authenticated %}
<footer class="bg-light mt-5 py-3 border-top">
<div class="container-fluid">
<div class="row">
<div class="col-md-6">
<small class="text-muted">
SKE Digital Signage v{{ server_version }}
<span class="text-muted">| Build: {{ build_date }}</span>
</small>
</div>
<div class="col-md-6 text-end">
<small class="text-muted">
Logged in as: {{ current_user.username }} ({{ current_user.role }})
</small>
</div>
</div>
</div>
</footer>
{% endif %}
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery and Sortable (for drag & drop) -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,234 @@
{% extends "base.html" %}
{% block title %}Dashboard - 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-house"></i> Dashboard</h1>
<p class="text-muted">Manage your digital signage displays and content</p>
</div>
{% if current_user.is_admin %}
<div class="col-auto">
<a href="{{ url_for('content.upload') }}" class="btn btn-primary">
<i class="bi bi-cloud-upload"></i> Upload Content
</a>
</div>
{% endif %}
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h5 class="card-title">Players</h5>
<h2>{{ players|length }}</h2>
</div>
<div class="align-self-center">
<i class="bi bi-display display-6"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h5 class="card-title">Groups</h5>
<h2>{{ groups|length }}</h2>
</div>
<div class="align-self-center">
<i class="bi bi-collection display-6"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<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 display-6"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h5 class="card-title">Total Content</h5>
<h2>{{ players|sum(attribute='content')|length }}</h2>
</div>
<div class="align-self-center">
<i class="bi bi-file-earmark-play display-6"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Players Section -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="bi bi-display"></i> Players</h5>
{% if current_user.is_admin %}
<a href="{{ url_for('player.add') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-plus"></i> Add Player
</a>
{% endif %}
</div>
<div class="card-body">
{% if players %}
{% for player in players %}
<div class="d-flex justify-content-between align-items-center p-2 border-bottom">
<div>
<h6 class="mb-1">{{ player.username }}</h6>
<small class="text-muted">{{ player.hostname }}</small>
{% if player.is_locked_to_group %}
<span class="badge bg-info ms-2">Locked to Group</span>
{% endif %}
</div>
<div>
<span class="badge bg-{{ 'success' if player.is_active else 'secondary' }}">
{{ 'Online' if player.is_active else 'Offline' }}
</span>
<div class="btn-group btn-group-sm ms-2">
<a href="{{ url_for('player.view', player_id=player.id) }}"
class="btn btn-outline-secondary" title="View">
<i class="bi bi-eye"></i>
</a>
<a href="{{ url_for('player.fullscreen', player_id=player.id) }}"
class="btn btn-outline-primary" title="Fullscreen" target="_blank">
<i class="bi bi-arrows-fullscreen"></i>
</a>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-4">
<i class="bi bi-display display-1"></i>
<p class="mt-2">No players configured yet</p>
{% if current_user.is_admin %}
<a href="{{ url_for('player.add') }}" class="btn btn-primary">
Add Your First Player
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Groups Section -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="bi bi-collection"></i> Groups</h5>
{% if current_user.is_admin %}
<a href="{{ url_for('group.create') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-plus"></i> Create Group
</a>
{% endif %}
</div>
<div class="card-body">
{% if groups %}
{% for group in groups %}
<div class="d-flex justify-content-between align-items-center p-2 border-bottom">
<div>
<h6 class="mb-1">{{ group.name }}</h6>
<small class="text-muted">{{ group.player_count }} players</small>
</div>
<div class="btn-group btn-group-sm">
{% if current_user.is_admin %}
<a href="{{ url_for('group.manage', group_id=group.id) }}"
class="btn btn-outline-secondary" title="Manage">
<i class="bi bi-gear"></i>
</a>
{% endif %}
<a href="{{ url_for('group.fullscreen', group_id=group.id) }}"
class="btn btn-outline-primary" title="Fullscreen" target="_blank">
<i class="bi bi-arrows-fullscreen"></i>
</a>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-4">
<i class="bi bi-collection display-1"></i>
<p class="mt-2">No groups created yet</p>
{% if current_user.is_admin %}
<a href="{{ url_for('group.create') }}" class="btn btn-primary">
Create Your First Group
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Server Logs -->
{% if current_user.is_admin and server_logs %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-journal-text"></i> Recent Server Logs</h5>
</div>
<div class="card-body">
<div class="table-responsive" style="max-height: 300px; overflow-y: auto;">
<table class="table table-sm">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>User</th>
<th>Level</th>
</tr>
</thead>
<tbody>
{% for log in server_logs %}
<tr class="log-entry">
<td class="text-nowrap">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ log.action }}</td>
<td>{{ log.user.username if log.user else '-' }}</td>
<td>
<span class="badge bg-{{ 'danger' if log.level == 'ERROR' else 'warning' if log.level == 'WARNING' else 'info' }}">
{{ log.level }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

23
app/utils/__init__.py Normal file
View File

@@ -0,0 +1,23 @@
"""
Utility functions package
"""
from .logger import log_action, get_recent_logs
from .uploads import process_uploaded_files
from .player_management import (
create_player, edit_player, delete_player,
get_player_content, update_player_content_order
)
from .group_management import (
create_group, edit_group, delete_group,
get_group_content, update_group_content_order
)
__all__ = [
'log_action', 'get_recent_logs',
'process_uploaded_files',
'create_player', 'edit_player', 'delete_player',
'get_player_content', 'update_player_content_order',
'create_group', 'edit_group', 'delete_group',
'get_group_content', 'update_group_content_order'
]

View File

@@ -0,0 +1,324 @@
"""
Group management utilities
"""
from app.extensions import db
from app.models.group import Group
from app.models.player import Player
from app.models.content import Content
from app.utils.logger import log_group_created, log_group_edited, log_group_deleted, log_content_reordered
def create_group(name, player_ids=None, description=None):
"""
Create a new group
Args:
name (str): Group name
player_ids (list): List of player IDs to add to group (optional)
description (str): Group description (optional)
Returns:
tuple: (success, group_or_error_message)
"""
try:
# Check if group name already exists
if Group.query.filter_by(name=name).first():
return False, f"Group with name '{name}' already exists"
# Create new group
group = Group(
name=name,
description=description or ""
)
# Add players to group
if player_ids:
players = Player.query.filter(Player.id.in_(player_ids)).all()
for player in players:
group.players.append(player)
db.session.add(group)
db.session.commit()
log_group_created(name)
return True, group
except Exception as e:
db.session.rollback()
return False, str(e)
def edit_group(group_id, name=None, player_ids=None, description=None):
"""
Edit an existing group
Args:
group_id (int): Group ID
name (str): New group name (optional)
player_ids (list): New list of player IDs (optional)
description (str): New description (optional)
Returns:
tuple: (success, group_or_error_message)
"""
try:
group = Group.query.get(group_id)
if not group:
return False, "Group not found"
# Check for name conflicts
if name and name != group.name:
existing = Group.query.filter_by(name=name).first()
if existing and existing.id != group_id:
return False, f"Group with name '{name}' already exists"
group.name = name
# Update description
if description is not None:
group.description = description
# Update players
if player_ids is not None:
# Clear current players
group.players.clear()
# Add new players
if player_ids:
players = Player.query.filter(Player.id.in_(player_ids)).all()
for player in players:
group.players.append(player)
db.session.commit()
log_group_edited(group.name)
return True, group
except Exception as e:
db.session.rollback()
return False, str(e)
def delete_group(group_id):
"""
Delete a group (players remain, just removed from group)
Args:
group_id (int): Group ID
Returns:
tuple: (success, error_message)
"""
try:
group = Group.query.get(group_id)
if not group:
return False, "Group not found"
group_name = group.name
# Remove all players from group
group.players.clear()
# Delete group
db.session.delete(group)
db.session.commit()
log_group_deleted(group_name)
return True, None
except Exception as e:
db.session.rollback()
return False, str(e)
def get_group_content(group_id):
"""
Get all unique content for players in a group
Args:
group_id (int): Group ID
Returns:
list: List of Content objects
"""
group = Group.query.get(group_id)
if not group:
return []
player_ids = [player.id for player in group.players]
if not player_ids:
return []
# Get unique content by filename, taking the first occurrence
content_query = (
db.session.query(
Content.file_name,
db.func.min(Content.id).label('id'),
db.func.min(Content.duration).label('duration'),
db.func.min(Content.position).label('position')
)
.filter(Content.player_id.in_(player_ids))
.group_by(Content.file_name)
)
# Get the actual content objects
content_ids = [c.id for c in content_query.all()]
content = Content.query.filter(Content.id.in_(content_ids)).order_by(Content.position).all()
return content
def update_group_content_order(group_id, content_items):
"""
Update the order of content items for all players in a group
Args:
group_id (int): Group ID
content_items (list): List of content items with new positions
Returns:
tuple: (success, error_message)
"""
try:
group = Group.query.get(group_id)
if not group:
return False, "Group not found"
player_ids = [player.id for player in group.players]
if not player_ids:
return True, None # No players in group
# Update positions for all matching content across all players
for i, item in enumerate(content_items):
file_name = item.get('file_name')
if file_name:
Content.query.filter(
Content.player_id.in_(player_ids),
Content.file_name == file_name
).update({Content.position: i})
# Increment playlist version for all players and group
group.increment_playlist_version()
db.session.commit()
log_content_reordered('group', group.name)
return True, None
except Exception as e:
db.session.rollback()
return False, str(e)
def add_player_to_group(group_id, player_id):
"""
Add a player to a group
Args:
group_id (int): Group ID
player_id (int): Player ID
Returns:
tuple: (success, error_message)
"""
try:
group = Group.query.get(group_id)
player = Player.query.get(player_id)
if not group:
return False, "Group not found"
if not player:
return False, "Player not found"
if player not in group.players:
group.players.append(player)
db.session.commit()
return True, None
except Exception as e:
db.session.rollback()
return False, str(e)
def remove_player_from_group(group_id, player_id):
"""
Remove a player from a group
Args:
group_id (int): Group ID
player_id (int): Player ID
Returns:
tuple: (success, error_message)
"""
try:
group = Group.query.get(group_id)
player = Player.query.get(player_id)
if not group:
return False, "Group not found"
if not player:
return False, "Player not found"
if player in group.players:
group.players.remove(player)
db.session.commit()
return True, None
except Exception as e:
db.session.rollback()
return False, str(e)
def get_available_players():
"""
Get all players that are not locked to any group
Returns:
list: List of Player objects
"""
return Player.query.filter_by(locked_to_group_id=None).all()
def lock_players_to_group(group_id, player_ids):
"""
Lock specified players to a group (exclusive membership)
Args:
group_id (int): Group ID
player_ids (list): List of player IDs to lock
Returns:
tuple: (success, error_message)
"""
try:
group = Group.query.get(group_id)
if not group:
return False, "Group not found"
players = Player.query.filter(Player.id.in_(player_ids)).all()
for player in players:
player.locked_to_group_id = group_id
db.session.commit()
return True, None
except Exception as e:
db.session.rollback()
return False, str(e)
def unlock_players_from_group(group_id):
"""
Unlock all players from a group
Args:
group_id (int): Group ID
Returns:
tuple: (success, error_message)
"""
try:
players = Player.query.filter_by(locked_to_group_id=group_id).all()
for player in players:
player.locked_to_group_id = None
db.session.commit()
return True, None
except Exception as e:
db.session.rollback()
return False, str(e)

116
app/utils/logger.py Normal file
View File

@@ -0,0 +1,116 @@
"""
Logging utilities for server actions
"""
import datetime
from flask import request
from flask_login import current_user
from app.extensions import db
from app.models.server_log import ServerLog
def log_action(action, level='INFO', user_id=None, ip_address=None, user_agent=None):
"""
Log an action to the server log database
Args:
action (str): Description of the action
level (str): Log level (INFO, WARNING, ERROR, DEBUG)
user_id (int): User ID if action is user-specific
ip_address (str): IP address of the request
user_agent (str): User agent string
"""
try:
# Auto-detect user and request info if not provided
if user_id is None and hasattr(current_user, 'id') and current_user.is_authenticated:
user_id = current_user.id
if ip_address is None and request:
ip_address = request.remote_addr
if user_agent is None and request:
user_agent = request.user_agent.string
ServerLog.log(
action=action,
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
level=level
)
print(f"[{level}] {action}")
except Exception as e:
print(f"Error logging action: {e}")
# Try to rollback and log without context
try:
db.session.rollback()
log_entry = ServerLog(action=f"ERROR: {action} (logging failed: {str(e)})", level='ERROR')
db.session.add(log_entry)
db.session.commit()
except:
pass # If this fails too, we can't do much
def get_recent_logs(limit=50):
"""
Get the most recent log entries
Args:
limit (int): Maximum number of log entries to return
Returns:
list: List of ServerLog objects
"""
return ServerLog.get_recent_logs(limit)
# Helper functions for common log actions
def log_upload(file_type, file_name, target_type, target_name):
"""Log file upload action"""
log_action(f"{file_type.upper()} file '{file_name}' uploaded for {target_type} '{target_name}'")
def log_process(file_type, file_name, target_type, target_name):
"""Log file processing action"""
log_action(f"{file_type.upper()} file '{file_name}' processed for {target_type} '{target_name}'")
def log_player_created(username, hostname):
"""Log player creation"""
log_action(f"Player '{username}' with hostname '{hostname}' was created")
def log_player_edited(username):
"""Log player edit"""
log_action(f"Player '{username}' was edited")
def log_player_deleted(username):
"""Log player deletion"""
log_action(f"Player '{username}' was deleted")
def log_group_created(name):
"""Log group creation"""
log_action(f"Group '{name}' was created")
def log_group_edited(name):
"""Log group edit"""
log_action(f"Group '{name}' was edited")
def log_group_deleted(name):
"""Log group deletion"""
log_action(f"Group '{name}' was deleted")
def log_user_created(username, role):
"""Log user creation"""
log_action(f"User '{username}' with role '{role}' was created")
def log_user_deleted(username):
"""Log user deletion"""
log_action(f"User '{username}' was deleted")
def log_content_added(file_name, target_type, target_name):
"""Log content addition"""
log_action(f"Content '{file_name}' added to {target_type} '{target_name}'")
def log_content_reordered(target_type, target_name):
"""Log content reordering"""
log_action(f"Content reordered for {target_type} '{target_name}'")
def log_content_duration_changed(file_name, new_duration):
"""Log content duration change"""
log_action(f"Content '{file_name}' duration changed to {new_duration} seconds")

View File

@@ -0,0 +1,222 @@
"""
Player management utilities
"""
from app.extensions import db, bcrypt
from app.models.player import Player
from app.models.content import Content
from app.utils.logger import log_player_created, log_player_edited, log_player_deleted, log_content_reordered
def create_player(username, hostname, password, quickconnect_password=None):
"""
Create a new player
Args:
username (str): Player username
hostname (str): Player hostname
password (str): Player password
quickconnect_password (str): Quick connect password (optional)
Returns:
tuple: (success, player_or_error_message)
"""
try:
# Check if hostname already exists
if Player.query.filter_by(hostname=hostname).first():
return False, f"Player with hostname '{hostname}' already exists"
# Create new player
player = Player(
username=username,
hostname=hostname
)
# Set passwords
player.set_password(password)
if quickconnect_password:
player.set_quickconnect_password(quickconnect_password)
db.session.add(player)
db.session.commit()
log_player_created(username, hostname)
return True, player
except Exception as e:
db.session.rollback()
return False, str(e)
def edit_player(player_id, username=None, hostname=None, password=None, quickconnect_password=None):
"""
Edit an existing player
Args:
player_id (int): Player ID
username (str): New username (optional)
hostname (str): New hostname (optional)
password (str): New password (optional)
quickconnect_password (str): New quick connect password (optional)
Returns:
tuple: (success, player_or_error_message)
"""
try:
player = Player.query.get(player_id)
if not player:
return False, "Player not found"
# Check for hostname conflicts
if hostname and hostname != player.hostname:
existing = Player.query.filter_by(hostname=hostname).first()
if existing and existing.id != player_id:
return False, f"Player with hostname '{hostname}' already exists"
player.hostname = hostname
# Update fields
if username:
player.username = username
if password:
player.set_password(password)
if quickconnect_password:
player.set_quickconnect_password(quickconnect_password)
db.session.commit()
log_player_edited(player.username)
return True, player
except Exception as e:
db.session.rollback()
return False, str(e)
def delete_player(player_id):
"""
Delete a player and all its content
Args:
player_id (int): Player ID
Returns:
tuple: (success, error_message)
"""
try:
player = Player.query.get(player_id)
if not player:
return False, "Player not found"
username = player.username
# Delete all content files
import os
from flask import current_app
upload_folder = os.path.join(current_app.static_folder, 'uploads')
for content in player.content:
file_path = os.path.join(upload_folder, content.file_name)
if os.path.exists(file_path):
try:
os.remove(file_path)
except:
pass # File might be used by other content
# Remove from groups
for group in player.groups:
group.players.remove(player)
# Delete player (cascades to content)
db.session.delete(player)
db.session.commit()
log_player_deleted(username)
return True, None
except Exception as e:
db.session.rollback()
return False, str(e)
def get_player_content(player_id):
"""
Get all content for a player ordered by position
Args:
player_id (int): Player ID
Returns:
list: List of Content objects
"""
return Content.query.filter_by(player_id=player_id).order_by(Content.position).all()
def update_player_content_order(player_id, content_items):
"""
Update the order of content items for a player
Args:
player_id (int): Player ID
content_items (list): List of content items with new positions
Returns:
tuple: (success, error_message, new_playlist_version)
"""
try:
player = Player.query.get(player_id)
if not player:
return False, "Player not found", None
# Update positions
for i, item in enumerate(content_items):
content_id = item.get('id')
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if content:
content.position = i
# Increment playlist version
player.increment_playlist_version()
db.session.commit()
log_content_reordered('player', player.username)
return True, None, player.playlist_version
except Exception as e:
db.session.rollback()
return False, str(e), None
def get_player_by_hostname(hostname):
"""
Get player by hostname
Args:
hostname (str): Player hostname
Returns:
Player: Player object or None
"""
return Player.query.filter_by(hostname=hostname).first()
def verify_player_credentials(hostname, password, quickconnect_code=None):
"""
Verify player credentials
Args:
hostname (str): Player hostname
password (str): Player password
quickconnect_code (str): Quick connect code (optional)
Returns:
tuple: (success, player_or_error_message)
"""
player = get_player_by_hostname(hostname)
if not player:
return False, "Player not found"
if quickconnect_code:
if player.verify_quickconnect_code(quickconnect_code):
return True, player
else:
return False, "Invalid quick connect code"
else:
if player.check_password(password):
return True, player
else:
return False, "Invalid password"

382
app/utils/uploads.py Normal file
View File

@@ -0,0 +1,382 @@
"""
File upload processing utilities
"""
import os
import subprocess
import shutil
from werkzeug.utils import secure_filename
from pdf2image import convert_from_path
from PIL import Image
from app.extensions import db
from app.models.content import Content
from app.utils.logger import log_upload, log_process, log_content_added
def allowed_file(filename, file_type='all'):
"""
Check if file extension is allowed
Args:
filename (str): Name of the file
file_type (str): Type of file to check ('images', 'videos', 'documents', 'all')
Returns:
bool: True if file is allowed
"""
from flask import current_app
if '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
allowed_extensions = current_app.config['ALLOWED_EXTENSIONS']
if file_type == 'all':
all_extensions = set()
for extensions in allowed_extensions.values():
all_extensions.update(extensions)
return ext in all_extensions
return ext in allowed_extensions.get(file_type, set())
def get_file_type(filename):
"""
Determine file type based on extension
Args:
filename (str): Name of the file
Returns:
str: File type ('image', 'video', 'document')
"""
from flask import current_app
if '.' not in filename:
return 'unknown'
ext = filename.rsplit('.', 1)[1].lower()
allowed_extensions = current_app.config['ALLOWED_EXTENSIONS']
for file_type, extensions in allowed_extensions.items():
if ext in extensions:
return file_type.rstrip('s') # Remove 's' from 'images', 'videos', etc.
return 'unknown'
def save_uploaded_file(file, upload_folder):
"""
Save uploaded file to disk
Args:
file: FileStorage object from request
upload_folder (str): Path to upload folder
Returns:
tuple: (success, filename, error_message)
"""
try:
if not file or file.filename == '':
return False, None, "No file selected"
if not allowed_file(file.filename):
return False, None, f"File type not allowed: {file.filename}"
# Generate secure filename
original_filename = file.filename
filename = secure_filename(original_filename)
# Handle duplicate filenames
base_name, ext = os.path.splitext(filename)
counter = 1
while os.path.exists(os.path.join(upload_folder, filename)):
filename = f"{base_name}_{counter}{ext}"
counter += 1
# Save file
file_path = os.path.join(upload_folder, filename)
file.save(file_path)
return True, filename, None
except Exception as e:
return False, None, str(e)
def process_image(file_path, max_width=1920, max_height=1080):
"""
Process and optimize image file
Args:
file_path (str): Path to image file
max_width (int): Maximum width for resizing
max_height (int): Maximum height for resizing
Returns:
tuple: (width, height) of processed image
"""
try:
with Image.open(file_path) as img:
# Get original dimensions
original_width, original_height = img.size
# Calculate new dimensions while maintaining aspect ratio
if original_width > max_width or original_height > max_height:
img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
img.save(file_path, optimize=True, quality=85)
return img.size
except Exception as e:
print(f"Error processing image {file_path}: {e}")
return None, None
def process_video(file_path, output_path=None):
"""
Process video file (convert to web-compatible format)
Args:
file_path (str): Path to input video file
output_path (str): Path for output file (optional)
Returns:
tuple: (success, output_filename, error_message)
"""
try:
if output_path is None:
base_name = os.path.splitext(file_path)[0]
output_path = f"{base_name}_converted.mp4"
# Use FFmpeg to convert video
cmd = [
'ffmpeg', '-i', file_path,
'-c:v', 'libx264',
'-preset', 'medium',
'-crf', '23',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
'-y', # Overwrite output file
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
# Remove original file if conversion successful
if os.path.exists(output_path) and file_path != output_path:
os.remove(file_path)
return True, os.path.basename(output_path), None
else:
return False, None, result.stderr
except Exception as e:
return False, None, str(e)
def process_pdf(file_path, output_folder):
"""
Convert PDF to images
Args:
file_path (str): Path to PDF file
output_folder (str): Folder to save converted images
Returns:
list: List of generated image filenames
"""
try:
images = convert_from_path(file_path, dpi=150)
base_name = os.path.splitext(os.path.basename(file_path))[0]
image_files = []
for i, image in enumerate(images):
image_filename = f"{base_name}_page_{i+1}.png"
image_path = os.path.join(output_folder, image_filename)
image.save(image_path, 'PNG')
image_files.append(image_filename)
# Remove original PDF
os.remove(file_path)
return image_files
except Exception as e:
print(f"Error processing PDF {file_path}: {e}")
return []
def process_pptx(file_path, output_folder):
"""
Convert PowerPoint to images using LibreOffice
Args:
file_path (str): Path to PPTX file
output_folder (str): Folder to save converted images
Returns:
list: List of generated image filenames
"""
try:
# Use LibreOffice to convert PPTX to PDF first
temp_dir = os.path.join(output_folder, 'temp')
os.makedirs(temp_dir, exist_ok=True)
cmd = [
'libreoffice', '--headless',
'--convert-to', 'pdf',
'--outdir', temp_dir,
file_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
# Find the generated PDF
base_name = os.path.splitext(os.path.basename(file_path))[0]
pdf_path = os.path.join(temp_dir, f"{base_name}.pdf")
if os.path.exists(pdf_path):
# Convert PDF to images
image_files = process_pdf(pdf_path, output_folder)
# Clean up
shutil.rmtree(temp_dir)
os.remove(file_path)
return image_files
return []
except Exception as e:
print(f"Error processing PPTX {file_path}: {e}")
return []
def process_uploaded_files(app, files, duration, target_type, target_id):
"""
Process uploaded files and add them to the database
Args:
app: Flask application instance
files: List of uploaded files
duration (int): Duration for each file in seconds
target_type (str): 'player' or 'group'
target_id (int): ID of the target player or group
Returns:
dict: Results of processing
"""
upload_folder = os.path.join(app.static_folder, 'uploads')
results = {
'success': [],
'errors': [],
'processed': 0
}
for file in files:
if not file or file.filename == '':
continue
try:
# Save the file
success, filename, error = save_uploaded_file(file, upload_folder)
if not success:
results['errors'].append(f"{file.filename}: {error}")
continue
file_path = os.path.join(upload_folder, filename)
file_type = get_file_type(filename)
# Process based on file type
processed_files = []
if file_type == 'image':
width, height = process_image(file_path)
processed_files = [filename]
elif file_type == 'video':
success, converted_filename, error = process_video(file_path)
if success:
processed_files = [converted_filename]
else:
results['errors'].append(f"{filename}: Video conversion failed - {error}")
continue
elif file_type == 'document':
if filename.lower().endswith('.pdf'):
processed_files = process_pdf(file_path, upload_folder)
elif filename.lower().endswith(('.pptx', '.ppt')):
processed_files = process_pptx(file_path, upload_folder)
if not processed_files:
results['errors'].append(f"{filename}: Document conversion failed")
continue
# Add processed files to database
from app.models.player import Player
if target_type == 'player':
player = Player.query.get(target_id)
if not player:
results['errors'].append(f"Player {target_id} not found")
continue
# Get max position for ordering
max_position = db.session.query(db.func.max(Content.position)).filter_by(player_id=target_id).scalar() or 0
for processed_file in processed_files:
content = Content(
file_name=processed_file,
original_name=file.filename,
duration=duration,
player_id=target_id,
content_type=file_type,
position=max_position + 1
)
db.session.add(content)
max_position += 1
# Update playlist version
player.increment_playlist_version()
log_content_added(file.filename, 'player', player.username)
elif target_type == 'group':
from app.models.group import Group
group = Group.query.get(target_id)
if not group:
results['errors'].append(f"Group {target_id} not found")
continue
# Add content to all players in the group
for player in group.players:
max_position = db.session.query(db.func.max(Content.position)).filter_by(player_id=player.id).scalar() or 0
for processed_file in processed_files:
content = Content(
file_name=processed_file,
original_name=file.filename,
duration=duration,
player_id=player.id,
content_type=file_type,
position=max_position + 1
)
db.session.add(content)
max_position += 1
player.increment_playlist_version()
log_content_added(file.filename, 'group', group.name)
results['success'].append(file.filename)
results['processed'] += len(processed_files)
# Log the upload
log_upload(file_type, file.filename, target_type, target_id)
except Exception as e:
results['errors'].append(f"{file.filename}: {str(e)}")
# Commit all changes
try:
db.session.commit()
except Exception as e:
db.session.rollback()
results['errors'].append(f"Database error: {str(e)}")
return results

65
config.py Normal file
View File

@@ -0,0 +1,65 @@
"""
SKE Digital Signage Server Configuration
"""
import os
from datetime import timedelta
class Config:
"""Base configuration class"""
# Application Settings
SECRET_KEY = os.environ.get('SECRET_KEY', 'ske-signage-secret-key-change-in-production')
# Server Information
SERVER_VERSION = "2.0.0"
BUILD_DATE = "2025-07-15"
# Database Configuration
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(os.path.abspath(os.path.dirname(__file__)), 'instance', 'ske_signage.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Upload Configuration
MAX_CONTENT_LENGTH = 2 * 1024 * 1024 * 1024 # 2GB
UPLOAD_FOLDER = 'static/uploads'
ALLOWED_EXTENSIONS = {
'images': {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'},
'videos': {'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'},
'documents': {'pdf', 'pptx', 'ppt'}
}
# Security Configuration
SESSION_PERMANENT = False
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
# Logging Configuration
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
# Media Processing Configuration
FFMPEG_PATH = os.environ.get('FFMPEG_PATH', 'ffmpeg')
LIBREOFFICE_PATH = os.environ.get('LIBREOFFICE_PATH', 'libreoffice')
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
SQLALCHEMY_ECHO = True
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
SQLALCHEMY_ECHO = False
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
WTF_CSRF_ENABLED = False
# Configuration dictionary
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}

57
docker-compose.yml Normal file
View File

@@ -0,0 +1,57 @@
version: '3.8'
services:
ske-signage:
build: .
image: ske-signage:2.0.0
container_name: ske-signage-server
restart: unless-stopped
ports:
- "8880:5000"
environment:
# Flask Configuration
- FLASK_CONFIG=production
- SECRET_KEY=${SECRET_KEY:-ske-signage-production-secret-change-me}
# Database Configuration
- DATABASE_URL=sqlite:///./instance/ske_signage.db
# Admin User Configuration
- ADMIN_USER=${ADMIN_USER:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-ChangeMe123!}
# Server Configuration
- HOST=0.0.0.0
- PORT=5000
- FLASK_DEBUG=false
# Logging
- LOG_LEVEL=INFO
volumes:
# Persistent data storage
- ske_signage_data:/app/instance
- ske_signage_uploads:/app/static/uploads
- ske_signage_assets:/app/static/assets
- ske_signage_logs:/app/logs
networks:
- ske_signage_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
ske_signage_data:
driver: local
ske_signage_uploads:
driver: local
ske_signage_assets:
driver: local
ske_signage_logs:
driver: local
networks:
ske_signage_network:
driver: bridge

56
main.py Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
SKE Digital Signage Server
Main application entry point
"""
import os
import sys
from app import create_app
from app.extensions import db
from app.models import User, ServerLog
from app.utils.logger import log_action
def main():
"""Main application entry point"""
# Create the Flask application
app = create_app()
# Initialize database and create default admin user if needed
with app.app_context():
db.create_all()
create_default_admin()
log_action("Server started")
# Run the application
port = int(os.environ.get('PORT', 5000))
host = os.environ.get('HOST', '0.0.0.0')
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
app.run(host=host, port=port, debug=debug)
def create_default_admin():
"""Create default admin user if none exists"""
from app.extensions import bcrypt
admin_username = os.environ.get('ADMIN_USER', 'admin')
admin_password = os.environ.get('ADMIN_PASSWORD', 'admin123')
# Check if admin user already exists
if not User.query.filter_by(username=admin_username).first():
hashed_password = bcrypt.generate_password_hash(admin_password).decode('utf-8')
admin_user = User(
username=admin_username,
password=hashed_password,
role='admin'
)
db.session.add(admin_user)
db.session.commit()
print(f"Default admin user '{admin_username}' created with password '{admin_password}'")
log_action(f"Default admin user '{admin_username}' created")
else:
print(f"Admin user '{admin_username}' already exists")
if __name__ == '__main__':
main()

231
manage.sh Executable file
View File

@@ -0,0 +1,231 @@
#!/bin/bash
# SKE Digital Signage - Management Script
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Helper functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if Docker is available
check_docker() {
if ! command -v docker &> /dev/null; then
log_error "Docker is not installed or not in PATH"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
log_error "Docker Compose is not installed or not in PATH"
exit 1
fi
}
# Check if Python environment is set up
check_python() {
if [ ! -d "venv" ]; then
log_error "Python virtual environment not found. Run './manage.sh setup' first."
exit 1
fi
}
# Development setup
setup_dev() {
log_info "Setting up development environment..."
# Create virtual environment
if [ ! -d "venv" ]; then
log_info "Creating Python virtual environment..."
python3 -m venv venv
fi
# Activate virtual environment and install dependencies
log_info "Installing Python dependencies..."
source venv/bin/activate
pip install -r requirements.txt
# Create environment file if it doesn't exist
if [ ! -f ".env" ]; then
log_info "Creating .env file from template..."
cp .env.example .env
log_warning "Please edit .env file with your configuration"
fi
# Create necessary directories
mkdir -p instance static/uploads static/assets logs
log_success "Development environment setup complete!"
log_info "Run './manage.sh dev' to start development server"
}
# Run development server
run_dev() {
check_python
log_info "Starting development server..."
source venv/bin/activate
export FLASK_CONFIG=development
export FLASK_DEBUG=true
python main.py
}
# Build Docker image
build_docker() {
check_docker
log_info "Building Docker image..."
docker build -t ske-signage:2.0.0 .
log_success "Docker image built successfully!"
}
# Start production with Docker Compose
start_prod() {
check_docker
if [ ! -f ".env" ]; then
log_warning "No .env file found, creating from template..."
cp .env.example .env
log_warning "Please edit .env file with production settings before starting"
return 1
fi
log_info "Starting production server with Docker Compose..."
docker-compose up -d
log_success "Production server started!"
log_info "Access the application at http://localhost:8880"
log_info "View logs with: ./manage.sh logs"
}
# Stop production server
stop_prod() {
check_docker
log_info "Stopping production server..."
docker-compose down
log_success "Production server stopped!"
}
# View logs
view_logs() {
check_docker
docker-compose logs -f
}
# Clean up
cleanup() {
log_info "Cleaning up..."
# Remove Python cache
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete 2>/dev/null || true
# Remove logs
rm -rf logs/*.log 2>/dev/null || true
log_success "Cleanup complete!"
}
# Backup data
backup() {
BACKUP_DIR="backups/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
log_info "Creating backup in $BACKUP_DIR..."
# Backup database
if [ -f "instance/ske_signage.db" ]; then
cp instance/ske_signage.db "$BACKUP_DIR/"
fi
# Backup uploads
if [ -d "static/uploads" ]; then
cp -r static/uploads "$BACKUP_DIR/"
fi
# Backup assets
if [ -d "static/assets" ]; then
cp -r static/assets "$BACKUP_DIR/"
fi
log_success "Backup created in $BACKUP_DIR"
}
# Show help
show_help() {
echo "SKE Digital Signage Management Script"
echo ""
echo "Usage: $0 <command>"
echo ""
echo "Commands:"
echo " setup - Set up development environment"
echo " dev - Run development server"
echo " build - Build Docker image"
echo " start - Start production server (Docker Compose)"
echo " stop - Stop production server"
echo " logs - View production logs"
echo " backup - Create backup of data"
echo " cleanup - Clean up temporary files"
echo " help - Show this help message"
echo ""
echo "Examples:"
echo " $0 setup # First time setup"
echo " $0 dev # Development"
echo " $0 start # Production"
}
# Main script logic
case "${1:-help}" in
setup)
setup_dev
;;
dev)
run_dev
;;
build)
build_docker
;;
start)
start_prod
;;
stop)
stop_prod
;;
logs)
view_logs
;;
backup)
backup
;;
cleanup)
cleanup
;;
help|--help|-h)
show_help
;;
*)
log_error "Unknown command: $1"
show_help
exit 1
;;
esac

36
requirements.txt Normal file
View File

@@ -0,0 +1,36 @@
# Core Flask
Flask==3.1.0
Werkzeug==3.1.3
Jinja2==3.1.5
itsdangerous==2.2.0
click==8.1.8
blinker==1.9.0
# Flask Extensions
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.1.0
Flask-Bcrypt==1.0.1
Flask-Login==0.6.3
# Database
SQLAlchemy==2.0.37
alembic==1.14.1
# File Processing
pdf2image==1.17.0
PyPDF2==3.0.1
python-pptx==0.6.21
Pillow==10.0.1
cairosvg==2.7.0
ffmpeg-python==0.2.0
python-magic==0.4.27
# Security
bcrypt==4.2.1
# Production Server
gunicorn==20.1.0
# Utilities
python-dotenv==1.0.1
MarkupSafe==3.0.2