Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4c3f65636 | |||
| 84b5cd3d48 | |||
| 57b7810069 | |||
| 46602f1933 | |||
| 1c5186463a | |||
| 046f5e5efd | |||
| 052751b599 | |||
| b7b21547f7 | |||
| 05194a19b6 | |||
| 2af04e1db3 | |||
| ae3b82862d | |||
| 8a89df3486 | |||
| 9c0a45afab | |||
| 49393d9a73 | |||
| d235c8e057 | |||
| 52e910346b | |||
| f2470e27ec | |||
| 0e242eb0b3 | |||
| c4e43ce69b | |||
| cf44843418 | |||
| a4262da7c9 | |||
| 024430754c | |||
| d17ed79e29 | |||
| 21eb63659a | |||
| bb293b6a81 | |||
| 2ea24a98cd | |||
| 2f0e9ffdf9 | |||
| b7afa9736b | |||
| c879bbaed0 | |||
| a39dbdd613 | |||
| cedb411536 | |||
| 361e0bc459 | |||
| 1e08fa45a1 | |||
| 48f1bfbcad | |||
| ef17abfe6b | |||
| fc4c8a7474 | |||
| 3829d98e91 | |||
| 88e24f8fec | |||
| 87709bab4d | |||
| 0dfeb0ef7f | |||
| 4a9616a0f7 |
+9
-3
@@ -44,6 +44,14 @@ flask_session/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Runtime data volumes (mounted at runtime, NOT part of the image)
|
||||
data/
|
||||
|
||||
# Archived snapshot of the pre-sanitization codebase (not part of the image).
|
||||
# Matched at any depth so it stays excluded regardless of where it is moved.
|
||||
legacy code/
|
||||
**/legacy code/
|
||||
|
||||
# Documentation
|
||||
BLUEPRINT_GUIDE.md
|
||||
ICON_INTEGRATION.md
|
||||
@@ -52,6 +60,4 @@ PLAYER_AUTH.md
|
||||
PROGRESS.md
|
||||
README.md
|
||||
|
||||
# Config templates
|
||||
player_config_template.ini
|
||||
player_auth_module.py
|
||||
|
||||
|
||||
+83
-15
@@ -1,21 +1,89 @@
|
||||
# Flask Environment
|
||||
FLASK_APP=app.py
|
||||
FLASK_ENV=development
|
||||
# DigiServer v2 Production Environment Configuration
|
||||
# Copy to .env and update with your production values
|
||||
# IMPORTANT: Never commit this file to git
|
||||
|
||||
# Security
|
||||
SECRET_KEY=change-this-to-a-random-secret-key
|
||||
# Server Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deploy-time TLS bootstrap. Copy this file to `.env` and set these two before
|
||||
# `docker compose up`. Both must be present for HTTPS to be configured at
|
||||
# startup; if either is missing the app stays on the plain-HTTP fallback and you
|
||||
# can enable HTTPS later from Admin → HTTPS Configuration (no restart needed).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite:///instance/dev.db
|
||||
# Hostname shown in the UI and used in the Caddy site block.
|
||||
HOSTNAME_INTERNAL=digiserver
|
||||
|
||||
# Redis (for production)
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
# The host's LAN IP as reachable by the players/browsers.
|
||||
# Replace 192.168.1.100 with THIS server's actual LAN IP. It is used for the
|
||||
# Caddy site blocks and the certificate, so a wrong value breaks HTTPS.
|
||||
# Find it with: ip -4 route get 1.1.1.1 | grep -oP 'src \K[\d.]+'
|
||||
HOST_IP=192.168.1.100
|
||||
|
||||
# Admin User Credentials (used during initial Docker deployment)
|
||||
# These credentials are set when the database is first created
|
||||
# Public domain for Let's Encrypt. LEAVE EMPTY for an intranet/internal name
|
||||
# (e.g. "digiserver" or "signage.corp.local") — a non-public name cannot pass an
|
||||
# ACME challenge, so an empty DOMAIN selects Caddy's internal CA instead.
|
||||
DOMAIN=
|
||||
|
||||
# Email for ACME/Let's Encrypt notifications (unused by the internal CA).
|
||||
SSL_EMAIL=admin@example.com
|
||||
|
||||
# Published ports. Caddy listens on 80/443 inside the container; these control
|
||||
# which host ports they are mapped to. Port 80 is always answered — the site
|
||||
# responds whether clients use the IP or the hostname.
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
|
||||
# "true" → also serve plain HTTP alongside HTTPS. Required for players whose
|
||||
# trust store lacks the internal CA (i.e. verify_ssl is not disabled).
|
||||
# "false" → serve TLS only and redirect HTTP to https://<host>:<HTTPS_PORT>.
|
||||
HTTPS_HTTP_FALLBACK=true
|
||||
|
||||
# After configuring HTTPS, probe it and automatically fall back to plain HTTP if
|
||||
# it does not come up — so a bad certificate can never make the site unreachable.
|
||||
# Set "false" to trust the configuration without probing.
|
||||
HTTPS_VERIFY=true
|
||||
|
||||
# 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-this-secure-password
|
||||
ADMIN_PASSWORD=change-me-to-a-strong-password
|
||||
ADMIN_EMAIL=admin@your-domain.com
|
||||
|
||||
# Optional: Sentry for error tracking
|
||||
# SENTRY_DSN=your-sentry-dsn-here
|
||||
# Database Configuration (optional - defaults to SQLite)
|
||||
# For PostgreSQL: postgresql://user:pass@host:5432/database
|
||||
# For SQLite: sqlite:////data/instance/dashboard.db
|
||||
# DATABASE_URL=
|
||||
|
||||
PREFERRED_URL_SCHEME=https
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 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
|
||||
|
||||
+31
@@ -13,6 +13,9 @@ ENV/
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Persistent data folder (containers, database, uploads)
|
||||
data/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -23,6 +26,23 @@ instance/
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Ad-hoc backups of .env (they contain real secrets — must never be committed)
|
||||
.env.bak
|
||||
.env.bak.*
|
||||
.env.*.bak
|
||||
*.env.bak
|
||||
|
||||
# Deployment artefacts that contain secrets
|
||||
.deployment-credentials
|
||||
caddy-root.crt
|
||||
|
||||
# Certificates / keys (generated by Caddy or the host)
|
||||
*.pem
|
||||
*.key
|
||||
!data/caddy-data/**
|
||||
!Caddyfile.example
|
||||
|
||||
# Database
|
||||
*.db
|
||||
@@ -52,3 +72,14 @@ htmlcov/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
#data
|
||||
data/
|
||||
|
||||
# Local archive / restore-point snapshots.
|
||||
# Kept on disk for reference (see docs/SANITIZATION-REVIEW.md) but deliberately
|
||||
# NOT tracked: docs/legacy code/ is a full pre-sanitization repo snapshot and
|
||||
# docs/old_code_documentation/ is a byte-identical copy of the tree inside it.
|
||||
docs/legacy code/
|
||||
docs/old_code_documentation/
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
# Caddy admin API — used by DigiServer to reload config after HTTPS is enabled
|
||||
admin 0.0.0.0:2019
|
||||
}
|
||||
|
||||
# Default: serve the app on HTTP port 80
|
||||
# Once HTTPS is configured via Admin → HTTPS Config, Caddy will reload
|
||||
# this file and start provisioning a Let's Encrypt certificate automatically.
|
||||
:80 {
|
||||
reverse_proxy digiserver-app:5000 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
}
|
||||
}
|
||||
|
||||
request_body {
|
||||
max_size 2GB
|
||||
}
|
||||
|
||||
encode gzip
|
||||
|
||||
header {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/access.log
|
||||
}
|
||||
}
|
||||
+15
-9
@@ -4,16 +4,23 @@ FROM python:3.13-slim
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# Note: LibreOffice is excluded from the base image to reduce size (~500MB)
|
||||
# It can be installed on-demand via the Admin Panel → System Dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# 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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
libreoffice-core \
|
||||
libreoffice-impress \
|
||||
libreoffice-writer \
|
||||
sshpass \
|
||||
openssh-client \
|
||||
rsync \
|
||||
git \
|
||||
&& apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
@@ -21,16 +28,15 @@ COPY requirements.txt .
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
# 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
|
||||
|
||||
# Create directories for uploads and database
|
||||
RUN mkdir -p app/static/uploads instance
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=app.app:create_app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
# DigiServer v2 - Quick Deployment Guide
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
DigiServer is deployed using Docker Compose with the following architecture:
|
||||
|
||||
```
|
||||
Internet (User) — Port 8080 (HTTP) / 8443 (HTTPS)
|
||||
↓
|
||||
Caddy Reverse Proxy (auto HTTPS, gzip, security headers)
|
||||
↓
|
||||
Internal Docker Network (digiserver-network)
|
||||
↓
|
||||
Flask App (Gunicorn on Port 5000)
|
||||
↓
|
||||
SQLite Database
|
||||
```
|
||||
|
||||
Caddy is used instead of Nginx because it:
|
||||
- **Auto-provisions Let's Encrypt certificates** — no manual certbot
|
||||
- **Simpler config** — a `Caddyfile` replaces the verbose `nginx.conf`
|
||||
- **Built-in HTTP/2, gzip, security headers**
|
||||
- **Admin API** (port 2019) — Flask reloads Caddy config dynamically
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### **1️⃣ Clone & Setup**
|
||||
```bash
|
||||
git clone <repository>
|
||||
cd digiserver-v2
|
||||
```
|
||||
|
||||
### **2️⃣ Start Containers**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. ✅ Builds the Flask app image from `Dockerfile`
|
||||
2. ✅ Creates persistent data directories (`data/instance`, `data/uploads`, etc.)
|
||||
3. ✅ Starts Caddy reverse proxy on ports **8080** (HTTP) and **8443** (HTTPS)
|
||||
4. ✅ Initializes the database on first run (auto-creates admin user)
|
||||
5. ✅ Runs all required migrations
|
||||
|
||||
**Access the app at:** `http://localhost:8080`
|
||||
|
||||
### **3️⃣ First-Time Login**
|
||||
```
|
||||
URL: http://localhost:8080
|
||||
Username: admin
|
||||
Password: admin123
|
||||
```
|
||||
⚠️ **CHANGE PASSWORD IMMEDIATELY IN PRODUCTION!**
|
||||
|
||||
---
|
||||
|
||||
## 📦 Container Architecture
|
||||
|
||||
### **Container 1: digiserver-app (Flask)**
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Image** | Built from `Dockerfile` (Python 3.13-slim) |
|
||||
| **Container name** | `digiserver-v2` |
|
||||
| **Exposed port** | `5000` (internal — proxied by Caddy) |
|
||||
| **Mapped port** | `5000:5000` (for direct dev access) |
|
||||
| **Entrypoint** | `docker-entrypoint.sh` (init DB → gunicorn) |
|
||||
| **Workers** | 4 gunicorn workers, 120s timeout |
|
||||
|
||||
**Volumes (persistent data):**
|
||||
| Host path | Container path | Purpose |
|
||||
|-----------|---------------|---------|
|
||||
| `./data/instance` | `/app/instance` | SQLite database |
|
||||
| `./data/uploads` | `/app/app/static/uploads` | Media uploads |
|
||||
|
||||
> **Code is baked into the Docker image** — see [Rebuild](#-rebuild-after-code-changes) below.
|
||||
|
||||
### **Container 2: caddy (Reverse Proxy)**
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Image** | `caddy:2-alpine` |
|
||||
| **Container name** | `digiserver-caddy` |
|
||||
| **HTTP** | `8080 → 80` (inside container) |
|
||||
| **HTTPS** | `8443 → 443` (inside container) |
|
||||
| **Admin API** | `2019` (internal, for dynamic reloads) |
|
||||
|
||||
**Volumes:**
|
||||
| Host path | Container path | Purpose |
|
||||
|-----------|---------------|---------|
|
||||
| `./data/Caddyfile` | `/etc/caddy/Caddyfile:rw` | Caddy config (rewritable by Flask) |
|
||||
| `./data/caddy-data` | `/data` | Let's Encrypt certs & keys |
|
||||
| `./data/caddy-config` | `/config` | Caddy JSON config |
|
||||
| `./data/caddy-logs` | `/var/log/caddy` | Access logs |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Full Automated Deployment
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
This runs the complete workflow:
|
||||
1. Creates `data/` directories (`instance`, `uploads`, `caddy-*`)
|
||||
2. Starts containers (`docker compose up -d`)
|
||||
3. Initialises database and admin user
|
||||
4. Runs all database migrations
|
||||
5. Enables HTTPS via Flask Admin
|
||||
6. Validates Caddy configuration
|
||||
7. Prints access URLs and credentials
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Deployment Steps (Manual)
|
||||
|
||||
### **Step 1: Build & Start**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
- Builds Flask app image (layer cached)
|
||||
- Creates `digiserver-network` bridge
|
||||
- Starts `digiserver-app`, then `caddy`
|
||||
|
||||
### **Step 2: Database Initialization** (docker-entrypoint.sh)
|
||||
On container start the entrypoint automatically:
|
||||
1. Creates required directories
|
||||
2. Checks for existing `dashboard.db`
|
||||
3. If absent — creates tables and seeds admin user
|
||||
4. Starts Gunicorn (4 workers, 120s timeout)
|
||||
|
||||
### **Step 3: Run Migrations**
|
||||
```bash
|
||||
docker compose exec -T digiserver-app python /app/migrations/<name>.py
|
||||
```
|
||||
Key migrations:
|
||||
- `add_https_config_table.py` — HTTPS settings table
|
||||
- `add_player_user_table.py` — Player user management
|
||||
- `add_email_to_https_config.py` — Email field
|
||||
- `migrate_player_user_global.py` — Global settings
|
||||
- `add_url_to_content.py` — Web link support
|
||||
- `add_deployment_fields_to_player.py` — Deployment tracking
|
||||
|
||||
### **Step 4: Configure HTTPS**
|
||||
Via Admin UI (recommended):
|
||||
1. Go to **Admin → HTTPS Config**
|
||||
2. Enter hostname, domain, email, IP, port
|
||||
3. Caddy auto-provisions a Let's Encrypt certificate
|
||||
|
||||
Via CLI:
|
||||
```bash
|
||||
docker compose exec -T digiserver-app python /app/https_manager.py enable \
|
||||
<hostname> <domain> <email> <ip_address> <port>
|
||||
```
|
||||
|
||||
### **Step 5: ProxyFix Middleware** (app/app.py)
|
||||
Flask extracts real client info from Caddy headers:
|
||||
- `X-Forwarded-For` → Real client IP
|
||||
- `X-Forwarded-Proto` → Protocol (http/https)
|
||||
- `X-Forwarded-Host` → Original hostname
|
||||
- `X-Forwarded-Port` → Original port
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Rebuild After Code Changes
|
||||
|
||||
Because the application code is baked into the Docker image, update it with:
|
||||
|
||||
```bash
|
||||
# 1. Rebuild the image
|
||||
docker build --no-cache -t digiserver-v2-digiserver-app .
|
||||
|
||||
# 2. Replace the running container
|
||||
docker stop digiserver-v2 && docker rm digiserver-v2
|
||||
docker compose up -d digiserver-app
|
||||
```
|
||||
|
||||
**Quick test (no rebuild)** — copy files into the running container:
|
||||
```bash
|
||||
docker cp <local_file> digiserver-v2:/app/<path>
|
||||
docker exec digiserver-v2 pkill -HUP -f gunicorn
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Network Migration
|
||||
|
||||
When the server moves to a different network / IP:
|
||||
|
||||
```bash
|
||||
./migrate_network.sh 10.55.150.160
|
||||
# Optional: ./migrate_network.sh 10.55.150.160 digiserver-secured
|
||||
```
|
||||
|
||||
This regenerates SSL certificates, updates the database HTTPS config, and restarts both containers.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Directory Structure
|
||||
|
||||
```
|
||||
digiserver-v2/
|
||||
├── app/ (Flask application code)
|
||||
│ ├── app.py (App factory)
|
||||
│ ├── blueprints/ (Route definitions)
|
||||
│ ├── models/ (SQLAlchemy models)
|
||||
│ ├── templates/ (Jinja2 templates)
|
||||
│ ├── static/uploads/ (Media files on disk)
|
||||
│ └── utils/ (Helpers)
|
||||
├── data/ (PERSISTENT — survives restarts)
|
||||
│ ├── instance/dashboard.db (SQLite database)
|
||||
│ ├── uploads/ (Media files)
|
||||
│ ├── Caddyfile (Caddy config)
|
||||
│ ├── caddy-data/ (Let's Encrypt certificates)
|
||||
│ ├── caddy-config/ (Caddy JSON config)
|
||||
│ └── caddy-logs/ (Access logs)
|
||||
├── migrations/ (One-shot DB scripts)
|
||||
├── old_code_documentation/ (Legacy Nginx files, etc.)
|
||||
├── docker-compose.yml (Service definitions)
|
||||
├── Dockerfile (Flask image build)
|
||||
├── deploy.sh (Full automated deploy)
|
||||
├── migrate_network.sh (Re-config after IP change)
|
||||
└── docker-entrypoint.sh (Container startup)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Common Commands
|
||||
|
||||
### **Containers**
|
||||
```bash
|
||||
docker compose up -d # Start all services
|
||||
docker compose down # Stop all services
|
||||
docker compose restart # Restart all
|
||||
docker compose restart caddy # Restart only Caddy
|
||||
```
|
||||
|
||||
### **Logs**
|
||||
```bash
|
||||
docker compose logs -f # Follow all
|
||||
docker compose logs -f digiserver-app # Flask only
|
||||
docker compose logs -f caddy # Caddy only
|
||||
docker compose logs --tail=50 digiserver-app
|
||||
```
|
||||
|
||||
### **Status**
|
||||
```bash
|
||||
docker compose ps
|
||||
docker ps --format="table {{.Names}}\t{{.Status}}"
|
||||
```
|
||||
|
||||
### **Database**
|
||||
```bash
|
||||
docker compose exec digiserver-app sqlite3 /app/instance/dashboard.db
|
||||
docker compose exec digiserver-app cp /app/instance/dashboard.db \
|
||||
/app/instance/dashboard.db.backup
|
||||
```
|
||||
|
||||
### **Caddy**
|
||||
```bash
|
||||
docker exec digiserver-caddy caddy validate --config /etc/caddy/Caddyfile
|
||||
docker exec digiserver-caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 HTTPS / SSL
|
||||
|
||||
Caddy automatically provisions Let's Encrypt certificates when a public domain is configured. For development with self-signed certificates:
|
||||
|
||||
```bash
|
||||
bash generate_nginx_certs.sh 192.168.0.121 365
|
||||
docker compose restart caddy
|
||||
```
|
||||
|
||||
> **Note:** `generate_nginx_certs.sh` has been moved to `old_code_documentation/` but still works.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### **Containers not starting?**
|
||||
```bash
|
||||
docker compose logs
|
||||
docker stats
|
||||
```
|
||||
|
||||
### **App not responding?**
|
||||
```bash
|
||||
docker compose ps
|
||||
curl http://localhost:5000/ # Bypass Caddy, hit Flask directly
|
||||
docker compose logs -f digiserver-app
|
||||
```
|
||||
|
||||
### **Caddy / HTTPS issues?**
|
||||
```bash
|
||||
docker exec digiserver-caddy caddy validate --config /etc/caddy/Caddyfile
|
||||
ls -la ./data/caddy-data/certificates/
|
||||
docker compose restart caddy
|
||||
```
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add muted column to playlist_content table."""
|
||||
from app.app import create_app
|
||||
from app.extensions import db
|
||||
|
||||
def add_muted_column():
|
||||
"""Add muted column to playlist_content association table."""
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
# Check if column already exists
|
||||
result = db.session.execute(db.text("PRAGMA table_info(playlist_content)")).fetchall()
|
||||
columns = [row[1] for row in result]
|
||||
|
||||
if 'muted' in columns:
|
||||
print("ℹ️ Column 'muted' already exists in playlist_content table")
|
||||
return
|
||||
|
||||
# Add muted column with default value True (muted by default)
|
||||
db.session.execute(db.text("""
|
||||
ALTER TABLE playlist_content
|
||||
ADD COLUMN muted BOOLEAN DEFAULT TRUE
|
||||
"""))
|
||||
db.session.commit()
|
||||
print("✅ Successfully added 'muted' column to playlist_content table")
|
||||
print(" Default: TRUE (videos will be muted by default)")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"❌ Error adding column: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
add_muted_column()
|
||||
+51
-5
@@ -4,6 +4,7 @@ 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
|
||||
@@ -37,6 +38,15 @@ def create_app(config_name=None):
|
||||
|
||||
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)
|
||||
@@ -47,13 +57,33 @@ def create_app(config_name=None):
|
||||
# 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
|
||||
|
||||
|
||||
@@ -63,9 +93,7 @@ def register_blueprints(app):
|
||||
from app.blueprints.auth import auth_bp
|
||||
from app.blueprints.admin import admin_bp
|
||||
from app.blueprints.players import players_bp
|
||||
from app.blueprints.groups import groups_bp
|
||||
from app.blueprints.content import content_bp
|
||||
from app.blueprints.playlist import playlist_bp
|
||||
from app.blueprints.api import api_bp
|
||||
|
||||
# Register blueprints (using URL prefixes from blueprint definitions)
|
||||
@@ -73,9 +101,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(players_bp)
|
||||
app.register_blueprint(groups_bp)
|
||||
app.register_blueprint(content_bp)
|
||||
app.register_blueprint(playlist_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
|
||||
@@ -165,9 +191,29 @@ def register_context_processors(app):
|
||||
@app.context_processor
|
||||
def inject_config():
|
||||
"""Inject configuration variables into all templates"""
|
||||
# Cache-busting token for the uploadable logos.
|
||||
#
|
||||
# Must be appended OUTSIDE url_for(): passing 'logo.png?v=1' as the
|
||||
# filename makes Flask percent-encode the '?' into '%3F', which asks
|
||||
# for a file literally named "logo.png%3Fv=1" and 404s.
|
||||
#
|
||||
# Derived from the newest logo's mtime so that uploading a new logo in
|
||||
# Admin → Customize Logos is picked up immediately, rather than reusing
|
||||
# a hardcoded value the browser has already cached.
|
||||
logo_version = 1
|
||||
for _logo_name in ('header_logo.png', 'login_logo.png'):
|
||||
try:
|
||||
_mtime = int(os.path.getmtime(
|
||||
os.path.join(app.config['UPLOAD_FOLDER'], _logo_name)))
|
||||
logo_version = max(logo_version, _mtime)
|
||||
except OSError:
|
||||
# Logo not uploaded yet — keep the current token.
|
||||
pass
|
||||
|
||||
return {
|
||||
'server_version': app.config['SERVER_VERSION'],
|
||||
'build_date': app.config['BUILD_DATE'],
|
||||
'logo_version': logo_version,
|
||||
'logo_exists': os.path.exists(
|
||||
os.path.join(app.root_path, app.config['UPLOAD_FOLDERLOGO'], 'logo.png')
|
||||
)
|
||||
|
||||
+418
-12
@@ -8,8 +8,9 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models import User, Player, Group, Content, ServerLog, Playlist
|
||||
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.caddy_manager import CaddyConfigGenerator
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@@ -31,7 +32,6 @@ def admin_required(f):
|
||||
|
||||
@admin_bp.route('/')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_panel():
|
||||
"""Display admin panel with system overview."""
|
||||
try:
|
||||
@@ -351,7 +351,6 @@ def system_info():
|
||||
|
||||
@admin_bp.route('/leftover-media')
|
||||
@login_required
|
||||
@admin_required
|
||||
def leftover_media():
|
||||
"""Display leftover media files not assigned to any playlist."""
|
||||
from app.models.playlist import playlist_content
|
||||
@@ -374,12 +373,15 @@ def leftover_media():
|
||||
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
|
||||
total_leftover_size = sum(c.file_size for c in leftover_content)
|
||||
images_size = sum(c.file_size for c in leftover_images)
|
||||
videos_size = sum(c.file_size for c in leftover_videos)
|
||||
pdfs_size = sum(c.file_size for c in leftover_pdfs)
|
||||
pptx_size = sum(c.file_size for c in leftover_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,
|
||||
@@ -401,7 +403,6 @@ def leftover_media():
|
||||
|
||||
@admin_bp.route('/delete-leftover-images', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_leftover_images():
|
||||
"""Delete all leftover images that are not part of any playlist"""
|
||||
from app.models.playlist import playlist_content
|
||||
@@ -457,7 +458,6 @@ def delete_leftover_images():
|
||||
|
||||
@admin_bp.route('/delete-leftover-videos', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_leftover_videos():
|
||||
"""Delete all leftover videos that are not part of any playlist"""
|
||||
from app.models.playlist import playlist_content
|
||||
@@ -513,7 +513,6 @@ def delete_leftover_videos():
|
||||
|
||||
@admin_bp.route('/delete-single-leftover/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_single_leftover(content_id):
|
||||
"""Delete a single leftover content file"""
|
||||
try:
|
||||
@@ -772,3 +771,410 @@ def upload_login_logo():
|
||||
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)')
|
||||
|
||||
return render_template('admin/https_config.html',
|
||||
config=config,
|
||||
is_https_active=is_https_active,
|
||||
current_host=current_host)
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Player Build & Deployment Routes
|
||||
# ============================================================================
|
||||
|
||||
def _player_build_meta_path() -> str:
|
||||
"""Path to the persisted player-build settings (in the instance folder)."""
|
||||
from app.utils.player_build import BUILD_META_FILENAME
|
||||
return os.path.join(current_app.instance_path, BUILD_META_FILENAME)
|
||||
|
||||
|
||||
@admin_bp.route('/build-player', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def build_player():
|
||||
"""Display the 'Build player files for deployment' admin page."""
|
||||
from app.utils.ssh_deploy import get_local_player_code_status
|
||||
from app.utils.player_build import load_build_settings
|
||||
|
||||
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
||||
settings = load_build_settings(_player_build_meta_path()) or {}
|
||||
|
||||
# Prefill server address from saved settings, else from HTTPS config.
|
||||
if not settings.get('server_ip'):
|
||||
https_cfg = HTTPSConfig.get_config()
|
||||
if https_cfg and (https_cfg.domain or https_cfg.ip_address):
|
||||
settings.setdefault('server_ip', https_cfg.domain or https_cfg.ip_address)
|
||||
settings.setdefault('port', str(https_cfg.port or 443))
|
||||
settings.setdefault('use_https', bool(https_cfg.https_enabled))
|
||||
|
||||
# Sensible defaults.
|
||||
settings.setdefault('repo_url', current_app.config.get('PLAYER_REPO_URL', ''))
|
||||
settings.setdefault('branch', 'main')
|
||||
default_host = request.host.split(':')[0]
|
||||
if default_host in ('localhost', '127.0.0.1', '') or default_host.startswith('127.'):
|
||||
from app.utils.ssh_deploy import detect_server_ip
|
||||
default_host = detect_server_ip() or default_host
|
||||
settings.setdefault('server_ip', default_host)
|
||||
settings.setdefault('port', '443')
|
||||
settings.setdefault('use_https', True)
|
||||
settings.setdefault('verify_ssl', False)
|
||||
settings.setdefault('orientation', 'Landscape')
|
||||
settings.setdefault('max_resolution', '1920x1080')
|
||||
|
||||
code_status = get_local_player_code_status(player_code_dir)
|
||||
if code_status.get('updated'):
|
||||
code_status['updated_str'] = datetime.fromtimestamp(
|
||||
code_status['updated']).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
return render_template(
|
||||
'admin/build_player.html',
|
||||
settings=settings,
|
||||
code_status=code_status,
|
||||
player_code_dir=player_code_dir,
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/build-player', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def build_player_action():
|
||||
"""Start building/refreshing the staged player code.
|
||||
|
||||
The build runs in a background thread because a full clone of the player
|
||||
repository takes far longer than gunicorn's worker timeout; running it
|
||||
in-request would get the worker killed mid-clone and leave a broken
|
||||
checkout. The page then polls ``admin.build_player_status`` for progress.
|
||||
"""
|
||||
from app.utils.player_build import start_background_build, is_build_running
|
||||
|
||||
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
||||
action = request.form.get('action', 'build_and_config')
|
||||
|
||||
repo_url = request.form.get('repo_url', '').strip()
|
||||
branch = request.form.get('branch', 'main').strip() or 'main'
|
||||
server_ip = request.form.get('server_ip', '').strip()
|
||||
port = request.form.get('port', '443').strip()
|
||||
use_https = request.form.get('use_https') == 'on'
|
||||
verify_ssl = request.form.get('verify_ssl') == 'on'
|
||||
orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape'
|
||||
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
|
||||
|
||||
# Validation (unchanged — fail fast before starting any work)
|
||||
errors = []
|
||||
if action in ('build_files', 'build_and_config') and not repo_url:
|
||||
errors.append('Repository URL is required to build player files.')
|
||||
if action in ('save_config', 'build_and_config') and not server_ip:
|
||||
errors.append('Server IP / domain is required for the player configuration.')
|
||||
try:
|
||||
int(port)
|
||||
except ValueError:
|
||||
errors.append('Port must be a valid number.')
|
||||
|
||||
if errors:
|
||||
for err in errors:
|
||||
flash(err, 'warning')
|
||||
return redirect(url_for('admin.build_player'))
|
||||
|
||||
# 'save_config' only writes the config file — it touches no network and is
|
||||
# fast, so it stays synchronous.
|
||||
if action == 'save_config':
|
||||
from app.utils.player_build import (
|
||||
write_base_config, get_short_head, save_build_settings, make_build_record,
|
||||
)
|
||||
cfg_result = write_base_config(
|
||||
player_code_dir=player_code_dir,
|
||||
server_ip=server_ip, port=port, use_https=use_https,
|
||||
verify_ssl=verify_ssl, orientation=orientation,
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
version = get_short_head(player_code_dir)
|
||||
save_build_settings(
|
||||
_player_build_meta_path(),
|
||||
make_build_record(
|
||||
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
|
||||
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
|
||||
max_resolution=max_resolution, version=version,
|
||||
built_by=current_user.username,
|
||||
),
|
||||
)
|
||||
if cfg_result['success']:
|
||||
log_action('info', f'Player config saved by {current_user.username}')
|
||||
flash(f"✅ {cfg_result['message']}", 'success')
|
||||
else:
|
||||
log_action('error', f'Player config write failed: {cfg_result["message"]}')
|
||||
flash(f"⚠️ {cfg_result['message']}", 'danger')
|
||||
return redirect(url_for('admin.build_player'))
|
||||
|
||||
# build_files / build_and_config → background thread.
|
||||
if is_build_running():
|
||||
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
|
||||
return redirect(url_for('admin.build_player'))
|
||||
|
||||
config_payload = None
|
||||
if action == 'build_and_config':
|
||||
config_payload = {
|
||||
'server_ip': server_ip, 'port': port, 'use_https': use_https,
|
||||
'verify_ssl': verify_ssl, 'orientation': orientation,
|
||||
'max_resolution': max_resolution,
|
||||
}
|
||||
|
||||
started = start_background_build(
|
||||
player_code_dir=player_code_dir,
|
||||
repo_url=repo_url,
|
||||
branch=branch,
|
||||
config_payload=config_payload,
|
||||
meta_path=_player_build_meta_path(),
|
||||
built_by=current_user.username,
|
||||
)
|
||||
|
||||
if not started:
|
||||
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
|
||||
else:
|
||||
log_action('info', f'Player build started by {current_user.username} '
|
||||
f'({branch} @ {repo_url})')
|
||||
flash('⏳ Build started — this page will update automatically.', 'info')
|
||||
|
||||
return redirect(url_for('admin.build_player'))
|
||||
|
||||
|
||||
@admin_bp.route('/build-player/status', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def build_player_status():
|
||||
"""JSON progress for the running/last player build (polled by the page)."""
|
||||
from app.utils.player_build import get_build_state
|
||||
return jsonify(get_build_state())
|
||||
|
||||
+283
-90
@@ -3,11 +3,14 @@ from flask import Blueprint, request, jsonify, current_app
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import hashlib
|
||||
import bcrypt
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Group, Content, PlayerFeedback, ServerLog
|
||||
from app.models import (
|
||||
Player, Playlist, Content, PlayerFeedback, ServerLog,
|
||||
)
|
||||
from app.utils.logger import log_action
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
@@ -85,6 +88,25 @@ def verify_player_auth(f):
|
||||
return decorated_function
|
||||
|
||||
|
||||
def get_assigned_playlist(player: Player) -> Optional[Playlist]:
|
||||
"""Return the playlist assigned to *player*, or ``None`` if unassigned.
|
||||
|
||||
Centralises playlist lookup so every endpoint reports the same sync
|
||||
version. Playlist edits bump ``Playlist.version``; players poll that
|
||||
value to decide whether their cached content is stale. A player with no
|
||||
assigned playlist has nothing to sync and resolves to version 0.
|
||||
|
||||
Args:
|
||||
player: The player whose assigned playlist should be resolved.
|
||||
|
||||
Returns:
|
||||
The assigned ``Playlist`` instance, or ``None`` when unassigned.
|
||||
"""
|
||||
if not player.playlist_id:
|
||||
return None
|
||||
return db.session.get(Playlist, player.playlist_id)
|
||||
|
||||
|
||||
@api_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""API health check endpoint."""
|
||||
@@ -95,6 +117,12 @@ def health_check():
|
||||
})
|
||||
|
||||
|
||||
@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():
|
||||
@@ -106,7 +134,7 @@ def authenticate_player():
|
||||
quickconnect_code: Quick connect code (optional if using password)
|
||||
|
||||
Returns:
|
||||
JSON with auth_code, player_id, group_id, and configuration
|
||||
JSON with auth_code, player_id, playlist_id, and configuration
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
@@ -258,12 +286,8 @@ def get_playlist_by_quickconnect():
|
||||
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
|
||||
assigned_playlist = get_assigned_playlist(player)
|
||||
playlist_version = assigned_playlist.version if assigned_playlist else 0
|
||||
|
||||
# Hash the quickconnect code for validation on client side
|
||||
hashed_quickconnect = bcrypt.hashpw(
|
||||
@@ -315,12 +339,8 @@ def get_player_playlist(player_id: int):
|
||||
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
|
||||
assigned_playlist = get_assigned_playlist(player)
|
||||
playlist_version = assigned_playlist.version if assigned_playlist else 0
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
@@ -356,10 +376,16 @@ def get_playlist_version(player_id: int):
|
||||
player.last_seen = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Player syncs against the version of its assigned playlist; the
|
||||
# content count comes from that same playlist (Content has no
|
||||
# player_id column - it reaches players through the playlist).
|
||||
assigned_playlist = get_assigned_playlist(player)
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'playlist_version': player.playlist_version,
|
||||
'content_count': Content.query.filter_by(player_id=player_id).count()
|
||||
'playlist_id': player.playlist_id,
|
||||
'playlist_version': assigned_playlist.version if assigned_playlist else 0,
|
||||
'content_count': assigned_playlist.contents.count() if assigned_playlist else 0
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -371,7 +397,6 @@ def get_playlist_version(player_id: int):
|
||||
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:
|
||||
@@ -390,9 +415,13 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
for idx, content in enumerate(content_list, start=1):
|
||||
# Generate full URL for content
|
||||
from flask import request as current_request
|
||||
# Get server base URL
|
||||
server_base = current_request.host_url.rstrip('/')
|
||||
content_url = f"{server_base}/static/uploads/{content.filename}"
|
||||
script_root = current_request.script_root.rstrip('/')
|
||||
content_url = f"{server_base}{script_root}/static/uploads/{content.filename}"
|
||||
|
||||
# Web links carry the page URL directly instead of a file download URL.
|
||||
is_weblink = content.content_type == 'weblink'
|
||||
item_url = content.url if is_weblink else content_url
|
||||
|
||||
playlist_data.append({
|
||||
'id': content.id,
|
||||
@@ -400,9 +429,14 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
'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
|
||||
'url': item_url, # Web page URL for weblinks, file download URL otherwise
|
||||
'description': content.description,
|
||||
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
|
||||
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False),
|
||||
'muted': getattr(content, '_playlist_muted', True),
|
||||
# Player expects 'audio' = "on"/"off" to set playback sound.
|
||||
# The playlist_content.muted column stores True when audio is muted,
|
||||
# so audio is "on" exactly when muted is False.
|
||||
'audio': 'off' if getattr(content, '_playlist_muted', True) else 'on'
|
||||
})
|
||||
|
||||
return playlist_data
|
||||
@@ -491,6 +525,18 @@ def receive_player_feedback():
|
||||
player.last_seen = datetime.utcnow()
|
||||
player.status = status
|
||||
|
||||
# A player that is sending feedback is running, so its deployment
|
||||
# finished successfully. Auto-mark it as deployed so the players list
|
||||
# doesn't stay stuck on "pending"/"deploying" after deployment is done.
|
||||
if player.deployment_status in ('pending', 'deploying'):
|
||||
player.deployment_status = 'deployed'
|
||||
player.last_deployment_status = 'success'
|
||||
player.last_deployment_message = (
|
||||
'Player is running and sending feedback'
|
||||
)
|
||||
if not player.last_deployment_at:
|
||||
player.last_deployment_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
|
||||
@@ -528,7 +574,6 @@ def get_player_status(player_id: int):
|
||||
'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,
|
||||
@@ -565,7 +610,6 @@ def system_info():
|
||||
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)
|
||||
@@ -582,7 +626,6 @@ def system_info():
|
||||
'total': total_players,
|
||||
'online': online_players
|
||||
},
|
||||
'groups': total_groups,
|
||||
'content': total_content,
|
||||
'logs_24h': recent_logs,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
@@ -593,33 +636,6 @@ def system_info():
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@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():
|
||||
@@ -635,8 +651,7 @@ def list_content():
|
||||
'type': content.content_type,
|
||||
'duration': content.duration,
|
||||
'size': content.file_size,
|
||||
'uploaded_at': content.uploaded_at.isoformat(),
|
||||
'group_count': content.groups.count()
|
||||
'uploaded_at': content.uploaded_at.isoformat()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
@@ -721,106 +736,166 @@ def receive_edited_media():
|
||||
"""
|
||||
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
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models.player_edit import PlayerEdit
|
||||
|
||||
# Find the original content by filename
|
||||
|
||||
original_name = metadata['original_name']
|
||||
version = metadata['version']
|
||||
new_filename = metadata['new_name']
|
||||
|
||||
# ── Content lookup ───────────────────────────────────────────────
|
||||
# If the player sends the current filename (pointing to edited_media/...),
|
||||
# we find the content by its ID embedded in the path. Otherwise do a
|
||||
# direct filename match.
|
||||
content = Content.query.filter_by(filename=original_name).first()
|
||||
|
||||
if not content:
|
||||
# Try to extract content_id from a path like "edited_media/<id>/file"
|
||||
import re
|
||||
m = re.match(r'edited_media/(\d+)/', original_name)
|
||||
if m:
|
||||
content = db.session.get(Content, int(m.group(1)))
|
||||
if not content:
|
||||
# Last resort – look up the most recent PlayerEdit for this
|
||||
# content and use its content_id.
|
||||
fallback_edit = PlayerEdit.query.filter_by(new_name=original_name)\
|
||||
.order_by(PlayerEdit.created_at.desc()).first()
|
||||
if fallback_edit:
|
||||
content = db.session.get(Content, fallback_edit.content_id)
|
||||
|
||||
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>/
|
||||
|
||||
# ── Versionized folder ───────────────────────────────────────────
|
||||
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']
|
||||
|
||||
# On the very first edit (v1) move the original file into the
|
||||
# versionized folder so it is never orphaned.
|
||||
is_first_edit = PlayerEdit.query.filter_by(content_id=content.id).count() == 0
|
||||
if is_first_edit:
|
||||
orig_upload = os.path.join(base_upload_dir, content.filename)
|
||||
# content.filename might already be an edited_media/ path if
|
||||
# this is a re-process; only move if it's a plain filename.
|
||||
if os.path.isfile(orig_upload) and not content.filename.startswith('edited_media/'):
|
||||
orig_stored = f"original_{content.filename}"
|
||||
shutil.move(orig_upload, os.path.join(edited_media_dir, orig_stored))
|
||||
log_action('info', f'Moved original file "{content.filename}" to versionized folder as "{orig_stored}"')
|
||||
|
||||
# ── Save the edited file ─────────────────────────────────────────
|
||||
edited_file_path = os.path.join(edited_media_dir, new_filename)
|
||||
file.save(edited_file_path)
|
||||
|
||||
# Save metadata JSON file
|
||||
|
||||
# Side-car metadata JSON
|
||||
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)
|
||||
|
||||
# Copy the versioned image to the main uploads folder
|
||||
import shutil
|
||||
versioned_upload_path = os.path.join(base_upload_dir, new_filename)
|
||||
shutil.copy2(edited_file_path, versioned_upload_path)
|
||||
|
||||
# Update the content record to reference the new versioned filename
|
||||
|
||||
# ── Preserve the original filename ──────────────────────────────
|
||||
# `content.filename` is (re)pointed at the latest edit below so the
|
||||
# player downloads the newest version. The pristine original must be
|
||||
# remembered separately, otherwise the original is lost forever and
|
||||
# the UI shows the edited image as the "original".
|
||||
if not content.original_filename:
|
||||
if not content.filename.startswith('edited_media/'):
|
||||
# Normal case: current filename is still the pristine upload.
|
||||
content.original_filename = content.filename
|
||||
else:
|
||||
# Re-process: content.filename already points at an edited
|
||||
# file. Recover the pristine original from the first (v1) edit.
|
||||
first_edit = PlayerEdit.query.filter_by(content_id=content.id)\
|
||||
.order_by(PlayerEdit.version.asc(), PlayerEdit.created_at.asc())\
|
||||
.first()
|
||||
if first_edit and first_edit.original_name:
|
||||
content.original_filename = first_edit.original_name
|
||||
|
||||
# ── Point Content.filename to the latest edit ────────────────────
|
||||
# This tells the player to download the latest edited version.
|
||||
old_filename = content.filename
|
||||
content.filename = new_filename
|
||||
|
||||
# Create edit record
|
||||
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=metadata.get('user'),
|
||||
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
|
||||
|
||||
# ── 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',
|
||||
@@ -828,7 +903,7 @@ def receive_edited_media():
|
||||
'version': version,
|
||||
'old_filename': old_filename,
|
||||
'new_filename': new_filename,
|
||||
'new_playlist_version': playlist.version if player.playlist_id and playlist else None
|
||||
'new_playlist_version': playlist.version if playlist else None
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
@@ -837,6 +912,124 @@ def receive_edited_media():
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# SSH/Deployment Endpoints - For player provisioning and code deployment
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@api_bp.route('/deploy/test-ssh', methods=['POST'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def test_ssh_connection():
|
||||
"""Test SSH connection to a remote host.
|
||||
|
||||
Request JSON:
|
||||
hostname: Target hostname or IP (required)
|
||||
username: SSH username (required)
|
||||
password: SSH password (required)
|
||||
port: SSH port (default: 22)
|
||||
|
||||
Returns:
|
||||
JSON with connection test result
|
||||
"""
|
||||
try:
|
||||
from app.utils.ssh_deploy import test_ssh_connection as test_ssh
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
hostname = data.get('hostname', '').strip()
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '').strip()
|
||||
port = data.get('port', 22)
|
||||
|
||||
if not hostname or not username or not password:
|
||||
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||||
|
||||
result = test_ssh(hostname, username, password, port)
|
||||
|
||||
log_action('info', f'SSH test for {username}@{hostname}: {result["message"]}')
|
||||
|
||||
return jsonify(result), 200 if result['success'] else 400
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error testing SSH connection: {str(e)}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'message': f'SSH test error: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/deploy/player', methods=['POST'])
|
||||
@rate_limit(max_requests=20, window=60)
|
||||
def deploy_player():
|
||||
"""Deploy player code to a remote host via SSH.
|
||||
|
||||
Request JSON:
|
||||
hostname: Target hostname or IP (required)
|
||||
username: SSH username (required)
|
||||
password: SSH password (required)
|
||||
player_name: Name for the player instance (required)
|
||||
port: SSH port (default: 22)
|
||||
deploy_path: Deployment path on remote host
|
||||
repo_url: Git repository URL
|
||||
|
||||
Returns:
|
||||
JSON with deployment status and step details
|
||||
"""
|
||||
try:
|
||||
from app.utils.ssh_deploy import deploy_player_to_host
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
hostname = data.get('hostname', '').strip()
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '').strip()
|
||||
player_name = data.get('player_name', '').strip()
|
||||
port = data.get('port', 22)
|
||||
deploy_path = data.get('deploy_path', None)
|
||||
repo_url = data.get('repo_url', 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git').strip()
|
||||
|
||||
if not hostname or not username or not password:
|
||||
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||||
|
||||
if not player_name:
|
||||
return jsonify({'error': 'player_name is required'}), 400
|
||||
|
||||
scheme = request.headers.get('X-Forwarded-Proto', request.scheme)
|
||||
host = request.headers.get('X-Forwarded-Host', request.host)
|
||||
server_url = f"{scheme}://{host}"
|
||||
|
||||
api_key = hashlib.sha256(f'{player_name}:{hostname}'.encode()).hexdigest()[:32]
|
||||
|
||||
result = deploy_player_to_host(
|
||||
hostname=hostname,
|
||||
username=username,
|
||||
password=password,
|
||||
player_name=player_name,
|
||||
repo_url=repo_url,
|
||||
deploy_path=deploy_path,
|
||||
port=port,
|
||||
server_url=server_url,
|
||||
server_api_key=api_key
|
||||
)
|
||||
|
||||
log_action('info', f'Player deployment for {player_name} on {hostname}: success={result["success"]}')
|
||||
|
||||
return jsonify(result), 200 if result['success'] else 400
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error deploying player: {str(e)}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'message': f'Deployment error: {str(e)}',
|
||||
'steps': []
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.errorhandler(404)
|
||||
def api_not_found(error):
|
||||
"""Handle 404 errors in API."""
|
||||
|
||||
+215
-2
@@ -6,6 +6,9 @@ from werkzeug.utils import secure_filename
|
||||
from typing import Optional
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Content, Playlist, Player
|
||||
@@ -201,8 +204,10 @@ def manage_playlist_content(playlist_id: int):
|
||||
# Get content in playlist (ordered)
|
||||
playlist_content = playlist.get_content_ordered()
|
||||
|
||||
# Get all available content not in this playlist
|
||||
all_content = Content.query.all()
|
||||
# Get all available content not in this playlist.
|
||||
# Web links are created on demand per playlist, so they are not offered
|
||||
# as reusable library items here.
|
||||
all_content = Content.query.filter(Content.content_type != 'weblink').all()
|
||||
playlist_content_ids = {c.id for c in playlist_content}
|
||||
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
||||
|
||||
@@ -262,6 +267,152 @@ def add_content_to_playlist(playlist_id: int):
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
|
||||
|
||||
@content_bp.route('/add-weblink', methods=['POST'])
|
||||
@login_required
|
||||
def add_weblink():
|
||||
"""Create a web link content item from the Upload Media page.
|
||||
|
||||
Optionally adds it directly to a playlist if playlist_id is supplied.
|
||||
Returns JSON when the request carries Accept: application/json, otherwise
|
||||
redirects back to the upload page.
|
||||
"""
|
||||
use_json = 'application/json' in request.accept_mimetypes.best or \
|
||||
request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
|
||||
try:
|
||||
web_url = (request.form.get('url') or '').strip()
|
||||
duration = request.form.get('duration', type=int, default=30)
|
||||
description = (request.form.get('description') or '').strip() or None
|
||||
playlist_id = request.form.get('playlist_id', type=int)
|
||||
|
||||
parsed = urlparse(web_url)
|
||||
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
|
||||
if use_json:
|
||||
return jsonify({'success': False, 'error': 'Please enter a valid http:// or https:// web address.'}), 400
|
||||
flash('Please enter a valid http:// or https:// web address.', 'warning')
|
||||
return redirect(url_for('content.upload_media_page'))
|
||||
|
||||
if not duration or duration < 1:
|
||||
duration = 30
|
||||
|
||||
content = Content(
|
||||
filename=f'weblink-{uuid.uuid4().hex[:12]}',
|
||||
content_type='weblink',
|
||||
url=web_url,
|
||||
duration=duration,
|
||||
description=description or web_url,
|
||||
uploaded_at=datetime.utcnow(),
|
||||
)
|
||||
db.session.add(content)
|
||||
db.session.flush()
|
||||
|
||||
if playlist_id:
|
||||
playlist = Playlist.query.get(playlist_id)
|
||||
if playlist:
|
||||
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
|
||||
db.session.execute(
|
||||
playlist_content.insert().values(
|
||||
playlist_id=playlist_id,
|
||||
content_id=content.id,
|
||||
position=max_pos + 1,
|
||||
duration=duration,
|
||||
)
|
||||
)
|
||||
playlist.increment_version()
|
||||
log_action('info', f'Web link "{web_url}" added to playlist "{playlist.name}"')
|
||||
else:
|
||||
log_action('warning', f'Web link "{web_url}" created; playlist {playlist_id} not found')
|
||||
else:
|
||||
log_action('info', f'Web link "{web_url}" added to media library')
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
if use_json:
|
||||
return jsonify({'success': True, 'content_id': content.id, 'message': 'Web link added successfully.'})
|
||||
flash('Web link added successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding web link: {str(e)}')
|
||||
if use_json:
|
||||
return jsonify({'success': False, 'error': 'Failed to add web link.'}), 500
|
||||
flash('Error adding web link.', 'danger')
|
||||
|
||||
return redirect(url_for('content.upload_media_page'))
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/add-weblink', methods=['POST'])
|
||||
@login_required
|
||||
def add_weblink_to_playlist(playlist_id: int):
|
||||
"""Create a web link content item and add it to the playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
web_url = (request.form.get('url') or '').strip()
|
||||
duration = request.form.get('duration', type=int, default=30)
|
||||
description = (request.form.get('description') or '').strip() or None
|
||||
|
||||
# Validate the URL: only http/https schemes are allowed (avoid file://, etc.)
|
||||
parsed = urlparse(web_url)
|
||||
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
|
||||
flash('Please enter a valid http:// or https:// web address.', 'warning')
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
|
||||
if duration is None or duration < 1:
|
||||
duration = 30
|
||||
|
||||
# Create a weblink Content row. filename is a synthetic unique label
|
||||
# (no file on disk); the real target lives in the url column.
|
||||
content = Content(
|
||||
filename=f'weblink-{uuid.uuid4().hex[:12]}',
|
||||
content_type='weblink',
|
||||
url=web_url,
|
||||
duration=duration,
|
||||
description=description or web_url,
|
||||
uploaded_at=datetime.utcnow(),
|
||||
)
|
||||
db.session.add(content)
|
||||
db.session.flush() # assign content.id
|
||||
|
||||
# Append to the end of the playlist
|
||||
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
|
||||
|
||||
db.session.execute(
|
||||
playlist_content.insert().values(
|
||||
playlist_id=playlist_id,
|
||||
content_id=content.id,
|
||||
position=max_pos + 1,
|
||||
duration=duration,
|
||||
)
|
||||
)
|
||||
|
||||
playlist.increment_version()
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Added web link "{web_url}" to playlist "{playlist.name}"')
|
||||
flash('Web link added to playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding web link to playlist: {str(e)}')
|
||||
flash('Error adding web link to playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_content_from_playlist(playlist_id: int, content_id: int):
|
||||
@@ -277,6 +428,12 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
|
||||
(playlist_content.c.content_id == content_id)
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Web link items are playlist-specific and have no media-library
|
||||
# presence, so delete the orphan Content row when it is removed.
|
||||
content = db.session.get(Content, content_id)
|
||||
if content is not None and content.content_type == 'weblink':
|
||||
db.session.delete(content)
|
||||
|
||||
playlist.increment_version()
|
||||
db.session.commit()
|
||||
@@ -458,6 +615,56 @@ def update_playlist_content_edit_enabled(playlist_id: int, content_id: int):
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/update-duration/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def update_playlist_content_duration(playlist_id: int, content_id: int):
|
||||
"""Update content duration in playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
# Get duration from request
|
||||
try:
|
||||
duration = int(request.form.get('duration', 10))
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'success': False, 'message': 'Invalid duration value'}), 400
|
||||
|
||||
# Validate duration (minimum 1 second)
|
||||
if duration < 1:
|
||||
return jsonify({'success': False, 'message': 'Duration must be at least 1 second'}), 400
|
||||
|
||||
from app.models.playlist import playlist_content
|
||||
from sqlalchemy import update
|
||||
|
||||
# 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={duration}s for "{content.filename}" in playlist "{playlist.name}"')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Duration updated',
|
||||
'duration': duration,
|
||||
'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
|
||||
|
||||
|
||||
@content_bp.route('/upload-media-page')
|
||||
@login_required
|
||||
def upload_media_page():
|
||||
@@ -622,6 +829,7 @@ def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
||||
page_filename = os.path.basename(page_file)
|
||||
page_content = Content(
|
||||
filename=page_filename,
|
||||
original_filename=page_filename,
|
||||
content_type='image',
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(page_file)
|
||||
@@ -679,6 +887,7 @@ def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
||||
slide_filename = os.path.basename(slide_file)
|
||||
slide_content = Content(
|
||||
filename=slide_filename,
|
||||
original_filename=slide_filename,
|
||||
content_type='image',
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(slide_file)
|
||||
@@ -723,6 +932,7 @@ def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
||||
if processing_success and os.path.exists(filepath):
|
||||
content = Content(
|
||||
filename=filename,
|
||||
original_filename=filename,
|
||||
content_type=detected_type,
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(filepath)
|
||||
@@ -1106,6 +1316,7 @@ def upload_media():
|
||||
# Create content record for page
|
||||
page_content = Content(
|
||||
filename=page_filename,
|
||||
original_filename=page_filename,
|
||||
content_type='image',
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(page_file)
|
||||
@@ -1166,6 +1377,7 @@ def upload_media():
|
||||
# Create content record for slide
|
||||
slide_content = Content(
|
||||
filename=slide_filename,
|
||||
original_filename=slide_filename,
|
||||
content_type='image',
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(slide_file)
|
||||
@@ -1212,6 +1424,7 @@ def upload_media():
|
||||
if os.path.exists(filepath):
|
||||
content = Content(
|
||||
filename=filename,
|
||||
original_filename=filename,
|
||||
content_type=detected_type,
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(filepath)
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
"""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
|
||||
@@ -1,401 +0,0 @@
|
||||
"""Groups blueprint for group management and player assignments."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required
|
||||
from typing import List, Dict
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Group, Player, Content
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.group_player_management import get_player_status_info, get_group_statistics
|
||||
|
||||
groups_bp = Blueprint('groups', __name__, url_prefix='/groups')
|
||||
|
||||
|
||||
@groups_bp.route('/')
|
||||
@login_required
|
||||
def groups_list():
|
||||
"""Display list of all groups."""
|
||||
try:
|
||||
groups = Group.query.order_by(Group.name).all()
|
||||
|
||||
# Get statistics for each group
|
||||
group_stats = {}
|
||||
for group in groups:
|
||||
stats = get_group_statistics(group.id)
|
||||
group_stats[group.id] = stats
|
||||
|
||||
return render_template('groups/groups_list.html',
|
||||
groups=groups,
|
||||
group_stats=group_stats)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading groups list: {str(e)}')
|
||||
flash('Error loading groups list.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@groups_bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_group():
|
||||
"""Create a new group."""
|
||||
if request.method == 'GET':
|
||||
available_content = Content.query.order_by(Content.filename).all()
|
||||
return render_template('groups/create_group.html', available_content=available_content)
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
content_ids = request.form.getlist('content_ids')
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
flash('Group name must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('groups.create_group'))
|
||||
|
||||
# Check if group name exists
|
||||
existing_group = Group.query.filter_by(name=name).first()
|
||||
if existing_group:
|
||||
flash(f'Group "{name}" already exists.', 'warning')
|
||||
return redirect(url_for('groups.create_group'))
|
||||
|
||||
# Create group
|
||||
new_group = Group(
|
||||
name=name,
|
||||
description=description or None
|
||||
)
|
||||
|
||||
# Add content to group
|
||||
if content_ids:
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(int(content_id))
|
||||
if content:
|
||||
new_group.contents.append(content)
|
||||
|
||||
db.session.add(new_group)
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Group "{name}" created with {len(content_ids)} content items')
|
||||
flash(f'Group "{name}" created successfully.', 'success')
|
||||
|
||||
return redirect(url_for('groups.groups_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error creating group: {str(e)}')
|
||||
flash('Error creating group. Please try again.', 'danger')
|
||||
return redirect(url_for('groups.create_group'))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_group(group_id: int):
|
||||
"""Edit group details."""
|
||||
group = Group.query.get_or_404(group_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
available_content = Content.query.order_by(Content.filename).all()
|
||||
return render_template('groups/edit_group.html',
|
||||
group=group,
|
||||
available_content=available_content)
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
content_ids = request.form.getlist('content_ids')
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
flash('Group name must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('groups.edit_group', group_id=group_id))
|
||||
|
||||
# Check if group name exists (excluding current group)
|
||||
existing_group = Group.query.filter(Group.name == name, Group.id != group_id).first()
|
||||
if existing_group:
|
||||
flash(f'Group name "{name}" is already in use.', 'warning')
|
||||
return redirect(url_for('groups.edit_group', group_id=group_id))
|
||||
|
||||
# Update group
|
||||
group.name = name
|
||||
group.description = description or None
|
||||
|
||||
# Update content
|
||||
group.contents = []
|
||||
if content_ids:
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(int(content_id))
|
||||
if content:
|
||||
group.contents.append(content)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for all players in this group
|
||||
for player in group.players:
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
log_action('info', f'Group "{name}" (ID: {group_id}) updated')
|
||||
flash(f'Group "{name}" updated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('groups.groups_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating group: {str(e)}')
|
||||
flash('Error updating group. Please try again.', 'danger')
|
||||
return redirect(url_for('groups.edit_group', group_id=group_id))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_group(group_id: int):
|
||||
"""Delete a group."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
group_name = group.name
|
||||
|
||||
# Unassign players from group
|
||||
for player in group.players:
|
||||
player.group_id = None
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
db.session.delete(group)
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Group "{group_name}" (ID: {group_id}) deleted')
|
||||
flash(f'Group "{group_name}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting group: {str(e)}')
|
||||
flash('Error deleting group. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('groups.groups_list'))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/manage')
|
||||
@login_required
|
||||
def manage_group(group_id: int):
|
||||
"""Manage group with player status cards and content."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
|
||||
# Get all players in this group
|
||||
players = group.players.order_by(Player.name).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
|
||||
|
||||
# Get group content
|
||||
contents = group.contents.order_by(Content.position).all()
|
||||
|
||||
# Get available players (not in this group)
|
||||
available_players = Player.query.filter(
|
||||
(Player.group_id == None) | (Player.group_id != group_id)
|
||||
).order_by(Player.name).all()
|
||||
|
||||
# Get available content (not in this group)
|
||||
all_content = Content.query.order_by(Content.filename).all()
|
||||
|
||||
return render_template('groups/manage_group.html',
|
||||
group=group,
|
||||
players=players,
|
||||
player_statuses=player_statuses,
|
||||
contents=contents,
|
||||
available_players=available_players,
|
||||
all_content=all_content)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading manage group page: {str(e)}')
|
||||
flash('Error loading manage group page.', 'danger')
|
||||
return redirect(url_for('groups.groups_list'))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/fullscreen')
|
||||
def group_fullscreen(group_id: int):
|
||||
"""Display group fullscreen view with all player status cards."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
|
||||
# Get all players in this group
|
||||
players = group.players.order_by(Player.name).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('groups/group_fullscreen.html',
|
||||
group=group,
|
||||
players=players,
|
||||
player_statuses=player_statuses)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading group fullscreen: {str(e)}')
|
||||
return "Error loading group fullscreen", 500
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/add-player', methods=['POST'])
|
||||
@login_required
|
||||
def add_player_to_group(group_id: int):
|
||||
"""Add a player to a group."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
player_id = request.form.get('player_id')
|
||||
|
||||
if not player_id:
|
||||
flash('No player selected.', 'warning')
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
player = Player.query.get_or_404(int(player_id))
|
||||
player.group_id = group_id
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
log_action('info', f'Player "{player.name}" added to group "{group.name}"')
|
||||
flash(f'Player "{player.name}" added to group successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding player to group: {str(e)}')
|
||||
flash('Error adding player to group. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/remove-player/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_player_from_group(group_id: int, player_id: int):
|
||||
"""Remove a player from a group."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if player.group_id != group_id:
|
||||
flash('Player is not in this group.', 'warning')
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
player_name = player.name
|
||||
player.group_id = None
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache
|
||||
cache.delete_memoized('get_player_playlist', player_id)
|
||||
|
||||
log_action('info', f'Player "{player_name}" removed from group {group_id}')
|
||||
flash(f'Player "{player_name}" removed from group successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error removing player from group: {str(e)}')
|
||||
flash('Error removing player from group. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/add-content', methods=['POST'])
|
||||
@login_required
|
||||
def add_content_to_group(group_id: int):
|
||||
"""Add content to a group."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content_ids = request.form.getlist('content_ids')
|
||||
|
||||
if not content_ids:
|
||||
flash('No content selected.', 'warning')
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
# Add content
|
||||
added_count = 0
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(int(content_id))
|
||||
if content and content not in group.contents:
|
||||
group.contents.append(content)
|
||||
added_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for all players in this group
|
||||
for player in group.players:
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
log_action('info', f'{added_count} content items added to group "{group.name}"')
|
||||
flash(f'{added_count} content items added successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding content to group: {str(e)}')
|
||||
flash('Error adding content to group. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/remove-content/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_content_from_group(group_id: int, content_id: int):
|
||||
"""Remove content from a group."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if content not in group.contents:
|
||||
flash('Content is not in this group.', 'warning')
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
group.contents.remove(content)
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for all players in this group
|
||||
for player in group.players:
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
log_action('info', f'Content "{content.filename}" removed from group "{group.name}"')
|
||||
flash('Content removed from group successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error removing content from group: {str(e)}')
|
||||
flash('Error removing content from group. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/reorder-content', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_group_content(group_id: int):
|
||||
"""Reorder content within a group."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content_order = request.json.get('order', [])
|
||||
|
||||
# Update positions
|
||||
for idx, content_id in enumerate(content_order):
|
||||
content = Content.query.get(content_id)
|
||||
if content and content in group.contents:
|
||||
content.position = idx
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for all players in this group
|
||||
for player in group.players:
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
log_action('info', f'Content reordered for group "{group.name}"')
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error reordering group content: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/stats')
|
||||
@login_required
|
||||
def group_stats(group_id: int):
|
||||
"""Get group statistics as JSON."""
|
||||
try:
|
||||
stats = get_group_statistics(group_id)
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting group stats: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
+47
-3
@@ -33,15 +33,59 @@ def dashboard():
|
||||
storage_mb += os.path.getsize(filepath)
|
||||
storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB
|
||||
|
||||
server_logs = get_recent_logs(20)
|
||||
|
||||
# Recent Activity.
|
||||
#
|
||||
# Player feedback ("Feedback received from ...") is a ~15s heartbeat: it is
|
||||
# ~84% of all log rows, and 18 of the newest 20. Showing it unfiltered
|
||||
# buries the events an operator actually cares about (uploads, logins,
|
||||
# HTTPS changes, deployments), so the two kinds are queried separately and
|
||||
# the card defaults to real activity, with heartbeats available on demand.
|
||||
#
|
||||
# The split is done in SQL rather than by filtering one wide window in
|
||||
# Python: with a fleet of players the heartbeat rate is high enough that a
|
||||
# fixed window would return mostly noise and only a handful of real events.
|
||||
HEARTBEAT_PREFIX = 'Feedback received from'
|
||||
activity_logs = get_recent_logs(25, exclude_prefix=HEARTBEAT_PREFIX)
|
||||
heartbeat_logs = get_recent_logs(25, include_prefix=HEARTBEAT_PREFIX)
|
||||
|
||||
# Per-playlist overview for the dashboard: which playlist holds what, and
|
||||
# which players consume it. This answers "where should I add media / which
|
||||
# playlist needs editing?" without opening each playlist.
|
||||
#
|
||||
# Players are fetched in a single query and grouped in Python rather than
|
||||
# relying on Playlist.players per row, to avoid one query per playlist.
|
||||
playlists = Playlist.query.order_by(Playlist.name).all()
|
||||
players_by_playlist = {}
|
||||
for player in Player.query.order_by(Player.name).all():
|
||||
if player.playlist_id is not None:
|
||||
players_by_playlist.setdefault(player.playlist_id, []).append(player)
|
||||
|
||||
playlist_overview = []
|
||||
for playlist in playlists:
|
||||
assigned = players_by_playlist.get(playlist.id, [])
|
||||
playlist_overview.append({
|
||||
'playlist': playlist,
|
||||
'content_count': playlist.contents.count(),
|
||||
'total_duration': playlist.total_duration,
|
||||
'players': assigned,
|
||||
'player_count': len(assigned),
|
||||
'online_count': sum(1 for p in assigned if p.is_online),
|
||||
})
|
||||
|
||||
# Players with no playlist produce no content on screen, so surface them.
|
||||
unassigned_players = Player.query.filter(Player.playlist_id.is_(None))\
|
||||
.order_by(Player.name).all()
|
||||
|
||||
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
|
||||
recent_logs=activity_logs,
|
||||
heartbeat_logs=heartbeat_logs,
|
||||
playlist_overview=playlist_overview,
|
||||
unassigned_players=unassigned_players
|
||||
)
|
||||
|
||||
|
||||
|
||||
+191
-110
@@ -1,5 +1,5 @@
|
||||
"""Players blueprint for player management and display."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
|
||||
from flask_login import login_required
|
||||
from werkzeug.security import generate_password_hash
|
||||
import secrets
|
||||
@@ -28,10 +28,23 @@ def list():
|
||||
status_info = get_player_status_info(player.id)
|
||||
player_statuses[player.id] = status_info
|
||||
|
||||
# Build a JSON-safe dict of deployment statuses for the polling JS
|
||||
import json
|
||||
from datetime import datetime as dt
|
||||
player_statuses_json = {}
|
||||
for player in players:
|
||||
player_statuses_json[str(player.id)] = {
|
||||
'deployment_status': player.deployment_status,
|
||||
'last_deployment_status': player.last_deployment_status,
|
||||
'last_deployment_at': player.last_deployment_at.isoformat() if player.last_deployment_at else None,
|
||||
'last_deployment_message': player.last_deployment_message,
|
||||
}
|
||||
|
||||
return render_template('players/players_list.html',
|
||||
players=players,
|
||||
playlists=playlists,
|
||||
player_statuses=player_statuses)
|
||||
player_statuses=player_statuses,
|
||||
player_statuses_json=json.dumps(player_statuses_json))
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading players list: {str(e)}')
|
||||
flash('Error loading players list.', 'danger')
|
||||
@@ -41,7 +54,7 @@ def list():
|
||||
@players_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_player():
|
||||
"""Add a new player."""
|
||||
"""Add a new player with optional SSH deployment."""
|
||||
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)
|
||||
@@ -55,6 +68,13 @@ def add_player():
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
playlist_id = request.form.get('playlist_id', '').strip()
|
||||
|
||||
# Get SSH deployment info if provided
|
||||
ssh_hostname = request.form.get('ssh_hostname', '').strip()
|
||||
ssh_username = request.form.get('ssh_username', '').strip()
|
||||
ssh_password = request.form.get('ssh_password', '').strip()
|
||||
ssh_port = int(request.form.get('ssh_port', '22')) if request.form.get('ssh_port') else 22
|
||||
deploy_player = request.form.get('deploy_player', '').strip()
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
flash('Player name must be at least 3 characters long.', 'warning')
|
||||
@@ -102,15 +122,101 @@ def add_player():
|
||||
|
||||
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
||||
|
||||
# If deployment requested and SSH credentials provided, trigger background deployment
|
||||
deployment_initiated = False
|
||||
if deploy_player and ssh_hostname and ssh_username and ssh_password:
|
||||
try:
|
||||
from app.utils.background_tasks import background_player_deployment, run_background_task
|
||||
|
||||
# Determine the server address the player should contact.
|
||||
from flask import request as flask_request
|
||||
from app.models.https_config import HTTPSConfig
|
||||
|
||||
server_url = None
|
||||
try:
|
||||
import os
|
||||
from app.utils.player_build import get_player_server_settings, BUILD_META_FILENAME
|
||||
meta_path = os.path.join(current_app.instance_path, BUILD_META_FILENAME)
|
||||
build_srv = get_player_server_settings(meta_path)
|
||||
if build_srv:
|
||||
scheme = 'https' if build_srv['use_https'] else 'http'
|
||||
server_url = f"{scheme}://{build_srv['server_ip']}:{build_srv['port']}"
|
||||
except Exception:
|
||||
server_url = None
|
||||
|
||||
if not server_url:
|
||||
https_cfg = HTTPSConfig.get_config()
|
||||
if https_cfg and https_cfg.https_enabled and (https_cfg.domain or https_cfg.ip_address):
|
||||
host = https_cfg.domain or https_cfg.ip_address
|
||||
cfg_port = https_cfg.port or 443
|
||||
server_url = f"https://{host}:{cfg_port}"
|
||||
else:
|
||||
host = flask_request.host
|
||||
hostname_only = host.split(':')[0]
|
||||
if hostname_only in ('localhost', '127.0.0.1', '') or hostname_only.startswith('127.'):
|
||||
from app.utils.ssh_deploy import detect_server_ip
|
||||
detected_ip = detect_server_ip()
|
||||
if detected_ip:
|
||||
port_part = host.split(':', 1)[1] if ':' in host else ''
|
||||
host = f"{detected_ip}:{port_part}" if port_part else detected_ip
|
||||
server_url = f"{flask_request.scheme}://{host}"
|
||||
|
||||
# Mark deployment as "in progress" immediately so the UI polling picks it up
|
||||
from datetime import datetime
|
||||
new_player.deployment_status = 'deploying'
|
||||
new_player.last_deployment_at = datetime.utcnow()
|
||||
new_player.last_deployment_message = 'Deployment in progress...'
|
||||
db.session.commit()
|
||||
|
||||
# Generate API key for player authentication
|
||||
import hashlib
|
||||
api_key = hashlib.sha256(f'{name}:{hostname}'.encode()).hexdigest()[:32]
|
||||
|
||||
# Start deployment in background thread
|
||||
run_background_task(
|
||||
background_player_deployment,
|
||||
hostname=ssh_hostname,
|
||||
username=ssh_username,
|
||||
password=ssh_password,
|
||||
player_name=name,
|
||||
player_id=new_player.id,
|
||||
port=ssh_port,
|
||||
server_url=server_url,
|
||||
server_api_key=api_key,
|
||||
player_hostname=hostname,
|
||||
quickconnect_code=quickconnect_code,
|
||||
orientation=orientation
|
||||
)
|
||||
deployment_initiated = True
|
||||
log_action('info', f'Background deployment initiated for player "{name}" on {ssh_hostname}')
|
||||
except Exception as deploy_err:
|
||||
log_action('error', f'Failed to initiate background deployment for player "{name}": {str(deploy_err)}')
|
||||
|
||||
# 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')
|
||||
if deployment_initiated:
|
||||
success_msg = f'''
|
||||
Player "{name}" created successfully!<br>
|
||||
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
||||
<strong>Hostname:</strong> {hostname}<br>
|
||||
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||
<br>
|
||||
<strong style="color: #0275d8;">⌛ Deployment in Progress</strong><br>
|
||||
Deploying to <strong>{ssh_hostname}</strong> in background...<br>
|
||||
<small>The deployment status will update automatically on the players list.</small>
|
||||
'''
|
||||
flash(success_msg, 'success')
|
||||
else:
|
||||
success_msg = f'''
|
||||
Player "{name}" created successfully!<br>
|
||||
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
||||
<strong>Hostname:</strong> {hostname}<br>
|
||||
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||
<br>
|
||||
<small>Configure the player with these credentials in app_config.json</small>
|
||||
'''
|
||||
flash(success_msg, 'success')
|
||||
if deploy_player:
|
||||
flash('Player was created but deployment could not be started. You can deploy manually from the Manage page.', 'warning')
|
||||
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
@@ -343,6 +449,8 @@ def edited_media(player_id: int):
|
||||
|
||||
# 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()
|
||||
@@ -355,16 +463,61 @@ def edited_media(player_id: int):
|
||||
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)
|
||||
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>/edited-media-report')
|
||||
@login_required
|
||||
def edited_media_report(player_id: int):
|
||||
"""Display a tabular report of all edited media from this player."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
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()
|
||||
|
||||
# Build user display name mapping
|
||||
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 and player_user.user_name:
|
||||
user_mappings[edit.user] = player_user.user_name
|
||||
else:
|
||||
user_mappings[edit.user] = edit.user
|
||||
|
||||
return render_template('players/edited_media_report.html',
|
||||
player=player,
|
||||
edited_media=edited_media,
|
||||
user_mappings=user_mappings)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading edited media report for player {player_id}: {str(e)}')
|
||||
flash('Error loading edited media report.', '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)."""
|
||||
@@ -413,27 +566,25 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
||||
# Build playlist
|
||||
playlist = []
|
||||
for content in ordered_content:
|
||||
# For weblinks, serve the actual URL directly; for files, serve static path
|
||||
if content.content_type == 'weblink' and content.url:
|
||||
item_url = content.url
|
||||
else:
|
||||
item_url = url_for('static', filename=f'uploads/{content.filename}')
|
||||
playlist.append({
|
||||
'id': content.id,
|
||||
'url': url_for('static', filename=f'uploads/{content.filename}'),
|
||||
'url': item_url,
|
||||
'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),
|
||||
'audio': 'off' if getattr(content, '_playlist_muted', True) else 'on',
|
||||
'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'])
|
||||
@@ -505,95 +656,25 @@ def bulk_assign_playlist():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/playlist/reorder', methods=['POST'])
|
||||
@players_bp.route('/deployment-status')
|
||||
@login_required
|
||||
def reorder_playlist(player_id: int):
|
||||
"""Reorder items in player's playlist."""
|
||||
def deployment_status():
|
||||
"""Return deployment status for all players (used by polling JS)."""
|
||||
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})
|
||||
|
||||
players = Player.query.with_entities(
|
||||
Player.id, Player.deployment_status,
|
||||
Player.last_deployment_status, Player.last_deployment_at,
|
||||
Player.last_deployment_message
|
||||
).all()
|
||||
data = {}
|
||||
for p in players:
|
||||
data[p.id] = {
|
||||
'deployment_status': p.deployment_status,
|
||||
'last_deployment_status': p.last_deployment_status,
|
||||
'last_deployment_at': p.last_deployment_at.isoformat() if p.last_deployment_at else None,
|
||||
'last_deployment_message': p.last_deployment_message,
|
||||
}
|
||||
return jsonify(data)
|
||||
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
|
||||
|
||||
log_action('error', f'Error fetching deployment status: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
"""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):
|
||||
"""Manage playlist for a specific player."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Get content from player's assigned playlist
|
||||
playlist_items = []
|
||||
if player.playlist_id:
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
if playlist:
|
||||
playlist_items = playlist.get_content_ordered()
|
||||
|
||||
# Get available content (all content not in current playlist)
|
||||
all_content = Content.query.all()
|
||||
playlist_content_ids = {item.id for item in playlist_items}
|
||||
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
||||
|
||||
return render_template('playlist/manage_playlist.html',
|
||||
player=player,
|
||||
playlist_content=playlist_items,
|
||||
available_content=available_content)
|
||||
|
||||
|
||||
@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))
|
||||
+10
-3
@@ -29,6 +29,11 @@ class Config:
|
||||
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
|
||||
|
||||
@@ -43,6 +48,10 @@ class Config:
|
||||
DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin')
|
||||
DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!')
|
||||
|
||||
# Player deployment — staged code directory and default repo
|
||||
PLAYER_CODE_DIR = os.getenv('PLAYER_CODE_DIR', '/app/data/player')
|
||||
PLAYER_REPO_URL = os.getenv('PLAYER_REPO_URL', '')
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration"""
|
||||
@@ -86,10 +95,8 @@ class ProductionConfig(Config):
|
||||
|
||||
# Security
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
# Rate Limiting
|
||||
RATELIMIT_STORAGE_URL = f"redis://{os.getenv('REDIS_HOST', 'redis')}:6379/1"
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
|
||||
@@ -7,6 +7,7 @@ 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()
|
||||
@@ -14,6 +15,7 @@ bcrypt = Bcrypt()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
cache = Cache()
|
||||
cors = CORS()
|
||||
|
||||
# Configure login manager
|
||||
login_manager.login_view = 'auth.login'
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
"""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',
|
||||
'group_content',
|
||||
'PlayerUser',
|
||||
'HTTPSConfig',
|
||||
'playlist_content',
|
||||
]
|
||||
|
||||
+53
-5
@@ -21,9 +21,16 @@ class Content(db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||
# The pristine original filename as uploaded. `filename` gets overwritten
|
||||
# with an edited_media/... path when a player edits the content, so this
|
||||
# column preserves the original so it can always be referenced/restored.
|
||||
original_filename = db.Column(db.String(255), nullable=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)
|
||||
# For 'weblink' content this holds the web page URL to display on the player.
|
||||
# NULL for file-based content (image/video/pdf).
|
||||
url = db.Column(db.String(2048), nullable=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
nullable=False, index=True)
|
||||
@@ -31,8 +38,6 @@ class Content(db.Model):
|
||||
# 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."""
|
||||
@@ -46,9 +51,26 @@ class Content(db.Model):
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def group_count(self) -> int:
|
||||
"""Get number of groups containing this content."""
|
||||
return self.groups.count()
|
||||
def original_display_name(self) -> str:
|
||||
"""Name of the original (unedited) file for display purposes."""
|
||||
return self.original_filename or self.filename
|
||||
|
||||
@property
|
||||
def original_media_path(self) -> str:
|
||||
"""Path (relative to uploads/) where the original unedited file lives.
|
||||
|
||||
When a player edits content the original file is archived under
|
||||
edited_media/<id>/original_<name>. If never edited, the original IS the
|
||||
current file.
|
||||
"""
|
||||
if self.original_filename and self.original_filename != self.filename:
|
||||
return f"edited_media/{self.id}/original_{self.original_filename}"
|
||||
return self.filename
|
||||
|
||||
@property
|
||||
def current_media_path(self) -> str:
|
||||
"""Path (relative to uploads/) of the current/latest version to display."""
|
||||
return self.filename
|
||||
|
||||
def is_image(self) -> bool:
|
||||
"""Check if content is an image."""
|
||||
@@ -61,3 +83,29 @@ class Content(db.Model):
|
||||
def is_pdf(self) -> bool:
|
||||
"""Check if content is a PDF."""
|
||||
return self.content_type == 'pdf'
|
||||
|
||||
def is_weblink(self) -> bool:
|
||||
"""Check if content is a web link (URL) rather than an uploaded file."""
|
||||
return self.content_type == 'weblink'
|
||||
|
||||
@property
|
||||
def has_player_edits(self) -> bool:
|
||||
"""Whether this content has been edited on a player."""
|
||||
return self.edits.count() > 0
|
||||
|
||||
@property
|
||||
def original_display_name(self) -> str:
|
||||
"""Display name of the original (unedited) file."""
|
||||
return self.original_filename or self.filename
|
||||
|
||||
@property
|
||||
def original_media_path(self) -> str:
|
||||
"""uploads/-relative path of the pristine original file.
|
||||
|
||||
After a player edit the original is archived at
|
||||
edited_media/<id>/original_<name>; before any edit it is simply the
|
||||
current file.
|
||||
"""
|
||||
if self.original_filename and self.filename != self.original_filename:
|
||||
return f"edited_media/{self.id}/original_{self.original_filename}"
|
||||
return self.filename
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class Player(db.Model):
|
||||
orientation: Display orientation (Landscape/Portrait)
|
||||
status: Current player status (online, offline, error)
|
||||
last_seen: Last activity timestamp
|
||||
playlist_version: Version number for playlist synchronization
|
||||
playlist_id: Assigned playlist (sync version comes from Playlist.version)
|
||||
created_at: Player creation timestamp
|
||||
"""
|
||||
__tablename__ = 'player'
|
||||
@@ -41,6 +41,12 @@ class Player(db.Model):
|
||||
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
||||
nullable=True, index=True)
|
||||
|
||||
# Deployment tracking
|
||||
deployment_status = db.Column(db.String(50), default='pending', nullable=True) # pending, deployed, failed
|
||||
last_deployment_at = db.Column(db.DateTime, nullable=True)
|
||||
last_deployment_status = db.Column(db.String(50), nullable=True) # success, failed
|
||||
last_deployment_message = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
playlist = db.relationship('Playlist', back_populates='players')
|
||||
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -48,7 +48,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management Card -->
|
||||
{% 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>
|
||||
@@ -58,6 +59,18 @@
|
||||
</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">
|
||||
@@ -70,7 +83,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Dependencies Card -->
|
||||
{% 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>
|
||||
@@ -81,7 +95,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo Customization Card -->
|
||||
<!-- Build Player Files Card (Admin Only) -->
|
||||
<div class="card management-card" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">
|
||||
<h2>🚀 Build Player Files</h2>
|
||||
<p>Clone/refresh the player code from Gitea and configure the server address for deployment</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.build_player') }}" class="btn btn-primary">
|
||||
Build Player Files
|
||||
</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>
|
||||
@@ -92,6 +117,18 @@
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Build Player Files - 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>🧰 Build Player Files for Deployment</h1>
|
||||
<p style="color: #6c757d; margin-top: 6px;">
|
||||
Pull the latest player source from a repository onto this server and bake the
|
||||
configuration it needs to talk to this server. SSH deployments ship exactly
|
||||
this staged copy, so the version you build here is what players run.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Current staged code status -->
|
||||
<div class="card status-card">
|
||||
<h2>Current Staged Player Code</h2>
|
||||
{% if code_status.available %}
|
||||
<p><span class="badge badge-success">✅ Ready</span></p>
|
||||
<ul style="line-height: 1.8;">
|
||||
<li><strong>Version (git):</strong> <code>{{ code_status.version }}</code></li>
|
||||
<li><strong>Size:</strong> {{ code_status.size }}</li>
|
||||
{% if code_status.updated_str %}
|
||||
<li><strong>Last updated:</strong> {{ code_status.updated_str }}</li>
|
||||
{% endif %}
|
||||
<li><strong>Path:</strong> <code>{{ code_status.path }}</code></li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>
|
||||
<span class="badge badge-warning">⚠️ Not staged</span>
|
||||
{{ code_status.reason }}
|
||||
</p>
|
||||
<p style="color: #6c757d;">Path: <code>{{ code_status.path }}</code></p>
|
||||
{% endif %}
|
||||
{% if settings.built_at %}
|
||||
<p style="color: #6c757d; margin-top: 8px;">
|
||||
Last build saved: {{ settings.built_at }}
|
||||
{% if settings.built_by %}by {{ settings.built_by }}{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Live build progress (updated by polling below) -->
|
||||
<div class="card" id="build-progress" style="display: none;">
|
||||
<h2>Build progress</h2>
|
||||
<p id="build-progress-text" style="margin: 0;">
|
||||
<span class="badge badge-warning" id="build-progress-badge">⏳ Running</span>
|
||||
<span id="build-progress-step"></span>
|
||||
</p>
|
||||
<p id="build-progress-message" style="color: #6c757d; margin-top: 8px;"></p>
|
||||
<div style="margin-top: 10px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="window.location.reload()">
|
||||
🔄 Refresh page
|
||||
</button>
|
||||
</div>
|
||||
<p style="color: #6c757d; font-size: 13px; margin-top: 10px;">
|
||||
The clone takes a couple of minutes. You can leave this page and come back.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.build_player_action') }}">
|
||||
<!-- Repository -->
|
||||
<div class="card">
|
||||
<h2>1. Player Files Repository</h2>
|
||||
<div class="form-group">
|
||||
<label for="repo_url">Git Repository URL</label>
|
||||
<input type="text" id="repo_url" name="repo_url" class="form-control"
|
||||
value="{{ settings.repo_url }}"
|
||||
placeholder="https://gitea.example.com/org/Kiwy-Signage.git">
|
||||
<small style="color: #6c757d;">Cloned/refreshed into the staged directory on this server.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="branch">Branch</label>
|
||||
<input type="text" id="branch" name="branch" class="form-control"
|
||||
value="{{ settings.branch }}" placeholder="main">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server configuration -->
|
||||
<div class="card">
|
||||
<h2>2. Player → Server Configuration</h2>
|
||||
<p style="color: #6c757d;">
|
||||
The player contacts the DigiServer API directly at
|
||||
<code>{scheme}://{server}:{port}/api/...</code> (no <code>/digiserver</code> path).
|
||||
Enter the address players can reach this server on.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="server_ip">Server IP / Domain</label>
|
||||
<input type="text" id="server_ip" name="server_ip" class="form-control"
|
||||
value="{{ settings.server_ip }}" placeholder="signage.example.com or 192.168.0.50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="port">Port</label>
|
||||
<input type="number" id="port" name="port" class="form-control"
|
||||
value="{{ settings.port }}" min="1" max="65535">
|
||||
<small style="color: #6c757d;">Use 443 for HTTPS, 80 for plain HTTP, or your custom port (e.g. 8080).</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" name="use_https" {% if settings.use_https %}checked{% endif %}>
|
||||
Use HTTPS
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" name="verify_ssl" {% if settings.verify_ssl %}checked{% endif %}>
|
||||
Verify SSL certificate
|
||||
</label>
|
||||
<small style="color: #6c757d;">Leave off for self-signed certificates.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="orientation">Orientation</label>
|
||||
<select id="orientation" name="orientation" class="form-control">
|
||||
<option value="Landscape" {% if settings.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
||||
<option value="Portrait" {% if settings.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="max_resolution">Max Resolution</label>
|
||||
<input type="text" id="max_resolution" name="max_resolution" class="form-control"
|
||||
value="{{ settings.max_resolution }}" placeholder="1920x1080">
|
||||
</div>
|
||||
<p style="color: #6c757d; font-size: 13px;">
|
||||
ℹ️ The per-player <strong>screen name</strong> and <strong>quick-connect code</strong>
|
||||
are filled in automatically for each device during SSH deployment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card">
|
||||
<h2>3. Build</h2>
|
||||
<div class="card-actions" style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button type="submit" name="action" value="build_and_config"
|
||||
class="btn btn-primary" id="btn-build-all">
|
||||
⬇️ Build files & write config
|
||||
</button>
|
||||
<button type="submit" name="action" value="build_files"
|
||||
class="btn btn-secondary" id="btn-build-files">
|
||||
Build files only
|
||||
</button>
|
||||
<button type="submit" name="action" value="save_config" class="btn btn-secondary">
|
||||
Write config only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Poll the build status so the admin sees live progress instead of a request
|
||||
// that appears to hang (the clone takes ~1-2 minutes).
|
||||
(function () {
|
||||
const statusUrl = "{{ url_for('admin.build_player_status') }}";
|
||||
const panel = document.getElementById('build-progress');
|
||||
const badge = document.getElementById('build-progress-badge');
|
||||
const step = document.getElementById('build-progress-step');
|
||||
const message = document.getElementById('build-progress-message');
|
||||
const bAll = document.getElementById('btn-build-all');
|
||||
const bFiles = document.getElementById('btn-build-files');
|
||||
let sawRunning = false;
|
||||
|
||||
function render(s) {
|
||||
const state = s.state || 'idle';
|
||||
|
||||
if (state === 'running') {
|
||||
sawRunning = true;
|
||||
panel.style.display = 'block';
|
||||
badge.className = 'badge badge-warning';
|
||||
badge.textContent = '⏳ Running';
|
||||
step.textContent = s.step || '';
|
||||
message.textContent = '';
|
||||
if (bAll) { bAll.disabled = true; bAll.textContent = '⏳ Building…'; }
|
||||
if (bFiles) { bFiles.disabled = true; }
|
||||
return;
|
||||
}
|
||||
|
||||
// Reached a terminal state.
|
||||
if (state === 'success' || state === 'error') {
|
||||
panel.style.display = 'block';
|
||||
if (state === 'success') {
|
||||
badge.className = 'badge badge-success';
|
||||
badge.textContent = '✅ Build complete';
|
||||
} else {
|
||||
badge.className = 'badge badge-danger';
|
||||
badge.textContent = '❌ Build failed';
|
||||
}
|
||||
step.textContent = '';
|
||||
message.textContent = s.message || '';
|
||||
if (sawRunning) {
|
||||
// Reload once so the staged-version panel reflects the new build.
|
||||
setTimeout(function () { window.location.reload(); }, 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch(statusUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (s) { if (s) render(s); })
|
||||
.catch(function () { /* transient — keep polling */ });
|
||||
}
|
||||
|
||||
poll();
|
||||
setInterval(poll, 3000);
|
||||
})();
|
||||
</script>
|
||||
{% 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,596 @@
|
||||
{% 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>
|
||||
|
||||
<!-- Caddy Reverse Proxy Status Card -->
|
||||
<div class="card nginx-status-card">
|
||||
<h2>🔧 Caddy Reverse Proxy Status</h2>
|
||||
<div class="nginx-status-content">
|
||||
<div class="status-item">
|
||||
<strong>Reverse Proxy:</strong>
|
||||
<span class="badge badge-success">✅ Caddy</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<strong>SSL/TLS:</strong>
|
||||
{% if config and config.https_enabled %}
|
||||
<span class="badge badge-success">🔒 Enabled — auto-managed by Caddy</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">⚠️ HTTP only — enable HTTPS above</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<strong>Certificate Provider:</strong>
|
||||
<code>Let's Encrypt (automatic)</code>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<strong>Admin API:</strong>
|
||||
<code>caddy:2019</code>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<strong>Upstream:</strong>
|
||||
<code>digiserver-app:5000</code>
|
||||
</div>
|
||||
</div>
|
||||
</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 %}
|
||||
@@ -181,7 +181,7 @@
|
||||
<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 }}"
|
||||
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ logo_version }}"
|
||||
alt="DigiServer Logo"
|
||||
onerror="this.style.display='none';">
|
||||
</div>
|
||||
|
||||
@@ -376,7 +376,7 @@
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>
|
||||
<img src="{{ url_for('static', filename='uploads/header_logo.png') }}" alt="DigiServer" style="height: 32px; width: auto;" onerror="this.src='{{ url_for('static', filename='icons/monitor.svg') }}'; this.style.filter='brightness(0) invert(1)'; this.style.width='28px'; this.style.height='28px';">
|
||||
<img src="{{ url_for('static', filename='uploads/header_logo.png') }}?v={{ logo_version }}" alt="DigiServer" style="height: 32px; width: auto; margin-right: 8px;" onerror="this.style.display='none';" onload="this.style.display='inline';">
|
||||
DigiServer
|
||||
</h1>
|
||||
<nav>
|
||||
@@ -393,9 +393,7 @@
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="">
|
||||
Playlists
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
|
||||
{% endif %}
|
||||
<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">
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -326,11 +326,11 @@
|
||||
<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 }}">
|
||||
<div class="media-item" title="{{ media.original_display_name }}">
|
||||
{% 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 }}"
|
||||
<img src="{{ url_for('static', filename='uploads/' + media.current_media_path) }}"
|
||||
alt="{{ media.original_display_name }}"
|
||||
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>
|
||||
@@ -347,7 +347,7 @@
|
||||
📁
|
||||
</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 class="media-name" style="font-size: 11px; line-height: 1.3;">{{ media.original_display_name[:25] }}{% if media.original_display_name|length > 25 %}...{% endif %}</div>
|
||||
<div style="font-size: 10px; color: #999; margin-top: 4px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -97,6 +97,67 @@
|
||||
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;
|
||||
}
|
||||
@@ -154,6 +215,36 @@
|
||||
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;">
|
||||
@@ -223,14 +314,41 @@
|
||||
</td>
|
||||
<td><span class="drag-handle">⋮⋮</span></td>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ content.filename }}</td>
|
||||
<td>
|
||||
{% if content.content_type == 'weblink' %}
|
||||
<a href="{{ content.url }}" target="_blank" rel="noopener noreferrer">{{ content.url }}</a>
|
||||
{% else %}
|
||||
{{ content.original_display_name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if content.content_type == 'image' %}📷 Image
|
||||
{% elif content.content_type == 'video' %}🎥 Video
|
||||
{% elif content.content_type == 'pdf' %}📄 PDF
|
||||
{% elif content.content_type == 'weblink' %}🔗 Web Link
|
||||
{% else %}📁 Other{% endif %}
|
||||
</td>
|
||||
<td>{{ content._playlist_duration or content.duration }}s</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">
|
||||
@@ -290,6 +408,22 @@
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 20px;">➕ Add Content</h2>
|
||||
|
||||
<div style="margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e0e0e0;">
|
||||
<h3 style="margin-bottom: 12px; font-size: 16px;">🔗 Add Web Link</h3>
|
||||
<form method="POST"
|
||||
action="{{ url_for('content.add_weblink_to_playlist', playlist_id=playlist.id) }}">
|
||||
<input type="url" name="url" required
|
||||
placeholder="https://example.com/dashboard"
|
||||
style="width: 100%; padding: 8px; margin-bottom: 8px; box-sizing: border-box;">
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<label style="font-size: 13px; color: #666;">Duration (s):</label>
|
||||
<input type="number" name="duration" value="30" min="1"
|
||||
style="width: 80px; padding: 6px;">
|
||||
<button type="submit" class="btn btn-primary btn-sm">+ Add Link</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if available_content %}
|
||||
<div class="available-content">
|
||||
{% for content in available_content %}
|
||||
@@ -300,7 +434,7 @@
|
||||
{% elif content.content_type == 'video' %}🎥
|
||||
{% elif content.content_type == 'pdf' %}📄
|
||||
{% else %}📁{% endif %}
|
||||
{{ content.filename }}
|
||||
{{ content.original_display_name }}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #999;">
|
||||
{{ content.file_size_mb }} MB
|
||||
@@ -413,6 +547,58 @@ function saveOrder() {
|
||||
});
|
||||
}
|
||||
|
||||
// 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 }};
|
||||
|
||||
@@ -275,11 +275,11 @@
|
||||
<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 }}"
|
||||
<img src="{{ url_for('static', filename='uploads/' + media.current_media_path) }}"
|
||||
alt="{{ media.original_display_name }}"
|
||||
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-filename" title="{{ media.original_display_name }}">{{ media.original_display_name }}</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>
|
||||
@@ -308,7 +308,7 @@
|
||||
<div class="media-thumbnail">
|
||||
<span class="media-icon">🎥</span>
|
||||
</div>
|
||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||
<div class="media-filename" title="{{ media.original_display_name }}">{{ media.original_display_name }}</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>
|
||||
@@ -337,7 +337,7 @@
|
||||
<div class="media-thumbnail">
|
||||
<span class="media-icon">📄</span>
|
||||
</div>
|
||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||
<div class="media-filename" title="{{ media.original_display_name }}">{{ media.original_display_name }}</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>
|
||||
@@ -366,7 +366,7 @@
|
||||
<div class="media-thumbnail">
|
||||
<span class="media-icon">📊</span>
|
||||
</div>
|
||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||
<div class="media-filename" title="{{ media.original_display_name }}">{{ media.original_display_name }}</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>
|
||||
@@ -395,7 +395,7 @@
|
||||
<div class="media-thumbnail">
|
||||
<span class="media-icon">📁</span>
|
||||
</div>
|
||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||
<div class="media-filename" title="{{ media.original_display_name }}">{{ media.original_display_name }}</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>
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -317,6 +317,66 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ── Web Link Card ─────────────────────────────────────────────────────── -->
|
||||
<div class="card" style="margin-top: 20px;">
|
||||
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
|
||||
🌐 Add Web Page Link
|
||||
</h2>
|
||||
<p style="color: #6c757d; font-size: 13px; margin-bottom: 16px;">
|
||||
Add a website URL to display on the player (e.g. a dashboard, live feed, or any public web page).
|
||||
</p>
|
||||
|
||||
<form id="weblink-form" method="POST" action="{{ request.script_root }}/content/add-weblink">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
||||
|
||||
<!-- URL -->
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="wl-url">Web Page URL <span style="color:#e53e3e;">*</span></label>
|
||||
<input type="url" id="wl-url" name="url" class="form-control"
|
||||
placeholder="https://example.com" required>
|
||||
<small style="color:#6c757d; font-size:11px;">Must start with http:// or https://</small>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group">
|
||||
<label for="wl-description">Label / Description</label>
|
||||
<input type="text" id="wl-description" name="description" class="form-control"
|
||||
placeholder="e.g. Live Dashboard">
|
||||
<small style="color:#6c757d; font-size:11px;">Optional — shown in the media library</small>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="form-group">
|
||||
<label for="wl-duration">Display Duration (seconds)</label>
|
||||
<input type="number" id="wl-duration" name="duration" class="form-control"
|
||||
value="30" min="5" max="3600">
|
||||
<small style="color:#6c757d; font-size:11px;">How long to show the page per loop</small>
|
||||
</div>
|
||||
|
||||
<!-- Playlist -->
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="wl-playlist">Add to Playlist (Optional)</label>
|
||||
<select id="wl-playlist" name="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:12px; margin-top:8px;">
|
||||
<button type="submit" class="btn-upload" id="wl-submit-btn"
|
||||
style="display:inline-flex; align-items:center; gap:0.5rem; padding:10px 24px;">
|
||||
🌐 Add Web Link
|
||||
</button>
|
||||
<span id="wl-status" style="font-size:13px; display:none;"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const uploadZone = document.getElementById('upload-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
@@ -509,6 +569,44 @@
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '⏳ Uploading...';
|
||||
});
|
||||
|
||||
// ── Web Link form — AJAX submit ────────────────────────────────────────
|
||||
const wlForm = document.getElementById('weblink-form');
|
||||
const wlBtn = document.getElementById('wl-submit-btn');
|
||||
const wlStatus = document.getElementById('wl-status');
|
||||
|
||||
wlForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
wlBtn.disabled = true;
|
||||
wlBtn.textContent = '⏳ Adding…';
|
||||
wlStatus.style.display = 'none';
|
||||
|
||||
try {
|
||||
const resp = await fetch(wlForm.action, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: new FormData(wlForm),
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
wlStatus.style.color = '#38a169';
|
||||
wlStatus.textContent = '✓ ' + data.message;
|
||||
wlForm.reset();
|
||||
document.getElementById('wl-duration').value = 30;
|
||||
} else {
|
||||
wlStatus.style.color = '#e53e3e';
|
||||
wlStatus.textContent = '✗ ' + (data.error || 'Failed to add web link.');
|
||||
}
|
||||
} catch (err) {
|
||||
wlStatus.style.color = '#e53e3e';
|
||||
wlStatus.textContent = '✗ Network error — please try again.';
|
||||
}
|
||||
|
||||
wlStatus.style.display = 'inline';
|
||||
wlBtn.disabled = false;
|
||||
wlBtn.innerHTML = '🌐 Add Web Link';
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
+435
-66
@@ -43,98 +43,467 @@
|
||||
</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>
|
||||
<div class="dash-grid">
|
||||
<!-- ── Main column: where content actually lives ─────────────────────── -->
|
||||
<div class="dash-main">
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
|
||||
<span style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Playlist Overview
|
||||
</span>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-sm btn-primary">Manage</a>
|
||||
</h2>
|
||||
<p class="secondary-text" style="font-size: 0.9rem; margin-top: -0.5rem; margin-bottom: 1rem;">
|
||||
Pick a playlist to add media to or edit. Players listed here are the devices
|
||||
that will show it.
|
||||
</p>
|
||||
|
||||
{% if playlist_overview %}
|
||||
{% for item in playlist_overview %}
|
||||
<div class="playlist-row">
|
||||
<div class="playlist-row-head">
|
||||
<div style="min-width: 0;">
|
||||
<a href="{{ url_for('content.manage_playlist_content', playlist_id=item.playlist.id) }}"
|
||||
class="playlist-name">{{ item.playlist.name }}</a>
|
||||
<div class="secondary-text" style="font-size: 0.82rem; margin-top: 2px;">
|
||||
{{ item.content_count }} item{{ '' if item.content_count == 1 else 's' }}
|
||||
· {{ item.total_duration }}s
|
||||
· v{{ item.playlist.version }}
|
||||
·
|
||||
{% if item.player_count %}
|
||||
<span style="color: #27ae60; font-weight: 600;">
|
||||
{{ item.online_count }}/{{ item.player_count }} online
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color: #e67e22; font-weight: 600;">no players</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('content.manage_playlist_content', playlist_id=item.playlist.id) }}"
|
||||
class="btn btn-sm">Edit</a>
|
||||
</div>
|
||||
|
||||
{% if item.players %}
|
||||
<div class="player-chips">
|
||||
{% for player in item.players %}
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="player-chip {{ 'is-online' if player.is_online else 'is-offline' }}"
|
||||
title="{{ player.name }}{% if player.location %} — {{ player.location }}{% endif %} ({{ 'online' if player.is_online else 'offline' }})">
|
||||
<span class="status-dot"></span>
|
||||
<span class="player-chip-name">{{ player.name }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-hint">
|
||||
No players assigned — content here is not shown on any screen yet.
|
||||
<a href="{{ url_for('players.list') }}">Assign a player →</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty-hint">
|
||||
No playlists yet.
|
||||
<a href="{{ url_for('content.content_list') }}">Create your first playlist →</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if unassigned_players %}
|
||||
<div class="unassigned-note">
|
||||
<strong>{{ unassigned_players | length }} player{{ '' if unassigned_players | length == 1 else 's' }}
|
||||
not assigned to any playlist:</strong>
|
||||
{% for player in unassigned_players %}
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}">{{ player.name }}</a>{{ ', ' if not loop.last else '' }}
|
||||
{% endfor %}
|
||||
— these will not display content until a playlist is assigned.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity sits under Playlist Overview: both describe what
|
||||
the server and its players are actually doing. -->
|
||||
{% if recent_logs or heartbeat_logs %}
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
|
||||
<span>Recent Activity</span>
|
||||
<span class="log-tabs">
|
||||
<button type="button" class="log-tab is-active" data-log-tab="activity">
|
||||
Activity <span class="log-count">{{ recent_logs | length }}</span>
|
||||
</button>
|
||||
<button type="button" class="log-tab" data-log-tab="heartbeat">
|
||||
Player heartbeats <span class="log-count">{{ heartbeat_logs | length }}</span>
|
||||
</button>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div class="log-pane" data-log-pane="activity">
|
||||
{% if recent_logs %}
|
||||
{% for log in recent_logs %}
|
||||
<div class="log-item">
|
||||
<span class="log-level" style="color: {% if log.level == 'error' %}#e74c3c{% elif log.level == 'warning' %}#f39c12{% elif log.level == 'debug' %}#718096{% else %}#27ae60{% endif %};">
|
||||
[{{ log.level.upper() }}]
|
||||
</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
<small class="secondary-text log-time">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty-hint">No activity recorded yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="log-pane" data-log-pane="heartbeat" hidden>
|
||||
{% if heartbeat_logs %}
|
||||
<p class="empty-hint" style="margin-top: 0;">
|
||||
Routine player status reports — newest first.
|
||||
</p>
|
||||
{% for log in heartbeat_logs %}
|
||||
<div class="log-item">
|
||||
<span class="log-level" style="color: #27ae60;">[{{ log.level.upper() }}]</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
<small class="secondary-text log-time">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty-hint">No player heartbeats recorded yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
<!-- ── Right sidebar: small, fixed-width helper cards ─────────────────── -->
|
||||
<aside class="dash-side">
|
||||
<div class="card card-compact">
|
||||
<h3 class="compact-title">Quick Actions</h3>
|
||||
<div class="compact-actions">
|
||||
<a href="{{ url_for('players.add_player') }}" class="btn btn-sm btn-success compact-btn">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" class="compact-btn-icon">
|
||||
Add Player
|
||||
</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-sm btn-success compact-btn">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" class="compact-btn-icon">
|
||||
Create Playlist
|
||||
</a>
|
||||
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-sm btn-success compact-btn">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" class="compact-btn-icon">
|
||||
Upload Media
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-sm compact-btn">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" class="compact-btn-icon">
|
||||
Admin Panel
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-compact">
|
||||
<h3 class="compact-title">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 18px; height: 18px;">
|
||||
Workflow Guide
|
||||
</h3>
|
||||
<div class="workflow-guide">
|
||||
<ol>
|
||||
<li><strong>Create a Playlist</strong></li>
|
||||
<li><strong>Upload Media</strong></li>
|
||||
<li><strong>Add Content</strong></li>
|
||||
<li><strong>Add Player</strong></li>
|
||||
<li><strong>Assign Playlist</strong></li>
|
||||
<li><strong>Auto-Download</strong></li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Dashboard two-column layout: main content + narrow right sidebar.
|
||||
`minmax(0, 1fr)` keeps long playlist/player names from widening the grid. */
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 220px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.dash-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
/* Stick the sidebar while the playlist card scrolls, on tall screens. */
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
/* Compact card used by the sidebar widgets. */
|
||||
.card-compact {
|
||||
padding: 0.85rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.card-compact:hover {
|
||||
/* The global .card lift is distracting on small utility cards. */
|
||||
transform: none;
|
||||
}
|
||||
.compact-title {
|
||||
margin: 0 0 0.6rem 0;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.compact-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.compact-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.compact-btn-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 auto;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
/* The Admin Panel button keeps the default (non-success) background, so its
|
||||
icon must stay in the button's text colour instead of being forced white. */
|
||||
.compact-btn:not(.btn-success) .compact-btn-icon {
|
||||
filter: none;
|
||||
}
|
||||
body.dark-mode .compact-btn:not(.btn-success) .compact-btn-icon {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.workflow-guide {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: var(--bg-color);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.workflow-guide ol {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
line-height: 1.65;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ── Playlist overview rows ───────────────────────────────────────────── */
|
||||
.playlist-row {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.playlist-row:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
.playlist-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.playlist-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
.playlist-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.player-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.player-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
max-width: 100%;
|
||||
}
|
||||
.player-chip:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.player-chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.player-chip.is-online .status-dot { background: #27ae60; }
|
||||
.player-chip.is-offline .status-dot { background: #cbd5e0; }
|
||||
body.dark-mode .player-chip.is-offline .status-dot { background: #718096; }
|
||||
|
||||
.empty-hint {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.unassigned-note {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #e67e22;
|
||||
background: rgba(230, 126, 34, 0.1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.unassigned-note a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
body.dark-mode .workflow-guide {
|
||||
background: #1a202c;
|
||||
border: 1px solid #4a5568;
|
||||
/* Sidebar collapses below the main column when space runs out. */
|
||||
@media (max-width: 900px) {
|
||||
.dash-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dash-side {
|
||||
position: static;
|
||||
/* Narrow screens: let the small cards sit side by side. */
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dash-side .card-compact {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared helpers used by the cards below. */
|
||||
.secondary-text {
|
||||
color: #7f8c8d;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .secondary-text {
|
||||
color: #a0aec0;
|
||||
/* ── Recent Activity: tabbed list, scrollable so it cannot dominate ────── */
|
||||
.log-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-tab {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.log-tab:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.log-tab.is-active {
|
||||
background: var(--primary-gradient);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
.log-count {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.log-tab.is-active .log-count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Cap the height and scroll: a fixed number of rows stay readable. */
|
||||
.log-pane {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.log-item {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
body.dark-mode .log-item {
|
||||
border-bottom: 1px solid #4a5568;
|
||||
.log-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.log-level {
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-message {
|
||||
/* Long messages wrap instead of pushing the timestamp off-screen. */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.log-time {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</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>📋 Playlist-centric architecture active</p>
|
||||
<p>🔄 Groups removed - Streamlined workflow</p>
|
||||
<p>⚡ DigiServer v2.0</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* Recent Activity tabs. Toggles the `hidden` attribute so the panes work even
|
||||
if this script is blocked — the activity pane is simply always shown. */
|
||||
(function () {
|
||||
var tabs = document.querySelectorAll('.log-tab');
|
||||
var panes = document.querySelectorAll('.log-pane');
|
||||
if (!tabs.length) return;
|
||||
|
||||
tabs.forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
var target = tab.getAttribute('data-log-tab');
|
||||
|
||||
tabs.forEach(function (t) {
|
||||
t.classList.toggle('is-active', t === tab);
|
||||
});
|
||||
panes.forEach(function (p) {
|
||||
p.hidden = p.getAttribute('data-log-pane') !== target;
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Group - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Create Group</h1>
|
||||
<div class="card">
|
||||
<form method="POST">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label>Group Name</label>
|
||||
<input type="text" name="name" required style="width: 100%; padding: 0.5rem;">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label>Description (optional)</label>
|
||||
<textarea name="description" rows="3" style="width: 100%; padding: 0.5rem;"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Create Group</button>
|
||||
<a href="{{ url_for('groups.groups_list') }}" class="btn">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Group{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Edit Group</h2>
|
||||
<p>Edit group functionality - placeholder</p>
|
||||
<a href="{{ url_for('groups.list') }}" class="btn btn-secondary">Back to Groups</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,10 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Group Fullscreen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Group Fullscreen View</h2>
|
||||
<p>Fullscreen group view - placeholder</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Groups - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Groups</h1>
|
||||
<div class="card">
|
||||
<p>Groups list view - Template in progress</p>
|
||||
<a href="{{ url_for('groups.create_group') }}" class="btn btn-success">Create New Group</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Group{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Manage Group</h2>
|
||||
<p>Manage group functionality - placeholder</p>
|
||||
<a href="{{ url_for('groups.list') }}" class="btn btn-secondary">Back to Groups</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% if player.deployment_status == 'deploying' %}
|
||||
<span class="deploy-badge deploying" id="deploying-badge-{{ player.id }}">
|
||||
<span class="spinner"></span>Deploying...
|
||||
</span>
|
||||
{% elif player.deployment_status == 'deployed' %}
|
||||
<span class="deploy-badge deployed" title="{{ player.last_deployment_message or 'Deployed successfully' }}">
|
||||
✅ Deployed
|
||||
{% if player.last_deployment_at %}
|
||||
<span class="deploy-timestamp">{{ player.last_deployment_at | localtime }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% elif player.deployment_status == 'failed' %}
|
||||
<span class="deploy-badge failed deploy-tooltip" title="{{ player.last_deployment_message or 'Deployment failed' }}">
|
||||
❌ Failed
|
||||
{% if player.last_deployment_at %}
|
||||
<span class="deploy-timestamp">{{ player.last_deployment_at | localtime }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% elif player.deployment_status == 'pending' %}
|
||||
<span class="deploy-badge pending" title="Awaiting deployment">
|
||||
⏳ Pending
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
@@ -4,135 +4,156 @@
|
||||
|
||||
{% 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-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;
|
||||
width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.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; }
|
||||
.section-header.purple { border-color: #9b59b6; }
|
||||
body.dark-mode .section-header.purple { border-color: #b794f6; }
|
||||
|
||||
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;
|
||||
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;
|
||||
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; }
|
||||
|
||||
/* Mode chooser cards */
|
||||
.mode-chooser {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
margin-top: 0;
|
||||
color: #007bff;
|
||||
@media (max-width: 700px) { .mode-chooser { grid-template-columns: 1fr; } }
|
||||
|
||||
.mode-card {
|
||||
border: 2px solid #ddd; border-radius: 8px; padding: 1.5rem;
|
||||
cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s;
|
||||
background: #fff; text-align: center;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box h4 {
|
||||
color: #667eea;
|
||||
body.dark-mode .mode-card { background: #2d3748; border-color: #4a5568; }
|
||||
|
||||
.mode-card:hover { border-color: #007bff; box-shadow: 0 4px 12px rgba(0,123,255,0.15); }
|
||||
body.dark-mode .mode-card:hover { border-color: #667eea; }
|
||||
|
||||
.mode-card.active { border-color: #007bff; box-shadow: 0 4px 12px rgba(0,123,255,0.2); }
|
||||
body.dark-mode .mode-card.active { border-color: #667eea; box-shadow: 0 4px 12px rgba(102,126,234,0.25); }
|
||||
|
||||
.mode-card.active-deploy { border-color: #28a745; box-shadow: 0 4px 12px rgba(40,167,69,0.2); }
|
||||
body.dark-mode .mode-card.active-deploy { border-color: #48bb78; }
|
||||
|
||||
.mode-icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
||||
.mode-title { font-size: 1.15rem; font-weight: bold; margin-bottom: 0.4rem; }
|
||||
body.dark-mode .mode-title { color: #e2e8f0; }
|
||||
.mode-desc { font-size: 0.875rem; color: #6c757d; }
|
||||
body.dark-mode .mode-desc { color: #a0aec0; }
|
||||
|
||||
/* Panel visibility */
|
||||
.mode-panel { display: none; }
|
||||
.mode-panel.active { display: block; }
|
||||
|
||||
/* SSH section */
|
||||
.ssh-section {
|
||||
background-color: #f8f9fa; padding: 1.5rem; border-radius: 6px; margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.info-box code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
body.dark-mode .ssh-section { background-color: #1e2a38; }
|
||||
|
||||
.connection-status {
|
||||
padding: 1rem; border-radius: 4px; margin-top: 1rem; display: none;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box code {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
.connection-status.success {
|
||||
background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; display: block;
|
||||
}
|
||||
|
||||
body.dark-mode small {
|
||||
color: #718096;
|
||||
.connection-status.error {
|
||||
background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; display: block;
|
||||
}
|
||||
body.dark-mode .connection-status.success { background-color: #22543d; border-color: #2f855a; color: #9ae6b4; }
|
||||
body.dark-mode .connection-status.error { background-color: #742a2a; border-color: #c53030; color: #fc8181; }
|
||||
|
||||
/* Deploy-form hidden until SSH verified */
|
||||
.deploy-player-form { display: none; }
|
||||
.deploy-player-form.active { display: block; }
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem; margin-right: 0.5rem; margin-bottom: 0.5rem;
|
||||
border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem;
|
||||
text-decoration: none; display: inline-block;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary { background-color: #007bff; color: white; }
|
||||
.btn-primary:hover:not(:disabled) { background-color: #0056b3; }
|
||||
.btn-success { background-color: #28a745; color: white; }
|
||||
.btn-success:hover:not(:disabled) { background-color: #218838; }
|
||||
.btn-secondary { background-color: #6c757d; color: white; }
|
||||
.btn-secondary:hover:not(:disabled) { background-color: #5a6268; }
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block; width: 1rem; height: 1rem;
|
||||
border: 2px solid rgba(255,255,255,0.3); border-radius: 50%;
|
||||
border-top-color: white; animation: spin 1s linear infinite; margin-right: 0.5rem;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.row2col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 768px) { .row2col { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<div class="container" style="max-width: 800px; margin-top: 2rem;">
|
||||
|
||||
<div class="container" style="max-width: 960px; 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 style="color: #6c757d; margin-bottom: 1.5rem;">
|
||||
Choose how you want to add the player to the system.
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<!-- ───────────────────────────── Mode selector ───────────────────────────── -->
|
||||
<div class="mode-chooser">
|
||||
<div class="mode-card active" id="card_manual" onclick="selectMode('manual')">
|
||||
<div class="mode-icon">📋</div>
|
||||
<div class="mode-title">Manual Registration</div>
|
||||
<div class="mode-desc">
|
||||
Register the player in the database and wait for the client to connect.
|
||||
You configure <code>app_config.json</code> on the device yourself.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-card" id="card_deploy" onclick="selectMode('deploy')">
|
||||
<div class="mode-icon">🚀</div>
|
||||
<div class="mode-title">Create & Deploy</div>
|
||||
<div class="mode-desc">
|
||||
Register the player <em>and</em> automatically push the Linux player
|
||||
code to the target host via SSH.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────────────── Mode 1 — Manual Registration ─────────────────── -->
|
||||
<div class="card mode-panel active" id="panel_manual">
|
||||
<form method="POST">
|
||||
<h3 class="section-header blue" style="margin-top: 0;">
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<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"
|
||||
@@ -145,31 +166,28 @@
|
||||
<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)
|
||||
Unique identifier — must match <code>screen_name</code> in the player's
|
||||
<code>app_config.json</code>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Location</label>
|
||||
<input type="text" name="location" class="form-control"
|
||||
placeholder="e.g., Main Office - Reception Area">
|
||||
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>
|
||||
<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"
|
||||
<input type="password" name="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>
|
||||
<small class="form-help">Secure password for player authentication (optional if using Quick Connect)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -177,13 +195,11 @@
|
||||
<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)
|
||||
Easy pairing code — must match <code>quickconnect_key</code> in player config
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-header yellow">
|
||||
Display Settings
|
||||
</h3>
|
||||
<h3 class="section-header yellow">Display Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Orientation</label>
|
||||
@@ -199,7 +215,9 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -208,16 +226,16 @@
|
||||
<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:
|
||||
<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>:
|
||||
<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>
|
||||
<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>
|
||||
<li>Start the player — it will authenticate automatically.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -225,11 +243,257 @@
|
||||
<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;">
|
||||
<a href="{{ url_for('players.list') }}" class="btn btn-secondary"
|
||||
style="padding: 0.75rem 2rem; margin-left: 0.5rem;">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ──────────────────────── Mode 2 — Create & Deploy ────────────────────── -->
|
||||
<div class="card mode-panel" id="panel_deploy">
|
||||
|
||||
<!-- Step 1 — SSH connection test -->
|
||||
<div class="ssh-section">
|
||||
<h3 class="section-header purple" style="margin-top: 0;">
|
||||
🔌 Step 1 — SSH Connection
|
||||
</h3>
|
||||
<p class="form-help">
|
||||
Test SSH connectivity to the target Linux host before proceeding.
|
||||
</p>
|
||||
|
||||
<div class="row2col">
|
||||
<div class="form-group">
|
||||
<label>Target Hostname / IP *</label>
|
||||
<input type="text" id="ssh_hostname" class="form-control"
|
||||
placeholder="e.g., 192.168.1.100">
|
||||
<small class="form-help">IP address or hostname of the target machine</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSH Port</label>
|
||||
<input type="number" id="ssh_port" class="form-control"
|
||||
value="22" min="1" max="65535">
|
||||
<small class="form-help">SSH port (default: 22)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row2col">
|
||||
<div class="form-group">
|
||||
<label>SSH Username *</label>
|
||||
<input type="text" id="ssh_username" class="form-control"
|
||||
placeholder="e.g., pi or ubuntu">
|
||||
<small class="form-help">SSH login username</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSH Password *</label>
|
||||
<input type="password" id="ssh_password" class="form-control"
|
||||
placeholder="SSH password">
|
||||
<small class="form-help">SSH login password</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="test_ssh_btn" class="btn btn-primary"
|
||||
onclick="testSSHConnection()">
|
||||
✓ Test SSH Connection
|
||||
</button>
|
||||
<button type="button" id="clear_ssh_btn" class="btn btn-secondary"
|
||||
onclick="clearSSHForm()" style="display: none;">
|
||||
🔄 Clear
|
||||
</button>
|
||||
|
||||
<div id="connection_status" class="connection-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 — Player info (shown after successful SSH test) -->
|
||||
<div id="deploy_player_form_section" class="deploy-player-form">
|
||||
<form method="POST" id="add_player_form">
|
||||
<!-- Hidden SSH fields -->
|
||||
<input type="hidden" id="form_ssh_hostname" name="ssh_hostname" value="">
|
||||
<input type="hidden" id="form_ssh_username" name="ssh_username" value="">
|
||||
<input type="hidden" id="form_ssh_password" name="ssh_password" value="">
|
||||
<input type="hidden" id="form_ssh_port" name="ssh_port" value="">
|
||||
<input type="hidden" name="deploy_player" value="1">
|
||||
|
||||
<h3 class="section-header blue">Step 2 — 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 — will be written to <code>app_config.json</code>
|
||||
on the remote host automatically
|
||||
</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 (optional)</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-header green">Authentication</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" class="form-control"
|
||||
placeholder="Leave empty to use Quick Connect only">
|
||||
<small class="form-help">Secure password (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 — deployed automatically to remote 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>🚀 What Happens Next</h4>
|
||||
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
||||
<li><strong>Player Record</strong> is created in the database with an Auth Code.</li>
|
||||
<li><strong>Player Code</strong> from the Kiwy-Signage repository is pushed to
|
||||
<strong id="deploy_host_info">the target host</strong> via SSH.</li>
|
||||
<li><strong>Installation Scripts</strong> run on the remote host in the background.</li>
|
||||
<li><strong>Config</strong> (<code>app_config.json</code>) is written automatically
|
||||
with the server address, hostname and Quick Connect Code.</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 & Deploy Player
|
||||
</button>
|
||||
<a href="{{ url_for('players.list') }}" class="btn btn-secondary"
|
||||
style="padding: 0.75rem 2rem; margin-left: 0.5rem;">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div><!-- /panel_deploy -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Mode switching ─────────────────────────────────────────────────────────
|
||||
function selectMode(mode) {
|
||||
// cards
|
||||
document.getElementById('card_manual').classList.remove('active', 'active-deploy');
|
||||
document.getElementById('card_deploy').classList.remove('active', 'active-deploy');
|
||||
if (mode === 'manual') {
|
||||
document.getElementById('card_manual').classList.add('active');
|
||||
} else {
|
||||
document.getElementById('card_deploy').classList.add('active-deploy');
|
||||
}
|
||||
|
||||
// panels
|
||||
document.getElementById('panel_manual').classList.toggle('active', mode === 'manual');
|
||||
document.getElementById('panel_deploy').classList.toggle('active', mode === 'deploy');
|
||||
}
|
||||
|
||||
// ── SSH flow ───────────────────────────────────────────────────────────────
|
||||
function testSSHConnection() {
|
||||
const hostname = document.getElementById('ssh_hostname').value.trim();
|
||||
const username = document.getElementById('ssh_username').value.trim();
|
||||
const password = document.getElementById('ssh_password').value.trim();
|
||||
const port = parseInt(document.getElementById('ssh_port').value) || 22;
|
||||
|
||||
if (!hostname || !username || !password) {
|
||||
alert('Please fill in all SSH connection fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('test_ssh_btn');
|
||||
const statusDiv = document.getElementById('connection_status');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loading-spinner"></span>Testing connection…';
|
||||
|
||||
fetch('{{ url_for("api.test_ssh_connection") }}', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({hostname, username, password, port})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
statusDiv.className = 'connection-status ' + (data.success ? 'success' : 'error');
|
||||
statusDiv.innerHTML = '<strong>' + (data.success ? '✓ Connected!' : '✗ Connection Failed')
|
||||
+ '</strong><br>' + data.message;
|
||||
|
||||
if (data.success) {
|
||||
// Lock SSH fields
|
||||
['ssh_hostname','ssh_username','ssh_password','ssh_port'].forEach(id => {
|
||||
document.getElementById(id).disabled = true;
|
||||
});
|
||||
document.getElementById('test_ssh_btn').style.display = 'none';
|
||||
document.getElementById('clear_ssh_btn').style.display = 'inline-block';
|
||||
|
||||
// Copy credentials to hidden form fields
|
||||
document.getElementById('form_ssh_hostname').value = hostname;
|
||||
document.getElementById('form_ssh_username').value = username;
|
||||
document.getElementById('form_ssh_password').value = password;
|
||||
document.getElementById('form_ssh_port').value = port;
|
||||
|
||||
// Reveal player form and update host label
|
||||
document.getElementById('deploy_player_form_section').classList.add('active');
|
||||
document.getElementById('deploy_host_info').textContent = hostname;
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '✓ Test SSH Connection';
|
||||
})
|
||||
.catch(err => {
|
||||
statusDiv.className = 'connection-status error';
|
||||
statusDiv.innerHTML = '<strong>✗ Error:</strong> ' + err.message;
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '✓ Test SSH Connection';
|
||||
});
|
||||
}
|
||||
|
||||
function clearSSHForm() {
|
||||
['ssh_hostname','ssh_username','ssh_password','ssh_port'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.value = id === 'ssh_port' ? '22' : '';
|
||||
el.disabled = false;
|
||||
});
|
||||
document.getElementById('test_ssh_btn').style.display = 'inline-block';
|
||||
document.getElementById('clear_ssh_btn').style.display = 'none';
|
||||
document.getElementById('connection_status').className = 'connection-status';
|
||||
document.getElementById('deploy_player_form_section').classList.remove('active');
|
||||
document.getElementById('add_player_form').reset();
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -333,7 +333,7 @@
|
||||
<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>📄 {{ original_content.original_display_name 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>
|
||||
@@ -376,7 +376,7 @@
|
||||
</div>
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">👤 Edited by:</span>
|
||||
<span class="preview-info-value" id="info-user-{{ content_id }}">{{ latest.user or 'Unknown' }}</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>
|
||||
@@ -398,7 +398,7 @@
|
||||
{% 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 }}', '{{ 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) }}')">
|
||||
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) }}"
|
||||
@@ -420,10 +420,10 @@
|
||||
{% 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) }}')">
|
||||
onclick="event.stopPropagation(); selectVersion({{ content_id }}, 'original', '{{ original_content.original_display_name }}', 'System', 'N/A', '{{ original_content.uploaded_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/' ~ original_content.original_media_path) }}')">
|
||||
<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) }}"
|
||||
{% if original_content.original_display_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ original_content.original_media_path) }}"
|
||||
alt="Original">
|
||||
{% else %}
|
||||
<div style="color: white; font-size: 2rem;">📄</div>
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edited Media Report - {{ player.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.report-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.report-header h1 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
body.dark-mode .summary-stat {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.summary-stat .stat-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.summary-stat .stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.report-table-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
body.dark-mode .report-table-wrapper {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.report-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.report-table thead {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.report-table thead th {
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-table tbody tr {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
body.dark-mode .report-table tbody tr {
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
.report-table tbody tr:hover {
|
||||
background: rgba(124, 58, 237, 0.05);
|
||||
}
|
||||
|
||||
body.dark-mode .report-table tbody tr:hover {
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
|
||||
.report-table tbody tr:nth-child(even) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
body.dark-mode .report-table tbody tr:nth-child(even) {
|
||||
background: #162032;
|
||||
}
|
||||
|
||||
.report-table tbody td {
|
||||
padding: 0.75rem 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.report-table .col-user {
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
body.dark-mode .report-table .col-user {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.report-table .col-filename {
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
color: #475569;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
body.dark-mode .report-table .col-filename {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.report-table .col-date {
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.report-table .col-link a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-table .col-link a:hover {
|
||||
background: #6d28d9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
body.dark-mode .report-table .col-link a {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
body.dark-mode .report-table .col-link a:hover {
|
||||
background: #5b21b6;
|
||||
}
|
||||
|
||||
.report-table .col-version {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .version-badge {
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.no-data .icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.no-data p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #64748b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.print-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
.report-table-wrapper {
|
||||
border: 1px solid #ccc !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.report-table thead {
|
||||
background: #333 !important;
|
||||
}
|
||||
.btn, .print-btn, nav, .back-link {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="report-container">
|
||||
<div class="report-header">
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="btn back-link"
|
||||
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>
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 28px; height: 28px;">
|
||||
Edited Media Report — {{ player.name }}
|
||||
</h1>
|
||||
<button class="print-btn" onclick="window.print()">
|
||||
🖨️ Print / PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if edited_media %}
|
||||
{% set total_edits = edited_media|length %}
|
||||
{% set unique_files = edited_media|map(attribute='content_id')|unique|list|length %}
|
||||
{% set unique_users = edited_media|selectattr('user')|map(attribute='user')|unique|list|length %}
|
||||
|
||||
<div class="report-summary">
|
||||
<div class="summary-stat">
|
||||
<span class="stat-value">{{ total_edits }}</span>
|
||||
<span class="stat-label">Total Edits</span>
|
||||
</div>
|
||||
<div class="summary-stat">
|
||||
<span class="stat-value">{{ unique_files }}</span>
|
||||
<span class="stat-label">Files Edited</span>
|
||||
</div>
|
||||
<div class="summary-stat">
|
||||
<span class="stat-value">{{ unique_users }}</span>
|
||||
<span class="stat-label">Editors</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-table-wrapper">
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 22%;">User</th>
|
||||
<th style="width: 30%;">Edited File</th>
|
||||
<th style="width: 15%;">Version</th>
|
||||
<th style="width: 18%;">Date</th>
|
||||
<th style="width: 15%;">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for edit in edited_media %}
|
||||
<tr>
|
||||
<td class="col-user">
|
||||
{% if edit.user %}
|
||||
{% set display_name = user_mappings.get(edit.user, edit.user) %}
|
||||
👤 {{ display_name }}
|
||||
{% else %}
|
||||
<span style="color: #94a3b8;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-filename">
|
||||
📄 {{ edit.new_name }}
|
||||
</td>
|
||||
<td class="col-version">
|
||||
<span class="version-badge">v{{ edit.version }}</span>
|
||||
</td>
|
||||
<td class="col-date">
|
||||
{% if edit.time_of_modification %}
|
||||
{{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
|
||||
{% elif edit.created_at %}
|
||||
{{ edit.created_at | localtime('%Y-%m-%d %H:%M') }}
|
||||
{% else %}
|
||||
<span style="color: #94a3b8;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-link">
|
||||
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
target="_blank"
|
||||
title="Open {{ edit.new_name }}">
|
||||
🔗 Open File
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-data">
|
||||
<div class="icon">📋</div>
|
||||
<p style="font-size: 1.1rem; font-weight: 500;">No edited media found</p>
|
||||
<p>This player has not submitted any edited media yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -646,13 +646,22 @@ document.addEventListener('keydown', function(event) {
|
||||
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>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<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>
|
||||
<a href="{{ url_for('players.edited_media_report', player_id=player.id) }}"
|
||||
class="btn"
|
||||
style="background: #059669; 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='#047857'"
|
||||
onmouseout="this.style.background='#059669'">
|
||||
📊 Launch Report
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -125,6 +125,82 @@
|
||||
body.dark-mode .info-box a {
|
||||
color: #90cdf4;
|
||||
}
|
||||
|
||||
/* Deployment status */
|
||||
.deploy-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deploy-badge.pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
body.dark-mode .deploy-badge.pending {
|
||||
background: #4a3800;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.deploy-badge.deployed {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
body.dark-mode .deploy-badge.deployed {
|
||||
background: #1a4d2e;
|
||||
color: #86efac;
|
||||
}
|
||||
.deploy-badge.failed {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
body.dark-mode .deploy-badge.failed {
|
||||
background: #4a1a1a;
|
||||
color: #fc8181;
|
||||
}
|
||||
.deploy-badge.deploying {
|
||||
background: #cce5ff;
|
||||
color: #004085;
|
||||
animation: pulse-bg 1.5s ease-in-out infinite;
|
||||
}
|
||||
body.dark-mode .deploy-badge.deploying {
|
||||
background: #1a365d;
|
||||
color: #90cdf4;
|
||||
}
|
||||
.deploy-badge .spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(0,64,133,0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #004085;
|
||||
animation: deploy-spin 0.8s linear infinite;
|
||||
}
|
||||
body.dark-mode .deploy-badge .spinner {
|
||||
border-color: rgba(144,205,244,0.3);
|
||||
border-top-color: #90cdf4;
|
||||
}
|
||||
@keyframes deploy-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse-bg {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
.deploy-timestamp {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
body.dark-mode .deploy-timestamp {
|
||||
color: #718096;
|
||||
}
|
||||
.deploy-tooltip {
|
||||
cursor: help;
|
||||
border-bottom: 1px dashed #aaa;
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
@@ -142,13 +218,14 @@
|
||||
<th>Location</th>
|
||||
<th>Orientation</th>
|
||||
<th>Status</th>
|
||||
<th>Deployment</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for player in players %}
|
||||
<tr>
|
||||
<tr id="player-row-{{ player.id }}">
|
||||
<td>
|
||||
<strong>{{ player.name }}</strong>
|
||||
</td>
|
||||
@@ -168,6 +245,9 @@
|
||||
<span class="status-badge offline">Offline</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td id="deploy-cell-{{ player.id }}">
|
||||
{% include "players/_deploy_badge.html" %}
|
||||
</td>
|
||||
<td>
|
||||
{% if player.last_seen %}
|
||||
{{ player.last_seen | localtime }}
|
||||
@@ -192,4 +272,88 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Deployment status polling ────────────────────────────────────────────
|
||||
(function() {
|
||||
var POLL_INTERVAL = 5000; // 5 seconds
|
||||
var polling = false;
|
||||
|
||||
// Initial check: any badge that isn't yet "Deployed" yet (deploying,
|
||||
// pending, or failed). We keep polling so that players which send
|
||||
// feedback auto-transition from "pending" to "deployed".
|
||||
var activeBadges = document.querySelectorAll(
|
||||
'.deploy-badge.deploying, .deploy-badge.pending, .deploy-badge.failed'
|
||||
);
|
||||
if (activeBadges.length > 0) {
|
||||
polling = true;
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
function schedulePoll() {
|
||||
setTimeout(pollDeploymentStatus, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function pollDeploymentStatus() {
|
||||
if (!polling) return;
|
||||
|
||||
fetch('{{ url_for("players.deployment_status") }}')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var anyActive = false;
|
||||
|
||||
for (var playerId in data) {
|
||||
if (!data.hasOwnProperty(playerId)) continue;
|
||||
var status = data[playerId];
|
||||
var cell = document.getElementById('deploy-cell-' + playerId);
|
||||
if (!cell) continue;
|
||||
|
||||
var ds = status.deployment_status;
|
||||
var lds = status.last_deployment_status;
|
||||
var msg = status.last_deployment_message || '';
|
||||
var ts = status.last_deployment_at
|
||||
? new Date(status.last_deployment_at + 'Z').toLocaleString()
|
||||
: '';
|
||||
|
||||
if (ds === 'deployed') {
|
||||
cell.innerHTML = '<span class="deploy-badge deployed" title="' + escapeHtml(msg) + '">\u2705 Deployed<span class="deploy-timestamp">' + ts + '</span></span>';
|
||||
} else if (ds === 'failed') {
|
||||
// Terminal state - no need to keep polling for this one
|
||||
cell.innerHTML = '<span class="deploy-badge failed deploy-tooltip" title="' + escapeHtml(msg) + '">\u274c Failed<span class="deploy-timestamp">' + ts + '</span></span>';
|
||||
} else if (ds === 'deploying') {
|
||||
anyActive = true;
|
||||
if (!cell.querySelector('.deploying')) {
|
||||
cell.innerHTML = '<span class="deploy-badge deploying"><span class="spinner"></span>Deploying...</span>';
|
||||
}
|
||||
} else {
|
||||
// pending / null - deployment not confirmed yet.
|
||||
// Keep polling: the player may start sending feedback,
|
||||
// which flips the status to "deployed" on the server.
|
||||
anyActive = true;
|
||||
if (!cell.querySelector('.pending')) {
|
||||
cell.innerHTML = '<span class="deploy-badge pending" title="Awaiting deployment">\u23f3 Pending</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anyActive) {
|
||||
schedulePoll();
|
||||
} else {
|
||||
polling = false;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
// Retry
|
||||
if (polling) schedulePoll();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,857 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Playlist - {{ player.name }} - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.playlist-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.player-info-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.player-info-card h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.player-info-card p {
|
||||
margin: 5px 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.playlist-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.playlist-table thead {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.playlist-table th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.draggable-row {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.draggable-row.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.add-content-form {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
width: 70px !important;
|
||||
padding: 5px 8px !important;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
background: white !important;
|
||||
border: 2px solid #ced4da !important;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.duration-input:hover {
|
||||
border-color: #667eea !important;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.duration-input:focus {
|
||||
border-color: #667eea !important;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||||
background: white !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.save-duration-btn {
|
||||
transition: all 0.2s ease;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.content-type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-image {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.badge-video {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.badge-pdf {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
body.dark-mode .playlist-section {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header {
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-table thead {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-table th {
|
||||
color: #cbd5e0;
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-table td {
|
||||
border-bottom-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-table tr:hover {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
border-color: #667eea;
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
body.dark-mode .add-content-form {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .empty-state {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
body.dark-mode .drag-handle {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-input {
|
||||
background: #1a202c !important;
|
||||
border-color: #4a5568 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-input:hover {
|
||||
border-color: #667eea !important;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
body.dark-mode .duration-input:focus {
|
||||
background: #2d3748 !important;
|
||||
border-color: #667eea !important;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
body.dark-mode .badge-image {
|
||||
background: #1e3a5f;
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
body.dark-mode .badge-video {
|
||||
background: #4a1e5a;
|
||||
color: #ce93d8;
|
||||
}
|
||||
|
||||
body.dark-mode .badge-pdf {
|
||||
background: #5a1e1e;
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
/* Audio toggle styles */
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="playlist-container">
|
||||
<!-- Player Info Card -->
|
||||
<div class="player-info-card">
|
||||
<h1>🎬 {{ player.name }}</h1>
|
||||
<p>📍 {{ player.location or 'No location' }}</p>
|
||||
<p>🖥️ Hostname: {{ player.hostname }}</p>
|
||||
<p>📊 Status: {{ '🟢 Online' if player.is_online else '🔴 Offline' }}</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Playlist Items</div>
|
||||
<div class="stat-value">{{ playlist_content|length }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Playlist Version</div>
|
||||
<div class="stat-value">{{ player.playlist_version }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Total Duration</div>
|
||||
<div class="stat-value">{{ playlist_content|sum(attribute='duration') }}s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div style="margin-bottom: 20px; display: flex; gap: 10px;">
|
||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}" class="btn btn-secondary">
|
||||
← Back to Player
|
||||
</a>
|
||||
<a href="{{ url_for('content.upload_content', player_id=player.id, return_url=url_for('playlist.manage_playlist', player_id=player.id)) }}"
|
||||
class="btn btn-success">
|
||||
➕ Upload New Content
|
||||
</a>
|
||||
{% if playlist_content %}
|
||||
<form method="POST" action="{{ url_for('playlist.clear_playlist', player_id=player.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to clear the entire playlist?');">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
🗑️ Clear Playlist
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Current Playlist -->
|
||||
<div class="playlist-section">
|
||||
<div class="section-header">
|
||||
<h2>📋 Current Playlist</h2>
|
||||
<span style="color: #999; font-size: 14px;">
|
||||
Drag and drop to reorder
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if playlist_content %}
|
||||
<table class="playlist-table" id="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Filename</th>
|
||||
<th style="width: 100px;">Type</th>
|
||||
<th style="width: 120px;">Duration (s)</th>
|
||||
<th style="width: 80px;">Audio</th>
|
||||
<th style="width: 100px;">Size</th>
|
||||
<th style="width: 150px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-tbody">
|
||||
{% for content in playlist_content %}
|
||||
<tr class="draggable-row" data-content-id="{{ content.id }}">
|
||||
<td>
|
||||
<span class="drag-handle" draggable="true">⋮⋮</span>
|
||||
</td>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ content.filename }}</td>
|
||||
<td>
|
||||
{% if content.content_type == 'image' %}
|
||||
<span class="content-type-badge badge-image">📷 Image</span>
|
||||
{% elif content.content_type == 'video' %}
|
||||
<span class="content-type-badge badge-video">🎥 Video</span>
|
||||
{% elif content.content_type == 'pdf' %}
|
||||
<span class="content-type-badge badge-pdf">📄 PDF</span>
|
||||
{% else %}
|
||||
<span class="content-type-badge">📁 {{ content.content_type }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="number"
|
||||
class="form-control duration-input"
|
||||
id="duration-{{ content.id }}"
|
||||
value="{{ content._playlist_duration }}"
|
||||
min="1"
|
||||
draggable="false"
|
||||
onclick="event.stopPropagation()"
|
||||
onmousedown="event.stopPropagation()"
|
||||
oninput="markDurationChanged({{ content.id }})"
|
||||
onkeypress="if(event.key==='Enter') saveDuration({{ content.id }})">
|
||||
<button type="button"
|
||||
class="btn btn-success btn-sm save-duration-btn"
|
||||
id="save-btn-{{ content.id }}"
|
||||
onclick="event.stopPropagation(); saveDuration({{ content.id }})"
|
||||
onmousedown="event.stopPropagation()"
|
||||
style="display: none;"
|
||||
title="Save duration (or press Enter)">
|
||||
💾
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if content.content_type == 'video' %}
|
||||
<label class="audio-toggle" onclick="event.stopPropagation()">
|
||||
<input type="checkbox"
|
||||
class="audio-checkbox"
|
||||
data-content-id="{{ content.id }}"
|
||||
{{ 'checked' if not content._playlist_muted else '' }}
|
||||
onchange="toggleAudio({{ content.id }}, this.checked)"
|
||||
onclick="event.stopPropagation()">
|
||||
<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>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('playlist.remove_from_playlist', player_id=player.id, content_id=content.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Remove {{ content.filename }} from playlist?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
✕ Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<h3>No content in playlist</h3>
|
||||
<p>Upload content or add existing files to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Add Content Section -->
|
||||
{% if available_content %}
|
||||
<div class="playlist-section">
|
||||
<div class="section-header">
|
||||
<h2>➕ Add Existing Content</h2>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
|
||||
class="add-content-form">
|
||||
<div class="form-group">
|
||||
<label for="content_id">Select Content:</label>
|
||||
<select name="content_id" id="content_id" class="form-control" required>
|
||||
<option value="" disabled selected>Choose content...</option>
|
||||
{% for content in available_content %}
|
||||
<option value="{{ content.id }}">{{ content.filename }} ({{ content.content_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="duration">Display Duration (seconds):</label>
|
||||
<input type="number"
|
||||
name="duration"
|
||||
id="duration"
|
||||
class="form-control"
|
||||
value="10"
|
||||
min="1"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">
|
||||
➕ Add to Playlist
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let draggedElement = null;
|
||||
|
||||
// Initialize drag and drop
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tbody = document.getElementById('playlist-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
// Set up drag handles
|
||||
const dragHandles = tbody.querySelectorAll('.drag-handle');
|
||||
dragHandles.forEach(handle => {
|
||||
handle.addEventListener('dragstart', handleDragStart);
|
||||
});
|
||||
|
||||
// Set up drop zones on rows
|
||||
const rows = tbody.querySelectorAll('.draggable-row');
|
||||
rows.forEach(row => {
|
||||
row.addEventListener('dragover', handleDragOver);
|
||||
row.addEventListener('drop', handleDrop);
|
||||
row.addEventListener('dragend', handleDragEnd);
|
||||
});
|
||||
|
||||
// Prevent dragging from inputs and buttons
|
||||
const inputs = document.querySelectorAll('.duration-input, button');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('mousedown', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
input.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function handleDragStart(e) {
|
||||
// Get the parent row
|
||||
const row = e.target.closest('.draggable-row');
|
||||
if (!row) return;
|
||||
|
||||
draggedElement = row;
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/html', row.innerHTML);
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
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.classList.remove('dragging');
|
||||
}
|
||||
|
||||
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("playlist.reorder_playlist", player_id=player.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content_ids: contentIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Playlist reordered successfully');
|
||||
} else {
|
||||
alert('Error reordering playlist: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error reordering playlist');
|
||||
});
|
||||
}
|
||||
|
||||
function markDurationChanged(contentId) {
|
||||
const saveBtn = document.getElementById(`save-btn-${contentId}`);
|
||||
const input = document.getElementById(`duration-${contentId}`);
|
||||
|
||||
// Show save button if value changed
|
||||
if (input.value !== input.defaultValue) {
|
||||
saveBtn.style.display = 'inline-block';
|
||||
input.style.borderColor = '#ffc107';
|
||||
} else {
|
||||
saveBtn.style.display = 'none';
|
||||
input.style.borderColor = '';
|
||||
}
|
||||
}
|
||||
|
||||
function saveDuration(contentId) {
|
||||
const inputElement = document.getElementById(`duration-${contentId}`);
|
||||
const saveBtn = document.getElementById(`save-btn-${contentId}`);
|
||||
const duration = parseInt(inputElement.value);
|
||||
|
||||
// Validate duration
|
||||
if (duration < 1) {
|
||||
alert('Duration must be at least 1 second');
|
||||
inputElement.value = inputElement.defaultValue;
|
||||
inputElement.style.borderColor = '';
|
||||
saveBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const originalValue = inputElement.defaultValue;
|
||||
|
||||
// Visual feedback
|
||||
inputElement.disabled = true;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '⏳';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('duration', duration);
|
||||
|
||||
const playerId = {{ player.id }};
|
||||
const url = `/playlist/${playerId}/update-duration/${contentId}`;
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Duration updated successfully');
|
||||
inputElement.style.borderColor = '#28a745';
|
||||
inputElement.defaultValue = duration;
|
||||
saveBtn.textContent = '✓';
|
||||
|
||||
// Update total duration display
|
||||
updateTotalDuration();
|
||||
|
||||
setTimeout(() => {
|
||||
inputElement.style.borderColor = '';
|
||||
inputElement.disabled = false;
|
||||
saveBtn.style.display = 'none';
|
||||
saveBtn.textContent = '💾';
|
||||
saveBtn.disabled = false;
|
||||
}, 1500);
|
||||
} else {
|
||||
inputElement.style.borderColor = '#dc3545';
|
||||
inputElement.value = originalValue;
|
||||
saveBtn.textContent = '✖';
|
||||
alert('Error updating duration: ' + data.message);
|
||||
|
||||
setTimeout(() => {
|
||||
inputElement.disabled = false;
|
||||
inputElement.style.borderColor = '';
|
||||
saveBtn.style.display = 'none';
|
||||
saveBtn.textContent = '💾';
|
||||
saveBtn.disabled = false;
|
||||
}, 1500);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
inputElement.style.borderColor = '#dc3545';
|
||||
inputElement.value = originalValue;
|
||||
saveBtn.textContent = '✖';
|
||||
alert('Error updating duration');
|
||||
|
||||
setTimeout(() => {
|
||||
inputElement.disabled = false;
|
||||
inputElement.style.borderColor = '';
|
||||
saveBtn.style.display = 'none';
|
||||
saveBtn.textContent = '💾';
|
||||
saveBtn.disabled = false;
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTotalDuration() {
|
||||
const durationInputs = document.querySelectorAll('.duration-input');
|
||||
let total = 0;
|
||||
durationInputs.forEach(input => {
|
||||
total += parseInt(input.value) || 0;
|
||||
});
|
||||
|
||||
const statValues = document.querySelectorAll('.stat-value');
|
||||
statValues.forEach((element, index) => {
|
||||
const label = element.parentElement.querySelector('.stat-label');
|
||||
if (label && label.textContent.includes('Total Duration')) {
|
||||
element.textContent = total + 's';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAudio(contentId, enabled) {
|
||||
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
|
||||
const playerId = {{ player.id }};
|
||||
const url = `/playlist/${playerId}/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;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
+2
-14
@@ -10,14 +10,7 @@ from app.utils.uploads import (
|
||||
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.group_player_management import get_player_status_info
|
||||
from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file
|
||||
|
||||
__all__ = [
|
||||
@@ -36,13 +29,8 @@ __all__ = [
|
||||
'clear_upload_progress',
|
||||
'get_file_size',
|
||||
'delete_file',
|
||||
# Group/Player Management
|
||||
# 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,116 @@
|
||||
"""Background task execution for long-running operations."""
|
||||
import threading
|
||||
import logging
|
||||
from typing import Callable, Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_background_task(task_func: Callable, *args, **kwargs) -> threading.Thread:
|
||||
"""Run a function in a background thread, with a Flask app context pushed."""
|
||||
from flask import current_app
|
||||
# Capture the app instance now (in the request context) so the thread can use it
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def wrapper():
|
||||
with app.app_context():
|
||||
try:
|
||||
logger.info(f"Starting background task: {task_func.__name__}")
|
||||
task_func(*args, **kwargs)
|
||||
logger.info(f"Completed background task: {task_func.__name__}")
|
||||
except Exception as e:
|
||||
logger.error(f"Background task failed ({task_func.__name__}): {str(e)}", exc_info=True)
|
||||
|
||||
thread = threading.Thread(target=wrapper, daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def background_player_deployment(
|
||||
hostname: str,
|
||||
username: str,
|
||||
password: str,
|
||||
player_name: str,
|
||||
player_id: int,
|
||||
port: int = 22,
|
||||
server_url: str = None,
|
||||
server_api_key: str = None,
|
||||
player_hostname: str = None,
|
||||
quickconnect_code: str = None,
|
||||
orientation: str = 'Landscape',
|
||||
verify_ssl: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Deploy player code to host in background.
|
||||
|
||||
Args:
|
||||
hostname: SSH hostname/IP
|
||||
username: SSH username
|
||||
password: SSH password
|
||||
player_name: Player name
|
||||
player_id: Player database ID
|
||||
port: SSH port
|
||||
server_url: DigiServer URL for player
|
||||
server_api_key: API key for player
|
||||
player_hostname: Player screen identity used for auth (Player.hostname)
|
||||
quickconnect_code: Quick connect code used for auth
|
||||
orientation: Player orientation (Landscape/Portrait)
|
||||
verify_ssl: Whether the player should verify the server TLS certificate
|
||||
"""
|
||||
from app.utils.ssh_deploy import deploy_player_to_host
|
||||
from app.models import Player
|
||||
from app.extensions import db
|
||||
from app.utils.logger import log_action
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
# Mark deployment as "in progress" immediately so the UI can show live status
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
player.deployment_status = 'deploying'
|
||||
player.last_deployment_at = datetime.utcnow()
|
||||
player.last_deployment_status = None
|
||||
player.last_deployment_message = 'Deployment in progress...'
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
# Execute deployment
|
||||
result = deploy_player_to_host(
|
||||
hostname=hostname,
|
||||
username=username,
|
||||
password=password,
|
||||
player_name=player_name,
|
||||
port=port,
|
||||
server_url=server_url,
|
||||
server_api_key=server_api_key,
|
||||
player_hostname=player_hostname,
|
||||
quickconnect_code=quickconnect_code,
|
||||
orientation=orientation,
|
||||
verify_ssl=verify_ssl
|
||||
)
|
||||
|
||||
# Update player with deployment status
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
if result.get('success'):
|
||||
player.deployment_status = 'deployed'
|
||||
player.last_deployment_status = 'success'
|
||||
player.last_deployment_message = result.get('message', 'Deployment successful')
|
||||
log_action('info', f'Background deployment completed for player "{player_name}": {result["message"]}')
|
||||
else:
|
||||
player.deployment_status = 'failed'
|
||||
player.last_deployment_status = 'failed'
|
||||
player.last_deployment_message = result.get('error', result.get('message', 'Deployment failed'))
|
||||
log_action('error', f'Background deployment failed for player "{player_name}": {result.get("error", result.get("message"))}')
|
||||
|
||||
db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Background deployment error for player '{player_name}': {str(e)}", exc_info=True)
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
player.deployment_status = 'failed'
|
||||
player.last_deployment_status = 'failed'
|
||||
player.last_deployment_message = f'Deployment crashed: {str(e)}'
|
||||
db.session.commit()
|
||||
log_action('error', f'Background deployment error for player "{player_name}": {str(e)}')
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Caddy configuration generator and manager."""
|
||||
import os
|
||||
from typing import Optional
|
||||
from app.models.https_config import HTTPSConfig
|
||||
|
||||
# Shared reverse-proxy snippet used in every Caddy site block
|
||||
_PROXY_SNIPPET = """\
|
||||
reverse_proxy digiserver-app:5000 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
}
|
||||
}
|
||||
|
||||
request_body {
|
||||
max_size 2GB
|
||||
}
|
||||
|
||||
encode gzip
|
||||
|
||||
header {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/access.log
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CaddyConfigGenerator:
|
||||
"""Generate Caddyfile configuration based on HTTPSConfig."""
|
||||
|
||||
@staticmethod
|
||||
def generate_caddyfile(config: Optional['HTTPSConfig'] = None,
|
||||
http_fallback: bool = True,
|
||||
http_port: int = 80,
|
||||
https_port: int = 443) -> str:
|
||||
"""Generate a complete Caddyfile.
|
||||
|
||||
Design goals
|
||||
------------
|
||||
* **One HTTP endpoint** (port 80) that always answers, whatever the Host
|
||||
header is — so ``http://<ip>`` and ``http://<hostname>`` both work.
|
||||
* **HTTPS on port 443** for the same names when it is enabled.
|
||||
* If HTTPS is disabled or never configured, port 80 simply serves the
|
||||
app — there is no separate "HTTP mode" to configure.
|
||||
|
||||
Behaviour by configuration
|
||||
--------------------------
|
||||
* HTTPS off, or no address configured → plain HTTP on ``:http_port``.
|
||||
* HTTPS on → the app is served on port 80 for every configured name and
|
||||
on port 443 over TLS. Whether port 80 *serves* or *redirects* to
|
||||
HTTPS is controlled by ``http_fallback``.
|
||||
|
||||
Which certificate each name gets
|
||||
--------------------------------
|
||||
* ``domain`` (when set) → Caddy obtains a certificate automatically
|
||||
(Let's Encrypt/ACME). Only valid for a **publicly resolvable** name.
|
||||
* ``ip_address`` / ``hostname`` → ``tls internal`` (Caddy's local CA).
|
||||
This needs no public DNS and no ACME, which is the right choice for an
|
||||
intranet name such as ``digiserver.sibiusb.harting.intra``.
|
||||
|
||||
Args:
|
||||
config: HTTPSConfig instance, or None to load from the database.
|
||||
http_fallback: When True, port 80 keeps *serving* the app alongside
|
||||
HTTPS. This is the resilient default: clients that cannot trust
|
||||
the internal CA (e.g. a Kivy player with ``verify_ssl: true``)
|
||||
are still able to connect. When False, port 80 issues a 301
|
||||
redirect to HTTPS instead.
|
||||
http_port: Port Caddy listens on for plain HTTP (default 80).
|
||||
https_port: Port used to build redirect targets when
|
||||
``http_fallback`` is False (default 443).
|
||||
|
||||
Returns:
|
||||
The complete Caddyfile as a string.
|
||||
"""
|
||||
if config is None:
|
||||
config = HTTPSConfig.get_config()
|
||||
|
||||
email = (config.email or "admin@localhost") if config else "admin@localhost"
|
||||
https_enabled = bool(config.https_enabled) if config else False
|
||||
domain = (config.domain or "").strip() if config else ""
|
||||
ip_address = (config.ip_address or "").strip() if config else ""
|
||||
hostname = (config.hostname or "").strip() if config else ""
|
||||
|
||||
# Every name the server should answer to, in priority order, without
|
||||
# duplicates. The IP comes first because it always resolves.
|
||||
names: list[str] = []
|
||||
for candidate in (ip_address, hostname, domain):
|
||||
if candidate and candidate not in names:
|
||||
names.append(candidate)
|
||||
|
||||
global_block = f"{{\n admin 0.0.0.0:2019\n email {email}\n"
|
||||
|
||||
# ── TLS with no SNI ────────────────────────────────────────────────
|
||||
# Browsers do NOT send SNI when the URL is an IP address (an IP is not
|
||||
# a valid SNI hostname). Without a fallback Caddy would identify such a
|
||||
# connection by the container's own internal IP, match no certificate
|
||||
# and abort the handshake with:
|
||||
# "no certificate available for '<container-ip>'"
|
||||
# `default_sni` makes a SNI-less ClientHello resolve to a name we do
|
||||
# serve, so https://<ip> works in the browser.
|
||||
if https_enabled and not domain and ip_address:
|
||||
global_block += f" default_sni {ip_address}\n"
|
||||
|
||||
global_block += "}\n\n"
|
||||
|
||||
# ── Plain HTTP only: HTTPS disabled, or no address to certify ───────
|
||||
if not (https_enabled and names):
|
||||
return global_block + f":{http_port} {{\n{_PROXY_SNIPPET}}}\n"
|
||||
|
||||
caddyfile = global_block
|
||||
|
||||
# ── Port 80: catch-all so ANY Host header is answered ──────────────
|
||||
# Without this, a request for an unexpected name (e.g. a bare IP when
|
||||
# only a hostname is configured) would hit no site block and fail.
|
||||
caddyfile += f":{http_port} {{\n{_PROXY_SNIPPET}}}\n\n"
|
||||
|
||||
# ── Port 80: explicit per-name blocks ──────────────────────────────
|
||||
for name in names:
|
||||
if http_fallback:
|
||||
caddyfile += f"http://{name} {{\n{_PROXY_SNIPPET}}}\n\n"
|
||||
else:
|
||||
# Redirect to the port the host actually publishes.
|
||||
https_url = (f"https://{name}" if https_port == 443
|
||||
else f"https://{name}:{https_port}")
|
||||
caddyfile += f"http://{name} {{\n redir {https_url}{{uri}} 301\n}}\n\n"
|
||||
|
||||
# ── Port 443: TLS listeners ────────────────────────────────────────
|
||||
for name in names:
|
||||
if domain and name == domain:
|
||||
# Public name → let Caddy obtain a real certificate.
|
||||
caddyfile += f"https://{name} {{\n{_PROXY_SNIPPET}}}\n\n"
|
||||
else:
|
||||
# IP or intranet name → Caddy's internal CA.
|
||||
caddyfile += (f"https://{name} {{\n tls internal\n"
|
||||
f"{_PROXY_SNIPPET}}}\n\n")
|
||||
|
||||
return caddyfile
|
||||
|
||||
@staticmethod
|
||||
def write_caddyfile(caddyfile_content: str,
|
||||
path: str = '/etc/caddy/Caddyfile') -> bool:
|
||||
"""Write Caddyfile to disk.
|
||||
|
||||
The default path is /etc/caddy/Caddyfile — the standard location inside
|
||||
the caddy:2-alpine container when a volume is mounted there.
|
||||
"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
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:
|
||||
"""Push the current Caddyfile to Caddy via its admin API (/load).
|
||||
|
||||
Caddy applies the new config live without dropping connections.
|
||||
"""
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
caddyfile_path = '/etc/caddy/Caddyfile'
|
||||
if not os.path.exists(caddyfile_path):
|
||||
print(f"Caddyfile not found at {caddyfile_path}")
|
||||
return False
|
||||
|
||||
with open(caddyfile_path, 'rb') as f:
|
||||
caddyfile_bytes = f.read()
|
||||
|
||||
req = urllib.request.Request(
|
||||
'http://caddy:2019/load',
|
||||
data=caddyfile_bytes,
|
||||
headers={'Content-Type': 'text/caddyfile'},
|
||||
method='POST',
|
||||
)
|
||||
response = urllib.request.urlopen(req, timeout=10)
|
||||
return response.status == 200
|
||||
except Exception as e:
|
||||
print(f"Caddy reload error: {str(e)}")
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
"""Group and player management utilities."""
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
"""Player status utilities.
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Player, Group, PlayerFeedback
|
||||
from app.utils.logger import log_action
|
||||
Note: the group-management helpers that used to live here were removed along
|
||||
with the deprecated Group subsystem (the ``group`` table had no rows and the
|
||||
``/api/groups`` endpoint had already been archived).
|
||||
"""
|
||||
from typing import Dict
|
||||
from datetime import datetime
|
||||
|
||||
from app.models import Player, PlayerFeedback
|
||||
|
||||
|
||||
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,
|
||||
@@ -25,18 +28,18 @@ def get_player_status_info(player_id: int) -> Dict:
|
||||
'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,
|
||||
@@ -51,154 +54,18 @@ def get_player_status_info(player_id: int) -> Dict:
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
|
||||
+25
-4
@@ -23,21 +23,42 @@ def log_action(level: str, message: str) -> None:
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
def get_recent_logs(limit: int = 20, level: Optional[str] = None) -> list:
|
||||
def get_recent_logs(limit: int = 20, level: Optional[str] = None,
|
||||
exclude_prefix: Optional[str] = None,
|
||||
include_prefix: Optional[str] = None) -> list:
|
||||
"""Get the most recent log entries.
|
||||
|
||||
|
||||
Args:
|
||||
limit: Maximum number of logs to return
|
||||
level: Optional filter by log level
|
||||
|
||||
exclude_prefix: Drop entries whose message starts with this text.
|
||||
Useful for high-frequency noise: player feedback alone is ~84% of
|
||||
all rows, so over-fetching a window and filtering in Python makes
|
||||
the number of *useful* entries depend on heartbeat volume. Filtering
|
||||
in SQL keeps a busy fleet from starving the result.
|
||||
include_prefix: Keep only entries whose message starts with this text.
|
||||
|
||||
Returns:
|
||||
List of ServerLog instances
|
||||
List of ServerLog instances, newest first
|
||||
|
||||
Note:
|
||||
Prefixes are escaped before reaching SQL LIKE (via ``autoescape``), so a
|
||||
literal '%' or '_' inside the prefix matches itself rather than acting
|
||||
as a wildcard.
|
||||
"""
|
||||
query = ServerLog.query
|
||||
|
||||
if level:
|
||||
query = query.filter_by(level=level)
|
||||
|
||||
if exclude_prefix:
|
||||
query = query.filter(
|
||||
~ServerLog.message.startswith(exclude_prefix, autoescape=True))
|
||||
|
||||
if include_prefix:
|
||||
query = query.filter(
|
||||
ServerLog.message.startswith(include_prefix, autoescape=True))
|
||||
|
||||
return query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Utilities for building/staging the player files on the server.
|
||||
|
||||
Admins use the "Build player files" admin page to:
|
||||
* clone/refresh the player source code from a git repository into a local
|
||||
staged directory (``PLAYER_CODE_DIR``), and
|
||||
* write a base ``config/app_config.json`` so the staged code already knows how
|
||||
to reach this server.
|
||||
|
||||
The SSH deployment flow then ships this staged directory to player devices, so
|
||||
the version admins build here is exactly what gets deployed.
|
||||
|
||||
Performance note
|
||||
----------------
|
||||
The player repository is large (~200 MB) and a full clone takes ~90 s. Because
|
||||
the build runs inside an HTTP request, that would exceed gunicorn's worker
|
||||
timeout and the worker would be killed mid-clone, leaving a broken checkout.
|
||||
Two mitigations are used together:
|
||||
|
||||
* **Shallow clones** (``--depth 1``) — only the tip of the requested branch is
|
||||
fetched, which is all a deployment needs. Drastically reduces transfer size.
|
||||
* **Background execution** — the admin route starts the build in a daemon
|
||||
thread and the page polls for progress, so no worker ever blocks on git.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.utils.ssh_deploy import generate_app_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Metadata file name stored in the Flask instance folder.
|
||||
BUILD_META_FILENAME = 'player_build.json'
|
||||
|
||||
# Only the tip of the branch is needed to deploy a player, so history is not
|
||||
# fetched. Keeps the transfer small enough to avoid worker timeouts.
|
||||
CLONE_DEPTH = '1'
|
||||
|
||||
# Never let git wait for a human. Without this, a private/renamed repository
|
||||
# makes git block on a username prompt until the worker is killed.
|
||||
GIT_ENV = {
|
||||
'GIT_TERMINAL_PROMPT': '0', # never prompt for credentials
|
||||
'GIT_ASKPASS': 'true', # answer any credential request immediately
|
||||
'GIT_SSH_COMMAND': 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new',
|
||||
}
|
||||
|
||||
|
||||
def _git_env() -> Dict[str, str]:
|
||||
"""Environment for git subprocesses: inherit the process env plus our flags."""
|
||||
env = dict(os.environ)
|
||||
env.update(GIT_ENV)
|
||||
return env
|
||||
|
||||
|
||||
def _run_git(args, cwd=None, timeout=120) -> subprocess.CompletedProcess:
|
||||
"""Run a git command, never prompting for input.
|
||||
|
||||
Args:
|
||||
args: git arguments (without the leading 'git').
|
||||
cwd: working directory for the command.
|
||||
timeout: hard cap in seconds. Defaults to 120 to stay within a
|
||||
reasonable window even when running in the foreground.
|
||||
|
||||
Returns:
|
||||
The completed process. ``returncode`` is 124 on timeout so callers can
|
||||
distinguish a timeout from a normal failure.
|
||||
"""
|
||||
try:
|
||||
return subprocess.run(
|
||||
['git'] + args,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=_git_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
# Surface timeouts as a normal result so callers do not need try/except.
|
||||
out = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or '')
|
||||
err = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or '')
|
||||
return subprocess.CompletedProcess(
|
||||
args=['git'] + list(args), returncode=124,
|
||||
stdout=out, stderr=(err + f'\ngit {" ".join(args)} timed out after {timeout}s').strip(),
|
||||
)
|
||||
|
||||
|
||||
def get_short_head(player_code_dir: str) -> str:
|
||||
"""Return the short git commit of the staged code, or 'unknown'."""
|
||||
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def is_valid_checkout(path: str) -> bool:
|
||||
"""True when *path* is a usable git checkout with a resolvable HEAD."""
|
||||
if not os.path.isdir(os.path.join(path, '.git')):
|
||||
return False
|
||||
return get_short_head(path) != 'unknown'
|
||||
|
||||
|
||||
def _clone(path: str, repo_url: str, branch: str) -> subprocess.CompletedProcess:
|
||||
"""Shallow-clone a single branch into *path*."""
|
||||
return _run_git([
|
||||
'clone', '--depth', CLONE_DEPTH, '--single-branch',
|
||||
'--branch', branch, repo_url, path,
|
||||
], timeout=600)
|
||||
|
||||
|
||||
def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]:
|
||||
"""Clone or refresh the player source into ``player_code_dir``.
|
||||
|
||||
Uses a **shallow single-branch clone/update** so only the tip of the wanted
|
||||
branch is transferred. If the directory is a usable checkout it is updated
|
||||
(fetch + hard reset to the branch). A directory that exists but is NOT a
|
||||
usable checkout — e.g. left behind by an interrupted clone — is removed and
|
||||
re-cloned, since updating it can never work.
|
||||
|
||||
Args:
|
||||
player_code_dir: Destination directory for the staged player code.
|
||||
repo_url: Git repository to pull from.
|
||||
branch: Branch to stage.
|
||||
|
||||
Returns:
|
||||
``{'success': bool, 'message': str, 'version': str|None, 'branch': str}``
|
||||
"""
|
||||
branch = (branch or 'main').strip()
|
||||
repo_url = (repo_url or '').strip()
|
||||
|
||||
def fail(message: str) -> Dict[str, Any]:
|
||||
return {'success': False, 'message': message,
|
||||
'version': get_short_head(player_code_dir), 'branch': branch}
|
||||
|
||||
if not repo_url:
|
||||
return {'success': False, 'message': 'Repository URL is required.',
|
||||
'version': None, 'branch': branch}
|
||||
|
||||
try:
|
||||
usable = is_valid_checkout(player_code_dir)
|
||||
|
||||
if usable:
|
||||
# Update in place. Depth 1 keeps the update cheap; fetch by ref so
|
||||
# it works on a shallow clone.
|
||||
fetch = _run_git(
|
||||
['-C', player_code_dir, 'fetch', '--depth', CLONE_DEPTH,
|
||||
'--prune', 'origin', branch])
|
||||
if fetch.returncode != 0:
|
||||
return fail(f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}')
|
||||
|
||||
# Point origin at the requested URL in case it changed.
|
||||
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
|
||||
|
||||
checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
|
||||
if checkout.returncode != 0:
|
||||
return fail(f'git checkout {branch} failed: {checkout.stderr.strip()}')
|
||||
|
||||
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
|
||||
if reset.returncode != 0:
|
||||
return fail(f'git reset failed: {reset.stderr.strip()}')
|
||||
|
||||
action = 'Updated'
|
||||
else:
|
||||
# Fresh clone. A previous attempt may have left a partial directory
|
||||
# (e.g. killed mid-clone) — it must go, or the clone will fail with
|
||||
# "destination path already exists and is not an empty directory".
|
||||
parent = os.path.dirname(player_code_dir.rstrip('/'))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
if os.path.exists(player_code_dir):
|
||||
logger.info('Removing unusable directory before clone: %s', player_code_dir)
|
||||
shutil.rmtree(player_code_dir, ignore_errors=True)
|
||||
|
||||
clone = _clone(player_code_dir, repo_url, branch)
|
||||
if clone.returncode != 0:
|
||||
# Do not leave a half-written directory behind.
|
||||
shutil.rmtree(player_code_dir, ignore_errors=True)
|
||||
detail = clone.stderr.strip() or clone.stdout.strip()
|
||||
if clone.returncode == 124 or 'timed out' in detail:
|
||||
return fail(f'git clone timed out. The repository may be very '
|
||||
f'large or unreachable: {detail}')
|
||||
return fail(f'git clone failed: {detail}')
|
||||
|
||||
action = 'Cloned'
|
||||
|
||||
version = get_short_head(player_code_dir)
|
||||
logger.info('%s player code from %s (%s) -> %s', action, repo_url, branch, version)
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'{action} player code from {branch} (version {version}).',
|
||||
'version': version,
|
||||
'branch': branch,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 - surface any failure
|
||||
logger.exception('build_player_files failed')
|
||||
# Never leave a broken checkout behind for the next attempt.
|
||||
try:
|
||||
if not is_valid_checkout(player_code_dir):
|
||||
shutil.rmtree(player_code_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
return fail(f'Build failed: {str(e)}')
|
||||
|
||||
|
||||
def write_base_config(
|
||||
player_code_dir: str,
|
||||
server_ip: str,
|
||||
port: str,
|
||||
use_https: bool = True,
|
||||
verify_ssl: bool = False,
|
||||
orientation: str = 'Landscape',
|
||||
max_resolution: str = '1920x1080',
|
||||
) -> Dict[str, Any]:
|
||||
"""Write a base ``config/app_config.json`` into the staged player code.
|
||||
|
||||
``screen_name`` and ``quickconnect_key`` are left blank on purpose: they are
|
||||
per-player and get filled in by the SSH deploy step for each device.
|
||||
"""
|
||||
try:
|
||||
config_dir = os.path.join(player_code_dir, 'config')
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
content = generate_app_config(
|
||||
server_ip=server_ip,
|
||||
port=str(port),
|
||||
screen_name='',
|
||||
quickconnect_code='',
|
||||
orientation=orientation,
|
||||
use_https=use_https,
|
||||
verify_ssl=verify_ssl,
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
config_path = os.path.join(config_dir, 'app_config.json')
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
logger.info('Wrote base player config -> %s', config_path)
|
||||
return {'success': True, 'message': 'Base player config written.', 'path': config_path}
|
||||
except Exception as e:
|
||||
logger.exception('write_base_config failed')
|
||||
return {'success': False, 'message': f'Failed to write config: {str(e)}'}
|
||||
|
||||
|
||||
def load_build_settings(meta_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load saved build settings from ``meta_path`` (or None if absent/invalid)."""
|
||||
try:
|
||||
if os.path.isfile(meta_path):
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning('Could not read build settings: %s', e)
|
||||
return None
|
||||
|
||||
|
||||
def save_build_settings(meta_path: str, data: Dict[str, Any]) -> bool:
|
||||
"""Persist build settings to ``meta_path``."""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(meta_path), exist_ok=True)
|
||||
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning('Could not save build settings: %s', e)
|
||||
return False
|
||||
|
||||
|
||||
def get_player_server_settings(meta_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the saved server address settings for deployment, if available.
|
||||
|
||||
Returns a dict with ``server_ip``, ``port`` (str), ``use_https`` (bool) and
|
||||
``verify_ssl`` (bool), or None when no usable build settings are saved.
|
||||
"""
|
||||
settings = load_build_settings(meta_path)
|
||||
if not settings:
|
||||
return None
|
||||
server_ip = (settings.get('server_ip') or '').strip()
|
||||
if not server_ip:
|
||||
return None
|
||||
return {
|
||||
'server_ip': server_ip,
|
||||
'port': str(settings.get('port') or ('443' if settings.get('use_https', True) else '80')),
|
||||
'use_https': bool(settings.get('use_https', True)),
|
||||
'verify_ssl': bool(settings.get('verify_ssl', False)),
|
||||
}
|
||||
|
||||
|
||||
def make_build_record(repo_url, branch, server_ip, port, use_https, verify_ssl,
|
||||
orientation, max_resolution, version, built_by) -> Dict[str, Any]:
|
||||
"""Assemble the metadata record to persist after a build."""
|
||||
return {
|
||||
'repo_url': repo_url,
|
||||
'branch': branch,
|
||||
'server_ip': server_ip,
|
||||
'port': str(port),
|
||||
'use_https': bool(use_https),
|
||||
'verify_ssl': bool(verify_ssl),
|
||||
'orientation': orientation,
|
||||
'max_resolution': max_resolution,
|
||||
'built_version': version,
|
||||
'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
|
||||
'built_by': built_by,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background builds
|
||||
#
|
||||
# A full clone/refresh takes far longer than gunicorn's worker timeout, so the
|
||||
# build must not run inside the request. The admin route starts it here and the
|
||||
# page polls `build_state()` for progress.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Serialises writes to _build_state between the request thread and the worker.
|
||||
_build_lock = threading.Lock()
|
||||
|
||||
# Coarse progress for the admin UI. 'state' is one of:
|
||||
# idle | running | success | error
|
||||
_build_state: Dict[str, Any] = {'state': 'idle'}
|
||||
|
||||
|
||||
def get_build_state() -> Dict[str, Any]:
|
||||
"""Return a snapshot of the current/last build for the admin UI."""
|
||||
with _build_lock:
|
||||
return dict(_build_state)
|
||||
|
||||
|
||||
def is_build_running() -> bool:
|
||||
"""True while a build is in progress."""
|
||||
with _build_lock:
|
||||
return _build_state.get('state') == 'running'
|
||||
|
||||
|
||||
def _set_build_state(**fields: Any) -> None:
|
||||
with _build_lock:
|
||||
_build_state.update(fields)
|
||||
|
||||
|
||||
def _run_build_job(app, player_code_dir: str, repo_url: str, branch: str,
|
||||
config_payload: Optional[Dict[str, Any]],
|
||||
meta_path: str, built_by: str) -> None:
|
||||
"""Worker body: build files, optionally write config, then persist settings.
|
||||
|
||||
Runs in a daemon thread with its own Flask app context so it is independent
|
||||
of the request/response cycle that triggered it.
|
||||
"""
|
||||
started = datetime.utcnow()
|
||||
try:
|
||||
_set_build_state(state='running', step='Fetching player source…',
|
||||
started_at=started.isoformat(timespec='seconds') + 'Z',
|
||||
message='', version=None)
|
||||
|
||||
result = build_player_files(player_code_dir, repo_url, branch)
|
||||
version = result.get('version')
|
||||
|
||||
if not result['success']:
|
||||
_set_build_state(state='error', step='', message=result['message'],
|
||||
version=version,
|
||||
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
|
||||
logger.error('Background player build failed: %s', result['message'])
|
||||
return
|
||||
|
||||
# Optional step 2: write the base config.
|
||||
if config_payload:
|
||||
_set_build_state(step='Writing player config…')
|
||||
cfg = write_base_config(player_code_dir=player_code_dir, **config_payload)
|
||||
if not cfg['success']:
|
||||
_set_build_state(state='error', step='', message=cfg['message'],
|
||||
version=version,
|
||||
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
|
||||
logger.error('Background player config write failed: %s', cfg['message'])
|
||||
return
|
||||
result = {**result, 'message': f"{result['message']} {cfg['message']}"}
|
||||
|
||||
if version is None:
|
||||
version = get_short_head(player_code_dir)
|
||||
|
||||
save_build_settings(
|
||||
meta_path,
|
||||
make_build_record(
|
||||
repo_url=repo_url, branch=branch,
|
||||
server_ip=(config_payload or {}).get('server_ip', ''),
|
||||
port=(config_payload or {}).get('port', ''),
|
||||
use_https=(config_payload or {}).get('use_https', False),
|
||||
verify_ssl=(config_payload or {}).get('verify_ssl', False),
|
||||
orientation=(config_payload or {}).get('orientation', 'Landscape'),
|
||||
max_resolution=(config_payload or {}).get('max_resolution', '1920x1080'),
|
||||
version=version, built_by=built_by,
|
||||
),
|
||||
)
|
||||
|
||||
_set_build_state(state='success', step='', message=result['message'],
|
||||
version=version,
|
||||
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
|
||||
logger.info('Background player build complete (version %s)', version)
|
||||
except Exception as e: # noqa: BLE001 - never kill the thread silently
|
||||
logger.exception('Background player build crashed')
|
||||
_set_build_state(state='error', step='', message=f'Build failed: {e}',
|
||||
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
|
||||
|
||||
|
||||
def start_background_build(player_code_dir: str, repo_url: str, branch: str,
|
||||
config_payload: Optional[Dict[str, Any]],
|
||||
meta_path: str, built_by: str) -> bool:
|
||||
"""Start a player build in a daemon thread.
|
||||
|
||||
Args:
|
||||
player_code_dir: Where to stage the player source.
|
||||
repo_url: Git repository URL.
|
||||
branch: Branch to stage.
|
||||
config_payload: Keyword args for :func:`write_base_config`, or None to
|
||||
skip writing the config.
|
||||
meta_path: Where to persist the build record.
|
||||
built_by: Username shown in the UI/logs.
|
||||
|
||||
Returns:
|
||||
False if a build is already running (callers should tell the user),
|
||||
True if a new build was started.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if called with no Flask application context — the worker
|
||||
thread needs a real app object to push its own context.
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
if is_build_running():
|
||||
return False
|
||||
|
||||
# Capture the real app object now. `current_app` resolves inside either a
|
||||
# request or a plain application context; the worker thread pushes its own
|
||||
# context later, since the caller's context is gone by then.
|
||||
app = current_app._get_current_object()
|
||||
|
||||
_set_build_state(state='running', step='Starting…', message='', version=None,
|
||||
started_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z',
|
||||
finished_at=None, built_by=built_by,
|
||||
repo_url=repo_url, branch=branch)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_build_job,
|
||||
args=(app, player_code_dir, repo_url, branch, config_payload, meta_path, built_by),
|
||||
name='player-build',
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return True
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
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:
|
||||
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='admin' if role == 'admin' else 'user',
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
except Exception:
|
||||
return None
|
||||
@@ -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,753 @@
|
||||
"""SSH deployment utilities for player provisioning."""
|
||||
import subprocess
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from typing import Tuple, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pre-staged player code location in container
|
||||
LOCAL_PLAYER_CODE_DIR = '/app/data/player'
|
||||
|
||||
|
||||
def get_local_player_code_status(player_code_dir: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Check status of pre-staged player code.
|
||||
|
||||
Args:
|
||||
player_code_dir: Optional override for the staged code path. Defaults to
|
||||
``LOCAL_PLAYER_CODE_DIR`` (the container location).
|
||||
|
||||
Returns:
|
||||
Dict with availability, version, and path info
|
||||
"""
|
||||
code_dir = player_code_dir or LOCAL_PLAYER_CODE_DIR
|
||||
try:
|
||||
if not os.path.isdir(code_dir):
|
||||
return {
|
||||
'available': False,
|
||||
'reason': 'Directory not found',
|
||||
'path': code_dir
|
||||
}
|
||||
|
||||
# Check if git repository
|
||||
git_dir = os.path.join(code_dir, '.git')
|
||||
if not os.path.isdir(git_dir):
|
||||
return {
|
||||
'available': False,
|
||||
'reason': 'Not a git repository',
|
||||
'path': code_dir
|
||||
}
|
||||
|
||||
# Get current git version
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', '-C', code_dir, 'rev-parse', '--short', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
version = result.stdout.strip() if result.returncode == 0 else 'unknown'
|
||||
except:
|
||||
version = 'unknown'
|
||||
|
||||
# Get directory size
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['du', '-sh', code_dir],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
size = result.stdout.split()[0] if result.returncode == 0 else 'unknown'
|
||||
except:
|
||||
size = 'unknown'
|
||||
|
||||
return {
|
||||
'available': True,
|
||||
'path': code_dir,
|
||||
'version': version,
|
||||
'size': size,
|
||||
'updated': os.path.getmtime(git_dir),
|
||||
'reason': 'Pre-staged code ready for deployment'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f'Error checking player code status: {str(e)}')
|
||||
return {
|
||||
'available': False,
|
||||
'reason': f'Status check failed: {str(e)}',
|
||||
'path': code_dir
|
||||
}
|
||||
|
||||
|
||||
def test_ssh_connection(hostname: str, username: str, password: str, port: int = 22) -> Dict[str, Any]:
|
||||
"""
|
||||
Test SSH connection to a remote host.
|
||||
|
||||
Args:
|
||||
hostname: Target hostname or IP
|
||||
username: SSH username
|
||||
password: SSH password
|
||||
port: SSH port (default 22)
|
||||
|
||||
Returns:
|
||||
Dict with status, message, and timestamp
|
||||
"""
|
||||
try:
|
||||
# Use sshpass to test connection without interactive prompt
|
||||
cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
'echo "SSH connection successful"'
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'SSH connection successful to {hostname}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'output': result.stdout.strip()
|
||||
}
|
||||
else:
|
||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'SSH connection failed: {error_msg}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': error_msg
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'SSH connection timeout to {hostname}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': 'Connection timeout (10s)'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'SSH test error: {str(e)}')
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'SSH connection error: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def generate_player_config(
|
||||
player_name: str,
|
||||
server_url: str,
|
||||
api_key: str,
|
||||
player_id: str = None,
|
||||
location: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Generate player configuration JSON for connecting to DigiServer.
|
||||
|
||||
Args:
|
||||
player_name: Name of the player
|
||||
server_url: DigiServer base URL (e.g., http://localhost/digiserver)
|
||||
api_key: API authentication key
|
||||
player_id: Optional player ID (defaults to player_name)
|
||||
location: Optional player location/description
|
||||
|
||||
Returns:
|
||||
JSON configuration string
|
||||
"""
|
||||
config = {
|
||||
"player": {
|
||||
"name": player_name,
|
||||
"id": player_id or player_name,
|
||||
"location": location or "",
|
||||
"version": "2.0"
|
||||
},
|
||||
"server": {
|
||||
"url": server_url,
|
||||
"api_endpoint": f"{server_url}/api",
|
||||
"authentication": {
|
||||
"type": "api_key",
|
||||
"key": api_key
|
||||
},
|
||||
"endpoints": {
|
||||
"playlists": f"{server_url}/api/playlists",
|
||||
"content": f"{server_url}/api/content",
|
||||
"schedule": f"{server_url}/api/schedule",
|
||||
"heartbeat": f"{server_url}/api/player/heartbeat",
|
||||
"logs": f"{server_url}/api/player/logs"
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"audio_enabled": True,
|
||||
"video_enabled": True,
|
||||
"max_resolution": "4K",
|
||||
"refresh_interval": 60,
|
||||
"rotation": "0"
|
||||
},
|
||||
"networking": {
|
||||
"timeout": 30,
|
||||
"retry_count": 3,
|
||||
"retry_delay": 5
|
||||
}
|
||||
}
|
||||
|
||||
return json.dumps(config, indent=2)
|
||||
|
||||
|
||||
def detect_server_ip() -> Optional[str]:
|
||||
"""Best-effort detection of this server's primary LAN IP address.
|
||||
|
||||
Opens a UDP socket toward a public address (no packets are actually sent)
|
||||
and reads the local socket address, which resolves to the IP of the
|
||||
interface used for outbound traffic. Returns None on failure.
|
||||
"""
|
||||
import socket
|
||||
s = None
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
ip = s.getsockname()[0]
|
||||
if ip and not ip.startswith('127.'):
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if s is not None:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback via hostname resolution.
|
||||
try:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
if ip and not ip.startswith('127.'):
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def parse_server_address(server_url: str) -> Dict[str, Any]:
|
||||
"""Derive the values the player needs from a DigiServer URL.
|
||||
|
||||
The player's config/app_config.json stores server_ip + port + use_https and
|
||||
builds requests as ``{scheme}://{server_ip}:{port}/api/...`` (it does NOT use
|
||||
any URL path prefix such as ``/digiserver``). This helper extracts the host,
|
||||
port and scheme from a server URL and drops any path component.
|
||||
|
||||
Args:
|
||||
server_url: e.g. ``https://signage.example.com/digiserver`` or
|
||||
``http://192.168.0.50:8080``
|
||||
|
||||
Returns:
|
||||
Dict with ``server_ip`` (str), ``port`` (str) and ``use_https`` (bool).
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(server_url if '://' in (server_url or '') else f'//{server_url}')
|
||||
use_https = (parsed.scheme or 'https') == 'https'
|
||||
host = parsed.hostname or ''
|
||||
port = parsed.port
|
||||
if port is None:
|
||||
port = 443 if use_https else 80
|
||||
return {'server_ip': host, 'port': str(port), 'use_https': use_https}
|
||||
|
||||
|
||||
def generate_app_config(
|
||||
server_ip: str,
|
||||
port: str,
|
||||
screen_name: str,
|
||||
quickconnect_code: str,
|
||||
orientation: str = 'Landscape',
|
||||
use_https: bool = True,
|
||||
verify_ssl: bool = False,
|
||||
max_resolution: str = '1920x1080',
|
||||
) -> str:
|
||||
"""Generate the config/app_config.json the player actually reads.
|
||||
|
||||
This is what binds a deployed player to the real server and its assigned
|
||||
playlist: the player authenticates with ``screen_name`` + ``quickconnect_code``
|
||||
and the server returns the playlist assigned to that player.
|
||||
|
||||
Args:
|
||||
server_ip: Server IP or domain the player should contact.
|
||||
port: Server port as a string.
|
||||
screen_name: Player hostname / screen identity (matches Player.hostname).
|
||||
quickconnect_code: Quick connect code (matches Player.quickconnect_code).
|
||||
orientation: Landscape or Portrait.
|
||||
use_https: Whether the player should use HTTPS.
|
||||
verify_ssl: Whether the player should verify the TLS certificate.
|
||||
max_resolution: Maximum playback resolution.
|
||||
|
||||
Returns:
|
||||
JSON configuration string.
|
||||
"""
|
||||
config = {
|
||||
'server_ip': server_ip,
|
||||
'port': str(port),
|
||||
'screen_name': screen_name,
|
||||
'quickconnect_key': quickconnect_code,
|
||||
'orientation': orientation or 'Landscape',
|
||||
'touch': 'True',
|
||||
'max_resolution': max_resolution,
|
||||
'edit_feature_enabled': True,
|
||||
'use_https': bool(use_https),
|
||||
'verify_ssl': bool(verify_ssl),
|
||||
}
|
||||
return json.dumps(config, indent=2)
|
||||
|
||||
|
||||
def deploy_player_to_host(
|
||||
hostname: str,
|
||||
username: str,
|
||||
password: str,
|
||||
player_name: str,
|
||||
repo_url: str = 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git',
|
||||
deploy_path: str = None, # Default: /home/[user]/kiwy-signage
|
||||
port: int = 22,
|
||||
server_url: str = None, # DigiServer URL for player to connect to
|
||||
server_api_key: str = None, # API key for player authentication
|
||||
player_hostname: str = None, # Player screen identity (Player.hostname)
|
||||
quickconnect_code: str = None, # Player quick connect code
|
||||
orientation: str = 'Landscape', # Player orientation
|
||||
verify_ssl: bool = False, # Whether the player should verify TLS
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Deploy player code to remote host.
|
||||
|
||||
Args:
|
||||
hostname: Target hostname or IP
|
||||
username: SSH username
|
||||
password: SSH password
|
||||
player_name: Name for the player instance
|
||||
repo_url: Git repository URL
|
||||
deploy_path: Path where to deploy on remote host (default: /home/[user]/kiwy-signage)
|
||||
port: SSH port (default 22)
|
||||
server_url: DigiServer URL for player connection
|
||||
server_api_key: API key for player authentication
|
||||
player_hostname: Player screen identity used for auth (Player.hostname)
|
||||
quickconnect_code: Quick connect code used for auth (Player.quickconnect_code)
|
||||
orientation: Player orientation (Landscape/Portrait)
|
||||
verify_ssl: Whether the player should verify the server TLS certificate
|
||||
|
||||
Returns:
|
||||
Dict with deployment status and output
|
||||
"""
|
||||
# Set default deployment path to user's home directory
|
||||
if deploy_path is None:
|
||||
deploy_path = f'/home/{username}/kiwy-signage'
|
||||
try:
|
||||
# Step 1: Verify host accessibility
|
||||
test_result = test_ssh_connection(hostname, username, password, port)
|
||||
if not test_result['success']:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Cannot deploy: SSH connection failed',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': test_result['message'],
|
||||
'steps': []
|
||||
}
|
||||
|
||||
steps = [
|
||||
{
|
||||
'step': 'SSH Connection Test',
|
||||
'status': 'completed',
|
||||
'message': 'SSH connection successful',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
]
|
||||
|
||||
# Step 2: Create deployment directory
|
||||
try:
|
||||
mkdir_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'mkdir -p {deploy_path}'
|
||||
]
|
||||
result = subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=30)
|
||||
steps.append({
|
||||
'step': 'Create Deploy Directory',
|
||||
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||
'message': f'Directory {deploy_path} created',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
steps.append({
|
||||
'step': 'Create Deploy Directory',
|
||||
'status': 'failed',
|
||||
'message': f'Failed: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Deployment failed at step: Create Deploy Directory',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': str(e),
|
||||
'steps': steps
|
||||
}
|
||||
|
||||
# Step 3: Deploy code (use local if available, otherwise clone from git)
|
||||
try:
|
||||
code_status = get_local_player_code_status()
|
||||
|
||||
if code_status['available']:
|
||||
# Use pre-staged player code via rsync
|
||||
logger.info(f'Using pre-staged player code (version: {code_status.get("version", "unknown")})')
|
||||
|
||||
rsync_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'rsync', '-avz',
|
||||
'--delete',
|
||||
'-e', f'ssh -o StrictHostKeyChecking=no -p {port}',
|
||||
f'{LOCAL_PLAYER_CODE_DIR}/',
|
||||
f'{username}@{hostname}:{deploy_path}/'
|
||||
]
|
||||
result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=300)
|
||||
|
||||
steps.append({
|
||||
'step': 'Deploy Code',
|
||||
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||
'message': f'Code deployed via rsync (version: {code_status.get("version", "local")})',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.warning(f'Rsync failed, falling back to git clone: {result.stderr}')
|
||||
# Fall back to git clone
|
||||
raise Exception('Rsync failed, retrying with git')
|
||||
else:
|
||||
# No local code, clone from repository
|
||||
logger.info(f'No pre-staged code available ({code_status.get("reason", "unknown")}), cloning from repository')
|
||||
raise Exception('Local code not available')
|
||||
|
||||
except Exception as rsync_error:
|
||||
# Fallback: Clone or pull repository
|
||||
try:
|
||||
logger.info(f'Deploying via git: {rsync_error}')
|
||||
|
||||
# Check if repo already exists
|
||||
check_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'[ -d {deploy_path}/.git ]'
|
||||
]
|
||||
result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=10)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Repo exists, pull latest
|
||||
git_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'cd {deploy_path} && git pull origin main 2>&1'
|
||||
]
|
||||
git_msg = 'Pull latest code'
|
||||
else:
|
||||
# Clone repository
|
||||
git_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'git clone {repo_url} {deploy_path} 2>&1'
|
||||
]
|
||||
git_msg = 'Clone repository'
|
||||
|
||||
result = subprocess.run(git_cmd, capture_output=True, text=True, timeout=120)
|
||||
steps.append({
|
||||
'step': 'Deploy Code',
|
||||
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||
'message': f'{git_msg}: {result.stdout.split(chr(10))[0][:100]}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Deployment failed at step: Deploy Code',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': result.stderr or result.stdout,
|
||||
'steps': steps
|
||||
}
|
||||
except Exception as e:
|
||||
steps.append({
|
||||
'step': 'Deploy Code',
|
||||
'status': 'failed',
|
||||
'message': f'Failed: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Deployment failed at step: Deploy Code',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': str(e),
|
||||
'steps': steps
|
||||
}
|
||||
|
||||
# Step 3.5: Generate player configuration (config/app_config.json)
|
||||
# This is the file the player actually reads to learn the server address
|
||||
# and its screen identity. Authenticating with that identity is what binds
|
||||
# the player to its assigned playlist on the real server.
|
||||
install_env_prefix = ''
|
||||
try:
|
||||
screen_name = player_hostname or player_name
|
||||
if server_url and screen_name and quickconnect_code:
|
||||
addr = parse_server_address(server_url)
|
||||
app_config_content = generate_app_config(
|
||||
server_ip=addr['server_ip'],
|
||||
port=addr['port'],
|
||||
screen_name=screen_name,
|
||||
quickconnect_code=quickconnect_code,
|
||||
orientation=orientation or 'Landscape',
|
||||
use_https=addr['use_https'],
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
|
||||
# Build an env prefix so install.sh's configure_player() also runs
|
||||
# (single, consistent configuration path on the player side).
|
||||
import shlex
|
||||
env_pairs = {
|
||||
'KIWY_SERVER_IP': addr['server_ip'],
|
||||
'KIWY_PORT': addr['port'],
|
||||
'KIWY_SCREEN_NAME': screen_name,
|
||||
'KIWY_QUICKCONNECT': quickconnect_code,
|
||||
'KIWY_ORIENTATION': orientation or 'Landscape',
|
||||
'KIWY_USE_HTTPS': 'true' if addr['use_https'] else 'false',
|
||||
'KIWY_VERIFY_SSL': 'true' if verify_ssl else 'false',
|
||||
}
|
||||
install_env_prefix = ' '.join(
|
||||
f'{k}={shlex.quote(str(v))}' for k, v in env_pairs.items()
|
||||
) + ' '
|
||||
|
||||
# Write config/app_config.json directly (robust even if install.sh
|
||||
# is missing or fails), and clear any stale baked-in auth.
|
||||
remote_cmd = (
|
||||
f'mkdir -p {deploy_path}/config && '
|
||||
f"cat > {deploy_path}/config/app_config.json << 'EOF'\n"
|
||||
f'{app_config_content}\n'
|
||||
f'EOF\n'
|
||||
f'rm -f {deploy_path}/player_auth.json {deploy_path}/src/player_auth.json 2>/dev/null || true'
|
||||
)
|
||||
write_config_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
remote_cmd
|
||||
]
|
||||
result = subprocess.run(write_config_cmd, capture_output=True, text=True, timeout=30)
|
||||
steps.append({
|
||||
'step': 'Configure Player',
|
||||
'status': 'completed' if result.returncode == 0 else 'warning',
|
||||
'message': (
|
||||
f'Wrote config/app_config.json '
|
||||
f'(server {addr["server_ip"]}:{addr["port"]}, screen {screen_name})'
|
||||
),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
else:
|
||||
steps.append({
|
||||
'step': 'Configure Player',
|
||||
'status': 'skipped',
|
||||
'message': 'Missing server_url / player hostname / quickconnect; player not auto-configured',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to create player config: {str(e)}')
|
||||
steps.append({
|
||||
'step': 'Configure Player',
|
||||
'status': 'warning',
|
||||
'message': f'Failed to write player config: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
# Step 4: Run installation script
|
||||
# Before running the install script, grant the SSH user temporary
|
||||
# passwordless sudo so that any 'sudo apt-get / pip install' calls inside
|
||||
# install.sh don't hang waiting for an interactive password prompt.
|
||||
# The sudoers entry is removed automatically after the script finishes.
|
||||
try:
|
||||
sudoers_file = f'/etc/sudoers.d/kiwy_deploy_{username}'
|
||||
setup_sudo_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
(
|
||||
f'echo {password!r} | sudo -S bash -c '
|
||||
f'"echo \\"{username} ALL=(ALL) NOPASSWD:ALL\\" '
|
||||
f'> {sudoers_file} && chmod 440 {sudoers_file}" 2>&1'
|
||||
)
|
||||
]
|
||||
sudo_result = subprocess.run(setup_sudo_cmd, capture_output=True, text=True, timeout=15)
|
||||
sudo_configured = sudo_result.returncode == 0
|
||||
if not sudo_configured:
|
||||
logger.warning(f'Could not configure passwordless sudo: {sudo_result.stderr[:200]}')
|
||||
except Exception as e:
|
||||
sudo_configured = False
|
||||
logger.warning(f'Passwordless sudo setup failed: {str(e)}')
|
||||
|
||||
try:
|
||||
# Build a shell one-liner: run the first install script found.
|
||||
# Priority: install.sh > setup.sh > install_player.sh > any *.sh except start.sh
|
||||
run_install_cmd = (
|
||||
f'cd {deploy_path} && '
|
||||
f'INSTALL_SCRIPT="" && '
|
||||
f'for s in install.sh setup.sh install_player.sh; do '
|
||||
f' if [ -f "$s" ]; then INSTALL_SCRIPT="$s"; break; fi; '
|
||||
f'done && '
|
||||
f'if [ -z "$INSTALL_SCRIPT" ]; then '
|
||||
f' INSTALL_SCRIPT=$(ls *.sh 2>/dev/null | grep -v "^start.sh$" | head -1); '
|
||||
f'fi && '
|
||||
f'if [ -n "$INSTALL_SCRIPT" ]; then '
|
||||
f' chmod +x "$INSTALL_SCRIPT" && '
|
||||
f' echo "Running $INSTALL_SCRIPT" && '
|
||||
f' {install_env_prefix}bash "$INSTALL_SCRIPT" 2>&1; '
|
||||
f' echo "Exit code: $?"; '
|
||||
f'else '
|
||||
f' echo "No install script found"; '
|
||||
f'fi'
|
||||
)
|
||||
install_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
run_install_cmd
|
||||
]
|
||||
result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=600)
|
||||
output = (result.stdout or '').strip()
|
||||
|
||||
if 'No install script found' in output:
|
||||
steps.append({
|
||||
'step': 'Run Installation Script',
|
||||
'status': 'skipped',
|
||||
'message': 'No install script found in deploy directory',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
else:
|
||||
script_line = next((l for l in output.splitlines() if l.startswith('Running ')), '')
|
||||
script_name = script_line.replace('Running ', '').strip() or 'install script'
|
||||
steps.append({
|
||||
'step': 'Run Installation Script',
|
||||
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
|
||||
'message': f'Executed {script_name} (exit {result.returncode})',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
if result.returncode != 0:
|
||||
logger.warning(f'Install script exited {result.returncode}: {result.stderr[:200]}')
|
||||
else:
|
||||
logger.info(f'Install script completed successfully on {hostname}')
|
||||
except subprocess.TimeoutExpired:
|
||||
steps.append({
|
||||
'step': 'Run Installation Script',
|
||||
'status': 'completed_with_warnings',
|
||||
'message': 'Install script timed out after 600s — it may still be running on the device',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
steps.append({
|
||||
'step': 'Run Installation Script',
|
||||
'status': 'error',
|
||||
'message': f'Error running installation: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
logger.error(f'Installation script error: {str(e)}')
|
||||
finally:
|
||||
# Always clean up the temporary sudoers entry
|
||||
if sudo_configured:
|
||||
try:
|
||||
cleanup_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'sudo rm -f {sudoers_file} 2>/dev/null || true'
|
||||
]
|
||||
subprocess.run(cleanup_cmd, capture_output=True, text=True, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 5: Start player service (execute start.sh)
|
||||
try:
|
||||
# Check if start.sh exists
|
||||
check_start = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'[ -f {deploy_path}/start.sh ]'
|
||||
]
|
||||
start_check = subprocess.run(check_start, capture_output=True, text=True, timeout=10)
|
||||
|
||||
if start_check.returncode == 0:
|
||||
# Make sure start.sh is executable and run it
|
||||
start_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'cd {deploy_path} && chmod +x start.sh && bash start.sh 2>&1'
|
||||
]
|
||||
result = subprocess.run(start_cmd, capture_output=True, text=True, timeout=300)
|
||||
|
||||
# Capture first line of output for feedback
|
||||
output_msg = result.stdout.split('\n')[0][:100] if result.stdout else 'Started'
|
||||
|
||||
steps.append({
|
||||
'step': 'Start Player Service',
|
||||
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
|
||||
'message': f'Player service started: {output_msg}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
logger.info(f'Player service started on {hostname} at {deploy_path}')
|
||||
else:
|
||||
steps.append({
|
||||
'step': 'Start Player Service',
|
||||
'status': 'warning',
|
||||
'message': 'start.sh not found - player may require manual startup',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
logger.warning(f'start.sh not found at {deploy_path}/start.sh on {hostname}')
|
||||
except Exception as e:
|
||||
steps.append({
|
||||
'step': 'Start Player Service',
|
||||
'status': 'error',
|
||||
'message': f'Error starting player service: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
logger.error(f'Failed to start player service: {str(e)}')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Player "{player_name}" deployed successfully to {hostname}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'deploy_path': deploy_path,
|
||||
'steps': steps
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Deployment error: {str(e)}')
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Unexpected deployment error: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': str(e),
|
||||
'steps': []
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check and fix player quickconnect code."""
|
||||
|
||||
from app import create_app
|
||||
from app.models import Player
|
||||
from app.extensions import db
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
# Find player by hostname
|
||||
player = Player.query.filter_by(hostname='tv-terasa').first()
|
||||
|
||||
if not player:
|
||||
print("❌ Player 'tv-terasa' NOT FOUND in database!")
|
||||
print("\nAll registered players:")
|
||||
all_players = Player.query.all()
|
||||
for p in all_players:
|
||||
print(f" - ID={p.id}, Name='{p.name}', Hostname='{p.hostname}'")
|
||||
else:
|
||||
print(f"✅ Player found:")
|
||||
print(f" ID: {player.id}")
|
||||
print(f" Name: {player.name}")
|
||||
print(f" Hostname: {player.hostname}")
|
||||
print(f" Playlist ID: {player.playlist_id}")
|
||||
print(f" Status: {player.status}")
|
||||
print(f" QuickConnect Hash: {player.quickconnect_code[:60] if player.quickconnect_code else 'Not set'}...")
|
||||
|
||||
# Test the quickconnect code
|
||||
test_code = "8887779"
|
||||
print(f"\n🔐 Testing quickconnect code: '{test_code}'")
|
||||
|
||||
if player.check_quickconnect_code(test_code):
|
||||
print(f"✅ Code '{test_code}' is VALID!")
|
||||
else:
|
||||
print(f"❌ Code '{test_code}' is INVALID - Hash doesn't match!")
|
||||
|
||||
# Update it
|
||||
print(f"\n🔧 Updating quickconnect code to: '{test_code}'")
|
||||
player.set_quickconnect_code(test_code)
|
||||
db.session.commit()
|
||||
print("✅ QuickConnect code updated successfully!")
|
||||
print(f" New hash: {player.quickconnect_code[:60]}...")
|
||||
|
||||
# Verify the update
|
||||
if player.check_quickconnect_code(test_code):
|
||||
print(f"✅ Verification successful - code '{test_code}' now works!")
|
||||
else:
|
||||
print(f"❌ Verification failed - something went wrong!")
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Clean development data before Docker deployment
|
||||
# This script removes all development data to ensure a fresh start
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧹 Cleaning DigiServer v2 for deployment..."
|
||||
echo ""
|
||||
|
||||
# Confirm action
|
||||
read -p "This will delete ALL data (database, uploads, logs). Continue? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "❌ Cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📦 Cleaning development data..."
|
||||
|
||||
# Remove database files
|
||||
if [ -d "instance" ]; then
|
||||
echo " 🗄️ Removing database files..."
|
||||
rm -rf instance/*.db
|
||||
rm -rf instance/*.db-*
|
||||
echo " ✅ Database cleaned"
|
||||
else
|
||||
echo " ℹ️ No instance directory found"
|
||||
fi
|
||||
|
||||
# Remove uploaded media
|
||||
if [ -d "app/static/uploads" ]; then
|
||||
echo " 📁 Removing uploaded media files..."
|
||||
find app/static/uploads -type f -not -name '.gitkeep' -delete 2>/dev/null || true
|
||||
find app/static/uploads -type d -empty -not -path "app/static/uploads" -delete 2>/dev/null || true
|
||||
echo " ✅ Uploads cleaned"
|
||||
else
|
||||
echo " ℹ️ No uploads directory found"
|
||||
fi
|
||||
|
||||
# Remove additional upload directory if exists
|
||||
if [ -d "static/uploads" ]; then
|
||||
echo " 📁 Removing static uploads..."
|
||||
find static/uploads -type f -not -name '.gitkeep' -delete 2>/dev/null || true
|
||||
find static/uploads -type d -empty -not -path "static/uploads" -delete 2>/dev/null || true
|
||||
echo " ✅ Static uploads cleaned"
|
||||
fi
|
||||
|
||||
# Remove log files
|
||||
echo " 📝 Removing log files..."
|
||||
find . -name "*.log" -type f -delete 2>/dev/null || true
|
||||
echo " ✅ Logs cleaned"
|
||||
|
||||
# Remove Python cache
|
||||
echo " 🐍 Removing Python cache..."
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
find . -type f -name "*.pyo" -delete 2>/dev/null || true
|
||||
echo " ✅ Python cache cleaned"
|
||||
|
||||
# Remove Flask session files if any
|
||||
if [ -d "flask_session" ]; then
|
||||
echo " 🔐 Removing session files..."
|
||||
rm -rf flask_session
|
||||
echo " ✅ Sessions cleaned"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "✨ Cleanup complete!"
|
||||
echo ""
|
||||
echo "📊 Summary:"
|
||||
echo " - Database: Removed"
|
||||
echo " - Uploaded media: Removed"
|
||||
echo " - Logs: Removed"
|
||||
echo " - Python cache: Removed"
|
||||
echo ""
|
||||
echo "🚀 Ready for deployment!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Build Docker image: docker compose build"
|
||||
echo " 2. Start container: docker compose up -d"
|
||||
echo " 3. Access at: http://localhost:80"
|
||||
echo " 4. Login with: admin / admin123"
|
||||
echo ""
|
||||
@@ -0,0 +1,343 @@
|
||||
#!/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. Accept either the modern plugin
|
||||
# (`docker compose`) or the standalone v1 binary (`docker-compose`), since not
|
||||
# every Docker installation ships the Compose plugin.
|
||||
if docker compose version &> /dev/null; then
|
||||
COMPOSE="docker compose"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
COMPOSE="docker-compose"
|
||||
echo -e "${YELLOW}⚠️ Using legacy 'docker-compose' (v1); the 'docker compose' plugin is unavailable.${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ docker compose not found!${NC}"
|
||||
echo "Please install the docker compose plugin or 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 seed the Caddy config
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}📁 Initializing data directories...${NC}"
|
||||
|
||||
# Create necessary data directories
|
||||
mkdir -p data/instance
|
||||
mkdir -p data/uploads
|
||||
mkdir -p data/caddy-data
|
||||
mkdir -p data/caddy-config
|
||||
mkdir -p data/caddy-logs
|
||||
|
||||
# Seed the Caddyfile. It is bind-mounted as a FILE, so it MUST exist before
|
||||
# `docker compose up` — otherwise Docker creates a directory in its place and
|
||||
# Caddy fails to start.
|
||||
if [ -f "data/Caddyfile" ]; then
|
||||
echo -e " ${GREEN}✓${NC} data/Caddyfile present"
|
||||
elif [ -f "Caddyfile.example" ]; then
|
||||
cp Caddyfile.example data/Caddyfile
|
||||
echo -e " ${GREEN}✓${NC} data/Caddyfile seeded from Caddyfile.example"
|
||||
else
|
||||
echo -e " ${RED}❌ Caddyfile.example not found in repo root!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Data directories initialized${NC}"
|
||||
echo ""
|
||||
# ============================================================================
|
||||
# CONFIGURATION VARIABLES
|
||||
# ============================================================================
|
||||
# NOTE: do NOT use the name HOSTNAME here — it is a bash built-in that already
|
||||
# holds the machine's hostname (e.g. "development"), so `${HOSTNAME:-default}`
|
||||
# would silently ignore the default and leak the OS hostname into the Caddyfile.
|
||||
SERVER_HOSTNAME="${SERVER_HOSTNAME:-digiserver}"
|
||||
EMAIL="${EMAIL:-admin@example.com}"
|
||||
|
||||
# Auto-detect the primary LAN IP unless one was supplied explicitly.
|
||||
if [ -z "${IP_ADDRESS:-}" ]; then
|
||||
IP_ADDRESS="$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' | head -1)"
|
||||
if [ -z "$IP_ADDRESS" ]; then
|
||||
IP_ADDRESS="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$IP_ADDRESS" ]; then
|
||||
echo -e "${RED}❌ Could not determine the server IP. Set IP_ADDRESS=... and re-run.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# HTTPS_MODE selects how TLS is provided:
|
||||
# internal : Caddy's internal CA, IP-only. Needs NO public DNS and NO ACME.
|
||||
# Correct for an intranet name such as digiserver.sibiusb.harting.intra.
|
||||
# acme : Let's Encrypt — requires DOMAIN to be publicly resolvable.
|
||||
# off : plain HTTP only.
|
||||
HTTPS_MODE="${HTTPS_MODE:-internal}"
|
||||
|
||||
case "$HTTPS_MODE" in
|
||||
internal)
|
||||
# An empty DOMAIN is what makes CaddyConfigGenerator choose the
|
||||
# internal-CA path instead of Let's Encrypt.
|
||||
DOMAIN=""
|
||||
;;
|
||||
acme)
|
||||
if [ -z "${DOMAIN:-}" ]; then
|
||||
echo -e "${RED}❌ HTTPS_MODE=acme requires DOMAIN to be set.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
off)
|
||||
DOMAIN=""
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}❌ Invalid HTTPS_MODE '$HTTPS_MODE' (use internal|acme|off).${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Published ports. These MUST match docker-compose.yml, which maps Caddy's
|
||||
# internal 80/443 to these host ports (HTTP_PORT / HTTPS_PORT).
|
||||
HTTP_PORT="${HTTP_PORT:-80}"
|
||||
HTTPS_PORT="${HTTPS_PORT:-443}"
|
||||
|
||||
echo -e "${BLUE}Configuration:${NC}"
|
||||
echo " Hostname: $SERVER_HOSTNAME"
|
||||
echo " HTTPS mode: $HTTPS_MODE"
|
||||
echo " Domain: ${DOMAIN:-(none — internal CA)}"
|
||||
echo " IP Address: $IP_ADDRESS"
|
||||
echo " Email: $EMAIL"
|
||||
echo " HTTP port: $HTTP_PORT"
|
||||
echo " HTTPS port: $HTTPS_PORT"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 1: Build and start containers
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}📦 [1/6] Building and starting containers...${NC}"
|
||||
|
||||
# Compose v1 refuses to build unless the buildx plugin is >= 0.17:
|
||||
# "compose build requires buildx 0.17.0 or later"
|
||||
# Distro Packaged Docker ships older buildx (or none). Detect that and fall back
|
||||
# to a plain `docker build` + `up --no-build`, which needs no buildx at all.
|
||||
APP_IMAGE="digiserver-v2-digiserver-app:latest"
|
||||
|
||||
BUILDX_VER="$(docker buildx version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)"
|
||||
BUILDX_OK=0
|
||||
if [ -n "$BUILDX_VER" ]; then
|
||||
_bx_major="${BUILDX_VER%%.*}"
|
||||
_bx_minor="${BUILDX_VER##*.}"
|
||||
if [ "$_bx_major" -gt 0 ] || { [ "$_bx_major" -eq 0 ] && [ "$_bx_minor" -ge 17 ]; }; then
|
||||
BUILDX_OK=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$BUILDX_OK" -eq 1 ]; then
|
||||
echo -e " ${GREEN}✓${NC} buildx ${BUILDX_VER} — building via compose"
|
||||
$COMPOSE up -d --build
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} buildx ${BUILDX_VER:-not found} (< 0.17) — falling back to 'docker build'"
|
||||
docker build -t "$APP_IMAGE" .
|
||||
$COMPOSE up -d --no-build
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}"
|
||||
sleep 15
|
||||
|
||||
# Verify containers are running
|
||||
if ! $COMPOSE ps | grep -q "Up"; then
|
||||
echo -e "${RED}❌ Containers failed to start!${NC}"
|
||||
$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..."
|
||||
$COMPOSE exec -T digiserver-app python /app/migrations/add_https_config_table.py
|
||||
echo -e " • Creating player_user table..."
|
||||
$COMPOSE exec -T digiserver-app python /app/migrations/add_player_user_table.py
|
||||
echo -e " • Adding email to https_config..."
|
||||
$COMPOSE exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
|
||||
echo -e " • Migrating player_user global settings..."
|
||||
$COMPOSE exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
|
||||
echo -e " • Adding original_filename to content..."
|
||||
$COMPOSE exec -T digiserver-app python /app/migrations/add_original_filename_to_content.py
|
||||
|
||||
echo -e "${GREEN}✅ All database migrations completed${NC}"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 3: Configure HTTPS
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS (mode: $HTTPS_MODE)...${NC}"
|
||||
|
||||
# https_manager.py mirrors the Admin UI code path: persist HTTPSConfig →
|
||||
# regenerate the Caddyfile → hot-reload Caddy → verify the TLS listener and
|
||||
# automatically fall back to HTTP if it does not come up.
|
||||
#
|
||||
# `bootstrap` (rather than `enable`) is used deliberately: it records provenance
|
||||
# via HTTPSConfig.updated_by, so an admin's later change in the UI is not
|
||||
# silently overwritten on the next restart.
|
||||
#
|
||||
# The values computed above are injected with -e so this run uses them instead
|
||||
# of whatever happens to be in the container's .env.
|
||||
#
|
||||
# Exit code 2 means "config applied but Caddy did not reload" — not fatal, so it
|
||||
# must not abort the deployment.
|
||||
set +e
|
||||
if [ "$HTTPS_MODE" = "off" ]; then
|
||||
$COMPOSE exec -T digiserver-app python /app/https_manager.py disable
|
||||
else
|
||||
$COMPOSE exec -T \
|
||||
-e HOSTNAME_INTERNAL="$SERVER_HOSTNAME" \
|
||||
-e HOST_IP="$IP_ADDRESS" \
|
||||
-e DOMAIN="$DOMAIN" \
|
||||
-e SSL_EMAIL="$EMAIL" \
|
||||
-e HTTPS_PORT="$HTTPS_PORT" \
|
||||
digiserver-app python /app/https_manager.py bootstrap
|
||||
fi
|
||||
HTTPS_RC=$?
|
||||
set -e
|
||||
|
||||
case "$HTTPS_RC" in
|
||||
0) echo -e "${GREEN}✅ HTTPS configured${NC}" ;;
|
||||
2) echo -e "${YELLOW}⚠️ HTTPS config applied but Caddy did not reload — restart the caddy container.${NC}" ;;
|
||||
*) echo -e "${YELLOW}⚠️ HTTPS not configured (or verification failed and it fell back to HTTP); continuing.${NC}" ;;
|
||||
esac
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 4: Verify database setup
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}"
|
||||
|
||||
$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'')
|
||||
print(f' ✅ Total tables: {len(tables)}')
|
||||
" || echo " ⚠️ Database verification skipped"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 5: Verify Caddy configuration
|
||||
# ============================================================================
|
||||
echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}"
|
||||
|
||||
$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 ""
|
||||
|
||||
$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 ""
|
||||
|
||||
# Build host:port suffixes, omitting the default ports for readability.
|
||||
_http_url="http://$IP_ADDRESS"
|
||||
[ "$HTTP_PORT" != "80" ] && _http_url="http://$IP_ADDRESS:$HTTP_PORT"
|
||||
_https_url="https://$IP_ADDRESS"
|
||||
[ "$HTTPS_PORT" != "443" ] && _https_url="https://$IP_ADDRESS:$HTTPS_PORT"
|
||||
|
||||
echo -e "${BLUE}📍 Access Points:${NC}"
|
||||
echo -e " 🌐 ${_http_url} (always available)"
|
||||
case "$HTTPS_MODE" in
|
||||
internal)
|
||||
echo -e " 🔒 ${_https_url} (internal CA — see note below)"
|
||||
echo -e " 🔒 https://$SERVER_HOSTNAME (needs DNS or an /etc/hosts entry)"
|
||||
;;
|
||||
acme)
|
||||
echo -e " 🔒 https://$DOMAIN"
|
||||
;;
|
||||
off)
|
||||
echo -e " ℹ️ HTTPS disabled (HTTPS_MODE=off)"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
|
||||
if [ "$HTTPS_MODE" = "internal" ]; then
|
||||
echo -e "${YELLOW}⚠️ Internal CA certificate notice:${NC}"
|
||||
echo " The TLS certificate is signed by Caddy's LOCAL CA, which is not in any"
|
||||
echo " client's trust store. Browsers will warn and players will reject it"
|
||||
echo " unless you either:"
|
||||
echo " a) install the root CA on each device, or"
|
||||
echo " b) use the plain HTTP endpoint above (simplest for players)."
|
||||
echo ""
|
||||
echo " Export the root CA with:"
|
||||
echo " $COMPOSE cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}📝 Administrator Account:${NC}"
|
||||
if [ -f ".deployment-credentials" ]; then
|
||||
echo " Credentials are in .deployment-credentials (chmod 600)."
|
||||
else
|
||||
echo " Username: ${ADMIN_USERNAME:-admin}"
|
||||
echo " Password: see ADMIN_PASSWORD in .env (or the container's ADMIN_PASSWORD)"
|
||||
fi
|
||||
if grep -qs '^ADMIN_PASSWORD=admin123' .env 2>/dev/null; then
|
||||
echo -e " ${RED}⚠️ ADMIN_PASSWORD is still the default — change it now!${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}📚 Documentation:${NC}"
|
||||
echo " • docs/07-deployment.md - Deployment + HTTPS details"
|
||||
echo " • docs/06-utils-services.md - Caddy / https_manager internals"
|
||||
echo " • Caddyfile.example - Caddy template (seeded into data/)"
|
||||
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 docs/07-deployment.md"
|
||||
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 TLS certificate (Caddy internal CA root):"
|
||||
echo " openssl x509 -enddate -noout -in data/caddy-data/caddy/pki/authorities/local/root.crt"
|
||||
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 ""
|
||||
+83
-13
@@ -1,19 +1,60 @@
|
||||
#version: '3.8'
|
||||
|
||||
services:
|
||||
digiserver:
|
||||
digiserver-app:
|
||||
build: .
|
||||
container_name: digiserver-v2
|
||||
# Don't expose directly; use Caddy reverse proxy instead
|
||||
# Port 5000 is also mapped directly for dev/testing access when Caddy isn't running
|
||||
expose:
|
||||
- "5000"
|
||||
ports:
|
||||
- "80:5000"
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./instance:/app/instance
|
||||
- ./app/static/uploads:/app/app/static/uploads
|
||||
# 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
|
||||
# Staged player source (git clone). Persisted so a rebuild of the app
|
||||
# container does not throw away the ~140 MB staged checkout, and so the
|
||||
# SSH deployment step has something to ship.
|
||||
- ./data/player:/app/data/player
|
||||
# The app GENERATES the Caddyfile (env bootstrap at startup, and the
|
||||
# Admin → HTTPS Configuration page at runtime) then asks Caddy to reload.
|
||||
# It therefore needs write access to the same file Caddy reads, so this
|
||||
# must be mounted here as well as in the caddy service.
|
||||
# The file is host-owned (uid 1000 == appuser), so writes succeed.
|
||||
- ./data/Caddyfile:/etc/caddy/Caddyfile:rw
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
# ---------------------------------------------------------------------
|
||||
# Deploy-time TLS bootstrap (all optional).
|
||||
#
|
||||
# If HOSTNAME_INTERNAL and HOST_IP are BOTH set, the container configures
|
||||
# Caddy for HTTPS using that address at startup — no manual step needed.
|
||||
# If either is missing, the app stays on the plain-HTTP fallback and you
|
||||
# can enable HTTPS later from Admin → HTTPS Configuration (which reloads
|
||||
# Caddy live, no restart required).
|
||||
#
|
||||
# Leave DOMAIN empty for Caddy's internal CA. That needs NO public DNS
|
||||
# and NO ACME, which is the right choice for an intranet name that is not
|
||||
# resolvable from the internet.
|
||||
# ---------------------------------------------------------------------
|
||||
- HOSTNAME_INTERNAL=${HOSTNAME_INTERNAL:-}
|
||||
- HOST_IP=${HOST_IP:-}
|
||||
- DOMAIN=${DOMAIN:-}
|
||||
- SSL_EMAIL=${SSL_EMAIL:-}
|
||||
# Externally published ports (must match the caddy service mappings below).
|
||||
# They are used to build correct HTTP→HTTPS redirect targets.
|
||||
- HTTP_PORT=${HTTP_PORT:-80}
|
||||
- HTTPS_PORT=${HTTPS_PORT:-443}
|
||||
# Set to "false" to serve TLS only and redirect plain HTTP to HTTPS.
|
||||
- HTTPS_HTTP_FALLBACK=${HTTPS_HTTP_FALLBACK:-true}
|
||||
# Post-bootstrap check: probe HTTPS and fall back to HTTP if it is broken.
|
||||
- HTTPS_VERIFY=${HTTPS_VERIFY:-true}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"]
|
||||
@@ -21,14 +62,43 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- digiserver-network
|
||||
|
||||
# Optional: Redis for caching (uncomment if needed)
|
||||
# redis:
|
||||
# image: redis:7-alpine
|
||||
# container_name: digiserver-redis
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - redis-data:/data
|
||||
# Caddy reverse proxy.
|
||||
# Port 80 → always answers, for both the IP and the hostname.
|
||||
# Port 443 → HTTPS when configured; plain HTTP is served alongside it by
|
||||
# default so clients that cannot trust the internal CA still work.
|
||||
# Ports are configurable so the stack also works on a host where 80/443 are
|
||||
# already taken (e.g. HTTP_PORT=8080 HTTPS_PORT=8443).
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: digiserver-caddy
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
# The app container regenerates this file on startup (env bootstrap) and
|
||||
# whenever HTTPS is changed in the Admin UI, then hot-reloads Caddy via
|
||||
# its admin API (http://caddy:2019/load). Because the file on disk is
|
||||
# always current, a plain restart of Caddy picks up the latest config.
|
||||
- ./data/Caddyfile:/etc/caddy/Caddyfile:rw
|
||||
- ./data/caddy-data:/data
|
||||
- ./data/caddy-config:/config
|
||||
- ./data/caddy-logs:/var/log/caddy
|
||||
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
|
||||
|
||||
# volumes:
|
||||
# redis-data:
|
||||
networks:
|
||||
digiserver-network:
|
||||
driver: bridge
|
||||
|
||||
+135
-17
@@ -3,50 +3,168 @@ set -e
|
||||
|
||||
echo "Starting DigiServer v2..."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pin the database explicitly.
|
||||
#
|
||||
# The migration scripts and the application must target the SAME SQLite file.
|
||||
# Without this, they do not: the config classes default to different files
|
||||
# (dev.db for DevelopmentConfig, dashboard.db for ProductionConfig), and most
|
||||
# migration scripts call create_app() without an argument, which selects the
|
||||
# *development* config. The app itself runs as create_app('production').
|
||||
# Exporting DATABASE_URL makes every code path below resolve to the same
|
||||
# database, regardless of which config gets loaded.
|
||||
# ---------------------------------------------------------------------------
|
||||
export DATABASE_URL="${DATABASE_URL:-sqlite:////app/instance/dashboard.db}"
|
||||
export FLASK_ENV="${FLASK_ENV:-production}"
|
||||
|
||||
echo "Database: ${DATABASE_URL}"
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p /app/instance
|
||||
mkdir -p /app/app/static/uploads
|
||||
# Staged player source (bind-mounted from ./data/player). Created here so the
|
||||
# container works even if the volume was not pre-created on the host.
|
||||
mkdir -p /app/data/player
|
||||
|
||||
# Initialize database if it doesn't exist
|
||||
if [ ! -f /app/instance/dashboard.db ]; then
|
||||
echo "Initializing database..."
|
||||
python -c "
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure the schema exists and bootstrap the admin user.
|
||||
#
|
||||
# Both operations are idempotent, so this runs on every container start:
|
||||
# db.create_all() only creates missing tables, and the admin block creates the
|
||||
# user if absent or otherwise refreshes its password from the environment.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "Ensuring database schema and admin user..."
|
||||
python -c "
|
||||
from app.app import create_app
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models import User
|
||||
import os
|
||||
|
||||
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()
|
||||
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
||||
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')
|
||||
# Keep the stored password in sync with the environment.
|
||||
admin.password = hashed
|
||||
db.session.commit()
|
||||
print(f'✅ Admin user password updated ({admin_username})')
|
||||
db.session.commit()
|
||||
"
|
||||
echo "Database initialized!"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply schema migrations.
|
||||
#
|
||||
# Every migration script is idempotent (it guards against duplicate columns and
|
||||
# already-migrated tables), so this is safe to run on every start and upgrades
|
||||
# databases created by older releases.
|
||||
#
|
||||
# ORDER MATTERS: migrations that create/repair a table must run before the ones
|
||||
# that alter that table (e.g. add_player_user_table.py before
|
||||
# migrate_player_user_global.py, add_https_config_table.py before
|
||||
# add_email_to_https_config.py).
|
||||
#
|
||||
# Migrations are deliberately NON-FATAL: a failure is logged and startup
|
||||
# continues, so one bad migration cannot strand the container in a restart
|
||||
# loop. Look for the WARNING lines in the logs if something looks wrong.
|
||||
# ---------------------------------------------------------------------------
|
||||
MIGRATIONS=(
|
||||
"add_https_config_table.py"
|
||||
"add_player_user_table.py"
|
||||
"add_email_to_https_config.py"
|
||||
"migrate_player_user_global.py"
|
||||
"add_url_to_content.py"
|
||||
"add_original_filename_to_content.py"
|
||||
"add_deployment_fields_to_player.py"
|
||||
)
|
||||
|
||||
echo "Running database migrations..."
|
||||
FAILED_MIGRATIONS=()
|
||||
|
||||
for migration in "${MIGRATIONS[@]}"; do
|
||||
migration_path="/app/migrations/${migration}"
|
||||
|
||||
if [ ! -f "$migration_path" ]; then
|
||||
echo "⚠️ Skipping missing migration: ${migration}"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " • ${migration}"
|
||||
if ! python "$migration_path"; then
|
||||
echo "⚠️ WARNING: migration failed: ${migration}"
|
||||
FAILED_MIGRATIONS+=("${migration}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#FAILED_MIGRATIONS[@]} -gt 0 ]; then
|
||||
echo "⚠️ WARNING: ${#FAILED_MIGRATIONS[@]} migration(s) failed: ${FAILED_MIGRATIONS[*]}"
|
||||
echo "⚠️ Starting anyway — check the errors above."
|
||||
else
|
||||
echo "✅ All database migrations applied."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap HTTPS from environment variables.
|
||||
#
|
||||
# HOSTNAME_INTERNAL + HOST_IP (compose → env) configure Caddy for HTTPS at
|
||||
# startup, so a fresh deployment is reachable over TLS without a manual step.
|
||||
# When either is unset this is a deliberate NO-OP: the app stays on plain HTTP
|
||||
# and HTTPS can be enabled later from Admin → HTTPS Configuration, which
|
||||
# reloads Caddy live.
|
||||
#
|
||||
# ORDERING MATTERS: gunicorn is started FIRST (below) and only then is HTTPS
|
||||
# configured. The bootstrap probes https://…/api/health to decide whether the
|
||||
# certificate really works; if it ran before the app was listening, Caddy would
|
||||
# return 502 and the probe would wrongly conclude HTTPS was broken.
|
||||
#
|
||||
# Non-fatal: if this fails the app still runs, and the admin page remains the
|
||||
# fallback path for configuring HTTPS.
|
||||
# ---------------------------------------------------------------------------
|
||||
bootstrap_https() {
|
||||
echo "Checking HTTPS bootstrap..."
|
||||
if ! python /app/https_manager.py bootstrap; then
|
||||
echo "⚠️ WARNING: HTTPS bootstrap failed — continuing; configure HTTPS from the admin UI."
|
||||
fi
|
||||
}
|
||||
|
||||
# Start the application
|
||||
# --timeout is a safety net for any remaining synchronous long operation. The
|
||||
# player build no longer blocks a worker (it runs in a background thread), but
|
||||
# a generous margin avoids surprise worker kills during large uploads or
|
||||
# dependency installs triggered from the admin UI.
|
||||
echo "Starting Gunicorn..."
|
||||
exec gunicorn \
|
||||
gunicorn \
|
||||
--bind 0.0.0.0:5000 \
|
||||
--workers 4 \
|
||||
--timeout 120 \
|
||||
--timeout 300 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
"app.app:create_app('production')"
|
||||
"app.app:create_app('production')" &
|
||||
GUNICORN_PID=$!
|
||||
|
||||
# Wait for the app to answer before configuring HTTPS, then run the bootstrap.
|
||||
for _ in $(seq 1 30); do
|
||||
if python -c "
|
||||
import sys, urllib.request
|
||||
try:
|
||||
urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=2)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
bootstrap_https
|
||||
|
||||
# Keep the container attached to gunicorn so signals and healthchecks behave.
|
||||
wait $GUNICORN_PID
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🚀 DigiServer v2 - Docker Quick Start"
|
||||
echo "====================================="
|
||||
echo ""
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "❌ Docker is not installed. Please install Docker first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Docker Compose is installed
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo "❌ Docker Compose is not installed. Please install Docker Compose first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
if [ ! -f .env ]; then
|
||||
echo "📝 Creating .env file..."
|
||||
cp .env.example .env
|
||||
|
||||
# Generate random secret key
|
||||
SECRET_KEY=$(openssl rand -base64 32)
|
||||
sed -i "s/change-this-to-a-random-secret-key/$SECRET_KEY/" .env
|
||||
echo "✅ Created .env with generated SECRET_KEY"
|
||||
fi
|
||||
|
||||
# Create required directories
|
||||
echo "📁 Creating required directories..."
|
||||
mkdir -p instance app/static/uploads
|
||||
echo "✅ Directories created"
|
||||
|
||||
echo ""
|
||||
echo "🔨 Building Docker image..."
|
||||
docker-compose build
|
||||
|
||||
echo ""
|
||||
echo "🚀 Starting DigiServer v2..."
|
||||
docker-compose up -d
|
||||
|
||||
echo ""
|
||||
echo "⏳ Waiting for application to start..."
|
||||
sleep 5
|
||||
|
||||
# Check if container is running
|
||||
if docker-compose ps | grep -q "Up"; then
|
||||
echo ""
|
||||
echo "✅ DigiServer v2 is running!"
|
||||
echo ""
|
||||
echo "📍 Access the application at: http://localhost:5000"
|
||||
echo ""
|
||||
echo "👤 Default credentials:"
|
||||
echo " Username: admin"
|
||||
echo " Password: admin123"
|
||||
echo ""
|
||||
echo "📋 Useful commands:"
|
||||
echo " View logs: docker-compose logs -f"
|
||||
echo " Stop: docker-compose down"
|
||||
echo " Restart: docker-compose restart"
|
||||
echo " Shell access: docker-compose exec digiserver bash"
|
||||
echo ""
|
||||
echo "⚠️ IMPORTANT: Change the admin password after first login!"
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Failed to start DigiServer v2"
|
||||
echo " Check logs with: docker-compose logs"
|
||||
fi
|
||||
@@ -0,0 +1,197 @@
|
||||
# 01 · System Architecture
|
||||
|
||||
> Generated from the Graphify knowledge graph (41 communities) and source analysis of `digiserver-v2`.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
DigiServer v2 is a Flask application serving **three audiences**:
|
||||
|
||||
1. **Humans (admins)** — manage content, playlists, players, users, HTTPS via the web UI.
|
||||
2. **Players (physical signage)** — poll the REST API for playlists, send status feedback, upload edited media.
|
||||
3. **Operators** — deploy/update player software remotely over SSH.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Browser["Web UI (Admins)"]
|
||||
A[Login]
|
||||
B[Dashboard / Admin / Content / Players]
|
||||
end
|
||||
|
||||
subgraph App["digiserver-app (Flask :5000)"]
|
||||
C[Gunicorn x4]
|
||||
D[7 Blueprints]
|
||||
E[SQLAlchemy + SQLite]
|
||||
F[Cache / Background tasks]
|
||||
end
|
||||
|
||||
subgraph Players["Signage Players"]
|
||||
G[Player 1]
|
||||
H[Player N]
|
||||
end
|
||||
|
||||
subgraph Proxy["Reverse Proxy"]
|
||||
I[Caddy :80/:443]
|
||||
J[Umbrella nginx gateway - optional SSO]
|
||||
end
|
||||
|
||||
I -->|reverse_proxy digiserver-app:5000| C
|
||||
J -.X-Auth-Username .-> C
|
||||
D --> E
|
||||
G <-->|"/api/*"| D
|
||||
H <-->|"/api/*"| D
|
||||
Browser --> I
|
||||
C -.SSH deploy/rsync.-> G
|
||||
C -.SSH deploy/rsync.-> H
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Layered Architecture
|
||||
|
||||
Graphify assigns each node a **level** (0 = entry/global → 3 = utility):
|
||||
|
||||
| Layer | Level | Components |
|
||||
|---|---|---|
|
||||
| **L0 — Entry / Global** | 0 | `create_app()`, `app.py`, config classes, error handlers, CLI commands |
|
||||
| **L1 — Strategic / Core** | 1 | Blueprint route handlers (players, content, api, admin), model classes |
|
||||
| **L2 — Implementation** | 2 | Playlist management helpers, processing helpers, model methods |
|
||||
| **L3 — Utility** | 3 | `logger.py`, `ssh_deploy.py`, `caddy_manager.py`, `uploads.py`, `pptx_converter.py`, migrations |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph L0["L0 · Entry"]
|
||||
create_app["create_app()"]
|
||||
ProxyFix
|
||||
ScriptNameFix
|
||||
portal_sso["init_portal_sso()"]
|
||||
end
|
||||
subgraph L1["L1 · Blueprints + Models"]
|
||||
bp["main · auth · admin · players · content · playlist · api"]
|
||||
models["User Player Content Playlist PlayerEdit PlayerFeedback PlayerUser HTTPSConfig ServerLog"]
|
||||
end
|
||||
subgraph L2["L2 · Implementation"]
|
||||
bg["run_background_task()"]
|
||||
dep["deploy_player_to_host()"]
|
||||
caddy["CaddyConfigGenerator"]
|
||||
pb["build_player_files() / write_base_config()"]
|
||||
end
|
||||
subgraph L3["L3 · Utility"]
|
||||
log["log_action()"]
|
||||
up["uploads.py"]
|
||||
pptx["pptx_converter.py"]
|
||||
end
|
||||
create_app --> bp
|
||||
create_app --> models
|
||||
bp --> log
|
||||
bp --> bg
|
||||
bp --> dep
|
||||
bp --> caddy
|
||||
bp --> pb
|
||||
bp --> up
|
||||
bp --> pptx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Map (files → communities)
|
||||
|
||||
Graphify clustered the code into **41 communities**. The 12 meaningful ones are shown below (communities 13–40 are mostly migrations, isolated helper functions, and archived code).
|
||||
|
||||
| Community | Domain (derived) | Files | Role |
|
||||
|---|---|---|---|
|
||||
| **C0** (90) | **Authentication + audit** | `blueprints/auth.py`, `models/server_log.py`, `utils/logger.py`, `old_code_documentation/blueprint_groups.py` | Auth flows + audit logging |
|
||||
| **C1** (80) | **Admin + HTTPS + player users** | `blueprints/admin.py`, `models/https_config.py`, `models/player_user.py`, `utils/caddy_manager.py`, `migrations/add_player_user_table.py` | Admin panel, Caddy HTTPS generation, editing-user registry |
|
||||
| **C2** (68) | **Playlist & content workflows** | `blueprints/content.py`, `blueprints/playlist.py`, `models/playlist.py` | Modern playlist-centric content management + legacy redirects |
|
||||
| **C3** (62) | **Player API + edits** | `blueprints/api.py`, `models/player_edit.py` | Player-facing REST, edited-media pipeline |
|
||||
| **C4** (55) | **Application core** | `app.py`, `config.py`, `models/user.py`, `utils/portal_sso.py`, `utils/script_name_fix.py` | App factory, config, auth identity, middleware |
|
||||
| **C5** (54) | **Content & player models** | `models/content.py`, `models/player.py`, `models/player_feedback.py`, `blueprints/main.py` | Media model, player model, feedback, dashboard |
|
||||
| **C6** (51) | **Player management UI** | `blueprints/players.py`, `migrations/add_https_config_table.py` | Player CRUD, manage page, deployment polling |
|
||||
| **C7** (42) | ***(removed)*** | — | Groups model + legacy nginx reader were deleted during sanitization |
|
||||
| **C8** (24) | **Dev tooling** | `old_code_documentation/test_edit_media_api.py`, `Colors`, integrity checker | Test/analysis utilities |
|
||||
| **C9** (21) | **Upload processing** | `utils/uploads.py` | Upload progress, video/image processing |
|
||||
| **C10** (20) | **Deployment** | `utils/background_tasks.py`, `utils/ssh_deploy.py` | Background SSH deployment engine |
|
||||
| **C11** (19) | **Player build/staging** | `utils/player_build.py` | Stage player source code on server |
|
||||
| **C12** (8) | **PPTX conversion** | `utils/pptx_converter.py` | LibreOffice PPTX → PDF → PNG |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
C0["C0 · Auth & logging"]
|
||||
C1["C1 · Admin & HTTPS"]
|
||||
C2["C2 · Playlists & content"]
|
||||
C3["C3 · Player API & edits"]
|
||||
C4["C4 · App core"]
|
||||
C5["C5 · Content/player models"]
|
||||
C6["C6 · Player management UI"]
|
||||
C9["C9 · Upload processing"]
|
||||
C10["C10 · Deployment"]
|
||||
C11["C11 · Player build"]
|
||||
C12["C12 · PPTX conversion"]
|
||||
|
||||
C1 -->|27 edges| C3
|
||||
C2 -->|25 edges| C3
|
||||
C2 -->|25 edges| C0
|
||||
C3 -->|25 edges| C1
|
||||
C1 -->|21 edges| C0
|
||||
C6 -->|16 edges| C1
|
||||
C6 -->|16 edges| C3
|
||||
C3 -->|14 edges| C0
|
||||
C6 -->|14 edges| C0
|
||||
C5 -->|9 edges| C3
|
||||
C9 -->|5 edges| C0
|
||||
C1 -->|3 edges| C11
|
||||
C10 --> C6
|
||||
C11 --> C10
|
||||
C12 --> C9
|
||||
```
|
||||
|
||||
*(edge weights from `graphify-out/metadata.json` communityLinks)*
|
||||
|
||||
---
|
||||
|
||||
## 4. Middleware & Request Pipeline
|
||||
|
||||
Every request flows through three layers before reaching a blueprint:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
R["Raw request"] --> PF["werkzeug ProxyFix"]
|
||||
PF --> SF["ScriptNameFix"]
|
||||
SF --> SSO["portal_sso (before_request)"]
|
||||
SSO --> BP["Blueprint route"]
|
||||
BP --> DB[("SQLAlchemy / SQLite")]
|
||||
```
|
||||
|
||||
1. **ProxyFix** — trusts `X-Forwarded-For/Proto/Host/Port` from Caddy/nginx (1 hop).
|
||||
2. **ScriptNameFix** — reads `X-Script-Name` (e.g. `/digiserver`) so `url_for()` produces correct prefixed URLs behind an umbrella nginx gateway.
|
||||
3. **Portal SSO** (`portal_sso.py`) — if the umbrella nginx sends `X-Auth-Username` / `X-Auth-Role`, automatically create + log in the local user.
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Architectural Decisions
|
||||
|
||||
| Decision | Implementation |
|
||||
|---|---|
|
||||
| **Application factory** | `create_app(config_name)` — clean testability, per-env config |
|
||||
| **Blueprint isolation** | 7 blueprints with clear URL prefixes; API separate from UI |
|
||||
| **Playlist versioning** | `Playlist.version` incremented on every mutation → players poll `/api/playlist-version/<id>` for refresh |
|
||||
| **In-memory caching** | Flask-Caching `@cache.memoize(300)` on playlist builders; `cache.clear()` on mutations |
|
||||
| **DB-backed audit log** | `log_action()` writes `ServerLog` rows; surfaced on dashboard & admin |
|
||||
| **Two auth systems** | Humans: Flask-Login (`User`). Players: `auth_code` Bearer + `quickconnect_code` (bcrypt) |
|
||||
| **Remote provisioning** | SSH + rsync/git staged code → write `app_config.json` → run install/start scripts |
|
||||
| **HTTPS automation** | `CaddyConfigGenerator` writes `Caddyfile`; reloads Caddy via admin API on `:2019` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Runtime Dependencies (requirements.txt)
|
||||
|
||||
**Runtime:** Flask 3.1, Werkzeug 3.1, SQLAlchemy 2.0.37, Flask-SQLAlchemy 3.1.1, Flask-Migrate, Flask-Bcrypt, Flask-Login, Flask-Caching, Flask-Cors, Flask-Talisman, pdf2image, Pillow, ffmpeg-python, python-magic, bcrypt, cryptography, gunicorn 23, psutil, python-dotenv.
|
||||
|
||||
**System binaries (Docker):** `poppler-utils`, `ffmpeg`, `libmagic1`, `LibreOffice` (core/impress/writer), `fonts-noto-color-emoji`, `sshpass`, `openssh-client`, `rsync`, `git`.
|
||||
|
||||
**Dev:** black, flake8, pytest, pytest-cov. *(`gevent` is commented out — incompatible with Python 3.13.)*
|
||||
|
||||
---
|
||||
|
||||
> Next: [02 · Knowledge Graph](02-knowledge-graph.md)
|
||||
@@ -0,0 +1,148 @@
|
||||
# 02 · The Graphify Knowledge Graph
|
||||
|
||||
This document explains the **interactive knowledge graph** generated by the [Graphify](https://marketplace.visualstudio.com/items?itemName=anytechiestudio.graphify-vscode) extension, and how to use it to explore and maintain DigiServer v2.
|
||||
|
||||
---
|
||||
|
||||
## 1. What Was Generated
|
||||
|
||||
Running `Graphify: Build Knowledge Graph` on the project root produced `graphify-out/`:
|
||||
|
||||
| Artifact | Description |
|
||||
|---|---|
|
||||
| `graph.html` | **Interactive visualizer** — zoom/pan/filter nodes, click to jump to source |
|
||||
| `graph.json` | Raw graph data (nodes, links, communities, heat) |
|
||||
| `GRAPH_REPORT.md` | God nodes, surprising connections, community list, knowledge gaps |
|
||||
| `COMPASS.md` | Token-optimized architecture summary (god nodes, layers) |
|
||||
| `DOMAINS.md` | Community ID → domain table |
|
||||
| `intelligence.json` | AI-detected intelligence (god nodes, surprising links) |
|
||||
| `graph.compact.txt` | Compact graph dump, ideal for LLM context |
|
||||
| `wiki/` | Markdown articles per community + per god-node, with Mermaid diagrams |
|
||||
|
||||
**Graph statistics:**
|
||||
|
||||
```
|
||||
631 nodes · 1162 edges · 41 communities
|
||||
Extraction: 56% EXTRACTED · 44% INFERRED (avg confidence 0.62)
|
||||
Node types: 313 FUNCTION · 300 FILE · 18 CLASS
|
||||
Link types: 311 uses · 290 calls · 275 rationale_for · 243 contains · 39 method · 4 inherits
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Opening the Visualizer
|
||||
|
||||
1. In VS Code, click the **Graphify** activity-bar icon (sidebar).
|
||||
2. Click **Build Knowledge Graph** (or right-click any folder → *Graphify: Build Knowledge Graph*).
|
||||
3. Once built, click **Open Interactive Visualizer** — or open `graphify-out/graph.html` directly in a browser.
|
||||
|
||||
> ⚠️ The visualizer opens best inside the VS Code webview (activity bar). Opening `graph.html` via `file://` may show a minor console error but still renders the graph data (nodes/edges/communities listed in the sidebar).
|
||||
|
||||
**View modes:** Deep Dive · Full Detail · Heatmap · Simulation (component-removal impact).
|
||||
|
||||
---
|
||||
|
||||
## 3. God Nodes — The Core Abstractions
|
||||
|
||||
These are the most-connected nodes (graph centrality). They are the architectural "load-bearing" abstractions:
|
||||
|
||||
| Rank | Node | Degree | File | Why it matters |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `log_action()` | 116 | `app/utils/logger.py` | Every important action across all blueprints logs here |
|
||||
| 2 | `PlayerEdit` | 99 | `app/models/player_edit.py` | Central record of on-player media edits |
|
||||
| 3 | `PlayerUser` | 74 | `app/models/player_user.py` | Maps player edit user codes to names |
|
||||
| 4 | `Playlist` | 33 | `app/models/playlist.py` | Core content organization + version sync |
|
||||
| 5 | `CaddyConfigGenerator` | 31 | `app/utils/caddy_manager.py` | HTTPS Caddyfile generation/reload |
|
||||
| 6 | `HTTPSConfig` | 30 | `app/models/https_config.py` | Persisted HTTPS settings |
|
||||
| 7 | `Content` | 24 | `app/models/content.py` | Media model used by everything |
|
||||
| 8 | `User` | 19 | `app/models/user.py` | Human accounts (admin/user/viewer) |
|
||||
| 9 | `create_app()` | 13 | `app/app.py` | Application factory |
|
||||
| 10 | `models/__init__.py` | 13 | `app/models/__init__.py` | Package export hub |
|
||||
|
||||
---
|
||||
|
||||
## 4. Heatmap — Hottest Code
|
||||
|
||||
`heat` (0–1) reflects how central/complex a node is in the graph. The hottest functions drive most of the system's behaviour:
|
||||
|
||||
| Heat | Function | File |
|
||||
|---|---|---|
|
||||
| 0.620 | `receive_edited_media()` | `app/blueprints/api.py` |
|
||||
| 0.614 | `deploy_player_to_host()` | `app/utils/ssh_deploy.py` |
|
||||
| 0.592 | `add_player()` | `app/blueprints/players.py` |
|
||||
| 0.537 | `upload_media()` | `app/blueprints/content.py` |
|
||||
| 0.429 | `manage_player()` | `app/blueprints/players.py` |
|
||||
| 0.425 | `receive_player_feedback()` | `app/blueprints/api.py` |
|
||||
| 0.407 | `update_https_config()` | `app/blueprints/admin.py` |
|
||||
| 0.376 | `process_file_in_background()` | `app/blueprints/content.py` |
|
||||
| 0.365 | `CaddyConfigGenerator` | `app/utils/caddy_manager.py` |
|
||||
| 0.354 | `get_playlist_by_quickconnect()` | `app/blueprints/api.py` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Communities (Clusters)
|
||||
|
||||
Graphify groups related code into communities. See [01-architecture.md §3](01-architecture.md#3-component-map-files--communities) for the full mapping. The largest:
|
||||
|
||||
- **C0 · Auth & logging** (90 nodes) — login/logout/register + audit logging
|
||||
- **C1 · Admin & HTTPS** (80 nodes) — admin panel, Caddy generation, editing users
|
||||
- **C2 · Playlists & content** (68 nodes) — the modern content/playlist workflow
|
||||
- **C3 · Player API & edits** (62 nodes) — player-facing REST + edited-media pipeline
|
||||
- **C4 · Application core** (55 nodes) — `create_app`, config, user, middleware
|
||||
- **C5 · Content/player models** (54 nodes) — `Content`, `Player`, feedback, dashboard
|
||||
|
||||
Each community has a wiki article with a **Mermaid class diagram**, key concepts, source files, and an audit trail (extracted vs inferred edges). Example: `graphify-out/wiki/Community_4.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Surprising Connections (AI-detected)
|
||||
|
||||
Graphify flags links that are non-obvious. Examples from `GRAPH_REPORT.md`:
|
||||
|
||||
- `reset_user_password()` → `log_action()` — admin password resets are audited
|
||||
- `upload_header_logo()` → `log_action()` — logo changes are audited
|
||||
- `delete_editing_user()` → `log_action()` — editing-user deletion is audited
|
||||
- `delete_playlist()` → `log_action()` — playlist deletion is audited
|
||||
- `Main playlist management page` uses `PlayerEdit` — the content UI surfaces edit counts
|
||||
|
||||
**Pattern:** virtually every destructive/admin action flows through `log_action()`, which is why it is the #1 god node.
|
||||
|
||||
---
|
||||
|
||||
## 7. Known Gaps in the Graph
|
||||
|
||||
- **178 isolated nodes** (≤1 connection) — mostly standalone migration scripts and model helper methods.
|
||||
- **Thin communities 13–40** — single-file migrations, isolated utility functions, and archived `old_code_documentation/` scripts. These are not "broken" — they are intentionally decoupled.
|
||||
- The graph does **not** index templates or static assets (Python AST extraction only).
|
||||
|
||||
---
|
||||
|
||||
## 8. Querying the Graph (CLI)
|
||||
|
||||
The `anytechie-graphify` engine (installed in `.venv`) ships a CLI for graph-aware questions:
|
||||
|
||||
```bash
|
||||
# Shortest path between two nodes
|
||||
python -m graphify path "Content" "Playlist"
|
||||
|
||||
# Explain a node and its neighbours
|
||||
python -m graphify explain "deploy_player_to_host()"
|
||||
|
||||
# BFS traversal to answer a question (no LLM)
|
||||
python -m graphify query "how is a playlist synced to a player?"
|
||||
|
||||
# Grounded chat over the graph (requires an LLM API key)
|
||||
python -m graphify ask "what happens when a player uploads edited media?"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Keeping the Graph Fresh
|
||||
|
||||
- **After code changes:** `Graphify: Build Knowledge Graph` again (or run `python -m graphify update <path>`).
|
||||
- **Git hooks:** `python -m graphify hook install` auto-rebuilds on `post-commit`/`post-checkout`.
|
||||
- The graph is stored in `graphify-out/` (not committed to git by default).
|
||||
|
||||
---
|
||||
|
||||
> Next: [03 · Data Model](03-data-model.md)
|
||||
@@ -0,0 +1,194 @@
|
||||
# 03 · Data Model
|
||||
|
||||
DigiServer v2 uses **SQLAlchemy 2.0** with **SQLite** (production: `instance/dashboard.db`, dev: `instance/dev.db`, tests: in-memory). All models live in `app/models/` and are re-exported from `app/models/__init__.py`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Entity-Relationship Overview
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
user ||--o{ server_log : "writes"
|
||||
player ||--o{ player_feedback : "sends (cascade)"
|
||||
player ||--o{ player_edit : "edits (cascade)"
|
||||
player }o--o| playlist : "assigned_to"
|
||||
content ||--o{ player_edit : "edited (cascade)"
|
||||
content ||--o{ player_feedback : "playing"
|
||||
playlist ||--o{ content : "playlist_content M2M (position,duration,muted,edit_on_player_enabled)"
|
||||
player_user ||--o{ player_edit : "user_code"
|
||||
https_config ||--o| https_config : "singleton row"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Tables & Columns
|
||||
|
||||
### `user` — human accounts
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | Integer | PK |
|
||||
| `username` | String(80) | unique, NOT NULL, indexed |
|
||||
| `password` | String(120) | bcrypt hash, NOT NULL |
|
||||
| `role` | String(20) | default `'user'`; also `'admin'`, `'viewer'`; indexed |
|
||||
| `theme` | String(20) | default `'light'` |
|
||||
| `created_at` | DateTime | NOT NULL |
|
||||
| `last_login` | DateTime | nullable |
|
||||
|
||||
Key methods: `is_admin` (property), `update_last_login()`. Mixin: `UserMixin` (Flask-Login).
|
||||
|
||||
### `player` — signage devices
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | Integer | PK |
|
||||
| `name` | String(255) | NOT NULL |
|
||||
| `hostname` | String(255) | unique, NOT NULL, indexed |
|
||||
| `location` | String(255) | nullable |
|
||||
| `auth_code` | String(255) | unique, NOT NULL, indexed (legacy Bearer auth) |
|
||||
| `password_hash` | String(255) | NOT NULL (bcrypt) |
|
||||
| `quickconnect_code` | String(255) | nullable, bcrypt-hashed |
|
||||
| `orientation` | String(16) | default `'Landscape'` |
|
||||
| `status` | String(50) | default `'offline'`, indexed |
|
||||
| `last_seen` | DateTime | indexed |
|
||||
| `last_heartbeat` | DateTime | indexed |
|
||||
| `created_at` | DateTime | NOT NULL |
|
||||
| `playlist_id` | Integer | FK → `playlist.id` ON DELETE SET NULL, indexed |
|
||||
| `deployment_status` | String(50) | default `'pending'`; pending/deploying/deployed/failed |
|
||||
| `last_deployment_at` | DateTime | nullable |
|
||||
| `last_deployment_status` | String(50) | nullable |
|
||||
| `last_deployment_message` | Text | nullable |
|
||||
|
||||
Relationships: `playlist`, `feedback` (→ PlayerFeedback, cascade delete-orphan).
|
||||
Methods: `is_online` (5-min window), `update_status()`, `set_password()/check_password()`, `set_quickconnect_code()/check_quickconnect_code()`, static `authenticate(hostname, password, quickconnect_code)`.
|
||||
|
||||
### `content` — media items (also weblinks)
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | Integer | PK |
|
||||
| `filename` | String(255) | unique, NOT NULL, indexed; may point to `edited_media/<id>/...` after a player edit |
|
||||
| `original_filename` | String(255) | nullable, indexed — pristine upload name |
|
||||
| `content_type` | String(50) | NOT NULL, indexed; image/video/pdf/pptx/weblink/other |
|
||||
| `duration` | Integer | default 10, nullable |
|
||||
| `file_size` | BigInteger | nullable |
|
||||
| `url` | String(2048) | nullable — target URL for `weblink` content |
|
||||
| `description` | Text | nullable |
|
||||
| `uploaded_at` | DateTime | NOT NULL, indexed |
|
||||
|
||||
Relationships: `playlists` (M2M via `playlist_content`).
|
||||
Properties/methods: `file_size_mb`, `original_display_name`, `original_media_path`, `current_media_path`, `is_image()/is_video()/is_pdf()/is_weblink()`, `has_player_edits`.
|
||||
|
||||
### `playlist` — ordered collections of content
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | Integer | PK |
|
||||
| `name` | String(100) | unique, NOT NULL, indexed |
|
||||
| `description` | Text | nullable |
|
||||
| `orientation` | String(20) | default `'Landscape'` |
|
||||
| `version` | Integer | default 1, NOT NULL — **sync detection** |
|
||||
| `is_active` | Boolean | default True |
|
||||
| `created_at` | DateTime | NOT NULL |
|
||||
| `updated_at` | DateTime | NOT NULL, onupdate |
|
||||
|
||||
Methods: `player_count`, `content_count`, `total_duration` (properties), `increment_version()`, `get_content_ordered()`.
|
||||
|
||||
### `playlist_content` — association table (M2M playlist ↔ content)
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `playlist_id` | Integer | FK → `playlist.id` CASCADE, composite PK |
|
||||
| `content_id` | Integer | FK → `content.id` CASCADE, composite PK |
|
||||
| `position` | Integer | default 0 — ordering |
|
||||
| `duration` | Integer | default 10 — per-playlist override |
|
||||
| `muted` | Boolean | default True |
|
||||
| `edit_on_player_enabled` | Boolean | default False — whether player-side editing allowed |
|
||||
|
||||
### `player_edit` — on-player media edits
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | Integer | PK |
|
||||
| `player_id` | Integer | FK → `player.id` CASCADE, indexed |
|
||||
| `content_id` | Integer | FK → `content.id` CASCADE, indexed |
|
||||
| `original_name` | String(255) | NOT NULL |
|
||||
| `new_name` | String(255) | NOT NULL |
|
||||
| `version` | Integer | default 1 |
|
||||
| `user` | String(255) | nullable (user code) |
|
||||
| `time_of_modification` | DateTime | nullable |
|
||||
| `metadata_path` | String(512) | nullable |
|
||||
| `edited_file_path` | String(512) | NOT NULL |
|
||||
| `created_at` | DateTime | NOT NULL, indexed |
|
||||
|
||||
Relationships: `player`, `content`. Method: `to_dict()`.
|
||||
|
||||
### `player_feedback` — status messages from players
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | Integer | PK |
|
||||
| `player_id` | Integer | FK → `player.id`, NOT NULL, indexed |
|
||||
| `status` | String(50) | default `'unknown'` |
|
||||
| `current_content_id` | Integer | FK → `content.id`, nullable |
|
||||
| `message` | Text | nullable |
|
||||
| `error` | Text | nullable |
|
||||
| `timestamp` | DateTime | NOT NULL, indexed |
|
||||
|
||||
Properties/classmethod: `is_error`, `age_seconds`, `get_latest_for_player()`.
|
||||
|
||||
### `player_user` — mapping of player edit user codes
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | Integer | PK |
|
||||
| `user_code` | String(255) | **globally unique**, NOT NULL, indexed |
|
||||
| `user_name` | String(255) | nullable |
|
||||
| `created_at` | DateTime | NOT NULL |
|
||||
| `updated_at` | DateTime | NOT NULL, onupdate |
|
||||
|
||||
Method: `to_dict()`. *(Migrated from a per-player table — see `migrate_player_user_global.py`.)*
|
||||
|
||||
### `server_log` — DB-backed audit log
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | Integer | PK |
|
||||
| `level` | String(20) | NOT NULL, indexed, default `'info'` |
|
||||
| `message` | Text | NOT NULL |
|
||||
| `timestamp` | DateTime | NOT NULL, indexed |
|
||||
|
||||
Classmethods: `log_info`, `log_warning`, `log_error`.
|
||||
|
||||
### `https_config` — HTTPS settings (singleton row)
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | Integer | PK |
|
||||
| `https_enabled` | Boolean | default False |
|
||||
| `hostname` | String(255) | nullable |
|
||||
| `domain` | String(255) | nullable |
|
||||
| `ip_address` | String(45) | nullable (IPv6-capable) |
|
||||
| `email` | String(255) | nullable (Let's Encrypt contact) |
|
||||
| `port` | Integer | default 443 |
|
||||
| `created_at` | DateTime | NOT NULL |
|
||||
| `updated_at` | DateTime | NOT NULL, onupdate |
|
||||
| `updated_by` | String(255) | nullable |
|
||||
|
||||
Classmethods: `get_config()` (first row), `create_or_update(...)`. Method: `to_dict()`.
|
||||
|
||||
### `group` + `group_content` — **REMOVED**
|
||||
|
||||
The `Group` model, the `group_content` association table, `Content.groups` /
|
||||
`Content.group_count`, and the group-management utility functions were **deleted**
|
||||
during the code sanitization pass (the feature was archived and the table had no
|
||||
rows). `Player` never had a `group_id` column. See
|
||||
[SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. Relationship Summary
|
||||
|
||||
| Relationship | Cardinality | FK / Mechanism |
|
||||
|---|---|---|
|
||||
| `playlist` → `content` | M:N | `playlist_content` (positioned, with extras) |
|
||||
| `player` → `playlist` | N:1 | `player.playlist_id` (ON DELETE SET NULL) |
|
||||
| `player` → `player_feedback` | 1:N | `player_feedback.player_id` (cascade) |
|
||||
| `player` → `player_edit` | 1:N | `player_edit.player_id` (cascade) |
|
||||
| `content` → `player_edit` | 1:N | `player_edit.content_id` (cascade) |
|
||||
| `player_user` → `player_edit` | 1:N | `player_edit.user` = `player_user.user_code` (logical) |
|
||||
| `https_config` | 1 row | singleton via `get_config()` |
|
||||
|
||||
---
|
||||
|
||||
> Next: [04 · Application Core](04-application-core.md)
|
||||
@@ -0,0 +1,179 @@
|
||||
# 04 · Application Core
|
||||
|
||||
Covers the application factory, configuration, extensions, middleware, CLI commands, context processors, and template layer. This is **Community 4** in the Graphify knowledge graph.
|
||||
|
||||
---
|
||||
|
||||
## 1. Application Factory — `app/app.py`
|
||||
|
||||
The entire app is constructed by `create_app(config_name=None)`:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["create_app()"] --> B["Set instance_path"]
|
||||
B --> C["Load config: Dev / Prod / Test"]
|
||||
C --> D["ProxyFix middleware"]
|
||||
D --> E["ScriptNameFix middleware"]
|
||||
E --> F["init extensions: db bcrypt login migrate cache cors"]
|
||||
F --> G["configure_login_manager()"]
|
||||
G --> H["register_blueprints()"]
|
||||
H --> I["register_error_handlers()"]
|
||||
I --> J["register_commands()"]
|
||||
J --> K["register_context_processors()"]
|
||||
K --> L["register_template_filters()"]
|
||||
L --> M["init_portal_sso(app)"]
|
||||
M --> N["db.create_all() (idempotent)"]
|
||||
N --> O["return app"]
|
||||
```
|
||||
|
||||
### Blueprint registration
|
||||
|
||||
```python
|
||||
def register_blueprints(app):
|
||||
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
|
||||
...
|
||||
```
|
||||
|
||||
> Note: `content_old.py` (legacy content routes) was **deleted** during the code
|
||||
> sanitization pass — see [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md). The
|
||||
> legacy `playlist.py` blueprint remains (redirect-only).
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration — `app/config.py`
|
||||
|
||||
Four classes: `Config` (base) → `DevelopmentConfig`, `ProductionConfig`, `TestingConfig`.
|
||||
|
||||
| Setting | Value | Notes |
|
||||
|---|---|---|
|
||||
| `MAX_CONTENT_LENGTH` | 2 GB | upload cap |
|
||||
| `UPLOAD_FOLDER` | `app/static/uploads` | media |
|
||||
| `UPLOAD_FOLDERLOGO` | `app/static/resurse` | logos |
|
||||
| `ALLOWED_EXTENSIONS` | png jpg jpeg gif bmp mp4 avi mkv mov webm pdf ppt pptx | |
|
||||
| `PERMANENT_SESSION_LIFETIME` | 30 min | |
|
||||
| `SESSION_COOKIE_SECURE` | False (True in prod) | |
|
||||
| `ITEMS_PER_PAGE` | 20 | |
|
||||
| `SERVER_VERSION` | 2.0.0 | shown in UI footer |
|
||||
| `PLAYER_CODE_DIR` | `/app/data/player` | staged player source |
|
||||
| `PLAYER_REPO_URL` | env `PLAYER_REPO_URL` | Kiwy-Signage repo |
|
||||
| DB (dev) | `instance/dev.db` | |
|
||||
| DB (prod) | `instance/dashboard.db` | |
|
||||
| DB (test) | in-memory | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Extensions — `app/extensions.py`
|
||||
|
||||
Centralizes shared singletons:
|
||||
|
||||
```python
|
||||
db = SQLAlchemy()
|
||||
bcrypt = Bcrypt()
|
||||
login_manager = LoginManager() # login_view='auth.login'
|
||||
migrate = Migrate()
|
||||
cache = Cache()
|
||||
cors = CORS()
|
||||
```
|
||||
|
||||
CORS is configured in `create_app` for `/api/*`: all origins, GET/POST/OPTIONS/PUT/DELETE, supports credentials.
|
||||
|
||||
---
|
||||
|
||||
## 4. Middleware
|
||||
|
||||
### ProxyFix (Werkzeug)
|
||||
`app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)` — trusts one reverse-proxy hop (Caddy / umbrella nginx).
|
||||
|
||||
### ScriptNameFix — `app/utils/script_name_fix.py`
|
||||
`ScriptNameFix` WSGI middleware sets `SCRIPT_NAME` from `HTTP_X_SCRIPT_NAME` so `url_for()` generates correct paths when the app is mounted under a sub-path (e.g. `/digiserver`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Login Manager
|
||||
|
||||
```python
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Error Handlers
|
||||
|
||||
| Code | Template | Behaviour |
|
||||
|---|---|---|
|
||||
| 404 | `errors/404.html` | Not found |
|
||||
| 403 | `errors/403.html` | Forbidden |
|
||||
| 500 | `errors/500.html` | Rolls back DB session |
|
||||
| 413 | `errors/413.html` | Payload too large |
|
||||
| 408 | `errors/408.html` | Request timeout |
|
||||
|
||||
The `/api` blueprint registers its own **JSON** 404/405/500 handlers.
|
||||
|
||||
---
|
||||
|
||||
## 7. CLI Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `flask init-db` | `db.create_all()` |
|
||||
| `flask create-admin --username X` | Create admin user (prompts for password) |
|
||||
| `flask seed-db` | Seed sample data (blocked in production) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Context Processors
|
||||
|
||||
- **`inject_config`** — exposes `server_version`, `build_date`, `logo_exists` to all templates.
|
||||
- **`inject_user_theme`** — exposes the authenticated user's `theme` (`light`/`dark`) for the UI toggle.
|
||||
|
||||
---
|
||||
|
||||
## 9. Template Filters
|
||||
|
||||
- **`localtime`** — converts naive/UTC datetimes to local time with a configurable `strftime` format (default `%Y-%m-%d %H:%M`).
|
||||
|
||||
---
|
||||
|
||||
## 10. Template & Static Layout
|
||||
|
||||
```
|
||||
app/templates/
|
||||
├── base.html ← main layout (theme, logos, nav)
|
||||
├── dashboard.html
|
||||
├── auth/ login.html, register.html, change_password.html
|
||||
├── admin/ admin.html, user_management.html, leftover_media.html,
|
||||
│ dependencies.html, customize_logos.html, editing_users.html,
|
||||
│ https_config.html, build_player.html
|
||||
├── content/ content_list_new.html, media_library.html,
|
||||
│ upload_media.html, manage_playlist_content.html
|
||||
├── players/ players_list.html, add_player.html, edit_player.html,
|
||||
│ manage_player.html, player_page.html, player_fullscreen.html,
|
||||
│ edited_media.html, edited_media_report.html, _deploy_badge.html
|
||||
└── errors/ 403.html, 404.html, 500.html (+413/408)
|
||||
|
||||
app/static/
|
||||
├── icons/ edit, home, info, monitor, moon, playlist, sun, trash, upload, warning (SVG)
|
||||
├── uploads/ uploaded media + edited_media/<content_id>/ (versioned edits)
|
||||
└── (resurse/ logo storage — referenced by config)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Portal SSO — `app/utils/portal_sso.py`
|
||||
|
||||
`init_portal_sso(app)` registers a `before_request` hook that reads `X-Auth-Username` / `X-Auth-Role` headers (set by the umbrella nginx gateway). If present, it auto-creates and logs in the local `User` (`_get_or_create_user`). This lets the app sit behind an existing corporate SSO portal.
|
||||
|
||||
---
|
||||
|
||||
> Next: [05 · Blueprints & API](05-blueprints-api.md)
|
||||
@@ -0,0 +1,280 @@
|
||||
# 05 · Blueprints & REST API
|
||||
|
||||
DigiServer v2 registers **7 active blueprints**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Blueprint Overview
|
||||
|
||||
| Blueprint | URL prefix | Module | Focus |
|
||||
|---|---|---|---|
|
||||
| `main_bp` | `/` | `blueprints/main.py` | Dashboard, health |
|
||||
| `auth_bp` | `/` | `blueprints/auth.py` | Login/logout/register/password |
|
||||
| `admin_bp` | `/admin` | `blueprints/admin.py` | Admin panel, HTTPS, build, users, logos |
|
||||
| `players_bp` | `/players` | `blueprints/players.py` | Player management |
|
||||
| `content_bp` | `/content` | `blueprints/content.py` | Media library + modern playlist management |
|
||||
| `playlist_bp` | `/playlist` | `blueprints/playlist.py` | Legacy per-player playlist (redirects) |
|
||||
| `api_bp` | `/api` | `blueprints/api.py` | Player-facing REST + deployment API |
|
||||
|
||||
---
|
||||
|
||||
## 2. `main` — Dashboard & Health (`/`)
|
||||
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/` | GET | `dashboard` | Counts (players/playlists/content), storage MB, 20 recent logs (cached 60s except `viewer` role) |
|
||||
| `/health` | GET | `health` | Health JSON: `SELECT 1` DB ping + free disk space |
|
||||
|
||||
---
|
||||
|
||||
## 3. `auth` — Authentication (`/`)
|
||||
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/login` | GET/POST | `login` | bcrypt login, `remember`, `next` redirect, failed-attempt logging |
|
||||
| `/logout` | GET | `logout` | Logs out + logs action |
|
||||
| `/register` | GET/POST | `register` | Self-registration → default `viewer` role |
|
||||
| `/change-password` | GET/POST | `change_password` | Change own password (verifies current) |
|
||||
|
||||
---
|
||||
|
||||
## 4. `admin` — Admin Panel (`/admin`)
|
||||
|
||||
**Security:** `@login_required` on all routes; `@admin_required` (custom decorator, `role == 'admin'`) on user-management.
|
||||
|
||||
### Users
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/` | GET | `admin_panel` | Stats, recent logs, users, storage |
|
||||
| `/user/create` | POST | `create_user` | Create user (username ≥3, password ≥6) |
|
||||
| `/user/<id>/role` | POST | `change_user_role` | Change role (not own) |
|
||||
| `/user/<id>/delete` | POST | `delete_user` | Delete user (not own) |
|
||||
| `/user/<id>/password` | POST | `reset_user_password` | Admin reset password |
|
||||
| `/users` | GET | `user_management` | User list page |
|
||||
| `/theme` | POST | `change_theme` | light/dark theme |
|
||||
|
||||
### Media housekeeping
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/leftover-media` | GET | `leftover_media` | Content not assigned to any playlist, by type + size |
|
||||
| `/delete-leftover-images` | POST | — | Bulk-delete leftover images (+ archive + PlayerEdit) |
|
||||
| `/delete-leftover-videos` | POST | — | Bulk-delete leftover videos |
|
||||
| `/delete-single-leftover/<id>` | POST | — | Delete one leftover item |
|
||||
|
||||
### System / dependencies
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/system/info` | GET | `system_info` | JSON: platform + psutil (CPU/mem/disk) |
|
||||
| `/dependencies` | GET | `dependencies` | Checks LibreOffice, Poppler, FFmpeg, emoji fonts |
|
||||
| `/install-libreoffice` | POST | — | `sudo -n install_libreoffice.sh` |
|
||||
| `/install-emoji-fonts` | POST | — | `sudo -n install_emoji_fonts.sh` |
|
||||
| `/logo/upload` | POST | `upload_logo` | Upload logo.png |
|
||||
| `/logs/clear` | POST | `clear_logs` | Delete all `ServerLog` rows |
|
||||
| `/customize-logos` | GET | `customize_logos` | Logo customization page |
|
||||
| `/upload-header-logo` | POST | — | Save header_logo.png |
|
||||
| `/upload-login-logo` | POST | — | Save login_logo.png |
|
||||
|
||||
### Editing users
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/editing-users` | GET | `manage_editing_users` | List `PlayerUser` + per-user edit counts |
|
||||
| `/editing-users/<id>/update` | POST | — | Rename editing user |
|
||||
| `/editing-users/<id>/delete` | POST | — | Delete editing user |
|
||||
|
||||
### HTTPS (Caddy)
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/https-config` | GET | `https_config` | HTTPS config page (auto-corrects DB flag if request is HTTPS) |
|
||||
| `/https-config/update` | POST | `update_https_config` | Validate + save config → regenerate Caddyfile → reload Caddy |
|
||||
| `/https-config/status` | GET | `https_config_status` | Config as JSON |
|
||||
|
||||
### Player build & deployment
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/build-player` | GET | `build_player` | Build page + staged-code status |
|
||||
| `/build-player` | POST | `build_player_action` | `build_files` / `save_config` / `build_and_config` → clone/refresh code, write `app_config.json`, persist `instance/player_build.json` |
|
||||
|
||||
---
|
||||
|
||||
## 5. `players` — Player Management (`/players`)
|
||||
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/` and `/list` | GET | `list` | Player list + live deployment-status polling data |
|
||||
| `/add` | GET/POST | `add_player` | Create player; optional **background SSH deployment** |
|
||||
| `/bulk/delete` | POST | — | JSON bulk delete |
|
||||
| `/bulk/assign-playlist` | POST | — | JSON bulk assign to playlist |
|
||||
| `/deployment-status` | GET | — | JSON deployment status for all players (polling) |
|
||||
| `/<int:player_id>` | GET | — | Redirect to `manage_player` |
|
||||
| `/<id>/edit` | GET/POST | — | Edit name/location (legacy) |
|
||||
| `/<id>/delete` | POST | — | Delete player + feedback |
|
||||
| `/<id>/regenerate-auth` | POST | — | New auth code |
|
||||
| `/<id>/manage` | GET/POST | `manage_player` | Manage page: `update_credentials` / `assign_playlist`; feedback, edits, status |
|
||||
| `/<id>/edited-media` | GET | `edited_media` | All edited media + user mapping |
|
||||
| `/<id>/edited-media-report` | GET | `edited_media_report` | Tabular report |
|
||||
| `/<id>/fullscreen` | GET | — | Player fullscreen (NO auth; optional `?auth=` check), cached playlist |
|
||||
| `/<id>/reorder` | POST | — | Legacy stub → 400 "use Playlists page" |
|
||||
| `/<id>/playlist/reorder` | POST | — | Legacy per-player reorder (JSON up/down) |
|
||||
| `/<id>/playlist/remove` | POST | — | Legacy removal (bumps `playlist_version`) |
|
||||
|
||||
Helper: `get_player_playlist(player_id)` — `@cache.memoize(300)` builds playlist dicts (`url`, `type`, `duration`, `position`, `muted`, `audio`).
|
||||
|
||||
---
|
||||
|
||||
## 6. `content` — Media Library & Modern Playlist Management (`/content`)
|
||||
|
||||
> This is the **primary workflow** in v2 (1,519 lines).
|
||||
|
||||
### Library & uploads
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/` | GET | `content_list` | Main page `content_list_new.html`: playlists, last-3 media, counts, players |
|
||||
| `/media-library` | GET | `media_library` | All media by type + edit counts |
|
||||
| `/media/<id>/delete` | POST | `delete_media` | Delete file + archive + edits; removes from playlists + bumps versions |
|
||||
| `/upload-media-page` | GET | `upload_media_page` | Upload page (playlist selector) |
|
||||
| `/upload-media` | POST | `upload_media` | Core upload: images optimized, videos validated, **PDF/PPTX → Full-HD PNGs**, large files → background threads |
|
||||
|
||||
### Web links
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/add-weblink` | POST | `add_weblink` | Create weblink Content (http/https validated), optional playlist add |
|
||||
| `/playlist/<id>/add-weblink` | POST | — | Create weblink + append to playlist |
|
||||
|
||||
### Playlist management
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/playlist/create` | POST | — | Create playlist (name, description, orientation) |
|
||||
| `/playlist/<id>/delete` | POST | — | Delete + unassign players |
|
||||
| `/playlist/<id>/manage` | GET | `manage_playlist_content` | Ordered content + available library (no weblinks) |
|
||||
| `/playlist/<id>/add-content` | POST | — | Add library content at next position |
|
||||
| `/playlist/<id>/remove-content/<content_id>` | POST | — | Remove content (deletes orphan weblinks) |
|
||||
| `/playlist/<id>/bulk-remove` | POST | — | JSON bulk remove |
|
||||
| `/playlist/<id>/reorder` | POST | — | JSON reorder (position = index) |
|
||||
| `/playlist/<id>/update-muted/<content_id>` | POST | — | Toggle muted |
|
||||
| `/playlist/<id>/update-edit-enabled/<content_id>` | POST | — | Toggle `edit_on_player_enabled` |
|
||||
| `/playlist/<id>/update-duration/<content_id>` | POST | — | Per-playlist duration (≥1s) |
|
||||
| `/player/<id>/assign-playlist` | POST | — | Assign/unassign player to playlist |
|
||||
|
||||
**All playlist mutations** call `Playlist.increment_version()` and `cache.clear()`.
|
||||
|
||||
### Processing helpers (module-level)
|
||||
`process_image_file`, `process_video_file_extended` (ffprobe), `process_pdf_file` (pdf2image @300 DPI → `_pageNNN.png`), `process_presentation_file` (LibreOffice → PDF → pdftoppm → Full-HD PNG), `create_fullhd_image`, `resize_image_to_fullhd`, `optimize_image_to_fullhd`, `process_file_in_background` (thread wrapper).
|
||||
|
||||
---
|
||||
|
||||
## 7. `playlist` — Legacy Per-Player Routes (`/playlist`)
|
||||
|
||||
Redirects/legacy — kept for compatibility:
|
||||
| Route | Methods | Purpose |
|
||||
|---|---|---|
|
||||
| `/<int:player_id>` | GET | Redirect to modern manage page |
|
||||
| `/<id>/add` | POST | Add content to player's playlist |
|
||||
| `/<id>/remove/<content_id>` | POST | Remove + renumber |
|
||||
| `/<id>/reorder` | POST | JSON reorder from `content_ids` |
|
||||
| `/<id>/update-duration/<content_id>` | POST | Update duration |
|
||||
| `/<id>/update-muted/<content_id>` | POST | Update muted |
|
||||
| `/<id>/clear` | POST | Clear all items |
|
||||
|
||||
---
|
||||
|
||||
## 8. `api` — Player-Facing REST + Deployment API (`/api`)
|
||||
|
||||
**Custom decorators:** `rate_limit(max_requests, window)` (in-memory, IP/Bearer keyed) and `verify_player_auth` (validates Bearer `auth_code` → sets `request.player`).
|
||||
|
||||
### Health & auth
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/health` | GET | `health_check` | API health (`version: 2.0.0`) |
|
||||
| `/certificate` | GET | — | TLS certificate test |
|
||||
| `/auth/player` | POST | `authenticate_player` | hostname + password/quickconnect → auth_code, playlist_id, orientation; sets online |
|
||||
| `/auth/verify` | POST | `verify_auth_code` | Verify auth code → player info |
|
||||
|
||||
### Playlist delivery
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/playlists` | GET | `get_playlist_by_quickconnect` | Kivy-compatible: `?hostname=&quickconnect_code=` → playlist + version + hashed quickconnect |
|
||||
| `/playlists/<player_id>` | GET | `get_player_playlist` | Bearer-authed fetch (cached) |
|
||||
| `/playlist-version/<player_id>` | GET | `get_playlist_version` | Lightweight refresh-poll version check |
|
||||
|
||||
**Payload keys the players expect** (from `get_cached_playlist`): `file_name`, `type`, `duration`, `position`, `url`, `description`, `edit_on_player_enabled`, `muted`, `audio` (`"on"`/`"off"`).
|
||||
|
||||
### Feedback & monitoring
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/player-feedback` | POST | `receive_player_feedback` | Status (playing/paused/error/restarting); infers player; **auto-marks deployment `deployed`** |
|
||||
| `/player-status/<player_id>` | GET | `get_player_status` | Online (5-min), latest feedback |
|
||||
| `/system-info` | GET | — | Counts: players online/total, content, 24h logs |
|
||||
| `/content` | GET | — | List content with counts |
|
||||
| `/logs` | GET | `get_logs` | Query logs (`limit`/`level`/`since`) |
|
||||
|
||||
### Edited media
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/player-edit-media` | POST | `receive_edited_media` | Bearer-authed multipart upload of edited image + metadata; versionizes to `edited_media/<content_id>/`; records `PlayerEdit`; auto-creates `PlayerUser`; repoints `Content.filename`; bumps playlist version; clears cache |
|
||||
|
||||
### SSH deployment
|
||||
| Route | Methods | Endpoint | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/deploy/test-ssh` | POST | `test_ssh_connection` | Test SSH (sshpass/ssh) |
|
||||
| `/deploy/player` | POST | `deploy_player` | Full remote deploy (`deploy_player_to_host`); API key = `sha256(name:hostname)[:32]` |
|
||||
|
||||
**Error handlers:** JSON 404 / 405 / 500.
|
||||
|
||||
---
|
||||
|
||||
## 9. API Call Flows (Mermaid)
|
||||
|
||||
### Player bootstrap (auth → playlist)
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as Player
|
||||
participant A as /api
|
||||
P->>A: POST /api/auth/player {hostname, password|quickconnect}
|
||||
A->>A: Player.authenticate()
|
||||
A-->>P: {auth_code, playlist_id, orientation}
|
||||
P->>A: GET /api/playlists/<id> (Bearer auth_code)
|
||||
A-->>P: playlist items + version
|
||||
loop refresh poll
|
||||
P->>A: GET /api/playlist-version/<id>
|
||||
A-->>P: {version}
|
||||
end
|
||||
```
|
||||
|
||||
### Edited media upload
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as Player
|
||||
participant A as /api
|
||||
participant DB as SQLite
|
||||
P->>A: POST /api/player-edit-media (Bearer, multipart image + metadata)
|
||||
A->>DB: locate Content (filename / edited_media/<id>/ regex / last PlayerEdit)
|
||||
A->>DB: move original → edited_media/<id>/original_*
|
||||
A->>DB: save new version + side-car JSON
|
||||
A->>DB: get_or_create PlayerUser (user_code)
|
||||
A->>DB: create PlayerEdit
|
||||
A->>DB: repoint Content.filename → latest edit (preserve original_filename)
|
||||
A->>DB: Playlist.increment_version()
|
||||
A-->>P: 200 OK
|
||||
```
|
||||
|
||||
### Deployment
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as Admin UI
|
||||
participant S as Server (app)
|
||||
participant T as Background task
|
||||
participant H as Player host
|
||||
Admin->>S: add player / deploy
|
||||
S->>T: background_player_deployment()
|
||||
T->>H: sshpass test_ssh_connection()
|
||||
T->>H: rsync staged code (or git clone/pull)
|
||||
T->>H: write config/app_config.json
|
||||
T->>H: temp passwordless sudo → install.sh → start.sh
|
||||
T->>H: cleanup sudoers
|
||||
T-->>S: update player.deployment_status = deployed|failed
|
||||
Admin->>S: GET /players/deployment-status (poll)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Next: [06 · Utils & Services](06-utils-services.md)
|
||||
@@ -0,0 +1,169 @@
|
||||
# 06 · Utilities & Services
|
||||
|
||||
All shared services live in `app/utils/`. This document details each module, its main functions, and how they fit together.
|
||||
|
||||
---
|
||||
|
||||
## Overview Table
|
||||
|
||||
| Module | Community | Responsibility | Key symbols |
|
||||
|---|---|---|---|
|
||||
| `logger.py` | C0 | DB-backed audit logging | `log_action()`, `get_recent_logs()`, `clear_old_logs()` |
|
||||
| `group_player_management.py` | C0 | Player status reporting | `get_player_status_info()` |
|
||||
| `caddy_manager.py` | C1 | HTTPS Caddyfile generation | `CaddyConfigGenerator`, `write_caddyfile()`, `reload_caddy()` |
|
||||
| `background_tasks.py` | C10 | Async task execution | `run_background_task()`, `background_player_deployment()` |
|
||||
| `ssh_deploy.py` | C10 | Remote player provisioning | `deploy_player_to_host()`, `test_ssh_connection()`, `generate_player_config()` |
|
||||
| `player_build.py` | C11 | Stage player source code | `build_player_files()`, `write_base_config()`, `load/save_build_settings()` |
|
||||
| `pptx_converter.py` | C12 | PPTX → PDF → PNG | `pptx_to_pdf_libreoffice()`, `validate_pptx_file()`, `cleanup_libreoffice_processes()` |
|
||||
| `uploads.py` | C9 | Upload progress + file ops | `get/set/clear_upload_progress()`, `save_uploaded_file()`, `process_video_file()` |
|
||||
| `portal_sso.py` | C4 | SSO auto-login | `init_portal_sso()`, `_get_or_create_user()` |
|
||||
| `script_name_fix.py` | C4 | WSGI sub-path middleware | `ScriptNameFix` |
|
||||
|
||||
> `nginx_config_reader.py` (C7) was **removed** during the sanitization pass — the
|
||||
> reverse proxy is Caddy. See [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. `logger.py` — Audit Logging ⭐ God Node (116 edges)
|
||||
|
||||
The most-connected module in the system. Every meaningful action across all blueprints is recorded here.
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `log_action(level, message)` | Create + commit a `ServerLog` row |
|
||||
| `log_info(message)` / `log_warning(message)` / `log_error(message)` | Convenience wrappers |
|
||||
| `get_recent_logs(limit, level)` | Query recent logs (used by dashboard/admin) |
|
||||
| `clear_old_logs(days)` | Housekeeping |
|
||||
|
||||
**Usage pattern:** admin actions (`reset_user_password`, `upload_header_logo`, `delete_editing_user`, `delete_playlist`, login failures, etc.) all funnel through `log_action()`.
|
||||
|
||||
---
|
||||
|
||||
## 2. `background_tasks.py` — Async Execution
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `run_background_task(func, *args)` | Spawns a **daemon thread** with a Flask app context pushed, then runs `func` |
|
||||
| `background_player_deployment(...)` | Runs SSH deployment in background; updates `player.deployment_status` (`deploying → deployed|failed`) |
|
||||
|
||||
Used by `players.add_player` to avoid blocking the HTTP request during long remote installs.
|
||||
|
||||
---
|
||||
|
||||
## 3. `ssh_deploy.py` — Remote Player Provisioning ⭐ (heat 0.614)
|
||||
|
||||
The deployment engine. Full pipeline implemented by `deploy_player_to_host(...)`:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["deploy_player_to_host()"] --> B["test_ssh_connection() (sshpass)"]
|
||||
B --> C["mkdir remote dir"]
|
||||
C --> D{"staged code present?"}
|
||||
D -- yes --> E["rsync pre-staged code"]
|
||||
D -- no --> F["git clone / pull"]
|
||||
E --> G["write config/app_config.json"]
|
||||
F --> G
|
||||
G --> H["temp passwordless sudo"]
|
||||
H --> I["run install.sh"]
|
||||
I --> J["run start.sh"]
|
||||
J --> K["cleanup sudoers"]
|
||||
K --> L["return steps[]"]
|
||||
```
|
||||
|
||||
Other helpers:
|
||||
- `get_local_player_code_status()` — inspect staged code state.
|
||||
- `detect_server_ip()` / `parse_server_address()` — resolve the server address players should reach.
|
||||
- `generate_player_config()` / `generate_app_config()` — write player's `config/app_config.json` with `server_ip`, `port`, `screen_name`, `quickconnect_key`, `orientation`, `use_https`, `verify_ssl`.
|
||||
|
||||
---
|
||||
|
||||
## 4. `caddy_manager.py` — HTTPS Automation ⭐ God Node
|
||||
|
||||
`CaddyConfigGenerator` produces the Caddyfile for the reverse proxy and reloads Caddy without restart.
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `generate_caddyfile(config, http_fallback=True)` | Pick template by mode: **HTTP-only** (`:80`), **domain** (Let's Encrypt), or **IP-only** (internal CA `tls internal`). Includes `reverse_proxy digiserver-app:5000`, 2 GB body limit, gzip, security headers. `http_fallback` also serves plain HTTP alongside internal-CA TLS so clients that cannot trust the local CA still work |
|
||||
| `write_caddyfile(content, path=/etc/caddy/Caddyfile)` | Write to disk |
|
||||
| `reload_caddy()` | POST to Caddy admin API `http://caddy:2019/load` |
|
||||
|
||||
Triggered from `admin.update_https_config` (Admin UI) **or** `https_manager.py` (CLI),
|
||||
both of which save `HTTPSConfig` first so the two paths stay in sync.
|
||||
|
||||
> **Internal CA vs Let's Encrypt:** an intranet name (e.g. `*.harting.intra`) is not
|
||||
> resolvable publicly, so ACME challenges cannot succeed. Leaving `domain` empty selects
|
||||
> `tls internal`, which needs no DNS and no external service. See
|
||||
> [07 · Deployment §6](07-deployment.md#6-https-setup-caddy).
|
||||
|
||||
---
|
||||
|
||||
## 4b. `https_manager.py` — HTTPS CLI (repo root)
|
||||
|
||||
Command-line equivalent of the Admin HTTPS page, used by `deploy.sh`.
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `enable <hostname> <domain> <email> <ip> [port]` | Persist `HTTPSConfig`, regenerate + write the Caddyfile, hot-reload Caddy. Empty `<domain>` → internal CA. `--redirect-only` to disable the HTTP fallback; `--no-https` for HTTP only |
|
||||
| `disable` | Turn HTTPS off (HTTP only) |
|
||||
| `status` | Print the stored configuration and resolved mode |
|
||||
|
||||
Exit codes: `0` success · `1` bad args/config · `2` config applied but Caddy did not reload.
|
||||
|
||||
---
|
||||
|
||||
## 5. `player_build.py` — Staging Player Code
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `build_player_files(dir, repo_url, branch)` | Clone or `fetch + reset` the player repository into `PLAYER_CODE_DIR` |
|
||||
| `write_base_config(...)` | Write `config/app_config.json` (blank screen / quickconnect) |
|
||||
| `load_build_settings()` / `save_build_settings()` | JSON at `instance/player_build.json` |
|
||||
| `get_player_server_settings` / `make_build_record` / `get_short_head` | Build metadata helpers |
|
||||
|
||||
---
|
||||
|
||||
## 6. `uploads.py` — Upload & Media Processing
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `get_upload_progress` / `set_upload_progress` / `clear_upload_progress` | In-memory per-file progress (for the upload page) |
|
||||
| `save_uploaded_file` | Save a multipart upload |
|
||||
| `process_video_file` | FFmpeg → H.264 main, 30 fps, ≤1080p, faststart |
|
||||
| `process_pdf_file` | PDF processing (stub — real logic in content.py) |
|
||||
| `get_file_size` / `delete_file` | FS helpers |
|
||||
|
||||
---
|
||||
|
||||
## 7. `pptx_converter.py` — LibreOffice Integration
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `pptx_to_pdf_libreoffice(pptx_path, output_dir)` | Headless LibreOffice → PDF (300 s timeout) |
|
||||
| `validate_pptx_file()` | Validate a file is a real PPTX |
|
||||
| `cleanup_libreoffice_processes()` | `pkill soffice` — clean hanging processes |
|
||||
|
||||
Used by the upload pipeline: **PPTX → PDF → PNG slides (Full HD)**.
|
||||
|
||||
---
|
||||
|
||||
## 8. `portal_sso.py` & `script_name_fix.py` — Gateway Integration
|
||||
|
||||
- **`portal_sso.py`** — `before_request` reads `X-Auth-Username`/`X-Auth-Role` from the umbrella nginx and auto-logs-in the local user (creating it on first arrival). See [04 · Application Core §11](04-application-core.md#11-portal-sso--apputilsportal_sso_py).
|
||||
- **`script_name_fix.py`** — WSGI middleware mapping `X-Script-Name` → `SCRIPT_NAME` so `url_for()` is correct behind a path-prefixed gateway.
|
||||
|
||||
---
|
||||
|
||||
## 9. `group_player_management.py` — Player Status
|
||||
|
||||
Only `get_player_status_info(player_id)` remains: it returns the online flag
|
||||
(5-minute window), status, last-seen plus a humanised "time ago", and the latest
|
||||
`PlayerFeedback`. Used by `players.list` and `players.manage_player`.
|
||||
|
||||
The group helpers (`get_group_statistics`, `assign_player_to_group`,
|
||||
`bulk_assign_players_to_group`) and the status-list helpers
|
||||
(`get_online_players_count`, `get_players_by_status`) were **removed** with the
|
||||
archived Group subsystem.
|
||||
|
||||
---
|
||||
|
||||
> Next: [07 · Deployment](07-deployment.md)
|
||||
@@ -0,0 +1,356 @@
|
||||
# 07 · Deployment
|
||||
|
||||
DigiServer v2 runs as a **Docker Compose** stack (app + Caddy) with optional remote SSH provisioning of players.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Host["Docker Host"]
|
||||
subgraph Net["digiserver-network"]
|
||||
APP["digiserver-app\nFlask + Gunicorn :5000"]
|
||||
CAD["caddy:2-alpine\n:80 / :443"]
|
||||
end
|
||||
VOL1["./data/instance → /app/instance\n(SQLite DBs)"]
|
||||
VOL2["./data/uploads → /app/app/static/uploads"]
|
||||
VOL3["./data/Caddyfile → /etc/caddy/Caddyfile"]
|
||||
VOL4["./data/caddy-data → /data\n./data/caddy-config → /config"]
|
||||
end
|
||||
Browser["Browser"] --> CAD
|
||||
CAD --> APP
|
||||
APP --> VOL1
|
||||
APP --> VOL2
|
||||
CAD --> VOL3
|
||||
CAD --> VOL4
|
||||
APP -.SSH/rsync.-> Players["Remote player hosts"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `docker-compose.yml`
|
||||
|
||||
### Service `digiserver-app`
|
||||
- **Build:** `.` (Dockerfile, `python:3.13-slim`)
|
||||
- **Ports:** `5000:5000` (direct dev access; also `expose: 5000`)
|
||||
- **Volumes:** `./data/instance:/app/instance`, `./data/uploads:/app/app/static/uploads`
|
||||
- **Env:** `FLASK_ENV=production`, `SECRET_KEY`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`
|
||||
- **Healthcheck:** HTTP GET `http://localhost:5000/` (30s interval, 40s start)
|
||||
- **Restart:** `unless-stopped`
|
||||
|
||||
### Service `caddy`
|
||||
- **Image:** `caddy:2-alpine`
|
||||
- **Ports:** `8080:80`, `8443:443`
|
||||
- **Volumes:** `./data/Caddyfile:/etc/caddy/Caddyfile:rw`, `./data/caddy-data:/data`, `./data/caddy-config:/config`, `./data/caddy-logs:/var/log/caddy`
|
||||
- **Depends on:** app (service started)
|
||||
- **Healthcheck:** `wget` on port 80
|
||||
|
||||
---
|
||||
|
||||
## 3. Dockerfile
|
||||
|
||||
| Stage | Content |
|
||||
|---|---|
|
||||
| Base | `python:3.13-slim` |
|
||||
| System deps | `poppler-utils`, `ffmpeg`, `libmagic1`, `sudo`, `fonts-noto-color-emoji`, `LibreOffice` (core/impress/writer), `sshpass`, `openssh-client`, `rsync`, `git` |
|
||||
| Python | `COPY requirements.txt` → `pip install` (cached layer), then `COPY . .` |
|
||||
| App config | `FLASK_APP=app.app:create_app`, `FLASK_ENV=production`, `EXPOSE 5000` |
|
||||
| User | Non-root `appuser` (UID 1000) with **passwordless sudo** limited to `apt-get`, `install_libreoffice.sh`, `install_emoji_fonts.sh` |
|
||||
| Runtime | `HEALTHCHECK` (HTTP 5000), `ENTRYPOINT /docker-entrypoint.sh` |
|
||||
|
||||
---
|
||||
|
||||
## 4. `docker-entrypoint.sh`
|
||||
|
||||
1. **Pin the database**: export `DATABASE_URL` (default `sqlite:////app/instance/dashboard.db`) so migrations and the app resolve to the *same* file. Required because the config classes default to different files (`dev.db` vs `dashboard.db`) and most migration scripts call `create_app()` without an argument (→ development config), while the app runs `create_app('production')`.
|
||||
2. Create `/app/instance` and `/app/app/static/uploads`.
|
||||
3. Ensure schema + admin user — runs on **every** start (idempotent): `db.create_all()`, then create the admin from `ADMIN_USERNAME` / `ADMIN_PASSWORD` or refresh its password.
|
||||
4. Run the **migration chain** (idempotent, ordered — table-creating migrations run before those that alter them):
|
||||
|
||||
```
|
||||
add_https_config_table.py
|
||||
add_player_user_table.py
|
||||
add_email_to_https_config.py
|
||||
migrate_player_user_global.py
|
||||
add_url_to_content.py
|
||||
add_original_filename_to_content.py
|
||||
add_deployment_fields_to_player.py
|
||||
```
|
||||
|
||||
Migrations are **non-fatal**: failures are logged as `⚠️ WARNING` and startup continues, so one bad migration can't strand the container in a restart loop.
|
||||
|
||||
5. Start **Gunicorn**: `--bind 0.0.0.0:5000 --workers 4 --timeout 120 app.app:create_app('production')`.
|
||||
|
||||
> Because migrations now run automatically on startup, `deploy.sh` step 4 is redundant (harmless — the scripts are idempotent).
|
||||
|
||||
### Data layout (bind mounts)
|
||||
|
||||
| Host path | Container path | Contents |
|
||||
|---|---|---|
|
||||
| `data/instance` | `/app/instance` | SQLite DB (`dashboard.db`), `player_build.json` |
|
||||
| `data/uploads` | `/app/app/static/uploads` | Media files + `edited_media/` |
|
||||
| `data/Caddyfile` | `/etc/caddy/Caddyfile` | Reverse-proxy config (**a file, not a directory**) |
|
||||
| `data/caddy-data` | `/data` | Caddy state (instance UUID, certificates) |
|
||||
| `data/caddy-config` | `/config` | Caddy autosave |
|
||||
| `data/caddy-logs` | `/var/log/caddy` | Access logs |
|
||||
|
||||
> ⚠️ **`data/Caddyfile` must exist before `docker compose up`.** It is bind-mounted as a *file*;
|
||||
> if it is missing, Docker creates a **directory** in its place and Caddy fails to start.
|
||||
> `deploy.sh` seeds it from the version-controlled `Caddyfile.example`. For a manual start:
|
||||
> ```
|
||||
> mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs
|
||||
> cp Caddyfile.example data/Caddyfile
|
||||
> docker compose up -d --build
|
||||
> ```
|
||||
|
||||
> ℹ️ The legacy `data/nginx-*` and `data/certbot` folders are **obsolete** — the reverse proxy is
|
||||
> Caddy. They are no longer created by `deploy.sh`.
|
||||
|
||||
### Clean start (wipe all runtime data)
|
||||
|
||||
`data/` is **gitignored**, so wiping it is irreversible. To reset to a pristine deployment:
|
||||
|
||||
```
|
||||
docker compose down
|
||||
docker rmi digiserver-v2-digiserver-app:latest # drop stale image
|
||||
docker image prune -f && docker builder prune -a -f # reclaim build cache
|
||||
rm -rf data # WIPES db, uploads, certs
|
||||
mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs
|
||||
cp Caddyfile.example data/Caddyfile
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
> ⚠️ Files under `data/caddy-*` are created **root-owned** by the Caddy container, so a plain
|
||||
> `rm -rf data` may fail with *Permission denied*. Remove them via a helper container:
|
||||
> ```
|
||||
> docker run --rm -v "$PWD/data:/data" caddy:2-alpine sh -c 'rm -rf /data/caddy-config /data/caddy-data'
|
||||
> ```
|
||||
> Avoid `docker system prune --volumes` — this host also holds volumes for **other** projects.
|
||||
|
||||
---
|
||||
|
||||
## 5. `deploy.sh` (One-Shot Deployment)
|
||||
|
||||
```
|
||||
1. Detect compose: `docker compose` (plugin) or `docker-compose` (v1 fallback)
|
||||
— stored in $COMPOSE and used for every subsequent call
|
||||
2. Create data/ subdirs (instance, uploads, caddy-data, caddy-config, caddy-logs)
|
||||
and seed data/Caddyfile from Caddyfile.example
|
||||
3. $COMPOSE up -d + verify containers "Up"
|
||||
4. Run migration scripts (add_https_config_table, add_player_user_table,
|
||||
add_email_to_https_config, migrate_player_user_global,
|
||||
add_original_filename_to_content)
|
||||
↳ NOTE: the container entrypoint already applies all seven on startup.
|
||||
This step is idempotent and therefore redundant.
|
||||
5. /app/https_manager.py enable <hostname> <domain> <email> <ip> <port>
|
||||
↳ Exit code 2 ("config applied, Caddy not reloaded") is non-fatal.
|
||||
6. Verify DB tables via SQLAlchemy inspector; caddy validate;
|
||||
https_manager.py status; print access URLs + default creds
|
||||
```
|
||||
|
||||
### Configuration variables
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `HOSTNAME` | `digiserver` | Display hostname |
|
||||
| `HTTPS_MODE` | `internal` | `internal` \| `acme` \| `off` |
|
||||
| `DOMAIN` | *(empty)* | Required only when `HTTPS_MODE=acme` |
|
||||
| `IP_ADDRESS` | **auto-detected** | Primary LAN IP (override if needed) |
|
||||
| `EMAIL` | `admin@example.com` | ACME account email (unused by internal CA) |
|
||||
| `PORT` | `8443` | Externally published HTTPS port |
|
||||
|
||||
> If `IP_ADDRESS` is unset, `deploy.sh` auto-detects it
|
||||
> (`ip -4 route get 1.1.1.1` → `src` address, falling back to `hostname -I`).
|
||||
> The old hard-coded defaults (`10.76.152.164`, a `.intra` domain) were wrong for
|
||||
> most hosts and have been removed.
|
||||
|
||||
---
|
||||
|
||||
## 6. HTTPS Setup (Caddy)
|
||||
|
||||
Three equivalent entry points drive the **same** code path
|
||||
(`HTTPSConfig` + `CaddyConfigGenerator`) so CLI, env bootstrap and UI cannot diverge:
|
||||
|
||||
1. **Env bootstrap (deploy time)** — the container entrypoint runs
|
||||
`python /app/https_manager.py bootstrap`, which reads `HOSTNAME_INTERNAL` and
|
||||
`HOST_IP` from the environment and configures Caddy for HTTPS automatically.
|
||||
2. **CLI** — `python /app/https_manager.py enable … | verify | status | disable`
|
||||
3. **UI** — *Admin → HTTPS Configuration* (reloads Caddy live; the ongoing source of truth)
|
||||
|
||||
### Addressing model — one HTTP endpoint, one HTTPS endpoint
|
||||
|
||||
| Port | What it does |
|
||||
|---|---|
|
||||
| **80** | Always answers. A catch-all `:80` block serves **any** Host header, plus explicit blocks for the IP and hostname so both work. |
|
||||
| **443** | HTTPS for the same names, using the internal CA (or ACME for a public domain). |
|
||||
|
||||
If HTTPS is disabled or never configured, port 80 simply serves the app — there is
|
||||
no separate "HTTP mode" to set.
|
||||
|
||||
> ⚠️ **`default_sni` is required for IP access.** Browsers send **no SNI** when the
|
||||
> URL is an IP address (an IP is not a valid SNI hostname). Caddy then identifies the
|
||||
> connection by the container's own internal IP and aborts the handshake with
|
||||
> `no certificate available for '<container-ip>'`. To prevent this, the generator emits
|
||||
> `default_sni <ip>` whenever internal-CA mode is used, so `https://<ip>` works in a
|
||||
> plain browser. This was found by end-to-end testing — see
|
||||
> `docs/tools/test_http_https_runtime.sh`.
|
||||
|
||||
### Mode selection
|
||||
|
||||
| Condition | Result |
|
||||
|---|---|
|
||||
| HTTPS off, or no IP/domain | Plain HTTP on port 80 |
|
||||
| HTTPS on + `ip_address` / `hostname` | `tls internal` per name (no DNS, no ACME) |
|
||||
| HTTPS on + `domain` set | Let's Encrypt for that name |
|
||||
|
||||
### Automatic fallback if HTTPS does not work
|
||||
|
||||
After applying a config, `https_manager.py` probes the HTTPS endpoint
|
||||
(`/api/health`, certificate validation deliberately disabled). If the TLS listener
|
||||
does not come up, the configuration is **automatically reverted to plain HTTP** so a
|
||||
failed certificate can never make the site unreachable:
|
||||
|
||||
```
|
||||
enable HTTPS → reload Caddy → probe https://<host>:<port>/api/health
|
||||
├─ OK → keep HTTPS
|
||||
└─ FAIL → revert to HTTP-only, log a warning
|
||||
```
|
||||
|
||||
Disable the probe with `HTTPS_VERIFY=false` (or `enable --no-verify`).
|
||||
Re-check at any time with `python /app/https_manager.py verify`.
|
||||
|
||||
### Deploy-time bootstrap via `.env`
|
||||
|
||||
Copy `.env.example` → `.env` and set the host address. `docker-compose.yml`
|
||||
forwards these to the app container:
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `HOSTNAME_INTERNAL` | Hostname served (both HTTP and HTTPS) |
|
||||
| `HOST_IP` | IP served and certified |
|
||||
| `DOMAIN` | **Leave empty for an intranet name** → internal CA. Set only for Let's Encrypt |
|
||||
| `SSL_EMAIL` | ACME contact (ignored by internal CA) |
|
||||
| `HTTP_PORT` / `HTTPS_PORT` | Host ports mapped to Caddy's 80/443 (default `80`/`443`) |
|
||||
| `HTTPS_HTTP_FALLBACK` | `true` (default) also serves plain HTTP; `false` redirects instead |
|
||||
| `HTTPS_VERIFY` | `true` (default) probe HTTPS and auto-fall back on failure |
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# set HOSTNAME_INTERNAL and HOST_IP
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
> **If either `HOSTNAME_INTERNAL` or `HOST_IP` is missing the bootstrap is a no-op** —
|
||||
> the app starts on plain HTTP and stays reachable. HTTPS can then be enabled from
|
||||
> *Admin → HTTPS Configuration*, which regenerates the Caddyfile and reloads Caddy
|
||||
> **without a restart**.
|
||||
|
||||
### Who owns the config (env vs admin UI)
|
||||
|
||||
The admin UI is the **ongoing source of truth**. The bootstrap runs on every container
|
||||
start, so it guards against silently overwriting an admin's change by tracking
|
||||
provenance in `HTTPSConfig.updated_by`:
|
||||
|
||||
| Current config | Bootstrap behaviour |
|
||||
|---|---|
|
||||
| *(none — first deploy)* | Apply from env ✅ |
|
||||
| Written by env/CLI (`updated_by='deploy.sh'`) | Apply from env ✅ — so editing `HOST_IP` and redeploying works |
|
||||
| Written by a user (`updated_by='<username>'`) | **Skip** — the admin's setting is preserved |
|
||||
|
||||
So an admin change made in the UI survives restarts even while the env vars remain set.
|
||||
|
||||
### Why internal CA (and not Let's Encrypt) for `.intra`
|
||||
|
||||
An intranet name such as `digiserver.sibiusb.harting.intra` is **not resolvable from the
|
||||
public internet**, so Let's Encrypt's HTTP-01/TLS-ALPN challenge cannot succeed. Setting
|
||||
`DOMAIN=` empty makes Caddy sign the certificate itself with its local CA — no external
|
||||
dependency at all.
|
||||
|
||||
**Trust caveat:** the internal CA is not in any client trust store, so browsers show a
|
||||
warning and a Kivy player with `verify_ssl: true` will **reject the connection**. Options:
|
||||
|
||||
1. **Use HTTP** — port 80 always serves the app, so players need no trust configuration.
|
||||
2. **Install the root CA** on each device:
|
||||
```
|
||||
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
|
||||
```
|
||||
|
||||
### Ports
|
||||
|
||||
| Host → Container | Purpose |
|
||||
|---|---|
|
||||
| `80 → 80` | HTTP (always available) |
|
||||
| `443 → 443` | HTTPS |
|
||||
| `5000 → 5000` | Direct app access (bypasses Caddy; dev/testing) |
|
||||
|
||||
> Ports are configurable via `HTTP_PORT`/`HTTPS_PORT` so the stack also works where
|
||||
> 80/443 are already taken (e.g. `HTTP_PORT=8080 HTTPS_PORT=8443`).
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7. `verify-deployment.sh` — Pre/Post-Deployment Checks
|
||||
|
||||
Sections checked (pass/fail/warn counters):
|
||||
- git status
|
||||
- `.env` / `.env.example`
|
||||
- Docker + Compose versions (plugin **or** v1) + `compose config` syntax
|
||||
- Dockerfile best practices (HEALTHCHECK, non-root, slim base)
|
||||
- `requirements.txt` critical packages + versions
|
||||
- migrations directory
|
||||
- **TLS certificate** — Caddy internal CA expiry (`data/caddy-data/caddy/pki/authorities/local/root.crt`)
|
||||
- Flask config (`ProductionConfig`, `SESSION_COOKIE_SECURE`)
|
||||
- `data/Caddyfile` checks (reverse_proxy, admin API, TLS mode)
|
||||
- runtime container health + live HTTP/HTTPS endpoint probes
|
||||
- security best practices
|
||||
|
||||
> The script now detects `docker compose` (plugin) or `docker-compose` (v1) and warns when
|
||||
> buildx is too old for compose v1 builds — matching `deploy.sh`, which then falls back to
|
||||
> `docker build`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Player Deployment Pipeline
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Admin
|
||||
participant A as App
|
||||
participant B as Background
|
||||
participant H as Player host
|
||||
U->>A: Add player (name, hostname, password/quickconnect)
|
||||
A->>B: background_player_deployment()
|
||||
B->>B: build/refresh staged code (PLAYER_CODE_DIR)
|
||||
B->>H: sshpass + ssh test
|
||||
B->>H: rsync code (or git clone/pull fallback)
|
||||
B->>H: write config/app_config.json (server_ip, quickconnect, https...)
|
||||
B->>H: temporary passwordless sudo
|
||||
B->>H: ./install.sh && ./start.sh
|
||||
B->>H: remove temp sudoers
|
||||
B-->>A: player.deployment_status = 'deployed' | 'failed'
|
||||
U->>A: poll /players/deployment-status
|
||||
```
|
||||
|
||||
Statuses: `pending → deploying → deployed | failed`, with `last_deployment_at/status/message` persisted on the player row. Receiving player feedback also auto-marks deployment `deployed`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ports & Volumes Summary
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| App port (container) | 5000 |
|
||||
| App port (host, dev) | 5000 |
|
||||
| Caddy HTTP | 8080 → 80 |
|
||||
| Caddy HTTPS | 8443 → 443 |
|
||||
| Caddy admin API | 2019 (container) |
|
||||
| DB volume | `./data/instance` |
|
||||
| Uploads volume | `./data/uploads` |
|
||||
| Caddy data/config/logs | `./data/caddy-*` |
|
||||
|
||||
---
|
||||
|
||||
> Next: [08 · Workflows](08-workflows.md)
|
||||
@@ -0,0 +1,150 @@
|
||||
# 08 · End-to-End Workflows
|
||||
|
||||
This document traces the main user journeys through the system, tying together blueprints, models, utils, and the Graphify graph.
|
||||
|
||||
---
|
||||
|
||||
## 1. Content Upload → Playlist → Player
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["Admin uploads media"] --> UF["/content/upload-media"]
|
||||
UF -->|image| IMG["optimize_image_to_fullhd"]
|
||||
UF -->|video| VID["validate via ffprobe"]
|
||||
UF -->|pdf| PDF["pdf2image @300dpi → page PNGs"]
|
||||
UF -->|pptx| PPT["LibreOffice → PDF → PNG slides"]
|
||||
UF -->|large file| BG["process_file_in_background (thread)"]
|
||||
UF --> DB[("Content row")]
|
||||
DB --> PLA["/content/playlist/<id>/add-content"]
|
||||
PLA --> PLC["playlist_content (position, duration, muted, edit_on_player_enabled)"]
|
||||
PLC --> VER["Playlist.increment_version() + cache.clear()"]
|
||||
VER --> API["/api/playlists/<id>"]
|
||||
API --> P["Player fetches new playlist"]
|
||||
```
|
||||
|
||||
**Key files:** `app/blueprints/content.py`, `app/utils/uploads.py`, `app/utils/pptx_converter.py`, `app/models/content.py`, `app/models/playlist.py`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Playlist Synchronization (the "version" mechanism)
|
||||
|
||||
Every playlist mutation bumps `Playlist.version`. Players detect changes by polling:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Admin UI
|
||||
participant App
|
||||
participant DB as SQLite
|
||||
participant P as Player
|
||||
|
||||
U->>App: reorder / mute / duration / add / remove
|
||||
App->>DB: mutate playlist_content
|
||||
App->>DB: playlist.increment_version()
|
||||
App->>App: cache.clear()
|
||||
loop while player runs
|
||||
P->>App: GET /api/playlist-version/<player_id>
|
||||
App-->>P: {version: N}
|
||||
alt version changed
|
||||
P->>App: GET /api/playlists/<player_id>
|
||||
App-->>P: new playlist payload
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Playlist payload items** include: `file_name`, `type`, `duration`, `position`, `url` (weblink or static file), `description`, `edit_on_player_enabled`, `muted`, `audio`.
|
||||
|
||||
---
|
||||
|
||||
## 3. On-Player Media Editing Pipeline ⭐ (heat 0.620)
|
||||
|
||||
When a signage operator edits an image directly on the player device:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Player posts edited image"] --> B["POST /api/player-edit-media (Bearer + multipart)"]
|
||||
B --> C{"Locate Content"}
|
||||
C -->|by filename| D["match filename"]
|
||||
C -->|edited_media regex| E["match edited_media/<id>/"]
|
||||
C -->|fallback| F["last PlayerEdit"]
|
||||
D/E/F --> G["Move original → edited_media/<content_id>/original_*"]
|
||||
G --> H["Save new version + side-car metadata JSON"]
|
||||
H --> I{"PlayerUser exists for user_code?"}
|
||||
I -- no --> J["auto-create PlayerUser"]
|
||||
I -- yes --> K["reuse"]
|
||||
J/K --> L["Create PlayerEdit (version, original_name, new_name...)"]
|
||||
L --> M["Repoint Content.filename → latest edit"]
|
||||
M --> N["Preserve original_filename"]
|
||||
N --> O["Playlist.increment_version() + cache.clear()"]
|
||||
O --> P["Player sees updated media on refresh"]
|
||||
```
|
||||
|
||||
**Why this is a god node:** `PlayerEdit` (99 edges) and `PlayerUser` (74 edges) make this the most-connected data flow in the system. See `app/models/player_edit.py`, `app/models/player_user.py`, `app/blueprints/api.py`.
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTPS Configuration (Caddy)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Admin → /admin/https-config"] --> B["POST /admin/https-config/update"]
|
||||
B --> C["Validate + save HTTPSConfig (email, domain, ip, port)"]
|
||||
C --> D["CaddyConfigGenerator.generate_caddyfile()"]
|
||||
D --> E{"Mode"}
|
||||
E -->|HTTP| F[":80 reverse proxy"]
|
||||
E -->|domain| G["Let's Encrypt automatic HTTPS"]
|
||||
E -->|IP| H["Internal CA self-signed"]
|
||||
F/G/H --> I["write_caddyfile(/etc/caddy/Caddyfile)"]
|
||||
I --> J["POST http://caddy:2019/load (hot reload)"]
|
||||
J --> K["HTTPS live without restart"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. User Management & Auditing
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Admin panel"] --> B["/admin/user/create"]
|
||||
A --> C["/admin/user/<id>/role"]
|
||||
A --> D["/admin/user/<id>/password"]
|
||||
A --> E["/admin/user/<id>/delete"]
|
||||
B/C/D/E --> L["log_action(level, message)"]
|
||||
L --> S[("server_log")]
|
||||
S --> F["dashboard / admin recent logs"]
|
||||
S --> G["/api/logs (limit, level, since)"]
|
||||
```
|
||||
|
||||
**Roles:** `admin` (full), `user` (manage content/players), `viewer` (read-only dashboard).
|
||||
|
||||
---
|
||||
|
||||
## 6. Portal SSO (optional corporate gateway)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant G as Umbrella nginx gateway
|
||||
participant A as DigiServer
|
||||
B->>G: request (authenticated by portal)
|
||||
G->>A: proxy + headers X-Auth-Username, X-Auth-Role
|
||||
A->>A: init_portal_sso before_request
|
||||
A->>A: _get_or_create_user(username, role)
|
||||
A-->>B: dashboard (auto logged-in)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Media Housekeeping (Leftovers)
|
||||
|
||||
Content not assigned to any playlist is flagged as "leftover":
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
L["/admin/leftover-media"] --> T["grouped by type: image/video/pdf/pptx + sizes"]
|
||||
T --> D["/admin/delete-leftover-images|videos"]
|
||||
D --> X["delete file + edited_media archive + PlayerEdit rows"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Next: [09 · Legacy & Migrations](09-legacy-and-migrations.md)
|
||||
@@ -0,0 +1,67 @@
|
||||
# 09 · Migrations & Legacy Components
|
||||
|
||||
---
|
||||
|
||||
## 1. Migrations
|
||||
|
||||
Migrations are **standalone Python scripts** (run via `docker compose exec ... python`, **not** Alembic revisions — there is no `migrations/versions/`).
|
||||
|
||||
| Script | Table(s) | What it does |
|
||||
|---|---|---|
|
||||
| `add_https_config_table.py` | `https_config` | Creates table via `db.create_all()` |
|
||||
| `add_player_user_table.py` | `player_user` | Creates table via `db.create_all()` |
|
||||
| `add_email_to_https_config.py` | `https_config` | `ALTER TABLE ... ADD COLUMN email VARCHAR(255)` (idempotent) |
|
||||
| `add_deployment_fields_to_player.py` | `player` | Adds `deployment_status`, `last_deployment_at`, `last_deployment_status`, `last_deployment_message` (idempotent) |
|
||||
| `add_url_to_content.py` | `content` | `ALTER TABLE ... ADD COLUMN url VARCHAR(2048)` (weblink support) |
|
||||
| `add_original_filename_to_content.py` | `content` | Adds `original_filename` then **backfills**: from first (v1) `PlayerEdit.original_name` for edited content, else `filename` |
|
||||
| `migrate_player_user_global.py` | `player_user` | **Drop & recreate**: removes old `player_id` FK, makes `user_code` globally unique, `user_name` nullable |
|
||||
|
||||
**Order (from `deploy.sh`):**
|
||||
```
|
||||
add_https_config_table.py
|
||||
add_player_user_table.py
|
||||
add_email_to_https_config.py
|
||||
migrate_player_user_global.py
|
||||
add_original_filename_to_content.py
|
||||
```
|
||||
|
||||
> ⚠ `deploy.sh` also calls `/app/https_manager.py` — **that file is not present in this repo**, so that deployment step needs attention.
|
||||
|
||||
---
|
||||
|
||||
## 2. Legacy / Archived Components
|
||||
|
||||
| Component | Status | Notes |
|
||||
|---|---|---|
|
||||
| `app/blueprints/content_old.py` | **DELETED** | Legacy per-player content routes. Removed in the sanitization pass together with its templates (`content_list.html`, `edit_content.html`, `upload_content.html`). |
|
||||
| `app/blueprints/playlist.py` | **Active but legacy** | Per-player playlist routes kept as redirects to the modern content workflow. |
|
||||
| `Group` model + group routes | **DELETED** | `models/group.py`, the `group_content` association, `Content.groups` / `Content.group_count`, and the group utility functions were removed. `Player` never had a `group_id` column. |
|
||||
| `utils/nginx_config_reader.py` | **DELETED** | Legacy nginx parsing — the reverse proxy is Caddy. |
|
||||
| `nginx` stack | **Replaced by Caddy** | `data/nginx.conf`, `data/nginx-custom-domains.conf`, `data/nginx-logs/`, `data/nginx-ssl/` removed with the Caddy migration. `migrate_network.sh` no longer generates self-signed certs — Caddy issues them. |
|
||||
| `https_manager.py` | **Missing** | Referenced by `deploy.sh` but not in repo — likely merged into `CaddyConfigGenerator`. |
|
||||
| `old_code_documentation/` | **Archive** | Full legacy docs, old scripts (`blueprint_groups.py`, `add_muted_column.py`, `fix_player_user_schema.py`, `test_edit_media_*.py`, `check_fix_player.py`, `migrate_add_edit_enabled.py`), deployment guides, HTTPS analysis, player analysis. |
|
||||
| `QUICK_DEPLOYMENT.md`, `deployment-commands-reference.sh` | **Active reference** | Manual deployment notes. |
|
||||
| `docs/legacy code/` | **Snapshot** | Full pre-sanitization copy of the codebase. Excluded from the Docker build via `.dockerignore`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Recommended Cleanup (optional)
|
||||
|
||||
- Remove `old_code_documentation/*.py` scripts that are no longer needed (keep the `.md` docs).
|
||||
- Resolve the missing `https_manager.py` in `deploy.sh` — **done**: `https_manager.py` now exists at the repo root.
|
||||
- Update `verify-deployment.sh` to reference Caddy instead of nginx — **done**.
|
||||
- Consider removing the legacy `playlist.py` blueprint (see [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md) batch B1).
|
||||
|
||||
---
|
||||
|
||||
## 📚 Back to Index
|
||||
|
||||
- [README · Documentation Home](README.md)
|
||||
- [01 · Architecture](01-architecture.md)
|
||||
- [02 · Knowledge Graph](02-knowledge-graph.md)
|
||||
- [03 · Data Model](03-data-model.md)
|
||||
- [04 · Application Core](04-application-core.md)
|
||||
- [05 · Blueprints & API](05-blueprints-api.md)
|
||||
- [06 · Utils & Services](06-utils-services.md)
|
||||
- [07 · Deployment](07-deployment.md)
|
||||
- [08 · Workflows](08-workflows.md)
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# DigiServer v2 — Project Documentation
|
||||
|
||||
> **Comprehensive technical documentation generated with [Graphify](https://marketplace.visualstudio.com/items?itemName=anytechiestudio.graphify-vscode)** — an interactive knowledge-graph engine for AI-assisted coding.
|
||||
|
||||
**Version:** 2.0.0 · **Build date:** 2025-11-12 · **Generated:** 2026-08-16
|
||||
|
||||
DigiServer v2 is a **digital signage management server** built with **Flask**. It manages media content (images, videos, PDFs, presentations), organizes it into **playlists**, assigns **players** (physical signage displays), and remotely **deploys and monitors** those players over SSH — including HTTPS/SSL provisioning via Caddy and an on-player photo-editing pipeline.
|
||||
|
||||
---
|
||||
|
||||
## 🗂 Documentation Index
|
||||
|
||||
| Document | Purpose |
|
||||
|---|---|
|
||||
| [**01-architecture.md**](01-architecture.md) | System overview, layers, component map, architecture diagrams |
|
||||
| [**02-knowledge-graph.md**](02-knowledge-graph.md) | Graphify graph: 41 communities, god nodes, and how to use the visualizer |
|
||||
| [**03-data-model.md**](03-data-model.md) | Database schema: all 10 tables, relationships, ER diagrams |
|
||||
| [**04-application-core.md**](04-application-core.md) | App factory, config, extensions, middleware, CLI, templates |
|
||||
| [**05-blueprints-api.md**](05-blueprints-api.md) | All 7 blueprints + full REST API reference |
|
||||
| [**06-utils-services.md**](06-utils-services.md) | Background tasks, SSH deploy, Caddy manager, player build, SSO, uploads |
|
||||
| [**07-deployment.md**](07-deployment.md) | Docker, Caddy, deployment pipeline, HTTPS setup, verify script |
|
||||
| [**08-workflows.md**](08-workflows.md) | End-to-end flows: upload, playlists, player edits, deployment, sync |
|
||||
| [**09-legacy-and-migrations.md**](09-legacy-and-migrations.md) | Migration scripts, deprecated/archived components |
|
||||
|
||||
**Auto-generated Graphify artifacts** (regenerate anytime — see [02-knowledge-graph.md](02-knowledge-graph.md)):
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html ← Interactive visualizer (open in browser)
|
||||
├── graph.json ← Raw graph data (631 nodes / 1162 edges)
|
||||
├── GRAPH_REPORT.md ← God nodes, communities, knowledge gaps
|
||||
├── COMPASS.md ← Token-optimized architecture summary
|
||||
├── DOMAINS.md ← Community → domain mapping
|
||||
├── intelligence.json ← AI-detected insights (god nodes, surprises)
|
||||
├── graph.compact.txt ← Compact graph dump for LLM context
|
||||
└── wiki/ ← Per-community + per-god-node Markdown articles
|
||||
├── index.md
|
||||
├── Community_0.md … Community_40.md
|
||||
├── CaddyConfigGenerator.md, Content.md, HTTPSConfig.md,
|
||||
├── PlayerEdit.md, PlayerUser.md, Playlist.md, User.md,
|
||||
├── log_action().md, create_app().md …
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Facts
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Framework | Flask 3.1 + SQLAlchemy 2.0 (SQLite) + Gunicorn |
|
||||
| Application type | Digital signage content/playlist/player management |
|
||||
| Graph size | **631 nodes · 1162 edges · 41 communities** |
|
||||
| Blueprints | 7 active (`main`, `auth`, `admin`, `players`, `content`, `playlist`, `api`) |
|
||||
| Database tables | 9 (`user`, `player`, `player_edit`, `player_feedback`, `player_user`, `content`, `playlist`, `server_log`, `https_config`) |
|
||||
| Reverse proxy | Caddy 2 (automatic HTTPS / Let's Encrypt) |
|
||||
| Deployment | Docker Compose (app + Caddy) + remote SSH player provisioning |
|
||||
| Key externals | LibreOffice, Poppler (pdf2image), FFmpeg, sshpass/rsync |
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Core Abstractions (Graphify "God Nodes")
|
||||
|
||||
Detected automatically from graph centrality:
|
||||
|
||||
1. `log_action()` — 116 edges · DB-backed audit logging (`app/utils/logger.py`)
|
||||
2. `PlayerEdit` — 99 edges · on-player edit records (`app/models/player_edit.py`)
|
||||
3. `PlayerUser` — 74 edges · player edit user mapping (`app/models/player_user.py`)
|
||||
4. `Playlist` — 33 edges · playlist + versioning (`app/models/playlist.py`)
|
||||
5. `CaddyConfigGenerator` — 31 edges · HTTPS Caddyfile generation (`app/utils/caddy_manager.py`)
|
||||
6. `HTTPSConfig` — 30 edges · HTTPS settings model (`app/models/https_config.py`)
|
||||
7. `Content` — 24 edges · media content model (`app/models/content.py`)
|
||||
8. `User` — 19 edges · admin/user/viewer accounts (`app/models/user.py`)
|
||||
9. `create_app()` — 13 edges · application factory (`app/app.py`)
|
||||
10. `app/models/__init__.py` — 13 edges · models package export
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Quick Navigation
|
||||
|
||||
### The 7 Active Blueprints
|
||||
|
||||
| Blueprint | Prefix | Responsibility |
|
||||
|---|---|---|
|
||||
| `main` | `/` | Dashboard + health check |
|
||||
| `auth` | `/` | Login / logout / register / change password |
|
||||
| `admin` | `/admin` | Users, HTTPS config, player build, logos, logs, leftovers |
|
||||
| `players` | `/players` | Player CRUD, manage, edited media, deployment status |
|
||||
| `content` | `/content` | Media library, uploads, playlist management (modern) |
|
||||
| `playlist` | `/playlist` | Legacy per-player playlist routes (redirects) |
|
||||
| `api` | `/api` | Player-facing REST API (auth, playlists, feedback, edits, deploy) |
|
||||
|
||||
### Data Model at a Glance
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
user ||--o{ server_log : ""
|
||||
player ||--o{ player_feedback : "cascade"
|
||||
player ||--o{ player_edit : "cascade"
|
||||
player }o--o| playlist : "assigned"
|
||||
content ||--o{ player_edit : "cascade"
|
||||
content ||--o{ player_feedback : ""
|
||||
playlist ||--o{ content : "playlist_content (M2M)"
|
||||
player_user ||--o{ player_edit : "user_code"
|
||||
https_config ||--|| https_config : "single row config"
|
||||
```
|
||||
|
||||
> **Proceed to [01-architecture.md](01-architecture.md)** for the full system overview and diagrams.
|
||||
@@ -0,0 +1,206 @@
|
||||
# DigiServer v2 — Code Sanitization Review
|
||||
|
||||
**Generated:** 2026-09-10
|
||||
**Snapshot:** `docs/legacy code/` (1.7 MB, 171 files, 49 Python files) — full restore point.
|
||||
|
||||
Analysed **42 Python files / 240 functions / 104 routes / 32 templates** under `app/` and `migrations/`.
|
||||
|
||||
> Review each section below and reply with the IDs you want deleted (e.g. `A1, A2, B1`).
|
||||
> Nothing is deleted until you confirm. Everything is recoverable from `docs/legacy code/`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Status — Applied 2026-09-10
|
||||
|
||||
**Removed (A1, A2, D1, D2):**
|
||||
|
||||
| ID | Removed | Notes |
|
||||
|---|---|---|
|
||||
| A1 | `app/blueprints/content_old.py` | + 3 templates orphaned by its removal |
|
||||
| A2 | `app/utils/nginx_config_reader.py` | |
|
||||
| D1 | 5 group functions + their `__init__.py` exports | `get_player_status_info` kept (live) |
|
||||
| D2 | `app/models/group.py`, `Content.groups`, `Content.group_count` | |
|
||||
|
||||
**Extra cleanup triggered by A1/D2:**
|
||||
- Deleted orphaned templates: `upload_content.html` (278), `edit_content.html` (11)
|
||||
- Removed the dead `groups` key from `/api/system-info` and `group_count` from `/api/content`
|
||||
- Removed the `'group_id': getattr(player, 'group_id', None)` compat shim from `/api/player-status`
|
||||
- Updated 8 documentation files
|
||||
|
||||
**Also removed (B1, B2) — applied in a second pass:**
|
||||
|
||||
| ID | Removed | Notes |
|
||||
|---|---|---|
|
||||
| B1 | `app/blueprints/playlist.py` (310 LOC) + its registration in `app.py` | Whole legacy blueprint; its only real route redirected to `content.manage_playlist_content` |
|
||||
| B2 | 3 routes in `players.py`: `reorder_content`, `reorder_playlist`, `remove_from_playlist` (~103 LOC) | Two queried nonexistent columns (`Content.player_id`, `Content.position`, `Player.playlist_version`) → guaranteed 500s |
|
||||
|
||||
**Extra cleanup triggered by B1:**
|
||||
- Deleted `content_list.html` (202 lines) — its only remaining reference was `url_for('playlist.manage_playlist')`. It was **already orphaned** (no Python file rendered it) after `content_old.py` was deleted, so it has been removed for real this time.
|
||||
- Deleted `players/player_page.html` (227 lines) — it was **never rendered** by any view (the `players.player_page` route redirects to `manage_player`), so it was dead UI. Corrects the earlier "false positive" note below.
|
||||
|
||||
> ⚠️ **Functional note:** `players.regenerate_auth_code` (`POST /players/<id>/regenerate-auth`) is now
|
||||
> referenced by **no template** — `player_page.html` was its only caller. The endpoint still works if
|
||||
> invoked directly. The equivalent control lives in `manage_player.html` via the quickconnect flow.
|
||||
> Restore `player_page.html` from `docs/legacy code/` if you want that button back.
|
||||
|
||||
**Result (A + B + D combined):**
|
||||
```
|
||||
42 → 38 Python modules 9,311 → 8,296 LOC
|
||||
104 → 82 routes 32 → 28 templates
|
||||
7 → 6 blueprints dead modules: 2 → 0, orphan templates: 0
|
||||
```
|
||||
|
||||
**Verified:** all files compile; smoke test passes on a fresh DB (every API endpoint + key UI route returns
|
||||
< 500); `db.metadata` no longer registers `group`/`group_content`; app boots against a migrated copy of the
|
||||
real `dashboard.db` with all data intact; `app.blueprints` no longer contains `playlist`.
|
||||
|
||||
> ⚠️ **Still open:** the real `data/instance/dashboard.db` predates the `original_filename` migration.
|
||||
> On startup the entrypoint now applies it automatically. To fix locally, run:
|
||||
> ```
|
||||
> DATABASE_URL=sqlite:///$PWD/data/instance/dashboard.db ./.venv/bin/python migrations/add_original_filename_to_content.py
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Section A — Dead modules (zero importers)
|
||||
|
||||
| ID | Target | LOC | Evidence | Risk |
|
||||
|---|---|---|---|---|
|
||||
| **A1** | `app/blueprints/content_old.py` | 500 | `app.py` imports `content.py`; this file's `content_bp` is **never registered**. Superseded "old" content workflow. | **Low** |
|
||||
| **A2** | `app/utils/nginx_config_reader.py` | 120 | Never imported anywhere. Stack migrated nginx → Caddy, so the reader is obsolete. | **Low** |
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
app_py["app.py<br/>register_blueprints()"] --> content["content.py<br/>content_bp ✅ ACTIVE"]
|
||||
content_old["content_old.py<br/>content_bp ❌ DEAD"] -.->|never imported| x1[" "]
|
||||
nginx["nginx_config_reader.py<br/>❌ DEAD"] -.->|never imported| x2[" "]
|
||||
caddy["caddy_manager.py<br/>✅ ACTIVE"] --> app_py
|
||||
style content_old fill:#7f1d1d,color:#fff
|
||||
style nginx fill:#7f1d1d,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section B — Legacy duplicate route surface — ✅ **REMOVED**
|
||||
|
||||
Two blueprints exposed **parallel implementations of the same operations**. Only the `content.*`
|
||||
versions were wired to the UI; the legacy ones had no template references.
|
||||
|
||||
| ID | Target | LOC | Evidence | Risk |
|
||||
|---|---|---|---|---|
|
||||
| **B1** ✅ | `app/blueprints/playlist.py` (whole file + registration) | 310 | Entire file was legacy per-player playlist. Its `manage_playlist` route did nothing but **redirect** to the modern `content.manage_playlist_content`. The other 6 routes had **no template reference**. | **Low–Med** |
|
||||
| **B2** ✅ | 3 routes in `players.py`: `reorder_content`, `reorder_playlist`, `remove_from_playlist` | ~103 | Superseded by `content.*`. Two queried nonexistent columns (`Content.player_id`, `.position`, `Player.playlist_version`) → guaranteed HTTP 500 if called. | **Low** |
|
||||
|
||||
**Duplicate operation matrix**
|
||||
|
||||
| Operation | Modern (LIVE) | Legacy (DEAD) |
|
||||
|---|---|---|
|
||||
| Add content | `content.add_content_to_playlist` | `playlist.add_to_playlist` |
|
||||
| Remove content | `content.remove_content_from_playlist` | `playlist.remove_from_playlist`, `players.remove_from_playlist` ⚠️broken |
|
||||
| Reorder | `content.reorder_playlist_content` | `playlist.reorder_playlist`, `players.reorder_content` ⚠️broken, `players.reorder_playlist` ⚠️broken |
|
||||
| Set duration | `content.update_playlist_content_duration` | `playlist.update_duration` |
|
||||
| Mute audio | `content.update_playlist_content_muted` | `playlist.update_muted` |
|
||||
| Toggle edit | `content.update_playlist_content_edit_enabled` | — |
|
||||
| Clear | — | `playlist.clear_playlist` |
|
||||
|
||||
---
|
||||
|
||||
## Section C — Broken code: references to columns that do not exist
|
||||
|
||||
Confirmed against the live DB schema. These raised `AttributeError`/`OperationalError` at runtime.
|
||||
**All resolved by deleting A1 + B2.**
|
||||
|
||||
| ID | Location | Broken reference | Reachable? |
|
||||
|---|---|---|---|
|
||||
| **C1** ✅ | `players.py:768,775` (`remove_from_playlist`) | `player.playlist_version` | Via route only (no UI link) |
|
||||
| **C2** ✅ | `players.py:~700` (`reorder_playlist`) | `Content.player_id`, `Content.position` | Via route only (no UI link) |
|
||||
| **C3** | `content_old.py:189-190` | `player.playlist_version` | No (dead module) |
|
||||
| **C4** | `content_old.py:50,57` | `content.player_id`, `player.group` | No (dead module) |
|
||||
|
||||
> ✅ **Resolved by deleting A1 + B2.** If you keep those files, they must be rewritten.
|
||||
|
||||
**Not a bug (verified by hand):** `Content._playlist_duration` /
|
||||
`._playlist_position` / `._playlist_muted` in `api.py` are set **dynamically** by
|
||||
`Playlist.get_content_ordered()`. These are intentional and work correctly — the
|
||||
static analyzer flags them because it only sees model class attributes.
|
||||
|
||||
---
|
||||
|
||||
## Section D — Legacy groups subsystem
|
||||
|
||||
Groups are fully deprecated (0 rows, `/api/groups` already commented out), but the code lingers.
|
||||
|
||||
| ID | Target | LOC | Evidence | Risk |
|
||||
|---|---|---|---|---|
|
||||
| **D1** | 5 group functions in `app/utils/group_player_management.py`: `get_group_statistics`, `assign_player_to_group`, `bulk_assign_players_to_group`, `get_online_players_count`, `get_players_by_status` + their `__init__.py` exports | ~130 | **Zero callers outside the module.** All three group functions reference `player.group_id`, **which does not exist** → broken. | **Low** |
|
||||
| **D2** | `app/models/group.py` (Group model + `group_content` table) | 71 | Retained only because `Content.groups` FK relationship and `Content.group_count` reference it. Requires touching the Content model. | **Medium** |
|
||||
|
||||
> ⚠️ **Keep:** `get_player_status_info()` (top of the same file) is **live** — used at
|
||||
> `players.py:28` and `players.py:432`. Only the group functions should go.
|
||||
|
||||
---
|
||||
|
||||
## Section E — Legacy redirect stubs
|
||||
|
||||
Thin compatibility shims that only redirect to the modern UI. They're harmless but keep dead
|
||||
URL surface alive.
|
||||
|
||||
| ID | Target | Evidence |
|
||||
|---|---|---|
|
||||
| **E1** | `players.player_page` (`/players/<id>`) | Body is a single `redirect(url_for('players.manage_player'))`. Still `url_for`-referenced by 2 templates, so keeping it is fine. |
|
||||
| **E2** | `playlist.manage_playlist` (`/playlist/<id>`) | Part of B1 — already covered by deleting that file. |
|
||||
|
||||
---
|
||||
|
||||
## Section F — Orphan templates / assets
|
||||
|
||||
| ID | Target | LOC | Evidence |
|
||||
|---|---|---|---|
|
||||
| — | (none) | — | After the B1/B2 pass the codebase has **0 orphan templates**. |
|
||||
|
||||
---
|
||||
|
||||
## Recommended batches — ✅ **ALL APPLIED**
|
||||
|
||||
| Batch | Contents | Total removed | Status |
|
||||
|---|---|---|---|
|
||||
| **Batch 1 — Safe clean** | **A1, A2** | ~620 LOC | ✅ done |
|
||||
| **Batch 2 — Legacy playlist** | **B1, B2** | ~413 LOC | ✅ done |
|
||||
| **Batch 3 — Groups** | **D1** | ~130 LOC | ✅ done |
|
||||
| **Batch 4 — Group model** | **D2** | ~71 LOC | ✅ done |
|
||||
|
||||
**Every batch was followed by:** compile-all + app-factory boot + route smoke test, so hidden
|
||||
dependencies were caught before moving on.
|
||||
|
||||
---
|
||||
|
||||
## How I verified this (so you can trust it)
|
||||
|
||||
| Check | Method |
|
||||
|---|---|
|
||||
| Module reachability | AST import extraction + `register_blueprint` cross-reference |
|
||||
| Route usage | `url_for('endpoint')` **and** literal path matching against all templates/JS |
|
||||
| Broken columns | Compared every `var.attr` access against live SQLite `PRAGMA table_info` |
|
||||
| Dynamic attributes | Manually inspected `get_content_ordered()` to rule out false positives |
|
||||
| Duplicate bodies | `ast.dump` body hashing across all functions (result: 0 exact duplicates) |
|
||||
|
||||
**Reproduce anytime:**
|
||||
```
|
||||
./.venv/bin/python docs/tools/sanitize_report.py # dead code + broken refs
|
||||
./.venv/bin/python docs/tools/sanitize_audit.py # full function inventory
|
||||
./.venv/bin/python docs/tools/sanitize_templates.py # orphan templates
|
||||
./.venv/bin/python docs/tools/smoke_test.py # post-change smoke test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Rollback / hygiene notes
|
||||
|
||||
1. **`docs/legacy code/` is excluded from the Docker image** (via `legacy code/` and
|
||||
`**/legacy code/` in `.dockerignore`) so it never
|
||||
ships to production or bloats the build.
|
||||
2. It is **not** git-ignored, so it will appear in `git status`. Decide:
|
||||
- commit it as a recovery point, or
|
||||
- add `legacy code/` to `.gitignore` if you'd rather rely on git history.
|
||||
3. Deleting files listed here is **not** recoverable from git unless committed first — the
|
||||
`docs/legacy code/` copy is your safety net.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Architectural Compass - /home/scheianu/digiserver-v2 (2026-08-16)
|
||||
|
||||
> [!NOTE]
|
||||
> This is a token-optimized summary. For deep logic, see GRAPH_REPORT.md.
|
||||
|
||||
## Core Abstractions (God Nodes)
|
||||
1. `log_action()` (116 edges)
|
||||
2. `PlayerEdit` (99 edges)
|
||||
3. `PlayerUser` (74 edges)
|
||||
4. `Playlist` (33 edges)
|
||||
5. `CaddyConfigGenerator` (31 edges)
|
||||
6. `HTTPSConfig` (30 edges)
|
||||
7. `Content` (24 edges)
|
||||
8. `User` (19 edges)
|
||||
9. `create_app()` (13 edges)
|
||||
10. `Models package for digiserver-v2.` (13 edges)
|
||||
|
||||
## System Layers
|
||||
- **L0: Global/Entry**:
|
||||
- **L1: Strategic/Core**:
|
||||
- **L2: Implementation**:
|
||||
- **L3: Utility**:
|
||||
@@ -0,0 +1,45 @@
|
||||
# System Domains (Communities)
|
||||
|
||||
| ID | Domain | Rationale |
|
||||
|---|---|---|
|
||||
| 0 | Community 0 | |
|
||||
| 1 | Community 1 | |
|
||||
| 2 | Community 2 | |
|
||||
| 3 | Community 3 | |
|
||||
| 4 | Community 4 | |
|
||||
| 5 | Community 5 | |
|
||||
| 6 | Community 6 | |
|
||||
| 7 | Community 7 | |
|
||||
| 8 | Community 8 | |
|
||||
| 9 | Community 9 | |
|
||||
| 10 | Community 10 | |
|
||||
| 11 | Community 11 | |
|
||||
| 12 | Community 12 | |
|
||||
| 13 | Community 13 | |
|
||||
| 14 | Community 14 | |
|
||||
| 15 | Community 15 | |
|
||||
| 16 | Community 16 | |
|
||||
| 17 | Community 17 | |
|
||||
| 18 | Community 18 | |
|
||||
| 19 | Community 19 | |
|
||||
| 20 | Community 20 | |
|
||||
| 21 | Community 21 | |
|
||||
| 22 | Community 22 | |
|
||||
| 23 | Community 23 | |
|
||||
| 24 | Community 24 | |
|
||||
| 25 | Community 25 | |
|
||||
| 26 | Community 26 | |
|
||||
| 27 | Community 27 | |
|
||||
| 28 | Community 28 | |
|
||||
| 29 | Community 29 | |
|
||||
| 30 | Community 30 | |
|
||||
| 31 | Community 31 | |
|
||||
| 32 | Community 32 | |
|
||||
| 33 | Community 33 | |
|
||||
| 34 | Community 34 | |
|
||||
| 35 | Community 35 | |
|
||||
| 36 | Community 36 | |
|
||||
| 37 | Community 37 | |
|
||||
| 38 | Community 38 | |
|
||||
| 39 | Community 39 | |
|
||||
| 40 | Community 40 | |
|
||||
@@ -0,0 +1,342 @@
|
||||
# Graph Report - /home/scheianu/digiserver-v2 (2026-08-16)
|
||||
|
||||
## Corpus Check
|
||||
- 49 files · ~210,805 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 631 nodes · 1162 edges · 41 communities detected
|
||||
- Extraction: 56% EXTRACTED · 44% INFERRED · 0% AMBIGUOUS · INFERRED: 512 edges (avg confidence: 0.62)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_Community 0|Community 0]]
|
||||
- [[_COMMUNITY_Community 1|Community 1]]
|
||||
- [[_COMMUNITY_Community 2|Community 2]]
|
||||
- [[_COMMUNITY_Community 3|Community 3]]
|
||||
- [[_COMMUNITY_Community 4|Community 4]]
|
||||
- [[_COMMUNITY_Community 5|Community 5]]
|
||||
- [[_COMMUNITY_Community 6|Community 6]]
|
||||
- [[_COMMUNITY_Community 7|Community 7]]
|
||||
- [[_COMMUNITY_Community 8|Community 8]]
|
||||
- [[_COMMUNITY_Community 9|Community 9]]
|
||||
- [[_COMMUNITY_Community 10|Community 10]]
|
||||
- [[_COMMUNITY_Community 11|Community 11]]
|
||||
- [[_COMMUNITY_Community 12|Community 12]]
|
||||
- [[_COMMUNITY_Community 13|Community 13]]
|
||||
- [[_COMMUNITY_Community 14|Community 14]]
|
||||
- [[_COMMUNITY_Community 15|Community 15]]
|
||||
- [[_COMMUNITY_Community 16|Community 16]]
|
||||
- [[_COMMUNITY_Community 17|Community 17]]
|
||||
- [[_COMMUNITY_Community 18|Community 18]]
|
||||
- [[_COMMUNITY_Community 19|Community 19]]
|
||||
- [[_COMMUNITY_Community 20|Community 20]]
|
||||
- [[_COMMUNITY_Community 21|Community 21]]
|
||||
- [[_COMMUNITY_Community 22|Community 22]]
|
||||
- [[_COMMUNITY_Community 23|Community 23]]
|
||||
- [[_COMMUNITY_Community 24|Community 24]]
|
||||
- [[_COMMUNITY_Community 25|Community 25]]
|
||||
- [[_COMMUNITY_Community 26|Community 26]]
|
||||
- [[_COMMUNITY_Community 27|Community 27]]
|
||||
- [[_COMMUNITY_Community 28|Community 28]]
|
||||
- [[_COMMUNITY_Community 29|Community 29]]
|
||||
- [[_COMMUNITY_Community 30|Community 30]]
|
||||
- [[_COMMUNITY_Community 31|Community 31]]
|
||||
- [[_COMMUNITY_Community 32|Community 32]]
|
||||
- [[_COMMUNITY_Community 33|Community 33]]
|
||||
- [[_COMMUNITY_Community 34|Community 34]]
|
||||
- [[_COMMUNITY_Community 35|Community 35]]
|
||||
- [[_COMMUNITY_Community 36|Community 36]]
|
||||
- [[_COMMUNITY_Community 37|Community 37]]
|
||||
- [[_COMMUNITY_Community 38|Community 38]]
|
||||
- [[_COMMUNITY_Community 39|Community 39]]
|
||||
- [[_COMMUNITY_Community 40|Community 40]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `log_action()` - 116 edges
|
||||
2. `PlayerEdit` - 99 edges
|
||||
3. `PlayerUser` - 74 edges
|
||||
4. `Playlist` - 33 edges
|
||||
5. `CaddyConfigGenerator` - 31 edges
|
||||
6. `HTTPSConfig` - 30 edges
|
||||
7. `Content` - 24 edges
|
||||
8. `User` - 19 edges
|
||||
9. `create_app()` - 13 edges
|
||||
10. `Models package for digiserver-v2.` - 13 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `reset_user_password()` --calls--> `log_action()` [INFERRED]
|
||||
/home/scheianu/digiserver-v2/app/blueprints/admin.py → /home/scheianu/digiserver-v2/app/utils/logger.py
|
||||
- `upload_header_logo()` --calls--> `log_action()` [INFERRED]
|
||||
/home/scheianu/digiserver-v2/app/blueprints/admin.py → /home/scheianu/digiserver-v2/app/utils/logger.py
|
||||
- `delete_editing_user()` --calls--> `log_action()` [INFERRED]
|
||||
/home/scheianu/digiserver-v2/app/blueprints/admin.py → /home/scheianu/digiserver-v2/app/utils/logger.py
|
||||
- `delete_playlist()` --calls--> `log_action()` [INFERRED]
|
||||
/home/scheianu/digiserver-v2/app/blueprints/content.py → /home/scheianu/digiserver-v2/app/utils/logger.py
|
||||
- `Main playlist management page.` --uses--> `PlayerEdit` [INFERRED]
|
||||
/home/scheianu/digiserver-v2/app/blueprints/content.py → /home/scheianu/digiserver-v2/app/models/player_edit.py
|
||||
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
|
||||
Cohesion: 0.03
|
||||
Nodes (80): change_password(), login(), logout(), Authentication Blueprint - Login, Logout, Register, register(), add_content_to_group(), add_player_to_group(), delete_group() (+72 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
|
||||
Cohesion: 0.04
|
||||
Nodes (72): Add player_user table for user code mappings., admin_panel(), admin_required(), build_player(), change_theme(), change_user_role(), clear_logs(), create_user() (+64 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
|
||||
Cohesion: 0.04
|
||||
Nodes (61): add_content_to_playlist(), add_weblink(), add_weblink_to_playlist(), assign_player_to_playlist(), bulk_remove_from_playlist(), content_list(), create_fullhd_image(), delete_playlist() (+53 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
|
||||
Cohesion: 0.06
|
||||
Nodes (57): api_not_found(), authenticate_player(), deploy_player(), get_cached_playlist(), get_logs(), get_player_playlist(), get_player_status(), get_playlist_by_quickconnect() (+49 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
|
||||
Cohesion: 0.07
|
||||
Nodes (42): add_muted_column(), Add muted column to playlist_content association table., configure_login_manager(), create_app(), DigiServer v2 - Application Factory Modern Flask application with blueprint arch, Configure Flask-Login, Register error handlers, Register CLI commands (+34 more)
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
|
||||
Cohesion: 0.04
|
||||
Nodes (27): Content, Content model for media files., String representation of Content., Check if content is an image., Check if content is a video., Content model representing media files for display. Attributes:, Check if content is a PDF., Check if content is a web link (URL) rather than an uploaded file. (+19 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
|
||||
Cohesion: 0.05
|
||||
Nodes (45): Add https_config table for HTTPS configuration management., Write Caddyfile to disk. Args: caddyfile_content: C, Reload Caddy configuration without restart. Note: Caddy monitor, Generate a complete Caddyfile. Behaviour: - HTTPS disabled / no, Write Caddyfile to disk. The default path is /etc/caddy/Caddyfile — the, Push the current Caddyfile to Caddy via its admin API (/load). Caddy ap, HTTPSConfig, String representation of HTTPSConfig. (+37 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
|
||||
Cohesion: 0.06
|
||||
Nodes (26): create_group(), delete_media(), Delete a media file and remove it from all playlists., Group, Group model for organizing players and content., Group model for organizing players with shared content. Attributes:, String representation of Group., Add a player to this group. Args: player: Player in (+18 more)
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
|
||||
Cohesion: 0.23
|
||||
Nodes (23): check_database_integrity(), Colors, get_player_auth_code(), get_sample_content(), main(), print_error(), print_info(), print_section() (+15 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
|
||||
Cohesion: 0.11
|
||||
Nodes (20): Get upload progress for a specific upload., upload_content(), upload_progress_status(), clear_upload_progress(), delete_file(), get_file_size(), get_upload_progress(), process_pdf_file() (+12 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
|
||||
Cohesion: 0.12
|
||||
Nodes (18): background_player_deployment(), Background task execution for long-running operations., Run a function in a background thread, with a Flask app context pushed., Deploy player code to host in background. Args: hostname: SSH h, run_background_task(), deploy_player_to_host(), generate_app_config(), generate_player_config() (+10 more)
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
|
||||
Cohesion: 0.15
|
||||
Nodes (18): build_player_action(), Build/refresh the staged player code and/or write its base config., build_player_files(), get_player_server_settings(), get_short_head(), load_build_settings(), make_build_record(), Utilities for building/staging the player files on the server. Admins use the " (+10 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
|
||||
Cohesion: 0.29
|
||||
Nodes (7): cleanup_libreoffice_processes(), pptx_to_pdf_libreoffice(), PowerPoint to PDF converter using LibreOffice., Clean up any hanging LibreOffice processes., Convert PPTX to PDF using LibreOffice for highest quality. This functio, Validate if file is a valid PowerPoint file. Args: filepath: Pa, validate_pptx_file()
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
|
||||
Cohesion: 0.5
|
||||
Nodes (3): migrate(), Migration: Add edit_on_player_enabled column to playlist_content table., Add edit_on_player_enabled column to playlist_content.
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Migrate player_user table to remove player_id and make user_code unique globally
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Add original_filename column to content table. This preserves the pristine uplo
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Add url column to content table for weblink support.
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Add deployment tracking columns to player table.
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Add email field to https_config table.
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Flask extensions initialization Centralized extension management for the applica
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Check if feedback indicates an error.
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get age of feedback in seconds.
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get most recent feedback for a player. Args: player
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get the current HTTPS configuration. Returns: HTTPS
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Create or update HTTPS configuration. Args: https_e
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Check if user has admin role.
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Check if player is online (seen in last 5 minutes).
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Authenticate a player by hostname and password or quickconnect code.
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Create an info level log entry. Args: message: Log
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Create a warning level log entry. Args: message: Lo
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Create an error level log entry. Args: message: Log
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get number of content items in this group.
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get file size in megabytes.
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get number of groups containing this content.
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Name of the original (unedited) file for display purposes.
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Path (relative to uploads/) where the original unedited file lives. Whe
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Path (relative to uploads/) of the current/latest version to display.
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Whether this content has been edited on a player.
|
||||
|
||||
### Community 38 - "Community 38"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Display name of the original (unedited) file.
|
||||
|
||||
### Community 39 - "Community 39"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (1): uploads/-relative path of the pristine original file. After a player ed
|
||||
|
||||
### Community 40 - "Community 40"
|
||||
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
## Knowledge Gaps
|
||||
- **178 isolated node(s):** `Migrate player_user table to remove player_id and make user_code unique globally`, `Add original_filename column to content table. This preserves the pristine uplo`, `Add url column to content table for weblink support.`, `Add deployment tracking columns to player table.`, `Add email field to https_config table.` (+173 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 14`** (2 nodes): `migrate_player_user_global.py`, `Migrate player_user table to remove player_id and make user_code unique globally`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 15`** (2 nodes): `Add original_filename column to content table. This preserves the pristine uplo`, `add_original_filename_to_content.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 16`** (2 nodes): `Add url column to content table for weblink support.`, `add_url_to_content.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 17`** (2 nodes): `Add deployment tracking columns to player table.`, `add_deployment_fields_to_player.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 18`** (2 nodes): `Add email field to https_config table.`, `add_email_to_https_config.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 19`** (2 nodes): `Flask extensions initialization Centralized extension management for the applica`, `extensions.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 20`** (1 nodes): `Check if feedback indicates an error.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 21`** (1 nodes): `Get age of feedback in seconds.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 22`** (1 nodes): `Get most recent feedback for a player. Args: player`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 23`** (1 nodes): `Get the current HTTPS configuration. Returns: HTTPS`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 24`** (1 nodes): `Create or update HTTPS configuration. Args: https_e`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 25`** (1 nodes): `Check if user has admin role.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 26`** (1 nodes): `Check if player is online (seen in last 5 minutes).`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 27`** (1 nodes): `Authenticate a player by hostname and password or quickconnect code.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 28`** (1 nodes): `Create an info level log entry. Args: message: Log`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 29`** (1 nodes): `Create a warning level log entry. Args: message: Lo`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 30`** (1 nodes): `Create an error level log entry. Args: message: Log`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 31`** (1 nodes): `Get number of content items in this group.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 32`** (1 nodes): `Get file size in megabytes.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 33`** (1 nodes): `Get number of groups containing this content.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 34`** (1 nodes): `Name of the original (unedited) file for display purposes.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 35`** (1 nodes): `Path (relative to uploads/) where the original unedited file lives. Whe`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 36`** (1 nodes): `Path (relative to uploads/) of the current/latest version to display.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 37`** (1 nodes): `Whether this content has been edited on a player.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 38`** (1 nodes): `Display name of the original (unedited) file.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 39`** (1 nodes): `uploads/-relative path of the pristine original file. After a player ed`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 40`** (1 nodes): `check_fix_player.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "label": "portal_sso.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "portal_sso_init_portal_sso", "label": "init_portal_sso()", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L17", "complexity": 4, "loc": 16}, {"id": "portal_sso_get_or_create_user", "label": "_get_or_create_user()", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L35", "complexity": 4, "loc": 18}, {"id": "portal_sso_rationale_1", "label": "Portal SSO middleware for DigiServer v2. When the umbrella nginx verifies the p", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L1"}, {"id": "portal_sso_rationale_18", "label": "Register the SSO before_request handler on the given Flask app.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L18"}], "edges": [{"source": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "target": "secrets", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L12", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "target": "flask", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L13", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "target": "flask_login", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L14", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "target": "portal_sso_init_portal_sso", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L17", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "target": "portal_sso_get_or_create_user", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L35", "weight": 1.0}, {"source": "portal_sso_rationale_1", "target": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L1", "weight": 1.0}, {"source": "portal_sso_rationale_18", "target": "portal_sso_init_portal_sso", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L18", "weight": 1.0}], "raw_calls": [{"caller_nid": "portal_sso_get_or_create_user", "callee": "first", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L40"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "filter_by", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L40"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "decode", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L42"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "generate_password_hash", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L42"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "token_hex", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L42"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "User", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L43"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "add", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L48"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "commit", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L49"}]}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "label": "add_https_config_table.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "add_https_config_table_rationale_1", "label": "Add https_config table for HTTPS configuration management.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L1"}], "edges": [{"source": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "target": "sys", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L2", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "target": "app_app", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L5", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "target": "app_extensions", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L6", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "target": "app_models_https_config", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L7", "weight": 1.0}, {"source": "add_https_config_table_rationale_1", "target": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_scheianu_digiserver_v2_app_blueprints_init_py", "label": "__init__.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/blueprints/__init__.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "init_rationale_1", "label": "Blueprints package initialization", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/blueprints/__init__.py", "source_location": "L1"}], "edges": [{"source": "init_rationale_1", "target": "home_scheianu_digiserver_v2_app_blueprints_init_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/blueprints/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_scheianu_digiserver_v2_old_code_documentation_check_fix_player_py", "label": "check_fix_player.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/check_fix_player.py", "source_location": "L1", "complexity": 1, "loc": 1}], "edges": [{"source": "home_scheianu_digiserver_v2_old_code_documentation_check_fix_player_py", "target": "app", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/check_fix_player.py", "source_location": "L4", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_old_code_documentation_check_fix_player_py", "target": "app_models", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/check_fix_player.py", "source_location": "L5", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_old_code_documentation_check_fix_player_py", "target": "app_extensions", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/check_fix_player.py", "source_location": "L6", "weight": 1.0}], "raw_calls": []}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_scheianu_digiserver_v2_app_models_player_edit_py", "label": "player_edit.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "player_edit_playeredit", "label": "PlayerEdit", "file_type": "code", "type": "CLASS", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L8", "complexity": 4, "loc": 53}, {"id": "player_edit_playeredit_repr", "label": ".__repr__()", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L42", "complexity": 1, "loc": 3}, {"id": "player_edit_playeredit_to_dict", "label": ".to_dict()", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L46", "complexity": 4, "loc": 15}, {"id": "player_edit_rationale_1", "label": "Player edit model for tracking media edited on players.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L1"}, {"id": "player_edit_rationale_9", "label": "Player edit model for tracking media files edited on player devices. At", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L9"}, {"id": "player_edit_rationale_43", "label": "String representation of PlayerEdit.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L43"}, {"id": "player_edit_rationale_47", "label": "Convert to dictionary for API responses.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L47"}], "edges": [{"source": "home_scheianu_digiserver_v2_app_models_player_edit_py", "target": "datetime", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L2", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_player_edit_py", "target": "typing", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L3", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_player_edit_py", "target": "app_extensions", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L5", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_player_edit_py", "target": "player_edit_playeredit", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L8", "weight": 1.0}, {"source": "player_edit_playeredit", "target": "player_edit_playeredit_repr", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L42", "weight": 1.0}, {"source": "player_edit_playeredit", "target": "player_edit_playeredit_to_dict", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L46", "weight": 1.0}, {"source": "player_edit_rationale_1", "target": "home_scheianu_digiserver_v2_app_models_player_edit_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L1", "weight": 1.0}, {"source": "player_edit_rationale_9", "target": "player_edit_playeredit", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L9", "weight": 1.0}, {"source": "player_edit_rationale_43", "target": "player_edit_playeredit_repr", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L43", "weight": 1.0}, {"source": "player_edit_rationale_47", "target": "player_edit_playeredit_to_dict", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L47", "weight": 1.0}], "raw_calls": [{"caller_nid": "player_edit_playeredit_to_dict", "callee": "isoformat", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L57"}, {"caller_nid": "player_edit_playeredit_to_dict", "callee": "isoformat", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L58"}]}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "label": "add_deployment_fields_to_player.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "add_deployment_fields_to_player_rationale_1", "label": "Add deployment tracking columns to player table.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L1"}], "edges": [{"source": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "target": "sys", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L2", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "target": "app_app", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L5", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "target": "app_extensions", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L6", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "target": "sqlalchemy", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L7", "weight": 1.0}, {"source": "add_deployment_fields_to_player_rationale_1", "target": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user