updated first commit
This commit is contained in:
69
.dockerignore
Normal file
69
.dockerignore
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Uploads and instance data
|
||||||
|
instance/
|
||||||
|
static/uploads/
|
||||||
|
static/assets/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
docker-compose*.yml
|
||||||
25
.env.example
Normal file
25
.env.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# SKE Digital Signage Environment Configuration
|
||||||
|
# Copy this file to .env and customize for your environment
|
||||||
|
|
||||||
|
# Secret key for Flask sessions (CHANGE THIS IN PRODUCTION!)
|
||||||
|
SECRET_KEY=ske-signage-production-secret-change-me
|
||||||
|
|
||||||
|
# Admin user credentials
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|
||||||
|
# Database configuration
|
||||||
|
DATABASE_URL=sqlite:///./instance/ske_signage.db
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=5000
|
||||||
|
FLASK_CONFIG=production
|
||||||
|
FLASK_DEBUG=false
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# File processing paths (optional, defaults to system paths)
|
||||||
|
FFMPEG_PATH=ffmpeg
|
||||||
|
LIBREOFFICE_PATH=libreoffice
|
||||||
92
.gitignore
vendored
Normal file
92
.gitignore
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
instance/
|
||||||
|
.flask_session
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Application specific
|
||||||
|
static/uploads/
|
||||||
|
static/assets/
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
63
Dockerfile
Normal file
63
Dockerfile
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
LABEL maintainer="SKE Digital Signage"
|
||||||
|
LABEL version="2.0.0"
|
||||||
|
LABEL description="SKE Digital Signage Server"
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libreoffice \
|
||||||
|
poppler-utils \
|
||||||
|
ffmpeg \
|
||||||
|
libpoppler-cpp-dev \
|
||||||
|
libmagic1 \
|
||||||
|
libffi-dev \
|
||||||
|
libssl-dev \
|
||||||
|
g++ \
|
||||||
|
curl \
|
||||||
|
libjpeg-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libxslt-dev \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN useradd -r -s /bin/bash -m -d /app -u 1001 appuser
|
||||||
|
|
||||||
|
# Copy requirements first to leverage Docker cache
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Upgrade pip and install Python dependencies
|
||||||
|
RUN python -m pip install --upgrade pip && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p instance static/uploads static/assets logs
|
||||||
|
|
||||||
|
# Set ownership to non-root user
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
ENV FLASK_APP=main.py
|
||||||
|
ENV FLASK_ENV=production
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:5000/api/health || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "main.py"]
|
||||||
242
README.md
Normal file
242
README.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# SKE Digital Signage Server
|
||||||
|
|
||||||
|
A modern, web-based digital signage management system built with Flask. This is a completely restructured and improved version of the original digiserver application.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Modern Architecture**: Clean, modular Flask application structure
|
||||||
|
- **User Management**: Role-based authentication (admin/user)
|
||||||
|
- **Player Management**: Individual digital signage displays
|
||||||
|
- **Group Management**: Organize players into groups for synchronized content
|
||||||
|
- **Content Management**: Upload and manage images, videos, and documents
|
||||||
|
- **RESTful API**: For player clients to fetch playlists
|
||||||
|
- **Docker Support**: Easy deployment with Docker and Docker Compose
|
||||||
|
- **Responsive UI**: Bootstrap-based modern interface
|
||||||
|
- **File Processing**: Automatic conversion of PDFs, PowerPoint, and videos
|
||||||
|
- **Audit Logging**: Track all system actions
|
||||||
|
|
||||||
|
## Quick Start with Docker
|
||||||
|
|
||||||
|
1. **Clone and Navigate**:
|
||||||
|
```bash
|
||||||
|
cd /home/pi/Ske_Signage
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure Environment** (optional):
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env file with your preferred settings
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Build and Start**:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access the Application**:
|
||||||
|
- Open your browser to `http://localhost:8880`
|
||||||
|
- Default login: `admin` / `ChangeMe123!`
|
||||||
|
|
||||||
|
## Manual Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- SQLite3
|
||||||
|
- FFmpeg
|
||||||
|
- LibreOffice
|
||||||
|
- Poppler-utils
|
||||||
|
|
||||||
|
### Installation Steps
|
||||||
|
|
||||||
|
1. **Install System Dependencies** (Ubuntu/Debian):
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libreoffice poppler-utils ffmpeg \
|
||||||
|
libpoppler-cpp-dev libmagic1 libffi-dev libssl-dev \
|
||||||
|
g++ libjpeg-dev zlib1g-dev libxml2-dev libxslt-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create Virtual Environment**:
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install Python Dependencies**:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Configure Environment**:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Initialize Database**:
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
# This will create the database and default admin user
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Run Application**:
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Application Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Ske_Signage/
|
||||||
|
├── app/ # Main application package
|
||||||
|
│ ├── models/ # Database models
|
||||||
|
│ ├── routes/ # Flask blueprints/routes
|
||||||
|
│ ├── utils/ # Utility functions
|
||||||
|
│ ├── templates/ # Jinja2 templates
|
||||||
|
│ ├── static/ # Static files (uploads, assets)
|
||||||
|
│ ├── __init__.py # Application factory
|
||||||
|
│ └── extensions.py # Flask extensions
|
||||||
|
├── config.py # Configuration classes
|
||||||
|
├── main.py # Application entry point
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── Dockerfile # Docker image definition
|
||||||
|
├── docker-compose.yml # Docker Compose configuration
|
||||||
|
└── .env.example # Environment template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The application uses environment variables for configuration. Key settings:
|
||||||
|
|
||||||
|
- `SECRET_KEY`: Flask secret key for sessions
|
||||||
|
- `ADMIN_USER` / `ADMIN_PASSWORD`: Default admin credentials
|
||||||
|
- `DATABASE_URL`: Database connection string
|
||||||
|
- `FLASK_CONFIG`: Environment (development/production)
|
||||||
|
- `HOST` / `PORT`: Server binding
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Player API
|
||||||
|
|
||||||
|
- `GET /api/playlists`: Get playlist for a player
|
||||||
|
- `GET /api/playlist_version`: Check playlist version
|
||||||
|
- `POST /api/player_status`: Update player status
|
||||||
|
- `GET /api/health`: Health check
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
All API endpoints require player authentication via hostname and quickconnect code.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 1. Create Players
|
||||||
|
|
||||||
|
1. Login as admin
|
||||||
|
2. Go to Dashboard
|
||||||
|
3. Click "Add Player"
|
||||||
|
4. Fill in player details (username, hostname, passwords)
|
||||||
|
|
||||||
|
### 2. Upload Content
|
||||||
|
|
||||||
|
1. Click "Upload Content"
|
||||||
|
2. Select target (player or group)
|
||||||
|
3. Choose files (images, videos, PDFs, PowerPoint)
|
||||||
|
4. Set display duration
|
||||||
|
5. Upload
|
||||||
|
|
||||||
|
### 3. Manage Groups
|
||||||
|
|
||||||
|
1. Create groups to synchronize content across multiple players
|
||||||
|
2. Add players to groups
|
||||||
|
3. Upload content to groups for synchronized playlists
|
||||||
|
|
||||||
|
### 4. Player Client
|
||||||
|
|
||||||
|
Players can connect using the API:
|
||||||
|
```bash
|
||||||
|
curl "http://server:8880/api/playlists?hostname=PLAYER_HOSTNAME&quickconnect_code=CODE"
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Processing
|
||||||
|
|
||||||
|
The application automatically processes uploaded files:
|
||||||
|
|
||||||
|
- **Images**: Resized and optimized
|
||||||
|
- **Videos**: Converted to web-compatible MP4 format
|
||||||
|
- **PDFs**: Converted to individual page images
|
||||||
|
- **PowerPoint**: Converted to images via LibreOffice
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running in Development Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FLASK_CONFIG=development
|
||||||
|
export FLASK_DEBUG=true
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask db init # Initialize migrations (first time)
|
||||||
|
flask db migrate # Create migration
|
||||||
|
flask db upgrade # Apply migration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Docker Compose (Recommended)
|
||||||
|
|
||||||
|
1. Use the provided `docker-compose.yml`
|
||||||
|
2. Set strong passwords in environment variables
|
||||||
|
3. Configure reverse proxy (nginx/traefik) for HTTPS
|
||||||
|
4. Set up backup for persistent volumes
|
||||||
|
|
||||||
|
### Manual Deployment
|
||||||
|
|
||||||
|
1. Use a production WSGI server (gunicorn included)
|
||||||
|
2. Set `FLASK_CONFIG=production`
|
||||||
|
3. Configure proper database (PostgreSQL recommended for production)
|
||||||
|
4. Set up log rotation
|
||||||
|
5. Configure firewall and security
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Change default admin credentials
|
||||||
|
- Use strong secret keys
|
||||||
|
- Enable HTTPS in production
|
||||||
|
- Regular backups
|
||||||
|
- Monitor logs for suspicious activity
|
||||||
|
- Keep dependencies updated
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **File Upload Errors**: Check file permissions and disk space
|
||||||
|
2. **Video Conversion Fails**: Ensure FFmpeg is installed and accessible
|
||||||
|
3. **PDF Processing Issues**: Verify poppler-utils installation
|
||||||
|
4. **Database Errors**: Check database file permissions
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
- Application logs: Check console output or `logs/` directory
|
||||||
|
- Docker logs: `docker-compose logs -f`
|
||||||
|
|
||||||
|
## Migrating from Original digiserver
|
||||||
|
|
||||||
|
The new application uses a different database schema. To migrate:
|
||||||
|
|
||||||
|
1. Export content from old system
|
||||||
|
2. Create players and groups in new system
|
||||||
|
3. Re-upload content through the new interface
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is proprietary software for SKE Digital Signage.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and support, please contact the development team.
|
||||||
85
app/__init__.py
Normal file
85
app/__init__.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
SKE Digital Signage Server Application Factory
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from flask import Flask
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
|
||||||
|
def create_app(config_name=None):
|
||||||
|
"""Application factory function"""
|
||||||
|
|
||||||
|
# Create Flask application
|
||||||
|
app = Flask(__name__, instance_relative_config=True)
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
if config_name is None:
|
||||||
|
config_name = os.environ.get('FLASK_CONFIG', 'default')
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
app.config.from_object(config[config_name])
|
||||||
|
|
||||||
|
# Ensure instance folder exists
|
||||||
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
from app.extensions import db, bcrypt, login_manager
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
bcrypt.init_app(app)
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = 'auth.login'
|
||||||
|
login_manager.login_message = 'Please log in to access this page.'
|
||||||
|
|
||||||
|
# Initialize Flask-Migrate
|
||||||
|
migrate = Migrate(app, db)
|
||||||
|
|
||||||
|
# Register user loader for Flask-Login
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return db.session.get(User, int(user_id))
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
from app.routes.auth import bp as auth_bp
|
||||||
|
from app.routes.dashboard import bp as dashboard_bp
|
||||||
|
from app.routes.admin import bp as admin_bp
|
||||||
|
from app.routes.player import bp as player_bp
|
||||||
|
from app.routes.group import bp as group_bp
|
||||||
|
from app.routes.content import bp as content_bp
|
||||||
|
from app.routes.api import bp as api_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||||
|
app.register_blueprint(dashboard_bp)
|
||||||
|
app.register_blueprint(admin_bp, url_prefix='/admin')
|
||||||
|
app.register_blueprint(player_bp, url_prefix='/player')
|
||||||
|
app.register_blueprint(group_bp, url_prefix='/group')
|
||||||
|
app.register_blueprint(content_bp, url_prefix='/content')
|
||||||
|
app.register_blueprint(api_bp, url_prefix='/api')
|
||||||
|
|
||||||
|
# Create upload folders
|
||||||
|
upload_path = os.path.join(app.static_folder, 'uploads')
|
||||||
|
assets_path = os.path.join(app.static_folder, 'assets')
|
||||||
|
os.makedirs(upload_path, exist_ok=True)
|
||||||
|
os.makedirs(assets_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Context processor for theme injection
|
||||||
|
@app.context_processor
|
||||||
|
def inject_theme():
|
||||||
|
from flask_login import current_user
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
theme = getattr(current_user, 'theme', 'light')
|
||||||
|
else:
|
||||||
|
theme = 'light'
|
||||||
|
return dict(theme=theme)
|
||||||
|
|
||||||
|
# Context processor for server info
|
||||||
|
@app.context_processor
|
||||||
|
def inject_server_info():
|
||||||
|
return dict(
|
||||||
|
server_version=app.config['SERVER_VERSION'],
|
||||||
|
build_date=app.config['BUILD_DATE']
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
12
app/extensions.py
Normal file
12
app/extensions.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Flask Extensions initialization
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
from flask_login import LoginManager
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db = SQLAlchemy()
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
login_manager = LoginManager()
|
||||||
11
app/models/__init__.py
Normal file
11
app/models/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
Database models package
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .user import User
|
||||||
|
from .player import Player
|
||||||
|
from .content import Content
|
||||||
|
from .group import Group, group_player
|
||||||
|
from .server_log import ServerLog
|
||||||
|
|
||||||
|
__all__ = ['User', 'Player', 'Content', 'Group', 'ServerLog', 'group_player']
|
||||||
38
app/models/content.py
Normal file
38
app/models/content.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Content model for media files
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
class Content(db.Model):
|
||||||
|
"""Content model representing media files for players"""
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
file_name = db.Column(db.String(255), nullable=False, index=True)
|
||||||
|
original_name = db.Column(db.String(255), nullable=True)
|
||||||
|
duration = db.Column(db.Integer, nullable=False, default=10) # Duration in seconds
|
||||||
|
position = db.Column(db.Integer, default=0) # Position in playlist
|
||||||
|
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=False, index=True)
|
||||||
|
content_type = db.Column(db.String(50), nullable=False, default='image') # image, video, document
|
||||||
|
file_size = db.Column(db.Integer, nullable=True) # File size in bytes
|
||||||
|
uploaded_at = db.Column(db.DateTime, default=db.func.current_timestamp())
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
# Metadata for different content types
|
||||||
|
width = db.Column(db.Integer, nullable=True)
|
||||||
|
height = db.Column(db.Integer, nullable=True)
|
||||||
|
format = db.Column(db.String(10), nullable=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_path(self):
|
||||||
|
"""Get the full file path for this content"""
|
||||||
|
return f"uploads/{self.file_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
"""Get the URL for this content"""
|
||||||
|
from flask import url_for
|
||||||
|
return url_for('static', filename=self.file_path)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Content {self.file_name} (Player: {self.player_id})>'
|
||||||
48
app/models/group.py
Normal file
48
app/models/group.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Group model for managing collections of players
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
# Association table for many-to-many relationship between Group and Player
|
||||||
|
group_player = db.Table('group_player',
|
||||||
|
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
|
||||||
|
db.Column('player_id', db.Integer, db.ForeignKey('player.id'), primary_key=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Group(db.Model):
|
||||||
|
"""Group model for managing collections of players"""
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
playlist_version = db.Column(db.Integer, default=0)
|
||||||
|
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
players = db.relationship('Player', secondary=group_player, backref='groups')
|
||||||
|
|
||||||
|
def increment_playlist_version(self):
|
||||||
|
"""Increment playlist version for all players in group"""
|
||||||
|
self.playlist_version += 1
|
||||||
|
for player in self.players:
|
||||||
|
player.increment_playlist_version()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def player_count(self):
|
||||||
|
"""Get the number of players in this group"""
|
||||||
|
return len(self.players)
|
||||||
|
|
||||||
|
def get_content(self):
|
||||||
|
"""Get all content from players in this group"""
|
||||||
|
from app.models.content import Content
|
||||||
|
if not self.players:
|
||||||
|
return []
|
||||||
|
|
||||||
|
player_ids = [player.id for player in self.players]
|
||||||
|
return Content.query.filter(Content.player_id.in_(player_ids)).order_by(Content.position).all()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Group {self.name} ({self.player_count} players)>'
|
||||||
54
app/models/player.py
Normal file
54
app/models/player.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Player model for digital signage displays
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db, bcrypt
|
||||||
|
|
||||||
|
class Player(db.Model):
|
||||||
|
"""Player model representing digital signage displays"""
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(255), nullable=False, index=True)
|
||||||
|
hostname = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||||
|
password = db.Column(db.String(255), nullable=False)
|
||||||
|
quickconnect_password = db.Column(db.String(255), nullable=True)
|
||||||
|
playlist_version = db.Column(db.Integer, default=1)
|
||||||
|
locked_to_group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
|
||||||
|
last_seen = db.Column(db.DateTime)
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
content = db.relationship('Content', backref='player', lazy=True, cascade='all, delete-orphan')
|
||||||
|
locked_to_group = db.relationship('Group', foreign_keys=[locked_to_group_id], backref='locked_players')
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
"""Hash and set player password"""
|
||||||
|
self.password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
"""Check if provided password matches player's password"""
|
||||||
|
return bcrypt.check_password_hash(self.password, password)
|
||||||
|
|
||||||
|
def set_quickconnect_password(self, password):
|
||||||
|
"""Hash and set quickconnect password"""
|
||||||
|
self.quickconnect_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
|
|
||||||
|
def verify_quickconnect_code(self, code):
|
||||||
|
"""Verify quickconnect code"""
|
||||||
|
if not self.quickconnect_password:
|
||||||
|
return False
|
||||||
|
return bcrypt.check_password_hash(self.quickconnect_password, code)
|
||||||
|
|
||||||
|
def increment_playlist_version(self):
|
||||||
|
"""Increment playlist version to notify clients of changes"""
|
||||||
|
self.playlist_version += 1
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_locked_to_group(self):
|
||||||
|
"""Check if player is locked to a group"""
|
||||||
|
return self.locked_to_group_id is not None
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Player {self.username} ({self.hostname})>'
|
||||||
42
app/models/server_log.py
Normal file
42
app/models/server_log.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Server log model for audit trail
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
class ServerLog(db.Model):
|
||||||
|
"""Server log model for tracking system actions"""
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
action = db.Column(db.String(255), nullable=False, index=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
ip_address = db.Column(db.String(45), nullable=True) # Support IPv6
|
||||||
|
user_agent = db.Column(db.Text, nullable=True)
|
||||||
|
timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow, index=True)
|
||||||
|
level = db.Column(db.String(20), default='INFO') # INFO, WARNING, ERROR, DEBUG
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = db.relationship('User', backref='logs')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def log(action, user_id=None, ip_address=None, user_agent=None, level='INFO'):
|
||||||
|
"""Create a new log entry"""
|
||||||
|
log_entry = ServerLog(
|
||||||
|
action=action,
|
||||||
|
user_id=user_id,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
level=level
|
||||||
|
)
|
||||||
|
db.session.add(log_entry)
|
||||||
|
db.session.commit()
|
||||||
|
return log_entry
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_recent_logs(limit=50):
|
||||||
|
"""Get recent log entries"""
|
||||||
|
return ServerLog.query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<ServerLog {self.action} at {self.timestamp}>'
|
||||||
53
app/models/user.py
Normal file
53
app/models/user.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
User model for authentication and authorization
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from app.extensions import db, bcrypt
|
||||||
|
|
||||||
|
class User(db.Model, UserMixin):
|
||||||
|
"""User model for authentication and role management"""
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||||
|
password = db.Column(db.String(255), nullable=False)
|
||||||
|
role = db.Column(db.String(80), nullable=False, default='user')
|
||||||
|
theme = db.Column(db.String(80), default='light')
|
||||||
|
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
|
||||||
|
last_login = db.Column(db.DateTime)
|
||||||
|
is_active_user = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
"""Hash and set user password"""
|
||||||
|
self.password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
"""Check if provided password matches user's password"""
|
||||||
|
return bcrypt.check_password_hash(self.password, password)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self):
|
||||||
|
"""Check if user has admin role"""
|
||||||
|
return self.role == 'admin'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
"""Required by Flask-Login"""
|
||||||
|
return self.is_active_user
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
"""Required by Flask-Login"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
"""Required by Flask-Login"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
"""Required by Flask-Login"""
|
||||||
|
return str(self.id)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User {self.username}>'
|
||||||
14
app/routes/__init__.py
Normal file
14
app/routes/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Routes package
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Import all blueprints to make them available
|
||||||
|
from .auth import bp as auth_bp
|
||||||
|
from .dashboard import bp as dashboard_bp
|
||||||
|
from .admin import bp as admin_bp
|
||||||
|
from .player import bp as player_bp
|
||||||
|
from .group import bp as group_bp
|
||||||
|
from .content import bp as content_bp
|
||||||
|
from .api import bp as api_bp
|
||||||
|
|
||||||
|
__all__ = ['auth_bp', 'dashboard_bp', 'admin_bp', 'player_bp', 'group_bp', 'content_bp', 'api_bp']
|
||||||
245
app/routes/admin.py
Normal file
245
app/routes/admin.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Admin routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from functools import wraps
|
||||||
|
from app.models.user import User
|
||||||
|
from app.extensions import db
|
||||||
|
from app.utils.logger import log_user_created, log_user_deleted, log_action
|
||||||
|
import os
|
||||||
|
|
||||||
|
bp = Blueprint('admin', __name__)
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
"""Decorator to require admin role"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated or not current_user.is_admin:
|
||||||
|
flash('Admin access required.', 'danger')
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
"""Admin dashboard"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
# Check if assets exist
|
||||||
|
logo_path = os.path.join(current_app.static_folder, 'assets', 'logo.png')
|
||||||
|
login_picture_path = os.path.join(current_app.static_folder, 'assets', 'login_picture.png')
|
||||||
|
|
||||||
|
logo_exists = os.path.exists(logo_path)
|
||||||
|
login_picture_exists = os.path.exists(login_picture_path)
|
||||||
|
|
||||||
|
# Get all users
|
||||||
|
users = User.query.order_by(User.username).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'admin/index.html',
|
||||||
|
users=users,
|
||||||
|
logo_exists=logo_exists,
|
||||||
|
login_picture_exists=login_picture_exists
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/create_user', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def create_user():
|
||||||
|
"""Create a new user"""
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
role = request.form.get('role', 'user')
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not username or not password:
|
||||||
|
flash('Username and password are required.', 'danger')
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
if len(password) < 6:
|
||||||
|
flash('Password must be at least 6 characters long.', 'danger')
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
if role not in ['user', 'admin']:
|
||||||
|
flash('Invalid role specified.', 'danger')
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
# Check if user already exists
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
flash(f'User "{username}" already exists.', 'danger')
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create new user
|
||||||
|
user = User(username=username, role=role)
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_user_created(username, role)
|
||||||
|
flash(f'User "{username}" created successfully.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error creating user: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
@bp.route('/delete_user/<int:user_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_user(user_id):
|
||||||
|
"""Delete a user"""
|
||||||
|
# Prevent self-deletion
|
||||||
|
if user_id == current_user.id:
|
||||||
|
flash('You cannot delete your own account.', 'danger')
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
username = user.username
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_user_deleted(username)
|
||||||
|
flash(f'User "{username}" deleted successfully.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error deleting user: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
@bp.route('/change_role/<int:user_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def change_role(user_id):
|
||||||
|
"""Change user role"""
|
||||||
|
# Prevent changing own role
|
||||||
|
if user_id == current_user.id:
|
||||||
|
flash('You cannot change your own role.', 'danger')
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
new_role = request.form.get('role')
|
||||||
|
|
||||||
|
if new_role not in ['user', 'admin']:
|
||||||
|
flash('Invalid role specified.', 'danger')
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
old_role = user.role
|
||||||
|
user.role = new_role
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(f"User '{user.username}' role changed from '{old_role}' to '{new_role}'")
|
||||||
|
flash(f'User "{user.username}" role changed to "{new_role}".', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error changing user role: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
@bp.route('/change_theme', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def change_theme():
|
||||||
|
"""Change user theme"""
|
||||||
|
theme = request.form.get('theme', 'light')
|
||||||
|
|
||||||
|
if theme not in ['light', 'dark']:
|
||||||
|
flash('Invalid theme specified.', 'danger')
|
||||||
|
return redirect(request.referrer or url_for('admin.index'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_user.theme = theme
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Theme changed to "{theme}".', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error changing theme: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(request.referrer or url_for('admin.index'))
|
||||||
|
|
||||||
|
@bp.route('/upload_assets', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def upload_assets():
|
||||||
|
"""Upload logo and login picture"""
|
||||||
|
from flask import current_app
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
assets_folder = os.path.join(current_app.static_folder, 'assets')
|
||||||
|
os.makedirs(assets_folder, exist_ok=True)
|
||||||
|
|
||||||
|
# Handle logo upload
|
||||||
|
logo_file = request.files.get('logo')
|
||||||
|
if logo_file and logo_file.filename:
|
||||||
|
try:
|
||||||
|
logo_path = os.path.join(assets_folder, 'logo.png')
|
||||||
|
logo_file.save(logo_path)
|
||||||
|
flash('Logo uploaded successfully.', 'success')
|
||||||
|
log_action('Logo uploaded')
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error uploading logo: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
# Handle login picture upload
|
||||||
|
login_picture_file = request.files.get('login_picture')
|
||||||
|
if login_picture_file and login_picture_file.filename:
|
||||||
|
try:
|
||||||
|
login_picture_path = os.path.join(assets_folder, 'login_picture.png')
|
||||||
|
login_picture_file.save(login_picture_path)
|
||||||
|
flash('Login picture uploaded successfully.', 'success')
|
||||||
|
log_action('Login picture uploaded')
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error uploading login picture: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
|
||||||
|
@bp.route('/clean_unused_files', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def clean_unused_files():
|
||||||
|
"""Clean unused files from uploads folder"""
|
||||||
|
from flask import current_app
|
||||||
|
from app.models.content import Content
|
||||||
|
|
||||||
|
try:
|
||||||
|
upload_folder = os.path.join(current_app.static_folder, 'uploads')
|
||||||
|
|
||||||
|
# Get all file names from database
|
||||||
|
content_files = {content.file_name for content in Content.query.all()}
|
||||||
|
|
||||||
|
# Get all files in upload folder
|
||||||
|
if os.path.exists(upload_folder):
|
||||||
|
all_files = set(os.listdir(upload_folder))
|
||||||
|
|
||||||
|
# Find unused files
|
||||||
|
unused_files = all_files - content_files
|
||||||
|
|
||||||
|
# Delete unused files
|
||||||
|
deleted_count = 0
|
||||||
|
for file_name in unused_files:
|
||||||
|
file_path = os.path.join(upload_folder, file_name)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting {file_path}: {e}")
|
||||||
|
|
||||||
|
flash(f'Cleaned {deleted_count} unused files.', 'success')
|
||||||
|
log_action(f'Cleaned {deleted_count} unused files')
|
||||||
|
else:
|
||||||
|
flash('Upload folder does not exist.', 'info')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error cleaning files: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
153
app/routes/api.py
Normal file
153
app/routes/api.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
API routes for player clients
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, url_for
|
||||||
|
from app.models.player import Player
|
||||||
|
from app.models.content import Content
|
||||||
|
from app.extensions import bcrypt, db
|
||||||
|
|
||||||
|
bp = Blueprint('api', __name__)
|
||||||
|
|
||||||
|
@bp.route('/playlists', methods=['GET'])
|
||||||
|
def get_playlists():
|
||||||
|
"""Get playlist for a player"""
|
||||||
|
hostname = request.args.get('hostname')
|
||||||
|
quickconnect_code = request.args.get('quickconnect_code')
|
||||||
|
|
||||||
|
# Validate parameters
|
||||||
|
if not hostname or not quickconnect_code:
|
||||||
|
return jsonify({'error': 'Hostname and quick connect code are required'}), 400
|
||||||
|
|
||||||
|
# Find player and verify credentials
|
||||||
|
player = Player.query.filter_by(hostname=hostname).first()
|
||||||
|
if not player or not player.verify_quickconnect_code(quickconnect_code):
|
||||||
|
return jsonify({'error': 'Invalid hostname or quick connect code'}), 404
|
||||||
|
|
||||||
|
# Update last seen
|
||||||
|
player.last_seen = db.func.current_timestamp()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Get content based on player's group status
|
||||||
|
if player.is_locked_to_group:
|
||||||
|
# Player is locked to a group - get shared content
|
||||||
|
group = player.locked_to_group
|
||||||
|
player_ids = [p.id for p in group.players]
|
||||||
|
|
||||||
|
# Get unique content by filename (first occurrence)
|
||||||
|
content_query = (
|
||||||
|
db.session.query(
|
||||||
|
Content.file_name,
|
||||||
|
db.func.min(Content.id).label('id'),
|
||||||
|
db.func.min(Content.duration).label('duration'),
|
||||||
|
db.func.min(Content.position).label('position'),
|
||||||
|
db.func.min(Content.content_type).label('content_type')
|
||||||
|
)
|
||||||
|
.filter(Content.player_id.in_(player_ids))
|
||||||
|
.group_by(Content.file_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
content = db.session.query(Content).filter(
|
||||||
|
Content.id.in_([c.id for c in content_query])
|
||||||
|
).order_by(Content.position).all()
|
||||||
|
else:
|
||||||
|
# Individual player content
|
||||||
|
content = Content.query.filter_by(player_id=player.id).order_by(Content.position).all()
|
||||||
|
|
||||||
|
# Build playlist
|
||||||
|
playlist = []
|
||||||
|
for media in content:
|
||||||
|
playlist.append({
|
||||||
|
'file_name': media.file_name,
|
||||||
|
'url': url_for('content.media', filename=media.file_name, _external=True),
|
||||||
|
'duration': media.duration,
|
||||||
|
'content_type': media.content_type,
|
||||||
|
'position': media.position
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'playlist': playlist,
|
||||||
|
'playlist_version': player.playlist_version,
|
||||||
|
'hashed_quickconnect': player.quickconnect_password,
|
||||||
|
'player_id': player.id,
|
||||||
|
'player_name': player.username
|
||||||
|
})
|
||||||
|
|
||||||
|
@bp.route('/playlist_version', methods=['GET'])
|
||||||
|
def get_playlist_version():
|
||||||
|
"""Get playlist version for a player (for checking updates)"""
|
||||||
|
hostname = request.args.get('hostname')
|
||||||
|
quickconnect_code = request.args.get('quickconnect_code')
|
||||||
|
|
||||||
|
# Validate parameters
|
||||||
|
if not hostname or not quickconnect_code:
|
||||||
|
return jsonify({'error': 'Hostname and quick connect code are required'}), 400
|
||||||
|
|
||||||
|
# Find player and verify credentials
|
||||||
|
player = Player.query.filter_by(hostname=hostname).first()
|
||||||
|
if not player or not player.verify_quickconnect_code(quickconnect_code):
|
||||||
|
return jsonify({'error': 'Invalid hostname or quick connect code'}), 404
|
||||||
|
|
||||||
|
# Update last seen
|
||||||
|
player.last_seen = db.func.current_timestamp()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'playlist_version': player.playlist_version,
|
||||||
|
'hashed_quickconnect': player.quickconnect_password
|
||||||
|
})
|
||||||
|
|
||||||
|
@bp.route('/player_status', methods=['POST'])
|
||||||
|
def update_player_status():
|
||||||
|
"""Update player status (heartbeat)"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'JSON data required'}), 400
|
||||||
|
|
||||||
|
hostname = data.get('hostname')
|
||||||
|
quickconnect_code = data.get('quickconnect_code')
|
||||||
|
|
||||||
|
if not hostname or not quickconnect_code:
|
||||||
|
return jsonify({'error': 'Hostname and quick connect code are required'}), 400
|
||||||
|
|
||||||
|
# Find player and verify credentials
|
||||||
|
player = Player.query.filter_by(hostname=hostname).first()
|
||||||
|
if not player or not player.verify_quickconnect_code(quickconnect_code):
|
||||||
|
return jsonify({'error': 'Invalid hostname or quick connect code'}), 404
|
||||||
|
|
||||||
|
# Update player status
|
||||||
|
player.last_seen = db.func.current_timestamp()
|
||||||
|
player.is_active = True
|
||||||
|
|
||||||
|
# Optional: Update additional status info if provided
|
||||||
|
if 'status' in data:
|
||||||
|
# Could store additional status information in the future
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'playlist_version': player.playlist_version,
|
||||||
|
'message': 'Status updated successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
@bp.route('/health', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'service': 'SKE Digital Signage Server',
|
||||||
|
'version': '2.0.0'
|
||||||
|
})
|
||||||
|
|
||||||
|
@bp.errorhandler(404)
|
||||||
|
def api_not_found(error):
|
||||||
|
"""API 404 handler"""
|
||||||
|
return jsonify({'error': 'API endpoint not found'}), 404
|
||||||
|
|
||||||
|
@bp.errorhandler(500)
|
||||||
|
def api_internal_error(error):
|
||||||
|
"""API 500 handler"""
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
111
app/routes/auth.py
Normal file
111
app/routes/auth.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Authentication routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.extensions import db
|
||||||
|
from app.utils.logger import log_action
|
||||||
|
import os
|
||||||
|
|
||||||
|
bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
@bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
"""User login"""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
flash('Please enter both username and password.', 'danger')
|
||||||
|
return render_template('auth/login.html')
|
||||||
|
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
|
||||||
|
if user and user.check_password(password) and user.is_active:
|
||||||
|
login_user(user)
|
||||||
|
user.last_login = db.func.current_timestamp()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(f"User '{username}' logged in")
|
||||||
|
|
||||||
|
# Redirect to next page or dashboard
|
||||||
|
next_page = request.args.get('next')
|
||||||
|
if next_page:
|
||||||
|
return redirect(next_page)
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
else:
|
||||||
|
flash('Invalid username or password.', 'danger')
|
||||||
|
log_action(f"Failed login attempt for username '{username}'", level='WARNING')
|
||||||
|
|
||||||
|
# Check if login picture exists
|
||||||
|
from flask import current_app
|
||||||
|
login_picture_path = os.path.join(current_app.static_folder, 'assets', 'login_picture.png')
|
||||||
|
login_picture_exists = os.path.exists(login_picture_path)
|
||||||
|
|
||||||
|
return render_template('auth/login.html', login_picture_exists=login_picture_exists)
|
||||||
|
|
||||||
|
@bp.route('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
"""User logout"""
|
||||||
|
username = current_user.username
|
||||||
|
logout_user()
|
||||||
|
log_action(f"User '{username}' logged out")
|
||||||
|
flash('You have been logged out successfully.', 'info')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
@bp.route('/register', methods=['GET', 'POST'])
|
||||||
|
def register():
|
||||||
|
"""User registration (for development only)"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
# Only allow registration in development mode
|
||||||
|
if not current_app.config.get('DEBUG', False):
|
||||||
|
flash('Registration is disabled.', 'danger')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
confirm_password = request.form.get('confirm_password', '')
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not username or not password:
|
||||||
|
flash('Please enter both username and password.', 'danger')
|
||||||
|
return render_template('auth/register.html')
|
||||||
|
|
||||||
|
if password != confirm_password:
|
||||||
|
flash('Passwords do not match.', 'danger')
|
||||||
|
return render_template('auth/register.html')
|
||||||
|
|
||||||
|
if len(password) < 6:
|
||||||
|
flash('Password must be at least 6 characters long.', 'danger')
|
||||||
|
return render_template('auth/register.html')
|
||||||
|
|
||||||
|
# Check if user already exists
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
flash('Username already exists.', 'danger')
|
||||||
|
return render_template('auth/register.html')
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
try:
|
||||||
|
user = User(username=username, role='user')
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(f"New user '{username}' registered")
|
||||||
|
flash('Registration successful! Please log in.', 'success')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Registration failed: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return render_template('auth/register.html')
|
||||||
232
app/routes/content.py
Normal file
232
app/routes/content.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
Content management routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_from_directory, current_app
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.models.content import Content
|
||||||
|
from app.models.player import Player
|
||||||
|
from app.models.group import Group
|
||||||
|
from app.extensions import db
|
||||||
|
from app.utils.uploads import process_uploaded_files
|
||||||
|
from app.routes.admin import admin_required
|
||||||
|
import os
|
||||||
|
|
||||||
|
bp = Blueprint('content', __name__)
|
||||||
|
|
||||||
|
@bp.route('/upload', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def upload():
|
||||||
|
"""Upload content to players or groups"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
target_type = request.form.get('target_type')
|
||||||
|
target_id = request.form.get('target_id')
|
||||||
|
files = request.files.getlist('files')
|
||||||
|
duration = request.form.get('duration', 10, type=int)
|
||||||
|
return_url = request.form.get('return_url', url_for('dashboard.index'))
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not target_type or not target_id:
|
||||||
|
flash('Please select a target type and target.', 'danger')
|
||||||
|
return redirect(url_for('content.upload'))
|
||||||
|
|
||||||
|
if not files or all(not file.filename for file in files):
|
||||||
|
flash('Please select at least one file to upload.', 'danger')
|
||||||
|
return redirect(url_for('content.upload'))
|
||||||
|
|
||||||
|
if duration < 1:
|
||||||
|
flash('Duration must be at least 1 second.', 'danger')
|
||||||
|
return redirect(url_for('content.upload'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_id = int(target_id)
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid target ID.', 'danger')
|
||||||
|
return redirect(url_for('content.upload'))
|
||||||
|
|
||||||
|
# Process files
|
||||||
|
results = process_uploaded_files(
|
||||||
|
app=current_app,
|
||||||
|
files=files,
|
||||||
|
duration=duration,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show results
|
||||||
|
if results['success']:
|
||||||
|
flash(f'Successfully uploaded {len(results["success"])} files.', 'success')
|
||||||
|
|
||||||
|
if results['errors']:
|
||||||
|
for error in results['errors']:
|
||||||
|
flash(f'Error: {error}', 'danger')
|
||||||
|
|
||||||
|
return redirect(return_url)
|
||||||
|
|
||||||
|
# GET request - show upload form
|
||||||
|
target_type = request.args.get('target_type')
|
||||||
|
target_id = request.args.get('target_id')
|
||||||
|
return_url = request.args.get('return_url', url_for('dashboard.index'))
|
||||||
|
|
||||||
|
players = Player.query.order_by(Player.username).all()
|
||||||
|
groups = Group.query.order_by(Group.name).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'content/upload.html',
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
players=players,
|
||||||
|
groups=groups,
|
||||||
|
return_url=return_url
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.route('/<int:content_id>/edit', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def edit(content_id):
|
||||||
|
"""Edit content duration"""
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
new_duration = request.form.get('duration', type=int)
|
||||||
|
|
||||||
|
if not new_duration or new_duration < 1:
|
||||||
|
flash('Duration must be at least 1 second.', 'danger')
|
||||||
|
return redirect(request.referrer or url_for('dashboard.index'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
content.duration = new_duration
|
||||||
|
|
||||||
|
# Update playlist version for the player
|
||||||
|
content.player.increment_playlist_version()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Content duration updated to {new_duration} seconds.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error updating content: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(request.referrer or url_for('dashboard.index'))
|
||||||
|
|
||||||
|
@bp.route('/<int:content_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete(content_id):
|
||||||
|
"""Delete content"""
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
player_id = content.player_id
|
||||||
|
file_name = content.file_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete file from disk
|
||||||
|
file_path = os.path.join(current_app.static_folder, 'uploads', content.file_name)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
# Check if file is used by other content
|
||||||
|
other_content = Content.query.filter(
|
||||||
|
Content.file_name == content.file_name,
|
||||||
|
Content.id != content_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not other_content:
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Delete from database
|
||||||
|
db.session.delete(content)
|
||||||
|
|
||||||
|
# Update playlist version for the player
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player:
|
||||||
|
player.increment_playlist_version()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Content "{file_name}" deleted successfully.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error deleting content: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(request.referrer or url_for('dashboard.index'))
|
||||||
|
|
||||||
|
@bp.route('/media/<path:filename>')
|
||||||
|
def media(filename):
|
||||||
|
"""Serve media files"""
|
||||||
|
upload_folder = os.path.join(current_app.static_folder, 'uploads')
|
||||||
|
return send_from_directory(upload_folder, filename)
|
||||||
|
|
||||||
|
@bp.route('/group/<int:group_id>/media/<int:content_id>/edit', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def edit_group_media(group_id, content_id):
|
||||||
|
"""Edit media duration for group content"""
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
new_duration = request.form.get('duration', type=int)
|
||||||
|
|
||||||
|
if not new_duration or new_duration < 1:
|
||||||
|
flash('Duration must be at least 1 second.', 'danger')
|
||||||
|
return redirect(url_for('group.manage', group_id=group_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update duration for all content with the same filename in the group
|
||||||
|
player_ids = [player.id for player in group.players]
|
||||||
|
Content.query.filter(
|
||||||
|
Content.player_id.in_(player_ids),
|
||||||
|
Content.file_name == content.file_name
|
||||||
|
).update({Content.duration: new_duration})
|
||||||
|
|
||||||
|
# Update playlist version for group
|
||||||
|
group.increment_playlist_version()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Media duration updated successfully.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error updating media duration: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('group.manage', group_id=group_id))
|
||||||
|
|
||||||
|
@bp.route('/group/<int:group_id>/media/<int:content_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_group_media(group_id, content_id):
|
||||||
|
"""Delete media from group"""
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
file_name = content.file_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
player_ids = [player.id for player in group.players]
|
||||||
|
|
||||||
|
# Get all content with the same filename in the group
|
||||||
|
group_content = Content.query.filter(
|
||||||
|
Content.player_id.in_(player_ids),
|
||||||
|
Content.file_name == file_name
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Delete all instances
|
||||||
|
for content_item in group_content:
|
||||||
|
db.session.delete(content_item)
|
||||||
|
|
||||||
|
# Check if file is used elsewhere
|
||||||
|
other_content = Content.query.filter(
|
||||||
|
~Content.player_id.in_(player_ids),
|
||||||
|
Content.file_name == file_name
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# Delete file if not used elsewhere
|
||||||
|
if not other_content:
|
||||||
|
file_path = os.path.join(current_app.static_folder, 'uploads', file_name)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Update playlist version for group
|
||||||
|
group.increment_playlist_version()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Media deleted successfully.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error deleting media: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('group.manage', group_id=group_id))
|
||||||
35
app/routes/dashboard.py
Normal file
35
app/routes/dashboard.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Dashboard routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.models.player import Player
|
||||||
|
from app.models.group import Group
|
||||||
|
from app.utils.logger import get_recent_logs
|
||||||
|
import os
|
||||||
|
|
||||||
|
bp = Blueprint('dashboard', __name__)
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
"""Main dashboard"""
|
||||||
|
players = Player.query.order_by(Player.username).all()
|
||||||
|
groups = Group.query.order_by(Group.name).all()
|
||||||
|
|
||||||
|
# Check if logo exists
|
||||||
|
from flask import current_app
|
||||||
|
logo_path = os.path.join(current_app.static_folder, 'assets', 'logo.png')
|
||||||
|
logo_exists = os.path.exists(logo_path)
|
||||||
|
|
||||||
|
# Get recent server logs
|
||||||
|
server_logs = get_recent_logs(20)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dashboard/index.html',
|
||||||
|
players=players,
|
||||||
|
groups=groups,
|
||||||
|
logo_exists=logo_exists,
|
||||||
|
server_logs=server_logs
|
||||||
|
)
|
||||||
152
app/routes/group.py
Normal file
152
app/routes/group.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Group management routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.models.group import Group
|
||||||
|
from app.models.player import Player
|
||||||
|
from app.utils.group_management import (
|
||||||
|
create_group, edit_group, delete_group,
|
||||||
|
get_group_content, update_group_content_order
|
||||||
|
)
|
||||||
|
from app.routes.admin import admin_required
|
||||||
|
|
||||||
|
bp = Blueprint('group', __name__)
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
"""List all groups"""
|
||||||
|
groups = Group.query.order_by(Group.name).all()
|
||||||
|
return render_template('group/index.html', groups=groups)
|
||||||
|
|
||||||
|
@bp.route('/create', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def create():
|
||||||
|
"""Create new group"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
description = request.form.get('description', '').strip()
|
||||||
|
player_ids = request.form.getlist('players')
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not name:
|
||||||
|
flash('Group name is required.', 'danger')
|
||||||
|
players = Player.query.order_by(Player.username).all()
|
||||||
|
return render_template('group/create.html', players=players)
|
||||||
|
|
||||||
|
# Convert player IDs to integers
|
||||||
|
try:
|
||||||
|
player_ids = [int(pid) for pid in player_ids if pid]
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid player selection.', 'danger')
|
||||||
|
players = Player.query.order_by(Player.username).all()
|
||||||
|
return render_template('group/create.html', players=players)
|
||||||
|
|
||||||
|
# Create group
|
||||||
|
success, result = create_group(
|
||||||
|
name=name,
|
||||||
|
player_ids=player_ids,
|
||||||
|
description=description if description else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
flash(f'Group "{name}" created successfully.', 'success')
|
||||||
|
return redirect(url_for('group.manage', group_id=result.id))
|
||||||
|
else:
|
||||||
|
flash(f'Error creating group: {result}', 'danger')
|
||||||
|
|
||||||
|
players = Player.query.order_by(Player.username).all()
|
||||||
|
return render_template('group/create.html', players=players)
|
||||||
|
|
||||||
|
@bp.route('/<int:group_id>')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def manage(group_id):
|
||||||
|
"""Manage group content"""
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
content = get_group_content(group_id)
|
||||||
|
|
||||||
|
return render_template('group/manage.html', group=group, content=content)
|
||||||
|
|
||||||
|
@bp.route('/<int:group_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def edit(group_id):
|
||||||
|
"""Edit group"""
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
description = request.form.get('description', '').strip()
|
||||||
|
player_ids = request.form.getlist('players')
|
||||||
|
|
||||||
|
# Convert player IDs to integers
|
||||||
|
try:
|
||||||
|
player_ids = [int(pid) for pid in player_ids if pid]
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid player selection.', 'danger')
|
||||||
|
players = Player.query.order_by(Player.username).all()
|
||||||
|
return render_template('group/edit.html', group=group, players=players)
|
||||||
|
|
||||||
|
# Update group
|
||||||
|
success, result = edit_group(
|
||||||
|
group_id=group_id,
|
||||||
|
name=name if name else None,
|
||||||
|
player_ids=player_ids,
|
||||||
|
description=description
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
flash(f'Group "{result.name}" updated successfully.', 'success')
|
||||||
|
return redirect(url_for('group.manage', group_id=group_id))
|
||||||
|
else:
|
||||||
|
flash(f'Error updating group: {result}', 'danger')
|
||||||
|
|
||||||
|
players = Player.query.order_by(Player.username).all()
|
||||||
|
return render_template('group/edit.html', group=group, players=players)
|
||||||
|
|
||||||
|
@bp.route('/<int:group_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete(group_id):
|
||||||
|
"""Delete group"""
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
group_name = group.name
|
||||||
|
|
||||||
|
success, error = delete_group(group_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
flash(f'Group "{group_name}" deleted successfully.', 'success')
|
||||||
|
else:
|
||||||
|
flash(f'Error deleting group: {error}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
|
||||||
|
@bp.route('/<int:group_id>/fullscreen')
|
||||||
|
@login_required
|
||||||
|
def fullscreen(group_id):
|
||||||
|
"""Group fullscreen view"""
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
content = get_group_content(group_id)
|
||||||
|
|
||||||
|
return render_template('group/fullscreen.html', group=group, content=content)
|
||||||
|
|
||||||
|
@bp.route('/<int:group_id>/update_order', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def update_order(group_id):
|
||||||
|
"""Update content order for group"""
|
||||||
|
if not request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
|
||||||
|
|
||||||
|
items = request.json.get('items', [])
|
||||||
|
success, error = update_group_content_order(group_id, items)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'success': True})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': error}), 500
|
||||||
167
app/routes/player.py
Normal file
167
app/routes/player.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
Player management routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app.models.player import Player
|
||||||
|
from app.models.content import Content
|
||||||
|
from app.utils.player_management import (
|
||||||
|
create_player, edit_player, delete_player,
|
||||||
|
get_player_content, update_player_content_order,
|
||||||
|
verify_player_credentials
|
||||||
|
)
|
||||||
|
from app.routes.admin import admin_required
|
||||||
|
|
||||||
|
bp = Blueprint('player', __name__)
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
"""List all players"""
|
||||||
|
players = Player.query.order_by(Player.username).all()
|
||||||
|
return render_template('player/index.html', players=players)
|
||||||
|
|
||||||
|
@bp.route('/add', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def add():
|
||||||
|
"""Add new player"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
hostname = request.form.get('hostname', '').strip()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
quickconnect_password = request.form.get('quickconnect_password', '')
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not username or not hostname or not password:
|
||||||
|
flash('Username, hostname, and password are required.', 'danger')
|
||||||
|
return render_template('player/add.html')
|
||||||
|
|
||||||
|
# Create player
|
||||||
|
success, result = create_player(
|
||||||
|
username=username,
|
||||||
|
hostname=hostname,
|
||||||
|
password=password,
|
||||||
|
quickconnect_password=quickconnect_password if quickconnect_password else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
flash(f'Player "{username}" created successfully.', 'success')
|
||||||
|
return redirect(url_for('player.view', player_id=result.id))
|
||||||
|
else:
|
||||||
|
flash(f'Error creating player: {result}', 'danger')
|
||||||
|
|
||||||
|
return render_template('player/add.html')
|
||||||
|
|
||||||
|
@bp.route('/<int:player_id>')
|
||||||
|
@login_required
|
||||||
|
def view(player_id):
|
||||||
|
"""View player details and content"""
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
content = get_player_content(player_id)
|
||||||
|
|
||||||
|
return render_template('player/view.html', player=player, content=content)
|
||||||
|
|
||||||
|
@bp.route('/<int:player_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def edit(player_id):
|
||||||
|
"""Edit player"""
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
hostname = request.form.get('hostname', '').strip()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
quickconnect_password = request.form.get('quickconnect_password', '')
|
||||||
|
|
||||||
|
# Update player
|
||||||
|
success, result = edit_player(
|
||||||
|
player_id=player_id,
|
||||||
|
username=username if username else None,
|
||||||
|
hostname=hostname if hostname else None,
|
||||||
|
password=password if password else None,
|
||||||
|
quickconnect_password=quickconnect_password if quickconnect_password else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
flash(f'Player "{result.username}" updated successfully.', 'success')
|
||||||
|
return redirect(url_for('player.view', player_id=player_id))
|
||||||
|
else:
|
||||||
|
flash(f'Error updating player: {result}', 'danger')
|
||||||
|
|
||||||
|
return_url = request.args.get('return_url', url_for('player.view', player_id=player_id))
|
||||||
|
return render_template('player/edit.html', player=player, return_url=return_url)
|
||||||
|
|
||||||
|
@bp.route('/<int:player_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete(player_id):
|
||||||
|
"""Delete player"""
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
username = player.username
|
||||||
|
|
||||||
|
success, error = delete_player(player_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
flash(f'Player "{username}" deleted successfully.', 'success')
|
||||||
|
else:
|
||||||
|
flash(f'Error deleting player: {error}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
|
||||||
|
@bp.route('/<int:player_id>/fullscreen', methods=['GET', 'POST'])
|
||||||
|
def fullscreen(player_id):
|
||||||
|
"""Player fullscreen view with authentication"""
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
hostname = request.form.get('hostname', '')
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
quickconnect_code = request.form.get('quickconnect_password', '')
|
||||||
|
|
||||||
|
# Verify credentials
|
||||||
|
if quickconnect_code:
|
||||||
|
success, result = verify_player_credentials(hostname, None, quickconnect_code)
|
||||||
|
else:
|
||||||
|
success, result = verify_player_credentials(hostname, password)
|
||||||
|
|
||||||
|
if success and result.id == player_id:
|
||||||
|
authenticated = True
|
||||||
|
else:
|
||||||
|
authenticated = False
|
||||||
|
flash('Invalid credentials.', 'danger')
|
||||||
|
else:
|
||||||
|
authenticated = current_user.is_authenticated
|
||||||
|
|
||||||
|
if authenticated:
|
||||||
|
content = get_player_content(player_id)
|
||||||
|
return render_template('player/fullscreen.html', player=player, content=content)
|
||||||
|
else:
|
||||||
|
return render_template('player/auth.html', player=player)
|
||||||
|
|
||||||
|
@bp.route('/<int:player_id>/update_order', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_order(player_id):
|
||||||
|
"""Update content order for player"""
|
||||||
|
if not request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
|
||||||
|
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
# Check if player is locked to a group (only admin can reorder)
|
||||||
|
if player.is_locked_to_group and not current_user.is_admin:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Cannot reorder playlist for players locked to groups'
|
||||||
|
}), 403
|
||||||
|
|
||||||
|
items = request.json.get('items', [])
|
||||||
|
success, error, new_version = update_player_content_order(player_id, items)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'success': True, 'new_version': new_version})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': error}), 500
|
||||||
76
app/templates/auth/login.html
Normal file
76
app/templates/auth/login.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Login - SKE Digital Signage{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid vh-100 d-flex align-items-center justify-content-center">
|
||||||
|
<div class="row w-100">
|
||||||
|
<div class="col-md-6 d-flex align-items-center justify-content-center">
|
||||||
|
{% if login_picture_exists %}
|
||||||
|
<img src="{{ url_for('static', filename='assets/login_picture.png') }}"
|
||||||
|
class="img-fluid rounded shadow" alt="Login Picture" style="max-height: 400px;">
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-display display-1 text-primary"></i>
|
||||||
|
<h2 class="mt-3 text-muted">SKE Digital Signage</h2>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 d-flex align-items-center justify-content-center">
|
||||||
|
<div class="card shadow" style="max-width: 400px; width: 100%;">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h3><i class="bi bi-lock"></i> Login</h3>
|
||||||
|
<p class="text-muted">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||||
|
<input type="text" class="form-control" id="username" name="username"
|
||||||
|
required autofocus placeholder="Enter your username">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||||
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
|
required placeholder="Enter your password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i> Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if config.DEBUG %}
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<small>
|
||||||
|
<a href="{{ url_for('auth.register') }}" class="text-decoration-none">
|
||||||
|
Don't have an account? Register here
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
173
app/templates/base.html
Normal file
173
app/templates/base.html
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="{{ theme }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}SKE Digital Signage{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<style>
|
||||||
|
.navbar-brand img {
|
||||||
|
height: 40px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.content-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.content-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.log-entry {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.sortable {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.sortable li {
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bs-light);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
.sortable li:hover {
|
||||||
|
background: var(--bs-secondary-bg);
|
||||||
|
}
|
||||||
|
.fullscreen-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.media-display {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navigation -->
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
||||||
|
{% if logo_exists %}
|
||||||
|
<img src="{{ url_for('static', filename='assets/logo.png') }}" alt="SKE Digital Signage">
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-display"></i> SKE Digital Signage
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('dashboard.index') }}">
|
||||||
|
<i class="bi bi-house"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('content.upload') }}">
|
||||||
|
<i class="bi bi-cloud-upload"></i> Upload Content
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin.index') }}">
|
||||||
|
<i class="bi bi-gear"></i> Admin
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-person"></i> {{ current_user.username }}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<form method="POST" action="{{ url_for('admin.change_theme') }}" class="d-inline">
|
||||||
|
<input type="hidden" name="theme" value="{{ 'light' if theme == 'dark' else 'dark' }}">
|
||||||
|
<button class="dropdown-item" type="submit">
|
||||||
|
<i class="bi bi-{{ 'sun' if theme == 'dark' else 'moon' }}"></i>
|
||||||
|
{{ 'Light' if theme == 'dark' else 'Dark' }} Theme
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Logout
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<footer class="bg-light mt-5 py-3 border-top">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<small class="text-muted">
|
||||||
|
SKE Digital Signage v{{ server_version }}
|
||||||
|
<span class="text-muted">| Build: {{ build_date }}</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-end">
|
||||||
|
<small class="text-muted">
|
||||||
|
Logged in as: {{ current_user.username }} ({{ current_user.role }})
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Bootstrap JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<!-- jQuery and Sortable (for drag & drop) -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
234
app/templates/dashboard/index.html
Normal file
234
app/templates/dashboard/index.html
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - SKE Digital Signage{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1><i class="bi bi-house"></i> Dashboard</h1>
|
||||||
|
<p class="text-muted">Manage your digital signage displays and content</p>
|
||||||
|
</div>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<div class="col-auto">
|
||||||
|
<a href="{{ url_for('content.upload') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-cloud-upload"></i> Upload Content
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-primary text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title">Players</h5>
|
||||||
|
<h2>{{ players|length }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="bi bi-display display-6"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-success text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title">Groups</h5>
|
||||||
|
<h2>{{ groups|length }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="bi bi-collection display-6"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-info text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title">Active Players</h5>
|
||||||
|
<h2>{{ players|selectattr('is_active')|list|length }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="bi bi-play-circle display-6"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-warning text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title">Total Content</h5>
|
||||||
|
<h2>{{ players|sum(attribute='content')|length }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="bi bi-file-earmark-play display-6"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Players Section -->
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5><i class="bi bi-display"></i> Players</h5>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('player.add') }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-plus"></i> Add Player
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if players %}
|
||||||
|
{% for player in players %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center p-2 border-bottom">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1">{{ player.username }}</h6>
|
||||||
|
<small class="text-muted">{{ player.hostname }}</small>
|
||||||
|
{% if player.is_locked_to_group %}
|
||||||
|
<span class="badge bg-info ms-2">Locked to Group</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-{{ 'success' if player.is_active else 'secondary' }}">
|
||||||
|
{{ 'Online' if player.is_active else 'Offline' }}
|
||||||
|
</span>
|
||||||
|
<div class="btn-group btn-group-sm ms-2">
|
||||||
|
<a href="{{ url_for('player.view', player_id=player.id) }}"
|
||||||
|
class="btn btn-outline-secondary" title="View">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('player.fullscreen', player_id=player.id) }}"
|
||||||
|
class="btn btn-outline-primary" title="Fullscreen" target="_blank">
|
||||||
|
<i class="bi bi-arrows-fullscreen"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-display display-1"></i>
|
||||||
|
<p class="mt-2">No players configured yet</p>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('player.add') }}" class="btn btn-primary">
|
||||||
|
Add Your First Player
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Groups Section -->
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5><i class="bi bi-collection"></i> Groups</h5>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('group.create') }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-plus"></i> Create Group
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if groups %}
|
||||||
|
{% for group in groups %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center p-2 border-bottom">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1">{{ group.name }}</h6>
|
||||||
|
<small class="text-muted">{{ group.player_count }} players</small>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('group.manage', group_id=group.id) }}"
|
||||||
|
class="btn btn-outline-secondary" title="Manage">
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('group.fullscreen', group_id=group.id) }}"
|
||||||
|
class="btn btn-outline-primary" title="Fullscreen" target="_blank">
|
||||||
|
<i class="bi bi-arrows-fullscreen"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-collection display-1"></i>
|
||||||
|
<p class="mt-2">No groups created yet</p>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('group.create') }}" class="btn btn-primary">
|
||||||
|
Create Your First Group
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Logs -->
|
||||||
|
{% if current_user.is_admin and server_logs %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5><i class="bi bi-journal-text"></i> Recent Server Logs</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive" style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Level</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in server_logs %}
|
||||||
|
<tr class="log-entry">
|
||||||
|
<td class="text-nowrap">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
|
<td>{{ log.action }}</td>
|
||||||
|
<td>{{ log.user.username if log.user else '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'danger' if log.level == 'ERROR' else 'warning' if log.level == 'WARNING' else 'info' }}">
|
||||||
|
{{ log.level }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
23
app/utils/__init__.py
Normal file
23
app/utils/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
Utility functions package
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .logger import log_action, get_recent_logs
|
||||||
|
from .uploads import process_uploaded_files
|
||||||
|
from .player_management import (
|
||||||
|
create_player, edit_player, delete_player,
|
||||||
|
get_player_content, update_player_content_order
|
||||||
|
)
|
||||||
|
from .group_management import (
|
||||||
|
create_group, edit_group, delete_group,
|
||||||
|
get_group_content, update_group_content_order
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'log_action', 'get_recent_logs',
|
||||||
|
'process_uploaded_files',
|
||||||
|
'create_player', 'edit_player', 'delete_player',
|
||||||
|
'get_player_content', 'update_player_content_order',
|
||||||
|
'create_group', 'edit_group', 'delete_group',
|
||||||
|
'get_group_content', 'update_group_content_order'
|
||||||
|
]
|
||||||
324
app/utils/group_management.py
Normal file
324
app/utils/group_management.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
"""
|
||||||
|
Group management utilities
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.group import Group
|
||||||
|
from app.models.player import Player
|
||||||
|
from app.models.content import Content
|
||||||
|
from app.utils.logger import log_group_created, log_group_edited, log_group_deleted, log_content_reordered
|
||||||
|
|
||||||
|
def create_group(name, player_ids=None, description=None):
|
||||||
|
"""
|
||||||
|
Create a new group
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Group name
|
||||||
|
player_ids (list): List of player IDs to add to group (optional)
|
||||||
|
description (str): Group description (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, group_or_error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if group name already exists
|
||||||
|
if Group.query.filter_by(name=name).first():
|
||||||
|
return False, f"Group with name '{name}' already exists"
|
||||||
|
|
||||||
|
# Create new group
|
||||||
|
group = Group(
|
||||||
|
name=name,
|
||||||
|
description=description or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add players to group
|
||||||
|
if player_ids:
|
||||||
|
players = Player.query.filter(Player.id.in_(player_ids)).all()
|
||||||
|
for player in players:
|
||||||
|
group.players.append(player)
|
||||||
|
|
||||||
|
db.session.add(group)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_group_created(name)
|
||||||
|
return True, group
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def edit_group(group_id, name=None, player_ids=None, description=None):
|
||||||
|
"""
|
||||||
|
Edit an existing group
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): Group ID
|
||||||
|
name (str): New group name (optional)
|
||||||
|
player_ids (list): New list of player IDs (optional)
|
||||||
|
description (str): New description (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, group_or_error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
group = Group.query.get(group_id)
|
||||||
|
if not group:
|
||||||
|
return False, "Group not found"
|
||||||
|
|
||||||
|
# Check for name conflicts
|
||||||
|
if name and name != group.name:
|
||||||
|
existing = Group.query.filter_by(name=name).first()
|
||||||
|
if existing and existing.id != group_id:
|
||||||
|
return False, f"Group with name '{name}' already exists"
|
||||||
|
group.name = name
|
||||||
|
|
||||||
|
# Update description
|
||||||
|
if description is not None:
|
||||||
|
group.description = description
|
||||||
|
|
||||||
|
# Update players
|
||||||
|
if player_ids is not None:
|
||||||
|
# Clear current players
|
||||||
|
group.players.clear()
|
||||||
|
|
||||||
|
# Add new players
|
||||||
|
if player_ids:
|
||||||
|
players = Player.query.filter(Player.id.in_(player_ids)).all()
|
||||||
|
for player in players:
|
||||||
|
group.players.append(player)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_group_edited(group.name)
|
||||||
|
return True, group
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def delete_group(group_id):
|
||||||
|
"""
|
||||||
|
Delete a group (players remain, just removed from group)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): Group ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
group = Group.query.get(group_id)
|
||||||
|
if not group:
|
||||||
|
return False, "Group not found"
|
||||||
|
|
||||||
|
group_name = group.name
|
||||||
|
|
||||||
|
# Remove all players from group
|
||||||
|
group.players.clear()
|
||||||
|
|
||||||
|
# Delete group
|
||||||
|
db.session.delete(group)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_group_deleted(group_name)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def get_group_content(group_id):
|
||||||
|
"""
|
||||||
|
Get all unique content for players in a group
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): Group ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of Content objects
|
||||||
|
"""
|
||||||
|
group = Group.query.get(group_id)
|
||||||
|
if not group:
|
||||||
|
return []
|
||||||
|
|
||||||
|
player_ids = [player.id for player in group.players]
|
||||||
|
if not player_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get unique content by filename, taking the first occurrence
|
||||||
|
content_query = (
|
||||||
|
db.session.query(
|
||||||
|
Content.file_name,
|
||||||
|
db.func.min(Content.id).label('id'),
|
||||||
|
db.func.min(Content.duration).label('duration'),
|
||||||
|
db.func.min(Content.position).label('position')
|
||||||
|
)
|
||||||
|
.filter(Content.player_id.in_(player_ids))
|
||||||
|
.group_by(Content.file_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the actual content objects
|
||||||
|
content_ids = [c.id for c in content_query.all()]
|
||||||
|
content = Content.query.filter(Content.id.in_(content_ids)).order_by(Content.position).all()
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def update_group_content_order(group_id, content_items):
|
||||||
|
"""
|
||||||
|
Update the order of content items for all players in a group
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): Group ID
|
||||||
|
content_items (list): List of content items with new positions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
group = Group.query.get(group_id)
|
||||||
|
if not group:
|
||||||
|
return False, "Group not found"
|
||||||
|
|
||||||
|
player_ids = [player.id for player in group.players]
|
||||||
|
if not player_ids:
|
||||||
|
return True, None # No players in group
|
||||||
|
|
||||||
|
# Update positions for all matching content across all players
|
||||||
|
for i, item in enumerate(content_items):
|
||||||
|
file_name = item.get('file_name')
|
||||||
|
if file_name:
|
||||||
|
Content.query.filter(
|
||||||
|
Content.player_id.in_(player_ids),
|
||||||
|
Content.file_name == file_name
|
||||||
|
).update({Content.position: i})
|
||||||
|
|
||||||
|
# Increment playlist version for all players and group
|
||||||
|
group.increment_playlist_version()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_content_reordered('group', group.name)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def add_player_to_group(group_id, player_id):
|
||||||
|
"""
|
||||||
|
Add a player to a group
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): Group ID
|
||||||
|
player_id (int): Player ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
group = Group.query.get(group_id)
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
|
||||||
|
if not group:
|
||||||
|
return False, "Group not found"
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found"
|
||||||
|
|
||||||
|
if player not in group.players:
|
||||||
|
group.players.append(player)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def remove_player_from_group(group_id, player_id):
|
||||||
|
"""
|
||||||
|
Remove a player from a group
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): Group ID
|
||||||
|
player_id (int): Player ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
group = Group.query.get(group_id)
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
|
||||||
|
if not group:
|
||||||
|
return False, "Group not found"
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found"
|
||||||
|
|
||||||
|
if player in group.players:
|
||||||
|
group.players.remove(player)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def get_available_players():
|
||||||
|
"""
|
||||||
|
Get all players that are not locked to any group
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of Player objects
|
||||||
|
"""
|
||||||
|
return Player.query.filter_by(locked_to_group_id=None).all()
|
||||||
|
|
||||||
|
def lock_players_to_group(group_id, player_ids):
|
||||||
|
"""
|
||||||
|
Lock specified players to a group (exclusive membership)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): Group ID
|
||||||
|
player_ids (list): List of player IDs to lock
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
group = Group.query.get(group_id)
|
||||||
|
if not group:
|
||||||
|
return False, "Group not found"
|
||||||
|
|
||||||
|
players = Player.query.filter(Player.id.in_(player_ids)).all()
|
||||||
|
|
||||||
|
for player in players:
|
||||||
|
player.locked_to_group_id = group_id
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def unlock_players_from_group(group_id):
|
||||||
|
"""
|
||||||
|
Unlock all players from a group
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): Group ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
players = Player.query.filter_by(locked_to_group_id=group_id).all()
|
||||||
|
|
||||||
|
for player in players:
|
||||||
|
player.locked_to_group_id = None
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
116
app/utils/logger.py
Normal file
116
app/utils/logger.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Logging utilities for server actions
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from flask import request
|
||||||
|
from flask_login import current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.server_log import ServerLog
|
||||||
|
|
||||||
|
def log_action(action, level='INFO', user_id=None, ip_address=None, user_agent=None):
|
||||||
|
"""
|
||||||
|
Log an action to the server log database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action (str): Description of the action
|
||||||
|
level (str): Log level (INFO, WARNING, ERROR, DEBUG)
|
||||||
|
user_id (int): User ID if action is user-specific
|
||||||
|
ip_address (str): IP address of the request
|
||||||
|
user_agent (str): User agent string
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Auto-detect user and request info if not provided
|
||||||
|
if user_id is None and hasattr(current_user, 'id') and current_user.is_authenticated:
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
if ip_address is None and request:
|
||||||
|
ip_address = request.remote_addr
|
||||||
|
|
||||||
|
if user_agent is None and request:
|
||||||
|
user_agent = request.user_agent.string
|
||||||
|
|
||||||
|
ServerLog.log(
|
||||||
|
action=action,
|
||||||
|
user_id=user_id,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
level=level
|
||||||
|
)
|
||||||
|
print(f"[{level}] {action}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error logging action: {e}")
|
||||||
|
# Try to rollback and log without context
|
||||||
|
try:
|
||||||
|
db.session.rollback()
|
||||||
|
log_entry = ServerLog(action=f"ERROR: {action} (logging failed: {str(e)})", level='ERROR')
|
||||||
|
db.session.add(log_entry)
|
||||||
|
db.session.commit()
|
||||||
|
except:
|
||||||
|
pass # If this fails too, we can't do much
|
||||||
|
|
||||||
|
def get_recent_logs(limit=50):
|
||||||
|
"""
|
||||||
|
Get the most recent log entries
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit (int): Maximum number of log entries to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of ServerLog objects
|
||||||
|
"""
|
||||||
|
return ServerLog.get_recent_logs(limit)
|
||||||
|
|
||||||
|
# Helper functions for common log actions
|
||||||
|
def log_upload(file_type, file_name, target_type, target_name):
|
||||||
|
"""Log file upload action"""
|
||||||
|
log_action(f"{file_type.upper()} file '{file_name}' uploaded for {target_type} '{target_name}'")
|
||||||
|
|
||||||
|
def log_process(file_type, file_name, target_type, target_name):
|
||||||
|
"""Log file processing action"""
|
||||||
|
log_action(f"{file_type.upper()} file '{file_name}' processed for {target_type} '{target_name}'")
|
||||||
|
|
||||||
|
def log_player_created(username, hostname):
|
||||||
|
"""Log player creation"""
|
||||||
|
log_action(f"Player '{username}' with hostname '{hostname}' was created")
|
||||||
|
|
||||||
|
def log_player_edited(username):
|
||||||
|
"""Log player edit"""
|
||||||
|
log_action(f"Player '{username}' was edited")
|
||||||
|
|
||||||
|
def log_player_deleted(username):
|
||||||
|
"""Log player deletion"""
|
||||||
|
log_action(f"Player '{username}' was deleted")
|
||||||
|
|
||||||
|
def log_group_created(name):
|
||||||
|
"""Log group creation"""
|
||||||
|
log_action(f"Group '{name}' was created")
|
||||||
|
|
||||||
|
def log_group_edited(name):
|
||||||
|
"""Log group edit"""
|
||||||
|
log_action(f"Group '{name}' was edited")
|
||||||
|
|
||||||
|
def log_group_deleted(name):
|
||||||
|
"""Log group deletion"""
|
||||||
|
log_action(f"Group '{name}' was deleted")
|
||||||
|
|
||||||
|
def log_user_created(username, role):
|
||||||
|
"""Log user creation"""
|
||||||
|
log_action(f"User '{username}' with role '{role}' was created")
|
||||||
|
|
||||||
|
def log_user_deleted(username):
|
||||||
|
"""Log user deletion"""
|
||||||
|
log_action(f"User '{username}' was deleted")
|
||||||
|
|
||||||
|
def log_content_added(file_name, target_type, target_name):
|
||||||
|
"""Log content addition"""
|
||||||
|
log_action(f"Content '{file_name}' added to {target_type} '{target_name}'")
|
||||||
|
|
||||||
|
def log_content_reordered(target_type, target_name):
|
||||||
|
"""Log content reordering"""
|
||||||
|
log_action(f"Content reordered for {target_type} '{target_name}'")
|
||||||
|
|
||||||
|
def log_content_duration_changed(file_name, new_duration):
|
||||||
|
"""Log content duration change"""
|
||||||
|
log_action(f"Content '{file_name}' duration changed to {new_duration} seconds")
|
||||||
222
app/utils/player_management.py
Normal file
222
app/utils/player_management.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
Player management utilities
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.extensions import db, bcrypt
|
||||||
|
from app.models.player import Player
|
||||||
|
from app.models.content import Content
|
||||||
|
from app.utils.logger import log_player_created, log_player_edited, log_player_deleted, log_content_reordered
|
||||||
|
|
||||||
|
def create_player(username, hostname, password, quickconnect_password=None):
|
||||||
|
"""
|
||||||
|
Create a new player
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username (str): Player username
|
||||||
|
hostname (str): Player hostname
|
||||||
|
password (str): Player password
|
||||||
|
quickconnect_password (str): Quick connect password (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, player_or_error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if hostname already exists
|
||||||
|
if Player.query.filter_by(hostname=hostname).first():
|
||||||
|
return False, f"Player with hostname '{hostname}' already exists"
|
||||||
|
|
||||||
|
# Create new player
|
||||||
|
player = Player(
|
||||||
|
username=username,
|
||||||
|
hostname=hostname
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set passwords
|
||||||
|
player.set_password(password)
|
||||||
|
if quickconnect_password:
|
||||||
|
player.set_quickconnect_password(quickconnect_password)
|
||||||
|
|
||||||
|
db.session.add(player)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_player_created(username, hostname)
|
||||||
|
return True, player
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def edit_player(player_id, username=None, hostname=None, password=None, quickconnect_password=None):
|
||||||
|
"""
|
||||||
|
Edit an existing player
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id (int): Player ID
|
||||||
|
username (str): New username (optional)
|
||||||
|
hostname (str): New hostname (optional)
|
||||||
|
password (str): New password (optional)
|
||||||
|
quickconnect_password (str): New quick connect password (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, player_or_error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found"
|
||||||
|
|
||||||
|
# Check for hostname conflicts
|
||||||
|
if hostname and hostname != player.hostname:
|
||||||
|
existing = Player.query.filter_by(hostname=hostname).first()
|
||||||
|
if existing and existing.id != player_id:
|
||||||
|
return False, f"Player with hostname '{hostname}' already exists"
|
||||||
|
player.hostname = hostname
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if username:
|
||||||
|
player.username = username
|
||||||
|
|
||||||
|
if password:
|
||||||
|
player.set_password(password)
|
||||||
|
|
||||||
|
if quickconnect_password:
|
||||||
|
player.set_quickconnect_password(quickconnect_password)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_player_edited(player.username)
|
||||||
|
return True, player
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def delete_player(player_id):
|
||||||
|
"""
|
||||||
|
Delete a player and all its content
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id (int): Player ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found"
|
||||||
|
|
||||||
|
username = player.username
|
||||||
|
|
||||||
|
# Delete all content files
|
||||||
|
import os
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
upload_folder = os.path.join(current_app.static_folder, 'uploads')
|
||||||
|
for content in player.content:
|
||||||
|
file_path = os.path.join(upload_folder, content.file_name)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
except:
|
||||||
|
pass # File might be used by other content
|
||||||
|
|
||||||
|
# Remove from groups
|
||||||
|
for group in player.groups:
|
||||||
|
group.players.remove(player)
|
||||||
|
|
||||||
|
# Delete player (cascades to content)
|
||||||
|
db.session.delete(player)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_player_deleted(username)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def get_player_content(player_id):
|
||||||
|
"""
|
||||||
|
Get all content for a player ordered by position
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id (int): Player ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of Content objects
|
||||||
|
"""
|
||||||
|
return Content.query.filter_by(player_id=player_id).order_by(Content.position).all()
|
||||||
|
|
||||||
|
def update_player_content_order(player_id, content_items):
|
||||||
|
"""
|
||||||
|
Update the order of content items for a player
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id (int): Player ID
|
||||||
|
content_items (list): List of content items with new positions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error_message, new_playlist_version)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found", None
|
||||||
|
|
||||||
|
# Update positions
|
||||||
|
for i, item in enumerate(content_items):
|
||||||
|
content_id = item.get('id')
|
||||||
|
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
|
||||||
|
if content:
|
||||||
|
content.position = i
|
||||||
|
|
||||||
|
# Increment playlist version
|
||||||
|
player.increment_playlist_version()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_content_reordered('player', player.username)
|
||||||
|
return True, None, player.playlist_version
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e), None
|
||||||
|
|
||||||
|
def get_player_by_hostname(hostname):
|
||||||
|
"""
|
||||||
|
Get player by hostname
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname (str): Player hostname
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Player: Player object or None
|
||||||
|
"""
|
||||||
|
return Player.query.filter_by(hostname=hostname).first()
|
||||||
|
|
||||||
|
def verify_player_credentials(hostname, password, quickconnect_code=None):
|
||||||
|
"""
|
||||||
|
Verify player credentials
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname (str): Player hostname
|
||||||
|
password (str): Player password
|
||||||
|
quickconnect_code (str): Quick connect code (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, player_or_error_message)
|
||||||
|
"""
|
||||||
|
player = get_player_by_hostname(hostname)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found"
|
||||||
|
|
||||||
|
if quickconnect_code:
|
||||||
|
if player.verify_quickconnect_code(quickconnect_code):
|
||||||
|
return True, player
|
||||||
|
else:
|
||||||
|
return False, "Invalid quick connect code"
|
||||||
|
else:
|
||||||
|
if player.check_password(password):
|
||||||
|
return True, player
|
||||||
|
else:
|
||||||
|
return False, "Invalid password"
|
||||||
382
app/utils/uploads.py
Normal file
382
app/utils/uploads.py
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
"""
|
||||||
|
File upload processing utilities
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
from PIL import Image
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.content import Content
|
||||||
|
from app.utils.logger import log_upload, log_process, log_content_added
|
||||||
|
|
||||||
|
def allowed_file(filename, file_type='all'):
|
||||||
|
"""
|
||||||
|
Check if file extension is allowed
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename (str): Name of the file
|
||||||
|
file_type (str): Type of file to check ('images', 'videos', 'documents', 'all')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if file is allowed
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
if '.' not in filename:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ext = filename.rsplit('.', 1)[1].lower()
|
||||||
|
allowed_extensions = current_app.config['ALLOWED_EXTENSIONS']
|
||||||
|
|
||||||
|
if file_type == 'all':
|
||||||
|
all_extensions = set()
|
||||||
|
for extensions in allowed_extensions.values():
|
||||||
|
all_extensions.update(extensions)
|
||||||
|
return ext in all_extensions
|
||||||
|
|
||||||
|
return ext in allowed_extensions.get(file_type, set())
|
||||||
|
|
||||||
|
def get_file_type(filename):
|
||||||
|
"""
|
||||||
|
Determine file type based on extension
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename (str): Name of the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: File type ('image', 'video', 'document')
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
if '.' not in filename:
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
ext = filename.rsplit('.', 1)[1].lower()
|
||||||
|
allowed_extensions = current_app.config['ALLOWED_EXTENSIONS']
|
||||||
|
|
||||||
|
for file_type, extensions in allowed_extensions.items():
|
||||||
|
if ext in extensions:
|
||||||
|
return file_type.rstrip('s') # Remove 's' from 'images', 'videos', etc.
|
||||||
|
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
def save_uploaded_file(file, upload_folder):
|
||||||
|
"""
|
||||||
|
Save uploaded file to disk
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: FileStorage object from request
|
||||||
|
upload_folder (str): Path to upload folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, filename, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not file or file.filename == '':
|
||||||
|
return False, None, "No file selected"
|
||||||
|
|
||||||
|
if not allowed_file(file.filename):
|
||||||
|
return False, None, f"File type not allowed: {file.filename}"
|
||||||
|
|
||||||
|
# Generate secure filename
|
||||||
|
original_filename = file.filename
|
||||||
|
filename = secure_filename(original_filename)
|
||||||
|
|
||||||
|
# Handle duplicate filenames
|
||||||
|
base_name, ext = os.path.splitext(filename)
|
||||||
|
counter = 1
|
||||||
|
while os.path.exists(os.path.join(upload_folder, filename)):
|
||||||
|
filename = f"{base_name}_{counter}{ext}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
file_path = os.path.join(upload_folder, filename)
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
return True, filename, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, None, str(e)
|
||||||
|
|
||||||
|
def process_image(file_path, max_width=1920, max_height=1080):
|
||||||
|
"""
|
||||||
|
Process and optimize image file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path (str): Path to image file
|
||||||
|
max_width (int): Maximum width for resizing
|
||||||
|
max_height (int): Maximum height for resizing
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (width, height) of processed image
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
# Get original dimensions
|
||||||
|
original_width, original_height = img.size
|
||||||
|
|
||||||
|
# Calculate new dimensions while maintaining aspect ratio
|
||||||
|
if original_width > max_width or original_height > max_height:
|
||||||
|
img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||||
|
img.save(file_path, optimize=True, quality=85)
|
||||||
|
|
||||||
|
return img.size
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing image {file_path}: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def process_video(file_path, output_path=None):
|
||||||
|
"""
|
||||||
|
Process video file (convert to web-compatible format)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path (str): Path to input video file
|
||||||
|
output_path (str): Path for output file (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, output_filename, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if output_path is None:
|
||||||
|
base_name = os.path.splitext(file_path)[0]
|
||||||
|
output_path = f"{base_name}_converted.mp4"
|
||||||
|
|
||||||
|
# Use FFmpeg to convert video
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg', '-i', file_path,
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', 'medium',
|
||||||
|
'-crf', '23',
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-b:a', '128k',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
'-y', # Overwrite output file
|
||||||
|
output_path
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Remove original file if conversion successful
|
||||||
|
if os.path.exists(output_path) and file_path != output_path:
|
||||||
|
os.remove(file_path)
|
||||||
|
return True, os.path.basename(output_path), None
|
||||||
|
else:
|
||||||
|
return False, None, result.stderr
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, None, str(e)
|
||||||
|
|
||||||
|
def process_pdf(file_path, output_folder):
|
||||||
|
"""
|
||||||
|
Convert PDF to images
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path (str): Path to PDF file
|
||||||
|
output_folder (str): Folder to save converted images
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of generated image filenames
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
images = convert_from_path(file_path, dpi=150)
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
|
||||||
|
image_files = []
|
||||||
|
for i, image in enumerate(images):
|
||||||
|
image_filename = f"{base_name}_page_{i+1}.png"
|
||||||
|
image_path = os.path.join(output_folder, image_filename)
|
||||||
|
image.save(image_path, 'PNG')
|
||||||
|
image_files.append(image_filename)
|
||||||
|
|
||||||
|
# Remove original PDF
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
return image_files
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing PDF {file_path}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def process_pptx(file_path, output_folder):
|
||||||
|
"""
|
||||||
|
Convert PowerPoint to images using LibreOffice
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path (str): Path to PPTX file
|
||||||
|
output_folder (str): Folder to save converted images
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of generated image filenames
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Use LibreOffice to convert PPTX to PDF first
|
||||||
|
temp_dir = os.path.join(output_folder, 'temp')
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'libreoffice', '--headless',
|
||||||
|
'--convert-to', 'pdf',
|
||||||
|
'--outdir', temp_dir,
|
||||||
|
file_path
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Find the generated PDF
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
pdf_path = os.path.join(temp_dir, f"{base_name}.pdf")
|
||||||
|
|
||||||
|
if os.path.exists(pdf_path):
|
||||||
|
# Convert PDF to images
|
||||||
|
image_files = process_pdf(pdf_path, output_folder)
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
return image_files
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing PPTX {file_path}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def process_uploaded_files(app, files, duration, target_type, target_id):
|
||||||
|
"""
|
||||||
|
Process uploaded files and add them to the database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: Flask application instance
|
||||||
|
files: List of uploaded files
|
||||||
|
duration (int): Duration for each file in seconds
|
||||||
|
target_type (str): 'player' or 'group'
|
||||||
|
target_id (int): ID of the target player or group
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Results of processing
|
||||||
|
"""
|
||||||
|
upload_folder = os.path.join(app.static_folder, 'uploads')
|
||||||
|
results = {
|
||||||
|
'success': [],
|
||||||
|
'errors': [],
|
||||||
|
'processed': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
if not file or file.filename == '':
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save the file
|
||||||
|
success, filename, error = save_uploaded_file(file, upload_folder)
|
||||||
|
if not success:
|
||||||
|
results['errors'].append(f"{file.filename}: {error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = os.path.join(upload_folder, filename)
|
||||||
|
file_type = get_file_type(filename)
|
||||||
|
|
||||||
|
# Process based on file type
|
||||||
|
processed_files = []
|
||||||
|
|
||||||
|
if file_type == 'image':
|
||||||
|
width, height = process_image(file_path)
|
||||||
|
processed_files = [filename]
|
||||||
|
|
||||||
|
elif file_type == 'video':
|
||||||
|
success, converted_filename, error = process_video(file_path)
|
||||||
|
if success:
|
||||||
|
processed_files = [converted_filename]
|
||||||
|
else:
|
||||||
|
results['errors'].append(f"{filename}: Video conversion failed - {error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif file_type == 'document':
|
||||||
|
if filename.lower().endswith('.pdf'):
|
||||||
|
processed_files = process_pdf(file_path, upload_folder)
|
||||||
|
elif filename.lower().endswith(('.pptx', '.ppt')):
|
||||||
|
processed_files = process_pptx(file_path, upload_folder)
|
||||||
|
|
||||||
|
if not processed_files:
|
||||||
|
results['errors'].append(f"{filename}: Document conversion failed")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add processed files to database
|
||||||
|
from app.models.player import Player
|
||||||
|
|
||||||
|
if target_type == 'player':
|
||||||
|
player = Player.query.get(target_id)
|
||||||
|
if not player:
|
||||||
|
results['errors'].append(f"Player {target_id} not found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get max position for ordering
|
||||||
|
max_position = db.session.query(db.func.max(Content.position)).filter_by(player_id=target_id).scalar() or 0
|
||||||
|
|
||||||
|
for processed_file in processed_files:
|
||||||
|
content = Content(
|
||||||
|
file_name=processed_file,
|
||||||
|
original_name=file.filename,
|
||||||
|
duration=duration,
|
||||||
|
player_id=target_id,
|
||||||
|
content_type=file_type,
|
||||||
|
position=max_position + 1
|
||||||
|
)
|
||||||
|
db.session.add(content)
|
||||||
|
max_position += 1
|
||||||
|
|
||||||
|
# Update playlist version
|
||||||
|
player.increment_playlist_version()
|
||||||
|
log_content_added(file.filename, 'player', player.username)
|
||||||
|
|
||||||
|
elif target_type == 'group':
|
||||||
|
from app.models.group import Group
|
||||||
|
group = Group.query.get(target_id)
|
||||||
|
if not group:
|
||||||
|
results['errors'].append(f"Group {target_id} not found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add content to all players in the group
|
||||||
|
for player in group.players:
|
||||||
|
max_position = db.session.query(db.func.max(Content.position)).filter_by(player_id=player.id).scalar() or 0
|
||||||
|
|
||||||
|
for processed_file in processed_files:
|
||||||
|
content = Content(
|
||||||
|
file_name=processed_file,
|
||||||
|
original_name=file.filename,
|
||||||
|
duration=duration,
|
||||||
|
player_id=player.id,
|
||||||
|
content_type=file_type,
|
||||||
|
position=max_position + 1
|
||||||
|
)
|
||||||
|
db.session.add(content)
|
||||||
|
max_position += 1
|
||||||
|
|
||||||
|
player.increment_playlist_version()
|
||||||
|
|
||||||
|
log_content_added(file.filename, 'group', group.name)
|
||||||
|
|
||||||
|
results['success'].append(file.filename)
|
||||||
|
results['processed'] += len(processed_files)
|
||||||
|
|
||||||
|
# Log the upload
|
||||||
|
log_upload(file_type, file.filename, target_type, target_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results['errors'].append(f"{file.filename}: {str(e)}")
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
results['errors'].append(f"Database error: {str(e)}")
|
||||||
|
|
||||||
|
return results
|
||||||
65
config.py
Normal file
65
config.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
SKE Digital Signage Server Configuration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Base configuration class"""
|
||||||
|
|
||||||
|
# Application Settings
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'ske-signage-secret-key-change-in-production')
|
||||||
|
|
||||||
|
# Server Information
|
||||||
|
SERVER_VERSION = "2.0.0"
|
||||||
|
BUILD_DATE = "2025-07-15"
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
|
||||||
|
'sqlite:///' + os.path.join(os.path.abspath(os.path.dirname(__file__)), 'instance', 'ske_signage.db')
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# Upload Configuration
|
||||||
|
MAX_CONTENT_LENGTH = 2 * 1024 * 1024 * 1024 # 2GB
|
||||||
|
UPLOAD_FOLDER = 'static/uploads'
|
||||||
|
ALLOWED_EXTENSIONS = {
|
||||||
|
'images': {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'},
|
||||||
|
'videos': {'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'},
|
||||||
|
'documents': {'pdf', 'pptx', 'ppt'}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security Configuration
|
||||||
|
SESSION_PERMANENT = False
|
||||||
|
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
|
||||||
|
|
||||||
|
# Media Processing Configuration
|
||||||
|
FFMPEG_PATH = os.environ.get('FFMPEG_PATH', 'ffmpeg')
|
||||||
|
LIBREOFFICE_PATH = os.environ.get('LIBREOFFICE_PATH', 'libreoffice')
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
"""Development configuration"""
|
||||||
|
DEBUG = True
|
||||||
|
SQLALCHEMY_ECHO = True
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
"""Production configuration"""
|
||||||
|
DEBUG = False
|
||||||
|
SQLALCHEMY_ECHO = False
|
||||||
|
|
||||||
|
class TestingConfig(Config):
|
||||||
|
"""Testing configuration"""
|
||||||
|
TESTING = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||||
|
WTF_CSRF_ENABLED = False
|
||||||
|
|
||||||
|
# Configuration dictionary
|
||||||
|
config = {
|
||||||
|
'development': DevelopmentConfig,
|
||||||
|
'production': ProductionConfig,
|
||||||
|
'testing': TestingConfig,
|
||||||
|
'default': DevelopmentConfig
|
||||||
|
}
|
||||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
ske-signage:
|
||||||
|
build: .
|
||||||
|
image: ske-signage:2.0.0
|
||||||
|
container_name: ske-signage-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8880:5000"
|
||||||
|
environment:
|
||||||
|
# Flask Configuration
|
||||||
|
- FLASK_CONFIG=production
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-ske-signage-production-secret-change-me}
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
- DATABASE_URL=sqlite:///./instance/ske_signage.db
|
||||||
|
|
||||||
|
# Admin User Configuration
|
||||||
|
- ADMIN_USER=${ADMIN_USER:-admin}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-ChangeMe123!}
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- PORT=5000
|
||||||
|
- FLASK_DEBUG=false
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
- LOG_LEVEL=INFO
|
||||||
|
volumes:
|
||||||
|
# Persistent data storage
|
||||||
|
- ske_signage_data:/app/instance
|
||||||
|
- ske_signage_uploads:/app/static/uploads
|
||||||
|
- ske_signage_assets:/app/static/assets
|
||||||
|
- ske_signage_logs:/app/logs
|
||||||
|
networks:
|
||||||
|
- ske_signage_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ske_signage_data:
|
||||||
|
driver: local
|
||||||
|
ske_signage_uploads:
|
||||||
|
driver: local
|
||||||
|
ske_signage_assets:
|
||||||
|
driver: local
|
||||||
|
ske_signage_logs:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ske_signage_network:
|
||||||
|
driver: bridge
|
||||||
56
main.py
Normal file
56
main.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SKE Digital Signage Server
|
||||||
|
Main application entry point
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import User, ServerLog
|
||||||
|
from app.utils.logger import log_action
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main application entry point"""
|
||||||
|
|
||||||
|
# Create the Flask application
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
# Initialize database and create default admin user if needed
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
create_default_admin()
|
||||||
|
log_action("Server started")
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
port = int(os.environ.get('PORT', 5000))
|
||||||
|
host = os.environ.get('HOST', '0.0.0.0')
|
||||||
|
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||||||
|
|
||||||
|
app.run(host=host, port=port, debug=debug)
|
||||||
|
|
||||||
|
def create_default_admin():
|
||||||
|
"""Create default admin user if none exists"""
|
||||||
|
from app.extensions import bcrypt
|
||||||
|
|
||||||
|
admin_username = os.environ.get('ADMIN_USER', 'admin')
|
||||||
|
admin_password = os.environ.get('ADMIN_PASSWORD', 'admin123')
|
||||||
|
|
||||||
|
# Check if admin user already exists
|
||||||
|
if not User.query.filter_by(username=admin_username).first():
|
||||||
|
hashed_password = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
||||||
|
admin_user = User(
|
||||||
|
username=admin_username,
|
||||||
|
password=hashed_password,
|
||||||
|
role='admin'
|
||||||
|
)
|
||||||
|
db.session.add(admin_user)
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Default admin user '{admin_username}' created with password '{admin_password}'")
|
||||||
|
log_action(f"Default admin user '{admin_username}' created")
|
||||||
|
else:
|
||||||
|
print(f"Admin user '{admin_username}' already exists")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
231
manage.sh
Executable file
231
manage.sh
Executable file
@@ -0,0 +1,231 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# SKE Digital Signage - Management Script
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if Docker is available
|
||||||
|
check_docker() {
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
log_error "Docker is not installed or not in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
log_error "Docker Compose is not installed or not in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if Python environment is set up
|
||||||
|
check_python() {
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
log_error "Python virtual environment not found. Run './manage.sh setup' first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Development setup
|
||||||
|
setup_dev() {
|
||||||
|
log_info "Setting up development environment..."
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
log_info "Creating Python virtual environment..."
|
||||||
|
python3 -m venv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Activate virtual environment and install dependencies
|
||||||
|
log_info "Installing Python dependencies..."
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Create environment file if it doesn't exist
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
log_info "Creating .env file from template..."
|
||||||
|
cp .env.example .env
|
||||||
|
log_warning "Please edit .env file with your configuration"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
mkdir -p instance static/uploads static/assets logs
|
||||||
|
|
||||||
|
log_success "Development environment setup complete!"
|
||||||
|
log_info "Run './manage.sh dev' to start development server"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
run_dev() {
|
||||||
|
check_python
|
||||||
|
log_info "Starting development server..."
|
||||||
|
source venv/bin/activate
|
||||||
|
export FLASK_CONFIG=development
|
||||||
|
export FLASK_DEBUG=true
|
||||||
|
python main.py
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
build_docker() {
|
||||||
|
check_docker
|
||||||
|
log_info "Building Docker image..."
|
||||||
|
docker build -t ske-signage:2.0.0 .
|
||||||
|
log_success "Docker image built successfully!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start production with Docker Compose
|
||||||
|
start_prod() {
|
||||||
|
check_docker
|
||||||
|
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
log_warning "No .env file found, creating from template..."
|
||||||
|
cp .env.example .env
|
||||||
|
log_warning "Please edit .env file with production settings before starting"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Starting production server with Docker Compose..."
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
log_success "Production server started!"
|
||||||
|
log_info "Access the application at http://localhost:8880"
|
||||||
|
log_info "View logs with: ./manage.sh logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop production server
|
||||||
|
stop_prod() {
|
||||||
|
check_docker
|
||||||
|
log_info "Stopping production server..."
|
||||||
|
docker-compose down
|
||||||
|
log_success "Production server stopped!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
view_logs() {
|
||||||
|
check_docker
|
||||||
|
docker-compose logs -f
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
cleanup() {
|
||||||
|
log_info "Cleaning up..."
|
||||||
|
|
||||||
|
# Remove Python cache
|
||||||
|
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
# Remove logs
|
||||||
|
rm -rf logs/*.log 2>/dev/null || true
|
||||||
|
|
||||||
|
log_success "Cleanup complete!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backup data
|
||||||
|
backup() {
|
||||||
|
BACKUP_DIR="backups/$(date +%Y%m%d_%H%M%S)"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
log_info "Creating backup in $BACKUP_DIR..."
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
if [ -f "instance/ske_signage.db" ]; then
|
||||||
|
cp instance/ske_signage.db "$BACKUP_DIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup uploads
|
||||||
|
if [ -d "static/uploads" ]; then
|
||||||
|
cp -r static/uploads "$BACKUP_DIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup assets
|
||||||
|
if [ -d "static/assets" ]; then
|
||||||
|
cp -r static/assets "$BACKUP_DIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Backup created in $BACKUP_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show help
|
||||||
|
show_help() {
|
||||||
|
echo "SKE Digital Signage Management Script"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 <command>"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " setup - Set up development environment"
|
||||||
|
echo " dev - Run development server"
|
||||||
|
echo " build - Build Docker image"
|
||||||
|
echo " start - Start production server (Docker Compose)"
|
||||||
|
echo " stop - Stop production server"
|
||||||
|
echo " logs - View production logs"
|
||||||
|
echo " backup - Create backup of data"
|
||||||
|
echo " cleanup - Clean up temporary files"
|
||||||
|
echo " help - Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 setup # First time setup"
|
||||||
|
echo " $0 dev # Development"
|
||||||
|
echo " $0 start # Production"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main script logic
|
||||||
|
case "${1:-help}" in
|
||||||
|
setup)
|
||||||
|
setup_dev
|
||||||
|
;;
|
||||||
|
dev)
|
||||||
|
run_dev
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
build_docker
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
start_prod
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
stop_prod
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
view_logs
|
||||||
|
;;
|
||||||
|
backup)
|
||||||
|
backup
|
||||||
|
;;
|
||||||
|
cleanup)
|
||||||
|
cleanup
|
||||||
|
;;
|
||||||
|
help|--help|-h)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown command: $1"
|
||||||
|
show_help
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
36
requirements.txt
Normal file
36
requirements.txt
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Core Flask
|
||||||
|
Flask==3.1.0
|
||||||
|
Werkzeug==3.1.3
|
||||||
|
Jinja2==3.1.5
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
click==8.1.8
|
||||||
|
blinker==1.9.0
|
||||||
|
|
||||||
|
# Flask Extensions
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Migrate==4.1.0
|
||||||
|
Flask-Bcrypt==1.0.1
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
|
||||||
|
# Database
|
||||||
|
SQLAlchemy==2.0.37
|
||||||
|
alembic==1.14.1
|
||||||
|
|
||||||
|
# File Processing
|
||||||
|
pdf2image==1.17.0
|
||||||
|
PyPDF2==3.0.1
|
||||||
|
python-pptx==0.6.21
|
||||||
|
Pillow==10.0.1
|
||||||
|
cairosvg==2.7.0
|
||||||
|
ffmpeg-python==0.2.0
|
||||||
|
python-magic==0.4.27
|
||||||
|
|
||||||
|
# Security
|
||||||
|
bcrypt==4.2.1
|
||||||
|
|
||||||
|
# Production Server
|
||||||
|
gunicorn==20.1.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
MarkupSafe==3.0.2
|
||||||
Reference in New Issue
Block a user