diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..265df39 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c55c406 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ae3f1e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..784901c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d7519e --- /dev/null +++ b/README.md @@ -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. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..5fa98ec --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..7ec0a48 --- /dev/null +++ b/app/extensions.py @@ -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() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..fb1f615 --- /dev/null +++ b/app/models/__init__.py @@ -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'] diff --git a/app/models/content.py b/app/models/content.py new file mode 100644 index 0000000..c013471 --- /dev/null +++ b/app/models/content.py @@ -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'' diff --git a/app/models/group.py b/app/models/group.py new file mode 100644 index 0000000..c37aaf1 --- /dev/null +++ b/app/models/group.py @@ -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'' diff --git a/app/models/player.py b/app/models/player.py new file mode 100644 index 0000000..53322f3 --- /dev/null +++ b/app/models/player.py @@ -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'' diff --git a/app/models/server_log.py b/app/models/server_log.py new file mode 100644 index 0000000..505b078 --- /dev/null +++ b/app/models/server_log.py @@ -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'' diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..45ba6dd --- /dev/null +++ b/app/models/user.py @@ -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'' diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..2c91942 --- /dev/null +++ b/app/routes/__init__.py @@ -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'] diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..ca4a389 --- /dev/null +++ b/app/routes/admin.py @@ -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/', 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/', 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')) diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..6b56724 --- /dev/null +++ b/app/routes/api.py @@ -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 diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..6bc173f --- /dev/null +++ b/app/routes/auth.py @@ -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') diff --git a/app/routes/content.py b/app/routes/content.py new file mode 100644 index 0000000..2af29f0 --- /dev/null +++ b/app/routes/content.py @@ -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('//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('//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/') +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//media//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//media//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)) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py new file mode 100644 index 0000000..99f36e0 --- /dev/null +++ b/app/routes/dashboard.py @@ -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 + ) diff --git a/app/routes/group.py b/app/routes/group.py new file mode 100644 index 0000000..26eb488 --- /dev/null +++ b/app/routes/group.py @@ -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('/') +@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('//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('//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('//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('//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 diff --git a/app/routes/player.py b/app/routes/player.py new file mode 100644 index 0000000..827f4c6 --- /dev/null +++ b/app/routes/player.py @@ -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('/') +@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('//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('//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('//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('//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 diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..16fc833 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% block title %}Login - SKE Digital Signage{% endblock %} + +{% block content %} +
+
+
+ {% if login_picture_exists %} + Login Picture + {% else %} +
+ +

SKE Digital Signage

+
+ {% endif %} +
+ +
+
+
+
+

Login

+

Sign in to your account

+
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+
+ + {% if config.DEBUG %} + + {% endif %} +
+
+
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..14352f1 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,173 @@ + + + + + + {% block title %}SKE Digital Signage{% endblock %} + + + + + + + + + {% block extra_css %}{% endblock %} + + + + {% if current_user.is_authenticated %} + + {% endif %} + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + +
+ {% block content %}{% endblock %} +
+ + + {% if current_user.is_authenticated %} +
+
+
+
+ + SKE Digital Signage v{{ server_version }} + | Build: {{ build_date }} + +
+
+ + Logged in as: {{ current_user.username }} ({{ current_user.role }}) + +
+
+
+
+ {% endif %} + + + + + + + + + {% block extra_js %}{% endblock %} + + diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html new file mode 100644 index 0000000..c525b09 --- /dev/null +++ b/app/templates/dashboard/index.html @@ -0,0 +1,234 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - SKE Digital Signage{% endblock %} + +{% block content %} +
+ +
+
+

Dashboard

+

Manage your digital signage displays and content

+
+ {% if current_user.is_admin %} + + {% endif %} +
+ + +
+
+
+
+
+
+
Players
+

{{ players|length }}

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Groups
+

{{ groups|length }}

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Active Players
+

{{ players|selectattr('is_active')|list|length }}

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Total Content
+

{{ players|sum(attribute='content')|length }}

+
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
Players
+ {% if current_user.is_admin %} + + Add Player + + {% endif %} +
+
+ {% if players %} + {% for player in players %} +
+
+
{{ player.username }}
+ {{ player.hostname }} + {% if player.is_locked_to_group %} + Locked to Group + {% endif %} +
+
+ + {{ 'Online' if player.is_active else 'Offline' }} + + +
+
+ {% endfor %} + {% else %} +
+ +

No players configured yet

+ {% if current_user.is_admin %} + + Add Your First Player + + {% endif %} +
+ {% endif %} +
+
+
+ + +
+
+
+
Groups
+ {% if current_user.is_admin %} + + Create Group + + {% endif %} +
+
+ {% if groups %} + {% for group in groups %} +
+
+
{{ group.name }}
+ {{ group.player_count }} players +
+
+ {% if current_user.is_admin %} + + + + {% endif %} + + + +
+
+ {% endfor %} + {% else %} +
+ +

No groups created yet

+ {% if current_user.is_admin %} + + Create Your First Group + + {% endif %} +
+ {% endif %} +
+
+
+
+ + + {% if current_user.is_admin and server_logs %} +
+
+
+
+
Recent Server Logs
+
+
+
+ + + + + + + + + + + {% for log in server_logs %} + + + + + + + {% endfor %} + +
TimeActionUserLevel
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ log.action }}{{ log.user.username if log.user else '-' }} + + {{ log.level }} + +
+
+
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..7db1390 --- /dev/null +++ b/app/utils/__init__.py @@ -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' +] diff --git a/app/utils/group_management.py b/app/utils/group_management.py new file mode 100644 index 0000000..f587544 --- /dev/null +++ b/app/utils/group_management.py @@ -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) diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..912b479 --- /dev/null +++ b/app/utils/logger.py @@ -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") diff --git a/app/utils/player_management.py b/app/utils/player_management.py new file mode 100644 index 0000000..c15fcf6 --- /dev/null +++ b/app/utils/player_management.py @@ -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" diff --git a/app/utils/uploads.py b/app/utils/uploads.py new file mode 100644 index 0000000..fdbb71d --- /dev/null +++ b/app/utils/uploads.py @@ -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 diff --git a/config.py b/config.py new file mode 100644 index 0000000..1cf106e --- /dev/null +++ b/config.py @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4fd1e1b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..14c6498 --- /dev/null +++ b/main.py @@ -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() diff --git a/manage.sh b/manage.sh new file mode 100755 index 0000000..7b17043 --- /dev/null +++ b/manage.sh @@ -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 " + 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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b08f133 --- /dev/null +++ b/requirements.txt @@ -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