updated first commit
This commit is contained in:
69
.dockerignore
Normal file
69
.dockerignore
Normal 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
25
.env.example
Normal 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
92
.gitignore
vendored
Normal 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
63
Dockerfile
Normal 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
242
README.md
Normal 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
85
app/__init__.py
Normal 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
12
app/extensions.py
Normal 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
11
app/models/__init__.py
Normal 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
38
app/models/content.py
Normal 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
48
app/models/group.py
Normal 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
54
app/models/player.py
Normal 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
42
app/models/server_log.py
Normal 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
53
app/models/user.py
Normal 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
14
app/routes/__init__.py
Normal 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
245
app/routes/admin.py
Normal 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
153
app/routes/api.py
Normal 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
111
app/routes/auth.py
Normal 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
232
app/routes/content.py
Normal 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
35
app/routes/dashboard.py
Normal 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
152
app/routes/group.py
Normal 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
167
app/routes/player.py
Normal 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
|
||||
76
app/templates/auth/login.html
Normal file
76
app/templates/auth/login.html
Normal 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
173
app/templates/base.html
Normal 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>
|
||||
234
app/templates/dashboard/index.html
Normal file
234
app/templates/dashboard/index.html
Normal 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
23
app/utils/__init__.py
Normal 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'
|
||||
]
|
||||
324
app/utils/group_management.py
Normal file
324
app/utils/group_management.py
Normal 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
116
app/utils/logger.py
Normal 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")
|
||||
222
app/utils/player_management.py
Normal file
222
app/utils/player_management.py
Normal 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
382
app/utils/uploads.py
Normal 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
65
config.py
Normal 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
57
docker-compose.yml
Normal 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
56
main.py
Normal 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
231
manage.sh
Executable 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
36
requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user