Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor
@@ -0,0 +1,55 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# Development
|
||||
.git/
|
||||
.gitignore
|
||||
*.md
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Exclude shell scripts except Docker-related ones
|
||||
*.sh
|
||||
!docker-entrypoint.sh
|
||||
!install_libreoffice.sh
|
||||
!install_emoji_fonts.sh
|
||||
|
||||
# Database (will be created in volume)
|
||||
instance/
|
||||
!instance/.gitkeep
|
||||
|
||||
# Uploads (will be in volume)
|
||||
app/static/uploads/*
|
||||
!app/static/uploads/.gitkeep
|
||||
static/uploads/*
|
||||
!static/uploads/.gitkeep
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Development data
|
||||
*.db
|
||||
*.db-*
|
||||
flask_session/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Documentation
|
||||
BLUEPRINT_GUIDE.md
|
||||
ICON_INTEGRATION.md
|
||||
KIVY_PLAYER_COMPATIBILITY.md
|
||||
PLAYER_AUTH.md
|
||||
PROGRESS.md
|
||||
README.md
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# DigiServer v2 Production Environment Configuration
|
||||
# Copy to .env and update with your production values
|
||||
# IMPORTANT: Never commit this file to git
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=production
|
||||
FLASK_APP=app.app:create_app
|
||||
|
||||
# Security - MUST BE SET IN PRODUCTION
|
||||
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
SECRET_KEY=change-me-to-a-strong-random-secret-key-at-least-32-characters
|
||||
|
||||
# Admin User Configuration
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-to-a-strong-password
|
||||
ADMIN_EMAIL=admin@your-domain.com
|
||||
|
||||
# Database Configuration (optional - defaults to SQLite)
|
||||
# For PostgreSQL: postgresql://user:pass@host:5432/database
|
||||
# For SQLite: sqlite:////data/instance/dashboard.db
|
||||
# DATABASE_URL=
|
||||
|
||||
# Server Configuration
|
||||
# Set BEFORE deployment if host will have static IP after restart
|
||||
# This IP/domain will be used for SSL certificates and nginx configuration
|
||||
DOMAIN=your-domain.com
|
||||
HOST_IP=192.168.0.121
|
||||
EMAIL=admin@your-domain.com
|
||||
PREFERRED_URL_SCHEME=https
|
||||
|
||||
# SSL/HTTPS (configured in nginx.conf by default)
|
||||
SSL_CERT_PATH=/etc/nginx/ssl/cert.pem
|
||||
SSL_KEY_PATH=/etc/nginx/ssl/key.pem
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Security Headers (configured in nginx.conf)
|
||||
HSTS_MAX_AGE=31536000
|
||||
HSTS_INCLUDE_SUBDOMAINS=true
|
||||
|
||||
# Features (optional)
|
||||
ENABLE_LIBREOFFICE=true
|
||||
MAX_UPLOAD_SIZE=500000000 # 500MB
|
||||
|
||||
# Cache Configuration (optional)
|
||||
CACHE_TYPE=simple
|
||||
CACHE_DEFAULT_TIMEOUT=300
|
||||
|
||||
# Session Configuration
|
||||
SESSION_COOKIE_SECURE=true
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
SESSION_COOKIE_SAMESITE=Lax
|
||||
|
||||
# Proxy Configuration (configured in app.py)
|
||||
# IMPORTANT: Set this to your actual network range or specific proxy IP
|
||||
# Examples:
|
||||
# - 192.168.0.0/24 (local network with /24 subnet)
|
||||
# - 10.0.0.0/8 (AWS or similar cloud)
|
||||
# - 172.16.0.0/12 (Docker networks)
|
||||
# For multiple IPs: 192.168.0.121,10.0.1.50
|
||||
TRUSTED_PROXIES=192.168.0.0/24
|
||||
@@ -0,0 +1,61 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Persistent data folder (containers, database, uploads)
|
||||
data/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Uploads
|
||||
app/static/uploads/*
|
||||
!app/static/uploads/.gitkeep
|
||||
app/static/resurse/*
|
||||
!app/static/resurse/.gitkeep
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
#data
|
||||
data/
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Use Python 3.13 slim image
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies including LibreOffice for PPTX conversion
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
poppler-utils \
|
||||
ffmpeg \
|
||||
libmagic1 \
|
||||
sudo \
|
||||
fonts-noto-color-emoji \
|
||||
libreoffice-core \
|
||||
libreoffice-impress \
|
||||
libreoffice-writer \
|
||||
&& apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy entire application code into container
|
||||
# This includes: app/, migrations/, configs, and all scripts
|
||||
# Code is immutable in the image - only data folders are mounted as volumes
|
||||
COPY . .
|
||||
|
||||
# Copy and set permissions for entrypoint script
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=app.app:create_app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV FLASK_ENV=production
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
|
||||
# Create a non-root user and grant sudo access for dependency installation
|
||||
RUN useradd -m -u 1000 appuser && \
|
||||
chown -R appuser:appuser /app /docker-entrypoint.sh && \
|
||||
echo "Defaults:appuser !requiretty, !use_pty" >> /etc/sudoers && \
|
||||
echo "appuser ALL=(ALL) NOPASSWD: /usr/bin/apt-get" >> /etc/sudoers && \
|
||||
echo "appuser ALL=(ALL) NOPASSWD: /app/install_libreoffice.sh" >> /etc/sudoers && \
|
||||
echo "appuser ALL=(ALL) NOPASSWD: /app/install_emoji_fonts.sh" >> /etc/sudoers && \
|
||||
chmod +x /app/install_libreoffice.sh /app/install_emoji_fonts.sh
|
||||
|
||||
USER appuser
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()" || exit 1
|
||||
|
||||
# Run the application via entrypoint
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,564 @@
|
||||
# DigiServer v2 - Quick Deployment Guide
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
DigiServer is deployed using Docker Compose with the following architecture:
|
||||
|
||||
```
|
||||
Internet (User)
|
||||
↓
|
||||
Nginx Reverse Proxy (Port 80/443)
|
||||
↓
|
||||
Internal Docker Network
|
||||
↓
|
||||
Flask App (Gunicorn on Port 5000)
|
||||
↓
|
||||
SQLite Database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Complete Deployment Workflow
|
||||
|
||||
### **1️⃣ Clone & Setup**
|
||||
```bash
|
||||
# Copy the app folder from repository
|
||||
git clone <repository>
|
||||
cd digiserver-v2
|
||||
|
||||
# Copy environment file and modify as needed
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your configuration:
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Configure in .env:**
|
||||
```env
|
||||
SECRET_KEY=your-secret-key-change-this
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
DOMAIN=your-domain.com
|
||||
EMAIL=admin@your-domain.com
|
||||
IP_ADDRESS=192.168.0.111
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **2️⃣ Deploy via Script**
|
||||
```bash
|
||||
# Run the deployment script
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. ✅ Creates `data/` directories (instance, uploads, nginx-ssl, etc.)
|
||||
2. ✅ Copies nginx configs from repo root to `data/`
|
||||
3. ✅ Starts Docker containers
|
||||
4. ✅ Initializes database
|
||||
5. ✅ Runs all migrations
|
||||
6. ✅ Configures HTTPS with SSL certificates
|
||||
7. ✅ Displays access information
|
||||
|
||||
**Output shows:**
|
||||
- Access URLs (HTTP/HTTPS)
|
||||
- Default credentials
|
||||
- Next steps for configuration
|
||||
|
||||
---
|
||||
|
||||
### **3️⃣ Network Migration (When Network Changes)**
|
||||
|
||||
When moving the server to a different network with a new IP:
|
||||
|
||||
```bash
|
||||
# Migrate to the new network IP
|
||||
./migrate_network.sh 10.55.150.160
|
||||
|
||||
# Optional: with custom hostname
|
||||
./migrate_network.sh 10.55.150.160 digiserver-secured
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. ✅ Regenerates SSL certificates for new IP
|
||||
2. ✅ Updates database HTTPS configuration
|
||||
3. ✅ Restarts nginx and app containers
|
||||
4. ✅ Verifies HTTPS connectivity
|
||||
|
||||
---
|
||||
|
||||
### **4️⃣ Normal Operations**
|
||||
|
||||
**Restart containers:**
|
||||
```bash
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
**Stop containers:**
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
**View logs:**
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
**View container status:**
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Container Architecture
|
||||
|
||||
### **Container 1: digiserver-app (Flask)**
|
||||
- **Image**: Built from Dockerfile (Python 3.13)
|
||||
- **Port**: 5000 (internal only)
|
||||
- **Volumes**:
|
||||
- `./data:/app` - Persistent application data
|
||||
- `./data/instance:/app/instance` - Database & configuration
|
||||
- `./data/uploads:/app/app/static/uploads` - User uploads
|
||||
- **Startup**: Automatically initializes database on first run
|
||||
- **Health Check**: Every 30 seconds
|
||||
|
||||
### **Container 2: nginx (Reverse Proxy)**
|
||||
- **Image**: nginx:alpine
|
||||
- **Ports**: 80 & 443 (exposed to internet)
|
||||
- **Volumes**:
|
||||
- `nginx.conf` - Main configuration
|
||||
- `./data/nginx-ssl/` - SSL certificates
|
||||
- `./data/nginx-logs/` - Access/error logs
|
||||
- `./data/certbot/` - Let's Encrypt challenges
|
||||
- **Startup**: Waits for Flask app to start
|
||||
- **Health Check**: Every 30 seconds
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Deployment Steps Explained
|
||||
|
||||
### **Step 1: Start Containers**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
- Builds Flask app image (if needed)
|
||||
- Starts both containers
|
||||
- Waits for containers to be healthy
|
||||
|
||||
### **Step 2: Database Initialization** (docker-entrypoint.sh)
|
||||
When Flask container starts:
|
||||
1. Create required directories
|
||||
2. Check if database exists
|
||||
3. If NOT exists:
|
||||
- Initialize SQLite database (dashboard.db)
|
||||
- Create admin user from environment variables
|
||||
4. Start Gunicorn server (4 workers, 120s timeout)
|
||||
|
||||
### **Step 3: Run Migrations**
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python /app/migrations/[migration_name].py
|
||||
```
|
||||
Applied migrations:
|
||||
- `add_https_config_table.py` - HTTPS settings
|
||||
- `add_player_user_table.py` - Player user management
|
||||
- `add_email_to_https_config.py` - Email configuration
|
||||
- `migrate_player_user_global.py` - Global settings
|
||||
|
||||
### **Step 4: Configure HTTPS**
|
||||
- SSL certificates stored in `./data/nginx-ssl/`
|
||||
- Pre-generated self-signed certs for development
|
||||
- Ready for Let's Encrypt integration
|
||||
|
||||
### **Step 5: Reverse Proxy Routing** (nginx.conf)
|
||||
```
|
||||
HTTP (80):
|
||||
• Redirect all traffic to HTTPS
|
||||
• Allow ACME challenges for Let's Encrypt
|
||||
|
||||
HTTPS (443):
|
||||
• TLS 1.2+, HTTP/2 enabled
|
||||
• Proxy all requests to Flask app
|
||||
• Security headers added
|
||||
• Gzip compression enabled
|
||||
• Max upload size: 2GB
|
||||
• Proxy timeout: 300s
|
||||
```
|
||||
|
||||
### **Step 6: ProxyFix Middleware** (app/app.py)
|
||||
Extracts real client information from Nginx headers:
|
||||
- `X-Forwarded-For` → Real client IP
|
||||
- `X-Forwarded-Proto` → Protocol (http/https)
|
||||
- `X-Forwarded-Host` → Original hostname
|
||||
- `X-Forwarded-Port` → Original port
|
||||
|
||||
---
|
||||
|
||||
## 📂 Directory Structure & Persistence
|
||||
|
||||
```
|
||||
/srv/digiserver-v2/
|
||||
├── app/ (Flask application code)
|
||||
├── data/ (PERSISTENT - mounted as Docker volume)
|
||||
│ ├── app/ (Copy of app/ for container)
|
||||
│ ├── instance/ (dashboard.db - SQLite database)
|
||||
│ ├── uploads/ (User uploaded files)
|
||||
│ ├── nginx-ssl/ (SSL certificates)
|
||||
│ ├── nginx-logs/ (Nginx logs)
|
||||
│ └── certbot/ (Let's Encrypt challenges)
|
||||
├── migrations/ (Database schema updates)
|
||||
├── docker-compose.yml (Container orchestration)
|
||||
├── Dockerfile (Flask app image definition)
|
||||
├── nginx.conf (Reverse proxy configuration)
|
||||
└── docker-entrypoint.sh (Container startup script)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Default Credentials
|
||||
|
||||
```
|
||||
Username: admin
|
||||
Password: admin123
|
||||
```
|
||||
|
||||
⚠️ **CHANGE IMMEDIATELY IN PRODUCTION!**
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Access Points
|
||||
|
||||
After deployment, access the app at:
|
||||
- `https://localhost` (if deployed locally)
|
||||
- `https://192.168.0.121` (if deployed on server)
|
||||
- `https://<DOMAIN>` (if DNS configured)
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Environment Variables (Optional)
|
||||
|
||||
Create `.env` file in project root:
|
||||
|
||||
```bash
|
||||
# Network Configuration
|
||||
HOSTNAME=digiserver
|
||||
DOMAIN=digiserver.example.com
|
||||
IP_ADDRESS=192.168.0.121
|
||||
|
||||
# SSL/HTTPS
|
||||
EMAIL=admin@example.com
|
||||
|
||||
# Flask Configuration
|
||||
SECRET_KEY=your-secret-key-here
|
||||
FLASK_ENV=production
|
||||
|
||||
# Admin User
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
```
|
||||
|
||||
Then start with:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Common Commands
|
||||
|
||||
### **Start/Stop Containers**
|
||||
```bash
|
||||
# Start containers
|
||||
docker-compose up -d
|
||||
|
||||
# Stop containers
|
||||
docker-compose down
|
||||
|
||||
# Restart containers
|
||||
docker-compose restart
|
||||
|
||||
# Restart specific container
|
||||
docker-compose restart digiserver-app
|
||||
docker-compose restart nginx
|
||||
```
|
||||
|
||||
### **View Logs**
|
||||
```bash
|
||||
# All containers
|
||||
docker-compose logs
|
||||
|
||||
# Follow logs (real-time)
|
||||
docker-compose logs -f
|
||||
|
||||
# Specific container
|
||||
docker-compose logs -f digiserver-app
|
||||
docker-compose logs -f nginx
|
||||
|
||||
# Show last 50 lines
|
||||
docker-compose logs --tail=50 digiserver-app
|
||||
```
|
||||
|
||||
### **Container Status**
|
||||
```bash
|
||||
# Show running containers
|
||||
docker-compose ps
|
||||
|
||||
# Show container details
|
||||
docker-compose ps -a
|
||||
|
||||
# Check container health
|
||||
docker ps --format="table {{.Names}}\t{{.Status}}"
|
||||
```
|
||||
|
||||
### **Database Operations**
|
||||
```bash
|
||||
# Access database shell
|
||||
docker-compose exec digiserver-app sqlite3 /app/instance/dashboard.db
|
||||
|
||||
# Backup database
|
||||
docker-compose exec digiserver-app cp /app/instance/dashboard.db /app/instance/dashboard.db.backup
|
||||
|
||||
# Restore database
|
||||
docker-compose exec digiserver-app cp /app/instance/dashboard.db.backup /app/instance/dashboard.db
|
||||
```
|
||||
|
||||
### **Nginx Operations**
|
||||
```bash
|
||||
# Validate Nginx configuration
|
||||
docker exec digiserver-nginx nginx -t
|
||||
|
||||
# Reload Nginx (without restart)
|
||||
docker exec digiserver-nginx nginx -s reload
|
||||
|
||||
# View Nginx logs
|
||||
docker-compose logs -f nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 SSL Certificate Management
|
||||
|
||||
### **Generate New Self-Signed Certificate**
|
||||
```bash
|
||||
bash generate_nginx_certs.sh 192.168.0.121 365
|
||||
docker-compose restart nginx
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `192.168.0.121` - Domain/IP for certificate
|
||||
- `365` - Certificate validity in days
|
||||
|
||||
### **Set Up Let's Encrypt (Production)**
|
||||
1. Update `DOMAIN` and `EMAIL` in environment
|
||||
2. Modify `nginx.conf` to enable certbot challenges
|
||||
3. Run certbot:
|
||||
```bash
|
||||
docker run --rm -v $(pwd)/data/certbot:/etc/letsencrypt \
|
||||
-v $(pwd)/data/nginx-logs:/var/log/letsencrypt \
|
||||
certbot/certbot certonly --webroot \
|
||||
-w /var/www/certbot \
|
||||
-d yourdomain.com \
|
||||
-m your-email@example.com \
|
||||
--agree-tos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### **Containers not starting?**
|
||||
```bash
|
||||
# Check docker-compose logs
|
||||
docker-compose logs
|
||||
|
||||
# Check system resources
|
||||
docker stats
|
||||
|
||||
# Restart Docker daemon
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
### **Application not responding?**
|
||||
```bash
|
||||
# Check app container health
|
||||
docker-compose ps
|
||||
|
||||
# View app logs
|
||||
docker-compose logs -f digiserver-app
|
||||
|
||||
# Test Flask directly
|
||||
docker-compose exec digiserver-app curl http://localhost:5000/
|
||||
```
|
||||
|
||||
### **HTTPS not working?**
|
||||
```bash
|
||||
# Verify Nginx config
|
||||
docker exec digiserver-nginx nginx -t
|
||||
|
||||
# Check SSL certificates exist
|
||||
ls -la ./data/nginx-ssl/
|
||||
|
||||
# View Nginx error logs
|
||||
docker-compose logs nginx
|
||||
```
|
||||
|
||||
### **Database issues?**
|
||||
```bash
|
||||
# Check database file exists
|
||||
ls -la ./data/instance/dashboard.db
|
||||
|
||||
# Verify database permissions
|
||||
docker-compose exec digiserver-app ls -la /app/instance/
|
||||
|
||||
# Check database tables
|
||||
docker-compose exec digiserver-app sqlite3 /app/instance/dashboard.db ".tables"
|
||||
```
|
||||
|
||||
### **Port already in use?**
|
||||
```bash
|
||||
# Find process using port 80
|
||||
sudo lsof -i :80
|
||||
|
||||
# Find process using port 443
|
||||
sudo lsof -i :443
|
||||
|
||||
# Kill process (if needed)
|
||||
sudo kill -9 <PID>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Health Checks
|
||||
|
||||
Both containers have health checks:
|
||||
|
||||
**Flask App**: Pings `http://localhost:5000/` every 30 seconds
|
||||
**Nginx**: Pings `http://localhost:80/` every 30 seconds
|
||||
|
||||
Check health status:
|
||||
```bash
|
||||
docker-compose ps
|
||||
# Look for "Up (healthy)" status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Database Backup & Restore
|
||||
|
||||
### **Backup**
|
||||
```bash
|
||||
# Create backup
|
||||
docker-compose exec digiserver-app cp /app/instance/dashboard.db /app/instance/dashboard.backup.db
|
||||
|
||||
# Download to local machine
|
||||
cp ./data/instance/dashboard.backup.db ./backup/
|
||||
```
|
||||
|
||||
### **Restore**
|
||||
```bash
|
||||
# Stop containers
|
||||
docker-compose stop
|
||||
|
||||
# Restore database
|
||||
cp ./backup/dashboard.backup.db ./data/instance/dashboard.db
|
||||
|
||||
# Start containers
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Performance Tuning
|
||||
|
||||
### **Gunicorn Workers** (docker-entrypoint.sh)
|
||||
```bash
|
||||
# Default: 4 workers
|
||||
# Formula: (2 × CPU_count) + 1
|
||||
# For 4-core CPU: 9 workers
|
||||
|
||||
# Modify docker-entrypoint.sh:
|
||||
gunicorn --workers 9 ...
|
||||
```
|
||||
|
||||
### **Nginx Worker Processes** (nginx.conf)
|
||||
```nginx
|
||||
# Default: auto (CPU count)
|
||||
worker_processes auto;
|
||||
|
||||
# Or specify manually:
|
||||
worker_processes 4;
|
||||
```
|
||||
|
||||
### **Upload Timeout** (nginx.conf)
|
||||
```nginx
|
||||
# Default: 300s
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Checklist
|
||||
|
||||
- [ ] Change admin password immediately
|
||||
- [ ] Set strong `SECRET_KEY` environment variable
|
||||
- [ ] Enable firewall rules (allow only ports 80, 443)
|
||||
- [ ] Set up HTTPS with Let's Encrypt
|
||||
- [ ] Configure regular database backups
|
||||
- [ ] Review Nginx security headers
|
||||
- [ ] Update Flask dependencies regularly
|
||||
- [ ] Monitor container logs for errors
|
||||
- [ ] Restrict admin panel access (IP whitelist optional)
|
||||
- [ ] Enable Flask debug mode only in development
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Documentation
|
||||
|
||||
- **Nginx Setup**: See `NGINX_SETUP_QUICK.md`
|
||||
- **ProxyFix Configuration**: See `PROXY_FIX_SETUP.md`
|
||||
- **Deployment Commands**: See `DEPLOYMENT_COMMANDS.md`
|
||||
- **Issue Troubleshooting**: Check `old_code_documentation/`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Deployment Checklist
|
||||
|
||||
- [ ] Docker & Docker Compose installed
|
||||
- [ ] Running from `/srv/digiserver-v2` directory
|
||||
- [ ] Environment variables configured (optional)
|
||||
- [ ] Port 80/443 available
|
||||
- [ ] Sufficient disk space (min 5GB)
|
||||
- [ ] Sufficient RAM (min 2GB free)
|
||||
- [ ] Network connectivity verified
|
||||
- [ ] SSL certificates generated or obtained
|
||||
- [ ] Admin credentials changed (production)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps After Deployment
|
||||
|
||||
1. **Access Web Interface**
|
||||
- Login with admin credentials
|
||||
- Change password immediately
|
||||
|
||||
2. **Configure Application**
|
||||
- Set up players
|
||||
- Upload content
|
||||
- Configure groups & permissions
|
||||
|
||||
3. **Production Hardening**
|
||||
- Enable Let's Encrypt HTTPS
|
||||
- Configure firewall rules
|
||||
- Set up database backups
|
||||
- Monitor logs
|
||||
|
||||
4. **Optional Enhancements**
|
||||
- Set up custom domain
|
||||
- Configure email notifications
|
||||
- Install optional dependencies (LibreOffice, etc.)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 15, 2026
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
DigiServer v2 - Application Factory
|
||||
Modern Flask application with blueprint architecture
|
||||
"""
|
||||
import os
|
||||
from flask import Flask, render_template
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app.config import DevelopmentConfig, ProductionConfig, TestingConfig
|
||||
from app.extensions import db, bcrypt, login_manager, migrate, cache
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def create_app(config_name=None):
|
||||
"""
|
||||
Application factory pattern
|
||||
|
||||
Args:
|
||||
config_name: Configuration environment (development, production, testing)
|
||||
|
||||
Returns:
|
||||
Flask application instance
|
||||
"""
|
||||
# Set instance path to absolute path
|
||||
instance_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'instance'))
|
||||
app = Flask(__name__, instance_path=instance_path, instance_relative_config=True)
|
||||
|
||||
# Load configuration
|
||||
if config_name == 'production':
|
||||
config = ProductionConfig
|
||||
elif config_name == 'testing':
|
||||
config = TestingConfig
|
||||
else:
|
||||
config = DevelopmentConfig
|
||||
|
||||
app.config.from_object(config)
|
||||
|
||||
# Apply ProxyFix middleware for reverse proxy (Nginx/Caddy)
|
||||
# This ensures proper handling of X-Forwarded-* headers
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
||||
|
||||
# ScriptNameFix: reads X-Script-Name header set by the umbrella nginx
|
||||
# (e.g. /digiserver) so that url_for() generates correct full paths.
|
||||
from app.utils.script_name_fix import ScriptNameFix
|
||||
app.wsgi_app = ScriptNameFix(app.wsgi_app)
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
bcrypt.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
cache.init_app(app)
|
||||
|
||||
# Configure Flask-Login
|
||||
configure_login_manager(app)
|
||||
|
||||
# Initialize CORS for player API access
|
||||
from app.extensions import cors
|
||||
cors.init_app(app, resources={
|
||||
r"/api/*": {
|
||||
"origins": ["*"],
|
||||
"methods": ["GET", "POST", "OPTIONS", "PUT", "DELETE"],
|
||||
"allow_headers": ["Content-Type", "Authorization"],
|
||||
"supports_credentials": True,
|
||||
"max_age": 3600
|
||||
}
|
||||
})
|
||||
|
||||
# Register components
|
||||
register_blueprints(app)
|
||||
register_error_handlers(app)
|
||||
register_commands(app)
|
||||
register_context_processors(app)
|
||||
register_template_filters(app)
|
||||
|
||||
# Portal SSO: auto-login users arriving via the umbrella nginx gateway
|
||||
from app.utils.portal_sso import init_portal_sso
|
||||
init_portal_sso(app)
|
||||
|
||||
# Ensure DB schema exists (idempotent; safe to call even with migrate)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register application blueprints"""
|
||||
from app.blueprints.main import main_bp
|
||||
from app.blueprints.auth import auth_bp
|
||||
from app.blueprints.admin import admin_bp
|
||||
from app.blueprints.players import players_bp
|
||||
from app.blueprints.content import content_bp
|
||||
from app.blueprints.playlist import playlist_bp
|
||||
from app.blueprints.api import api_bp
|
||||
from app.blueprints.internal import internal_bp
|
||||
|
||||
# Register blueprints (using URL prefixes from blueprint definitions)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(players_bp)
|
||||
app.register_blueprint(content_bp)
|
||||
app.register_blueprint(playlist_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(internal_bp)
|
||||
|
||||
|
||||
def configure_login_manager(app):
|
||||
"""Configure Flask-Login"""
|
||||
from app.models.user import User
|
||||
|
||||
# Unauthenticated users are sent to the portal login, not DigiServer's own
|
||||
# login page (which is disabled). Flask-Login's login_view is still set as a
|
||||
# fallback URL builder target, but the actual /login route redirects to the portal.
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in via the Enterprise Digital Platform portal.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
|
||||
def register_error_handlers(app):
|
||||
"""Register error handlers"""
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return render_template('errors/404.html'), 404
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
return render_template('errors/403.html'), 403
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
db.session.rollback()
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
@app.errorhandler(413)
|
||||
def request_entity_too_large(error):
|
||||
return render_template('errors/413.html'), 413
|
||||
|
||||
@app.errorhandler(408)
|
||||
def request_timeout(error):
|
||||
return render_template('errors/408.html'), 408
|
||||
|
||||
|
||||
def register_commands(app):
|
||||
"""Register CLI commands"""
|
||||
import click
|
||||
|
||||
@app.cli.command('init-db')
|
||||
def init_db():
|
||||
"""Initialize the database"""
|
||||
db.create_all()
|
||||
click.echo('Database initialized.')
|
||||
|
||||
@app.cli.command('create-admin')
|
||||
@click.option('--username', default='admin', help='Admin username')
|
||||
@click.option('--password', prompt=True, hide_input=True, help='Admin password')
|
||||
def create_admin(username, password):
|
||||
"""Create an admin user"""
|
||||
from app.models.user import User
|
||||
|
||||
# Check if user exists
|
||||
if User.query.filter_by(username=username).first():
|
||||
click.echo(f'User {username} already exists.')
|
||||
return
|
||||
|
||||
# Create admin user
|
||||
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
admin = User(username=username, password=hashed_password, role='admin')
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
click.echo(f'Admin user {username} created successfully.')
|
||||
|
||||
@app.cli.command('seed-db')
|
||||
def seed_db():
|
||||
"""Seed database with sample data (development only)"""
|
||||
if app.config['ENV'] == 'production':
|
||||
click.echo('Cannot seed database in production.')
|
||||
return
|
||||
|
||||
click.echo('Seeding database with sample data...')
|
||||
# Add your seeding logic here
|
||||
click.echo('Database seeded successfully.')
|
||||
|
||||
|
||||
def register_context_processors(app):
|
||||
"""Register context processors for templates"""
|
||||
from flask_login import current_user
|
||||
|
||||
@app.context_processor
|
||||
def inject_config():
|
||||
"""Inject configuration variables into all templates"""
|
||||
return {
|
||||
'server_version': app.config['SERVER_VERSION'],
|
||||
'build_date': app.config['BUILD_DATE'],
|
||||
'logo_exists': os.path.exists(
|
||||
os.path.join(app.root_path, app.config['UPLOAD_FOLDERLOGO'], 'logo.png')
|
||||
)
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
def inject_user_theme():
|
||||
"""Inject user theme preference"""
|
||||
theme = 'light'
|
||||
if current_user.is_authenticated and hasattr(current_user, 'theme'):
|
||||
theme = current_user.theme
|
||||
return {'theme': theme}
|
||||
|
||||
|
||||
def register_template_filters(app):
|
||||
"""Register custom Jinja2 template filters"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@app.template_filter('localtime')
|
||||
def localtime_filter(dt, format='%Y-%m-%d %H:%M'):
|
||||
"""Convert UTC datetime to local time and format it.
|
||||
|
||||
Args:
|
||||
dt: datetime object in UTC
|
||||
format: strftime format string
|
||||
|
||||
Returns:
|
||||
Formatted datetime string in local timezone
|
||||
"""
|
||||
if dt is None:
|
||||
return ''
|
||||
|
||||
# If datetime is naive (no timezone), assume it's UTC
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Convert to local time
|
||||
local_dt = dt.astimezone()
|
||||
|
||||
return local_dt.strftime(format)
|
||||
|
||||
|
||||
# For backwards compatibility and direct running
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Blueprints package initialization
|
||||
"""
|
||||
@@ -0,0 +1,938 @@
|
||||
"""Admin blueprint for user management and system settings."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
from functools import wraps
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.caddy_manager import CaddyConfigGenerator
|
||||
from app.utils.nginx_config_reader import get_nginx_status
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin role for route access."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
flash('Please login to access this page.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
if current_user.role != 'admin':
|
||||
log_action('warning', f'Unauthorized admin access attempt by {current_user.username}')
|
||||
flash('You do not have permission to access this page.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@admin_bp.route('/')
|
||||
@login_required
|
||||
def admin_panel():
|
||||
"""Display admin panel with system overview."""
|
||||
try:
|
||||
# Get statistics
|
||||
total_users = User.query.count()
|
||||
total_players = Player.query.count()
|
||||
total_playlists = Playlist.query.count()
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Get recent logs
|
||||
recent_logs = ServerLog.query.order_by(ServerLog.timestamp.desc()).limit(10).all()
|
||||
|
||||
# Get all users
|
||||
users = User.query.all()
|
||||
|
||||
# Calculate storage usage
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
total_size = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
|
||||
storage_mb = round(total_size / (1024 * 1024), 2)
|
||||
|
||||
return render_template('admin/admin.html',
|
||||
total_users=total_users,
|
||||
total_players=total_players,
|
||||
total_playlists=total_playlists,
|
||||
total_content=total_content,
|
||||
storage_mb=storage_mb,
|
||||
users=users,
|
||||
recent_logs=recent_logs)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading admin panel: {str(e)}')
|
||||
flash('Error loading admin panel.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/user/create', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
"""User creation is disabled — accounts are managed by the EDP portal."""
|
||||
flash('User management is handled through the Enterprise Digital Platform portal.', 'info')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/role', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def change_user_role(user_id: int):
|
||||
"""Role changes are disabled — roles are set from the EDP portal and synced via SSO."""
|
||||
flash('Role management is handled through the Enterprise Digital Platform portal.', 'info')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id: int):
|
||||
"""User deletion is disabled — accounts are managed by the EDP portal."""
|
||||
flash('User management is handled through the Enterprise Digital Platform portal.', 'info')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/theme', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def change_theme():
|
||||
"""Change application theme."""
|
||||
try:
|
||||
theme = request.form.get('theme', 'light').strip()
|
||||
|
||||
if theme not in ['light', 'dark']:
|
||||
flash('Invalid theme specified.', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
# Store theme preference (you can extend this to save to database)
|
||||
# For now, just log the action
|
||||
log_action('info', f'Theme changed to {theme} by {current_user.username}')
|
||||
flash(f'Theme changed to {theme} mode.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error changing theme: {str(e)}')
|
||||
flash('Error changing theme. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/logo/upload', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def upload_logo():
|
||||
"""Upload custom logo for application."""
|
||||
try:
|
||||
if 'logo' not in request.files:
|
||||
flash('No logo file provided.', 'warning')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
file = request.files['logo']
|
||||
|
||||
if file.filename == '':
|
||||
flash('No file selected.', 'warning')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
# Validate file type
|
||||
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'svg'}
|
||||
filename = secure_filename(file.filename)
|
||||
if not ('.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions):
|
||||
flash('Invalid file type. Please upload an image file (PNG, JPG, GIF, SVG).', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
# Save logo
|
||||
static_folder = current_app.config.get('STATIC_FOLDER', 'app/static')
|
||||
logo_path = os.path.join(static_folder, 'logo.png')
|
||||
|
||||
# Create static folder if it doesn't exist
|
||||
os.makedirs(static_folder, exist_ok=True)
|
||||
|
||||
file.save(logo_path)
|
||||
|
||||
log_action('info', f'Logo uploaded by admin {current_user.username}')
|
||||
flash('Logo uploaded successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error uploading logo: {str(e)}')
|
||||
flash('Error uploading logo. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/logs/clear', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def clear_logs():
|
||||
"""Clear all server logs."""
|
||||
try:
|
||||
ServerLog.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'All logs cleared by admin {current_user.username}')
|
||||
flash('All logs cleared successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error clearing logs: {str(e)}')
|
||||
flash('Error clearing logs. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/users')
|
||||
@login_required
|
||||
@admin_required
|
||||
def user_management():
|
||||
"""Display user management page."""
|
||||
try:
|
||||
users = User.query.order_by(User.created_at.desc()).all()
|
||||
return render_template('admin/user_management.html', users=users)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading user management: {str(e)}')
|
||||
flash('Error loading user management page.', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/password', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def reset_user_password(user_id: int):
|
||||
"""Reset user password."""
|
||||
try:
|
||||
user = User.query.get_or_404(user_id)
|
||||
new_password = request.form.get('password', '').strip()
|
||||
|
||||
# Validation
|
||||
if not new_password or len(new_password) < 6:
|
||||
flash('Password must be at least 6 characters long.', 'warning')
|
||||
return redirect(url_for('admin.user_management'))
|
||||
|
||||
# Prevent changing own password through this route
|
||||
if user.id == current_user.id:
|
||||
flash('Use the change password option to update your own password.', 'warning')
|
||||
return redirect(url_for('admin.user_management'))
|
||||
|
||||
# Update password
|
||||
hashed_password = bcrypt.generate_password_hash(new_password).decode('utf-8')
|
||||
user.password = hashed_password
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Password reset for user {user.username} by admin {current_user.username}')
|
||||
flash(f'Password reset successfully for user "{user.username}".', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error resetting password: {str(e)}')
|
||||
flash('Error resetting password. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.user_management'))
|
||||
|
||||
|
||||
@admin_bp.route('/system/info')
|
||||
@login_required
|
||||
@admin_required
|
||||
def system_info():
|
||||
"""Get system information as JSON."""
|
||||
try:
|
||||
import platform
|
||||
import psutil
|
||||
|
||||
# Get system info
|
||||
info = {
|
||||
'system': platform.system(),
|
||||
'release': platform.release(),
|
||||
'version': platform.version(),
|
||||
'machine': platform.machine(),
|
||||
'processor': platform.processor(),
|
||||
'cpu_count': psutil.cpu_count(),
|
||||
'cpu_percent': psutil.cpu_percent(interval=1),
|
||||
'memory_total': round(psutil.virtual_memory().total / (1024**3), 2), # GB
|
||||
'memory_used': round(psutil.virtual_memory().used / (1024**3), 2), # GB
|
||||
'memory_percent': psutil.virtual_memory().percent,
|
||||
'disk_total': round(psutil.disk_usage('/').total / (1024**3), 2), # GB
|
||||
'disk_used': round(psutil.disk_usage('/').used / (1024**3), 2), # GB
|
||||
'disk_percent': psutil.disk_usage('/').percent
|
||||
}
|
||||
|
||||
return jsonify(info)
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting system info: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@admin_bp.route('/leftover-media')
|
||||
@login_required
|
||||
def leftover_media():
|
||||
"""Display leftover media files not assigned to any playlist."""
|
||||
from app.models.playlist import playlist_content
|
||||
from sqlalchemy import select
|
||||
|
||||
try:
|
||||
# Get all content IDs that are in playlists
|
||||
stmt = select(playlist_content.c.content_id).distinct()
|
||||
content_in_playlists = set(row[0] for row in db.session.execute(stmt))
|
||||
|
||||
# Get all content
|
||||
all_content = Content.query.all()
|
||||
|
||||
# Filter content not in any playlist
|
||||
leftover_content = [c for c in all_content if c.id not in content_in_playlists]
|
||||
|
||||
# Separate by type
|
||||
leftover_images = [c for c in leftover_content if c.content_type == 'image']
|
||||
leftover_videos = [c for c in leftover_content if c.content_type == 'video']
|
||||
leftover_pdfs = [c for c in leftover_content if c.content_type == 'pdf']
|
||||
leftover_pptx = [c for c in leftover_content if c.content_type == 'pptx']
|
||||
|
||||
# Calculate storage (handle None values)
|
||||
def safe_file_size(content_list):
|
||||
return sum(c.file_size or 0 for c in content_list)
|
||||
|
||||
total_leftover_size = safe_file_size(leftover_content)
|
||||
images_size = safe_file_size(leftover_images)
|
||||
videos_size = safe_file_size(leftover_videos)
|
||||
pdfs_size = safe_file_size(leftover_pdfs)
|
||||
pptx_size = safe_file_size(leftover_pptx)
|
||||
|
||||
return render_template('admin/leftover_media.html',
|
||||
leftover_images=leftover_images,
|
||||
leftover_videos=leftover_videos,
|
||||
leftover_pdfs=leftover_pdfs,
|
||||
leftover_pptx=leftover_pptx,
|
||||
total_leftover=len(leftover_content),
|
||||
total_leftover_size_mb=total_leftover_size / (1024 * 1024),
|
||||
images_size_mb=images_size / (1024 * 1024),
|
||||
videos_size_mb=videos_size / (1024 * 1024),
|
||||
pdfs_size_mb=pdfs_size / (1024 * 1024),
|
||||
pptx_size_mb=pptx_size / (1024 * 1024))
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading leftover media: {str(e)}')
|
||||
flash('Error loading leftover media.', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/delete-leftover-images', methods=['POST'])
|
||||
@login_required
|
||||
def delete_leftover_images():
|
||||
"""Delete all leftover images that are not part of any playlist"""
|
||||
from app.models.playlist import playlist_content
|
||||
|
||||
try:
|
||||
# Find all leftover image content
|
||||
leftover_images = db.session.query(Content).filter(
|
||||
Content.content_type == 'image',
|
||||
~Content.id.in_(
|
||||
db.session.query(playlist_content.c.content_id)
|
||||
)
|
||||
).all()
|
||||
|
||||
deleted_count = 0
|
||||
errors = []
|
||||
|
||||
for content in leftover_images:
|
||||
try:
|
||||
# Delete physical file
|
||||
if content.filename:
|
||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
# Delete edited media archive folder if it exists
|
||||
import shutil
|
||||
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
||||
if os.path.exists(edited_media_dir):
|
||||
shutil.rmtree(edited_media_dir)
|
||||
|
||||
# Delete associated player edit records first
|
||||
from app.models.player_edit import PlayerEdit
|
||||
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
||||
|
||||
# Delete database record
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Error deleting {content.filename}: {str(e)}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if errors:
|
||||
flash(f'Deleted {deleted_count} images with {len(errors)} errors', 'warning')
|
||||
else:
|
||||
flash(f'Successfully deleted {deleted_count} leftover images', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error deleting leftover images: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.leftover_media'))
|
||||
|
||||
@admin_bp.route('/delete-leftover-videos', methods=['POST'])
|
||||
@login_required
|
||||
def delete_leftover_videos():
|
||||
"""Delete all leftover videos that are not part of any playlist"""
|
||||
from app.models.playlist import playlist_content
|
||||
|
||||
try:
|
||||
# Find all leftover video content
|
||||
leftover_videos = db.session.query(Content).filter(
|
||||
Content.content_type == 'video',
|
||||
~Content.id.in_(
|
||||
db.session.query(playlist_content.c.content_id)
|
||||
)
|
||||
).all()
|
||||
|
||||
deleted_count = 0
|
||||
errors = []
|
||||
|
||||
for content in leftover_videos:
|
||||
try:
|
||||
# Delete physical file
|
||||
if content.filename:
|
||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
# Delete edited media archive folder if it exists
|
||||
import shutil
|
||||
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
||||
if os.path.exists(edited_media_dir):
|
||||
shutil.rmtree(edited_media_dir)
|
||||
|
||||
# Delete associated player edit records first
|
||||
from app.models.player_edit import PlayerEdit
|
||||
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
||||
|
||||
# Delete database record
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Error deleting {content.filename}: {str(e)}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if errors:
|
||||
flash(f'Deleted {deleted_count} videos with {len(errors)} errors', 'warning')
|
||||
else:
|
||||
flash(f'Successfully deleted {deleted_count} leftover videos', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error deleting leftover videos: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.leftover_media'))
|
||||
|
||||
@admin_bp.route('/delete-single-leftover/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_single_leftover(content_id):
|
||||
"""Delete a single leftover content file"""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
# Delete physical file
|
||||
if content.filename:
|
||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
# Delete edited media archive folder if it exists
|
||||
import shutil
|
||||
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
||||
if os.path.exists(edited_media_dir):
|
||||
shutil.rmtree(edited_media_dir)
|
||||
|
||||
# Delete associated player edit records first
|
||||
from app.models.player_edit import PlayerEdit
|
||||
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
||||
|
||||
# Delete database record
|
||||
db.session.delete(content)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Successfully deleted {content.filename}', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error deleting file: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.leftover_media'))
|
||||
|
||||
|
||||
@admin_bp.route('/dependencies')
|
||||
@login_required
|
||||
@admin_required
|
||||
def dependencies():
|
||||
"""Show system dependencies status."""
|
||||
import subprocess
|
||||
|
||||
# Check LibreOffice
|
||||
libreoffice_installed = False
|
||||
libreoffice_version = "Not installed"
|
||||
try:
|
||||
result = subprocess.run(['libreoffice', '--version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5)
|
||||
if result.returncode == 0:
|
||||
libreoffice_installed = True
|
||||
libreoffice_version = result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check Poppler (for PDF)
|
||||
poppler_installed = False
|
||||
poppler_version = "Not installed"
|
||||
try:
|
||||
result = subprocess.run(['pdftoppm', '-v'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5)
|
||||
if result.returncode == 0 or 'pdftoppm' in result.stderr:
|
||||
poppler_installed = True
|
||||
poppler_version = "Installed"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check FFmpeg (for video)
|
||||
ffmpeg_installed = False
|
||||
ffmpeg_version = "Not installed"
|
||||
try:
|
||||
result = subprocess.run(['ffmpeg', '-version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5)
|
||||
if result.returncode == 0:
|
||||
ffmpeg_installed = True
|
||||
ffmpeg_version = result.stdout.split('\n')[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check Emoji Fonts
|
||||
emoji_installed = False
|
||||
emoji_version = 'Not installed'
|
||||
try:
|
||||
result = subprocess.run(['dpkg', '-l', 'fonts-noto-color-emoji'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5)
|
||||
if result.returncode == 0 and 'ii' in result.stdout:
|
||||
emoji_installed = True
|
||||
# Get version from dpkg output
|
||||
lines = result.stdout.split('\n')
|
||||
for line in lines:
|
||||
if 'fonts-noto-color-emoji' in line and line.startswith('ii'):
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
emoji_version = f'Noto Color Emoji {parts[2]}'
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return render_template('admin/dependencies.html',
|
||||
libreoffice_installed=libreoffice_installed,
|
||||
libreoffice_version=libreoffice_version,
|
||||
poppler_installed=poppler_installed,
|
||||
poppler_version=poppler_version,
|
||||
ffmpeg_installed=ffmpeg_installed,
|
||||
ffmpeg_version=ffmpeg_version,
|
||||
emoji_installed=emoji_installed,
|
||||
emoji_version=emoji_version)
|
||||
|
||||
|
||||
@admin_bp.route('/install-libreoffice', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def install_libreoffice():
|
||||
"""Install LibreOffice for PPTX conversion."""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
# Run installation script
|
||||
script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
'install_libreoffice.sh')
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
flash('Installation script not found', 'danger')
|
||||
return redirect(url_for('admin.dependencies'))
|
||||
|
||||
result = subprocess.run(['sudo', '-n', script_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300)
|
||||
|
||||
if result.returncode == 0:
|
||||
log_action('info', 'LibreOffice installed successfully')
|
||||
flash('LibreOffice installed successfully! You can now convert PPTX files.', 'success')
|
||||
else:
|
||||
log_action('error', f'LibreOffice installation failed: {result.stderr}')
|
||||
flash(f'Installation failed: {result.stderr}', 'danger')
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
flash('Installation timeout. Please try again.', 'warning')
|
||||
except Exception as e:
|
||||
log_action('error', f'Error installing LibreOffice: {str(e)}')
|
||||
flash(f'Error: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.dependencies'))
|
||||
|
||||
|
||||
@admin_bp.route('/install-emoji-fonts', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def install_emoji_fonts():
|
||||
"""Install Emoji Fonts for better UI display."""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
# Run installation script
|
||||
script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
'install_emoji_fonts.sh')
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
flash('Installation script not found', 'danger')
|
||||
return redirect(url_for('admin.dependencies'))
|
||||
|
||||
result = subprocess.run(['sudo', '-n', script_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180)
|
||||
|
||||
if result.returncode == 0:
|
||||
log_action('info', 'Emoji fonts installed successfully')
|
||||
flash('Emoji fonts installed successfully! Please restart your browser to see changes.', 'success')
|
||||
else:
|
||||
log_action('error', f'Emoji fonts installation failed: {result.stderr}')
|
||||
flash(f'Installation failed: {result.stderr}', 'danger')
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
flash('Installation timeout. Please try again.', 'warning')
|
||||
except Exception as e:
|
||||
log_action('error', f'Error installing emoji fonts: {str(e)}')
|
||||
flash(f'Error: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.dependencies'))
|
||||
|
||||
|
||||
@admin_bp.route('/customize-logos')
|
||||
@login_required
|
||||
@admin_required
|
||||
def customize_logos():
|
||||
"""Logo customization page."""
|
||||
import time
|
||||
return render_template('admin/customize_logos.html', version=int(time.time()))
|
||||
|
||||
|
||||
@admin_bp.route('/upload-header-logo', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def upload_header_logo():
|
||||
"""Upload header logo."""
|
||||
try:
|
||||
if 'header_logo' not in request.files:
|
||||
flash('No file selected', 'warning')
|
||||
return redirect(url_for('admin.customize_logos'))
|
||||
|
||||
file = request.files['header_logo']
|
||||
if file.filename == '':
|
||||
flash('No file selected', 'warning')
|
||||
return redirect(url_for('admin.customize_logos'))
|
||||
|
||||
if file:
|
||||
# Save as header_logo.png
|
||||
filename = 'header_logo.png'
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(filepath)
|
||||
|
||||
log_action('info', f'Header logo uploaded: {filename}')
|
||||
flash('Header logo uploaded successfully!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error uploading header logo: {str(e)}')
|
||||
flash(f'Error uploading logo: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.customize_logos'))
|
||||
|
||||
|
||||
@admin_bp.route('/upload-login-logo', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def upload_login_logo():
|
||||
"""Upload login page logo."""
|
||||
try:
|
||||
if 'login_logo' not in request.files:
|
||||
flash('No file selected', 'warning')
|
||||
return redirect(url_for('admin.customize_logos'))
|
||||
|
||||
file = request.files['login_logo']
|
||||
if file.filename == '':
|
||||
flash('No file selected', 'warning')
|
||||
return redirect(url_for('admin.customize_logos'))
|
||||
|
||||
if file:
|
||||
# Save as login_logo.png
|
||||
filename = 'login_logo.png'
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(filepath)
|
||||
|
||||
log_action('info', f'Login logo uploaded: {filename}')
|
||||
flash('Login logo uploaded successfully!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error uploading login logo: {str(e)}')
|
||||
flash(f'Error uploading logo: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.customize_logos'))
|
||||
|
||||
|
||||
@admin_bp.route('/editing-users')
|
||||
@login_required
|
||||
def manage_editing_users():
|
||||
"""Display and manage users that edit images on players."""
|
||||
try:
|
||||
from app.models.player_user import PlayerUser
|
||||
from app.models.player_edit import PlayerEdit
|
||||
|
||||
# Get all editing users
|
||||
users = PlayerUser.query.order_by(PlayerUser.created_at.desc()).all()
|
||||
|
||||
# Get edit counts for each user
|
||||
user_stats = {}
|
||||
for user in users:
|
||||
edit_count = PlayerEdit.query.filter_by(user=user.user_code).count()
|
||||
user_stats[user.user_code] = edit_count
|
||||
|
||||
return render_template('admin/editing_users.html',
|
||||
users=users,
|
||||
user_stats=user_stats)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading editing users: {str(e)}')
|
||||
flash('Error loading editing users.', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/editing-users/<int:user_id>/update', methods=['POST'])
|
||||
@login_required
|
||||
def update_editing_user(user_id: int):
|
||||
"""Update editing user name."""
|
||||
try:
|
||||
from app.models.player_user import PlayerUser
|
||||
|
||||
user = PlayerUser.query.get_or_404(user_id)
|
||||
user_name = request.form.get('user_name', '').strip()
|
||||
|
||||
user.user_name = user_name if user_name else None
|
||||
user.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Updated editing user {user.user_code} name to: {user_name or "None"}')
|
||||
flash('User name updated successfully!', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating editing user: {str(e)}')
|
||||
flash(f'Error updating user: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.manage_editing_users'))
|
||||
|
||||
|
||||
@admin_bp.route('/editing-users/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_editing_user(user_id: int):
|
||||
"""Delete editing user."""
|
||||
try:
|
||||
from app.models.player_user import PlayerUser
|
||||
|
||||
user = PlayerUser.query.get_or_404(user_id)
|
||||
user_code = user.user_code
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Deleted editing user: {user_code}')
|
||||
flash('User deleted successfully!', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting editing user: {str(e)}')
|
||||
flash(f'Error deleting user: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.manage_editing_users'))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HTTPS Configuration Management Routes
|
||||
# ============================================================================
|
||||
|
||||
@admin_bp.route('/https-config', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def https_config():
|
||||
"""Display HTTPS configuration management page."""
|
||||
try:
|
||||
config = HTTPSConfig.get_config()
|
||||
|
||||
# Detect actual current HTTPS status
|
||||
# Check if current connection is HTTPS
|
||||
is_https_active = request.scheme == 'https' or request.headers.get('X-Forwarded-Proto') == 'https'
|
||||
current_host = request.host.split(':')[0] # Remove port if present
|
||||
|
||||
# If HTTPS is active but database shows disabled, sync it
|
||||
if is_https_active and config and not config.https_enabled:
|
||||
# Update database to reflect actual HTTPS status
|
||||
config.https_enabled = True
|
||||
db.session.commit()
|
||||
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
|
||||
|
||||
# Get Nginx configuration status
|
||||
nginx_status = get_nginx_status()
|
||||
|
||||
return render_template('admin/https_config.html',
|
||||
config=config,
|
||||
is_https_active=is_https_active,
|
||||
current_host=current_host,
|
||||
nginx_status=nginx_status)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading HTTPS config page: {str(e)}')
|
||||
flash('Error loading HTTPS configuration page.', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/https-config/update', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def update_https_config():
|
||||
"""Update HTTPS configuration."""
|
||||
try:
|
||||
https_enabled = request.form.get('https_enabled') == 'on'
|
||||
hostname = request.form.get('hostname', '').strip()
|
||||
domain = request.form.get('domain', '').strip()
|
||||
ip_address = request.form.get('ip_address', '').strip()
|
||||
email = request.form.get('email', '').strip()
|
||||
port = request.form.get('port', '443').strip()
|
||||
|
||||
# Validation
|
||||
errors = []
|
||||
|
||||
if https_enabled:
|
||||
if not hostname:
|
||||
errors.append('Hostname is required when HTTPS is enabled.')
|
||||
if not domain:
|
||||
errors.append('Domain name is required when HTTPS is enabled.')
|
||||
if not ip_address:
|
||||
errors.append('IP address is required when HTTPS is enabled.')
|
||||
if not email:
|
||||
errors.append('Email address is required when HTTPS is enabled.')
|
||||
|
||||
# Validate domain format (basic)
|
||||
if domain and '.' not in domain:
|
||||
errors.append('Please enter a valid domain name (e.g., example.com).')
|
||||
|
||||
# Validate IP format (basic)
|
||||
if ip_address:
|
||||
ip_parts = ip_address.split('.')
|
||||
if len(ip_parts) != 4:
|
||||
errors.append('Please enter a valid IPv4 address (e.g., 10.76.152.164).')
|
||||
else:
|
||||
try:
|
||||
for part in ip_parts:
|
||||
num = int(part)
|
||||
if num < 0 or num > 255:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
errors.append('Please enter a valid IPv4 address.')
|
||||
|
||||
# Validate email format (basic)
|
||||
if email and '@' not in email:
|
||||
errors.append('Please enter a valid email address.')
|
||||
|
||||
# Validate port
|
||||
try:
|
||||
port_num = int(port)
|
||||
if port_num < 1 or port_num > 65535:
|
||||
errors.append('Port must be between 1 and 65535.')
|
||||
port = port_num
|
||||
except ValueError:
|
||||
errors.append('Port must be a valid number.')
|
||||
else:
|
||||
port = 443
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
flash(error, 'warning')
|
||||
return redirect(url_for('admin.https_config'))
|
||||
|
||||
# Update configuration
|
||||
config = HTTPSConfig.create_or_update(
|
||||
https_enabled=https_enabled,
|
||||
hostname=hostname if https_enabled else None,
|
||||
domain=domain if https_enabled else None,
|
||||
ip_address=ip_address if https_enabled else None,
|
||||
email=email if https_enabled else None,
|
||||
port=port if https_enabled else 443,
|
||||
updated_by=current_user.username
|
||||
)
|
||||
|
||||
# Generate and update Caddyfile
|
||||
try:
|
||||
caddyfile_content = CaddyConfigGenerator.generate_caddyfile(config)
|
||||
if CaddyConfigGenerator.write_caddyfile(caddyfile_content):
|
||||
# Reload Caddy configuration
|
||||
if CaddyConfigGenerator.reload_caddy():
|
||||
caddy_status = '✅ Caddy configuration updated successfully!'
|
||||
log_action('info', f'Caddy configuration reloaded by {current_user.username}')
|
||||
else:
|
||||
caddy_status = '⚠️ Caddyfile updated but reload failed. Please restart containers.'
|
||||
log_action('warning', f'Caddy reload failed for {current_user.username}')
|
||||
else:
|
||||
caddy_status = '⚠️ Configuration saved but Caddyfile update failed.'
|
||||
log_action('warning', f'Caddyfile write failed for {current_user.username}')
|
||||
except Exception as caddy_error:
|
||||
caddy_status = f'⚠️ Configuration saved but Caddy update failed: {str(caddy_error)}'
|
||||
log_action('error', f'Caddy update error: {str(caddy_error)}')
|
||||
|
||||
if https_enabled:
|
||||
log_action('info', f'HTTPS enabled by {current_user.username}: domain={domain}, hostname={hostname}, ip={ip_address}, email={email}')
|
||||
flash(f'✅ HTTPS configuration saved successfully!\n{caddy_status}\nServer available at https://{domain}', 'success')
|
||||
else:
|
||||
log_action('info', f'HTTPS disabled by {current_user.username}')
|
||||
flash(f'✅ HTTPS has been disabled. Server running on HTTP only.\n{caddy_status}', 'success')
|
||||
|
||||
return redirect(url_for('admin.https_config'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating HTTPS config: {str(e)}')
|
||||
flash(f'Error updating HTTPS configuration: {str(e)}', 'danger')
|
||||
return redirect(url_for('admin.https_config'))
|
||||
|
||||
|
||||
@admin_bp.route('/https-config/status')
|
||||
@login_required
|
||||
@admin_required
|
||||
def https_config_status():
|
||||
"""Get current HTTPS configuration status as JSON."""
|
||||
try:
|
||||
config = HTTPSConfig.get_config()
|
||||
|
||||
if config:
|
||||
return jsonify(config.to_dict())
|
||||
else:
|
||||
return jsonify({
|
||||
'https_enabled': False,
|
||||
'hostname': None,
|
||||
'domain': None,
|
||||
'ip_address': None,
|
||||
'port': 443,
|
||||
})
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting HTTPS status: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,878 @@
|
||||
"""API blueprint for REST endpoints and player communication."""
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import bcrypt
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Content, PlayerFeedback, ServerLog
|
||||
from app.utils.logger import log_action
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
# Simple rate limiting (use Redis-based solution in production)
|
||||
rate_limit_storage = {}
|
||||
|
||||
|
||||
def rate_limit(max_requests: int = 60, window: int = 60):
|
||||
"""Rate limiting decorator.
|
||||
|
||||
Args:
|
||||
max_requests: Maximum number of requests allowed
|
||||
window: Time window in seconds
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get client identifier (IP address or API key)
|
||||
client_id = request.remote_addr
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
client_id = auth_header[7:] # Use API key as identifier
|
||||
|
||||
now = datetime.now()
|
||||
key = f"{client_id}:{f.__name__}"
|
||||
|
||||
# Clean old entries
|
||||
if key in rate_limit_storage:
|
||||
rate_limit_storage[key] = [
|
||||
req_time for req_time in rate_limit_storage[key]
|
||||
if now - req_time < timedelta(seconds=window)
|
||||
]
|
||||
else:
|
||||
rate_limit_storage[key] = []
|
||||
|
||||
# Check rate limit
|
||||
if len(rate_limit_storage[key]) >= max_requests:
|
||||
return jsonify({
|
||||
'error': 'Rate limit exceeded',
|
||||
'retry_after': window
|
||||
}), 429
|
||||
|
||||
# Add current request
|
||||
rate_limit_storage[key].append(now)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def verify_player_auth(f):
|
||||
"""Decorator to verify player authentication via auth code."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Missing or invalid authorization header'}), 401
|
||||
|
||||
auth_code = auth_header[7:] # Remove 'Bearer ' prefix
|
||||
|
||||
# Find player with this auth code
|
||||
player = Player.query.filter_by(auth_code=auth_code).first()
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Invalid auth code attempt: {auth_code}')
|
||||
return jsonify({'error': 'Invalid authentication code'}), 403
|
||||
|
||||
# Store player in request context
|
||||
request.player = player
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@api_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""API health check endpoint."""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'version': '2.0.0'
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route('/certificate', methods=['GET'])
|
||||
def get_server_certificate():
|
||||
"""Get server SSL certificate."""
|
||||
return jsonify({'test': 'certificate_endpoint_works'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/player', methods=['POST'])
|
||||
@rate_limit(max_requests=120, window=60)
|
||||
def authenticate_player():
|
||||
"""Authenticate a player and return auth code and configuration.
|
||||
|
||||
Request JSON:
|
||||
hostname: Player hostname/identifier (required)
|
||||
password: Player password (optional if using quickconnect)
|
||||
quickconnect_code: Quick connect code (optional if using password)
|
||||
|
||||
Returns:
|
||||
JSON with auth_code, player_id, group_id, and configuration
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
hostname = data.get('hostname')
|
||||
password = data.get('password')
|
||||
quickconnect_code = data.get('quickconnect_code')
|
||||
|
||||
if not hostname:
|
||||
return jsonify({'error': 'Hostname is required'}), 400
|
||||
|
||||
if not password and not quickconnect_code:
|
||||
return jsonify({'error': 'Password or quickconnect code required'}), 400
|
||||
|
||||
# Authenticate player
|
||||
player = Player.authenticate(hostname, password, quickconnect_code)
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
# Update player status
|
||||
player.update_status('online')
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Player authenticated: {player.name} ({player.hostname})')
|
||||
|
||||
# Return authentication response
|
||||
response = {
|
||||
'success': True,
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'auth_code': player.auth_code,
|
||||
'playlist_id': player.playlist_id,
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
|
||||
return jsonify(response), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/verify', methods=['POST'])
|
||||
@rate_limit(max_requests=300, window=60)
|
||||
def verify_auth_code():
|
||||
"""Verify an auth code and return player information.
|
||||
|
||||
Request JSON:
|
||||
auth_code: Player authentication code
|
||||
|
||||
Returns:
|
||||
JSON with player information if valid
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
auth_code = data.get('auth_code')
|
||||
|
||||
if not auth_code:
|
||||
return jsonify({'error': 'Auth code is required'}), 400
|
||||
|
||||
# Find player with this auth code
|
||||
player = Player.query.filter_by(auth_code=auth_code).first()
|
||||
|
||||
if not player:
|
||||
return jsonify({'error': 'Invalid auth code'}), 401
|
||||
|
||||
# Update last seen
|
||||
player.update_status(player.status)
|
||||
db.session.commit()
|
||||
|
||||
response = {
|
||||
'valid': True,
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'playlist_id': player.playlist_id,
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
|
||||
return jsonify(response), 200
|
||||
|
||||
|
||||
@api_bp.route('/playlists', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def get_playlist_by_quickconnect():
|
||||
"""Get playlist using hostname and quickconnect code (Kivy player compatible).
|
||||
|
||||
Query parameters:
|
||||
hostname: Player hostname/identifier
|
||||
quickconnect_code: Quick connect code for authentication
|
||||
|
||||
Returns:
|
||||
JSON with playlist, playlist_version, and hashed_quickconnect
|
||||
"""
|
||||
try:
|
||||
import bcrypt
|
||||
|
||||
hostname = request.args.get('hostname')
|
||||
quickconnect_code = request.args.get('quickconnect_code')
|
||||
|
||||
if not hostname or not quickconnect_code:
|
||||
return jsonify({
|
||||
'error': 'hostname and quickconnect_code are required',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 400
|
||||
|
||||
# Find player by hostname and validate quickconnect
|
||||
player = Player.query.filter_by(hostname=hostname).first()
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Player not found with hostname: {hostname}')
|
||||
return jsonify({
|
||||
'error': 'Player not found',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 404
|
||||
|
||||
# Validate quickconnect code
|
||||
if not player.quickconnect_code:
|
||||
log_action('warning', f'Player {hostname} has no quickconnect code set')
|
||||
return jsonify({
|
||||
'error': 'Quickconnect not configured',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 403
|
||||
|
||||
# Check if quickconnect matches (using bcrypt verification)
|
||||
if not player.check_quickconnect_code(quickconnect_code):
|
||||
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
|
||||
return jsonify({
|
||||
'error': 'Invalid quickconnect code',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 403
|
||||
|
||||
# Get playlist (with caching)
|
||||
playlist = get_cached_playlist(player.id)
|
||||
|
||||
# Update player's last seen timestamp and status
|
||||
player.last_seen = datetime.utcnow()
|
||||
player.status = 'online'
|
||||
db.session.commit()
|
||||
|
||||
# Get playlist version from the assigned playlist
|
||||
playlist_version = 1
|
||||
if player.playlist_id:
|
||||
from app.models import Playlist
|
||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
||||
if assigned_playlist:
|
||||
playlist_version = assigned_playlist.version
|
||||
|
||||
# Hash the quickconnect code for validation on client side
|
||||
hashed_quickconnect = bcrypt.hashpw(
|
||||
quickconnect_code.encode('utf-8'),
|
||||
bcrypt.gensalt()
|
||||
).decode('utf-8')
|
||||
|
||||
log_action('info', f'Playlist fetched for player: {player.name} ({hostname})')
|
||||
|
||||
return jsonify({
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'playlist_id': player.playlist_id,
|
||||
'playlist_version': playlist_version,
|
||||
'playlist': playlist,
|
||||
'hashed_quickconnect': hashed_quickconnect,
|
||||
'count': len(playlist)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting playlist: {str(e)}')
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=300, window=60)
|
||||
@verify_player_auth
|
||||
def get_player_playlist(player_id: int):
|
||||
"""Get playlist for a specific player.
|
||||
|
||||
Requires player authentication via Bearer token.
|
||||
"""
|
||||
try:
|
||||
# Verify the authenticated player matches the requested player_id
|
||||
if request.player.id != player_id:
|
||||
return jsonify({'error': 'Unauthorized access to this player'}), 403
|
||||
|
||||
player = request.player
|
||||
|
||||
# Get playlist (with caching)
|
||||
playlist = get_cached_playlist(player_id)
|
||||
|
||||
# Update player's last seen timestamp
|
||||
player.last_seen = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Get playlist version from the assigned playlist
|
||||
playlist_version = 1
|
||||
if player.playlist_id:
|
||||
from app.models import Playlist
|
||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
||||
if assigned_playlist:
|
||||
playlist_version = assigned_playlist.version
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'player_name': player.name,
|
||||
'playlist_id': player.playlist_id,
|
||||
'playlist_version': playlist_version,
|
||||
'playlist': playlist,
|
||||
'count': len(playlist)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting playlist for player {player_id}: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/playlist-version/<int:player_id>', methods=['GET'])
|
||||
@verify_player_auth
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def get_playlist_version(player_id: int):
|
||||
"""Get current playlist version for a player.
|
||||
|
||||
Lightweight endpoint for players to check if playlist needs updating.
|
||||
Requires player authentication via Bearer token.
|
||||
"""
|
||||
try:
|
||||
# Verify the authenticated player matches the requested player_id
|
||||
if request.player.id != player_id:
|
||||
return jsonify({'error': 'Unauthorized access to this player'}), 403
|
||||
|
||||
player = request.player
|
||||
|
||||
# Update last seen
|
||||
player.last_seen = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'playlist_version': player.playlist_version,
|
||||
'content_count': Content.query.filter_by(player_id=player_id).count()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting playlist version for player {player_id}: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
"""Get cached playlist for a player based on assigned playlist."""
|
||||
from flask import url_for
|
||||
from app.models import Playlist
|
||||
|
||||
player = Player.query.get(player_id)
|
||||
if not player or not player.playlist_id:
|
||||
return []
|
||||
|
||||
# Get the playlist assigned to this player
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
if not playlist:
|
||||
return []
|
||||
|
||||
# Get content from playlist (ordered)
|
||||
content_list = playlist.get_content_ordered()
|
||||
|
||||
# Build playlist response
|
||||
playlist_data = []
|
||||
for idx, content in enumerate(content_list, start=1):
|
||||
# Generate full URL for content.
|
||||
# request.script_root holds the X-Script-Name prefix set by the umbrella
|
||||
# nginx (e.g. '/digiserver'), so the URL is correct whether the app runs
|
||||
# standalone or behind the portal reverse proxy.
|
||||
from flask import request as current_request
|
||||
server_base = current_request.host_url.rstrip('/')
|
||||
script_root = current_request.script_root.rstrip('/')
|
||||
content_url = f"{server_base}{script_root}/static/uploads/{content.filename}"
|
||||
|
||||
playlist_data.append({
|
||||
'id': content.id,
|
||||
'file_name': content.filename, # Player expects 'file_name' not 'filename'
|
||||
'type': content.content_type,
|
||||
'duration': content._playlist_duration or content.duration or 10,
|
||||
'position': content._playlist_position or idx,
|
||||
'url': content_url, # Full URL for downloads
|
||||
'description': content.description,
|
||||
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
|
||||
})
|
||||
|
||||
return playlist_data
|
||||
|
||||
|
||||
@api_bp.route('/player-feedback', methods=['POST'])
|
||||
@rate_limit(max_requests=600, window=60)
|
||||
def receive_player_feedback():
|
||||
"""Receive feedback/status updates from players (Kivy player compatible).
|
||||
|
||||
Expected JSON payload:
|
||||
{
|
||||
"player_name": "Screen1",
|
||||
"quickconnect_code": "ABC123",
|
||||
"status": "playing|paused|error|restarting",
|
||||
"message": "Status message",
|
||||
"playlist_version": 1,
|
||||
"error_details": "Optional error details",
|
||||
"timestamp": "ISO timestamp"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
if not data:
|
||||
log_action('warning', 'Player feedback received with no data')
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
player_name = data.get('player_name')
|
||||
hostname = data.get('hostname') # Also accept hostname
|
||||
quickconnect_code = data.get('quickconnect_code')
|
||||
|
||||
# Find player by hostname first (more reliable), then by name
|
||||
player = None
|
||||
if hostname:
|
||||
player = Player.query.filter_by(hostname=hostname).first()
|
||||
if not player and player_name:
|
||||
player = Player.query.filter_by(name=player_name).first()
|
||||
|
||||
# If player not found and no credentials provided, try to infer from IP and recent auth
|
||||
if not player and (not quickconnect_code or (not player_name and not hostname)):
|
||||
# Try to find player by recent authentication from same IP
|
||||
client_ip = request.remote_addr
|
||||
# Look for players with matching IP in recent activity (last 5 minutes)
|
||||
recent_time = datetime.utcnow() - timedelta(minutes=5)
|
||||
possible_player = Player.query.filter(
|
||||
Player.last_seen >= recent_time
|
||||
).order_by(Player.last_seen.desc()).first()
|
||||
|
||||
if possible_player:
|
||||
player = possible_player
|
||||
log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity')
|
||||
|
||||
# Still require quickconnect validation if provided
|
||||
if not player:
|
||||
if not player_name and not hostname:
|
||||
log_action('warning', f'Player feedback missing required fields. Data: {data}')
|
||||
return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400
|
||||
else:
|
||||
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
|
||||
return jsonify({'error': 'Player not found'}), 404
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
|
||||
return jsonify({'error': 'Player not found'}), 404
|
||||
|
||||
# Validate quickconnect code if provided (using bcrypt verification)
|
||||
if quickconnect_code and not player.check_quickconnect_code(quickconnect_code):
|
||||
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
|
||||
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
||||
|
||||
# Create feedback record
|
||||
status = data.get('status', 'unknown')
|
||||
message = data.get('message', '')
|
||||
error_details = data.get('error_details')
|
||||
|
||||
feedback = PlayerFeedback(
|
||||
player_id=player.id,
|
||||
status=status,
|
||||
message=message,
|
||||
error=error_details
|
||||
)
|
||||
db.session.add(feedback)
|
||||
|
||||
# Update player's last seen and status
|
||||
player.last_seen = datetime.utcnow()
|
||||
player.status = status
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Feedback received',
|
||||
'player_id': player.id
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error receiving player feedback: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/player-status/<int:player_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def get_player_status(player_id: int):
|
||||
"""Get current status of a player (public endpoint for monitoring)."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Get latest feedback
|
||||
latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
|
||||
.order_by(PlayerFeedback.timestamp.desc())\
|
||||
.first()
|
||||
|
||||
# Calculate if player is online (seen in last 5 minutes)
|
||||
is_online = False
|
||||
if player.last_seen:
|
||||
is_online = (datetime.utcnow() - player.last_seen).total_seconds() < 300
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'name': player.name,
|
||||
'location': player.location,
|
||||
'group_id': player.group_id,
|
||||
'status': player.status,
|
||||
'is_online': is_online,
|
||||
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
|
||||
'latest_feedback': {
|
||||
'status': latest_feedback.status,
|
||||
'message': latest_feedback.message,
|
||||
'timestamp': latest_feedback.timestamp.isoformat()
|
||||
} if latest_feedback else None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting player status: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/upload-progress/<upload_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=120, window=60)
|
||||
def get_upload_progress(upload_id: str):
|
||||
"""Get progress of a file upload."""
|
||||
from app.utils.uploads import get_upload_progress as get_progress
|
||||
|
||||
try:
|
||||
progress = get_progress(upload_id)
|
||||
return jsonify(progress)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting upload progress: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/system-info', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def system_info():
|
||||
"""Get system information and statistics."""
|
||||
try:
|
||||
# Get counts
|
||||
total_players = Player.query.count()
|
||||
total_groups = Group.query.count()
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Count online players (seen in last 5 minutes)
|
||||
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
|
||||
online_players = Player.query.filter(Player.last_seen >= five_min_ago).count()
|
||||
|
||||
# Get recent logs count
|
||||
recent_logs = ServerLog.query.filter(
|
||||
ServerLog.timestamp >= datetime.utcnow() - timedelta(hours=24)
|
||||
).count()
|
||||
|
||||
return jsonify({
|
||||
'players': {
|
||||
'total': total_players,
|
||||
'online': online_players
|
||||
},
|
||||
'groups': total_groups,
|
||||
'content': total_content,
|
||||
'logs_24h': recent_logs,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting system info: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
|
||||
# DEPRECATED: Groups functionality has been archived
|
||||
# @api_bp.route('/groups', methods=['GET'])
|
||||
# @rate_limit(max_requests=60, window=60)
|
||||
# def list_groups():
|
||||
# """List all groups with basic information."""
|
||||
# try:
|
||||
# groups = Group.query.order_by(Group.name).all()
|
||||
#
|
||||
# groups_data = []
|
||||
# for group in groups:
|
||||
# groups_data.append({
|
||||
# 'id': group.id,
|
||||
# 'name': group.name,
|
||||
# 'description': group.description,
|
||||
# 'player_count': group.players.count(),
|
||||
# 'content_count': group.contents.count()
|
||||
# })
|
||||
#
|
||||
# return jsonify({
|
||||
# 'groups': groups_data,
|
||||
# 'count': len(groups_data)
|
||||
# })
|
||||
#
|
||||
# except Exception as e:
|
||||
# log_action('error', f'Error listing groups: {str(e)}')
|
||||
# return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/content', methods=['GET'])
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def list_content():
|
||||
"""List all content with basic information."""
|
||||
try:
|
||||
contents = Content.query.order_by(Content.uploaded_at.desc()).all()
|
||||
|
||||
content_data = []
|
||||
for content in contents:
|
||||
content_data.append({
|
||||
'id': content.id,
|
||||
'filename': content.filename,
|
||||
'type': content.content_type,
|
||||
'duration': content.duration,
|
||||
'size': content.file_size,
|
||||
'uploaded_at': content.uploaded_at.isoformat(),
|
||||
'group_count': content.groups.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'content': content_data,
|
||||
'count': len(content_data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error listing content: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/logs', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def get_logs():
|
||||
"""Get recent server logs.
|
||||
|
||||
Query parameters:
|
||||
limit: Number of logs to return (default: 50, max: 200)
|
||||
level: Filter by log level (info, warning, error)
|
||||
since: ISO timestamp to get logs since that time
|
||||
"""
|
||||
try:
|
||||
# Get query parameters
|
||||
limit = min(request.args.get('limit', 50, type=int), 200)
|
||||
level = request.args.get('level')
|
||||
since_str = request.args.get('since')
|
||||
|
||||
# Build query
|
||||
query = ServerLog.query
|
||||
|
||||
if level:
|
||||
query = query.filter_by(level=level)
|
||||
|
||||
if since_str:
|
||||
try:
|
||||
since = datetime.fromisoformat(since_str)
|
||||
query = query.filter(ServerLog.timestamp >= since)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid since timestamp format'}), 400
|
||||
|
||||
# Get logs
|
||||
logs = query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
|
||||
|
||||
logs_data = []
|
||||
for log in logs:
|
||||
logs_data.append({
|
||||
'id': log.id,
|
||||
'level': log.level,
|
||||
'message': log.message,
|
||||
'timestamp': log.timestamp.isoformat()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'logs': logs_data,
|
||||
'count': len(logs_data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting logs: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/player-edit-media', methods=['POST'])
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
@verify_player_auth
|
||||
def receive_edited_media():
|
||||
"""Receive edited media from player.
|
||||
|
||||
Expected multipart/form-data:
|
||||
- image_file: The edited image file
|
||||
- metadata: JSON string with metadata
|
||||
|
||||
Metadata JSON structure:
|
||||
{
|
||||
"time_of_modification": "ISO timestamp",
|
||||
"original_name": "original_file.jpg",
|
||||
"new_name": "original_file_v1.jpg",
|
||||
"version": 1,
|
||||
"user": "player_user"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
player = request.player
|
||||
|
||||
# Check if file is present
|
||||
if 'image_file' not in request.files:
|
||||
return jsonify({'error': 'No image file provided'}), 400
|
||||
|
||||
file = request.files['image_file']
|
||||
if file.filename == '':
|
||||
return jsonify({'error': 'No file selected'}), 400
|
||||
|
||||
# Get metadata
|
||||
import json
|
||||
metadata_str = request.form.get('metadata')
|
||||
if not metadata_str:
|
||||
return jsonify({'error': 'No metadata provided'}), 400
|
||||
|
||||
try:
|
||||
metadata = json.loads(metadata_str)
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({'error': 'Invalid metadata JSON'}), 400
|
||||
|
||||
# Validate required metadata fields
|
||||
required_fields = ['time_of_modification', 'original_name', 'new_name', 'version']
|
||||
for field in required_fields:
|
||||
if field not in metadata:
|
||||
return jsonify({'error': f'Missing required field: {field}'}), 400
|
||||
|
||||
# Import required modules
|
||||
import os
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models.player_edit import PlayerEdit
|
||||
|
||||
# Find the original content by filename
|
||||
original_name = metadata['original_name']
|
||||
content = Content.query.filter_by(filename=original_name).first()
|
||||
|
||||
if not content:
|
||||
log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}')
|
||||
return jsonify({'error': f'Original content not found: {original_name}'}), 404
|
||||
|
||||
# Create versioned folder structure: edited_media/<content_id>/
|
||||
base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads')
|
||||
edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id))
|
||||
os.makedirs(edited_media_dir, exist_ok=True)
|
||||
|
||||
# Save the edited file with version suffix
|
||||
version = metadata['version']
|
||||
new_filename = metadata['new_name']
|
||||
edited_file_path = os.path.join(edited_media_dir, new_filename)
|
||||
file.save(edited_file_path)
|
||||
|
||||
# Save metadata JSON file
|
||||
metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json"
|
||||
metadata_path = os.path.join(edited_media_dir, metadata_filename)
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
# Update the content record to reference the edited version path
|
||||
# Keep original filename unchanged, point to edited_media folder
|
||||
old_filename = content.filename
|
||||
content.filename = f"edited_media/{content.id}/{new_filename}"
|
||||
|
||||
# Create edit record
|
||||
time_of_mod = None
|
||||
if metadata.get('time_of_modification'):
|
||||
try:
|
||||
time_of_mod = datetime.fromisoformat(metadata['time_of_modification'].replace('Z', '+00:00'))
|
||||
except:
|
||||
time_of_mod = datetime.utcnow()
|
||||
|
||||
# Auto-create PlayerUser record if user code is provided
|
||||
user_code = metadata.get('user_card_data')
|
||||
log_action('debug', f'Metadata user code: {user_code}')
|
||||
if user_code:
|
||||
from app.models.player_user import PlayerUser
|
||||
existing_user = PlayerUser.query.filter_by(user_code=user_code).first()
|
||||
if not existing_user:
|
||||
new_user = PlayerUser(user_code=user_code)
|
||||
db.session.add(new_user)
|
||||
log_action('info', f'Auto-created PlayerUser record for code: {user_code}')
|
||||
else:
|
||||
log_action('debug', f'PlayerUser already exists for code: {user_code}')
|
||||
else:
|
||||
log_action('debug', 'No user code in metadata')
|
||||
|
||||
edit_record = PlayerEdit(
|
||||
player_id=player.id,
|
||||
content_id=content.id,
|
||||
original_name=original_name,
|
||||
new_name=new_filename,
|
||||
version=version,
|
||||
user=user_code,
|
||||
time_of_modification=time_of_mod,
|
||||
metadata_path=metadata_path,
|
||||
edited_file_path=edited_file_path
|
||||
)
|
||||
db.session.add(edit_record)
|
||||
|
||||
# Update playlist version to force player refresh
|
||||
playlist = None
|
||||
if player.playlist_id:
|
||||
from app.models.playlist import Playlist
|
||||
playlist = db.session.get(Playlist, player.playlist_id)
|
||||
if playlist:
|
||||
playlist.version += 1
|
||||
|
||||
# Clear playlist cache
|
||||
cache.delete_memoized(get_cached_playlist, player.id)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Player {player.name} uploaded edited media: {old_filename} -> {new_filename} (v{version})')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Edited media received and processed',
|
||||
'edit_id': edit_record.id,
|
||||
'version': version,
|
||||
'old_filename': old_filename,
|
||||
'new_filename': new_filename,
|
||||
'new_playlist_version': playlist.version if playlist else None
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error receiving edited media: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.errorhandler(404)
|
||||
def api_not_found(error):
|
||||
"""Handle 404 errors in API."""
|
||||
return jsonify({'error': 'Endpoint not found'}), 404
|
||||
|
||||
|
||||
@api_bp.errorhandler(405)
|
||||
def method_not_allowed(error):
|
||||
"""Handle 405 errors in API."""
|
||||
return jsonify({'error': 'Method not allowed'}), 405
|
||||
|
||||
|
||||
@api_bp.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""Handle 500 errors in API."""
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Authentication Blueprint - Login, Logout
|
||||
User management is handled exclusively by the Enterprise Digital Platform portal.
|
||||
Direct registration and local user creation are disabled.
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.extensions import db, bcrypt, login_manager
|
||||
from app.models import User
|
||||
from app.utils.logger import log_action
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""
|
||||
Login handler.
|
||||
When accessed through the portal nginx gateway the portal_sso.py before_request
|
||||
hook already logs the user in and redirects to the dashboard — this handler is
|
||||
only reached if someone accesses DigiServer directly (bypassing the gateway).
|
||||
In that case we redirect them to the portal login page.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
# If there are X-Auth-Username headers the SSO hook should have handled this
|
||||
# already. If we still end up here the user has no portal session — send them
|
||||
# to the portal login so they can authenticate through the proper gateway.
|
||||
portal_login = current_app.config.get('PORTAL_LOGIN_URL', '/login')
|
||||
return redirect(portal_login)
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout"""
|
||||
username = current_user.username
|
||||
logout_user()
|
||||
log_action('info', f'User {username} logged out')
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
"""
|
||||
Self-registration is disabled — users are managed exclusively by the portal.
|
||||
Redirect to the portal login page.
|
||||
"""
|
||||
portal_login = current_app.config.get('PORTAL_LOGIN_URL', '/login')
|
||||
return redirect(portal_login)
|
||||
|
||||
|
||||
@auth_bp.route('/change-password', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""
|
||||
Password changes are managed by the portal.
|
||||
Passwords for portal-managed users are randomly generated and not user-facing.
|
||||
"""
|
||||
flash('Password management is handled through the Enterprise Digital Platform portal.', 'info')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Content blueprint for media upload and management."""
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app, send_from_directory)
|
||||
from flask_login import login_required
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Content, Group
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.uploads import (
|
||||
save_uploaded_file,
|
||||
process_video_file,
|
||||
process_pdf_file,
|
||||
get_upload_progress,
|
||||
set_upload_progress
|
||||
)
|
||||
|
||||
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
||||
|
||||
|
||||
# In-memory storage for upload progress (for simple demo; use Redis in production)
|
||||
upload_progress = {}
|
||||
|
||||
|
||||
@content_bp.route('/')
|
||||
@login_required
|
||||
def content_list():
|
||||
"""Display list of all content."""
|
||||
try:
|
||||
# Get all unique content files (by filename)
|
||||
from sqlalchemy import func
|
||||
|
||||
# Get content with player information
|
||||
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
|
||||
|
||||
# Group content by filename to show which players have each file
|
||||
content_map = {}
|
||||
for content in contents:
|
||||
if content.filename not in content_map:
|
||||
content_map[content.filename] = {
|
||||
'content': content,
|
||||
'players': [],
|
||||
'groups': []
|
||||
}
|
||||
|
||||
# Add player info if assigned to a player
|
||||
if content.player_id:
|
||||
from app.models import Player
|
||||
player = Player.query.get(content.player_id)
|
||||
if player:
|
||||
content_map[content.filename]['players'].append({
|
||||
'id': player.id,
|
||||
'name': player.name,
|
||||
'group': player.group.name if player.group else None
|
||||
})
|
||||
|
||||
# Convert to list for template
|
||||
content_list = []
|
||||
for filename, data in content_map.items():
|
||||
content_list.append({
|
||||
'filename': filename,
|
||||
'content_type': data['content'].content_type,
|
||||
'duration': data['content'].duration,
|
||||
'file_size': data['content'].file_size_mb,
|
||||
'uploaded_at': data['content'].uploaded_at,
|
||||
'players': data['players'],
|
||||
'player_count': len(data['players'])
|
||||
})
|
||||
|
||||
# Sort by upload date
|
||||
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
|
||||
|
||||
return render_template('content/content_list.html',
|
||||
content_list=content_list)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading content list: {str(e)}')
|
||||
flash('Error loading content list.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@content_bp.route('/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload_content():
|
||||
"""Upload new content."""
|
||||
if request.method == 'GET':
|
||||
# Get parameters for return URL and pre-selection
|
||||
player_id = request.args.get('player_id', type=int)
|
||||
return_url = request.args.get('return_url', url_for('content.content_list'))
|
||||
|
||||
# Get all players for selection
|
||||
from app.models import Player
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
|
||||
return render_template('content/upload_content.html',
|
||||
players=players,
|
||||
selected_player_id=player_id,
|
||||
return_url=return_url)
|
||||
|
||||
try:
|
||||
# Get form data
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
media_type = request.form.get('media_type', 'image')
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
session_id = request.form.get('session_id', os.urandom(8).hex())
|
||||
return_url = request.form.get('return_url', url_for('content.content_list'))
|
||||
|
||||
# Get files
|
||||
files = request.files.getlist('files')
|
||||
|
||||
if not files or files[0].filename == '':
|
||||
flash('No files provided.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
if not player_id:
|
||||
flash('Please select a player.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
# Initialize progress tracking using shared utility
|
||||
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
|
||||
|
||||
# Process each file
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
processed_count = 0
|
||||
total_files = len(files)
|
||||
|
||||
for idx, file in enumerate(files):
|
||||
if file.filename == '':
|
||||
continue
|
||||
|
||||
# Update progress
|
||||
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
|
||||
set_upload_progress(session_id, progress_pct,
|
||||
f'Processing file {idx + 1} of {total_files}...', 'processing')
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
|
||||
# Save file
|
||||
file.save(filepath)
|
||||
|
||||
# Determine content type
|
||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
||||
content_type = 'image'
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
||||
content_type = 'video'
|
||||
# Process video (convert to Raspberry Pi optimized format)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
|
||||
success, message = process_video_file(filepath, session_id)
|
||||
if not success:
|
||||
log_action('error', f'Video optimization failed: {message}')
|
||||
continue # Skip this file and move to next
|
||||
elif file_ext == 'pdf':
|
||||
content_type = 'pdf'
|
||||
# Process PDF (convert to images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PDF {idx + 1}...', 'processing')
|
||||
# process_pdf_file(filepath, session_id)
|
||||
elif file_ext in ['ppt', 'pptx']:
|
||||
content_type = 'presentation'
|
||||
# Process presentation (convert to PDF then images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PowerPoint {idx + 1}...', 'processing')
|
||||
# This would call pptx_converter utility
|
||||
else:
|
||||
content_type = 'other'
|
||||
|
||||
# Create content record linked to player
|
||||
from app.models import Player
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
new_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(filepath),
|
||||
player_id=player_id
|
||||
)
|
||||
db.session.add(new_content)
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
|
||||
|
||||
processed_count += 1
|
||||
|
||||
# Commit all changes
|
||||
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
|
||||
db.session.commit()
|
||||
|
||||
# Complete
|
||||
set_upload_progress(session_id, 100,
|
||||
f'Successfully uploaded {processed_count} file(s)!', 'complete')
|
||||
|
||||
# Clear all playlist caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
|
||||
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
|
||||
|
||||
return redirect(return_url)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
|
||||
# Update progress to error state
|
||||
if 'session_id' in locals():
|
||||
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
|
||||
|
||||
log_action('error', f'Error uploading content: {str(e)}')
|
||||
flash('Error uploading content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_content(content_id: int):
|
||||
"""Edit content metadata."""
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('content/edit_content.html', content=content)
|
||||
|
||||
try:
|
||||
duration = request.form.get('duration', type=int)
|
||||
description = request.form.get('description', '').strip()
|
||||
|
||||
# Update content
|
||||
if duration is not None:
|
||||
content.duration = duration
|
||||
content.description = description or None
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
|
||||
flash(f'Content "{content.filename}" updated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating content: {str(e)}')
|
||||
flash('Error updating content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.edit_content', content_id=content_id))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_content(content_id: int):
|
||||
"""Delete content and associated file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
filename = content.filename
|
||||
|
||||
# Delete file from disk
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
|
||||
flash(f'Content "{filename}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content: {str(e)}')
|
||||
flash('Error deleting content. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
|
||||
@content_bp.route('/delete-by-filename', methods=['POST'])
|
||||
@login_required
|
||||
def delete_by_filename():
|
||||
"""Delete all content entries with a specific filename."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
filename = data.get('filename')
|
||||
|
||||
if not filename:
|
||||
return jsonify({'success': False, 'message': 'No filename provided'}), 400
|
||||
|
||||
# Find all content entries with this filename
|
||||
contents = Content.query.filter_by(filename=filename).all()
|
||||
|
||||
if not contents:
|
||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
||||
|
||||
deleted_count = len(contents)
|
||||
|
||||
# Delete file from disk (only once)
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
log_action('info', f'Deleted file from disk: {filename}')
|
||||
|
||||
# Delete all database entries
|
||||
for content in contents:
|
||||
db.session.delete(content)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Content deleted from {deleted_count} playlist(s)',
|
||||
'deleted_count': deleted_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content by filename: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/bulk/delete', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_delete_content():
|
||||
"""Delete multiple content items at once."""
|
||||
try:
|
||||
content_ids = request.json.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'error': 'No content selected'}), 400
|
||||
|
||||
# Delete content
|
||||
deleted_count = 0
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(content_id)
|
||||
if content:
|
||||
# Delete file
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Bulk deleted {deleted_count} content items')
|
||||
return jsonify({'success': True, 'deleted': deleted_count})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error bulk deleting content: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/upload-progress/<upload_id>')
|
||||
@login_required
|
||||
def upload_progress_status(upload_id: str):
|
||||
"""Get upload progress for a specific upload."""
|
||||
progress = get_upload_progress(upload_id)
|
||||
return jsonify(progress)
|
||||
|
||||
|
||||
@content_bp.route('/preview/<int:content_id>')
|
||||
@login_required
|
||||
def preview_content(content_id: int):
|
||||
"""Preview content in browser."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
# Serve file from uploads folder
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=False
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error previewing content: {str(e)}')
|
||||
return "Error loading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/download')
|
||||
@login_required
|
||||
def download_content(content_id: int):
|
||||
"""Download content file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
log_action('info', f'Content "{content.filename}" downloaded')
|
||||
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=True
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error downloading content: {str(e)}')
|
||||
return "Error downloading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/statistics')
|
||||
@login_required
|
||||
def content_statistics():
|
||||
"""Get content statistics."""
|
||||
try:
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Count by type
|
||||
type_counts = {}
|
||||
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
|
||||
count = Content.query.filter_by(content_type=content_type).count()
|
||||
type_counts[content_type] = count
|
||||
|
||||
# Calculate total storage
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
total_size = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
|
||||
return jsonify({
|
||||
'total': total_content,
|
||||
'by_type': type_counts,
|
||||
'total_size_mb': round(total_size / (1024 * 1024), 2)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content statistics: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/check-duplicates')
|
||||
@login_required
|
||||
def check_duplicates():
|
||||
"""Check for duplicate filenames."""
|
||||
try:
|
||||
# Get all filenames
|
||||
all_content = Content.query.all()
|
||||
filename_counts = {}
|
||||
|
||||
for content in all_content:
|
||||
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
|
||||
|
||||
# Find duplicates
|
||||
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
|
||||
|
||||
return jsonify({
|
||||
'has_duplicates': len(duplicates) > 0,
|
||||
'duplicates': duplicates
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error checking duplicates: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/groups')
|
||||
@login_required
|
||||
def content_groups_info(content_id: int):
|
||||
"""Get groups that contain this content."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
groups_data = []
|
||||
for group in content.groups:
|
||||
groups_data.append({
|
||||
'id': group.id,
|
||||
'name': group.name,
|
||||
'description': group.description,
|
||||
'player_count': group.players.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'content_id': content_id,
|
||||
'filename': content.filename,
|
||||
'groups': groups_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content groups: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Internal blueprint — service-to-service endpoints.
|
||||
|
||||
These routes are NOT exposed through nginx to the public internet.
|
||||
They are called from the portal when it needs to pre-provision users.
|
||||
Protected with a shared secret in the X-Internal-Token header.
|
||||
"""
|
||||
import os
|
||||
import secrets as _secrets
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
|
||||
from app.extensions import db, bcrypt
|
||||
|
||||
internal_bp = Blueprint('internal', __name__, url_prefix='/internal')
|
||||
|
||||
|
||||
def _check_token():
|
||||
"""Return True if the request carries a valid internal sync token."""
|
||||
expected = current_app.config.get('INTERNAL_SYNC_SECRET', '')
|
||||
provided = request.headers.get('X-Internal-Token', '')
|
||||
if not expected or not provided:
|
||||
return False
|
||||
# Constant-time comparison to prevent timing attacks
|
||||
return _secrets.compare_digest(expected, provided)
|
||||
|
||||
|
||||
@internal_bp.route('/sync-user', methods=['POST'])
|
||||
def sync_user():
|
||||
"""
|
||||
Create or update a portal-managed user in DigiServer's local DB.
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"username": "<portal username>",
|
||||
"role": "admin" | "user"
|
||||
}
|
||||
"""
|
||||
if not _check_token():
|
||||
return jsonify({'error': 'forbidden'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
role_raw = (data.get('role') or 'user').strip()
|
||||
role = 'admin' if role_raw == 'admin' else 'user'
|
||||
|
||||
if not username:
|
||||
return jsonify({'error': 'username required'}), 400
|
||||
|
||||
from app.models.user import User
|
||||
try:
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user:
|
||||
if user.role != role:
|
||||
user.role = role
|
||||
db.session.commit()
|
||||
status = 'updated'
|
||||
else:
|
||||
pw_hash = bcrypt.generate_password_hash(
|
||||
_secrets.token_hex(32)
|
||||
).decode('utf-8')
|
||||
user = User(username=username, password=pw_hash, role=role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
status = 'created'
|
||||
|
||||
return jsonify({'status': status, 'username': username, 'role': role}), 200
|
||||
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
current_app.logger.error('sync-user error: %s', exc)
|
||||
return jsonify({'error': 'internal error'}), 500
|
||||
|
||||
|
||||
@internal_bp.route('/users', methods=['GET'])
|
||||
def list_users():
|
||||
"""Return a list of portal-managed users (for portal Settings UI)."""
|
||||
if not _check_token():
|
||||
return jsonify({'error': 'forbidden'}), 403
|
||||
|
||||
from app.models.user import User
|
||||
users = User.query.order_by(User.username).all()
|
||||
return jsonify([
|
||||
{
|
||||
'username': u.username,
|
||||
'role': u.role,
|
||||
'last_login': u.last_login.isoformat() if u.last_login else None,
|
||||
}
|
||||
for u in users
|
||||
]), 200
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Main Blueprint - Dashboard and Home Routes
|
||||
"""
|
||||
from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db, cache
|
||||
from app.models.player import Player
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.content import Content
|
||||
from app.utils.logger import get_recent_logs
|
||||
import os
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
@cache.cached(timeout=60, unless=lambda: current_user.role != 'viewer')
|
||||
def dashboard():
|
||||
"""Main dashboard page"""
|
||||
# Get statistics
|
||||
total_players = Player.query.count()
|
||||
total_playlists = Playlist.query.count()
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Calculate storage usage
|
||||
upload_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads')
|
||||
storage_mb = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for filename in os.listdir(upload_folder):
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
if os.path.isfile(filepath):
|
||||
storage_mb += os.path.getsize(filepath)
|
||||
storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB
|
||||
|
||||
server_logs = get_recent_logs(20)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
total_players=total_players,
|
||||
total_playlists=total_playlists,
|
||||
total_content=total_content,
|
||||
storage_mb=storage_mb,
|
||||
recent_logs=server_logs
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route('/health')
|
||||
def health():
|
||||
"""Health check endpoint"""
|
||||
from flask import jsonify
|
||||
import os
|
||||
|
||||
try:
|
||||
# Check database
|
||||
db.session.execute(db.text('SELECT 1'))
|
||||
|
||||
# Check disk space
|
||||
upload_folder = os.path.join(
|
||||
main_bp.root_path or '.',
|
||||
'static/uploads'
|
||||
)
|
||||
|
||||
if os.path.exists(upload_folder):
|
||||
stat = os.statvfs(upload_folder)
|
||||
free_space_gb = (stat.f_bavail * stat.f_frsize) / (1024**3)
|
||||
else:
|
||||
free_space_gb = 0
|
||||
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'database': 'ok',
|
||||
'disk_space_gb': round(free_space_gb, 2)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}), 500
|
||||
@@ -0,0 +1,612 @@
|
||||
"""Players blueprint for player management and display."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required
|
||||
from werkzeug.security import generate_password_hash
|
||||
import secrets
|
||||
from typing import Optional, List
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Content, PlayerFeedback, Playlist
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.group_player_management import get_player_status_info
|
||||
|
||||
players_bp = Blueprint('players', __name__, url_prefix='/players')
|
||||
|
||||
|
||||
@players_bp.route('/')
|
||||
@players_bp.route('/list')
|
||||
@login_required
|
||||
def list():
|
||||
"""Display list of all players."""
|
||||
try:
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
playlists = Playlist.query.all()
|
||||
|
||||
# Get player status for each player
|
||||
player_statuses = {}
|
||||
for player in players:
|
||||
status_info = get_player_status_info(player.id)
|
||||
player_statuses[player.id] = status_info
|
||||
|
||||
return render_template('players/players_list.html',
|
||||
players=players,
|
||||
playlists=playlists,
|
||||
player_statuses=player_statuses)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading players list: {str(e)}')
|
||||
flash('Error loading players list.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@players_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_player():
|
||||
"""Add a new player."""
|
||||
if request.method == 'GET':
|
||||
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
|
||||
return render_template('players/add_player.html', playlists=playlists)
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
hostname = request.form.get('hostname', '').strip()
|
||||
location = request.form.get('location', '').strip()
|
||||
password = request.form.get('password', '').strip()
|
||||
quickconnect_code = request.form.get('quickconnect_code', '').strip()
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
playlist_id = request.form.get('playlist_id', '').strip()
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
flash('Player name must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('players.add_player'))
|
||||
|
||||
if not hostname or len(hostname) < 3:
|
||||
flash('Hostname must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('players.add_player'))
|
||||
|
||||
# Check if hostname already exists
|
||||
existing_player = Player.query.filter_by(hostname=hostname).first()
|
||||
if existing_player:
|
||||
flash(f'A player with hostname "{hostname}" already exists.', 'warning')
|
||||
return redirect(url_for('players.add_player'))
|
||||
|
||||
if not quickconnect_code:
|
||||
flash('Quick Connect Code is required.', 'warning')
|
||||
return redirect(url_for('players.add_player'))
|
||||
|
||||
# Generate unique auth code
|
||||
auth_code = secrets.token_urlsafe(32)
|
||||
|
||||
# Create player
|
||||
new_player = Player(
|
||||
name=name,
|
||||
hostname=hostname,
|
||||
location=location or None,
|
||||
auth_code=auth_code,
|
||||
orientation=orientation,
|
||||
playlist_id=int(playlist_id) if playlist_id else None
|
||||
)
|
||||
|
||||
# Set password if provided
|
||||
if password:
|
||||
new_player.set_password(password)
|
||||
else:
|
||||
# Use quickconnect code as default password
|
||||
new_player.set_password(quickconnect_code)
|
||||
|
||||
# Set quickconnect code
|
||||
new_player.set_quickconnect_code(quickconnect_code)
|
||||
|
||||
db.session.add(new_player)
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
||||
|
||||
# Flash detailed success message
|
||||
success_msg = f'''
|
||||
Player "{name}" created successfully!<br>
|
||||
<strong>Auth Code:</strong> {auth_code}<br>
|
||||
<strong>Hostname:</strong> {hostname}<br>
|
||||
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||
<small>Configure the player with these credentials in app_config.json</small>
|
||||
'''
|
||||
flash(success_msg, 'success')
|
||||
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error creating player: {str(e)}')
|
||||
flash('Error creating player. Please try again.', 'danger')
|
||||
return redirect(url_for('players.add_player'))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_player(player_id: int):
|
||||
"""Edit player details."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('players/edit_player.html', player=player)
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
location = request.form.get('location', '').strip()
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
flash('Player name must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('players.edit_player', player_id=player_id))
|
||||
|
||||
# Update player
|
||||
player.name = name
|
||||
player.location = location or None
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for this player
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
|
||||
log_action('info', f'Player "{name}" (ID: {player_id}) updated')
|
||||
flash(f'Player "{name}" updated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating player: {str(e)}')
|
||||
flash('Error updating player. Please try again.', 'danger')
|
||||
return redirect(url_for('players.edit_player', player_id=player_id))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_player(player_id: int):
|
||||
"""Delete a player."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
player_name = player.name
|
||||
|
||||
# Delete associated feedback
|
||||
PlayerFeedback.query.filter_by(player_id=player_id).delete()
|
||||
|
||||
db.session.delete(player)
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
|
||||
log_action('info', f'Player "{player_name}" (ID: {player_id}) deleted')
|
||||
flash(f'Player "{player_name}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting player: {str(e)}')
|
||||
flash('Error deleting player. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/regenerate-auth', methods=['POST'])
|
||||
@login_required
|
||||
def regenerate_auth_code(player_id: int):
|
||||
"""Regenerate authentication code for a player."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Generate new auth code
|
||||
new_auth_code = secrets.token_urlsafe(16)
|
||||
player.auth_code = new_auth_code
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Auth code regenerated for player "{player.name}" (ID: {player_id})')
|
||||
flash(f'New auth code for "{player.name}": {new_auth_code}', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error regenerating auth code: {str(e)}')
|
||||
flash('Error regenerating auth code. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>')
|
||||
@login_required
|
||||
def player_page(player_id: int):
|
||||
"""Redirect to manage player page (combined view)."""
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def manage_player(player_id: int):
|
||||
"""Manage player - edit credentials, assign playlist, view logs."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
try:
|
||||
if action == 'update_credentials':
|
||||
# Update player name, location, orientation, and authentication
|
||||
name = request.form.get('name', '').strip()
|
||||
location = request.form.get('location', '').strip()
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
hostname = request.form.get('hostname', '').strip()
|
||||
password = request.form.get('password', '').strip()
|
||||
quickconnect_code = request.form.get('quickconnect_code', '').strip()
|
||||
|
||||
if not name or len(name) < 3:
|
||||
flash('Player name must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
if not hostname or len(hostname) < 3:
|
||||
flash('Hostname must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
# Check if hostname is taken by another player
|
||||
if hostname != player.hostname:
|
||||
existing = Player.query.filter_by(hostname=hostname).first()
|
||||
if existing:
|
||||
flash(f'Hostname "{hostname}" is already in use by another player.', 'warning')
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
# Update basic info
|
||||
player.name = name
|
||||
player.hostname = hostname
|
||||
player.location = location or None
|
||||
player.orientation = orientation
|
||||
|
||||
# Update password if provided
|
||||
if password:
|
||||
player.set_password(password)
|
||||
log_action('info', f'Password updated for player "{name}"')
|
||||
|
||||
# Update quickconnect code if provided
|
||||
if quickconnect_code:
|
||||
player.set_quickconnect_code(quickconnect_code)
|
||||
log_action('info', f'QuickConnect code updated for player "{name}" to: {quickconnect_code}')
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Player "{name}" (hostname: {hostname}) credentials updated')
|
||||
flash(f'Player "{name}" updated successfully.', 'success')
|
||||
|
||||
elif action == 'assign_playlist':
|
||||
# Assign playlist to player
|
||||
playlist_id = request.form.get('playlist_id')
|
||||
|
||||
if playlist_id:
|
||||
playlist = Playlist.query.get(int(playlist_id))
|
||||
if playlist:
|
||||
player.playlist_id = int(playlist_id)
|
||||
db.session.commit()
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
log_action('info', f'Player "{player.name}" assigned to playlist "{playlist.name}"')
|
||||
flash(f'Player assigned to playlist "{playlist.name}".', 'success')
|
||||
else:
|
||||
flash('Invalid playlist selected.', 'warning')
|
||||
else:
|
||||
# Unassign playlist
|
||||
player.playlist_id = None
|
||||
db.session.commit()
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
log_action('info', f'Player "{player.name}" unassigned from playlist')
|
||||
flash('Player unassigned from playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error managing player: {str(e)}')
|
||||
flash('Error updating player. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
# GET request - show manage page
|
||||
playlists = Playlist.query.order_by(Playlist.name).all()
|
||||
|
||||
# Get player's current playlist
|
||||
current_playlist = None
|
||||
if player.playlist_id:
|
||||
current_playlist = Playlist.query.get(player.playlist_id)
|
||||
|
||||
# Get recent feedback/logs from player
|
||||
recent_logs = PlayerFeedback.query.filter_by(player_id=player_id)\
|
||||
.order_by(PlayerFeedback.timestamp.desc())\
|
||||
.limit(20)\
|
||||
.all()
|
||||
|
||||
# Get edited media history from player
|
||||
from app.models.player_edit import PlayerEdit
|
||||
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
|
||||
.order_by(PlayerEdit.created_at.desc())\
|
||||
.limit(20)\
|
||||
.all()
|
||||
|
||||
# Get player status
|
||||
status_info = get_player_status_info(player_id)
|
||||
|
||||
return render_template('players/manage_player.html',
|
||||
player=player,
|
||||
playlists=playlists,
|
||||
current_playlist=current_playlist,
|
||||
recent_logs=recent_logs,
|
||||
edited_media=edited_media,
|
||||
status_info=status_info)
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/edited-media')
|
||||
@login_required
|
||||
def edited_media(player_id: int):
|
||||
"""Display all edited media files from this player."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Get all edited media history from player
|
||||
from app.models.player_edit import PlayerEdit
|
||||
from app.models.player_user import PlayerUser
|
||||
|
||||
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
|
||||
.order_by(PlayerEdit.created_at.desc())\
|
||||
.all()
|
||||
|
||||
# Get original content files for each edited media
|
||||
content_files = {}
|
||||
for edit in edited_media:
|
||||
if edit.content_id not in content_files:
|
||||
content = Content.query.get(edit.content_id)
|
||||
if content:
|
||||
content_files[edit.content_id] = content
|
||||
|
||||
# Get user mappings for display names
|
||||
user_mappings = {}
|
||||
for edit in edited_media:
|
||||
if edit.user and edit.user not in user_mappings:
|
||||
player_user = PlayerUser.query.filter_by(user_code=edit.user).first()
|
||||
if player_user:
|
||||
user_mappings[edit.user] = player_user.user_name or edit.user
|
||||
else:
|
||||
user_mappings[edit.user] = edit.user
|
||||
|
||||
return render_template('players/edited_media.html',
|
||||
player=player,
|
||||
edited_media=edited_media,
|
||||
content_files=content_files,
|
||||
user_mappings=user_mappings)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading edited media for player {player_id}: {str(e)}')
|
||||
flash('Error loading edited media.', 'danger')
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/fullscreen')
|
||||
def player_fullscreen(player_id: int):
|
||||
"""Display player fullscreen view (no authentication required for players)."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Verify auth code if provided
|
||||
auth_code = request.args.get('auth')
|
||||
if auth_code and auth_code != player.auth_code:
|
||||
log_action('warning', f'Invalid auth code attempt for player {player_id}')
|
||||
return "Invalid authentication code", 403
|
||||
|
||||
# Get player's playlist
|
||||
playlist = get_player_playlist(player_id)
|
||||
|
||||
return render_template('players/player_fullscreen.html',
|
||||
player=player,
|
||||
playlist=playlist)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading player fullscreen: {str(e)}')
|
||||
return "Error loading player", 500
|
||||
|
||||
|
||||
@cache.memoize(timeout=300) # Cache for 5 minutes
|
||||
def get_player_playlist(player_id: int) -> List[dict]:
|
||||
"""Get playlist for a player based on their assigned playlist.
|
||||
|
||||
Args:
|
||||
player_id: The player's database ID
|
||||
|
||||
Returns:
|
||||
List of content dictionaries with url, type, duration, and position
|
||||
"""
|
||||
player = Player.query.get(player_id)
|
||||
if not player or not player.playlist_id:
|
||||
return []
|
||||
|
||||
# Get the player's assigned playlist
|
||||
playlist_obj = Playlist.query.get(player.playlist_id)
|
||||
if not playlist_obj:
|
||||
return []
|
||||
|
||||
# Get ordered content from the playlist
|
||||
ordered_content = playlist_obj.get_content_ordered()
|
||||
|
||||
# Build playlist
|
||||
playlist = []
|
||||
for content in ordered_content:
|
||||
playlist.append({
|
||||
'id': content.id,
|
||||
'url': url_for('static', filename=f'uploads/{content.filename}'),
|
||||
'type': content.content_type,
|
||||
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
||||
'position': getattr(content, '_playlist_position', 0),
|
||||
'muted': getattr(content, '_playlist_muted', True),
|
||||
'filename': content.filename
|
||||
})
|
||||
|
||||
return playlist
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_content(player_id: int):
|
||||
"""Legacy endpoint - Content reordering now handled in playlist management."""
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.'
|
||||
}), 400
|
||||
|
||||
|
||||
@players_bp.route('/bulk/delete', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_delete_players():
|
||||
"""Delete multiple players at once."""
|
||||
try:
|
||||
player_ids = request.json.get('player_ids', [])
|
||||
|
||||
if not player_ids:
|
||||
return jsonify({'success': False, 'error': 'No players selected'}), 400
|
||||
|
||||
# Delete players
|
||||
deleted_count = 0
|
||||
for player_id in player_ids:
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
# Delete associated feedback
|
||||
PlayerFeedback.query.filter_by(player_id=player_id).delete()
|
||||
db.session.delete(player)
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
deleted_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Bulk deleted {deleted_count} players')
|
||||
return jsonify({'success': True, 'deleted': deleted_count})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error bulk deleting players: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@players_bp.route('/bulk/assign-playlist', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_assign_playlist():
|
||||
"""Assign multiple players to a playlist."""
|
||||
try:
|
||||
player_ids = request.json.get('player_ids', [])
|
||||
playlist_id = request.json.get('playlist_id')
|
||||
|
||||
if not player_ids:
|
||||
return jsonify({'success': False, 'error': 'No players selected'}), 400
|
||||
|
||||
# Validate playlist
|
||||
if playlist_id:
|
||||
playlist = Playlist.query.get(playlist_id)
|
||||
if not playlist:
|
||||
return jsonify({'success': False, 'error': 'Invalid playlist'}), 400
|
||||
|
||||
# Assign players
|
||||
updated_count = 0
|
||||
for player_id in player_ids:
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
player.playlist_id = playlist_id
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
updated_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Bulk assigned {updated_count} players to playlist {playlist_id}')
|
||||
return jsonify({'success': True, 'updated': updated_count})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error bulk assigning players: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/playlist/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_playlist(player_id: int):
|
||||
"""Reorder items in player's playlist."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
content_id = data.get('content_id')
|
||||
direction = data.get('direction') # 'up' or 'down'
|
||||
|
||||
if not content_id or not direction:
|
||||
return jsonify({'success': False, 'message': 'Missing parameters'}), 400
|
||||
|
||||
# Get the content item
|
||||
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
|
||||
if not content:
|
||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
||||
|
||||
# Get all content for this player, ordered by position
|
||||
all_content = Content.query.filter_by(player_id=player_id)\
|
||||
.order_by(Content.position, Content.uploaded_at).all()
|
||||
|
||||
# Find current index
|
||||
current_index = None
|
||||
for idx, item in enumerate(all_content):
|
||||
if item.id == content_id:
|
||||
current_index = idx
|
||||
break
|
||||
|
||||
if current_index is None:
|
||||
return jsonify({'success': False, 'message': 'Content not in playlist'}), 404
|
||||
|
||||
# Swap positions
|
||||
if direction == 'up' and current_index > 0:
|
||||
# Swap with previous item
|
||||
all_content[current_index].position, all_content[current_index - 1].position = \
|
||||
all_content[current_index - 1].position, all_content[current_index].position
|
||||
elif direction == 'down' and current_index < len(all_content) - 1:
|
||||
# Swap with next item
|
||||
all_content[current_index].position, all_content[current_index + 1].position = \
|
||||
all_content[current_index + 1].position, all_content[current_index].position
|
||||
|
||||
db.session.commit()
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
|
||||
log_action('info', f'Reordered playlist for player {player_id}')
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error reordering playlist: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/playlist/remove', methods=['POST'])
|
||||
@login_required
|
||||
def remove_from_playlist(player_id: int):
|
||||
"""Remove content from player's playlist."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
content_id = data.get('content_id')
|
||||
|
||||
if not content_id:
|
||||
return jsonify({'success': False, 'message': 'Missing content_id'}), 400
|
||||
|
||||
# Get the content item
|
||||
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
|
||||
if not content:
|
||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
||||
|
||||
filename = content.filename
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
|
||||
# Increment playlist version
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
|
||||
log_action('info', f'Removed "{filename}" from player {player_id} playlist (version {player.playlist_version})')
|
||||
return jsonify({'success': True, 'message': f'Removed "{filename}" from playlist'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error removing from playlist: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Playlist blueprint for managing player playlists."""
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app)
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import desc, update
|
||||
import os
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Content, Playlist
|
||||
from app.models.playlist import playlist_content
|
||||
from app.utils.logger import log_action
|
||||
|
||||
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>')
|
||||
@login_required
|
||||
def manage_playlist(player_id: int):
|
||||
"""Legacy route - redirect to new content management area."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if player.playlist_id:
|
||||
# Redirect to the new content management interface
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=player.playlist_id))
|
||||
else:
|
||||
# Player has no playlist assigned
|
||||
flash('This player has no playlist assigned.', 'warning')
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_to_playlist(player_id: int):
|
||||
"""Add content to player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.playlist_id:
|
||||
flash('Player has no playlist assigned.', 'warning')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
try:
|
||||
content_id = request.form.get('content_id', type=int)
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
|
||||
if not content_id:
|
||||
flash('Please select content.', 'warning')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
content = Content.query.get_or_404(content_id)
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
|
||||
# Get max position
|
||||
from sqlalchemy import select, func
|
||||
max_pos = db.session.execute(
|
||||
select(func.max(playlist_content.c.position)).where(
|
||||
playlist_content.c.playlist_id == playlist.id
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Add to playlist_content association table
|
||||
stmt = playlist_content.insert().values(
|
||||
playlist_id=playlist.id,
|
||||
content_id=content.id,
|
||||
position=max_pos + 1,
|
||||
duration=duration
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"')
|
||||
flash(f'Added "{content.filename}" to playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding to playlist: {str(e)}')
|
||||
flash('Error adding to playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/remove/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_from_playlist(player_id: int, content_id: int):
|
||||
"""Remove content from player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.playlist_id:
|
||||
flash('Player has no playlist assigned.', 'danger')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
filename = content.filename
|
||||
|
||||
# Remove from playlist_content association table
|
||||
from sqlalchemy import delete
|
||||
stmt = delete(playlist_content).where(
|
||||
(playlist_content.c.playlist_id == playlist.id) &
|
||||
(playlist_content.c.content_id == content_id)
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Reorder remaining content
|
||||
from sqlalchemy import select
|
||||
remaining = db.session.execute(
|
||||
select(playlist_content.c.content_id, playlist_content.c.position).where(
|
||||
playlist_content.c.playlist_id == playlist.id
|
||||
).order_by(playlist_content.c.position)
|
||||
).fetchall()
|
||||
|
||||
for idx, row in enumerate(remaining, start=1):
|
||||
stmt = update(playlist_content).where(
|
||||
(playlist_content.c.playlist_id == playlist.id) &
|
||||
(playlist_content.c.content_id == row.content_id)
|
||||
).values(position=idx)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"')
|
||||
flash(f'Removed "{filename}" from playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error removing from playlist: {str(e)}')
|
||||
flash('Error removing from playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_playlist(player_id: int):
|
||||
"""Reorder playlist items."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.playlist_id:
|
||||
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
||||
|
||||
try:
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
|
||||
# Get new order from JSON
|
||||
data = request.get_json()
|
||||
content_ids = data.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
||||
|
||||
# Update positions in association table
|
||||
for idx, content_id in enumerate(content_ids, start=1):
|
||||
stmt = update(playlist_content).where(
|
||||
(playlist_content.c.playlist_id == playlist.id) &
|
||||
(playlist_content.c.content_id == content_id)
|
||||
).values(position=idx)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Playlist reordered successfully',
|
||||
'version': playlist.version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error reordering playlist: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/update-duration/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def update_duration(player_id: int, content_id: int):
|
||||
"""Update content duration in playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.playlist_id:
|
||||
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
||||
|
||||
try:
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
duration = request.form.get('duration', type=int)
|
||||
|
||||
if not duration or duration < 1:
|
||||
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
|
||||
|
||||
# Update duration in association table
|
||||
stmt = update(playlist_content).where(
|
||||
(playlist_content.c.playlist_id == playlist.id) &
|
||||
(playlist_content.c.content_id == content_id)
|
||||
).values(duration=duration)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Duration updated',
|
||||
'version': playlist.version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating duration: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/update-muted/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def update_muted(player_id: int, content_id: int):
|
||||
"""Update content muted setting in playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.playlist_id:
|
||||
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
||||
|
||||
try:
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
muted = request.form.get('muted', 'true').lower() == 'true'
|
||||
|
||||
# Update muted in association table
|
||||
stmt = update(playlist_content).where(
|
||||
(playlist_content.c.playlist_id == playlist.id) &
|
||||
(playlist_content.c.content_id == content_id)
|
||||
).values(muted=muted)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Updated muted={muted} for "{content.filename}" in player "{player.name}" playlist')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Audio setting updated',
|
||||
'muted': muted,
|
||||
'version': playlist.version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating muted setting: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
|
||||
@login_required
|
||||
def clear_playlist(player_id: int):
|
||||
"""Clear all content from player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.playlist_id:
|
||||
flash('Player has no playlist assigned.', 'warning')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
try:
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
|
||||
# Delete all content from playlist
|
||||
from sqlalchemy import delete
|
||||
stmt = delete(playlist_content).where(
|
||||
playlist_content.c.playlist_id == playlist.id
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Increment playlist version
|
||||
playlist.increment_version()
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Cleared playlist for player "{player.name}"')
|
||||
flash('Playlist cleared successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error clearing playlist: {str(e)}')
|
||||
flash('Error clearing playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Configuration settings for DigiServer v2
|
||||
Environment-based configuration with sensible defaults
|
||||
"""
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration"""
|
||||
|
||||
# Basic Flask config
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
|
||||
# Database
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ECHO = False
|
||||
|
||||
# File Upload - use absolute paths
|
||||
MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB
|
||||
_basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
UPLOAD_FOLDER = os.path.join(_basedir, 'static', 'uploads')
|
||||
UPLOAD_FOLDERLOGO = os.path.join(_basedir, 'static', 'resurse')
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'}
|
||||
|
||||
# Session
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(minutes=30)
|
||||
SESSION_COOKIE_SECURE = False # Set to True in production with HTTPS
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# Reverse proxy trust (for Nginx/Caddy with ProxyFix middleware)
|
||||
# These are set by werkzeug.middleware.proxy_fix
|
||||
TRUSTED_PROXIES = os.getenv('TRUSTED_PROXIES', '127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16')
|
||||
PREFERRED_URL_SCHEME = os.getenv('PREFERRED_URL_SCHEME', 'https')
|
||||
|
||||
# Cache
|
||||
SEND_FILE_MAX_AGE_DEFAULT = 300 # 5 minutes for static files
|
||||
|
||||
# Server Info
|
||||
SERVER_VERSION = "2.0.0"
|
||||
BUILD_DATE = "2025-11-12"
|
||||
|
||||
# Pagination
|
||||
ITEMS_PER_PAGE = 20
|
||||
|
||||
# Admin defaults
|
||||
DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin')
|
||||
DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!')
|
||||
|
||||
# Portal internal sync secret — must match INTERNAL_SYNC_SECRET in portal config
|
||||
INTERNAL_SYNC_SECRET = os.getenv('INTERNAL_SYNC_SECRET', 'change-this-internal-secret')
|
||||
|
||||
# URL of the portal login page — users are redirected here if they try to
|
||||
# access DigiServer directly without a portal session.
|
||||
PORTAL_LOGIN_URL = os.getenv('PORTAL_LOGIN_URL', 'http://localhost:8080/login')
|
||||
|
||||
# Set to True to disable DigiServer's own user registration and management UI.
|
||||
# When True, all user accounts are managed exclusively through the portal.
|
||||
PORTAL_MANAGED_USERS = True
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration"""
|
||||
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
|
||||
# Database - construct absolute path
|
||||
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
'DATABASE_URL',
|
||||
f'sqlite:///{os.path.join(_basedir, "instance", "dev.db")}'
|
||||
)
|
||||
|
||||
# Cache (simple in-memory for development)
|
||||
CACHE_TYPE = 'simple'
|
||||
CACHE_DEFAULT_TIMEOUT = 60
|
||||
|
||||
# Security (relaxed for development)
|
||||
WTF_CSRF_ENABLED = True
|
||||
WTF_CSRF_TIME_LIMIT = None
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration"""
|
||||
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
TEMPLATES_AUTO_RELOAD = True # Force template reload
|
||||
|
||||
# Database - construct absolute path
|
||||
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
'DATABASE_URL',
|
||||
f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}'
|
||||
)
|
||||
|
||||
# Cache - use simple cache instead of Redis
|
||||
CACHE_TYPE = 'simple'
|
||||
CACHE_DEFAULT_TIMEOUT = 300
|
||||
|
||||
# Security
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing configuration"""
|
||||
|
||||
DEBUG = True
|
||||
TESTING = True
|
||||
|
||||
# Database (in-memory for tests)
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
|
||||
# Cache (simple for tests)
|
||||
CACHE_TYPE = 'simple'
|
||||
|
||||
# Security (disabled for tests)
|
||||
WTF_CSRF_ENABLED = False
|
||||
|
||||
|
||||
# Configuration dictionary
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'testing': TestingConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
|
||||
|
||||
def get_config(env=None):
|
||||
"""Get configuration based on environment"""
|
||||
if env is None:
|
||||
env = os.getenv('FLASK_ENV', 'development')
|
||||
return config.get(env, config['default'])
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Flask extensions initialization
|
||||
Centralized extension management for the application
|
||||
"""
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_bcrypt import Bcrypt
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_caching import Cache
|
||||
from flask_cors import CORS
|
||||
|
||||
# Initialize extensions (will be bound to app in create_app)
|
||||
db = SQLAlchemy()
|
||||
bcrypt = Bcrypt()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
cache = Cache()
|
||||
cors = CORS()
|
||||
|
||||
# Configure login manager
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'info'
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Models package for digiserver-v2."""
|
||||
from app.models.user import User
|
||||
from app.models.player import Player
|
||||
from app.models.group import Group, group_content
|
||||
from app.models.playlist import Playlist, playlist_content
|
||||
from app.models.content import Content
|
||||
from app.models.server_log import ServerLog
|
||||
from app.models.player_feedback import PlayerFeedback
|
||||
from app.models.player_edit import PlayerEdit
|
||||
from app.models.player_user import PlayerUser
|
||||
from app.models.https_config import HTTPSConfig
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
'Player',
|
||||
'Group',
|
||||
'Playlist',
|
||||
'Content',
|
||||
'ServerLog',
|
||||
'PlayerFeedback',
|
||||
'PlayerEdit',
|
||||
'PlayerUser',
|
||||
'HTTPSConfig',
|
||||
'group_content',
|
||||
'playlist_content',
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Content model for media files."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Content(db.Model):
|
||||
"""Content model representing media files for display.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
filename: Original filename
|
||||
content_type: Type of content (image, video, pdf, presentation, other)
|
||||
duration: Default display duration in seconds
|
||||
file_size: File size in bytes
|
||||
description: Optional content description
|
||||
uploaded_at: Upload timestamp
|
||||
"""
|
||||
__tablename__ = 'content'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||
content_type = db.Column(db.String(50), nullable=False, index=True)
|
||||
duration = db.Column(db.Integer, default=10, nullable=True)
|
||||
file_size = db.Column(db.BigInteger, nullable=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
nullable=False, index=True)
|
||||
|
||||
# Relationships - many-to-many with playlists
|
||||
playlists = db.relationship('Playlist', secondary='playlist_content',
|
||||
back_populates='contents', lazy='dynamic')
|
||||
groups = db.relationship('Group', secondary='group_content',
|
||||
back_populates='contents', lazy='dynamic')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of Content."""
|
||||
return f'<Content {self.filename} (Type={self.content_type})>'
|
||||
|
||||
@property
|
||||
def file_size_mb(self) -> float:
|
||||
"""Get file size in megabytes."""
|
||||
if self.file_size:
|
||||
return round(self.file_size / (1024 * 1024), 2)
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def group_count(self) -> int:
|
||||
"""Get number of groups containing this content."""
|
||||
return self.groups.count()
|
||||
|
||||
def is_image(self) -> bool:
|
||||
"""Check if content is an image."""
|
||||
return self.content_type == 'image'
|
||||
|
||||
def is_video(self) -> bool:
|
||||
"""Check if content is a video."""
|
||||
return self.content_type == 'video'
|
||||
|
||||
def is_pdf(self) -> bool:
|
||||
"""Check if content is a PDF."""
|
||||
return self.content_type == 'pdf'
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Group model for organizing players and content."""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
# Association table for many-to-many relationship between groups and content
|
||||
group_content = db.Table('group_content',
|
||||
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
|
||||
db.Column('content_id', db.Integer, db.ForeignKey('content.id'), primary_key=True)
|
||||
)
|
||||
|
||||
|
||||
class Group(db.Model):
|
||||
"""Group model for organizing players with shared content.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
name: Unique group name
|
||||
description: Optional group description
|
||||
created_at: Group creation timestamp
|
||||
updated_at: Last modification timestamp
|
||||
"""
|
||||
__tablename__ = 'group'
|
||||
|
||||
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)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
contents = db.relationship('Content', secondary=group_content,
|
||||
back_populates='groups', lazy='dynamic')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of Group."""
|
||||
return f'<Group {self.name} (ID={self.id})>'
|
||||
|
||||
|
||||
|
||||
@property
|
||||
def content_count(self) -> int:
|
||||
"""Get number of content items in this group."""
|
||||
return self.contents.count()
|
||||
|
||||
def add_player(self, player) -> None:
|
||||
"""Add a player to this group.
|
||||
|
||||
Args:
|
||||
player: Player instance to add
|
||||
"""
|
||||
player.group_id = self.id
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def remove_player(self, player) -> None:
|
||||
"""Remove a player from this group.
|
||||
|
||||
Args:
|
||||
player: Player instance to remove
|
||||
"""
|
||||
if player.group_id == self.id:
|
||||
player.group_id = None
|
||||
self.updated_at = datetime.utcnow()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""HTTPS Configuration model."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class HTTPSConfig(db.Model):
|
||||
"""HTTPS configuration model for managing secure connections.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
https_enabled: Whether HTTPS is enabled
|
||||
hostname: Server hostname (e.g., 'digiserver')
|
||||
domain: Full domain name (e.g., 'digiserver.sibiusb.harting.intra')
|
||||
ip_address: IP address for direct access
|
||||
email: Email address for SSL certificate notifications
|
||||
port: HTTPS port (default 443)
|
||||
created_at: Creation timestamp
|
||||
updated_at: Last update timestamp
|
||||
updated_by: User who made the last update
|
||||
"""
|
||||
__tablename__ = 'https_config'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
https_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||
hostname = db.Column(db.String(255), nullable=True)
|
||||
domain = db.Column(db.String(255), nullable=True)
|
||||
ip_address = db.Column(db.String(45), nullable=True) # Support IPv6
|
||||
email = db.Column(db.String(255), nullable=True)
|
||||
port = db.Column(db.Integer, default=443, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
updated_by = db.Column(db.String(255), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of HTTPSConfig."""
|
||||
status = 'ENABLED' if self.https_enabled else 'DISABLED'
|
||||
return f'<HTTPSConfig [{status}] {self.domain or "N/A"}>'
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Optional['HTTPSConfig']:
|
||||
"""Get the current HTTPS configuration.
|
||||
|
||||
Returns:
|
||||
HTTPSConfig instance or None if not configured
|
||||
"""
|
||||
return cls.query.first()
|
||||
|
||||
@classmethod
|
||||
def create_or_update(cls, https_enabled: bool, hostname: str = None,
|
||||
domain: str = None, ip_address: str = None,
|
||||
email: str = None, port: int = 443,
|
||||
updated_by: str = None) -> 'HTTPSConfig':
|
||||
"""Create or update HTTPS configuration.
|
||||
|
||||
Args:
|
||||
https_enabled: Whether HTTPS is enabled
|
||||
hostname: Server hostname
|
||||
domain: Full domain name
|
||||
ip_address: IP address
|
||||
email: Email for SSL certificates
|
||||
port: HTTPS port
|
||||
updated_by: Username of who made the update
|
||||
|
||||
Returns:
|
||||
HTTPSConfig instance
|
||||
"""
|
||||
config = cls.get_config()
|
||||
if not config:
|
||||
config = cls()
|
||||
|
||||
config.https_enabled = https_enabled
|
||||
config.hostname = hostname
|
||||
config.domain = domain
|
||||
config.ip_address = ip_address
|
||||
config.email = email
|
||||
config.port = port
|
||||
config.updated_by = updated_by
|
||||
config.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.add(config)
|
||||
db.session.commit()
|
||||
return config
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert configuration to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of config
|
||||
"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'https_enabled': self.https_enabled,
|
||||
'hostname': self.hostname,
|
||||
'domain': self.domain,
|
||||
'ip_address': self.ip_address,
|
||||
'email': self.email,
|
||||
'port': self.port,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'updated_by': self.updated_by,
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Player model for digital signage players."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Player(db.Model):
|
||||
"""Player model representing a digital signage device.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
name: Display name for the player
|
||||
hostname: Unique hostname/identifier for the player
|
||||
location: Physical location description
|
||||
auth_code: Authentication code for API access (legacy)
|
||||
password_hash: Hashed password for player authentication
|
||||
quickconnect_code: Hashed quick connect code for easy pairing
|
||||
orientation: Display orientation (Landscape/Portrait)
|
||||
status: Current player status (online, offline, error)
|
||||
last_seen: Last activity timestamp
|
||||
playlist_version: Version number for playlist synchronization
|
||||
created_at: Player creation timestamp
|
||||
"""
|
||||
__tablename__ = 'player'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
hostname = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
location = db.Column(db.String(255), nullable=True)
|
||||
auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
quickconnect_code = db.Column(db.String(255), nullable=True)
|
||||
orientation = db.Column(db.String(16), default='Landscape', nullable=False)
|
||||
status = db.Column(db.String(50), default='offline', index=True)
|
||||
last_seen = db.Column(db.DateTime, nullable=True, index=True)
|
||||
last_heartbeat = db.Column(db.DateTime, nullable=True, index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Playlist assignment
|
||||
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
||||
nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
playlist = db.relationship('Playlist', back_populates='players')
|
||||
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of Player."""
|
||||
return f'<Player {self.name} (ID={self.id}, Status={self.status})>'
|
||||
|
||||
@property
|
||||
def is_online(self) -> bool:
|
||||
"""Check if player is online (seen in last 5 minutes)."""
|
||||
if not self.last_seen:
|
||||
return False
|
||||
delta = datetime.utcnow() - self.last_seen
|
||||
return delta.total_seconds() < 300 # 5 minutes
|
||||
|
||||
def update_status(self, status: str) -> None:
|
||||
"""Update player status and last seen timestamp.
|
||||
|
||||
Args:
|
||||
status: New status value
|
||||
"""
|
||||
self.status = status
|
||||
self.last_seen = datetime.utcnow()
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
"""Set player password with bcrypt hashing.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
"""
|
||||
from app.extensions import bcrypt
|
||||
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""Verify player password.
|
||||
|
||||
Args:
|
||||
password: Plain text password to check
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
from app.extensions import bcrypt
|
||||
return bcrypt.check_password_hash(self.password_hash, password)
|
||||
|
||||
def set_quickconnect_code(self, code: str) -> None:
|
||||
"""Set quick connect code with bcrypt hashing.
|
||||
|
||||
Args:
|
||||
code: Plain text quick connect code
|
||||
"""
|
||||
from app.extensions import bcrypt
|
||||
self.quickconnect_code = bcrypt.generate_password_hash(code).decode('utf-8')
|
||||
|
||||
def check_quickconnect_code(self, code: str) -> bool:
|
||||
"""Verify quick connect code.
|
||||
|
||||
Args:
|
||||
code: Plain text code to check
|
||||
|
||||
Returns:
|
||||
True if code matches, False otherwise
|
||||
"""
|
||||
if not self.quickconnect_code:
|
||||
return False
|
||||
from app.extensions import bcrypt
|
||||
return bcrypt.check_password_hash(self.quickconnect_code, code)
|
||||
|
||||
@staticmethod
|
||||
def authenticate(hostname: str, password: str = None, quickconnect_code: str = None) -> Optional['Player']:
|
||||
"""Authenticate a player by hostname and password or quickconnect code.
|
||||
|
||||
Args:
|
||||
hostname: Player hostname
|
||||
password: Player password (optional if using quickconnect)
|
||||
quickconnect_code: Quick connect code (optional if using password)
|
||||
|
||||
Returns:
|
||||
Player instance if authentication successful, None otherwise
|
||||
"""
|
||||
player = Player.query.filter_by(hostname=hostname).first()
|
||||
if not player:
|
||||
return None
|
||||
|
||||
# Try password authentication first
|
||||
if password and player.check_password(password):
|
||||
return player
|
||||
|
||||
# Try quickconnect code authentication
|
||||
if quickconnect_code and player.check_quickconnect_code(quickconnect_code):
|
||||
return player
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Player edit model for tracking media edited on players."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class PlayerEdit(db.Model):
|
||||
"""Player edit model for tracking media files edited on player devices.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
player_id: Foreign key to player
|
||||
content_id: Foreign key to content that was edited
|
||||
original_name: Original filename
|
||||
new_name: New filename after editing
|
||||
version: Edit version number (v1, v2, etc.)
|
||||
user: User who made the edit (from player)
|
||||
time_of_modification: When the edit was made
|
||||
metadata_path: Path to the metadata JSON file
|
||||
edited_file_path: Path to the edited file
|
||||
created_at: Record creation timestamp
|
||||
"""
|
||||
__tablename__ = 'player_edit'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
content_id = db.Column(db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
original_name = db.Column(db.String(255), nullable=False)
|
||||
new_name = db.Column(db.String(255), nullable=False)
|
||||
version = db.Column(db.Integer, default=1, nullable=False)
|
||||
user = db.Column(db.String(255), nullable=True)
|
||||
time_of_modification = db.Column(db.DateTime, nullable=True)
|
||||
metadata_path = db.Column(db.String(512), nullable=True)
|
||||
edited_file_path = db.Column(db.String(512), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
player = db.relationship('Player', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
content = db.relationship('Content', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of PlayerEdit."""
|
||||
return f'<PlayerEdit {self.original_name} v{self.version} by {self.user or "unknown"}>'
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'player_id': self.player_id,
|
||||
'player_name': self.player.name if self.player else None,
|
||||
'content_id': self.content_id,
|
||||
'original_name': self.original_name,
|
||||
'new_name': self.new_name,
|
||||
'version': self.version,
|
||||
'user': self.user,
|
||||
'time_of_modification': self.time_of_modification.isoformat() if self.time_of_modification else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'edited_file_path': self.edited_file_path
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Player feedback model for tracking player status and errors."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class PlayerFeedback(db.Model):
|
||||
"""Player feedback model for tracking player status updates.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
player_id: Foreign key to player
|
||||
status: Current status (playing, paused, error, unknown)
|
||||
current_content_id: ID of currently playing content
|
||||
message: Optional status message
|
||||
error: Optional error message
|
||||
timestamp: Feedback timestamp
|
||||
"""
|
||||
__tablename__ = 'player_feedback'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('player.id'),
|
||||
nullable=False, index=True)
|
||||
status = db.Column(db.String(50), nullable=False, default='unknown')
|
||||
current_content_id = db.Column(db.Integer, db.ForeignKey('content.id'),
|
||||
nullable=True)
|
||||
message = db.Column(db.Text, nullable=True)
|
||||
error = db.Column(db.Text, nullable=True)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
player = db.relationship('Player', back_populates='feedback')
|
||||
content = db.relationship('Content', backref='feedback_entries')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of PlayerFeedback."""
|
||||
return f'<PlayerFeedback Player={self.player_id} Status={self.status}>'
|
||||
|
||||
@property
|
||||
def is_error(self) -> bool:
|
||||
"""Check if feedback indicates an error."""
|
||||
return self.status == 'error' or self.error is not None
|
||||
|
||||
@property
|
||||
def age_seconds(self) -> float:
|
||||
"""Get age of feedback in seconds."""
|
||||
delta = datetime.utcnow() - self.timestamp
|
||||
return delta.total_seconds()
|
||||
|
||||
@classmethod
|
||||
def get_latest_for_player(cls, player_id: int) -> Optional['PlayerFeedback']:
|
||||
"""Get most recent feedback for a player.
|
||||
|
||||
Args:
|
||||
player_id: Player ID to query
|
||||
|
||||
Returns:
|
||||
Latest PlayerFeedback instance or None
|
||||
"""
|
||||
return cls.query.filter_by(player_id=player_id)\
|
||||
.order_by(cls.timestamp.desc())\
|
||||
.first()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Player user model for managing user codes and names."""
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class PlayerUser(db.Model):
|
||||
"""Player user model for managing user codes and names globally.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
user_code: User code received from player (unique)
|
||||
user_name: Display name for the user
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
__tablename__ = 'player_user'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_code = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||
user_name = db.Column(db.String(255), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of PlayerUser."""
|
||||
return f'<PlayerUser {self.user_code} -> {self.user_name or "Unnamed"}>'
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_code': self.user_code,
|
||||
'user_name': self.user_name,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Playlist model for managing content collections."""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
# Association table for many-to-many relationship between playlists and content
|
||||
playlist_content = db.Table('playlist_content',
|
||||
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), primary_key=True),
|
||||
db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True),
|
||||
db.Column('position', db.Integer, default=0),
|
||||
db.Column('duration', db.Integer, default=10),
|
||||
db.Column('muted', db.Boolean, default=True),
|
||||
db.Column('edit_on_player_enabled', db.Boolean, default=False)
|
||||
)
|
||||
|
||||
|
||||
class Playlist(db.Model):
|
||||
"""Playlist model representing a collection of content.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
name: Unique playlist name
|
||||
description: Optional playlist description
|
||||
version: Version number for synchronization
|
||||
is_active: Whether playlist is active
|
||||
created_at: Playlist creation timestamp
|
||||
updated_at: Last modification timestamp
|
||||
"""
|
||||
__tablename__ = 'playlist'
|
||||
|
||||
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)
|
||||
orientation = db.Column(db.String(20), default='Landscape', nullable=False)
|
||||
version = db.Column(db.Integer, default=1, nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
players = db.relationship('Player', back_populates='playlist', lazy='dynamic')
|
||||
contents = db.relationship('Content', secondary=playlist_content,
|
||||
back_populates='playlists', lazy='dynamic')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of Playlist."""
|
||||
return f'<Playlist {self.name} (ID={self.id}, Version={self.version})>'
|
||||
|
||||
@property
|
||||
def player_count(self) -> int:
|
||||
"""Get number of players assigned to this playlist."""
|
||||
return self.players.count()
|
||||
|
||||
@property
|
||||
def content_count(self) -> int:
|
||||
"""Get number of content items in this playlist."""
|
||||
return self.contents.count()
|
||||
|
||||
@property
|
||||
def total_duration(self) -> int:
|
||||
"""Calculate total duration of all content in seconds."""
|
||||
total = 0
|
||||
for content in self.contents:
|
||||
total += content.duration or 10
|
||||
return total
|
||||
|
||||
def increment_version(self) -> None:
|
||||
"""Increment playlist version for sync detection."""
|
||||
self.version += 1
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def get_content_ordered(self) -> List:
|
||||
"""Get content items ordered by position."""
|
||||
# Query through association table to get position
|
||||
from sqlalchemy import select
|
||||
stmt = select(playlist_content.c.content_id,
|
||||
playlist_content.c.position,
|
||||
playlist_content.c.duration,
|
||||
playlist_content.c.muted,
|
||||
playlist_content.c.edit_on_player_enabled).where(
|
||||
playlist_content.c.playlist_id == self.id
|
||||
).order_by(playlist_content.c.position)
|
||||
|
||||
results = db.session.execute(stmt).fetchall()
|
||||
|
||||
ordered_content = []
|
||||
for row in results:
|
||||
content = db.session.get(Content, row.content_id)
|
||||
if content:
|
||||
content._playlist_position = row.position
|
||||
content._playlist_duration = row.duration
|
||||
content._playlist_muted = row.muted if len(row) > 3 else True
|
||||
content._playlist_edit_on_player_enabled = row.edit_on_player_enabled if len(row) > 4 else False
|
||||
ordered_content.append(content)
|
||||
|
||||
return ordered_content
|
||||
|
||||
|
||||
# Import Content here to avoid circular import
|
||||
from app.models.content import Content
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Server log model for audit trail."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class ServerLog(db.Model):
|
||||
"""Server log model for tracking system events.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
level: Log level (info, warning, error)
|
||||
message: Log message content
|
||||
timestamp: Event timestamp
|
||||
"""
|
||||
__tablename__ = 'server_log'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
level = db.Column(db.String(20), nullable=False, index=True, default='info')
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
nullable=False, index=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of ServerLog."""
|
||||
return f'<ServerLog [{self.level.upper()}] {self.message[:50]}>'
|
||||
|
||||
@classmethod
|
||||
def log_info(cls, message: str) -> 'ServerLog':
|
||||
"""Create an info level log entry.
|
||||
|
||||
Args:
|
||||
message: Log message
|
||||
|
||||
Returns:
|
||||
ServerLog instance
|
||||
"""
|
||||
log = cls(level='info', message=message)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
return log
|
||||
|
||||
@classmethod
|
||||
def log_warning(cls, message: str) -> 'ServerLog':
|
||||
"""Create a warning level log entry.
|
||||
|
||||
Args:
|
||||
message: Log message
|
||||
|
||||
Returns:
|
||||
ServerLog instance
|
||||
"""
|
||||
log = cls(level='warning', message=message)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
return log
|
||||
|
||||
@classmethod
|
||||
def log_error(cls, message: str) -> 'ServerLog':
|
||||
"""Create an error level log entry.
|
||||
|
||||
Args:
|
||||
message: Log message
|
||||
|
||||
Returns:
|
||||
ServerLog instance
|
||||
"""
|
||||
log = cls(level='error', message=message)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
return log
|
||||
@@ -0,0 +1,43 @@
|
||||
"""User model for authentication and authorization."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from flask_login import UserMixin
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
"""User model for application authentication.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
username: Unique username for login
|
||||
password: Bcrypt hashed password
|
||||
role: User role (user or admin)
|
||||
theme: UI theme preference (light or dark)
|
||||
created_at: Account creation timestamp
|
||||
last_login: Last successful login timestamp
|
||||
"""
|
||||
__tablename__ = 'user'
|
||||
|
||||
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(120), nullable=False)
|
||||
role = db.Column(db.String(20), nullable=False, default='user', index=True)
|
||||
theme = db.Column(db.String(20), default='light')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_login = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of User."""
|
||||
return f'<User {self.username} (role={self.role})>'
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user has admin role."""
|
||||
return self.role == 'admin'
|
||||
|
||||
def update_last_login(self) -> None:
|
||||
"""Update last login timestamp."""
|
||||
self.last_login = datetime.utcnow()
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 294 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 329 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="7" width="20" height="15" rx="2" ry="2"></rect>
|
||||
<polyline points="17 2 12 7 7 2"></polyline>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 301 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 651 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 345 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,256 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Panel - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Admin Panel</h1>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- System Overview Card -->
|
||||
<div class="card">
|
||||
<h2>📊 System Overview</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Total Users</span>
|
||||
<span class="stat-value">{{ total_users or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">🖥️</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Total Players</span>
|
||||
<span class="stat-value">{{ total_players or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">📋</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Total Playlists</span>
|
||||
<span class="stat-value">{{ total_playlists or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">📁</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Media Files</span>
|
||||
<span class="stat-value">{{ total_content or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">💾</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Storage Used</span>
|
||||
<span class="stat-value">{{ storage_mb or 0 }} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<!-- User Management Card (Admin Only) -->
|
||||
<div class="card management-card">
|
||||
<h2>👥 User Management</h2>
|
||||
<p>Manage application users, roles and permissions</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.user_management') }}" class="btn btn-primary">
|
||||
Manage Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Editing Users Card -->
|
||||
<div class="card management-card">
|
||||
<h2>✏️ Editing Users</h2>
|
||||
<p>Manage user codes from players that edit images on-screen</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.manage_editing_users') }}" class="btn btn-primary">
|
||||
Manage Editing Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leftover Media Management Card -->
|
||||
<div class="card management-card">
|
||||
<h2>🗑️ Manage Leftover Media</h2>
|
||||
<p>Clean up media files not assigned to any playlist</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.leftover_media') }}" class="btn btn-warning">
|
||||
Manage Leftover Files
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<!-- System Dependencies Card (Admin Only) -->
|
||||
<div class="card management-card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
|
||||
<h2>🔧 System Dependencies</h2>
|
||||
<p>Check and install required software dependencies</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.dependencies') }}" class="btn btn-primary">
|
||||
View Dependencies
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo Customization Card (Admin Only) -->
|
||||
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
|
||||
<h2>🎨 Logo Customization</h2>
|
||||
<p>Upload custom logos for header and login page</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.customize_logos') }}" class="btn btn-primary">
|
||||
Customize Logos
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTTPS Configuration Card (Admin Only) -->
|
||||
<div class="card management-card" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||
<h2>🔒 HTTPS Configuration</h2>
|
||||
<p>Manage SSL/HTTPS settings, domain, and access points</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.https_config') }}" class="btn btn-primary">
|
||||
Configure HTTPS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="card">
|
||||
<h2>⚡ Quick Actions</h2>
|
||||
<div class="quick-actions">
|
||||
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">🖥️ View Players</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📋 View Playlists</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📁 View Media Library</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
body.dark-mode .stat-item {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-value {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.management-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.management-card h2 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.management-card p {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Logo Customization - DigiServer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 900px;">
|
||||
<h1 style="margin-bottom: 25px;">🎨 Logo Customization</h1>
|
||||
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<h2 style="margin-bottom: 20px;">📸 Upload Custom Logos</h2>
|
||||
|
||||
<!-- Header Logo -->
|
||||
<div style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
|
||||
<h3 style="margin-bottom: 15px;">Header Logo (Small)</h3>
|
||||
<p style="color: #666; margin-bottom: 15px;">
|
||||
This logo appears in the top header next to "DigiServer" text.<br>
|
||||
<strong>Recommended:</strong> 150x40 pixels (or similar aspect ratio), transparent background PNG
|
||||
</p>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 15px;">
|
||||
<div style="flex: 1;">
|
||||
<img src="{{ url_for('static', filename='uploads/header_logo.png') }}?v={{ version }}"
|
||||
alt="Current Header Logo"
|
||||
style="max-height: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 10px; border-radius: 4px;"
|
||||
onerror="this.src='{{ url_for('static', filename='icons/monitor.svg') }}'; this.style.filter='brightness(0) invert(1)'; this.style.maxWidth='50px';">
|
||||
<p style="margin-top: 5px; font-size: 0.9rem; color: #888;">Current Header Logo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.upload_header_logo') }}" enctype="multipart/form-data">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<input type="file" name="header_logo" accept="image/png,image/jpeg,image/svg+xml" required
|
||||
style="padding: 10px; border: 2px solid #ddd; border-radius: 4px; width: 100%;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">📤 Upload Header Logo</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Login Logo -->
|
||||
<div style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
|
||||
<h3 style="margin-bottom: 15px;">Login Page Logo (Large)</h3>
|
||||
<p style="color: #666; margin-bottom: 15px;">
|
||||
This logo appears on the left side of the login page (2/3 of screen).<br>
|
||||
<strong>Recommended:</strong> 800x600 pixels (or similar), transparent background PNG
|
||||
</p>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 15px;">
|
||||
<div style="flex: 1;">
|
||||
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ version }}"
|
||||
alt="Current Login Logo"
|
||||
style="max-width: 300px; max-height: 200px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px;"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
||||
<p style="margin-top: 10px; font-size: 0.9rem; color: #888; display: none;">No login logo uploaded yet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.upload_login_logo') }}" enctype="multipart/form-data">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<input type="file" name="login_logo" accept="image/png,image/jpeg,image/svg+xml" required
|
||||
style="padding: 10px; border: 2px solid #ddd; border-radius: 4px; width: 100%;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">📤 Upload Login Logo</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #0066cc;">
|
||||
<h4 style="margin: 0 0 10px 0;">ℹ️ Logo Guidelines</h4>
|
||||
<ul style="margin: 5px 0; padding-left: 25px; color: #555;">
|
||||
<li><strong>Header Logo:</strong> Keep it simple and small (max 200px width recommended)</li>
|
||||
<li><strong>Login Logo:</strong> Can be larger and more detailed (800x600px works great)</li>
|
||||
<li><strong>Format:</strong> PNG with transparent background recommended, or JPG/SVG</li>
|
||||
<li><strong>File Size:</strong> Keep under 2MB for optimal performance</li>
|
||||
<li>Logos are cached - clear browser cache if changes don't appear immediately</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||
← Back to Admin Panel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
body.dark-mode div[style*="background: #f8f9fa"] {
|
||||
background: #2d3748 !important;
|
||||
}
|
||||
|
||||
body.dark-mode div[style*="background: #e7f3ff"] {
|
||||
background: #1e3a5f !important;
|
||||
border-left-color: #64b5f6 !important;
|
||||
}
|
||||
|
||||
body.dark-mode p[style*="color: #666"],
|
||||
body.dark-mode p[style*="color: #888"],
|
||||
body.dark-mode ul[style*="color: #555"] {
|
||||
color: #cbd5e0 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}System Dependencies - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1000px;">
|
||||
<h1 style="margin-bottom: 25px;">🔧 System Dependencies</h1>
|
||||
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<h2 style="margin-bottom: 20px;">📦 Installed Dependencies</h2>
|
||||
|
||||
<!-- LibreOffice -->
|
||||
<div class="dependency-card" style="background: {% if libreoffice_installed %}#d4edda{% else %}#f8d7da{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if libreoffice_installed %}#28a745{% else %}#dc3545{% endif %};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||
{% if libreoffice_installed %}
|
||||
<span style="font-size: 24px;">✅</span>
|
||||
{% else %}
|
||||
<span style="font-size: 24px;">❌</span>
|
||||
{% endif %}
|
||||
LibreOffice
|
||||
</h3>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Purpose:</strong> Required for PowerPoint (PPTX/PPT) to image conversion
|
||||
</p>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Status:</strong> {{ libreoffice_version }}
|
||||
</p>
|
||||
{% if not libreoffice_installed %}
|
||||
<p style="margin: 10px 0 0 0; color: #721c24;">
|
||||
⚠️ Without LibreOffice, you cannot upload or convert PowerPoint presentations.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not libreoffice_installed %}
|
||||
<form method="POST" action="{{ url_for('admin.install_libreoffice') }}" style="margin-left: 20px;">
|
||||
<button type="submit" class="btn btn-success" onclick="return confirm('Install LibreOffice? This may take 2-5 minutes.');">
|
||||
📥 Install LibreOffice
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Poppler Utils -->
|
||||
<div class="dependency-card" style="background: {% if poppler_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if poppler_installed %}#28a745{% else %}#ffc107{% endif %};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||
{% if poppler_installed %}
|
||||
<span style="font-size: 24px;">✅</span>
|
||||
{% else %}
|
||||
<span style="font-size: 24px;">⚠️</span>
|
||||
{% endif %}
|
||||
Poppler Utils
|
||||
</h3>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Purpose:</strong> Required for PDF to image conversion
|
||||
</p>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Status:</strong> {{ poppler_version }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FFmpeg -->
|
||||
<div class="dependency-card" style="background: {% if ffmpeg_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if ffmpeg_installed %}#28a745{% else %}#ffc107{% endif %};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||
{% if ffmpeg_installed %}
|
||||
<span style="font-size: 24px;">✅</span>
|
||||
{% else %}
|
||||
<span style="font-size: 24px;">⚠️</span>
|
||||
{% endif %}
|
||||
FFmpeg
|
||||
</h3>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Purpose:</strong> Required for video processing and validation
|
||||
</p>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Status:</strong> {{ ffmpeg_version }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emoji Fonts -->
|
||||
<div class="dependency-card" style="background: {% if emoji_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if emoji_installed %}#28a745{% else %}#ffc107{% endif %};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||
{% if emoji_installed %}
|
||||
<span style="font-size: 24px;">✅</span>
|
||||
{% else %}
|
||||
<span style="font-size: 24px;">⚠️</span>
|
||||
{% endif %}
|
||||
Emoji Fonts
|
||||
</h3>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Purpose:</strong> Better emoji display in UI (optional, mainly for Raspberry Pi)
|
||||
</p>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Status:</strong> {{ emoji_version }}
|
||||
</p>
|
||||
{% if not emoji_installed %}
|
||||
<p style="margin: 10px 0 0 0; color: #856404;">
|
||||
ℹ️ Optional: Improves emoji rendering on systems without native emoji support.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not emoji_installed %}
|
||||
<form method="POST" action="{{ url_for('admin.install_emoji_fonts') }}" style="margin-left: 20px;">
|
||||
<button type="submit" class="btn btn-warning" onclick="return confirm('Install emoji fonts? This may take 1-2 minutes.');">
|
||||
📥 Install Emoji Fonts
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 25px; padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #0066cc;">
|
||||
<h4 style="margin: 0 0 10px 0;">ℹ️ Installation Notes</h4>
|
||||
<ul style="margin: 5px 0; padding-left: 25px; color: #555;">
|
||||
<li>LibreOffice can be installed using the button above (requires sudo access)</li>
|
||||
<li>Emoji fonts improve UI display, especially on Raspberry Pi systems</li>
|
||||
<li>Installation may take 1-5 minutes depending on your internet connection</li>
|
||||
<li>After installation, refresh this page to verify the status</li>
|
||||
<li>Docker containers may require rebuilding to include dependencies</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||
← Back to Admin Panel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
body.dark-mode .dependency-card {
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .dependency-card p {
|
||||
color: #cbd5e0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .dependency-card[style*="#d4edda"] {
|
||||
background: #1e4620 !important;
|
||||
border-left-color: #48bb78 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .dependency-card[style*="#f8d7da"] {
|
||||
background: #5a1e1e !important;
|
||||
border-left-color: #ef5350 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .dependency-card[style*="#fff3cd"] {
|
||||
background: #5a4a1e !important;
|
||||
border-left-color: #ffc107 !important;
|
||||
}
|
||||
|
||||
body.dark-mode div[style*="#e7f3ff"] {
|
||||
background: #1e3a5f !important;
|
||||
border-left-color: #64b5f6 !important;
|
||||
}
|
||||
|
||||
body.dark-mode div[style*="#e7f3ff"] ul {
|
||||
color: #cbd5e0 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Editing Users{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
<a href="{{ url_for('admin.admin_panel') }}"
|
||||
class="btn"
|
||||
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px;">
|
||||
← Back to Admin
|
||||
</a>
|
||||
<h1 style="margin: 0;">👤 Manage Editing Users</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color: #6c757d;">Manage users who edit images on players. User codes are automatically created from player metadata.</p>
|
||||
</div>
|
||||
|
||||
{% if users %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 style="margin: 0;">Editing Users ({{ users|length }})</h3>
|
||||
</div>
|
||||
<div class="card-body" style="padding: 0;">
|
||||
<table class="table" style="margin: 0;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30%;">User Code</th>
|
||||
<th style="width: 30%;">Display Name</th>
|
||||
<th style="width: 15%;">Edits Count</th>
|
||||
<th style="width: 15%;">Created</th>
|
||||
<th style="width: 10%;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td style="font-family: monospace; font-weight: 600;">{{ user.user_code }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('admin.update_editing_user', user_id=user.id) }}" style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<input type="text"
|
||||
name="user_name"
|
||||
value="{{ user.user_name or '' }}"
|
||||
placeholder="Enter display name"
|
||||
class="form-control"
|
||||
style="flex: 1;">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-info">{{ user_stats.get(user.user_code, 0) }} edits</span>
|
||||
</td>
|
||||
<td>{{ user.created_at | localtime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('admin.delete_editing_user', user_id=user.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this user? This will not delete their edit history.');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 4rem 2rem;">
|
||||
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">👤</div>
|
||||
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Editing Users Yet</h2>
|
||||
<p style="color: #6c757d; font-size: 1.1rem;">
|
||||
User codes will appear here automatically when players edit media files.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
body.dark-mode .table {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .table thead th {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .table tbody tr {
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .table tbody tr:hover {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,654 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}HTTPS Configuration - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="back-link">← Back to Admin Panel</a>
|
||||
<h1>🔒 HTTPS Configuration</h1>
|
||||
</div>
|
||||
|
||||
<div class="https-config-container">
|
||||
<!-- Status Display -->
|
||||
<div class="card status-card">
|
||||
<h2>Current Status</h2>
|
||||
|
||||
<!-- Real-time HTTPS detection -->
|
||||
<div class="status-detection">
|
||||
<p class="detection-info">
|
||||
<strong>🔍 Detected Connection:</strong>
|
||||
{% if is_https_active %}
|
||||
<span class="badge badge-success">🔒 HTTPS ({{ request.scheme.upper() }})</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">🔓 HTTP</span>
|
||||
{% endif %}
|
||||
<br>
|
||||
<small>Current host: <code>{{ current_host }}</code> via {{ request.host }}</small>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if config and config.https_enabled %}
|
||||
<div class="status-enabled">
|
||||
<span class="status-badge">✅ HTTPS ENABLED</span>
|
||||
<div class="status-details">
|
||||
<p><strong>Domain:</strong> {{ config.domain }}</p>
|
||||
<p><strong>Hostname:</strong> {{ config.hostname }}</p>
|
||||
<p><strong>Email:</strong> {{ config.email }}</p>
|
||||
<p><strong>IP Address:</strong> {{ config.ip_address }}</p>
|
||||
<p><strong>Port:</strong> {{ config.port }}</p>
|
||||
<p><strong>Access URL:</strong> <code>https://{{ config.domain }}</code></p>
|
||||
<p><strong>Last Updated:</strong> {{ config.updated_at.strftime('%Y-%m-%d %H:%M:%S') }} by {{ config.updated_by }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-disabled">
|
||||
<span class="status-badge-inactive">⚠️ HTTPS DISABLED</span>
|
||||
{% if is_https_active %}
|
||||
<p style="color: #156b2e; background: #d1f0e0; padding: 10px; border-radius: 4px; margin: 10px 0;">
|
||||
✅ <strong>Note:</strong> You are currently accessing this page via HTTPS, but the configuration shows as disabled.
|
||||
This configuration will be automatically updated. Please refresh the page.
|
||||
</p>
|
||||
{% else %}
|
||||
<p>The application is currently running on HTTP only (port 80)</p>
|
||||
<p>Enable HTTPS below to secure your application.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Configuration Form -->
|
||||
<div class="card config-card">
|
||||
<h2>Configure HTTPS Settings</h2>
|
||||
<p class="info-text">
|
||||
💡 <strong>Workflow:</strong> First, the app runs on HTTP (port 80). After you configure the HTTPS settings below,
|
||||
the application will be available over HTTPS (port 443) using the domain and hostname you specify.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.update_https_config') }}" class="https-form">
|
||||
<!-- Enable HTTPS Toggle -->
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="https_enabled" id="https_enabled"
|
||||
{% if config and config.https_enabled %}checked{% endif %}
|
||||
class="toggle-input">
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="toggle-text">Enable HTTPS</span>
|
||||
</label>
|
||||
<p class="form-hint">Check this box to enable HTTPS/SSL for your application</p>
|
||||
</div>
|
||||
|
||||
<!-- Hostname Field -->
|
||||
<div class="form-group">
|
||||
<label for="hostname">Hostname <span class="required">*</span></label>
|
||||
<input type="text" id="hostname" name="hostname"
|
||||
value="{{ config.hostname or 'digiserver' }}"
|
||||
placeholder="e.g., digiserver"
|
||||
class="form-input"
|
||||
required>
|
||||
<p class="form-hint">Short name for your server (e.g., 'digiserver')</p>
|
||||
</div>
|
||||
|
||||
<!-- Domain Field -->
|
||||
<div class="form-group">
|
||||
<label for="domain">Full Domain Name <span class="required">*</span></label>
|
||||
<input type="text" id="domain" name="domain"
|
||||
value="{{ config.domain or 'digiserver.sibiusb.harting.intra' }}"
|
||||
placeholder="e.g., digiserver.sibiusb.harting.intra"
|
||||
class="form-input"
|
||||
required>
|
||||
<p class="form-hint">Complete domain name (e.g., digiserver.sibiusb.harting.intra)</p>
|
||||
</div>
|
||||
|
||||
<!-- IP Address Field -->
|
||||
<div class="form-group">
|
||||
<label for="ip_address">IP Address <span class="required">*</span></label>
|
||||
<input type="text" id="ip_address" name="ip_address"
|
||||
value="{{ config.ip_address or '10.76.152.164' }}"
|
||||
placeholder="e.g., 10.76.152.164"
|
||||
class="form-input"
|
||||
required>
|
||||
<p class="form-hint">Server's IP address for direct access (e.g., 10.76.152.164)</p>
|
||||
</div>
|
||||
|
||||
<!-- Email Field -->
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address <span class="required">*</span></label>
|
||||
<input type="email" id="email" name="email"
|
||||
value="{{ config.email or '' }}"
|
||||
placeholder="e.g., admin@example.com"
|
||||
class="form-input"
|
||||
required>
|
||||
<p class="form-hint">Email address for SSL certificate notifications and Let's Encrypt communications</p>
|
||||
</div>
|
||||
|
||||
<!-- Port Field -->
|
||||
<div class="form-group">
|
||||
<label for="port">HTTPS Port</label>
|
||||
<input type="number" id="port" name="port"
|
||||
value="{{ config.port or 443 }}"
|
||||
placeholder="443"
|
||||
min="1" max="65535"
|
||||
class="form-input">
|
||||
<p class="form-hint">Port for HTTPS connections (default: 443)</p>
|
||||
</div>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="preview-section">
|
||||
<h3>Access Points After Configuration:</h3>
|
||||
<ul class="access-points">
|
||||
<li>
|
||||
<strong>HTTPS (Recommended):</strong>
|
||||
<code>https://<span id="preview-domain">digiserver.sibiusb.harting.intra</span></code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>HTTP (Fallback):</strong>
|
||||
<code>http://<span id="preview-ip">10.76.152.164</span></code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
💾 Save HTTPS Configuration
|
||||
</button>
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Nginx Status Card -->
|
||||
<div class="card nginx-status-card">
|
||||
<h2>🔧 Nginx Reverse Proxy Status</h2>
|
||||
{% if nginx_status.available %}
|
||||
<div class="nginx-status-content">
|
||||
<div class="status-item">
|
||||
<strong>Status:</strong>
|
||||
<span class="badge badge-success">✅ Nginx Configured</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<strong>Configuration Path:</strong>
|
||||
<code>{{ nginx_status.path }}</code>
|
||||
</div>
|
||||
|
||||
{% if nginx_status.ssl_enabled %}
|
||||
<div class="status-item">
|
||||
<strong>SSL/TLS:</strong>
|
||||
<span class="badge badge-success">🔒 Enabled</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-item">
|
||||
<strong>SSL/TLS:</strong>
|
||||
<span class="badge badge-warning">⚠️ Not Configured</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.http_ports %}
|
||||
<div class="status-item">
|
||||
<strong>HTTP Ports:</strong>
|
||||
<code>{{ nginx_status.http_ports|join(', ') }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.https_ports %}
|
||||
<div class="status-item">
|
||||
<strong>HTTPS Ports:</strong>
|
||||
<code>{{ nginx_status.https_ports|join(', ') }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.server_names %}
|
||||
<div class="status-item">
|
||||
<strong>Server Names:</strong>
|
||||
{% for name in nginx_status.server_names %}
|
||||
<code>{{ name }}</code>{% if not loop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.upstream_servers %}
|
||||
<div class="status-item">
|
||||
<strong>Upstream Servers:</strong>
|
||||
{% for server in nginx_status.upstream_servers %}
|
||||
<code>{{ server }}</code>{% if not loop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.ssl_protocols %}
|
||||
<div class="status-item">
|
||||
<strong>SSL Protocols:</strong>
|
||||
<code>{{ nginx_status.ssl_protocols|join(', ') }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.client_max_body_size %}
|
||||
<div class="status-item">
|
||||
<strong>Max Body Size:</strong>
|
||||
<code>{{ nginx_status.client_max_body_size }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.gzip_enabled %}
|
||||
<div class="status-item">
|
||||
<strong>Gzip Compression:</strong>
|
||||
<span class="badge badge-success">✅ Enabled</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-disabled">
|
||||
<p>⚠️ <strong>Nginx configuration not accessible</strong></p>
|
||||
<p>Error: {{ nginx_status.error|default('Unknown error') }}</p>
|
||||
<p style="font-size: 12px; color: #666;">Path checked: {{ nginx_status.path }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Information Section -->
|
||||
<div class="card info-card">
|
||||
<h2>ℹ️ Important Information</h2>
|
||||
<div class="info-sections">
|
||||
<div class="info-section">
|
||||
<h3>📝 Before You Start</h3>
|
||||
<ul>
|
||||
<li>Ensure your DNS is configured to resolve the domain to your server</li>
|
||||
<li>Verify the IP address matches your server's actual network interface</li>
|
||||
<li>Check that ports 80, 443, and 443/UDP are open for traffic</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h3>🔐 HTTPS Setup</h3>
|
||||
<ul>
|
||||
<li>SSL certificates are automatically managed by Caddy</li>
|
||||
<li>Certificates are obtained from Let's Encrypt</li>
|
||||
<li>Automatic renewal is handled by the system</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h3>✅ After Configuration</h3>
|
||||
<ul>
|
||||
<li>Your app will restart with the new settings</li>
|
||||
<li>Both HTTP and HTTPS access points will be available</li>
|
||||
<li>HTTP requests will be redirected to HTTPS</li>
|
||||
<li>Check the status above for current configuration</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.https-config-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 15px;
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-detection {
|
||||
background: #f0f7ff;
|
||||
border-left: 4px solid #0066cc;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.detection-info {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d1f0e0;
|
||||
color: #156b2e;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
margin-bottom: 30px;
|
||||
border-left: 5px solid #ddd;
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border-left: 5px solid #28a745;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border-left: 5px solid #ffc107;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-badge-inactive {
|
||||
display: inline-block;
|
||||
background: #ffc107;
|
||||
color: #333;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-details {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.status-details p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-details code {
|
||||
background: rgba(0,0,0,0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
background: #e7f3ff;
|
||||
border-left: 4px solid #0066cc;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 25px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.https-form {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Toggle Switch Styling */
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 28px;
|
||||
background: #ccc;
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.toggle-slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-slider {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-slider::after {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
background: #f8f9fa;
|
||||
border: 2px dashed #0066cc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 25px 0;
|
||||
}
|
||||
|
||||
.preview-section h3 {
|
||||
margin-top: 0;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.access-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.access-points li {
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
border-left: 4px solid #0066cc;
|
||||
}
|
||||
|
||||
.access-points code {
|
||||
background: #e7f3ff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
padding-top: 25px;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 12px 30px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: linear-gradient(135deg, #e7f3ff 0%, #f0f7ff 100%);
|
||||
}
|
||||
|
||||
.info-card h2 {
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.nginx-status-card {
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e7f3ff 100%);
|
||||
border-left: 5px solid #0066cc;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.nginx-status-card h2 {
|
||||
color: #0066cc;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.nginx-status-content {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
border-left: 3px solid #0066cc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-item strong {
|
||||
display: inline-block;
|
||||
min-width: 150px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-item code {
|
||||
background: #f0f7ff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #0066cc;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
color: #0066cc;
|
||||
margin-top: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-section ul {
|
||||
padding-left: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.https-config-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.info-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Update preview in real-time
|
||||
document.getElementById('domain').addEventListener('input', function() {
|
||||
document.getElementById('preview-domain').textContent = this.value || 'digiserver.sibiusb.harting.intra';
|
||||
});
|
||||
|
||||
document.getElementById('ip_address').addEventListener('input', function() {
|
||||
document.getElementById('preview-ip').textContent = this.value || '10.76.152.164';
|
||||
});
|
||||
|
||||
// Load initial preview
|
||||
document.getElementById('preview-domain').textContent = document.getElementById('domain').value || 'digiserver.sibiusb.harting.intra';
|
||||
document.getElementById('preview-ip').textContent = document.getElementById('ip_address').value || '10.76.152.164';
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Leftover Media - Admin - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 1400px; margin: 0 auto;">
|
||||
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
🗑️ Manage Leftover Media
|
||||
</h1>
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||
← Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Overview Stats -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<h2>📊 Overview</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 20px;">
|
||||
Media files that are not assigned to any playlist. These can be safely deleted to free up storage.
|
||||
</p>
|
||||
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
|
||||
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Leftover Files</div>
|
||||
<div style="font-size: 24px; font-weight: bold;">{{ total_leftover }}</div>
|
||||
</div>
|
||||
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Size</div>
|
||||
<div style="font-size: 24px; font-weight: bold;">{{ "%.2f"|format(total_leftover_size_mb) }} MB</div>
|
||||
</div>
|
||||
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Images</div>
|
||||
<div style="font-size: 24px; font-weight: bold;">{{ leftover_images|length }} ({{ "%.2f"|format(images_size_mb) }} MB)</div>
|
||||
</div>
|
||||
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Videos</div>
|
||||
<div style="font-size: 24px; font-weight: bold;">{{ leftover_videos|length }} ({{ "%.2f"|format(videos_size_mb) }} MB)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Images Section -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>📷 Leftover Images ({{ leftover_images|length }})</h2>
|
||||
{% if leftover_images %}
|
||||
<form method="POST" action="{{ url_for('admin.delete_leftover_images') }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_images|length }} leftover images? This cannot be undone!');"
|
||||
style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
🗑️ Delete All Images
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if leftover_images %}
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table" style="width: 100%; border-collapse: collapse;">
|
||||
<thead style="position: sticky; top: 0; background: #f8f9fa;">
|
||||
<tr>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6; width: 80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in leftover_images %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px;">📷 {{ img.filename }}</td>
|
||||
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
|
||||
<td style="padding: 10px;">{{ img.duration }}s</td>
|
||||
<td style="padding: 10px;">{{ img.uploaded_at | localtime if img.uploaded_at else 'N/A' }}</td>
|
||||
<td style="padding: 10px;">
|
||||
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=img.id) }}" style="display: inline;" onsubmit="return confirm('Delete this image?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<div style="font-size: 48px; margin-bottom: 15px;">✓</div>
|
||||
<p>No leftover images found. All images are assigned to playlists!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Videos Section -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h2 style="margin: 0;">🎥 Leftover Videos ({{ leftover_videos|length }})</h2>
|
||||
{% if leftover_videos %}
|
||||
<form method="POST" action="{{ url_for('admin.delete_leftover_videos') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_videos|length }} leftover videos? This action cannot be undone!');">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 8px 16px; font-size: 14px;">
|
||||
🗑️ Delete All Videos
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if leftover_videos %}
|
||||
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
|
||||
<table class="table" style="width: 100%; border-collapse: collapse;">
|
||||
<thead style="position: sticky; top: 0; background: #f8f9fa;">
|
||||
<tr>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6; width: 80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for video in leftover_videos %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px;">🎥 {{ video.filename }}</td>
|
||||
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
|
||||
<td style="padding: 10px;">{{ video.duration }}s</td>
|
||||
<td style="padding: 10px;">{{ video.uploaded_at | localtime if video.uploaded_at else 'N/A' }}</td>
|
||||
<td style="padding: 10px;">
|
||||
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=video.id) }}" style="display: inline;" onsubmit="return confirm('Delete this video?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<div style="font-size: 48px; margin-bottom: 15px;">✓</div>
|
||||
<p>No leftover videos found. All videos are assigned to playlists!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- PDFs Section -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<h2>📄 Leftover PDFs ({{ leftover_pdfs|length }})</h2>
|
||||
|
||||
{% if leftover_pdfs %}
|
||||
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
|
||||
<table class="table" style="width: 100%; border-collapse: collapse;">
|
||||
<thead style="position: sticky; top: 0; background: #f8f9fa;">
|
||||
<tr>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pdf in leftover_pdfs %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px;">📄 {{ pdf.filename }}</td>
|
||||
<td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td>
|
||||
<td style="padding: 10px;">{{ pdf.duration }}s</td>
|
||||
<td style="padding: 10px;">{{ pdf.uploaded_at | localtime if pdf.uploaded_at else 'N/A' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<div style="font-size: 48px; margin-bottom: 15px;">✓</div>
|
||||
<p>No leftover PDFs found. All PDFs are assigned to playlists!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
body.dark-mode .card {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode h1,
|
||||
body.dark-mode h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-item {
|
||||
background: #1a202c !important;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .table thead {
|
||||
background: #1a202c !important;
|
||||
}
|
||||
|
||||
body.dark-mode .table th,
|
||||
body.dark-mode .table td {
|
||||
color: #e2e8f0;
|
||||
border-color: #4a5568 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .table tr {
|
||||
border-color: #4a5568 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .table tr:hover {
|
||||
background: #1a202c;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,524 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}User Management - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>👥 User Management</h1>
|
||||
<button class="btn btn-primary" onclick="showCreateUserModal()">+ Create New User</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="card">
|
||||
<h2>All Users</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created At</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if users %}
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ user.username }}</strong>
|
||||
{% if user.id == current_user.id %}
|
||||
<span class="badge badge-info">You</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ 'success' if user.role == 'admin' else 'secondary' }}">
|
||||
{{ user.role|capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.created_at | localtime if user.created_at else 'N/A' }}</td>
|
||||
<td>{{ user.last_login | localtime if user.last_login else 'Never' }}</td>
|
||||
<td class="actions">
|
||||
{% if user.id != current_user.id %}
|
||||
<button class="btn btn-sm btn-warning" onclick="showEditUserModal({{ user.id }}, '{{ user.username }}', '{{ user.role }}')">
|
||||
Edit Role
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="showResetPasswordModal({{ user.id }}, '{{ user.username }}')">
|
||||
Reset Password
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="confirmDeleteUser({{ user.id }}, '{{ user.username }}')">
|
||||
Delete
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted">Current User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">No users found</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role Descriptions -->
|
||||
<div class="card">
|
||||
<h2>📖 Role Descriptions</h2>
|
||||
<div class="role-descriptions">
|
||||
<div class="role-item">
|
||||
<h3>👤 Normal User</h3>
|
||||
<ul>
|
||||
<li>Upload media content</li>
|
||||
<li>Add/remove media from playlists</li>
|
||||
<li>Edit media in playlists</li>
|
||||
<li>Set display time for media items</li>
|
||||
<li>View players and groups</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="role-item">
|
||||
<h3>👑 Admin User</h3>
|
||||
<ul>
|
||||
<li>All normal user permissions</li>
|
||||
<li>Create and manage users</li>
|
||||
<li>Manage players and groups</li>
|
||||
<li>Delete content</li>
|
||||
<li>Access system settings</li>
|
||||
<li>View system logs</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div id="createUserModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Create New User</h2>
|
||||
<span class="close" onclick="closeModal('createUserModal')">×</span>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin.create_user') }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Username *</label>
|
||||
<input type="text" id="username" name="username" required minlength="3"
|
||||
placeholder="Enter username" class="form-control">
|
||||
<small>Minimum 3 characters</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password *</label>
|
||||
<input type="password" id="password" name="password" required minlength="6"
|
||||
placeholder="Enter password" class="form-control">
|
||||
<small>Minimum 6 characters</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="role">Role *</label>
|
||||
<select id="role" name="role" required class="form-control">
|
||||
<option value="user">Normal User</option>
|
||||
<option value="admin">Admin User</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('createUserModal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Modal -->
|
||||
<div id="editRoleModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Edit User Role</h2>
|
||||
<span class="close" onclick="closeModal('editRoleModal')">×</span>
|
||||
</div>
|
||||
<form method="POST" id="editRoleForm">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="edit_username" readonly class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit_role">New Role *</label>
|
||||
<select id="edit_role" name="role" required class="form-control">
|
||||
<option value="user">Normal User</option>
|
||||
<option value="admin">Admin User</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('editRoleModal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Update Role</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Modal -->
|
||||
<div id="resetPasswordModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Reset User Password</h2>
|
||||
<span class="close" onclick="closeModal('resetPasswordModal')">×</span>
|
||||
</div>
|
||||
<form method="POST" id="resetPasswordForm">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="reset_username" readonly class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password *</label>
|
||||
<input type="password" id="new_password" name="password" required minlength="6"
|
||||
placeholder="Enter new password" class="form-control">
|
||||
<small>Minimum 6 characters</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('resetPasswordModal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Reset Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header .btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.page-header .btn-primary:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
body.dark-mode .page-header .btn-primary {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .page-header .btn-primary:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.user-table th,
|
||||
.user-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.user-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.user-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
body.dark-mode .user-table th,
|
||||
body.dark-mode .user-table td {
|
||||
border-bottom: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .user-table th {
|
||||
background: #1a202c;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .user-table tbody tr:hover {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.role-descriptions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.role-item h3 {
|
||||
margin-bottom: 10px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.role-item ul {
|
||||
list-style-position: inside;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.role-item li {
|
||||
padding: 5px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
body.dark-mode .role-item {
|
||||
background: #1a202c;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .role-item h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .role-item li {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fff;
|
||||
margin: 5% auto;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
body.dark-mode .modal-content {
|
||||
background-color: #2d3748;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-header {
|
||||
border-bottom: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-header h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.close {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #6c757d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group small {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-footer {
|
||||
border-top: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function showCreateUserModal() {
|
||||
document.getElementById('createUserModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function showEditUserModal(userId, username, currentRole) {
|
||||
document.getElementById('edit_username').value = username;
|
||||
document.getElementById('edit_role').value = currentRole;
|
||||
document.getElementById('editRoleForm').action = `/admin/user/${userId}/role`;
|
||||
document.getElementById('editRoleModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function showResetPasswordModal(userId, username) {
|
||||
document.getElementById('reset_username').value = username;
|
||||
document.getElementById('resetPasswordForm').action = `/admin/user/${userId}/password`;
|
||||
document.getElementById('resetPasswordModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).style.display = 'none';
|
||||
}
|
||||
|
||||
function confirmDeleteUser(userId, username) {
|
||||
if (confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/admin/user/${userId}/delete`;
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.onclick = function(event) {
|
||||
const modals = document.getElementsByClassName('modal');
|
||||
for (let modal of modals) {
|
||||
if (event.target === modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Change Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 500px; margin-top: 50px;">
|
||||
<h2>Change Password</h2>
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label>Current Password</label>
|
||||
<input type="password" name="current_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" name="new_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - DigiServer</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.logo-section img {
|
||||
max-width: 70%;
|
||||
max-height: 70%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 10px 30px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.form-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-form h2 {
|
||||
color: #2d3748;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #4a5568;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.remember-me input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.remember-me label {
|
||||
color: #4a5568;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.register-link {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.register-link a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.register-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
border: 1px solid #fc8181;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #c6f6d5;
|
||||
color: #2f855a;
|
||||
border: 1px solid #68d391;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #feebc8;
|
||||
color: #c05621;
|
||||
border: 1px solid #f6ad55;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
flex: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<!-- Logo Section (Left - 2/3) -->
|
||||
<div class="logo-section">
|
||||
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ range(1, 999999) | random }}"
|
||||
alt="DigiServer Logo"
|
||||
onerror="this.style.display='none';">
|
||||
</div>
|
||||
|
||||
<!-- Form Section (Right - 1/3) -->
|
||||
<div class="form-section">
|
||||
<div class="login-form">
|
||||
<h2>Welcome Back</h2>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<div class="remember-me">
|
||||
<input type="checkbox" id="remember" name="remember" value="yes">
|
||||
<label for="remember">Remember me</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-login">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card" style="max-width: 400px; margin: 2rem auto;">
|
||||
<h2>Register</h2>
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="username" style="display: block; margin-bottom: 0.5rem;">Username</label>
|
||||
<input type="text" id="username" name="username" required minlength="3"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<small style="color: #7f8c8d;">Minimum 3 characters</small>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="password" style="display: block; margin-bottom: 0.5rem;">Password</label>
|
||||
<input type="password" id="password" name="password" required minlength="6"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<small style="color: #7f8c8d;">Minimum 6 characters</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success" style="width: 100%;">Register</button>
|
||||
</form>
|
||||
<p style="margin-top: 1rem; text-align: center;">
|
||||
Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,463 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}DigiServer v2{% endblock %}</title>
|
||||
<style>
|
||||
/* Ensure emoji font support */
|
||||
@supports (font-family: "Apple Color Emoji") {
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Light Mode Colors */
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--primary-color: #667eea;
|
||||
--primary-dark: #5568d3;
|
||||
--secondary-color: #764ba2;
|
||||
--bg-color: #f5f7fa;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #2d3748;
|
||||
--text-secondary: #718096;
|
||||
--border-color: #e2e8f0;
|
||||
--header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body.dark-mode {
|
||||
/* Dark Mode Colors */
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--primary-color: #7c3aed;
|
||||
--primary-dark: #6d28d9;
|
||||
--secondary-color: #8b5cf6;
|
||||
--bg-color: #1a202c;
|
||||
--card-bg: #2d3748;
|
||||
--text-color: #e2e8f0;
|
||||
--text-secondary: #a0aec0;
|
||||
--border-color: #4a5568;
|
||||
--header-bg: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background: var(--bg-color);
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
header {
|
||||
background: var(--header-bg);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
header .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
nav a:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
nav a img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
header .container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.25rem;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
nav {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
nav a {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.dark-mode-toggle {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 15px;
|
||||
margin: -1rem -1rem 1rem -1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
header h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
|
||||
nav a img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dark-mode-toggle {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
margin-left: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.dark-mode-toggle:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.dark-mode-toggle img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* Emoji fallback for systems without emoji fonts */
|
||||
.emoji-fallback::before {
|
||||
content: attr(data-emoji);
|
||||
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
||||
}
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
border-color: #28a745;
|
||||
color: #155724;
|
||||
}
|
||||
body.dark-mode .alert-success {
|
||||
background: rgba(40, 167, 69, 0.2);
|
||||
border-color: #28a745;
|
||||
color: #7ce3a3;
|
||||
}
|
||||
.alert-danger {
|
||||
background: #f8d7da;
|
||||
border-color: #dc3545;
|
||||
color: #721c24;
|
||||
}
|
||||
body.dark-mode .alert-danger {
|
||||
background: rgba(220, 53, 69, 0.2);
|
||||
border-color: #dc3545;
|
||||
color: #f88f9a;
|
||||
}
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
border-color: #ffc107;
|
||||
color: #856404;
|
||||
}
|
||||
body.dark-mode .alert-warning {
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
border-color: #ffc107;
|
||||
color: #ffd454;
|
||||
}
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
border-color: #17a2b8;
|
||||
color: #0c5460;
|
||||
}
|
||||
body.dark-mode .alert-info {
|
||||
background: rgba(23, 162, 184, 0.2);
|
||||
border-color: #17a2b8;
|
||||
color: #7dd3e0;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.3s;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.card h2 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.card h3 {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Card with gradient header */
|
||||
.card-header {
|
||||
background: var(--primary-gradient);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
|
||||
}
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary-gradient);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
.btn-danger { background: #e74c3c; }
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4);
|
||||
}
|
||||
.btn-success { background: #27ae60; }
|
||||
.btn-success:hover {
|
||||
background: #229954;
|
||||
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
|
||||
}
|
||||
.btn-info {
|
||||
background: #3498db;
|
||||
}
|
||||
.btn-info:hover {
|
||||
background: #2980b9;
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Form Inputs */
|
||||
input, textarea, select {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
body.dark-mode input:focus,
|
||||
body.dark-mode textarea:focus,
|
||||
body.dark-mode select:focus {
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
th {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
tr {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
code, pre {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>
|
||||
<img src="{{ url_for('static', filename='uploads/header_logo.png?v=1') }}" alt="DigiServer" style="height: 32px; width: auto; margin-right: 8px;" onerror="this.style.display='none';" onload="this.style.display='inline';">
|
||||
DigiServer
|
||||
</h1>
|
||||
<nav>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('main.dashboard') }}">
|
||||
<img src="{{ url_for('static', filename='icons/home.svg') }}" alt="">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('players.list') }}">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="">
|
||||
Players
|
||||
</a>
|
||||
<a href="{{ url_for('content.content_list') }}">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="">
|
||||
Playlists
|
||||
</a>
|
||||
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
|
||||
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
|
||||
<button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode">
|
||||
<img id="theme-icon" src="{{ url_for('static', filename='icons/moon.svg') }}" alt="Toggle theme">
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">Login</a>
|
||||
<a href="{{ url_for('auth.register') }}">Register</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
DigiServer v2.0.0-alpha | Blueprint Architecture | {{ server_version if server_version else 'Development' }}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Dark Mode Toggle
|
||||
function toggleDarkMode() {
|
||||
const body = document.body;
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
|
||||
body.classList.toggle('dark-mode');
|
||||
|
||||
// Update icon
|
||||
if (body.classList.contains('dark-mode')) {
|
||||
themeIcon.src = "{{ url_for('static', filename='icons/sun.svg') }}";
|
||||
localStorage.setItem('darkMode', 'enabled');
|
||||
} else {
|
||||
themeIcon.src = "{{ url_for('static', filename='icons/moon.svg') }}";
|
||||
localStorage.setItem('darkMode', 'disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved theme preference
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const darkMode = localStorage.getItem('darkMode');
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
|
||||
if (darkMode === 'enabled') {
|
||||
document.body.classList.add('dark-mode');
|
||||
if (themeIcon) {
|
||||
themeIcon.src = "{{ url_for('static', filename='icons/sun.svg') }}";
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,205 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Content Library - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h1>Content Library</h1>
|
||||
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">+ Upload Content</a>
|
||||
</div>
|
||||
|
||||
{% if content_list %}
|
||||
<div class="card">
|
||||
<div style="margin-bottom: 15px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
|
||||
<strong>Total Files:</strong> {{ content_list|length }} |
|
||||
<strong>Total Assignments:</strong> {% set total = namespace(count=0) %}{% for item in content_list %}{% set total.count = total.count + item.player_count %}{% endfor %}{{ total.count }}
|
||||
</div>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa; text-align: left;">
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">File Name</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Type</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned To</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in content_list %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 12px;">
|
||||
<strong>{{ item.filename }}</strong>
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{% if item.content_type == 'image' %}
|
||||
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
|
||||
{% elif item.content_type == 'video' %}
|
||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
|
||||
{% elif item.content_type == 'pdf' %}
|
||||
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
|
||||
{% elif item.content_type == 'presentation' %}
|
||||
<span style="background: #ffc107; color: black; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📊 PPT</span>
|
||||
{% else %}
|
||||
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{{ item.duration }}s
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{{ item.file_size }} MB
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{% if item.player_count == 0 %}
|
||||
<span style="color: #6c757d; font-style: italic;">Not assigned</span>
|
||||
{% else %}
|
||||
<div style="max-height: 100px; overflow-y: auto;">
|
||||
{% for player in item.players %}
|
||||
<div style="margin-bottom: 5px;">
|
||||
<strong>{{ player.name }}</strong>
|
||||
{% if player.group %}
|
||||
<span style="color: #6c757d; font-size: 12px;">({{ player.group }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="margin-top: 5px;">
|
||||
<span style="background: #007bff; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
|
||||
{{ item.player_count }} player{% if item.player_count != 1 %}s{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<small style="color: #6c757d;">{{ item.uploaded_at | localtime }}</small>
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{% if item.player_count > 0 %}
|
||||
{% set first_player = item.players[0] %}
|
||||
<a href="{{ url_for('players.player_page', player_id=first_player.id) }}"
|
||||
class="btn btn-primary btn-sm"
|
||||
title="Manage Playlist for {{ first_player.name }}"
|
||||
style="margin-bottom: 5px;">
|
||||
📝 Manage Playlist
|
||||
</a>
|
||||
{% if item.player_count > 1 %}
|
||||
<button onclick="showAllPlayers('{{ item.filename|replace("'", "\\'") }}', {{ item.players|tojson }})"
|
||||
class="btn btn-info btn-sm"
|
||||
title="View all players with this content">
|
||||
👥 View All ({{ item.player_count }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<button onclick="deleteContent('{{ item.filename|replace("'", "\\'") }}')"
|
||||
class="btn btn-danger btn-sm"
|
||||
title="Delete this content from all playlists"
|
||||
style="margin-top: 5px;">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
|
||||
ℹ️ No content uploaded yet. <a href="{{ url_for('content.upload_content') }}" style="color: #0c5460; text-decoration: underline;">Upload your first content</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Modal for viewing all players -->
|
||||
<div id="playersModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 600px; margin: 100px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
||||
<h2 id="modalTitle" style="margin-bottom: 20px; color: #2c3e50;">Players with this content</h2>
|
||||
<div id="playersList" style="max-height: 400px; overflow-y: auto;"></div>
|
||||
<div style="text-align: center; margin-top: 20px;">
|
||||
<button type="button" class="btn" onclick="closePlayersModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function showAllPlayers(filename, players) {
|
||||
document.getElementById('modalTitle').textContent = 'Players with: ' + filename;
|
||||
|
||||
const playersList = document.getElementById('playersList');
|
||||
playersList.innerHTML = '<table style="width: 100%; border-collapse: collapse;">';
|
||||
playersList.innerHTML += '<thead><tr style="background: #f8f9fa;"><th style="padding: 10px; text-align: left;">Player Name</th><th style="padding: 10px; text-align: left;">Group</th><th style="padding: 10px; text-align: left;">Action</th></tr></thead><tbody>';
|
||||
|
||||
players.forEach(player => {
|
||||
playersList.innerHTML += `
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px;"><strong>${player.name}</strong></td>
|
||||
<td style="padding: 10px;">${player.group || '-'}</td>
|
||||
<td style="padding: 10px;">
|
||||
<a href="/players/${player.id}" class="btn btn-sm" style="background: #007bff; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">
|
||||
Manage Playlist
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
playersList.innerHTML += '</tbody></table>';
|
||||
|
||||
document.getElementById('playersModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closePlayersModal() {
|
||||
document.getElementById('playersModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function deleteContent(filename) {
|
||||
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis will remove it from ALL player playlists!`)) {
|
||||
fetch('/content/delete-by-filename', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: filename
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(`Successfully deleted "${filename}" from ${data.deleted_count} playlist(s)`);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error deleting content: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error deleting content: ' + error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('playersModal');
|
||||
if (event.target == modal) {
|
||||
closePlayersModal();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,550 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Playlist Management - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.playlist-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #667eea;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.playlist-item:hover {
|
||||
background: #e9ecef;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-item {
|
||||
background: #1a202c;
|
||||
border-left-color: #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-item:hover {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
.playlist-info h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.playlist-stats {
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-stats {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.playlist-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode small {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #667eea;
|
||||
background: #f0f2ff;
|
||||
}
|
||||
|
||||
.upload-zone.drag-over {
|
||||
border-color: #667eea;
|
||||
background: #e7e9ff;
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.file-input-wrapper input[type=file] {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
}
|
||||
|
||||
.media-library {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
background: white;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-item:hover {
|
||||
border-color: #667eea;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
body.dark-mode .media-item {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .media-item:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.media-thumbnail {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
body.dark-mode .media-thumbnail {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
.media-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
body.dark-mode .media-name {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Table styling for dark mode */
|
||||
body.dark-mode table thead tr {
|
||||
background: #1a202c !important;
|
||||
}
|
||||
|
||||
body.dark-mode table th {
|
||||
color: #e2e8f0;
|
||||
border-bottom-color: #4a5568 !important;
|
||||
}
|
||||
|
||||
body.dark-mode table tbody tr {
|
||||
border-bottom-color: #4a5568 !important;
|
||||
}
|
||||
|
||||
body.dark-mode table td {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode code {
|
||||
background: #1a202c !important;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Dark mode for upload section */
|
||||
body.dark-mode .card > div[style*="background: #f8f9fa"] {
|
||||
background: #2d3748 !important;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container" style="max-width: 1400px;">
|
||||
<h1 style="margin-bottom: 25px; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
Playlist Management
|
||||
</h1>
|
||||
|
||||
<div class="main-grid">
|
||||
<!-- Create Playlist Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
|
||||
Create New Playlist
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Create New Playlist Form -->
|
||||
<form method="POST" action="{{ url_for('content.create_playlist') }}">
|
||||
<div class="form-group">
|
||||
<label for="playlist_name">Playlist Name *</label>
|
||||
<input type="text" name="name" id="playlist_name" class="form-control" required
|
||||
placeholder="e.g., Main Lobby Display">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="playlist_orientation">Content Orientation *</label>
|
||||
<select name="orientation" id="playlist_orientation" class="form-control" required>
|
||||
<option value="Landscape">Landscape (Horizontal)</option>
|
||||
<option value="Portrait">Portrait (Vertical)</option>
|
||||
</select>
|
||||
<small style="color: #6c757d; font-size: 12px; display: block; margin-top: 5px;">
|
||||
Select the orientation that matches your display screens
|
||||
</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="playlist_description">Description (Optional)</label>
|
||||
<textarea name="description" id="playlist_description" class="form-control"
|
||||
placeholder="Describe the purpose of this playlist..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
➕ Create Playlist
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Upload Media Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
|
||||
Media Library
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Compact Upload Section -->
|
||||
<div style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 8px; margin-bottom: 20px;">
|
||||
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
|
||||
Upload New Media
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Media Library with Thumbnails -->
|
||||
<h3 style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span>📚 Last 3 Added Media</span>
|
||||
<small style="color: #6c757d; font-size: 0.85rem;">Total: {{ total_media_count }}</small>
|
||||
</h3>
|
||||
<div class="media-library" style="max-height: 350px; overflow-y: auto;">
|
||||
{% if media_files %}
|
||||
{% for media in media_files %}
|
||||
<div class="media-item" title="{{ media.filename }}">
|
||||
{% if media.content_type == 'image' %}
|
||||
<div class="media-thumbnail" style="width: 100%; height: 100px; overflow: hidden; border-radius: 6px; margin-bottom: 8px; background: #f0f0f0; display: flex; align-items: center; justify-content: center;">
|
||||
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
|
||||
alt="{{ media.filename }}"
|
||||
style="max-width: 100%; max-height: 100%; object-fit: cover;"
|
||||
onerror="this.style.display='none'; this.parentElement.innerHTML='<span style=\'font-size: 48px;\'>📷</span>'">
|
||||
</div>
|
||||
{% elif media.content_type == 'video' %}
|
||||
<div class="media-icon" style="height: 100px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 6px; margin-bottom: 8px;">
|
||||
🎥
|
||||
</div>
|
||||
{% elif media.content_type == 'pdf' %}
|
||||
<div class="media-icon" style="height: 100px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 6px; margin-bottom: 8px;">
|
||||
📄
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="media-icon" style="height: 100px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 6px; margin-bottom: 8px;">
|
||||
📁
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="media-name" style="font-size: 11px; line-height: 1.3;">{{ media.filename[:25] }}{% if media.filename|length > 25 %}...{% endif %}</div>
|
||||
<div style="font-size: 10px; color: #999; margin-top: 4px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999; grid-column: 1 / -1;">
|
||||
<div style="font-size: 48px; margin-bottom: 10px;">📭</div>
|
||||
<p>No media files yet. Upload your first file!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- View All Media Button -->
|
||||
{% if total_media_count > 3 %}
|
||||
<div style="text-align: center; padding: 15px; border-top: 1px solid #dee2e6; margin-top: 10px;">
|
||||
<a href="{{ url_for('content.media_library') }}" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<span>📚</span>
|
||||
View All Media ({{ total_media_count }} files)
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Playlists Card -->
|
||||
<div class="card full-width">
|
||||
<div class="card-header">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
|
||||
Existing Playlists
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="playlist-list">
|
||||
{% if playlists %}
|
||||
{% for playlist in playlists %}
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-info">
|
||||
<h3>{{ playlist.name }}</h3>
|
||||
<div class="playlist-stats">
|
||||
📊 {{ playlist.content_count }} items |
|
||||
👥 {{ playlist.player_count }} players |
|
||||
🔄 v{{ playlist.version }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-actions">
|
||||
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
✏️ Manage
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Delete playlist {{ playlist.name }}?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm" style="display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/trash.svg') }}" alt="" style="width: 14px; height: 14px; filter: brightness(0) invert(1);">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 64px; height: 64px; opacity: 0.3; margin-bottom: 10px;">
|
||||
<p>No playlists yet. Create your first playlist above!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Players to Playlists Card -->
|
||||
<div class="card full-width">
|
||||
<div class="card-header">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
|
||||
Player Assignments
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa; text-align: left;">
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Player Name</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Hostname</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned Playlist</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for player in players %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 12px;"><strong>{{ player.name }}</strong></td>
|
||||
<td style="padding: 12px;">
|
||||
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">
|
||||
{{ player.hostname }}
|
||||
</code>
|
||||
</td>
|
||||
<td style="padding: 12px;">{{ player.location or '-' }}</td>
|
||||
<td style="padding: 12px;">
|
||||
<form method="POST" action="{{ url_for('content.assign_player_to_playlist', player_id=player.id) }}"
|
||||
style="display: inline;">
|
||||
<select name="playlist_id" class="form-control" style="width: auto; display: inline-block;"
|
||||
onchange="this.form.submit()">
|
||||
<option value="">No Playlist</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}"
|
||||
{% if player.playlist_id == playlist.id %}selected{% endif %}>
|
||||
{{ playlist.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{% if player.is_online %}
|
||||
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
|
||||
🟢 Online
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
|
||||
⚫ Offline
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
⚙️ Manage
|
||||
</a>
|
||||
{% if player.playlist_id %}
|
||||
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
|
||||
class="btn btn-success btn-sm" target="_blank"
|
||||
title="View live content preview">
|
||||
🖥️ Live
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// File upload handling
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const uploadZone = document.getElementById('upload-zone');
|
||||
const fileList = document.getElementById('file-list');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
|
||||
fileInput.addEventListener('change', handleFiles);
|
||||
|
||||
// Drag and drop
|
||||
uploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('dragleave', () => {
|
||||
uploadZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('drag-over');
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
handleFiles();
|
||||
});
|
||||
|
||||
function handleFiles() {
|
||||
const files = fileInput.files;
|
||||
fileList.innerHTML = '';
|
||||
|
||||
if (files.length > 0) {
|
||||
uploadBtn.disabled = false;
|
||||
const ul = document.createElement('ul');
|
||||
ul.style.cssText = 'list-style: none; padding: 0;';
|
||||
|
||||
for (let file of files) {
|
||||
const li = document.createElement('li');
|
||||
li.style.cssText = 'padding: 8px; background: #f8f9fa; margin-bottom: 5px; border-radius: 4px;';
|
||||
li.textContent = `📎 ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
|
||||
fileList.appendChild(ul);
|
||||
} else {
|
||||
uploadBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Content{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Edit Content</h2>
|
||||
<p>Edit content functionality - placeholder</p>
|
||||
<a href="{{ url_for('content.list') }}" class="btn btn-secondary">Back to Content</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,719 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage {{ playlist.name }} - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.playlist-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.playlist-header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.playlist-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.draggable-row {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.draggable-row:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.available-content {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-item {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Audio toggle styles */
|
||||
.audio-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Duration spinner control */
|
||||
.duration-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.duration-display {
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
padding: 6px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.duration-spinner button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.duration-spinner button:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.duration-spinner button:active {
|
||||
background: #e0e0e0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.duration-spinner button.btn-increase {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.duration-spinner button.btn-decrease {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.audio-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.audio-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-label {
|
||||
font-size: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.audio-checkbox + .audio-label .audio-on {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-checkbox + .audio-label .audio-off {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.audio-checkbox:checked + .audio-label .audio-on {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.audio-checkbox:checked + .audio-label .audio-off {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-label:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
body.dark-mode .playlist-table th {
|
||||
background: #1a202c;
|
||||
color: #cbd5e0;
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-table td {
|
||||
border-bottom-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .draggable-row:hover {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
body.dark-mode .drag-handle {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
body.dark-mode .content-item {
|
||||
background: #1a202c;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .available-content {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Dark mode for duration spinner */
|
||||
body.dark-mode .duration-display {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-spinner button {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-spinner button:hover {
|
||||
background: #4a5568;
|
||||
border-color: #718096;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-spinner button:active {
|
||||
background: #5a6a78;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-spinner button.btn-increase {
|
||||
color: #48bb78;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-spinner button.btn-decrease {
|
||||
color: #f56565;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container" style="max-width: 1400px;">
|
||||
<div class="playlist-header">
|
||||
<h1>🎬 {{ playlist.name }}</h1>
|
||||
{% if playlist.description %}
|
||||
<p style="margin: 5px 0; opacity: 0.9;">{{ playlist.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Content Items</span>
|
||||
<span class="stat-value">{{ playlist_content|length }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Duration</span>
|
||||
<span class="stat-value">{{ playlist.total_duration }}s</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Version</span>
|
||||
<span class="stat-value">{{ playlist.version }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Players Assigned</span>
|
||||
<span class="stat-value">{{ playlist.player_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">
|
||||
← Back to Playlists
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2 style="margin: 0;">📋 Playlist Content (Drag to Reorder)</h2>
|
||||
<button id="bulk-delete-btn" class="btn btn-danger" style="display: none;" onclick="bulkDeleteSelected()">
|
||||
🗑️ Delete Selected (<span id="selected-count">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if playlist_content %}
|
||||
<table class="playlist-table" id="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" title="Select all">
|
||||
</th>
|
||||
<th style="width: 40px;"></th>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Filename</th>
|
||||
<th style="width: 100px;">Type</th>
|
||||
<th style="width: 100px;">Duration</th>
|
||||
<th style="width: 80px;">Audio</th>
|
||||
<th style="width: 80px;">Edit</th>
|
||||
<th style="width: 100px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-tbody">
|
||||
{% for content in playlist_content %}
|
||||
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
|
||||
<td>
|
||||
<input type="checkbox" class="content-checkbox" data-content-id="{{ content.id }}" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
<td><span class="drag-handle">⋮⋮</span></td>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ content.filename }}</td>
|
||||
<td>
|
||||
{% if content.content_type == 'image' %}📷 Image
|
||||
{% elif content.content_type == 'video' %}🎥 Video
|
||||
{% elif content.content_type == 'pdf' %}📄 PDF
|
||||
{% else %}📁 Other{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="duration-spinner">
|
||||
<button type="button"
|
||||
class="btn-decrease"
|
||||
onclick="event.stopPropagation(); changeDuration({{ content.id }}, -1)"
|
||||
onmousedown="event.stopPropagation()"
|
||||
title="Decrease duration by 1 second">
|
||||
⬇️
|
||||
</button>
|
||||
<div class="duration-display" id="duration-display-{{ content.id }}">
|
||||
{{ content._playlist_duration or content.duration }}s
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn-increase"
|
||||
onclick="event.stopPropagation(); changeDuration({{ content.id }}, 1)"
|
||||
onmousedown="event.stopPropagation()"
|
||||
title="Increase duration by 1 second">
|
||||
⬆️
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if content.content_type == 'video' %}
|
||||
<label class="audio-toggle">
|
||||
<input type="checkbox"
|
||||
class="audio-checkbox"
|
||||
data-content-id="{{ content.id }}"
|
||||
{{ 'checked' if not content._playlist_muted else '' }}
|
||||
onchange="toggleAudio({{ content.id }}, this.checked)">
|
||||
<span class="audio-label">
|
||||
<span class="audio-on">🔊</span>
|
||||
<span class="audio-off">🔇</span>
|
||||
</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<span style="color: #999;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if content.content_type in ['image', 'pdf'] %}
|
||||
<label class="audio-toggle">
|
||||
<input type="checkbox"
|
||||
class="edit-checkbox"
|
||||
data-content-id="{{ content.id }}"
|
||||
{{ 'checked' if content._playlist_edit_on_player_enabled else '' }}
|
||||
onchange="toggleEdit({{ content.id }}, this.checked)">
|
||||
<span class="audio-label">
|
||||
<span class="audio-on">✏️</span>
|
||||
<span class="audio-off">🔒</span>
|
||||
</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<span style="color: #999;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Remove from playlist?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<div style="font-size: 48px;">📭</div>
|
||||
<p>No content in playlist yet. Add content from the right panel.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 20px;">➕ Add Content</h2>
|
||||
|
||||
{% if available_content %}
|
||||
<div class="available-content">
|
||||
{% for content in available_content %}
|
||||
<div class="content-item">
|
||||
<div>
|
||||
<div>
|
||||
{% if content.content_type == 'image' %}📷
|
||||
{% elif content.content_type == 'video' %}🎥
|
||||
{% elif content.content_type == 'pdf' %}📄
|
||||
{% else %}📁{% endif %}
|
||||
{{ content.filename }}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #999;">
|
||||
{{ content.file_size_mb }} MB
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST"
|
||||
action="{{ url_for('content.add_content_to_playlist', playlist_id=playlist.id) }}"
|
||||
style="display: inline;">
|
||||
<input type="hidden" name="content_id" value="{{ content.id }}">
|
||||
<input type="hidden" name="duration" value="{{ content.duration }}">
|
||||
<button type="submit" class="btn btn-success btn-sm">
|
||||
+ Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<p>All available content has been added to this playlist!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let draggedElement = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tbody = document.getElementById('playlist-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const rows = tbody.querySelectorAll('.draggable-row');
|
||||
|
||||
rows.forEach(row => {
|
||||
row.addEventListener('dragstart', handleDragStart);
|
||||
row.addEventListener('dragover', handleDragOver);
|
||||
row.addEventListener('drop', handleDrop);
|
||||
row.addEventListener('dragend', handleDragEnd);
|
||||
});
|
||||
});
|
||||
|
||||
function handleDragStart(e) {
|
||||
draggedElement = this;
|
||||
this.style.opacity = '0.5';
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
if (e.stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (draggedElement !== this) {
|
||||
const tbody = document.getElementById('playlist-tbody');
|
||||
const allRows = [...tbody.querySelectorAll('.draggable-row')];
|
||||
const draggedIndex = allRows.indexOf(draggedElement);
|
||||
const targetIndex = allRows.indexOf(this);
|
||||
|
||||
if (draggedIndex < targetIndex) {
|
||||
this.parentNode.insertBefore(draggedElement, this.nextSibling);
|
||||
} else {
|
||||
this.parentNode.insertBefore(draggedElement, this);
|
||||
}
|
||||
|
||||
updateRowNumbers();
|
||||
saveOrder();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
this.style.opacity = '1';
|
||||
}
|
||||
|
||||
function updateRowNumbers() {
|
||||
const rows = document.querySelectorAll('#playlist-tbody tr');
|
||||
rows.forEach((row, index) => {
|
||||
row.querySelector('td:nth-child(2)').textContent = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
function saveOrder() {
|
||||
const rows = document.querySelectorAll('#playlist-tbody .draggable-row');
|
||||
const contentIds = Array.from(rows).map(row => parseInt(row.dataset.contentId));
|
||||
|
||||
fetch('{{ url_for("content.reorder_playlist_content", playlist_id=playlist.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content_ids: contentIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
alert('Error reordering: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Change duration with spinner buttons
|
||||
function changeDuration(contentId, change) {
|
||||
const displayElement = document.getElementById(`duration-display-${contentId}`);
|
||||
const currentText = displayElement.textContent;
|
||||
const currentDuration = parseInt(currentText);
|
||||
const newDuration = currentDuration + change;
|
||||
|
||||
// Validate duration (minimum 1 second)
|
||||
if (newDuration < 1) {
|
||||
alert('Duration must be at least 1 second');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update display immediately for visual feedback
|
||||
displayElement.style.opacity = '0.7';
|
||||
displayElement.textContent = newDuration + 's';
|
||||
|
||||
// Save to server
|
||||
const playlistId = {{ playlist.id }};
|
||||
const url = `/content/playlist/${playlistId}/update-duration/${contentId}`;
|
||||
const formData = new FormData();
|
||||
formData.append('duration', newDuration);
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Duration updated successfully');
|
||||
displayElement.style.opacity = '1';
|
||||
displayElement.style.color = '#28a745';
|
||||
setTimeout(() => {
|
||||
displayElement.style.color = '';
|
||||
}, 1000);
|
||||
} else {
|
||||
// Revert on error
|
||||
displayElement.textContent = currentDuration + 's';
|
||||
displayElement.style.opacity = '1';
|
||||
alert('Error updating duration: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Revert on error
|
||||
displayElement.textContent = currentDuration + 's';
|
||||
displayElement.style.opacity = '1';
|
||||
console.error('Error:', error);
|
||||
alert('Error updating duration');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAudio(contentId, enabled) {
|
||||
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
|
||||
const playlistId = {{ playlist.id }};
|
||||
const url = `/content/playlist/${playlistId}/update-muted/${contentId}`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('muted', muted ? 'true' : 'false');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Audio setting updated:', enabled ? 'Enabled' : 'Muted');
|
||||
} else {
|
||||
alert('Error updating audio setting: ' + data.message);
|
||||
// Revert checkbox on error
|
||||
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
||||
if (checkbox) checkbox.checked = !enabled;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error updating audio setting');
|
||||
// Revert checkbox on error
|
||||
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
||||
if (checkbox) checkbox.checked = !enabled;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEdit(contentId, enabled) {
|
||||
const playlistId = {{ playlist.id }};
|
||||
const url = `/content/playlist/${playlistId}/update-edit-enabled/${contentId}`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('edit_enabled', enabled ? 'true' : 'false');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Edit setting updated:', enabled ? 'Enabled' : 'Disabled');
|
||||
} else {
|
||||
alert('Error updating edit setting: ' + data.message);
|
||||
// Revert checkbox on error
|
||||
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
|
||||
if (checkbox) checkbox.checked = !enabled;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error updating edit setting');
|
||||
// Revert checkbox on error
|
||||
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
|
||||
if (checkbox) checkbox.checked = !enabled;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.content-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
}
|
||||
|
||||
function updateBulkDeleteButton() {
|
||||
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
|
||||
const count = checkboxes.length;
|
||||
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
|
||||
if (count > 0) {
|
||||
bulkDeleteBtn.style.display = 'block';
|
||||
selectedCount.textContent = count;
|
||||
} else {
|
||||
bulkDeleteBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update select-all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.content-checkbox');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = allCheckboxes.length > 0 && count === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = count > 0 && count < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function bulkDeleteSelected() {
|
||||
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
|
||||
const contentIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.contentId));
|
||||
|
||||
if (contentIds.length === 0) {
|
||||
alert('No items selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmMsg = `Are you sure you want to remove ${contentIds.length} item(s) from this playlist?`;
|
||||
if (!confirm(confirmMsg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
|
||||
const originalText = bulkDeleteBtn.innerHTML;
|
||||
bulkDeleteBtn.disabled = true;
|
||||
bulkDeleteBtn.innerHTML = '⏳ Removing...';
|
||||
|
||||
fetch('{{ url_for("content.bulk_remove_from_playlist", playlist_id=playlist.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content_ids: contentIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Reload page to show updated playlist
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error removing items: ' + data.message);
|
||||
bulkDeleteBtn.disabled = false;
|
||||
bulkDeleteBtn.innerHTML = originalText;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error removing items from playlist');
|
||||
bulkDeleteBtn.disabled = false;
|
||||
bulkDeleteBtn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,806 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Media Library - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.media-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
body.dark-mode .media-card {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.media-thumbnail {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
body.dark-mode .media-thumbnail {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
.media-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
body.dark-mode .media-info {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.media-filename {
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body.dark-mode .media-filename {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.media-card:hover .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #a02834;
|
||||
}
|
||||
|
||||
.playlist-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.playlist-badge.in-use {
|
||||
background: #ffc107;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.playlist-badge.unused {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-badge.in-use {
|
||||
background: #856404;
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
body.dark-mode .upload-section {
|
||||
background: #2d3748 !important;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-badge.unused {
|
||||
background: #1a4d2e;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.type-badge.image { background: #d4edda; color: #155724; }
|
||||
.type-badge.video { background: #cce5ff; color: #004085; }
|
||||
.type-badge.pdf { background: #fff3cd; color: #856404; }
|
||||
.type-badge.pptx { background: #f8d7da; color: #721c24; }
|
||||
|
||||
body.dark-mode .type-badge.image { background: #1a4d2e; color: #86efac; }
|
||||
body.dark-mode .type-badge.video { background: #1e3a5f; color: #93c5fd; }
|
||||
body.dark-mode .type-badge.pdf { background: #4a3800; color: #fbbf24; }
|
||||
body.dark-mode .type-badge.pptx { background: #4a1a1a; color: #fca5a5; }
|
||||
|
||||
.stats-box {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-item {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-value {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 30px 0 15px 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header {
|
||||
border-bottom-color: #a78bfa;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
📚 Media Library
|
||||
</h1>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="float: right; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Back to Playlists
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="stats-box">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ media_files|length }}</div>
|
||||
<div class="stat-label">Total Files</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ images|length }}</div>
|
||||
<div class="stat-label">Images</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ videos|length }}</div>
|
||||
<div class="stat-label">Videos</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ pdfs|length }}</div>
|
||||
<div class="stat-label">PDFs</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ presentations|length }}</div>
|
||||
<div class="stat-label">Presentations</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<div class="upload-section" style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 8px; margin-bottom: 30px;">
|
||||
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Upload New Media
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Images Section -->
|
||||
{% if images %}
|
||||
<div class="section-header">
|
||||
<span style="font-size: 32px;">📷</span>
|
||||
<h2>Images ({{ images|length }})</h2>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
{% for media in images %}
|
||||
<div class="media-card">
|
||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||
<div class="media-thumbnail">
|
||||
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
|
||||
alt="{{ media.filename }}"
|
||||
onerror="this.style.display='none'; this.parentElement.innerHTML='<span class=\'media-icon\'>📷</span>'">
|
||||
</div>
|
||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||
<div class="media-info">
|
||||
<span class="type-badge image">Image</span>
|
||||
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
|
||||
{% if media.playlists.count() > 0 %}
|
||||
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
|
||||
{% else %}
|
||||
<div class="playlist-badge unused">✓ Not in use</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Videos Section -->
|
||||
{% if videos %}
|
||||
<div class="section-header">
|
||||
<span style="font-size: 32px;">🎥</span>
|
||||
<h2>Videos ({{ videos|length }})</h2>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
{% for media in videos %}
|
||||
<div class="media-card">
|
||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||
<div class="media-thumbnail">
|
||||
<span class="media-icon">🎥</span>
|
||||
</div>
|
||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||
<div class="media-info">
|
||||
<span class="type-badge video">Video</span>
|
||||
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
|
||||
{% if media.playlists.count() > 0 %}
|
||||
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
|
||||
{% else %}
|
||||
<div class="playlist-badge unused">✓ Not in use</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- PDFs Section -->
|
||||
{% if pdfs %}
|
||||
<div class="section-header">
|
||||
<span style="font-size: 32px;">📄</span>
|
||||
<h2>PDFs ({{ pdfs|length }})</h2>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
{% for media in pdfs %}
|
||||
<div class="media-card">
|
||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||
<div class="media-thumbnail">
|
||||
<span class="media-icon">📄</span>
|
||||
</div>
|
||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||
<div class="media-info">
|
||||
<span class="type-badge pdf">PDF</span>
|
||||
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
|
||||
{% if media.playlists.count() > 0 %}
|
||||
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
|
||||
{% else %}
|
||||
<div class="playlist-badge unused">✓ Not in use</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Presentations Section -->
|
||||
{% if presentations %}
|
||||
<div class="section-header">
|
||||
<span style="font-size: 32px;">📊</span>
|
||||
<h2>Presentations ({{ presentations|length }})</h2>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
{% for media in presentations %}
|
||||
<div class="media-card">
|
||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||
<div class="media-thumbnail">
|
||||
<span class="media-icon">📊</span>
|
||||
</div>
|
||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||
<div class="media-info">
|
||||
<span class="type-badge pptx">PPTX</span>
|
||||
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
|
||||
{% if media.playlists.count() > 0 %}
|
||||
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
|
||||
{% else %}
|
||||
<div class="playlist-badge unused">✓ Not in use</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Others Section -->
|
||||
{% if others %}
|
||||
<div class="section-header">
|
||||
<span style="font-size: 32px;">📁</span>
|
||||
<h2>Other Files ({{ others|length }})</h2>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
{% for media in others %}
|
||||
<div class="media-card">
|
||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||
<div class="media-thumbnail">
|
||||
<span class="media-icon">📁</span>
|
||||
</div>
|
||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||
<div class="media-info">
|
||||
<span class="type-badge">{{ media.content_type }}</span>
|
||||
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
|
||||
{% if media.playlists.count() > 0 %}
|
||||
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
|
||||
{% else %}
|
||||
<div class="playlist-badge unused">✓ Not in use</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not media_files %}
|
||||
<div style="text-align: center; padding: 80px 20px;">
|
||||
<div style="font-size: 96px; margin-bottom: 20px;">📭</div>
|
||||
<h2 style="color: #6c757d;">No Media Files Yet</h2>
|
||||
<p style="color: #999; margin-bottom: 30px;">Start by uploading your first media file!</p>
|
||||
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Upload Media
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="deleteModal" class="delete-modal">
|
||||
<div class="delete-modal-content">
|
||||
<div class="delete-modal-header">
|
||||
<span class="delete-icon">⚠️</span>
|
||||
<h2>Confirm Delete</h2>
|
||||
</div>
|
||||
|
||||
<div class="delete-modal-body">
|
||||
<p class="delete-question">
|
||||
Are you sure you want to delete <strong id="deleteFilename" class="delete-filename"></strong>?
|
||||
</p>
|
||||
|
||||
<div id="playlistWarning" class="warning-box warning-playlist">
|
||||
<div class="warning-header">
|
||||
<span>⚠️</span>
|
||||
<strong>Playlist Warning</strong>
|
||||
</div>
|
||||
<p>This file is used in <strong id="playlistCount"></strong> playlist(s). Deleting it will remove it from all playlists and increment their version numbers.</p>
|
||||
</div>
|
||||
|
||||
<div id="editWarning" class="warning-box warning-edit">
|
||||
<div class="warning-header">
|
||||
<span>✏️</span>
|
||||
<strong>Edited Versions</strong>
|
||||
</div>
|
||||
<p>This file has <strong id="editCount"></strong> edited version(s) from player devices. All edited versions and their metadata will also be permanently deleted.</p>
|
||||
</div>
|
||||
|
||||
<div class="delete-final-warning">
|
||||
<strong>⚠️ This action cannot be undone!</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="delete-modal-footer">
|
||||
<button onclick="closeDeleteModal()" class="btn btn-cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<form id="deleteForm" method="POST">
|
||||
<button type="submit" class="btn btn-delete">
|
||||
Yes, Delete File
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Delete Modal - Light Mode Styles */
|
||||
.delete-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.delete-modal-content {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
margin: 8% auto;
|
||||
padding: 0;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 550px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: slideDown 0.3s ease-out;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-modal-header {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
color: white;
|
||||
padding: 24px 30px;
|
||||
border-radius: 16px 16px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
font-size: 2rem;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.delete-modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delete-modal-body {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.delete-question {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 20px 0;
|
||||
color: #2d3748;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.delete-filename {
|
||||
color: #dc3545;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
display: none;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.warning-box:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.warning-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.warning-header span {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.warning-header strong {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.warning-playlist {
|
||||
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.warning-playlist .warning-header,
|
||||
.warning-playlist p {
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.warning-edit {
|
||||
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.warning-edit .warning-header,
|
||||
.warning-edit p {
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.delete-final-warning {
|
||||
background: linear-gradient(135deg, #fee 0%, #fdd 100%);
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 20px 0 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.delete-final-warning strong {
|
||||
color: #dc3545;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.delete-modal-footer {
|
||||
padding: 20px 30px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 0 0 16px 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.delete-modal-footer form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: linear-gradient(135deg, #5a6268 0%, #4e555b 100%);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
color: white;
|
||||
padding: 12px 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 6px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: linear-gradient(135deg, #c82333 0%, #bd2130 100%);
|
||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Delete Modal - Dark Mode Styles */
|
||||
body.dark-mode .delete-modal {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
body.dark-mode .delete-modal-content {
|
||||
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
||||
border: 1px solid #4a5568;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
body.dark-mode .delete-modal-header {
|
||||
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
|
||||
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.4);
|
||||
}
|
||||
|
||||
body.dark-mode .delete-question {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .delete-filename {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
body.dark-mode .warning-playlist {
|
||||
background: linear-gradient(135deg, #422006 0%, #713f12 100%);
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
|
||||
body.dark-mode .warning-playlist .warning-header,
|
||||
body.dark-mode .warning-playlist p {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
body.dark-mode .warning-edit {
|
||||
background: linear-gradient(135deg, #3b0764 0%, #581c87 100%);
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
|
||||
body.dark-mode .warning-edit .warning-header,
|
||||
body.dark-mode .warning-edit p {
|
||||
color: #ddd6fe;
|
||||
}
|
||||
|
||||
body.dark-mode .delete-final-warning {
|
||||
background: linear-gradient(135deg, #450a0a 0%, #7f1d1d 100%);
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
body.dark-mode .delete-final-warning strong {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
body.dark-mode .delete-modal-footer {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-top: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-cancel {
|
||||
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
body.dark-mode .btn-cancel:hover {
|
||||
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
body.dark-mode .btn-delete {
|
||||
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
|
||||
box-shadow: 0 2px 6px rgba(185, 28, 28, 0.4);
|
||||
}
|
||||
|
||||
body.dark-mode .btn-delete:hover {
|
||||
background: linear-gradient(135deg, #991b1b 0%, #7f1d1d 100%);
|
||||
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.6);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let deleteMediaId = null;
|
||||
|
||||
function confirmDelete(mediaId, filename, playlistCount, editCount) {
|
||||
deleteMediaId = mediaId;
|
||||
document.getElementById('deleteFilename').textContent = filename;
|
||||
document.getElementById('deleteForm').action = "{{ url_for('content.delete_media', media_id=0) }}".replace('/0', '/' + mediaId);
|
||||
|
||||
// Show playlist warning if file is in use
|
||||
if (playlistCount > 0) {
|
||||
document.getElementById('playlistWarning').style.display = 'block';
|
||||
document.getElementById('playlistCount').textContent = playlistCount;
|
||||
} else {
|
||||
document.getElementById('playlistWarning').style.display = 'none';
|
||||
}
|
||||
|
||||
// Show edit versions warning if file has been edited
|
||||
if (editCount > 0) {
|
||||
document.getElementById('editWarning').style.display = 'block';
|
||||
document.getElementById('editCount').textContent = editCount;
|
||||
} else {
|
||||
document.getElementById('editWarning').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('deleteModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').style.display = 'none';
|
||||
deleteMediaId = null;
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('deleteModal');
|
||||
if (event.target == modal) {
|
||||
closeDeleteModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal with ESC key
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,278 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upload Content - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1200px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h1>Upload Content</h1>
|
||||
</div>
|
||||
|
||||
<form id="upload-form" method="POST" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
|
||||
<input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}">
|
||||
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<h3 style="margin-bottom: 15px;">Select Player</h3>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Player:</label>
|
||||
<select name="player_id" id="player_id" class="form-control" required>
|
||||
<option value="" disabled {% if not selected_player_id %}selected{% endif %}>Select a Player</option>
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}" {% if selected_player_id == player.id %}selected{% endif %}>
|
||||
{{ player.name }} - {{ player.location or 'No location' }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<h3 style="margin-bottom: 15px;">Media Details</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Media Type:</label>
|
||||
<select name="media_type" id="media_type" class="form-control" required onchange="handleMediaTypeChange()">
|
||||
<option value="image">Image (JPG, PNG, GIF)</option>
|
||||
<option value="video">Video (MP4, AVI, MOV)</option>
|
||||
<option value="pdf">PDF Document</option>
|
||||
<option value="ppt">PowerPoint (PPT/PPTX)</option>
|
||||
</select>
|
||||
<small style="color: #6c757d; display: block; margin-top: 5px;" id="media-type-hint">
|
||||
Images will be displayed as-is
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Duration (seconds):</label>
|
||||
<input type="number" name="duration" id="duration" class="form-control" required min="1" value="10">
|
||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
||||
How long to display each image/slide (videos use actual length)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Files:</label>
|
||||
<input type="file" name="files" id="files" class="form-control" multiple required
|
||||
accept="image/*,video/*,.pdf,.ppt,.pptx" onchange="handleFileChange()">
|
||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
||||
Select multiple files. Supported: JPG, PNG, GIF, MP4, PDF, PPT, PPTX
|
||||
</small>
|
||||
<div id="file-list" style="margin-top: 10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<button type="submit" id="submit-button" class="btn btn-success" style="padding: 10px 30px; font-size: 16px;">
|
||||
📤 Upload Files
|
||||
</button>
|
||||
<a href="{{ return_url or url_for('content.content_list') }}" class="btn" style="padding: 10px 30px; font-size: 16px;">
|
||||
← Back
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Status Updates -->
|
||||
<div id="statusModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 800px; margin: 50px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
||||
<h2 style="margin-bottom: 20px; color: #2c3e50;">Processing Files</h2>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<p id="status-message" style="font-size: 16px; color: #555;">Uploading and processing your files. Please wait...</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<label style="display: block; margin-bottom: 10px; font-weight: bold;">File Processing Progress</label>
|
||||
<div style="width: 100%; height: 30px; background: #e9ecef; border-radius: 5px; overflow: hidden;">
|
||||
<div id="progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 14px;">
|
||||
0%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 20px;">
|
||||
<button type="button" class="btn" onclick="closeModal()" disabled id="close-modal-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let progressInterval = null;
|
||||
let sessionId = null;
|
||||
let returnUrl = '{{ return_url or url_for("content.content_list") }}';
|
||||
|
||||
function generateSessionId() {
|
||||
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
function handleFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
sessionId = generateSessionId();
|
||||
const form = document.getElementById('upload-form');
|
||||
let sessionInput = document.getElementById('session_id_input');
|
||||
if (!sessionInput) {
|
||||
sessionInput = document.createElement('input');
|
||||
sessionInput.type = 'hidden';
|
||||
sessionInput.name = 'session_id';
|
||||
sessionInput.id = 'session_id_input';
|
||||
form.appendChild(sessionInput);
|
||||
}
|
||||
sessionInput.value = sessionId;
|
||||
|
||||
showStatusModal();
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
console.log('Form submitted successfully');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Form submission error:', error);
|
||||
document.getElementById('status-message').textContent = 'Upload failed: ' + error.message;
|
||||
document.getElementById('progress-bar').style.background = '#dc3545';
|
||||
document.getElementById('close-modal-btn').disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function showStatusModal() {
|
||||
const modal = document.getElementById('statusModal');
|
||||
modal.style.display = 'block';
|
||||
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
const statusMessage = document.getElementById('status-message');
|
||||
|
||||
switch(mediaType) {
|
||||
case 'image':
|
||||
statusMessage.textContent = 'Uploading images...';
|
||||
break;
|
||||
case 'video':
|
||||
statusMessage.textContent = 'Uploading and converting video. This may take several minutes...';
|
||||
break;
|
||||
case 'pdf':
|
||||
statusMessage.textContent = 'Uploading and converting PDF to images...';
|
||||
break;
|
||||
case 'ppt':
|
||||
statusMessage.textContent = 'Uploading and converting PowerPoint to images...';
|
||||
break;
|
||||
default:
|
||||
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
|
||||
}
|
||||
|
||||
pollUploadProgress();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('statusModal');
|
||||
modal.style.display = 'none';
|
||||
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
|
||||
window.location.href = returnUrl;
|
||||
}
|
||||
|
||||
function pollUploadProgress() {
|
||||
progressInterval = setInterval(() => {
|
||||
fetch(`/api/upload-progress/${sessionId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
progressBar.style.width = `${data.progress}%`;
|
||||
progressBar.textContent = `${data.progress}%`;
|
||||
|
||||
document.getElementById('status-message').textContent = data.message;
|
||||
|
||||
if (data.status === 'complete' || data.status === 'error') {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
|
||||
const closeBtn = document.getElementById('close-modal-btn');
|
||||
closeBtn.disabled = false;
|
||||
|
||||
if (data.status === 'complete') {
|
||||
progressBar.style.background = '#28a745';
|
||||
setTimeout(() => closeModal(), 2000);
|
||||
} else if (data.status === 'error') {
|
||||
progressBar.style.background = '#dc3545';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching progress:', error));
|
||||
}, 500);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function handleMediaTypeChange() {
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
const hint = document.getElementById('media-type-hint');
|
||||
|
||||
switch(mediaType) {
|
||||
case 'image':
|
||||
hint.textContent = 'Images will be displayed as-is';
|
||||
break;
|
||||
case 'video':
|
||||
hint.textContent = 'Videos will be converted to optimized format';
|
||||
break;
|
||||
case 'pdf':
|
||||
hint.textContent = 'PDF will be converted to images (one per page)';
|
||||
break;
|
||||
case 'ppt':
|
||||
hint.textContent = 'PowerPoint will be converted to images (one per slide)';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange() {
|
||||
const filesInput = document.getElementById('files');
|
||||
const fileList = document.getElementById('file-list');
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
const durationInput = document.getElementById('duration');
|
||||
|
||||
fileList.innerHTML = '';
|
||||
if (filesInput.files.length > 0) {
|
||||
fileList.innerHTML = '<strong>Selected files:</strong><ul style="margin: 5px 0; padding-left: 20px;">';
|
||||
for (let i = 0; i < filesInput.files.length; i++) {
|
||||
const file = filesInput.files[i];
|
||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
fileList.innerHTML += `<li>${file.name} (${sizeMB} MB)</li>`;
|
||||
}
|
||||
fileList.innerHTML += '</ul>';
|
||||
}
|
||||
|
||||
if (mediaType === 'video' && filesInput.files.length > 0) {
|
||||
const file = filesInput.files[0];
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = function() {
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
const duration = Math.round(video.duration);
|
||||
durationInput.value = duration;
|
||||
};
|
||||
video.src = URL.createObjectURL(file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,514 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upload Media - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.upload-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 2px dashed #ced4da;
|
||||
border-radius: 8px;
|
||||
padding: 25px 20px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body.dark-mode .upload-zone {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #667eea;
|
||||
background: #f0f2ff;
|
||||
}
|
||||
|
||||
body.dark-mode .upload-zone:hover {
|
||||
border-color: #7c3aed;
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
.upload-zone.dragover {
|
||||
border-color: #667eea;
|
||||
background: #e8ebff;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
body.dark-mode .upload-zone.dragover {
|
||||
border-color: #7c3aed;
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.file-input-wrapper input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: 15px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
body.dark-mode .file-item {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.remove-file {
|
||||
cursor: pointer;
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.remove-file:hover {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 40px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-upload:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-upload:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Dark mode text colors */
|
||||
body.dark-mode h1,
|
||||
body.dark-mode h2,
|
||||
body.dark-mode h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode p,
|
||||
body.dark-mode small {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
body.dark-mode .file-info > div > div {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .file-info > div > div:last-child {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.playlist-selector {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-selector {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.playlist-selector.selected {
|
||||
border-color: #667eea;
|
||||
background: #f0f2ff;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-selector.selected {
|
||||
border-color: #7c3aed;
|
||||
background: #2d3748;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="upload-container">
|
||||
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h1 style="display: flex; align-items: center; gap: 0.5rem; font-size: 24px; margin: 0;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 28px; height: 28px;">
|
||||
Upload Media
|
||||
</h1>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem; padding: 8px 16px;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
|
||||
Back to Playlists
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
|
||||
|
||||
<!-- Compact Two-Column Layout -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
|
||||
<!-- Left Column: Upload Zone -->
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px;">
|
||||
Select Files
|
||||
</h2>
|
||||
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 10px;">
|
||||
<h3 style="margin-bottom: 8px; font-size: 16px;">Drag & Drop</h3>
|
||||
<p style="color: #6c757d; margin: 8px 0; font-size: 13px;">or</p>
|
||||
<div class="file-input-wrapper">
|
||||
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem; padding: 8px 16px; font-size: 14px;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
|
||||
Browse
|
||||
</label>
|
||||
<input type="file" id="file-input" name="files" multiple
|
||||
accept="image/*,video/*,.pdf,.ppt,.pptx">
|
||||
</div>
|
||||
<p style="font-size: 11px; color: #999; margin-top: 12px; line-height: 1.4;">
|
||||
<strong>Supported:</strong> JPG, PNG, GIF, MP4, AVI, MOV, PDF, PPT
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="file-list" class="file-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Settings -->
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px;">
|
||||
Upload Settings
|
||||
</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="playlist_id">Target Playlist (Optional)</label>
|
||||
<select name="playlist_id" id="playlist_id" class="form-control">
|
||||
<option value="">-- Media Library Only --</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}">
|
||||
{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
||||
💡 Add to playlists later if needed
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content_type">Media Type</label>
|
||||
<select name="content_type" id="content_type" class="form-control">
|
||||
<option value="image">Image</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="pptx">PPTX</option>
|
||||
</select>
|
||||
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
||||
Auto-detected from file extension
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="duration">Default Duration (seconds)</label>
|
||||
<input type="number" name="duration" id="duration" class="form-control"
|
||||
value="10" min="1" max="300">
|
||||
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
||||
Display time for images and PDFs
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; cursor: pointer; user-select: none;">
|
||||
<input type="checkbox" name="edit_on_player_enabled" id="edit_on_player_enabled"
|
||||
value="1" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
|
||||
<span>Allow editing on player (PDF, Images, PPTX)</span>
|
||||
</label>
|
||||
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
||||
✏️ Enable local editing of this media on the player device
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Upload Files
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const uploadZone = document.getElementById('upload-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const fileList = document.getElementById('file-list');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
const uploadForm = document.getElementById('upload-form');
|
||||
|
||||
let selectedFiles = [];
|
||||
|
||||
// Prevent default drag behaviors
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
uploadZone.addEventListener(eventName, preventDefaults, false);
|
||||
document.body.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Highlight drop zone when item is dragged over it
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
uploadZone.addEventListener(eventName, () => {
|
||||
uploadZone.classList.add('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
uploadZone.addEventListener(eventName, () => {
|
||||
uploadZone.classList.remove('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle dropped files
|
||||
uploadZone.addEventListener('drop', (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
handleFiles(files);
|
||||
});
|
||||
|
||||
// Handle file input
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
console.log('File input changed, files:', e.target.files.length);
|
||||
if (e.target.files.length > 0) {
|
||||
handleFiles(e.target.files);
|
||||
}
|
||||
});
|
||||
|
||||
// Click upload zone to trigger file input (but not if clicking on the label)
|
||||
uploadZone.addEventListener('click', (e) => {
|
||||
// Don't trigger if clicking on the label or button
|
||||
if (e.target.tagName === 'LABEL' || e.target.closest('label') || e.target.tagName === 'INPUT') {
|
||||
return;
|
||||
}
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
function handleFiles(files) {
|
||||
console.log('handleFiles called with', files.length, 'file(s)');
|
||||
selectedFiles = Array.from(files);
|
||||
displayFiles();
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
|
||||
// Auto-detect media type and duration from first file
|
||||
if (selectedFiles.length > 0) {
|
||||
console.log('Auto-detecting for first file:', selectedFiles[0].name);
|
||||
autoDetectMediaType(selectedFiles[0]);
|
||||
autoDetectDuration(selectedFiles[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function autoDetectMediaType(file) {
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
const contentTypeSelect = document.getElementById('content_type');
|
||||
|
||||
console.log('Auto-detecting media type for:', file.name, 'Extension:', ext);
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
|
||||
contentTypeSelect.value = 'image';
|
||||
console.log('Set type to: image');
|
||||
} else if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
|
||||
contentTypeSelect.value = 'video';
|
||||
console.log('Set type to: video');
|
||||
} else if (ext === 'pdf') {
|
||||
contentTypeSelect.value = 'pdf';
|
||||
console.log('Set type to: pdf');
|
||||
} else if (['ppt', 'pptx'].includes(ext)) {
|
||||
contentTypeSelect.value = 'pptx';
|
||||
console.log('Set type to: pptx');
|
||||
}
|
||||
}
|
||||
|
||||
function autoDetectDuration(file) {
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
const durationInput = document.getElementById('duration');
|
||||
|
||||
console.log('Auto-detecting duration for:', file.name, 'Extension:', ext);
|
||||
|
||||
// For videos, try to get actual duration
|
||||
if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
|
||||
console.log('Processing as video...');
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
|
||||
video.onloadedmetadata = function() {
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
const duration = Math.ceil(video.duration);
|
||||
console.log('Video duration detected:', duration, 'seconds');
|
||||
if (duration && duration > 0) {
|
||||
durationInput.value = duration;
|
||||
}
|
||||
};
|
||||
|
||||
video.onerror = function() {
|
||||
console.log('Video loading error, using default 30s');
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
durationInput.value = 30; // Default for videos if can't read duration
|
||||
};
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
|
||||
// Images: default 10 seconds
|
||||
console.log('Setting image duration: 10s');
|
||||
durationInput.value = 10;
|
||||
} else if (ext === 'pdf') {
|
||||
// PDFs: default 15 seconds per page (estimate)
|
||||
console.log('Setting PDF duration: 15s');
|
||||
durationInput.value = 15;
|
||||
} else if (['ppt', 'pptx'].includes(ext)) {
|
||||
// Presentations: default 20 seconds per slide (estimate)
|
||||
console.log('Setting presentation duration: 20s');
|
||||
durationInput.value = 20;
|
||||
}
|
||||
}
|
||||
|
||||
function displayFiles() {
|
||||
fileList.innerHTML = '';
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const fileItem = document.createElement('div');
|
||||
fileItem.className = 'file-item';
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
let icon = '📁';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(ext)) icon = '📷';
|
||||
else if (['mp4', 'avi', 'mov', 'mkv', 'webm'].includes(ext)) icon = '🎥';
|
||||
else if (ext === 'pdf') icon = '📄';
|
||||
else if (['ppt', 'pptx'].includes(ext)) icon = '📊';
|
||||
|
||||
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
|
||||
fileItem.innerHTML = `
|
||||
<div class="file-info">
|
||||
<span class="file-icon">${icon}</span>
|
||||
<div>
|
||||
<div style="font-weight: 600;">${file.name}</div>
|
||||
<div style="font-size: 12px; color: #6c757d;">${sizeInMB} MB</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="remove-file" onclick="removeFile(${index})">✕</span>
|
||||
`;
|
||||
|
||||
fileList.appendChild(fileItem);
|
||||
});
|
||||
}
|
||||
|
||||
function removeFile(index) {
|
||||
selectedFiles.splice(index, 1);
|
||||
displayFiles();
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
|
||||
// Update file input
|
||||
const dt = new DataTransfer();
|
||||
selectedFiles.forEach(file => dt.items.add(file));
|
||||
fileInput.files = dt.files;
|
||||
}
|
||||
|
||||
// Form submission
|
||||
uploadForm.addEventListener('submit', (e) => {
|
||||
if (selectedFiles.length === 0) {
|
||||
e.preventDefault();
|
||||
alert('Please select files to upload');
|
||||
return;
|
||||
}
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '⏳ Uploading...';
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,140 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="margin-bottom: 2rem;">Dashboard</h1>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
<div class="card">
|
||||
<h3 style="color: #3498db; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Players
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_players or 0 }}</p>
|
||||
<a href="{{ url_for('players.list') }}" class="btn" style="margin-top: 1rem;">View Players</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color: #9b59b6; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Playlists
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_playlists or 0 }}</p>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="margin-top: 1rem;">Manage Playlists</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color: #e67e22; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Media Library
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_content or 0 }}</p>
|
||||
<p class="secondary-text" style="font-size: 0.9rem; margin-top: 0.5rem;">Unique media files</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color: #27ae60; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Storage
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ storage_mb or 0 }} MB</p>
|
||||
<p class="secondary-text" style="font-size: 0.9rem; margin-top: 0.5rem;">Total uploads</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Quick Actions</h2>
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;">
|
||||
<a href="{{ url_for('players.add_player') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Add Player
|
||||
</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Create Playlist
|
||||
</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Upload Media
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn">Admin Panel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Workflow Guide
|
||||
</h2>
|
||||
<div class="workflow-guide">
|
||||
<ol style="line-height: 2; margin: 0; padding-left: 1.5rem;">
|
||||
<li><strong>Create a Playlist</strong> - Group your content into themed collections</li>
|
||||
<li><strong>Upload Media</strong> - Add images, videos, or PDFs to your media library</li>
|
||||
<li><strong>Add Content to Playlist</strong> - Build your playlist with drag-and-drop ordering</li>
|
||||
<li><strong>Add Player</strong> - Register physical display devices</li>
|
||||
<li><strong>Assign Playlist</strong> - Connect players to their playlists</li>
|
||||
<li><strong>Players Auto-Download</strong> - Devices fetch and display content automatically</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workflow-guide {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .workflow-guide {
|
||||
background: #1a202c;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
.secondary-text {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
body.dark-mode .secondary-text {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .log-item {
|
||||
border-bottom: 1px solid #4a5568;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% if recent_logs %}
|
||||
<div class="card">
|
||||
<h2>Recent Activity</h2>
|
||||
<div style="margin-top: 1rem;">
|
||||
{% for log in recent_logs %}
|
||||
<div class="log-item">
|
||||
<span style="color: {% if log.level == 'error' %}#e74c3c{% elif log.level == 'warning' %}#f39c12{% else %}#27ae60{% endif %}; font-weight: bold;">
|
||||
[{{ log.level.upper() }}]
|
||||
</span>
|
||||
{{ log.message }}
|
||||
<small class="secondary-text" style="float: right;">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2>System Status</h2>
|
||||
<p>✅ All systems operational</p>
|
||||
<p>� Playlist-centric architecture active</p>
|
||||
<p>🔄 Groups removed - Streamlined workflow</p>
|
||||
<p>⚡ DigiServer v2.0</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}403 - Access Denied{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
|
||||
<h1 style="font-size: 72px; color: #ffc107;">403</h1>
|
||||
<h2>Access Denied</h2>
|
||||
<p style="color: #6c757d; margin: 20px 0;">You don't have permission to access this resource.</p>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}404 - Page Not Found{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
|
||||
<h1 style="font-size: 72px; color: #dc3545;">404</h1>
|
||||
<h2>Page Not Found</h2>
|
||||
<p style="color: #6c757d; margin: 20px 0;">The page you're looking for doesn't exist.</p>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}500 - Server Error{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
|
||||
<h1 style="font-size: 72px; color: #dc3545;">500</h1>
|
||||
<h2>Internal Server Error</h2>
|
||||
<p style="color: #6c757d; margin: 20px 0;">Something went wrong on our end. Please try again later.</p>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,235 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Player - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
border-color: #7c3aed;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
body.dark-mode .form-help {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-top: 2rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid;
|
||||
}
|
||||
|
||||
.section-header.blue {
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header.blue {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.section-header.green {
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header.green {
|
||||
border-color: #48bb78;
|
||||
}
|
||||
|
||||
.section-header.yellow {
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header.yellow {
|
||||
border-color: #ecc94b;
|
||||
}
|
||||
|
||||
body.dark-mode h1,
|
||||
body.dark-mode h3,
|
||||
body.dark-mode h4 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 1rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box {
|
||||
background-color: #1a365d;
|
||||
border-left-color: #667eea;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
margin-top: 0;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box h4 {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.info-box code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box code {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode small {
|
||||
color: #718096;
|
||||
}
|
||||
</style>
|
||||
<div class="container" style="max-width: 800px; margin-top: 2rem;">
|
||||
<h1>Add New Player</h1>
|
||||
<p style="color: #6c757d; margin-bottom: 2rem;">
|
||||
Create a new digital signage player with authentication credentials
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<form method="POST">
|
||||
<h3 class="section-header blue" style="margin-top: 0;">
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Display Name *</label>
|
||||
<input type="text" name="name" required class="form-control"
|
||||
placeholder="e.g., Office Reception Player">
|
||||
<small class="form-help">Friendly name for the player</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Hostname *</label>
|
||||
<input type="text" name="hostname" required class="form-control"
|
||||
placeholder="e.g., office-player-001">
|
||||
<small class="form-help">
|
||||
Unique identifier for this player (must match screen_name in player config)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Location</label>
|
||||
<input type="text" name="location" class="form-control"
|
||||
placeholder="e.g., Main Office - Reception Area">
|
||||
<small class="form-help">Physical location of the player (optional)</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-header green">
|
||||
Authentication
|
||||
</h3>
|
||||
<p class="form-help" style="margin-bottom: 1rem;">
|
||||
Choose one authentication method (Quick Connect recommended for easy setup)
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" id="password" class="form-control"
|
||||
placeholder="Leave empty to use Quick Connect only">
|
||||
<small class="form-help">
|
||||
Secure password for player authentication (optional if using Quick Connect)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Quick Connect Code *</label>
|
||||
<input type="text" name="quickconnect_code" required class="form-control"
|
||||
placeholder="e.g., OFFICE123">
|
||||
<small class="form-help">
|
||||
Easy pairing code for quick setup (must match quickconnect_key in player config)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-header yellow">
|
||||
Display Settings
|
||||
</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Orientation</label>
|
||||
<select name="orientation" class="form-control">
|
||||
<option value="Landscape" selected>Landscape</option>
|
||||
<option value="Portrait">Portrait</option>
|
||||
</select>
|
||||
<small class="form-help">Display orientation for the player</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Assign Playlist</label>
|
||||
<select name="playlist_id" class="form-control">
|
||||
<option value="">No Playlist (Unassigned)</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}">{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-help">Assign player to a playlist (optional)</small>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h4>📋 Setup Instructions</h4>
|
||||
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
||||
<li>Create the player with the form above</li>
|
||||
<li>Note the generated <strong>Auth Code</strong> (shown after creation)</li>
|
||||
<li>Configure the player's <code>app_config.json</code> with:
|
||||
<ul style="margin-top: 0.5rem;">
|
||||
<li><code>server_ip</code>: Your server address</li>
|
||||
<li><code>screen_name</code>: Same as <strong>Hostname</strong> above</li>
|
||||
<li><code>quickconnect_key</code>: Same as <strong>Quick Connect Code</strong> above</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Start the player - it will authenticate automatically</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
|
||||
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
|
||||
✓ Create Player
|
||||
</button>
|
||||
<a href="{{ url_for('players.list') }}" class="btn" style="padding: 0.75rem 2rem; margin-left: 1rem;">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Player{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Edit Player</h2>
|
||||
<p>Edit player functionality - placeholder</p>
|
||||
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">Back to Players</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,525 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edited Media - {{ player.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.expandable-card {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
border: 2px solid #cbd5e1;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expandable-card:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.expandable-card.expanded {
|
||||
border-color: #7c3aed;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.expandable-card:not(.expanded) .card-header:hover {
|
||||
background: rgba(124, 58, 237, 0.05);
|
||||
}
|
||||
|
||||
.card-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.card-header-icon {
|
||||
font-size: 1.5rem;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.expandable-card.expanded .card-header-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s ease;
|
||||
}
|
||||
|
||||
.expandable-card.expanded .card-content {
|
||||
max-height: 800px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.preview-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-info-item {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.preview-info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.preview-info-label {
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.preview-info-value {
|
||||
color: #1a202c;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.versions-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.versions-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.versions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
background: white;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-item:hover {
|
||||
border-color: #7c3aed;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.version-item.active {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
.version-thumbnail {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.version-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version-label {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.version-item.active .version-label {
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.badge-latest {
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-original {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
body.dark-mode .expandable-card {
|
||||
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .expandable-card:hover,
|
||||
body.dark-mode .expandable-card.expanded {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .card-header-title {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .expandable-card:not(.expanded) .card-header:hover {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
body.dark-mode .preview-info {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
body.dark-mode .preview-info-item {
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .preview-info-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
body.dark-mode .preview-info-value {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .versions-title {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
body.dark-mode .version-item {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .version-item:hover,
|
||||
body.dark-mode .version-item.active {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .version-label {
|
||||
background: #1a202c;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .version-item.active .version-label {
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="btn"
|
||||
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
← Back to Player
|
||||
</a>
|
||||
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
Edited Media - {{ player.name }}
|
||||
</h1>
|
||||
</div>
|
||||
<p style="color: #6c757d; font-size: 1rem;">Complete history of media files edited on this player</p>
|
||||
</div>
|
||||
|
||||
{% if edited_media %}
|
||||
{% set edited_by_content = {} %}
|
||||
{% for edit in edited_media %}
|
||||
{% if edit.content_id not in edited_by_content %}
|
||||
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'versions': []}}) %}
|
||||
{% endif %}
|
||||
{% set _ = edited_by_content[edit.content_id]['versions'].append(edit) %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Expandable Cards -->
|
||||
{% for content_id, data in edited_by_content.items() %}
|
||||
{% set original_content = content_files.get(content_id) %}
|
||||
<div class="expandable-card" id="card-{{ content_id }}" onclick="toggleCard({{ content_id }})">
|
||||
<div class="card-header">
|
||||
<div class="card-header-title">
|
||||
<span class="card-header-icon">▶</span>
|
||||
<span>📄 {{ original_content.filename if original_content else data.original_name }}</span>
|
||||
<span style="font-size: 0.9rem; color: #64748b; font-weight: normal;">
|
||||
({{ data.versions|length + 1 }} version{{ 's' if (data.versions|length + 1) > 1 else '' }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<!-- Preview Area (Left Column) -->
|
||||
<div class="preview-area">
|
||||
<div class="preview-container" id="preview-{{ content_id }}">
|
||||
{% set latest = data.versions|sort(attribute='version', reverse=True)|first %}
|
||||
{% if latest.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
|
||||
alt="{{ latest.new_name }}"
|
||||
id="preview-img-{{ content_id }}">
|
||||
{% else %}
|
||||
<div style="color: white; text-align: center;">
|
||||
<div style="font-size: 3rem; margin-bottom: 0.5rem;">📄</div>
|
||||
<div>No preview available</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
|
||||
download
|
||||
class="download-btn"
|
||||
id="download-btn-{{ content_id }}">
|
||||
💾 Download File
|
||||
</a>
|
||||
|
||||
<div class="preview-info" id="info-{{ content_id }}">
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">📄 Filename:</span>
|
||||
<span class="preview-info-value" id="info-filename-{{ content_id }}">{{ latest.new_name }}</span>
|
||||
</div>
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">📦 Version:</span>
|
||||
<span class="preview-info-value" id="info-version-{{ content_id }}">v{{ latest.version }}</span>
|
||||
</div>
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">👤 Edited by:</span>
|
||||
<span class="preview-info-value" id="info-user-{{ content_id }}">{{ user_mappings.get(latest.user, latest.user or 'Unknown') }}</span>
|
||||
</div>
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">🕒 Modified:</span>
|
||||
<span class="preview-info-value" id="info-date-{{ content_id }}">{{ latest.time_of_modification | localtime('%Y-%m-%d %H:%M') if latest.time_of_modification else 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">📅 Uploaded:</span>
|
||||
<span class="preview-info-value" id="info-created-{{ content_id }}">{{ latest.created_at | localtime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Versions Area (Right Column) -->
|
||||
<div class="versions-area">
|
||||
<div class="versions-title">
|
||||
📚 All Versions
|
||||
</div>
|
||||
<div class="versions-grid">
|
||||
{% for edit in data.versions|sort(attribute='version', reverse=True) %}
|
||||
<div class="version-item {% if loop.first %}active{% endif %}"
|
||||
id="version-{{ content_id }}-{{ edit.version }}"
|
||||
onclick="event.stopPropagation(); selectVersion({{ content_id }}, {{ edit.version }}, '{{ edit.new_name }}', '{{ user_mappings.get(edit.user, edit.user or 'Unknown') }}', '{{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') if edit.time_of_modification else 'N/A' }}', '{{ edit.created_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}')">
|
||||
<div class="version-thumbnail">
|
||||
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
alt="Version {{ edit.version }}">
|
||||
{% else %}
|
||||
<div style="color: white; font-size: 2rem;">📄</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="version-label">
|
||||
v{{ edit.version }}
|
||||
{% if loop.first %}
|
||||
<span class="version-badge badge-latest">Latest</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Original File -->
|
||||
{% if original_content %}
|
||||
<div class="version-item"
|
||||
id="version-{{ content_id }}-original"
|
||||
onclick="event.stopPropagation(); selectVersion({{ content_id }}, 'original', '{{ original_content.filename }}', 'System', 'N/A', '{{ original_content.uploaded_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/' ~ original_content.filename) }}')">
|
||||
<div class="version-thumbnail">
|
||||
{% if original_content.filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ original_content.filename) }}"
|
||||
alt="Original">
|
||||
{% else %}
|
||||
<div style="color: white; font-size: 2rem;">📄</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="version-label">
|
||||
Original
|
||||
<span class="version-badge badge-original">Source</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Summary Statistics -->
|
||||
<div class="card" style="margin-top: 2rem; background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); color: white; border-radius: 12px; overflow: hidden;">
|
||||
<div style="padding: 1.5rem; display: flex; justify-content: space-around; align-items: center; flex-wrap: wrap; gap: 2rem;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_by_content|length }}</div>
|
||||
<div style="font-size: 0.9rem; opacity: 0.9;">Total Files Edited</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_media|length }}</div>
|
||||
<div style="font-size: 0.9rem; opacity: 0.9;">Total Versions</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ ((edited_media|length / edited_by_content|length) | round(1)) if edited_by_content else 0 }}</div>
|
||||
<div style="font-size: 0.9rem; opacity: 0.9;">Avg Versions per File</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 4rem 2rem;">
|
||||
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">📭</div>
|
||||
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Edited Media Yet</h2>
|
||||
<p style="color: #6c757d; font-size: 1.1rem;">
|
||||
This player hasn't edited any media files yet. When the player edits content,<br>
|
||||
all versions will be tracked and displayed here.
|
||||
</p>
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="btn"
|
||||
style="margin-top: 2rem; display: inline-block; background: #7c3aed; color: white; padding: 0.75rem 1.5rem; text-decoration: none; border-radius: 6px; font-size: 1rem;">
|
||||
← Back to Player Management
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toggleCard(contentId) {
|
||||
const card = document.getElementById('card-' + contentId);
|
||||
const wasExpanded = card.classList.contains('expanded');
|
||||
|
||||
// Close all cards
|
||||
document.querySelectorAll('.expandable-card').forEach(c => {
|
||||
c.classList.remove('expanded');
|
||||
});
|
||||
|
||||
// If this card wasn't expanded, expand it
|
||||
if (!wasExpanded) {
|
||||
card.classList.add('expanded');
|
||||
}
|
||||
}
|
||||
|
||||
function selectVersion(contentId, version, filename, user, modifiedDate, createdDate, fileUrl) {
|
||||
// Update preview image
|
||||
const previewImg = document.getElementById('preview-img-' + contentId);
|
||||
if (previewImg) {
|
||||
previewImg.src = fileUrl;
|
||||
}
|
||||
|
||||
// Update download button
|
||||
const downloadBtn = document.getElementById('download-btn-' + contentId);
|
||||
if (downloadBtn) {
|
||||
downloadBtn.href = fileUrl;
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
document.getElementById('info-filename-' + contentId).textContent = filename;
|
||||
document.getElementById('info-version-' + contentId).textContent = 'v' + version;
|
||||
document.getElementById('info-user-' + contentId).textContent = user;
|
||||
document.getElementById('info-date-' + contentId).textContent = modifiedDate;
|
||||
document.getElementById('info-created-' + contentId).textContent = createdDate;
|
||||
|
||||
// Update active state on version items
|
||||
document.querySelectorAll('.version-item').forEach(item => {
|
||||
if (item.id.startsWith('version-' + contentId + '-')) {
|
||||
item.classList.remove('active');
|
||||
}
|
||||
});
|
||||
document.getElementById('version-' + contentId + '-' + version).classList.add('active');
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,770 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Player - {{ player.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
border-color: #7c3aed;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-box.neutral {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box.neutral {
|
||||
background: #1a202c;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
.info-box.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box.success {
|
||||
background: #1a4d2e;
|
||||
color: #86efac;
|
||||
border: 1px solid #48bb78;
|
||||
}
|
||||
|
||||
.info-box.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box.warning {
|
||||
background: #4a3800;
|
||||
color: #fbbf24;
|
||||
border: 1px solid #ecc94b;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
body.dark-mode .log-item {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
.log-item pre {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
body.dark-mode .log-item pre {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode h1,
|
||||
body.dark-mode h2,
|
||||
body.dark-mode h3,
|
||||
body.dark-mode h4 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
body.dark-mode strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode code {
|
||||
background: #1a202c;
|
||||
color: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
body.dark-mode small {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.credential-item {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .credential-item {
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
.credential-item:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.credential-label {
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
body.dark-mode .credential-label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.credential-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #f8f9fa;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
body.dark-mode .credential-value {
|
||||
background: #0d1117;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status-card.online {
|
||||
background: #d4edda;
|
||||
}
|
||||
|
||||
body.dark-mode .status-card.online {
|
||||
background: #1a4d2e;
|
||||
border: 1px solid #48bb78;
|
||||
}
|
||||
|
||||
.status-card.offline {
|
||||
background: #f8d7da;
|
||||
}
|
||||
|
||||
body.dark-mode .status-card.offline {
|
||||
background: #4a1a1a;
|
||||
border: 1px solid #dc3545;
|
||||
}
|
||||
|
||||
.status-card.other {
|
||||
background: #fff3cd;
|
||||
}
|
||||
|
||||
body.dark-mode .status-card.other {
|
||||
background: #4a3800;
|
||||
border: 1px solid #ecc94b;
|
||||
}
|
||||
|
||||
.playlist-stats {
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-stats {
|
||||
background: #1a202c;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-stats > div > div:first-child {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
/* Player Logs Dark Mode */
|
||||
body.dark-mode .card p[style*="color: #6c757d"] {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] {
|
||||
background: #2d3748 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p {
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p[style*="color: #6c757d"] {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] small {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] details summary {
|
||||
color: #f87171 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] pre {
|
||||
background: #1a202c !important;
|
||||
border-color: #4a5568 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="text-align: center"] {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
|
||||
/* Edited Media Cards Dark Mode */
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] {
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important;
|
||||
border-color: #8b5cf6 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] h3 {
|
||||
color: #a78bfa !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] div[style*="background: white"] {
|
||||
background: #374151 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] strong {
|
||||
color: #a78bfa !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #475569"],
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #64748b"] {
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
body.dark-mode div[style*="background: #f8f9fa; border-radius: 8px"] {
|
||||
background: #2d3748 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="text-align: center"] p {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
</style>
|
||||
<div style="margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
Manage Player: {{ player.name }}
|
||||
</h1>
|
||||
<a href="{{ url_for('players.list') }}" class="btn" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Back to Players
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="deleteModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);">
|
||||
<div style="background-color: #fefefe; margin: 15% auto; padding: 20px; border: 1px solid #888; border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||
<h2 style="margin-top: 0; color: #dc3545; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span style="font-size: 1.5rem;">⚠️</span>
|
||||
Confirm Delete
|
||||
</h2>
|
||||
<p style="font-size: 1.1rem; margin: 1.5rem 0;">
|
||||
Are you sure you want to delete player <strong>"{{ player.name }}"</strong>?
|
||||
</p>
|
||||
<p style="color: #dc3545; margin: 1rem 0;">
|
||||
<strong>Warning:</strong> This action cannot be undone. All feedback logs for this player will also be deleted.
|
||||
</p>
|
||||
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 2rem;">
|
||||
<button onclick="closeDeleteModal()" class="btn" style="background: #6c757d;">
|
||||
Cancel
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}" style="margin: 0;">
|
||||
<button type="submit" class="btn" style="background: #dc3545;">
|
||||
Yes, Delete Player
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
body.dark-mode #deleteModal > div {
|
||||
background-color: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode #deleteModal h2 {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
body.dark-mode #deleteModal p {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode #deleteModal p strong {
|
||||
color: #fbbf24;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function confirmDelete() {
|
||||
document.getElementById('deleteModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Close modal when clicking outside of it
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('deleteModal');
|
||||
if (event.target == modal) {
|
||||
closeDeleteModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal with ESC key
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Player Status Overview -->
|
||||
<div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}">
|
||||
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
Status:
|
||||
{% if player.status == 'online' %}
|
||||
<span style="color: #28a745; display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px; color: #28a745;">
|
||||
Online
|
||||
</span>
|
||||
{% elif player.status == 'offline' %}
|
||||
<span style="color: #dc3545; display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 20px; height: 20px; color: #dc3545;">
|
||||
Offline
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color: #ffc107; display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px; color: #ffc107;">
|
||||
{{ player.status|title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
|
||||
<p><strong>Last Seen:</strong>
|
||||
{% if player.last_seen %}
|
||||
{{ player.last_seen | localtime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
<p><strong>Assigned Playlist:</strong>
|
||||
{% if current_playlist %}
|
||||
<span style="color: #28a745; font-weight: bold;">{{ current_playlist.name }} (v{{ current_playlist.version }})</span>
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">No playlist assigned</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Overview Card -->
|
||||
{% if current_playlist %}
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Current Playlist: {{ current_playlist.name }}
|
||||
</h2>
|
||||
<div class="playlist-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-top: 1rem;">
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Total Items</div>
|
||||
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.contents.count() }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Playlist Version</div>
|
||||
<div style="font-size: 1.5rem; font-weight: bold;">v{{ current_playlist.version }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div>
|
||||
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at | localtime }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
|
||||
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.orientation }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
|
||||
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
|
||||
class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Edit Playlist Content
|
||||
</a>
|
||||
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
|
||||
class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;"
|
||||
target="_blank">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
View Live Content
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Three Column Layout -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;">
|
||||
|
||||
<!-- Card 1: Edit Credentials -->
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Edit Credentials
|
||||
</h2>
|
||||
<form method="POST" style="margin-top: 1rem;">
|
||||
<input type="hidden" name="action" value="update_credentials">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Player Name *</label>
|
||||
<input type="text" id="name" name="name" value="{{ player.name }}"
|
||||
required minlength="3" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location">Location</label>
|
||||
<input type="text" id="location" name="location" value="{{ player.location or '' }}"
|
||||
placeholder="e.g., Main Lobby" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="orientation">Orientation</label>
|
||||
<select id="orientation" name="orientation" class="form-control">
|
||||
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
||||
<option value="Portrait" {% if player.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid #ddd; margin: 1.5rem 0; padding-top: 1.5rem;">
|
||||
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem;">🔑 Authentication Settings</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hostname">Hostname *</label>
|
||||
<input type="text" id="hostname" name="hostname" value="{{ player.hostname }}"
|
||||
required minlength="3" class="form-control"
|
||||
placeholder="e.g., tv-terasa">
|
||||
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
|
||||
ℹ️ This is the unique identifier for the player
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">New Password (leave blank to keep current)</label>
|
||||
<input type="password" id="password" name="password" class="form-control"
|
||||
placeholder="Enter new password">
|
||||
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
|
||||
🔒 Optional: Set a new password for player authentication
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="quickconnect_code">Quick Connect Code</label>
|
||||
<input type="text" id="quickconnect_code" name="quickconnect_code" class="form-control"
|
||||
placeholder="e.g., 8887779">
|
||||
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
|
||||
🔗 Enter the plain text code (e.g., 8887779) - will be hashed automatically
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Save Changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Assign Playlist -->
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Assign Playlist
|
||||
</h2>
|
||||
<form method="POST" style="margin-top: 1rem;">
|
||||
<input type="hidden" name="action" value="assign_playlist">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="playlist_id">Select Playlist</label>
|
||||
<select id="playlist_id" name="playlist_id" class="form-control">
|
||||
<option value="">-- No Playlist (Unassign) --</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}"
|
||||
{% if player.playlist_id == playlist.id %}selected{% endif %}>
|
||||
{{ playlist.name }} (v{{ playlist.version }}) - {{ playlist.contents.count() }} items
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if current_playlist %}
|
||||
<div class="info-box success">
|
||||
<h4 style="margin: 0 0 0.5rem 0;">Currently Assigned:</h4>
|
||||
<p style="margin: 0;"><strong>{{ current_playlist.name }}</strong></p>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem; color: #6c757d;">
|
||||
Updated: {{ current_playlist.updated_at | localtime }}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="info-box warning">
|
||||
<p style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;">
|
||||
No playlist currently assigned to this player.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Assign Playlist
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid #ddd;">
|
||||
<h4>Quick Actions:</h4>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="width: 100%; margin-top: 0.5rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Create New Playlist
|
||||
</a>
|
||||
{% if current_playlist %}
|
||||
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
|
||||
class="btn" style="width: 100%; margin-top: 0.5rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Edit Current Playlist
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="confirmDelete()" class="btn" style="width: 100%; margin-top: 0.5rem; background: #dc3545; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<span style="font-size: 1.2rem;">🗑️</span>
|
||||
Delete Player
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: Player Logs -->
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Player Logs
|
||||
</h2>
|
||||
<p style="color: #6c757d; font-size: 0.9rem;">Recent feedback from the player device</p>
|
||||
|
||||
<div style="max-height: 500px; overflow-y: auto; margin-top: 1rem;">
|
||||
{% if recent_logs %}
|
||||
{% for log in recent_logs %}
|
||||
<div style="padding: 0.75rem; margin-bottom: 0.5rem; border-left: 4px solid
|
||||
{% if log.status == 'error' %}#dc3545
|
||||
{% elif log.status == 'warning' %}#ffc107
|
||||
{% elif log.status == 'playing' %}#28a745
|
||||
{% else %}#17a2b8{% endif %};
|
||||
background: #f8f9fa; border-radius: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<strong style="color:
|
||||
{% if log.status == 'error' %}#dc3545
|
||||
{% elif log.status == 'warning' %}#ffc107
|
||||
{% elif log.status == 'playing' %}#28a745
|
||||
{% else %}#17a2b8{% endif %};">
|
||||
{% if log.status == 'error' %}❌
|
||||
{% elif log.status == 'warning' %}⚠️
|
||||
{% elif log.status == 'playing' %}▶️
|
||||
{% elif log.status == 'restarting' %}🔄
|
||||
{% else %}ℹ️{% endif %}
|
||||
{{ log.status|upper }}
|
||||
</strong>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">{{ log.message }}</p>
|
||||
{% if log.playlist_version %}
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.85rem; color: #6c757d;">
|
||||
Playlist v{{ log.playlist_version }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if log.error_details %}
|
||||
<details style="margin-top: 0.5rem;">
|
||||
<summary style="cursor: pointer; font-size: 0.85rem; color: #dc3545;">Error Details</summary>
|
||||
<pre style="margin: 0.5rem 0 0 0; padding: 0.5rem; background: #fff; border: 1px solid #ddd; border-radius: 4px; font-size: 0.8rem; overflow-x: auto;">{{ log.error_details }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small style="color: #6c757d; white-space: nowrap; margin-left: 1rem;">
|
||||
{{ log.timestamp | localtime('%m/%d %H:%M') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 2rem; color: #6c757d;">
|
||||
<p>📭 No logs received yet</p>
|
||||
<p style="font-size: 0.9rem;">Logs will appear here once the player starts sending feedback</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Edited Media Section - Full Width -->
|
||||
<div class="card" style="margin-top: 2rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Edited Media on the Player
|
||||
</h2>
|
||||
{% if edited_media %}
|
||||
<a href="{{ url_for('players.edited_media', player_id=player.id) }}"
|
||||
class="btn"
|
||||
style="background: #7c3aed; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#6d28d9'"
|
||||
onmouseout="this.style.background='#7c3aed'">
|
||||
📋 View All Edited Media
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
|
||||
|
||||
{% if edited_media %}
|
||||
{% set edited_by_content = {} %}
|
||||
{% for edit in edited_media %}
|
||||
{% if edit.content_id not in edited_by_content %}
|
||||
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'latest_version': edit}}) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;">
|
||||
{% for content_id, data in edited_by_content.items() %}
|
||||
{% if loop.index <= 3 %}
|
||||
<div class="card" style="background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border: 2px solid #7c3aed; box-shadow: 0 2px 8px rgba(124, 58, 237, 0.1);">
|
||||
{% set edit = data.latest_version %}
|
||||
|
||||
<!-- Image Preview if it's an image -->
|
||||
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||
<div style="width: 100%; height: 200px; overflow: hidden; border-radius: 8px 8px 0 0; background: #000;">
|
||||
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
alt="{{ edit.new_name }}"
|
||||
style="width: 100%; height: 100%; object-fit: contain;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="padding: 1rem;">
|
||||
<h3 style="margin: 0 0 0.75rem 0; color: #7c3aed; font-size: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
✏️ {{ data.original_name }}
|
||||
</h3>
|
||||
|
||||
<div style="padding: 0.75rem; background: white; border-radius: 6px; border-left: 4px solid #7c3aed; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
|
||||
<strong style="color: #7c3aed; font-size: 0.95rem;">
|
||||
Version {{ edit.version }}
|
||||
<span style="background: #7c3aed; color: white; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; margin-left: 0.5rem;">Latest</span>
|
||||
</strong>
|
||||
<small style="color: #6c757d; white-space: nowrap;">
|
||||
{{ edit.created_at | localtime('%m/%d %H:%M') }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<p style="margin: 0.25rem 0; font-size: 0.85rem; color: #475569;">
|
||||
📄 {{ edit.new_name }}
|
||||
</p>
|
||||
|
||||
{% if edit.user %}
|
||||
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
|
||||
👤 {{ edit.user }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if edit.time_of_modification %}
|
||||
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
|
||||
🕒 {{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
||||
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
target="_blank"
|
||||
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #7c3aed; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#6d28d9'"
|
||||
onmouseout="this.style.background='#7c3aed'">
|
||||
📥 View File
|
||||
</a>
|
||||
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
download
|
||||
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #64748b; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#475569'"
|
||||
onmouseout="this.style.background='#64748b'">
|
||||
💾 Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 3rem; color: #6c757d; background: #f8f9fa; border-radius: 8px; margin-top: 1.5rem;">
|
||||
<p style="font-size: 2rem; margin: 0;">📝</p>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 1.1rem; font-weight: 500;">No edited media yet</p>
|
||||
<p style="font-size: 0.9rem; margin: 0.5rem 0 0 0;">Media edits will appear here once the player sends edited files</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Additional Info Section -->
|
||||
<div class="card" style="margin-top: 2rem;">
|
||||
<h2>ℹ️ Player Information</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;">
|
||||
<div>
|
||||
<p><strong>Player ID:</strong> {{ player.id }}</p>
|
||||
<p><strong>Created:</strong> {{ (player.created_at | localtime) if player.created_at else 'N/A' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>Orientation:</strong> {{ player.orientation }}</p>
|
||||
<p><strong>Location:</strong> {{ player.location or 'Not set' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>Last Heartbeat:</strong>
|
||||
{% if player.last_heartbeat %}
|
||||
{{ player.last_heartbeat | localtime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,235 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ player.name }} - Live Preview</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
#player-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#media-display {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#media-display.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-overlay {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.controls button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.no-content {
|
||||
color: white;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="player-container">
|
||||
<div class="loading" id="loading">Loading playlist...</div>
|
||||
<img id="media-display" alt="Content">
|
||||
<video id="video-display" muted autoplay playsinline style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
|
||||
<div class="no-content" id="no-content" style="display: none;">
|
||||
<p>💭 No content in playlist</p>
|
||||
<p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-overlay" id="info-overlay" style="display: none;">
|
||||
<div id="current-item">Item: -</div>
|
||||
<div id="playlist-info">Playlist: {{ playlist|length }} items</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="toggleFullscreen()">🔳 Fullscreen</button>
|
||||
<button onclick="restartPlaylist()">🔄 Restart</button>
|
||||
<button onclick="window.close()">✖️ Close</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const playlist = {{ playlist|tojson }};
|
||||
let currentIndex = 0;
|
||||
let timer = null;
|
||||
let inactivityTimer = null;
|
||||
|
||||
const imgDisplay = document.getElementById('media-display');
|
||||
const videoDisplay = document.getElementById('video-display');
|
||||
const loading = document.getElementById('loading');
|
||||
const noContent = document.getElementById('no-content');
|
||||
const infoOverlay = document.getElementById('info-overlay');
|
||||
const currentItemDiv = document.getElementById('current-item');
|
||||
const controls = document.querySelector('.controls');
|
||||
|
||||
function playNext() {
|
||||
if (!playlist || playlist.length === 0) {
|
||||
loading.style.display = 'none';
|
||||
noContent.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const item = playlist[currentIndex];
|
||||
loading.style.display = 'none';
|
||||
infoOverlay.style.display = 'block';
|
||||
|
||||
// Update info
|
||||
currentItemDiv.textContent = `Item ${currentIndex + 1}/${playlist.length}: ${item.filename}`;
|
||||
|
||||
// Hide both displays
|
||||
imgDisplay.style.display = 'none';
|
||||
videoDisplay.style.display = 'none';
|
||||
videoDisplay.pause();
|
||||
|
||||
if (item.type === 'video') {
|
||||
videoDisplay.src = item.url;
|
||||
videoDisplay.muted = item.muted !== false; // Muted unless explicitly set to false
|
||||
videoDisplay.style.display = 'block';
|
||||
videoDisplay.play();
|
||||
|
||||
// When video ends, move to next
|
||||
videoDisplay.onended = () => {
|
||||
currentIndex = (currentIndex + 1) % playlist.length;
|
||||
playNext();
|
||||
};
|
||||
} else {
|
||||
// Image or PDF
|
||||
imgDisplay.src = item.url;
|
||||
imgDisplay.style.display = 'block';
|
||||
imgDisplay.classList.add('active');
|
||||
|
||||
// Clear any existing timer
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
// Show for specified duration
|
||||
timer = setTimeout(() => {
|
||||
currentIndex = (currentIndex + 1) % playlist.length;
|
||||
playNext();
|
||||
}, (item.duration || 10) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function restartPlaylist() {
|
||||
currentIndex = 0;
|
||||
if (timer) clearTimeout(timer);
|
||||
videoDisplay.pause();
|
||||
playNext();
|
||||
}
|
||||
|
||||
// Auto-hide controls after 5 seconds of inactivity
|
||||
function resetInactivityTimer() {
|
||||
// Show controls
|
||||
controls.style.opacity = '1';
|
||||
controls.style.pointerEvents = 'auto';
|
||||
|
||||
// Clear existing timer
|
||||
if (inactivityTimer) {
|
||||
clearTimeout(inactivityTimer);
|
||||
}
|
||||
|
||||
// Set new timer to hide controls after 5 seconds
|
||||
inactivityTimer = setTimeout(() => {
|
||||
controls.style.opacity = '0';
|
||||
controls.style.pointerEvents = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Track user activity to show/hide controls
|
||||
document.addEventListener('mousemove', resetInactivityTimer);
|
||||
document.addEventListener('mousedown', resetInactivityTimer);
|
||||
document.addEventListener('keydown', resetInactivityTimer);
|
||||
document.addEventListener('touchstart', resetInactivityTimer);
|
||||
|
||||
// Start playing when page loads
|
||||
window.addEventListener('load', () => {
|
||||
playNext();
|
||||
resetInactivityTimer(); // Start inactivity timer
|
||||
});
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'f' || e.key === 'F') {
|
||||
toggleFullscreen();
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
restartPlaylist();
|
||||
} else if (e.key === 'Escape' && document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,227 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ player.name }} - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1400px;">
|
||||
<!-- Header -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1>{{ player.name }}</h1>
|
||||
<div style="margin-top: 10px;">
|
||||
{% if status_info.online %}
|
||||
<span style="background: #28a745; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
|
||||
🟢 Online
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="background: #6c757d; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
|
||||
⚫ Offline
|
||||
</span>
|
||||
{% endif %}
|
||||
<span style="color: #6c757d; font-size: 14px;">
|
||||
Last seen: {{ status_info.last_seen_ago }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
|
||||
✏️ Edit Player
|
||||
</a>
|
||||
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}" class="btn btn-success">
|
||||
🎬 Manage Playlist
|
||||
</a>
|
||||
<a href="{{ url_for('players.list') }}" class="btn">
|
||||
← Back to Players
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
|
||||
<!-- Player Information Card -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
|
||||
📋 Player Information
|
||||
</h3>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold; width: 40%;">Display Name:</td>
|
||||
<td style="padding: 10px;">{{ player.name }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Hostname:</td>
|
||||
<td style="padding: 10px;">
|
||||
<code style="background: #f8f9fa; padding: 3px 8px; border-radius: 3px;">{{ player.hostname }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Location:</td>
|
||||
<td style="padding: 10px;">{{ player.location or '-' }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Orientation:</td>
|
||||
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px; font-weight: bold;">Created:</td>
|
||||
<td style="padding: 10px;">{{ player.created_at | localtime }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Authentication Details Card -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
|
||||
🔐 Authentication Details
|
||||
</h3>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold; width: 40%;">Password Set:</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if player.password_hash %}
|
||||
<span style="color: #28a745;">✓ Yes</span>
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">✗ No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Quick Connect Code:</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if player.quickconnect_code %}
|
||||
<span style="color: #28a745;">✓ Yes</span>
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">✗ No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Auth Code:</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if player.auth_code %}
|
||||
<span style="color: #28a745;">✓ Yes</span>
|
||||
<form method="POST" action="{{ url_for('players.regenerate_auth_code', player_id=player.id) }}" style="display: inline; margin-left: 10px;">
|
||||
<button type="submit" class="btn btn-sm" style="background: #ffc107; padding: 3px 8px;"
|
||||
onclick="return confirm('Regenerate auth code? The player will need to authenticate again.')">
|
||||
🔄 Regenerate
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">✗ No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 15px 10px;">
|
||||
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
|
||||
class="btn btn-primary" style="width: 100%; text-align: center;">
|
||||
✏️ Edit Authentication Settings
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Management Card -->
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">🎬 Playlist Management</h3>
|
||||
</div>
|
||||
|
||||
{% if playlist %}
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 15px;">
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;">
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Items</div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ playlist|length }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Duration</div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #333;">
|
||||
{% set total_duration = namespace(value=0) %}
|
||||
{% for item in playlist %}
|
||||
{% set total_duration.value = total_duration.value + (item.duration or 10) %}
|
||||
{% endfor %}
|
||||
{{ total_duration.value }}s
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Playlist Version</div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ player.playlist_version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}"
|
||||
class="btn btn-primary"
|
||||
style="display: inline-block; width: 100%; text-align: center; padding: 15px; font-size: 16px;">
|
||||
🎬 Open Playlist Manager
|
||||
</a>
|
||||
|
||||
{% if not playlist %}
|
||||
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center; margin-top: 15px;">
|
||||
⚠️ No content in playlist. Open the playlist manager to add content.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Player Activity Log Card -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
|
||||
📊 Recent Activity & Feedback
|
||||
</h3>
|
||||
|
||||
{% if recent_feedback %}
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead style="position: sticky; top: 0; background: white;">
|
||||
<tr style="background: #f8f9fa; text-align: left;">
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Time</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Status</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Message</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for feedback in recent_feedback %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; white-space: nowrap;">
|
||||
<small style="color: #6c757d;">{{ feedback.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if feedback.status == 'playing' %}
|
||||
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">▶️ Playing</span>
|
||||
{% elif feedback.status == 'idle' %}
|
||||
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">⏸️ Idle</span>
|
||||
{% elif feedback.status == 'error' %}
|
||||
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">❌ Error</span>
|
||||
{% else %}
|
||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ feedback.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{{ feedback.message or '-' }}
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if feedback.error %}
|
||||
<span style="color: #dc3545; font-family: monospace; font-size: 12px;">{{ feedback.error[:50] }}...</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px; text-align: center;">
|
||||
ℹ️ No activity logs yet. The player will send feedback once it starts playing content.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,195 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Players - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
body.dark-mode h1 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.players-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.players-table thead tr {
|
||||
background: #f8f9fa;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table thead tr {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
.players-table th {
|
||||
padding: 12px;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table th {
|
||||
border-bottom-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.players-table tbody tr {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table tbody tr {
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
.players-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table tbody tr:hover {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
.players-table td {
|
||||
padding: 12px;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table td {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.players-table td strong {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table td strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.players-table code {
|
||||
background: #f8f9fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table code {
|
||||
background: #1a202c;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
body.dark-mode .text-muted {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box {
|
||||
background: #1a365d;
|
||||
border-color: #2c5282;
|
||||
color: #90cdf4;
|
||||
}
|
||||
|
||||
.info-box a {
|
||||
color: #0c5460;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box a {
|
||||
color: #90cdf4;
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h1>Players</h1>
|
||||
<a href="{{ url_for('players.add_player') }}" class="btn btn-success">+ Add New Player</a>
|
||||
</div>
|
||||
|
||||
{% if players %}
|
||||
<div class="card">
|
||||
<table class="players-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>Location</th>
|
||||
<th>Orientation</th>
|
||||
<th>Status</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for player in players %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ player.name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ player.hostname }}</code>
|
||||
</td>
|
||||
<td>
|
||||
{{ player.location or '-' }}
|
||||
</td>
|
||||
<td>
|
||||
{{ player.orientation or 'Landscape' }}
|
||||
</td>
|
||||
<td>
|
||||
{% if player.is_online %}
|
||||
<span class="status-badge online">Online</span>
|
||||
{% else %}
|
||||
<span class="status-badge offline">Offline</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if player.last_seen %}
|
||||
{{ player.last_seen | localtime }}
|
||||
{% else %}
|
||||
<span class="text-muted">Never</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="btn btn-info btn-sm" title="Manage Player">
|
||||
⚙️ Manage
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="info-box">
|
||||
ℹ️ No players yet. <a href="{{ url_for('players.add_player') }}">Add your first player</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Utils package for digiserver-v2."""
|
||||
from app.utils.logger import log_action, log_info, log_warning, log_error, get_recent_logs
|
||||
from app.utils.uploads import (
|
||||
save_uploaded_file,
|
||||
process_video_file,
|
||||
process_pdf_file,
|
||||
get_upload_progress,
|
||||
set_upload_progress,
|
||||
clear_upload_progress,
|
||||
get_file_size,
|
||||
delete_file
|
||||
)
|
||||
from app.utils.group_player_management import (
|
||||
get_player_status_info,
|
||||
get_group_statistics,
|
||||
assign_player_to_group,
|
||||
bulk_assign_players_to_group,
|
||||
get_online_players_count,
|
||||
get_players_by_status
|
||||
)
|
||||
from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file
|
||||
|
||||
__all__ = [
|
||||
# Logger
|
||||
'log_action',
|
||||
'log_info',
|
||||
'log_warning',
|
||||
'log_error',
|
||||
'get_recent_logs',
|
||||
# Uploads
|
||||
'save_uploaded_file',
|
||||
'process_video_file',
|
||||
'process_pdf_file',
|
||||
'get_upload_progress',
|
||||
'set_upload_progress',
|
||||
'clear_upload_progress',
|
||||
'get_file_size',
|
||||
'delete_file',
|
||||
# Group/Player Management
|
||||
'get_player_status_info',
|
||||
'get_group_statistics',
|
||||
'assign_player_to_group',
|
||||
'bulk_assign_players_to_group',
|
||||
'get_online_players_count',
|
||||
'get_players_by_status',
|
||||
# PPTX Converter
|
||||
'pptx_to_pdf_libreoffice',
|
||||
'validate_pptx_file',
|
||||
]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Caddy configuration generator and manager."""
|
||||
import os
|
||||
from typing import Optional
|
||||
from app.models.https_config import HTTPSConfig
|
||||
|
||||
|
||||
class CaddyConfigGenerator:
|
||||
"""Generate Caddyfile configuration based on HTTPSConfig."""
|
||||
|
||||
@staticmethod
|
||||
def generate_caddyfile(config: Optional[HTTPSConfig] = None) -> str:
|
||||
"""Generate complete Caddyfile content.
|
||||
|
||||
Args:
|
||||
config: HTTPSConfig instance or None
|
||||
|
||||
Returns:
|
||||
Complete Caddyfile content as string
|
||||
"""
|
||||
# Get config from database if not provided
|
||||
if config is None:
|
||||
config = HTTPSConfig.get_config()
|
||||
|
||||
# Base configuration
|
||||
email = "admin@localhost"
|
||||
if config and config.email:
|
||||
email = config.email
|
||||
|
||||
base_config = f"""{{
|
||||
# Global options
|
||||
email {email}
|
||||
# Admin API for configuration management (listen on all interfaces)
|
||||
admin 0.0.0.0:2019
|
||||
# Uncomment for testing to avoid rate limits
|
||||
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
}}
|
||||
|
||||
# Shared reverse proxy configuration
|
||||
(reverse_proxy_config) {{
|
||||
reverse_proxy digiserver-app:5000 {{
|
||||
header_up Host {{host}}
|
||||
header_up X-Real-IP {{remote_host}}
|
||||
header_up X-Forwarded-Proto {{scheme}}
|
||||
|
||||
# Timeouts for large uploads
|
||||
transport http {{
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
}}
|
||||
}}
|
||||
|
||||
# File upload size limit (2GB)
|
||||
request_body {{
|
||||
max_size 2GB
|
||||
}}
|
||||
|
||||
# Security headers
|
||||
header {{
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
}}
|
||||
|
||||
# Logging
|
||||
log {{
|
||||
output file /var/log/caddy/access.log
|
||||
}}
|
||||
}}
|
||||
|
||||
# Localhost (development/local access)
|
||||
http://localhost {{
|
||||
import reverse_proxy_config
|
||||
}}
|
||||
"""
|
||||
|
||||
# Add main domain/IP configuration if HTTPS is enabled
|
||||
if config and config.https_enabled and config.domain and config.ip_address:
|
||||
# Internal domain configuration
|
||||
domain_config = f"""
|
||||
# Internal domain (HTTP only - internal use)
|
||||
http://{config.domain} {{
|
||||
import reverse_proxy_config
|
||||
}}
|
||||
|
||||
# Handle IP address access
|
||||
http://{config.ip_address} {{
|
||||
import reverse_proxy_config
|
||||
}}
|
||||
"""
|
||||
base_config += domain_config
|
||||
else:
|
||||
# Default fallback configuration
|
||||
base_config += """
|
||||
# Internal domain (HTTP only - internal use)
|
||||
http://digiserver.sibiusb.harting.intra {
|
||||
import reverse_proxy_config
|
||||
}
|
||||
|
||||
# Handle IP address access
|
||||
http://10.76.152.164 {
|
||||
import reverse_proxy_config
|
||||
}
|
||||
"""
|
||||
|
||||
# Add catch-all for any other HTTP requests
|
||||
base_config += """
|
||||
# Catch-all for any other HTTP requests
|
||||
http://* {
|
||||
import reverse_proxy_config
|
||||
}
|
||||
"""
|
||||
|
||||
return base_config
|
||||
|
||||
@staticmethod
|
||||
def write_caddyfile(caddyfile_content: str, path: str = '/app/Caddyfile') -> bool:
|
||||
"""Write Caddyfile to disk.
|
||||
|
||||
Args:
|
||||
caddyfile_content: Content to write
|
||||
path: Path to Caddyfile
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(path, 'w') as f:
|
||||
f.write(caddyfile_content)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error writing Caddyfile: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def reload_caddy() -> bool:
|
||||
"""Reload Caddy configuration without restart.
|
||||
|
||||
Note: Caddy monitoring is handled via file watching. After writing the Caddyfile,
|
||||
Caddy should automatically reload. If it doesn't, you may need to restart the
|
||||
Caddy container manually.
|
||||
|
||||
Returns:
|
||||
True if configuration was written successfully (Caddy will auto-reload)
|
||||
"""
|
||||
try:
|
||||
# Just verify that Caddy is reachable
|
||||
import urllib.request
|
||||
response = urllib.request.urlopen('http://caddy:2019/config/', timeout=2)
|
||||
return response.status == 200
|
||||
except Exception as e:
|
||||
# Caddy might not be reachable, but Caddyfile was already written
|
||||
# Caddy should reload automatically when it detects file changes
|
||||
print(f"Note: Caddy reload check returned: {str(e)}")
|
||||
return True # Return True anyway since Caddyfile was written
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Group and player management utilities."""
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Player, Group, PlayerFeedback
|
||||
from app.utils.logger import log_action
|
||||
|
||||
|
||||
def get_player_status_info(player_id: int) -> Dict:
|
||||
"""Get comprehensive status information for a player.
|
||||
|
||||
Args:
|
||||
player_id: Player ID to query
|
||||
|
||||
Returns:
|
||||
Dictionary with status information
|
||||
"""
|
||||
player = Player.query.get(player_id)
|
||||
|
||||
if not player:
|
||||
return {
|
||||
'online': False,
|
||||
'status': 'unknown',
|
||||
'last_seen': None,
|
||||
'latest_feedback': None
|
||||
}
|
||||
|
||||
# Check if player is online (seen in last 5 minutes)
|
||||
is_online = False
|
||||
if player.last_seen:
|
||||
delta = datetime.utcnow() - player.last_seen
|
||||
is_online = delta.total_seconds() < 300
|
||||
|
||||
# Get latest feedback
|
||||
latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
|
||||
.order_by(PlayerFeedback.timestamp.desc())\
|
||||
.first()
|
||||
|
||||
return {
|
||||
'online': is_online,
|
||||
'status': player.status,
|
||||
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
|
||||
'last_seen_ago': _format_time_ago(player.last_seen) if player.last_seen else 'Never',
|
||||
'latest_feedback': {
|
||||
'status': latest_feedback.status,
|
||||
'message': latest_feedback.message,
|
||||
'error': latest_feedback.error,
|
||||
'timestamp': latest_feedback.timestamp.isoformat()
|
||||
} if latest_feedback else None
|
||||
}
|
||||
|
||||
|
||||
def get_group_statistics(group_id: int) -> Dict:
|
||||
"""Get statistics for a group.
|
||||
|
||||
Args:
|
||||
group_id: Group ID to query
|
||||
|
||||
Returns:
|
||||
Dictionary with group statistics
|
||||
"""
|
||||
group = Group.query.get(group_id)
|
||||
|
||||
if not group:
|
||||
return {
|
||||
'total_players': 0,
|
||||
'online_players': 0,
|
||||
'total_content': 0,
|
||||
'error_count': 0
|
||||
}
|
||||
|
||||
total_players = group.player_count
|
||||
total_content = group.content_count
|
||||
|
||||
# Count online players
|
||||
online_players = 0
|
||||
error_count = 0
|
||||
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
|
||||
|
||||
for player in group.players:
|
||||
if player.last_seen and player.last_seen >= five_min_ago:
|
||||
online_players += 1
|
||||
if player.status == 'error':
|
||||
error_count += 1
|
||||
|
||||
return {
|
||||
'group_id': group_id,
|
||||
'group_name': group.name,
|
||||
'total_players': total_players,
|
||||
'online_players': online_players,
|
||||
'offline_players': total_players - online_players,
|
||||
'total_content': total_content,
|
||||
'error_count': error_count
|
||||
}
|
||||
|
||||
|
||||
def assign_player_to_group(player_id: int, group_id: Optional[int]) -> bool:
|
||||
"""Assign a player to a group or unassign if group_id is None.
|
||||
|
||||
Args:
|
||||
player_id: Player ID to assign
|
||||
group_id: Group ID to assign to, or None to unassign
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
player = Player.query.get(player_id)
|
||||
|
||||
if not player:
|
||||
log_action('error', f'Player {player_id} not found')
|
||||
return False
|
||||
|
||||
old_group_id = player.group_id
|
||||
player.group_id = group_id
|
||||
db.session.commit()
|
||||
|
||||
if group_id:
|
||||
group = Group.query.get(group_id)
|
||||
log_action('info', f'Player "{player.name}" assigned to group "{group.name}"')
|
||||
else:
|
||||
log_action('info', f'Player "{player.name}" unassigned from group')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error assigning player to group: {str(e)}')
|
||||
return False
|
||||
|
||||
|
||||
def bulk_assign_players_to_group(player_ids: List[int], group_id: Optional[int]) -> int:
|
||||
"""Assign multiple players to a group.
|
||||
|
||||
Args:
|
||||
player_ids: List of player IDs to assign
|
||||
group_id: Group ID to assign to, or None to unassign
|
||||
|
||||
Returns:
|
||||
Number of players successfully assigned
|
||||
"""
|
||||
count = 0
|
||||
|
||||
try:
|
||||
for player_id in player_ids:
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
player.group_id = group_id
|
||||
count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if group_id:
|
||||
group = Group.query.get(group_id)
|
||||
log_action('info', f'Bulk assigned {count} players to group "{group.name}"')
|
||||
else:
|
||||
log_action('info', f'Bulk unassigned {count} players from groups')
|
||||
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error bulk assigning players: {str(e)}')
|
||||
return 0
|
||||
|
||||
|
||||
def get_online_players_count() -> int:
|
||||
"""Get count of online players (seen in last 5 minutes).
|
||||
|
||||
Returns:
|
||||
Number of online players
|
||||
"""
|
||||
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
|
||||
return Player.query.filter(Player.last_seen >= five_min_ago).count()
|
||||
|
||||
|
||||
def get_players_by_status(status: str) -> List[Player]:
|
||||
"""Get all players with a specific status.
|
||||
|
||||
Args:
|
||||
status: Status to filter by
|
||||
|
||||
Returns:
|
||||
List of Player instances
|
||||
"""
|
||||
return Player.query.filter_by(status=status).all()
|
||||
|
||||
|
||||
def _format_time_ago(dt: datetime) -> str:
|
||||
"""Format datetime as 'time ago' string.
|
||||
|
||||
Args:
|
||||
dt: Datetime to format
|
||||
|
||||
Returns:
|
||||
Formatted string like '5 minutes ago'
|
||||
"""
|
||||
delta = datetime.utcnow() - dt
|
||||
seconds = delta.total_seconds()
|
||||
|
||||
if seconds < 60:
|
||||
return f'{int(seconds)} seconds ago'
|
||||
elif seconds < 3600:
|
||||
return f'{int(seconds / 60)} minutes ago'
|
||||
elif seconds < 86400:
|
||||
return f'{int(seconds / 3600)} hours ago'
|
||||
else:
|
||||
return f'{int(seconds / 86400)} days ago'
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Logging utility for tracking system events."""
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.server_log import ServerLog
|
||||
|
||||
|
||||
def log_action(level: str, message: str) -> None:
|
||||
"""Log an action to the database with specified level.
|
||||
|
||||
Args:
|
||||
level: Log level (info, warning, error)
|
||||
message: Log message content
|
||||
"""
|
||||
try:
|
||||
new_log = ServerLog(level=level, message=message)
|
||||
db.session.add(new_log)
|
||||
db.session.commit()
|
||||
print(f"[{level.upper()}] {message}")
|
||||
except Exception as e:
|
||||
print(f"Error logging action: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
def get_recent_logs(limit: int = 20, level: Optional[str] = None) -> list:
|
||||
"""Get the most recent log entries.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of logs to return
|
||||
level: Optional filter by log level
|
||||
|
||||
Returns:
|
||||
List of ServerLog instances
|
||||
"""
|
||||
query = ServerLog.query
|
||||
|
||||
if level:
|
||||
query = query.filter_by(level=level)
|
||||
|
||||
return query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
|
||||
|
||||
|
||||
def clear_old_logs(days: int = 30) -> int:
|
||||
"""Delete logs older than specified days.
|
||||
|
||||
Args:
|
||||
days: Number of days to keep
|
||||
|
||||
Returns:
|
||||
Number of logs deleted
|
||||
"""
|
||||
try:
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
deleted = ServerLog.query.filter(ServerLog.timestamp < cutoff_date).delete()
|
||||
db.session.commit()
|
||||
log_action('info', f'Deleted {deleted} old log entries (older than {days} days)')
|
||||
return deleted
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error clearing old logs: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
# Convenience functions for specific log levels
|
||||
def log_info(message: str) -> None:
|
||||
"""Log an info level message."""
|
||||
log_action('info', message)
|
||||
|
||||
|
||||
def log_warning(message: str) -> None:
|
||||
"""Log a warning level message."""
|
||||
log_action('warning', message)
|
||||
|
||||
|
||||
def log_error(message: str) -> None:
|
||||
"""Log an error level message."""
|
||||
log_action('error', message)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Nginx configuration reader utility."""
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
|
||||
class NginxConfigReader:
|
||||
"""Read and parse Nginx configuration files."""
|
||||
|
||||
def __init__(self, config_path: str = '/etc/nginx/nginx.conf'):
|
||||
"""Initialize Nginx config reader."""
|
||||
self.config_path = config_path
|
||||
self.config_content = None
|
||||
self.is_available = os.path.exists(config_path)
|
||||
|
||||
if self.is_available:
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
self.config_content = f.read()
|
||||
except Exception as e:
|
||||
self.is_available = False
|
||||
self.error = str(e)
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get Nginx configuration status."""
|
||||
if not self.is_available:
|
||||
return {
|
||||
'available': False,
|
||||
'error': 'Nginx configuration not found',
|
||||
'path': self.config_path
|
||||
}
|
||||
|
||||
return {
|
||||
'available': True,
|
||||
'path': self.config_path,
|
||||
'file_exists': os.path.exists(self.config_path),
|
||||
'ssl_enabled': self._check_ssl_enabled(),
|
||||
'http_ports': self._extract_http_ports(),
|
||||
'https_ports': self._extract_https_ports(),
|
||||
'upstream_servers': self._extract_upstream_servers(),
|
||||
'server_names': self._extract_server_names(),
|
||||
'ssl_protocols': self._extract_ssl_protocols(),
|
||||
'client_max_body_size': self._extract_client_max_body_size(),
|
||||
'gzip_enabled': self._check_gzip_enabled(),
|
||||
}
|
||||
|
||||
def _check_ssl_enabled(self) -> bool:
|
||||
"""Check if SSL is enabled."""
|
||||
if not self.config_content:
|
||||
return False
|
||||
return 'ssl_certificate' in self.config_content
|
||||
|
||||
def _extract_http_ports(self) -> List[int]:
|
||||
"""Extract HTTP listening ports."""
|
||||
if not self.config_content:
|
||||
return []
|
||||
pattern = r'listen\s+(\d+)'
|
||||
matches = re.findall(pattern, self.config_content)
|
||||
return sorted(list(set(int(p) for p in matches if int(p) < 1000)))
|
||||
|
||||
def _extract_https_ports(self) -> List[int]:
|
||||
"""Extract HTTPS listening ports."""
|
||||
if not self.config_content:
|
||||
return []
|
||||
pattern = r'listen\s+(\d+).*ssl'
|
||||
matches = re.findall(pattern, self.config_content)
|
||||
return sorted(list(set(int(p) for p in matches)))
|
||||
|
||||
def _extract_upstream_servers(self) -> List[str]:
|
||||
"""Extract upstream servers."""
|
||||
if not self.config_content:
|
||||
return []
|
||||
upstream_match = re.search(r'upstream\s+\w+\s*{([^}]+)}', self.config_content)
|
||||
if upstream_match:
|
||||
upstream_content = upstream_match.group(1)
|
||||
servers = re.findall(r'server\s+([^\s;]+)', upstream_content)
|
||||
return servers
|
||||
return []
|
||||
|
||||
def _extract_server_names(self) -> List[str]:
|
||||
"""Extract server names."""
|
||||
if not self.config_content:
|
||||
return []
|
||||
pattern = r'server_name\s+([^;]+);'
|
||||
matches = re.findall(pattern, self.config_content)
|
||||
result = []
|
||||
for match in matches:
|
||||
names = match.strip().split()
|
||||
result.extend(names)
|
||||
return result
|
||||
|
||||
def _extract_ssl_protocols(self) -> List[str]:
|
||||
"""Extract SSL protocols."""
|
||||
if not self.config_content:
|
||||
return []
|
||||
pattern = r'ssl_protocols\s+([^;]+);'
|
||||
match = re.search(pattern, self.config_content)
|
||||
if match:
|
||||
return match.group(1).strip().split()
|
||||
return []
|
||||
|
||||
def _extract_client_max_body_size(self) -> Optional[str]:
|
||||
"""Extract client max body size."""
|
||||
if not self.config_content:
|
||||
return None
|
||||
pattern = r'client_max_body_size\s+([^;]+);'
|
||||
match = re.search(pattern, self.config_content)
|
||||
return match.group(1).strip() if match else None
|
||||
|
||||
def _check_gzip_enabled(self) -> bool:
|
||||
"""Check if gzip is enabled."""
|
||||
if not self.config_content:
|
||||
return False
|
||||
return bool(re.search(r'gzip\s+on\s*;', self.config_content))
|
||||
|
||||
|
||||
def get_nginx_status() -> Dict[str, Any]:
|
||||
"""Get Nginx configuration status."""
|
||||
reader = NginxConfigReader()
|
||||
return reader.get_status()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Portal SSO middleware for DigiServer v2.
|
||||
|
||||
When the umbrella nginx verifies the portal JWT it sets two headers:
|
||||
X-Auth-Username — the portal username
|
||||
X-Auth-Role — 'admin' or 'user'
|
||||
|
||||
This before_request handler reads those headers and auto-logs in the
|
||||
corresponding local DigiServer user, creating them on first access if
|
||||
needed. The local session is then maintained normally by Flask-Login.
|
||||
"""
|
||||
import secrets
|
||||
from flask import request
|
||||
from flask_login import login_user, current_user
|
||||
|
||||
|
||||
def init_portal_sso(app):
|
||||
"""Register the SSO before_request handler on the given Flask app."""
|
||||
|
||||
@app.before_request
|
||||
def _portal_sso():
|
||||
if current_user.is_authenticated:
|
||||
return
|
||||
|
||||
username = request.headers.get('X-Auth-Username', '').strip()
|
||||
if not username:
|
||||
return
|
||||
|
||||
role = request.headers.get('X-Auth-Role', 'user').strip()
|
||||
user = _get_or_create_user(username, role)
|
||||
if user:
|
||||
login_user(user, remember=False)
|
||||
|
||||
|
||||
def _get_or_create_user(username, role):
|
||||
from app.models.user import User
|
||||
from app.extensions import db, bcrypt
|
||||
|
||||
try:
|
||||
target_role = 'admin' if role == 'admin' else 'user'
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
hashed_pw = bcrypt.generate_password_hash(secrets.token_hex(32)).decode('utf-8')
|
||||
user = User(
|
||||
username=username,
|
||||
password=hashed_pw,
|
||||
role=target_role,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
elif user.role != target_role:
|
||||
# Role changed in portal → sync it here immediately
|
||||
user.role = target_role
|
||||
db.session.commit()
|
||||
return user
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""PowerPoint to PDF converter using LibreOffice."""
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cleanup_libreoffice_processes() -> None:
|
||||
"""Clean up any hanging LibreOffice processes."""
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', 'soffice'], capture_output=True, timeout=10)
|
||||
time.sleep(1) # Give processes time to terminate
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup LibreOffice processes: {e}")
|
||||
|
||||
|
||||
def pptx_to_pdf_libreoffice(pptx_path: str, output_dir: str) -> Optional[str]:
|
||||
"""Convert PPTX to PDF using LibreOffice for highest quality.
|
||||
|
||||
This function is the core component of the PPTX processing workflow:
|
||||
PPTX → PDF (this function) → JPG images (handled in uploads.py)
|
||||
|
||||
Args:
|
||||
pptx_path: Path to the PPTX file
|
||||
output_dir: Directory to save the PDF
|
||||
|
||||
Returns:
|
||||
Path to the generated PDF file, or None if conversion failed
|
||||
"""
|
||||
try:
|
||||
# Clean up any existing LibreOffice processes
|
||||
cleanup_libreoffice_processes()
|
||||
|
||||
# Ensure output directory exists
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Use LibreOffice to convert PPTX to PDF
|
||||
cmd = [
|
||||
'libreoffice',
|
||||
'--headless',
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', output_dir,
|
||||
'--invisible',
|
||||
'--nodefault',
|
||||
pptx_path
|
||||
]
|
||||
|
||||
logger.info(f"Converting PPTX to PDF using LibreOffice: {pptx_path}")
|
||||
|
||||
# Increase timeout to 300 seconds (5 minutes) for large presentations
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"LibreOffice conversion failed: {result.stderr}")
|
||||
logger.error(f"LibreOffice stdout: {result.stdout}")
|
||||
cleanup_libreoffice_processes()
|
||||
return None
|
||||
|
||||
# Find the generated PDF file
|
||||
base_name = os.path.splitext(os.path.basename(pptx_path))[0]
|
||||
pdf_path = os.path.join(output_dir, f"{base_name}.pdf")
|
||||
|
||||
if os.path.exists(pdf_path):
|
||||
logger.info(f"PDF conversion successful: {pdf_path}")
|
||||
cleanup_libreoffice_processes()
|
||||
return pdf_path
|
||||
else:
|
||||
logger.error(f"PDF file not found after conversion: {pdf_path}")
|
||||
cleanup_libreoffice_processes()
|
||||
return None
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("LibreOffice conversion timed out (300s)")
|
||||
cleanup_libreoffice_processes()
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in PPTX to PDF conversion: {e}")
|
||||
cleanup_libreoffice_processes()
|
||||
return None
|
||||
|
||||
|
||||
def validate_pptx_file(filepath: str) -> bool:
|
||||
"""Validate if file is a valid PowerPoint file.
|
||||
|
||||
Args:
|
||||
filepath: Path to file to validate
|
||||
|
||||
Returns:
|
||||
True if valid PPTX file, False otherwise
|
||||
"""
|
||||
if not os.path.exists(filepath):
|
||||
return False
|
||||
|
||||
# Check file extension
|
||||
if not filepath.lower().endswith(('.ppt', '.pptx')):
|
||||
return False
|
||||
|
||||
# Check file size (must be > 0)
|
||||
if os.path.getsize(filepath) == 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
ScriptNameFix WSGI middleware.
|
||||
|
||||
When nginx strips the path prefix before forwarding to a Flask app it also
|
||||
sets the X-Script-Name header (e.g. /digiserver). This middleware reads
|
||||
that header and sets SCRIPT_NAME in the WSGI environ so that Flask's
|
||||
url_for() generates absolute URLs with the correct prefix.
|
||||
|
||||
Usage in the app factory:
|
||||
from app.utils.script_name_fix import ScriptNameFix
|
||||
app.wsgi_app = ScriptNameFix(app.wsgi_app)
|
||||
"""
|
||||
|
||||
|
||||
class ScriptNameFix:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
script_name = environ.get('HTTP_X_SCRIPT_NAME', '').rstrip('/')
|
||||
if script_name:
|
||||
environ['SCRIPT_NAME'] = script_name
|
||||
path_info = environ.get('PATH_INFO', '/')
|
||||
if path_info.startswith(script_name):
|
||||
environ['PATH_INFO'] = path_info[len(script_name):] or '/'
|
||||
return self.app(environ, start_response)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Upload utilities for handling file uploads and processing."""
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Optional, Dict, Tuple
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from app.utils.logger import log_action
|
||||
|
||||
|
||||
# In-memory storage for upload progress (use Redis in production)
|
||||
_upload_progress: Dict[str, Dict] = {}
|
||||
|
||||
|
||||
def get_upload_progress(upload_id: str) -> Dict:
|
||||
"""Get upload progress for a specific upload ID.
|
||||
|
||||
Args:
|
||||
upload_id: Unique upload identifier
|
||||
|
||||
Returns:
|
||||
Progress dictionary with status, progress, and message
|
||||
"""
|
||||
return _upload_progress.get(upload_id, {
|
||||
'status': 'unknown',
|
||||
'progress': 0,
|
||||
'message': 'Upload not found'
|
||||
})
|
||||
|
||||
|
||||
def set_upload_progress(upload_id: str, progress: int, message: str,
|
||||
status: str = 'processing') -> None:
|
||||
"""Set upload progress for a specific upload ID.
|
||||
|
||||
Args:
|
||||
upload_id: Unique upload identifier
|
||||
progress: Progress percentage (0-100)
|
||||
message: Status message
|
||||
status: Status string (uploading, processing, complete, error)
|
||||
"""
|
||||
_upload_progress[upload_id] = {
|
||||
'status': status,
|
||||
'progress': progress,
|
||||
'message': message
|
||||
}
|
||||
|
||||
|
||||
def clear_upload_progress(upload_id: str) -> None:
|
||||
"""Clear upload progress for a specific upload ID.
|
||||
|
||||
Args:
|
||||
upload_id: Unique upload identifier
|
||||
"""
|
||||
if upload_id in _upload_progress:
|
||||
del _upload_progress[upload_id]
|
||||
|
||||
|
||||
def save_uploaded_file(file: FileStorage, upload_folder: str,
|
||||
filename: Optional[str] = None) -> Tuple[bool, str, str]:
|
||||
"""Save an uploaded file to the upload folder.
|
||||
|
||||
Args:
|
||||
file: FileStorage object from request.files
|
||||
upload_folder: Path to upload directory
|
||||
filename: Optional custom filename (will be secured)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, filepath, message)
|
||||
"""
|
||||
try:
|
||||
if not filename:
|
||||
filename = secure_filename(file.filename)
|
||||
else:
|
||||
filename = secure_filename(filename)
|
||||
|
||||
# Ensure upload folder exists
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
|
||||
# Save file
|
||||
file.save(filepath)
|
||||
|
||||
log_action('info', f'File saved: {filename}')
|
||||
return True, filepath, 'File saved successfully'
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error saving file: {str(e)}')
|
||||
return False, '', f'Error saving file: {str(e)}'
|
||||
|
||||
|
||||
def process_video_file(filepath: str, upload_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
"""Process video file for optimization (H.264, 30fps, max 1080p).
|
||||
|
||||
Args:
|
||||
filepath: Path to video file
|
||||
upload_id: Optional upload ID for progress tracking
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
if upload_id:
|
||||
set_upload_progress(upload_id, 60, 'Converting video to optimized format...')
|
||||
|
||||
# Prepare temp output file
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_output = os.path.join(temp_dir, f"optimized_{os.path.basename(filepath)}")
|
||||
|
||||
# ffmpeg command for Raspberry Pi optimization
|
||||
ffmpeg_cmd = [
|
||||
'ffmpeg', '-y', '-i', filepath,
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'medium',
|
||||
'-profile:v', 'main',
|
||||
'-crf', '23',
|
||||
'-maxrate', '8M',
|
||||
'-bufsize', '12M',
|
||||
'-vf', 'scale=\'min(1920,iw)\':\'min(1080,ih)\':force_original_aspect_ratio=decrease,fps=30',
|
||||
'-r', '30',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
temp_output
|
||||
]
|
||||
|
||||
if upload_id:
|
||||
set_upload_progress(upload_id, 70, 'Processing video (this may take a few minutes)...')
|
||||
|
||||
# Run ffmpeg
|
||||
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, timeout=1800)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = f'Video conversion failed: {result.stderr[:200]}'
|
||||
log_action('error', error_msg)
|
||||
|
||||
if upload_id:
|
||||
set_upload_progress(upload_id, 0, error_msg, 'error')
|
||||
|
||||
# Remove original file if conversion failed
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
return False, error_msg
|
||||
|
||||
if upload_id:
|
||||
set_upload_progress(upload_id, 85, 'Replacing with optimized video...')
|
||||
|
||||
# Replace original with optimized version
|
||||
shutil.move(temp_output, filepath)
|
||||
|
||||
log_action('info', f'Video optimized successfully: {os.path.basename(filepath)}')
|
||||
return True, 'Video optimized successfully'
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = 'Video conversion timed out (30 minutes)'
|
||||
log_action('error', error_msg)
|
||||
|
||||
if upload_id:
|
||||
set_upload_progress(upload_id, 0, error_msg, 'error')
|
||||
|
||||
return False, error_msg
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Error processing video: {str(e)}'
|
||||
log_action('error', error_msg)
|
||||
|
||||
if upload_id:
|
||||
set_upload_progress(upload_id, 0, error_msg, 'error')
|
||||
|
||||
return False, error_msg
|
||||
|
||||
|
||||
def process_pdf_file(filepath: str, upload_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
"""Process PDF file (convert to images for display).
|
||||
|
||||
Args:
|
||||
filepath: Path to PDF file
|
||||
upload_id: Optional upload ID for progress tracking
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
if upload_id:
|
||||
set_upload_progress(upload_id, 60, 'Converting PDF to images...')
|
||||
|
||||
# This would use pdf2image or similar
|
||||
# For now, just log the action
|
||||
log_action('info', f'PDF processing requested for: {os.path.basename(filepath)}')
|
||||
|
||||
if upload_id:
|
||||
set_upload_progress(upload_id, 85, 'PDF processed successfully')
|
||||
|
||||
return True, 'PDF processed successfully'
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Error processing PDF: {str(e)}'
|
||||
log_action('error', error_msg)
|
||||
|
||||
if upload_id:
|
||||
set_upload_progress(upload_id, 0, error_msg, 'error')
|
||||
|
||||
return False, error_msg
|
||||
|
||||
|
||||
def get_file_size(filepath: str) -> int:
|
||||
"""Get file size in bytes.
|
||||
|
||||
Args:
|
||||
filepath: Path to file
|
||||
|
||||
Returns:
|
||||
File size in bytes, or 0 if file doesn't exist
|
||||
"""
|
||||
try:
|
||||
return os.path.getsize(filepath)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def delete_file(filepath: str) -> Tuple[bool, str]:
|
||||
"""Delete a file from disk.
|
||||
|
||||
Args:
|
||||
filepath: Path to file
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
log_action('info', f'File deleted: {os.path.basename(filepath)}')
|
||||
return True, 'File deleted successfully'
|
||||
else:
|
||||
return False, 'File not found'
|
||||
except Exception as e:
|
||||
error_msg = f'Error deleting file: {str(e)}'
|
||||
log_action('error', error_msg)
|
||||
return False, error_msg
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/bin/bash
|
||||
# Automated deployment script for DigiServer on a new PC
|
||||
# Run this script to completely set up DigiServer with all configurations
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ DigiServer Automated Deployment ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if docker compose is available
|
||||
if ! docker compose version &> /dev/null; then
|
||||
echo -e "${RED}❌ docker compose not found!${NC}"
|
||||
echo "Please install docker compose first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if we're in the project directory
|
||||
if [ ! -f "docker-compose.yml" ]; then
|
||||
echo -e "${RED}❌ docker-compose.yml not found!${NC}"
|
||||
echo "Please run this script from the digiserver-v2 directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# INITIALIZATION: Create data directories and copy nginx configs
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}📁 Initializing data directories...${NC}"
|
||||
|
||||
# Create necessary data directories
|
||||
mkdir -p data/instance
|
||||
mkdir -p data/uploads
|
||||
mkdir -p data/nginx-ssl
|
||||
mkdir -p data/nginx-logs
|
||||
mkdir -p data/certbot
|
||||
|
||||
# Copy nginx configuration files from repo root to data folder
|
||||
if [ -f "nginx.conf" ]; then
|
||||
cp nginx.conf data/nginx.conf
|
||||
echo -e " ${GREEN}✓${NC} nginx.conf copied to data/"
|
||||
else
|
||||
echo -e " ${RED}❌ nginx.conf not found in repo root!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "nginx-custom-domains.conf" ]; then
|
||||
cp nginx-custom-domains.conf data/nginx-custom-domains.conf
|
||||
echo -e " ${GREEN}✓${NC} nginx-custom-domains.conf copied to data/"
|
||||
else
|
||||
echo -e " ${RED}❌ nginx-custom-domains.conf not found in repo root!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Data directories initialized${NC}"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# CONFIGURATION VARIABLES
|
||||
# ============================================================================
|
||||
HOSTNAME="${HOSTNAME:-digiserver}"
|
||||
DOMAIN="${DOMAIN:-digiserver.sibiusb.harting.intra}"
|
||||
IP_ADDRESS="${IP_ADDRESS:-10.76.152.164}"
|
||||
EMAIL="${EMAIL:-admin@example.com}"
|
||||
PORT="${PORT:-443}"
|
||||
|
||||
echo -e "${BLUE}Configuration:${NC}"
|
||||
echo " Hostname: $HOSTNAME"
|
||||
echo " Domain: $DOMAIN"
|
||||
echo " IP Address: $IP_ADDRESS"
|
||||
echo " Email: $EMAIL"
|
||||
echo " Port: $PORT"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 1: Start containers
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}📦 [1/6] Starting containers...${NC}"
|
||||
docker compose up -d
|
||||
|
||||
echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}"
|
||||
sleep 10
|
||||
|
||||
# Verify containers are running
|
||||
if ! docker compose ps | grep -q "Up"; then
|
||||
echo -e "${RED}❌ Containers failed to start!${NC}"
|
||||
docker compose logs
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✅ Containers started successfully${NC}"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 2: Run database migrations
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}📊 [2/6] Running database migrations...${NC}"
|
||||
|
||||
echo -e " • Creating https_config table..."
|
||||
docker compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
|
||||
echo -e " • Creating player_user table..."
|
||||
docker compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
|
||||
echo -e " • Adding email to https_config..."
|
||||
docker compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
|
||||
echo -e " • Migrating player_user global settings..."
|
||||
docker compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
|
||||
|
||||
echo -e "${GREEN}✅ All database migrations completed${NC}"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 3: Configure HTTPS
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS...${NC}"
|
||||
|
||||
docker compose exec -T digiserver-app python /app/https_manager.py enable \
|
||||
"$HOSTNAME" \
|
||||
"$DOMAIN" \
|
||||
"$EMAIL" \
|
||||
"$IP_ADDRESS" \
|
||||
"$PORT"
|
||||
|
||||
echo -e "${GREEN}✅ HTTPS configured successfully${NC}"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 4: Verify database setup
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}"
|
||||
|
||||
docker compose exec -T digiserver-app python -c "
|
||||
from app.app import create_app
|
||||
from sqlalchemy import inspect
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
inspector = inspect(app.extensions.db.engine)
|
||||
tables = inspector.get_table_names()
|
||||
print(' Database tables:')
|
||||
for table in sorted(tables):
|
||||
print(f' ✓ {table}')
|
||||
print(f'')
|
||||
print(f' ✅ Total tables: {len(tables)}')
|
||||
" 2>/dev/null || echo " ⚠️ Database verification skipped"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 5: Verify Caddy configuration
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}"
|
||||
|
||||
docker compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e " ${GREEN}✅ Caddy configuration is valid${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠️ Caddy validation skipped${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 6: Display summary
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}📋 [6/6] Displaying configuration summary...${NC}"
|
||||
echo ""
|
||||
|
||||
docker compose exec -T digiserver-app python /app/https_manager.py status
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ 🎉 Deployment Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}📍 Access Points:${NC}"
|
||||
echo " 🔒 https://$HOSTNAME"
|
||||
echo " 🔒 https://$IP_ADDRESS"
|
||||
echo " 🔒 https://$DOMAIN"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}📝 Default Credentials:${NC}"
|
||||
echo " Username: admin"
|
||||
echo " Password: admin123 (⚠️ CHANGE IN PRODUCTION)"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}📚 Documentation:${NC}"
|
||||
echo " • DEPLOYMENT_COMMANDS.md - Detailed docker exec commands"
|
||||
echo " • HTTPS_CONFIGURATION.md - HTTPS setup details"
|
||||
echo " • setup_https.sh - Manual configuration script"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}Next Steps:${NC}"
|
||||
echo "1. Access the application at one of the URLs above"
|
||||
echo "2. Log in with admin credentials"
|
||||
echo "3. Change the default password immediately"
|
||||
echo "4. Configure your players and content"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}📞 Support:${NC}"
|
||||
echo "For troubleshooting, see DEPLOYMENT_COMMANDS.md section 7"
|
||||
echo ""
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/bin/bash
|
||||
|
||||
# DigiServer v2 Production Deployment Commands Reference
|
||||
# Use this file as a reference for all deployment-related operations
|
||||
|
||||
echo "📋 DigiServer v2 Production Deployment Reference"
|
||||
echo "================================================="
|
||||
echo ""
|
||||
echo "QUICK START:"
|
||||
echo " 1. Set environment variables"
|
||||
echo " 2. Create .env file"
|
||||
echo " 3. Run: docker-compose up -d"
|
||||
echo ""
|
||||
echo "Available commands below (copy/paste as needed):"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SECTION: INITIAL SETUP
|
||||
# ============================================================================
|
||||
|
||||
echo "=== SECTION 1: INITIAL SETUP ==="
|
||||
echo ""
|
||||
echo "Generate Secret Key:"
|
||||
echo ' python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
||||
echo ""
|
||||
echo "Create environment file from template:"
|
||||
echo " cp .env.example .env"
|
||||
echo " nano .env # Edit with your values"
|
||||
echo ""
|
||||
echo "Required .env variables:"
|
||||
echo " SECRET_KEY=<generated-32-char-key>"
|
||||
echo " ADMIN_USERNAME=admin"
|
||||
echo " ADMIN_PASSWORD=<strong-password>"
|
||||
echo " ADMIN_EMAIL=admin@company.com"
|
||||
echo " DOMAIN=your-domain.com"
|
||||
echo " EMAIL=admin@your-domain.com"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SECTION: DOCKER OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
echo "=== SECTION 2: DOCKER OPERATIONS ==="
|
||||
echo ""
|
||||
echo "Build images:"
|
||||
echo " docker-compose build"
|
||||
echo ""
|
||||
echo "Start services:"
|
||||
echo " docker-compose up -d"
|
||||
echo ""
|
||||
echo "Stop services:"
|
||||
echo " docker-compose down"
|
||||
echo ""
|
||||
echo "Restart services:"
|
||||
echo " docker-compose restart"
|
||||
echo ""
|
||||
echo "View container status:"
|
||||
echo " docker-compose ps"
|
||||
echo ""
|
||||
echo "View logs (live):"
|
||||
echo " docker-compose logs -f digiserver-app"
|
||||
echo ""
|
||||
echo "View logs (last 100 lines):"
|
||||
echo " docker-compose logs --tail=100 digiserver-app"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SECTION: DATABASE OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
echo "=== SECTION 3: DATABASE OPERATIONS ==="
|
||||
echo ""
|
||||
echo "Initialize database (first deployment only):"
|
||||
echo " docker-compose exec digiserver-app flask db upgrade"
|
||||
echo ""
|
||||
echo "Run database migrations:"
|
||||
echo " docker-compose exec digiserver-app flask db upgrade head"
|
||||
echo ""
|
||||
echo "Create new migration (after model changes):"
|
||||
echo " docker-compose exec digiserver-app flask db migrate -m 'description'"
|
||||
echo ""
|
||||
echo "Backup database:"
|
||||
echo " docker-compose exec digiserver-app cp instance/dashboard.db /backup/dashboard.db.bak"
|
||||
echo ""
|
||||
echo "Restore database:"
|
||||
echo " docker-compose exec digiserver-app cp /backup/dashboard.db.bak instance/dashboard.db"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SECTION: VERIFICATION & TESTING
|
||||
# ============================================================================
|
||||
|
||||
echo "=== SECTION 4: VERIFICATION & TESTING ==="
|
||||
echo ""
|
||||
echo "Health check:"
|
||||
echo " curl -k https://your-domain.com/api/health"
|
||||
echo ""
|
||||
echo "Check CORS headers (should see Access-Control-Allow-*):"
|
||||
echo " curl -i -k https://your-domain.com/api/playlists"
|
||||
echo ""
|
||||
echo "Check HTTPS only (should redirect):"
|
||||
echo " curl -i http://your-domain.com/"
|
||||
echo ""
|
||||
echo "Test certificate:"
|
||||
echo " openssl s_client -connect your-domain.com:443 -showcerts"
|
||||
echo ""
|
||||
echo "Check SSL certificate expiry:"
|
||||
echo " openssl x509 -enddate -noout -in data/nginx-ssl/cert.pem"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SECTION: TROUBLESHOOTING
|
||||
# ============================================================================
|
||||
|
||||
echo "=== SECTION 5: TROUBLESHOOTING ==="
|
||||
echo ""
|
||||
echo "View full container logs:"
|
||||
echo " docker-compose logs digiserver-app"
|
||||
echo ""
|
||||
echo "Execute command in container:"
|
||||
echo " docker-compose exec digiserver-app bash"
|
||||
echo ""
|
||||
echo "Check container resources:"
|
||||
echo " docker stats"
|
||||
echo ""
|
||||
echo "Remove and rebuild from scratch:"
|
||||
echo " docker-compose down -v"
|
||||
echo " docker-compose build --no-cache"
|
||||
echo " docker-compose up -d"
|
||||
echo ""
|
||||
echo "Check disk space:"
|
||||
echo " du -sh data/"
|
||||
echo ""
|
||||
echo "View network configuration:"
|
||||
echo " docker-compose exec digiserver-app netstat -tuln"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SECTION: MAINTENANCE
|
||||
# ============================================================================
|
||||
|
||||
echo "=== SECTION 6: MAINTENANCE ==="
|
||||
echo ""
|
||||
echo "Clean up unused Docker resources:"
|
||||
echo " docker system prune -a"
|
||||
echo ""
|
||||
echo "Backup entire application:"
|
||||
echo " tar -czf digiserver-backup-\$(date +%Y%m%d).tar.gz ."
|
||||
echo ""
|
||||
echo "Update Docker images:"
|
||||
echo " docker-compose pull"
|
||||
echo " docker-compose up -d"
|
||||
echo ""
|
||||
echo "Rebuild and redeploy:"
|
||||
echo " docker-compose down"
|
||||
echo " docker-compose build --no-cache"
|
||||
echo " docker-compose up -d"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SECTION: MONITORING
|
||||
# ============================================================================
|
||||
|
||||
echo "=== SECTION 7: MONITORING ==="
|
||||
echo ""
|
||||
echo "Monitor containers in real-time:"
|
||||
echo " watch -n 1 docker-compose ps"
|
||||
echo ""
|
||||
echo "Monitor resource usage:"
|
||||
echo " docker stats --no-stream"
|
||||
echo ""
|
||||
echo "Check application errors:"
|
||||
echo " docker-compose logs --since 10m digiserver-app | grep ERROR"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SECTION: GIT OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
echo "=== SECTION 8: GIT OPERATIONS ==="
|
||||
echo ""
|
||||
echo "Check deployment status:"
|
||||
echo " git status"
|
||||
echo ""
|
||||
echo "View deployment history:"
|
||||
echo " git log --oneline -5"
|
||||
echo ""
|
||||
echo "Commit deployment changes:"
|
||||
echo " git add ."
|
||||
echo " git commit -m 'Deployment configuration'"
|
||||
echo ""
|
||||
echo "Tag release:"
|
||||
echo " git tag -a v2.0.0 -m 'Production release'"
|
||||
echo " git push --tags"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SECTION: EMERGENCY PROCEDURES
|
||||
# ============================================================================
|
||||
|
||||
echo "=== SECTION 9: EMERGENCY PROCEDURES ==="
|
||||
echo ""
|
||||
echo "Kill stuck container:"
|
||||
echo " docker-compose kill digiserver-app"
|
||||
echo ""
|
||||
echo "Restore from backup:"
|
||||
echo " docker-compose down"
|
||||
echo " cp /backup/dashboard.db.bak data/instance/dashboard.db"
|
||||
echo " docker-compose up -d"
|
||||
echo ""
|
||||
echo "Rollback to previous version:"
|
||||
echo " git checkout v1.9.0"
|
||||
echo " docker-compose down"
|
||||
echo " docker-compose build"
|
||||
echo " docker-compose up -d"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SECTION: QUICK REFERENCE
|
||||
# ============================================================================
|
||||
|
||||
echo "=== SECTION 10: QUICK REFERENCE ALIASES ==="
|
||||
echo ""
|
||||
echo "Add these to your ~/.bashrc for quick access:"
|
||||
echo ""
|
||||
cat << 'EOF'
|
||||
alias ds-start='docker-compose up -d'
|
||||
alias ds-stop='docker-compose down'
|
||||
alias ds-logs='docker-compose logs -f digiserver-app'
|
||||
alias ds-health='curl -k https://your-domain/api/health'
|
||||
alias ds-status='docker-compose ps'
|
||||
alias ds-bash='docker-compose exec digiserver-app bash'
|
||||
EOF
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# DONE
|
||||
# ============================================================================
|
||||
|
||||
echo "=== END OF REFERENCE ==="
|
||||
echo ""
|
||||
echo "For detailed documentation, see:"
|
||||
echo " - PRODUCTION_DEPLOYMENT_GUIDE.md"
|
||||
echo " - DEPLOYMENT_READINESS_SUMMARY.md"
|
||||
echo " - old_code_documentation/"
|
||||
echo ""
|
||||
@@ -0,0 +1,61 @@
|
||||
#version: '3.8'
|
||||
|
||||
services:
|
||||
digiserver-app:
|
||||
build: .
|
||||
container_name: digiserver-v2
|
||||
# Don't expose directly; use Caddy reverse proxy instead
|
||||
expose:
|
||||
- "5000"
|
||||
volumes:
|
||||
# Code is in the Docker image - no volume mount needed
|
||||
# Only mount persistent data folders:
|
||||
- ./data/instance:/app/instance
|
||||
- ./data/uploads:/app/app/static/uploads
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- digiserver-network
|
||||
|
||||
# Nginx reverse proxy with HTTPS support
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: digiserver-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./data/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./data/nginx-custom-domains.conf:/etc/nginx/conf.d/custom-domains.conf:rw
|
||||
- ./data/nginx-ssl:/etc/nginx/ssl:ro
|
||||
- ./data/nginx-logs:/var/log/nginx
|
||||
- ./data/certbot:/var/www/certbot:ro # For Let's Encrypt ACME challenges
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN:-localhost}
|
||||
- EMAIL=${EMAIL:-admin@localhost}
|
||||
depends_on:
|
||||
digiserver-app:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- digiserver-network
|
||||
|
||||
networks:
|
||||
digiserver-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting DigiServer v2..."
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p /app/instance
|
||||
mkdir -p /app/app/static/uploads
|
||||
|
||||
# Initialize database if it doesn't exist
|
||||
if [ ! -f /app/instance/dashboard.db ]; then
|
||||
echo "Initializing database..."
|
||||
python -c "
|
||||
from app.app import create_app
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models import User
|
||||
|
||||
app = create_app('production')
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Create or update admin user from environment variables
|
||||
import os
|
||||
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
|
||||
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
||||
|
||||
admin = User.query.filter_by(username=admin_username).first()
|
||||
if not admin:
|
||||
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
||||
admin = User(username=admin_username, password=hashed, role='admin')
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
print(f'✅ Admin user created ({admin_username})')
|
||||
else:
|
||||
# Update password if it exists
|
||||
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
||||
admin.password = hashed
|
||||
db.session.commit()
|
||||
print(f'✅ Admin user password updated ({admin_username})')
|
||||
"
|
||||
echo "Database initialized!"
|
||||
fi
|
||||
|
||||
# Start the application
|
||||
echo "Starting Gunicorn..."
|
||||
exec gunicorn \
|
||||
--bind 0.0.0.0:5000 \
|
||||
--workers 4 \
|
||||
--timeout 120 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
"app.app:create_app('production')"
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fix player_user table schema by dropping and recreating it."""
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from app.app import create_app
|
||||
from app.extensions import db
|
||||
from app.models.player_user import PlayerUser
|
||||
|
||||
def main():
|
||||
app = create_app('production')
|
||||
with app.app_context():
|
||||
# Drop the old table
|
||||
print("Dropping player_user table...")
|
||||
db.session.execute(db.text('DROP TABLE IF EXISTS player_user'))
|
||||
db.session.commit()
|
||||
|
||||
# Recreate with new schema
|
||||
print("Creating player_user table with new schema...")
|
||||
PlayerUser.__table__.create(db.engine)
|
||||
|
||||
print("Done! player_user table recreated successfully.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Generate self-signed SSL certificates for Nginx
|
||||
# Usage: ./generate_nginx_certs.sh [domain] [days]
|
||||
|
||||
DOMAIN=${1:-localhost}
|
||||
DAYS=${2:-365}
|
||||
CERT_DIR="./data/nginx-ssl"
|
||||
|
||||
echo "🔐 Generating self-signed SSL certificate for Nginx"
|
||||
echo "Domain: $DOMAIN"
|
||||
echo "Valid for: $DAYS days"
|
||||
echo "Certificate directory: $CERT_DIR"
|
||||
|
||||
# Create directory if it doesnt exist
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Generate private key and certificate
|
||||
openssl req -x509 -nodes -days "$DAYS" \
|
||||
-newkey rsa:2048 \
|
||||
-keyout "$CERT_DIR/key.pem" \
|
||||
-out "$CERT_DIR/cert.pem" \
|
||||
-subj "/CN=$DOMAIN/O=DigiServer/C=US"
|
||||
|
||||
# Set proper permissions
|
||||
chmod 644 "$CERT_DIR/cert.pem"
|
||||
chmod 600 "$CERT_DIR/key.pem"
|
||||
|
||||
echo "✅ Certificates generated successfully!"
|
||||
echo "Certificate: $CERT_DIR/cert.pem"
|
||||
echo "Key: $CERT_DIR/key.pem"
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Install emoji fonts for Raspberry Pi
|
||||
echo "Installing emoji font support for Raspberry Pi..."
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y fonts-noto-color-emoji fonts-noto-emoji
|
||||
|
||||
echo "✅ Emoji fonts installed!"
|
||||
echo "Please restart your browser to see the changes."
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# LibreOffice installation script for DigiServer v2
|
||||
# This script installs LibreOffice for PPTX to image conversion
|
||||
|
||||
set -e
|
||||
|
||||
echo "======================================"
|
||||
echo "LibreOffice Installation Script"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
# Check if already installed
|
||||
if command -v libreoffice &> /dev/null; then
|
||||
VERSION=$(libreoffice --version 2>/dev/null || echo "Unknown")
|
||||
echo "✅ LibreOffice is already installed: $VERSION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📦 Installing LibreOffice..."
|
||||
echo ""
|
||||
|
||||
# Update package list
|
||||
echo "Updating package list..."
|
||||
apt-get update -qq
|
||||
|
||||
# Install LibreOffice
|
||||
echo "Installing LibreOffice (this may take a few minutes)..."
|
||||
apt-get install -y libreoffice libreoffice-impress
|
||||
|
||||
# Verify installation
|
||||
if command -v libreoffice &> /dev/null; then
|
||||
VERSION=$(libreoffice --version 2>/dev/null || echo "Installed")
|
||||
echo ""
|
||||
echo "✅ LibreOffice successfully installed: $VERSION"
|
||||
echo ""
|
||||
echo "You can now upload and convert PowerPoint presentations (PPTX files)."
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo "❌ LibreOffice installation failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/bin/bash
|
||||
# Network Migration Script for DigiServer
|
||||
# Use this when moving the server to a new network with a different IP address
|
||||
# Example: ./migrate_network.sh 10.55.150.160
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Check arguments
|
||||
if [ $# -lt 1 ]; then
|
||||
echo -e "${RED}❌ Usage: ./migrate_network.sh <new_ip_address> [hostname]${NC}"
|
||||
echo ""
|
||||
echo " Example: ./migrate_network.sh 10.55.150.160"
|
||||
echo " Example: ./migrate_network.sh 10.55.150.160 digiserver-secured"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_IP="$1"
|
||||
HOSTNAME="${2:-digiserver}"
|
||||
EMAIL="${EMAIL:-admin@example.com}"
|
||||
PORT="${PORT:-443}"
|
||||
|
||||
# Validate IP format
|
||||
if ! [[ "$NEW_IP" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
echo -e "${RED}❌ Invalid IP address format: $NEW_IP${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ DigiServer Network Migration ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}Migration Settings:${NC}"
|
||||
echo " New IP Address: $NEW_IP"
|
||||
echo " Hostname: $HOSTNAME"
|
||||
echo " Email: $EMAIL"
|
||||
echo " Port: $PORT"
|
||||
echo ""
|
||||
|
||||
# Check if containers are running
|
||||
echo -e "${YELLOW}🔍 [1/4] Checking containers...${NC}"
|
||||
if ! docker compose ps | grep -q "digiserver-app"; then
|
||||
echo -e "${RED}❌ digiserver-app container not running!${NC}"
|
||||
echo "Please start containers with: docker compose up -d"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✅ Containers are running${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 1: Regenerate SSL certificates for new IP
|
||||
echo -e "${YELLOW}🔐 [2/4] Regenerating SSL certificates for new IP...${NC}"
|
||||
echo " Generating self-signed certificate for $NEW_IP..."
|
||||
|
||||
CERT_DIR="./data/nginx-ssl"
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
openssl req -x509 -nodes -days 365 \
|
||||
-newkey rsa:2048 \
|
||||
-keyout "$CERT_DIR/key.pem" \
|
||||
-out "$CERT_DIR/cert.pem" \
|
||||
-subj "/CN=$NEW_IP/O=DigiServer/C=US" >/dev/null 2>&1
|
||||
|
||||
chmod 644 "$CERT_DIR/cert.pem"
|
||||
chmod 600 "$CERT_DIR/key.pem"
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Certificates regenerated for $NEW_IP"
|
||||
echo -e "${GREEN}✅ SSL certificates updated${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 2: Update HTTPS configuration in database
|
||||
echo -e "${YELLOW}🔧 [3/4] Updating HTTPS configuration in database...${NC}"
|
||||
|
||||
docker compose exec -T digiserver-app python << EOF
|
||||
from app.app import create_app
|
||||
from app.models.https_config import HTTPSConfig
|
||||
from app.extensions import db
|
||||
|
||||
app = create_app('production')
|
||||
with app.app_context():
|
||||
# Update or create HTTPS config for the new IP
|
||||
https_config = HTTPSConfig.query.first()
|
||||
|
||||
if https_config:
|
||||
https_config.hostname = '$HOSTNAME'
|
||||
https_config.ip_address = '$NEW_IP'
|
||||
https_config.email = '$EMAIL'
|
||||
https_config.port = $PORT
|
||||
https_config.enabled = True
|
||||
db.session.commit()
|
||||
print(f" ✓ HTTPS configuration updated")
|
||||
print(f" Hostname: {https_config.hostname}")
|
||||
print(f" IP: {https_config.ip_address}")
|
||||
print(f" Port: {https_config.port}")
|
||||
else:
|
||||
print(" ⚠️ No existing HTTPS config found")
|
||||
print(" This will be created on next app startup")
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}✅ Database configuration updated${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 3: Restart containers
|
||||
echo -e "${YELLOW}🔄 [4/4] Restarting containers...${NC}"
|
||||
|
||||
docker compose restart nginx digiserver-app
|
||||
sleep 3
|
||||
|
||||
if ! docker compose ps | grep -q "Up"; then
|
||||
echo -e "${RED}❌ Containers failed to restart!${NC}"
|
||||
docker compose logs | tail -20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Containers restarted successfully${NC}"
|
||||
echo ""
|
||||
|
||||
# Verification
|
||||
echo -e "${YELLOW}🔍 Verifying HTTPS connectivity...${NC}"
|
||||
sleep 2
|
||||
|
||||
if curl -s -k -I https://$NEW_IP 2>/dev/null | grep -q "HTTP"; then
|
||||
echo -e "${GREEN}✅ HTTPS connection verified${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ HTTPS verification pending (containers warming up)${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ✅ Network Migration Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}📍 New Access Points:${NC}"
|
||||
echo " 🔒 https://$NEW_IP"
|
||||
echo " 🔒 https://$HOSTNAME.local (if mDNS enabled)"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}📋 Changes Made:${NC}"
|
||||
echo " ✓ SSL certificates regenerated for $NEW_IP"
|
||||
echo " ✓ Database HTTPS config updated"
|
||||
echo " ✓ Nginx and app containers restarted"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}⏳ Allow 30 seconds for containers to become fully healthy${NC}"
|
||||
echo ""
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add email field to https_config table."""
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from app.app import create_app
|
||||
from app.extensions import db
|
||||
from sqlalchemy import text
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
print("Adding email column to https_config table...")
|
||||
|
||||
try:
|
||||
# Check if column already exists
|
||||
inspector = db.inspect(db.engine)
|
||||
columns = [col['name'] for col in inspector.get_columns('https_config')]
|
||||
|
||||
if 'email' not in columns:
|
||||
# Add the column
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text('ALTER TABLE https_config ADD COLUMN email VARCHAR(255)'))
|
||||
conn.commit()
|
||||
print("✓ Email column added to https_config table!")
|
||||
else:
|
||||
print("✓ Email column already exists in https_config table.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ℹ️ Note: If table doesn't exist, run add_https_config_table.py first")
|
||||
print(f"Error details: {str(e)}")
|
||||
print("\nAlternatively, you can reset the database:")
|
||||
print(" rm instance/digiserver.db")
|
||||
print(" python migrations/add_https_config_table.py")
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Add https_config table for HTTPS configuration management."""
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from app.app import create_app
|
||||
from app.extensions import db
|
||||
from app.models.https_config import HTTPSConfig
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
print("Creating https_config table...")
|
||||
db.create_all()
|
||||
print("✓ https_config table created successfully!")
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Add player_user table for user code mappings."""
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from app.app import create_app
|
||||
from app.extensions import db
|
||||
from app.models.player_user import PlayerUser
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
print("Creating player_user table...")
|
||||
db.create_all()
|
||||
print("✓ player_user table created successfully!")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Migrate player_user table to remove player_id and make user_code unique globally."""
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from app.app import create_app
|
||||
from app.extensions import db
|
||||
from sqlalchemy import text
|
||||
|
||||
app = create_app('production')
|
||||
|
||||
with app.app_context():
|
||||
print("Migrating player_user table...")
|
||||
|
||||
# Drop existing table and recreate with new schema
|
||||
db.session.execute(text('DROP TABLE IF EXISTS player_user'))
|
||||
db.session.commit()
|
||||
|
||||
# Create new table
|
||||
db.create_all()
|
||||
|
||||
print("✓ player_user table migrated successfully!")
|
||||
print(" - Removed player_id foreign key")
|
||||
print(" - Made user_code unique globally")
|
||||
print(" - user_name is now nullable")
|
||||
@@ -0,0 +1,21 @@
|
||||
# Nginx configuration for custom HTTPS domains
|
||||
# This file will be dynamically generated based on HTTPSConfig database entries
|
||||
# Include this in your nginx.conf with: include /etc/nginx/conf.d/custom-domains.conf;
|
||||
|
||||
# Example entry for custom domain:
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# listen [::]:443 ssl http2;
|
||||
# server_name digiserver.example.com;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/custom/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/custom/key.pem;
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://digiserver_app;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
@@ -0,0 +1,129 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 2048M;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml;
|
||||
|
||||
# Upstream to Flask application
|
||||
upstream digiserver_app {
|
||||
server digiserver-app:5000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# HTTP Server - redirect to HTTPS
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
# Allow ACME challenges for Let's Encrypt
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS for non-ACME requests
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS Server (with self-signed cert by default)
|
||||
server {
|
||||
listen 443 ssl http2 default_server;
|
||||
listen [::]:443 ssl http2 default_server;
|
||||
server_name localhost;
|
||||
|
||||
# SSL certificate paths (will be volume-mounted)
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
|
||||
# CORS Headers for API endpoints (allows player device connections)
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
|
||||
add_header 'Access-Control-Max-Age' '3600' always;
|
||||
|
||||
# Handle OPTIONS requests for CORS preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Proxy settings
|
||||
location / {
|
||||
proxy_pass http://digiserver_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Timeouts for large uploads
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
# Buffering
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
# Static files caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://digiserver_app;
|
||||
proxy_cache_valid 200 60d;
|
||||
expires 60d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# Additional server blocks for custom domains can be included here
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Flask Environment
|
||||
FLASK_APP=app.py
|
||||
FLASK_ENV=development
|
||||
|
||||
# Security
|
||||
SECRET_KEY=change-this-to-a-random-secret-key
|
||||
|
||||
# Domain & SSL (for HTTPS with Caddy)
|
||||
DOMAIN=your-domain.com
|
||||
EMAIL=admin@your-domain.com
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite:///instance/dev.db
|
||||
|
||||
# Admin User Credentials (used during initial Docker deployment)
|
||||
# These credentials are set when the database is first created
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-this-secure-password
|
||||
|
||||
# Optional: Sentry for error tracking
|
||||
# SENTRY_DSN=your-sentry-dsn-here
|
||||
@@ -0,0 +1,295 @@
|
||||
# Caddy Dynamic Configuration Management
|
||||
|
||||
## Overview
|
||||
|
||||
The HTTPS configuration system now automatically generates and manages the Caddy configuration in real-time. When an admin updates settings through the admin panel, the Caddyfile is regenerated and reloaded without requiring a full container restart.
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. **Configuration Generation**
|
||||
When admin saves HTTPS settings:
|
||||
1. Settings are saved to database (HTTPSConfig table)
|
||||
2. `CaddyConfigGenerator` creates a new Caddyfile based on settings
|
||||
3. Generated Caddyfile is written to disk
|
||||
|
||||
### 2. **Configuration Reload**
|
||||
After Caddyfile is written:
|
||||
1. Caddy reload API is called via `docker-compose exec`
|
||||
2. Caddy validates and applies new configuration
|
||||
3. No downtime - live configuration update
|
||||
|
||||
### 3. **Fallback Configuration**
|
||||
If HTTPS is disabled:
|
||||
1. System uses default hardcoded configuration
|
||||
2. Supports localhost, internal domain, and IP address
|
||||
3. Catch-all configuration for any other requests
|
||||
|
||||
## Files Involved
|
||||
|
||||
### New Files
|
||||
- **`app/utils/caddy_manager.py`** - CaddyConfigGenerator class with:
|
||||
- `generate_caddyfile()` - Generates Caddyfile content
|
||||
- `write_caddyfile()` - Writes to disk
|
||||
- `reload_caddy()` - Reloads via Docker
|
||||
|
||||
### Updated Files
|
||||
- **`app/blueprints/admin.py`** - HTTPS config route now:
|
||||
- Generates new Caddyfile
|
||||
- Writes to disk
|
||||
- Reloads Caddy automatically
|
||||
- Reports status to user
|
||||
|
||||
## Admin Panel Workflow
|
||||
|
||||
### Step 1: User Fills Form
|
||||
```
|
||||
Admin Panel → HTTPS Configuration
|
||||
- Hostname: digiserver
|
||||
- Domain: digiserver.sibiusb.harting.intra
|
||||
- Email: admin@example.com
|
||||
- IP: 10.76.152.164
|
||||
- Port: 443
|
||||
```
|
||||
|
||||
### Step 2: Admin Saves Configuration
|
||||
- POST /admin/https-config/update
|
||||
- Settings validated and saved to database
|
||||
- Caddyfile generated dynamically
|
||||
- Caddy reloaded with new configuration
|
||||
|
||||
### Step 3: User Sees Confirmation
|
||||
```
|
||||
✅ HTTPS configuration saved successfully!
|
||||
✅ Caddy configuration updated successfully!
|
||||
Server available at https://digiserver.sibiusb.harting.intra
|
||||
```
|
||||
|
||||
### Step 4: Configuration Live
|
||||
- New domain/IP immediately active
|
||||
- No container restart needed
|
||||
- Caddy applying new routes in real-time
|
||||
|
||||
## Generated Caddyfile Structure
|
||||
|
||||
**When HTTPS Enabled:**
|
||||
```caddyfile
|
||||
{
|
||||
email admin@example.com
|
||||
}
|
||||
|
||||
(reverse_proxy_config) {
|
||||
reverse_proxy digiserver-app:5000 { ... }
|
||||
request_body { max_size 2GB }
|
||||
header { ... }
|
||||
log { ... }
|
||||
}
|
||||
|
||||
http://localhost { import reverse_proxy_config }
|
||||
http://digiserver.sibiusb.harting.intra { import reverse_proxy_config }
|
||||
http://10.76.152.164 { import reverse_proxy_config }
|
||||
http://* { import reverse_proxy_config }
|
||||
```
|
||||
|
||||
**When HTTPS Disabled:**
|
||||
```caddyfile
|
||||
{
|
||||
email admin@localhost
|
||||
}
|
||||
|
||||
(reverse_proxy_config) { ... }
|
||||
|
||||
http://localhost { import reverse_proxy_config }
|
||||
http://digiserver.sibiusb.harting.intra { import reverse_proxy_config }
|
||||
http://10.76.152.164 { import reverse_proxy_config }
|
||||
http://* { import reverse_proxy_config }
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### ✅ No Restart Required
|
||||
- Caddyfile changes applied without restarting containers
|
||||
- Caddy reload API handles configuration hot-swap
|
||||
- Zero downtime configuration updates
|
||||
|
||||
### ✅ Dynamic Configuration
|
||||
- Settings in admin panel → Generated Caddyfile
|
||||
- Database is source of truth
|
||||
- Easy to modify in admin UI
|
||||
|
||||
### ✅ Automatic Fallbacks
|
||||
- Catch-all `http://*` handles any host
|
||||
- Always has localhost access
|
||||
- Always has IP address access
|
||||
|
||||
### ✅ User Feedback
|
||||
- Admin sees status of Caddy reload
|
||||
- Error messages if Caddy reload fails
|
||||
- Logging of all changes
|
||||
|
||||
### ✅ Safe Updates
|
||||
- Caddyfile validation before reload
|
||||
- Graceful error handling
|
||||
- Falls back to previous config if reload fails
|
||||
|
||||
## Error Handling
|
||||
|
||||
If Caddy reload fails:
|
||||
1. Database still has updated settings
|
||||
2. Old Caddyfile may still be in use
|
||||
3. User sees warning with status
|
||||
4. Admin can manually restart: `docker-compose restart caddy`
|
||||
|
||||
## Admin Panel Status Messages
|
||||
|
||||
### Success (✅)
|
||||
```
|
||||
✅ HTTPS configuration saved successfully!
|
||||
✅ Caddy configuration updated successfully!
|
||||
Server available at https://domain.local
|
||||
```
|
||||
|
||||
### Partial Success (⚠️)
|
||||
```
|
||||
✅ HTTPS configuration saved successfully!
|
||||
⚠️ Caddyfile updated but reload failed. Please restart containers.
|
||||
Server available at https://domain.local
|
||||
```
|
||||
|
||||
### Configuration Saved, Update Failed (⚠️)
|
||||
```
|
||||
⚠️ Configuration saved but Caddy update failed: [error details]
|
||||
```
|
||||
|
||||
## Testing Configuration
|
||||
|
||||
### Check Caddyfile Content
|
||||
```bash
|
||||
cat /srv/digiserver-v2/Caddyfile
|
||||
```
|
||||
|
||||
### Manually Reload Caddy
|
||||
```bash
|
||||
docker-compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
### Check Caddy Status
|
||||
```bash
|
||||
docker-compose logs caddy --tail=20
|
||||
```
|
||||
|
||||
### Test Access Points
|
||||
```bash
|
||||
# Test all configured domains/IPs
|
||||
curl http://localhost
|
||||
curl http://digiserver.sibiusb.harting.intra
|
||||
curl http://10.76.152.164
|
||||
```
|
||||
|
||||
## Configuration Database
|
||||
|
||||
Settings stored in `https_config` table:
|
||||
```
|
||||
https_enabled: boolean
|
||||
hostname: string
|
||||
domain: string
|
||||
ip_address: string
|
||||
email: string
|
||||
port: integer
|
||||
updated_at: datetime
|
||||
updated_by: string
|
||||
```
|
||||
|
||||
When admin updates form → Database updated → Caddyfile regenerated → Caddy reloaded
|
||||
|
||||
## Workflow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Admin Panel Form │
|
||||
│ (HTTPS Config) │
|
||||
└──────────┬──────────┘
|
||||
│ Submit
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ Validate Input │
|
||||
└──────────┬──────────┘
|
||||
│ Valid
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ Save to Database │
|
||||
│ (HTTPSConfig) │
|
||||
└──────────┬──────────┘
|
||||
│ Saved
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ Generate Caddyfile │
|
||||
│ (CaddyConfigGen) │
|
||||
└──────────┬──────────┘
|
||||
│ Generated
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ Write to Disk │
|
||||
│ (/Caddyfile) │
|
||||
└──────────┬──────────┘
|
||||
│ Written
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ Reload Caddy │
|
||||
│ (Docker exec) │
|
||||
└──────────┬──────────┘
|
||||
│ Reloaded
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ Show Status to │
|
||||
│ Admin (Success) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### CaddyConfigGenerator Class
|
||||
|
||||
**generate_caddyfile(config)**
|
||||
- Takes HTTPSConfig from database
|
||||
- Generates complete Caddyfile content
|
||||
- Uses shared reverse proxy configuration template
|
||||
- Returns full Caddyfile as string
|
||||
|
||||
**write_caddyfile(content, path)**
|
||||
- Writes generated content to disk
|
||||
- Path defaults to /srv/digiserver-v2/Caddyfile
|
||||
- Returns True on success, False on error
|
||||
|
||||
**reload_caddy()**
|
||||
- Runs: `docker-compose exec -T caddy caddy reload`
|
||||
- Validates config and applies live
|
||||
- Returns True on success, False on error
|
||||
|
||||
## Advantages Over Manual Configuration
|
||||
|
||||
| Manual | Dynamic |
|
||||
|--------|---------|
|
||||
| Edit Caddyfile manually | Change via admin panel |
|
||||
| Restart container | No restart needed |
|
||||
| Risk of syntax errors | Validated generation |
|
||||
| No audit trail | Logged with username |
|
||||
| Each change is manual | One-time setup |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Configuration history/backup
|
||||
- Rollback to previous config
|
||||
- Health check after reload
|
||||
- Automatic backup before update
|
||||
- Configuration templates
|
||||
- Multi-domain support
|
||||
|
||||
## Support
|
||||
|
||||
For issues:
|
||||
1. Check admin panel messages for Caddy reload status
|
||||
2. Review logs: `docker-compose logs caddy`
|
||||
3. Check Caddyfile: `cat /srv/digiserver-v2/Caddyfile`
|
||||
4. Manual reload: `docker-compose exec caddy caddy reload --config /etc/caddy/Caddyfile`
|
||||
5. Full restart: `docker-compose restart caddy`
|
||||
@@ -0,0 +1,75 @@
|
||||
# Data Folder Deployment Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The `./data` folder is the **persistent data storage** for the DigiServer deployment. It is **NOT committed to the repository** but contains all necessary files copied from the repo during deployment.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
data/
|
||||
├── app/ # Complete application code (copied from ./app)
|
||||
├── Caddyfile # Reverse proxy configuration (copied from root)
|
||||
├── instance/ # Flask instance folder (database, configs)
|
||||
├── uploads/ # User file uploads
|
||||
├── caddy-data/ # Caddy SSL certificates and cache
|
||||
└── caddy-config/ # Caddy configuration data
|
||||
```
|
||||
|
||||
## Deployment Process
|
||||
|
||||
### Step 1: Initialize Data Folder
|
||||
|
||||
Run this script to copy all necessary files from the repository to `./data`:
|
||||
|
||||
```bash
|
||||
./init-data.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Create the `./data` directory structure
|
||||
- Copy `./app` folder to `./data/app`
|
||||
- Copy `Caddyfile` to `./data/Caddyfile`
|
||||
- Set proper permissions for all files and folders
|
||||
|
||||
### Step 2: Start Docker Containers
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### Step 3: Run Migrations (First Time Only)
|
||||
|
||||
```bash
|
||||
sudo bash deploy.sh
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **./data is NOT in git**: The `./data` folder is listed in `.gitignore` and will not be committed
|
||||
- **All persistent data here**: Database files, uploads, certificates, and configurations are stored in `./data`
|
||||
- **Easy backups**: To backup the entire deployment, backup the `./data` folder
|
||||
- **Easy troubleshooting**: Check the `./data` folder to verify all required files are present
|
||||
- **Updates**: When you pull new changes, run `./init-data.sh` to update app files in `./data`
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
✓ All volumes in docker-compose.yml point to `./data`
|
||||
✓ `./data` folder contains: app/, Caddyfile, instance/, uploads/, caddy-data/, caddy-config/
|
||||
✓ Files are copied from repository to `./data` via init-data.sh
|
||||
✓ Permissions are correctly set for Docker container user
|
||||
|
||||
## Verification
|
||||
|
||||
Before starting:
|
||||
```bash
|
||||
ls -la data/
|
||||
# Should show: app/, Caddyfile, instance/, uploads/, caddy-data/, caddy-config/
|
||||
```
|
||||
|
||||
After deployment check data folder for:
|
||||
```bash
|
||||
data/instance/*.db # Database files
|
||||
data/uploads/ # User uploads
|
||||
data/caddy-data/*.pem # SSL certificates
|
||||
```
|
||||
@@ -0,0 +1,201 @@
|
||||
# Dockerfile vs init-data.sh Analysis
|
||||
|
||||
**Date:** January 17, 2026
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Current Workflow
|
||||
```
|
||||
1. Run init-data.sh (on host)
|
||||
↓
|
||||
2. Copies app code → data/app/
|
||||
3. Docker build creates image
|
||||
4. Docker run mounts ./data:/app
|
||||
5. Container runs with host's data/ folder
|
||||
```
|
||||
|
||||
### Current Docker Setup
|
||||
- **Dockerfile**: Copies code from build context to `/app` inside image
|
||||
- **docker-compose**: Mounts `./data:/app` **OVERRIDING** the Dockerfile copy
|
||||
- **Result**: Code in image is replaced by volume mount to host's `./data` folder
|
||||
|
||||
---
|
||||
|
||||
## Problem with Current Approach
|
||||
|
||||
1. **Code Duplication**
|
||||
- Code exists in: Host `./app/` folder
|
||||
- Code copied to: Host `./data/app/` folder
|
||||
- Code in Docker image: Ignored/overridden
|
||||
|
||||
2. **Extra Deployment Step**
|
||||
- Must run `init-data.sh` before deployment
|
||||
- Manual file copying required
|
||||
- Room for sync errors
|
||||
|
||||
3. **No Dockerfile Optimization**
|
||||
- Dockerfile copies code but it's never used
|
||||
- Volume mount replaces everything
|
||||
- Wastes build time and image space
|
||||
|
||||
---
|
||||
|
||||
## Proposed Solution: Two Options
|
||||
|
||||
### **Option 1: Use Dockerfile Copy (Recommended)** ✅
|
||||
|
||||
**Change Dockerfile:**
|
||||
```dockerfile
|
||||
# Copy everything to /app inside image
|
||||
COPY . /app/
|
||||
|
||||
# No need for volume mount - image contains all code
|
||||
```
|
||||
|
||||
**Change docker-compose.yml:**
|
||||
```yaml
|
||||
volumes:
|
||||
# REMOVE the ./data:/app volume mount
|
||||
# Keep only data-specific mounts:
|
||||
- ./data/instance:/app/instance # Database
|
||||
- ./data/uploads:/app/app/static/uploads # User uploads
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Single source of truth (Dockerfile)
|
||||
- ✅ Code is immutable in image
|
||||
- ✅ No init-data.sh needed
|
||||
- ✅ Faster deployment (no file copying)
|
||||
- ✅ Cleaner architecture
|
||||
- ✅ Can upgrade code by rebuilding image
|
||||
|
||||
**Drawbacks:**
|
||||
- Code changes require docker-compose rebuild
|
||||
- Can't edit code in container (which is good for production)
|
||||
|
||||
---
|
||||
|
||||
### **Option 2: Keep Current (With Improvements)**
|
||||
|
||||
**Keep:**
|
||||
- init-data.sh for copying code to data/
|
||||
- Volume mount at ./data:/app
|
||||
|
||||
**Improve:**
|
||||
- Add validation that init-data.sh ran successfully
|
||||
- Check file sync status before starting app
|
||||
- Add automated sync on container restart
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Dev-friendly (can edit code, restart container)
|
||||
- ✅ Faster iteration during development
|
||||
|
||||
**Drawbacks:**
|
||||
- ❌ Production anti-pattern (code changes without rebuild)
|
||||
- ❌ Extra deployment complexity
|
||||
- ❌ Manual init-data.sh step required
|
||||
|
||||
---
|
||||
|
||||
## Current Production Setup Evaluation
|
||||
|
||||
**Current System:** Option 2 (with volume mount override)
|
||||
|
||||
### Why This Setup Exists
|
||||
|
||||
The current architecture with `./data:/app` volume mount suggests:
|
||||
1. **Development-focused** - Allows code editing and hot-reload
|
||||
2. **Host-based persistence** - All data on host machine
|
||||
3. **Easy backup** - Just backup the `./data/` folder
|
||||
|
||||
### Is This Actually Used?
|
||||
|
||||
- ✅ Code updates via `git pull` in `/app/` folder
|
||||
- ✅ Then `cp -r app/* data/app/` copies to running container
|
||||
- ✅ Allows live code updates without container rebuild
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
### For Production
|
||||
**Use Option 1 (Dockerfile-based):**
|
||||
- Build immutable images
|
||||
- No init-data.sh needed
|
||||
- Cleaner deployment pipeline
|
||||
- Better for CI/CD
|
||||
|
||||
### For Development
|
||||
**Keep Option 2 (current approach):**
|
||||
- Code editing and hot-reload
|
||||
- Faster iteration
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps for Option 1
|
||||
|
||||
### 1. **Update Dockerfile**
|
||||
```dockerfile
|
||||
# Instead of: COPY . .
|
||||
# Change docker-compose volume mount pattern
|
||||
```
|
||||
|
||||
### 2. **Update docker-compose.yml**
|
||||
```yaml
|
||||
volumes:
|
||||
# Remove: ./data:/app
|
||||
# Keep only:
|
||||
- ./data/instance:/app/instance
|
||||
- ./data/uploads:/app/app/static/uploads
|
||||
```
|
||||
|
||||
### 3. **Update deploy.sh**
|
||||
```bash
|
||||
# Remove: bash init-data.sh
|
||||
# Just build and run:
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 4. **Add Migration Path**
|
||||
```bash
|
||||
# For existing deployments:
|
||||
# Copy any instance/database data from data/instance to new location
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Persistence Strategy (Post-Migration)
|
||||
|
||||
```
|
||||
Current: After Option 1:
|
||||
./data/app/ (code) → /app/ (in image)
|
||||
./data/instance/ (db) → ./data/instance/ (volume mount)
|
||||
./data/uploads/ (files) → ./data/uploads/ (volume mount)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Option 1 (Dockerfile-only)
|
||||
- **Risk Level:** LOW ✅
|
||||
- **Data Loss Risk:** NONE (instance & uploads still mounted)
|
||||
- **Rollback:** Can use old image tag
|
||||
|
||||
### Option 2 (Current)
|
||||
- **Risk Level:** MEDIUM
|
||||
- **Data Loss Risk:** Manual copying errors
|
||||
- **Rollback:** Manual file restore
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Recommendation: Option 1 (Dockerfile-based)** for production deployment
|
||||
- Simpler architecture
|
||||
- Better practices
|
||||
- Faster deployment
|
||||
- Cleaner code management
|
||||
|
||||
Would you like to implement this change?
|
||||
@@ -0,0 +1,272 @@
|
||||
# DigiServer Deployment Commands
|
||||
|
||||
This document contains all necessary `docker exec` commands to deploy and configure DigiServer on a new PC with the same settings as the production system.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
# Ensure you're in the project directory
|
||||
cd /path/to/digiserver-v2
|
||||
|
||||
# Start the containers
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 1. Database Initialization and Migrations
|
||||
|
||||
### Run all database migrations in sequence:
|
||||
|
||||
```bash
|
||||
# Create https_config table
|
||||
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
|
||||
|
||||
# Create player_user table
|
||||
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
|
||||
|
||||
# Add email to https_config table
|
||||
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
|
||||
|
||||
# Migrate player_user global settings
|
||||
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
|
||||
```
|
||||
|
||||
**Note:** The `-T` flag prevents Docker from allocating a pseudo-terminal, which is useful for automated deployments.
|
||||
|
||||
## 2. HTTPS Configuration via CLI
|
||||
|
||||
### Check HTTPS Configuration Status:
|
||||
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python /app/https_manager.py status
|
||||
```
|
||||
|
||||
### Enable HTTPS with Production Settings:
|
||||
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
|
||||
digiserver \
|
||||
digiserver.sibiusb.harting.intra \
|
||||
admin@example.com \
|
||||
10.76.152.164 \
|
||||
443
|
||||
```
|
||||
|
||||
### Show Detailed Configuration:
|
||||
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python /app/https_manager.py show
|
||||
```
|
||||
|
||||
## 3. Admin User Setup
|
||||
|
||||
### Create/Reset Admin User (if needed):
|
||||
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python -c "
|
||||
from app.app import create_app
|
||||
from app.models.user import User
|
||||
from app.extensions import db
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
# Check if admin exists
|
||||
admin = User.query.filter_by(username='admin').first()
|
||||
if admin:
|
||||
print('✅ Admin user already exists')
|
||||
else:
|
||||
# Create new admin user
|
||||
admin = User(username='admin', email='admin@example.com')
|
||||
admin.set_password('admin123') # Change this password!
|
||||
admin.is_admin = True
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
print('✅ Admin user created with username: admin')
|
||||
"
|
||||
```
|
||||
|
||||
## 4. Database Verification
|
||||
|
||||
### Check Database Tables:
|
||||
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python -c "
|
||||
from app.app import create_app
|
||||
from app.extensions import db
|
||||
from sqlalchemy import inspect
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
inspector = inspect(db.engine)
|
||||
tables = inspector.get_table_names()
|
||||
print('📊 Database Tables:')
|
||||
for table in sorted(tables):
|
||||
print(f' ✓ {table}')
|
||||
print(f'\\n✅ Total tables: {len(tables)}')
|
||||
"
|
||||
```
|
||||
|
||||
### Check HTTPS Configuration in Database:
|
||||
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python -c "
|
||||
from app.app import create_app
|
||||
from app.models.https_config import HTTPSConfig
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
config = HTTPSConfig.get_config()
|
||||
if config:
|
||||
print('✅ HTTPS Configuration Found:')
|
||||
print(f' Status: {\"ENABLED\" if config.https_enabled else \"DISABLED\"}')
|
||||
print(f' Hostname: {config.hostname}')
|
||||
print(f' Domain: {config.domain}')
|
||||
print(f' IP Address: {config.ip_address}')
|
||||
print(f' Port: {config.port}')
|
||||
else:
|
||||
print('⚠️ No HTTPS configuration found')
|
||||
"
|
||||
```
|
||||
|
||||
## 5. Health Checks
|
||||
|
||||
### Test Caddy Configuration:
|
||||
|
||||
```bash
|
||||
docker-compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
### Test Flask Application Health:
|
||||
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python -c "
|
||||
import urllib.request
|
||||
try:
|
||||
response = urllib.request.urlopen('http://localhost:5000/health', timeout=5)
|
||||
print('✅ Application is responding')
|
||||
print(f' Status: {response.status}')
|
||||
except Exception as e:
|
||||
print(f'❌ Application health check failed: {e}')
|
||||
"
|
||||
```
|
||||
|
||||
### Check Docker Container Logs:
|
||||
|
||||
```bash
|
||||
# Flask app logs
|
||||
docker-compose logs digiserver-app | tail -50
|
||||
|
||||
# Caddy logs
|
||||
docker-compose logs caddy | tail -50
|
||||
```
|
||||
|
||||
## 6. Complete Deployment Script
|
||||
|
||||
Create a file called `deploy.sh` to run all steps automatically:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 DigiServer Deployment Script"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
|
||||
# Change to project directory
|
||||
cd /path/to/digiserver-v2
|
||||
|
||||
# Step 1: Start containers
|
||||
echo "📦 Starting containers..."
|
||||
docker-compose up -d
|
||||
sleep 5
|
||||
|
||||
# Step 2: Run migrations
|
||||
echo "📊 Running database migrations..."
|
||||
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
|
||||
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
|
||||
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
|
||||
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
|
||||
|
||||
# Step 3: Configure HTTPS
|
||||
echo "🔒 Configuring HTTPS..."
|
||||
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
|
||||
digiserver \
|
||||
digiserver.sibiusb.harting.intra \
|
||||
admin@example.com \
|
||||
10.76.152.164 \
|
||||
443
|
||||
|
||||
# Step 4: Verify setup
|
||||
echo "✅ Verifying setup..."
|
||||
docker-compose exec -T digiserver-app python /app/https_manager.py status
|
||||
|
||||
echo ""
|
||||
echo "🎉 Deployment Complete!"
|
||||
echo "=================================="
|
||||
echo "Access your application at:"
|
||||
echo " - https://digiserver"
|
||||
echo " - https://10.76.152.164"
|
||||
echo " - https://digiserver.sibiusb.harting.intra"
|
||||
echo ""
|
||||
echo "Login with:"
|
||||
echo " Username: admin"
|
||||
echo " Password: (check your password settings)"
|
||||
```
|
||||
|
||||
Make it executable:
|
||||
```bash
|
||||
chmod +x deploy.sh
|
||||
```
|
||||
|
||||
Run it:
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
### Restart Services:
|
||||
|
||||
```bash
|
||||
# Restart all containers
|
||||
docker-compose restart
|
||||
|
||||
# Restart just the app
|
||||
docker-compose restart digiserver-app
|
||||
|
||||
# Restart just Caddy
|
||||
docker-compose restart caddy
|
||||
```
|
||||
|
||||
### View Caddy Configuration:
|
||||
|
||||
```bash
|
||||
docker-compose exec -T caddy cat /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
### Test HTTPS Endpoints:
|
||||
|
||||
```bash
|
||||
# Test from host machine (if accessible)
|
||||
curl -k https://digiserver.sibiusb.harting.intra
|
||||
|
||||
# Test from within containers
|
||||
docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ | head -20
|
||||
```
|
||||
|
||||
### Clear Caddy Cache (if certificate issues occur):
|
||||
|
||||
```bash
|
||||
docker volume rm digiserver-v2_caddy-data
|
||||
docker volume rm digiserver-v2_caddy-config
|
||||
docker-compose restart caddy
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Always use `-T` flag with `docker-compose exec` in automated scripts to prevent TTY issues
|
||||
- Change default passwords (`admin123`) in production environments
|
||||
- Adjust email address in HTTPS configuration as needed
|
||||
- For different network setups, modify the IP address and domain in the enable HTTPS command
|
||||
- Keep database backups before running migrations
|
||||
- Test all three access points after deployment
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# 📚 DigiServer Deployment Documentation Index
|
||||
|
||||
Complete documentation for deploying and maintaining DigiServer. Choose your path below:
|
||||
|
||||
---
|
||||
|
||||
## 🚀 I Want to Deploy Now!
|
||||
|
||||
### Quick Start (2 minutes)
|
||||
```bash
|
||||
cd /path/to/digiserver-v2
|
||||
./deploy.sh
|
||||
```
|
||||
→ See [DEPLOYMENT_README.md](DEPLOYMENT_README.md)
|
||||
|
||||
### Or Step-by-Step Setup
|
||||
```bash
|
||||
./setup_https.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation Files
|
||||
|
||||
### 1. **[DEPLOYMENT_README.md](DEPLOYMENT_README.md)** ⭐ START HERE
|
||||
- **Size**: 9.4 KB
|
||||
- **Purpose**: Complete deployment guide for beginners
|
||||
- **Contains**:
|
||||
- Quick start instructions
|
||||
- Prerequisites checklist
|
||||
- 3 deployment methods (auto, semi-auto, manual)
|
||||
- Verification procedures
|
||||
- First access setup
|
||||
- Troubleshooting guide
|
||||
- **Read time**: 15-20 minutes
|
||||
|
||||
### 2. **[DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)** ⭐ REFERENCE GUIDE
|
||||
- **Size**: 7.6 KB
|
||||
- **Purpose**: Quick reference for all docker exec commands
|
||||
- **Contains**:
|
||||
- Database migrations
|
||||
- HTTPS configuration
|
||||
- User management
|
||||
- Database inspection
|
||||
- Health checks
|
||||
- Maintenance commands
|
||||
- Troubleshooting commands
|
||||
- **Use when**: You need a specific command
|
||||
- **Read time**: 5-10 minutes (or search for what you need)
|
||||
|
||||
### 3. **[DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md)**
|
||||
- **Size**: 6.8 KB
|
||||
- **Purpose**: Detailed deployment command explanations
|
||||
- **Contains**:
|
||||
- Individual command explanations
|
||||
- Complete deployment script template
|
||||
- Health check procedures
|
||||
- Verification steps
|
||||
- Advanced troubleshooting
|
||||
- **Read time**: 20-30 minutes
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Executable Scripts
|
||||
|
||||
### 1. **[deploy.sh](deploy.sh)** - Fully Automated
|
||||
- **Size**: 6.7 KB
|
||||
- **Purpose**: One-command deployment
|
||||
- **Does**:
|
||||
1. Starts Docker containers
|
||||
2. Runs all migrations
|
||||
3. Configures HTTPS
|
||||
4. Verifies setup
|
||||
5. Shows access URLs
|
||||
- **Usage**:
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
- **With custom settings**:
|
||||
```bash
|
||||
HOSTNAME=server1 DOMAIN=server1.internal ./deploy.sh
|
||||
```
|
||||
|
||||
### 2. **[setup_https.sh](setup_https.sh)** - Semi-Automated
|
||||
- **Size**: 5.9 KB
|
||||
- **Purpose**: Setup script that works in or outside Docker
|
||||
- **Does**:
|
||||
- Detects environment (Docker container or host)
|
||||
- Runs migrations
|
||||
- Configures HTTPS
|
||||
- Shows status
|
||||
- **Usage**:
|
||||
```bash
|
||||
./setup_https.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Navigation by Task
|
||||
|
||||
### "I need to deploy on a new PC"
|
||||
1. Read: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#prerequisites)
|
||||
2. Run: `./deploy.sh`
|
||||
3. Access: https://digiserver.sibiusb.harting.intra
|
||||
|
||||
### "I need a specific docker exec command"
|
||||
→ Search [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)
|
||||
|
||||
### "I want to understand what's being deployed"
|
||||
→ Read [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md#prerequisites)
|
||||
|
||||
### "Something went wrong, help!"
|
||||
→ See [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-troubleshooting) or [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-troubleshooting)
|
||||
|
||||
### "I need to configure custom settings"
|
||||
→ Read [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-environment-variables)
|
||||
|
||||
### "I want to manage HTTPS"
|
||||
→ See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-https-configuration-management)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Deployment Checklist
|
||||
|
||||
- [ ] Docker and Docker Compose installed
|
||||
- [ ] Project files copied to new PC
|
||||
- [ ] Run `./deploy.sh` or `./setup_https.sh`
|
||||
- [ ] Verify with `docker-compose ps`
|
||||
- [ ] Access https://digiserver.sibiusb.harting.intra
|
||||
- [ ] Log in with admin/admin123
|
||||
- [ ] Change default password
|
||||
- [ ] Configure players and content
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Configuration Options
|
||||
|
||||
### Default Settings
|
||||
```
|
||||
Hostname: digiserver
|
||||
Domain: digiserver.sibiusb.harting.intra
|
||||
IP Address: 10.76.152.164
|
||||
Port: 443
|
||||
Email: admin@example.com
|
||||
Username: admin
|
||||
Password: admin123
|
||||
```
|
||||
|
||||
### Customize During Deployment
|
||||
```bash
|
||||
HOSTNAME=myserver \
|
||||
DOMAIN=myserver.internal \
|
||||
IP_ADDRESS=192.168.1.100 \
|
||||
EMAIL=admin@myserver.com \
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Common Tasks
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| **Start containers** | `docker-compose up -d` |
|
||||
| **Stop containers** | `docker-compose stop` |
|
||||
| **View logs** | `docker-compose logs -f` |
|
||||
| **Check HTTPS status** | `docker-compose exec -T digiserver-app python /app/https_manager.py status` |
|
||||
| **Reset password** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#reset-admin-password) |
|
||||
| **View all tables** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#list-all-tables) |
|
||||
| **Create admin user** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#create-admin-user) |
|
||||
|
||||
---
|
||||
|
||||
## 📊 File Structure
|
||||
|
||||
```
|
||||
digiserver-v2/
|
||||
├── DEPLOYMENT_README.md ..................... Main deployment guide
|
||||
├── DOCKER_EXEC_COMMANDS.md ................. Quick reference (BEST FOR COMMANDS)
|
||||
├── DEPLOYMENT_COMMANDS.md .................. Detailed explanations
|
||||
├── deploy.sh ............................. Fully automated deployment
|
||||
├── setup_https.sh ......................... Semi-automated setup
|
||||
├── docker-compose.yml ..................... Docker services config
|
||||
├── Caddyfile .............................. Reverse proxy config
|
||||
├── requirements.txt ....................... Python dependencies
|
||||
│
|
||||
├── app/
|
||||
│ ├── app.py ............................ Flask application
|
||||
│ ├── models/ ........................... Database models
|
||||
│ │ ├── https_config.py
|
||||
│ │ ├── user.py
|
||||
│ │ └── ...
|
||||
│ └── ...
|
||||
│
|
||||
├── migrations/
|
||||
│ ├── add_https_config_table.py
|
||||
│ ├── add_player_user_table.py
|
||||
│ ├── add_email_to_https_config.py
|
||||
│ └── migrate_player_user_global.py
|
||||
│
|
||||
└── old_code_documentation/
|
||||
├── HTTPS_CONFIGURATION.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Methods Comparison
|
||||
|
||||
| Method | Time | Effort | Best For |
|
||||
|--------|------|--------|----------|
|
||||
| `./deploy.sh` | 2-3 min | Click & wait | First-time setup, automation |
|
||||
| `./setup_https.sh` | 3-5 min | Manual review | Learning, step debugging |
|
||||
| Manual commands | 10-15 min | Full control | Advanced users, scripting |
|
||||
|
||||
---
|
||||
|
||||
## ✨ What Gets Deployed
|
||||
|
||||
✅ Flask web application with admin dashboard
|
||||
✅ HTTPS with self-signed certificates
|
||||
✅ Caddy reverse proxy for routing
|
||||
✅ SQLite database with all tables
|
||||
✅ User management system
|
||||
✅ HTTPS configuration management
|
||||
✅ Player and content management
|
||||
✅ Group and playlist management
|
||||
✅ Admin audit trail
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Learning Path
|
||||
|
||||
1. **Total Beginner?**
|
||||
- Start: [DEPLOYMENT_README.md](DEPLOYMENT_README.md)
|
||||
- Run: `./deploy.sh`
|
||||
- Learn: Browse [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md) for available commands
|
||||
|
||||
2. **Want to Understand Everything?**
|
||||
- Read: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-deployment-methods) (all 3 methods)
|
||||
- Study: [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md)
|
||||
- Reference: [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)
|
||||
|
||||
3. **Need to Troubleshoot?**
|
||||
- Check: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-troubleshooting)
|
||||
- Or: [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 💡 Pro Tips
|
||||
|
||||
1. **Use `-T` flag** in docker-compose exec for scripts (prevents TTY issues)
|
||||
2. **Keep backups** before major changes
|
||||
3. **Check logs often**: `docker-compose logs -f`
|
||||
4. **Use environment variables** for custom deployments
|
||||
5. **Verify after deployment** using health check commands
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- **HTTPS Setup**: `old_code_documentation/HTTPS_CONFIGURATION.md`
|
||||
- **Admin Features**: Check admin panel after login
|
||||
- **API Documentation**: See `old_code_documentation/PLAYER_EDIT_MEDIA_API.md`
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- Logs: `docker-compose logs digiserver-app`
|
||||
- Health Check: See [DOCKER_EXEC_COMMANDS.md#-health-checks](DOCKER_EXEC_COMMANDS.md#-health-checks)
|
||||
- Troubleshooting: See [DEPLOYMENT_README.md#-troubleshooting](DEPLOYMENT_README.md#-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
**Ready? Start with:** `./deploy.sh` 🚀
|
||||
|
||||
Or read [DEPLOYMENT_README.md](DEPLOYMENT_README.md) for the full guide.
|
||||
@@ -0,0 +1,433 @@
|
||||
# DigiServer Deployment Guide
|
||||
|
||||
Complete guide for deploying DigiServer on a new PC with automatic or manual configuration.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
1. [Quick Start](#quick-start)
|
||||
2. [Prerequisites](#prerequisites)
|
||||
3. [Deployment Methods](#deployment-methods)
|
||||
4. [Verification](#verification)
|
||||
5. [Documentation Files](#documentation-files)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
The fastest way to deploy DigiServer on a new PC:
|
||||
|
||||
```bash
|
||||
# 1. Clone or copy the project to your new PC
|
||||
cd /path/to/digiserver-v2
|
||||
|
||||
# 2. Run the automated deployment script
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
That's it! The script will:
|
||||
- ✅ Start all Docker containers
|
||||
- ✅ Run all database migrations
|
||||
- ✅ Configure HTTPS with self-signed certificates
|
||||
- ✅ Verify the setup
|
||||
- ✅ Display access URLs
|
||||
|
||||
---
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
Before deploying, ensure you have:
|
||||
|
||||
### 1. Docker & Docker Compose
|
||||
```bash
|
||||
# Check Docker installation
|
||||
docker --version
|
||||
|
||||
# Check Docker Compose installation
|
||||
docker-compose --version
|
||||
```
|
||||
|
||||
If not installed, follow the official guides:
|
||||
- [Docker Installation](https://docs.docker.com/install/)
|
||||
- [Docker Compose Installation](https://docs.docker.com/compose/install/)
|
||||
|
||||
### 2. Project Files
|
||||
```bash
|
||||
# You should have these files in the project directory:
|
||||
ls -la
|
||||
# Caddyfile - Reverse proxy configuration
|
||||
# docker-compose.yml - Docker services definition
|
||||
# setup_https.sh - Manual setup script
|
||||
# deploy.sh - Automated deployment script
|
||||
# requirements.txt - Python dependencies
|
||||
```
|
||||
|
||||
### 3. Sufficient Disk Space
|
||||
- ~2GB for Docker images and volumes
|
||||
- Additional space for your content/uploads
|
||||
|
||||
### 4. Network Access
|
||||
- Ports 80, 443 available (or configure in docker-compose.yml)
|
||||
- Port 2019 for Caddy admin API (internal only)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Deployment Methods
|
||||
|
||||
### Method 1: Fully Automated (Recommended)
|
||||
|
||||
```bash
|
||||
cd /path/to/digiserver-v2
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Starts Docker containers
|
||||
2. Runs all migrations
|
||||
3. Configures HTTPS
|
||||
4. Verifies setup
|
||||
5. Shows access URLs
|
||||
|
||||
**Configuration variables** (can be customized):
|
||||
```bash
|
||||
# Use environment variables to customize
|
||||
HOSTNAME=digiserver \
|
||||
DOMAIN=digiserver.sibiusb.harting.intra \
|
||||
IP_ADDRESS=10.76.152.164 \
|
||||
EMAIL=admin@example.com \
|
||||
PORT=443 \
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Method 2: Semi-Automated Setup
|
||||
|
||||
```bash
|
||||
cd /path/to/digiserver-v2
|
||||
./setup_https.sh
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Starts containers (if needed)
|
||||
2. Runs all migrations
|
||||
3. Configures HTTPS with production settings
|
||||
4. Shows status
|
||||
|
||||
---
|
||||
|
||||
### Method 3: Manual Step-by-Step
|
||||
|
||||
#### Step 1: Start Containers
|
||||
```bash
|
||||
cd /path/to/digiserver-v2
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Wait for containers to be ready (check with `docker-compose ps`).
|
||||
|
||||
#### Step 2: Run Migrations
|
||||
```bash
|
||||
# Migration 1: HTTPS Config
|
||||
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
|
||||
|
||||
# Migration 2: Player User
|
||||
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
|
||||
|
||||
# Migration 3: Email
|
||||
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
|
||||
|
||||
# Migration 4: Player User Global
|
||||
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
|
||||
```
|
||||
|
||||
#### Step 3: Configure HTTPS
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
|
||||
digiserver \
|
||||
digiserver.sibiusb.harting.intra \
|
||||
admin@example.com \
|
||||
10.76.152.164 \
|
||||
443
|
||||
```
|
||||
|
||||
#### Step 4: Verify Status
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python /app/https_manager.py status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Verification
|
||||
|
||||
### Check Container Status
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
NAME SERVICE STATUS PORTS
|
||||
digiserver-v2 digiserver-app Up (healthy) 5000/tcp
|
||||
digiserver-caddy caddy Up 80, 443, 2019/tcp
|
||||
```
|
||||
|
||||
### Test HTTPS Access
|
||||
```bash
|
||||
# From the same network (if DNS configured)
|
||||
curl -k https://digiserver.sibiusb.harting.intra
|
||||
|
||||
# Or from container
|
||||
docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ | head -10
|
||||
```
|
||||
|
||||
### Expected Response
|
||||
Should show HTML login page with "DigiServer" in the title.
|
||||
|
||||
### Check Database
|
||||
```bash
|
||||
docker-compose exec -T digiserver-app python -c "
|
||||
from app.app import create_app
|
||||
from sqlalchemy import inspect
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
inspector = inspect(app.extensions.db.engine)
|
||||
tables = inspector.get_table_names()
|
||||
print('Database tables:', len(tables))
|
||||
for t in sorted(tables):
|
||||
print(f' ✓ {t}')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation Files
|
||||
|
||||
### 1. `DOCKER_EXEC_COMMANDS.md` ⭐ **START HERE**
|
||||
Quick reference for all docker exec commands
|
||||
- Database operations
|
||||
- User management
|
||||
- HTTPS configuration
|
||||
- Health checks
|
||||
- Maintenance tasks
|
||||
|
||||
### 2. `DEPLOYMENT_COMMANDS.md`
|
||||
Comprehensive deployment guide
|
||||
- Prerequisites
|
||||
- Each deployment step explained
|
||||
- Complete deployment script template
|
||||
- Troubleshooting section
|
||||
|
||||
### 3. `deploy.sh`
|
||||
Automated deployment script (executable)
|
||||
- Runs all steps automatically
|
||||
- Shows progress with colors
|
||||
- Configurable via environment variables
|
||||
|
||||
### 4. `setup_https.sh`
|
||||
Semi-automated setup script (executable)
|
||||
- Detects if running in Docker or on host
|
||||
- Manual configuration option
|
||||
- Detailed output
|
||||
|
||||
### 5. `Caddyfile`
|
||||
Reverse proxy configuration
|
||||
- HTTPS certificate management
|
||||
- Domain routing
|
||||
- Security headers
|
||||
|
||||
### 6. `docker-compose.yml`
|
||||
Docker services definition
|
||||
- Flask application
|
||||
- Caddy reverse proxy
|
||||
- Volumes and networks
|
||||
|
||||
---
|
||||
|
||||
## 🔐 First Access
|
||||
|
||||
After deployment:
|
||||
|
||||
1. **Access the application**
|
||||
- https://digiserver.sibiusb.harting.intra
|
||||
- https://10.76.152.164
|
||||
- https://digiserver
|
||||
|
||||
2. **Log in with default credentials**
|
||||
```
|
||||
Username: admin
|
||||
Password: admin123
|
||||
```
|
||||
|
||||
3. **⚠️ IMPORTANT: Change the password immediately**
|
||||
- Click on admin user settings
|
||||
- Change default password to a strong password
|
||||
|
||||
4. **Configure your system**
|
||||
- Set up players
|
||||
- Upload content
|
||||
- Create groups
|
||||
- Configure playlists
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
|
||||
### Containers Won't Start
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose logs
|
||||
|
||||
# Try rebuilding
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### Migration Fails
|
||||
```bash
|
||||
# Check database connection
|
||||
docker-compose exec -T digiserver-app python -c "
|
||||
from app.app import create_app
|
||||
app = create_app()
|
||||
print('Database OK')
|
||||
"
|
||||
|
||||
# Check if tables already exist
|
||||
docker-compose exec -T digiserver-app python -c "
|
||||
from app.app import create_app
|
||||
from sqlalchemy import inspect
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
inspector = inspect(app.extensions.db.engine)
|
||||
print('Existing tables:', inspector.get_table_names())
|
||||
"
|
||||
```
|
||||
|
||||
### HTTPS Certificate Issues
|
||||
```bash
|
||||
# Clear Caddy certificate cache
|
||||
docker volume rm digiserver-v2_caddy-data
|
||||
docker volume rm digiserver-v2_caddy-config
|
||||
|
||||
# Restart Caddy
|
||||
docker-compose restart caddy
|
||||
```
|
||||
|
||||
### Port 80/443 Already in Use
|
||||
```bash
|
||||
# Find what's using the port
|
||||
lsof -i :80 # For port 80
|
||||
lsof -i :443 # For port 443
|
||||
|
||||
# Stop the conflicting service or change ports in docker-compose.yml
|
||||
```
|
||||
|
||||
### Can't Access via IP Address
|
||||
```bash
|
||||
# Verify Caddy is listening
|
||||
docker-compose exec -T caddy netstat -tlnp 2>/dev/null | grep -E ':(80|443)'
|
||||
|
||||
# Test from container
|
||||
docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/
|
||||
```
|
||||
|
||||
### Database Corruption
|
||||
```bash
|
||||
# Backup current database
|
||||
docker-compose exec -T digiserver-app cp /app/instance/digiserver.db /app/instance/digiserver.db.backup
|
||||
|
||||
# Reset database (CAUTION: This deletes all data)
|
||||
docker-compose exec -T digiserver-app rm /app/instance/digiserver.db
|
||||
|
||||
# Restart and re-run migrations
|
||||
docker-compose restart digiserver-app
|
||||
./setup_https.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 More Help
|
||||
|
||||
See the detailed documentation files:
|
||||
- **Quick Commands**: `DOCKER_EXEC_COMMANDS.md`
|
||||
- **Full Guide**: `DEPLOYMENT_COMMANDS.md`
|
||||
- **HTTPS Details**: `old_code_documentation/HTTPS_CONFIGURATION.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Deployment on Different PC
|
||||
|
||||
To deploy on a different PC:
|
||||
|
||||
1. **Copy project files** to the new PC (or clone from git)
|
||||
2. **Ensure Docker and Docker Compose are installed**
|
||||
3. **Run deployment script**:
|
||||
```bash
|
||||
cd /path/to/digiserver-v2
|
||||
./deploy.sh
|
||||
```
|
||||
4. **Access the application** on the new PC at the configured URLs
|
||||
|
||||
All settings will be automatically configured! 🎉
|
||||
|
||||
---
|
||||
|
||||
## 📋 Environment Variables
|
||||
|
||||
You can customize deployment using environment variables:
|
||||
|
||||
```bash
|
||||
# Customize hostname
|
||||
HOSTNAME=myserver ./deploy.sh
|
||||
|
||||
# Customize domain
|
||||
DOMAIN=myserver.example.com ./deploy.sh
|
||||
|
||||
# Customize IP address
|
||||
IP_ADDRESS=192.168.1.100 ./deploy.sh
|
||||
|
||||
# Customize email
|
||||
EMAIL=admin@myserver.com ./deploy.sh
|
||||
|
||||
# Customize port
|
||||
PORT=8443 ./deploy.sh
|
||||
|
||||
# All together
|
||||
HOSTNAME=server1 \
|
||||
DOMAIN=server1.internal \
|
||||
IP_ADDRESS=192.168.1.100 \
|
||||
EMAIL=admin@server1.com \
|
||||
PORT=443 \
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
✅ Automated HTTPS with self-signed certificates
|
||||
✅ Multi-access (hostname, domain, IP address)
|
||||
✅ Automatic reverse proxy with Caddy
|
||||
✅ Docker containerized (easy deployment)
|
||||
✅ Complete database schema with migrations
|
||||
✅ Admin dashboard for configuration
|
||||
✅ User management
|
||||
✅ Player management
|
||||
✅ Content/Playlist management
|
||||
✅ Group management
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Default SSL certificates are **self-signed** (internal use)
|
||||
- For production with Let's Encrypt, edit the Caddyfile
|
||||
- Keep database backups before major changes
|
||||
- Default credentials are in the code; change them in production
|
||||
- All logs available via `docker-compose logs`
|
||||
|
||||
---
|
||||
|
||||
**Ready to deploy? Run:** `./deploy.sh` 🚀
|
||||
|
||||