Compare commits
10 Commits
1394ef6f67
...
f4b7fb62f5
| Author | SHA1 | Date | |
|---|---|---|---|
| f4b7fb62f5 | |||
| a5fe0f73a0 | |||
| 4df004c18a | |||
| a9a1a6cdbe | |||
| 4d4ab086c9 | |||
| 3684d98456 | |||
| f01de7a8e6 | |||
| 97e17854b9 | |||
| 7f0092ff10 | |||
| ea3d0164f2 |
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
instance/
|
||||||
|
app/static/uploads/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.flaskenv
|
||||||
|
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
|
||||||
|
README.md
|
||||||
14
.flaskenv
14
.flaskenv
@@ -1,9 +1,9 @@
|
|||||||
FLASK_APP=app:create_app
|
FLASK_APP=app:create_app
|
||||||
FLASK_DEBUG=1
|
FLASK_DEBUG=1
|
||||||
SMTP_HOST=smtp.strato.de
|
##SMTP_HOST=smtp.strato.de
|
||||||
SMTP_PORT=465
|
##SMTP_PORT=465
|
||||||
SMTP_USERNAME=beheer@alphen.cloud
|
##SMTP_USERNAME=beheer@alphen.cloud
|
||||||
SMTP_PASSWORD=Fr@nkrijk2024!
|
##SMTP_PASSWORD=***
|
||||||
SMTP_FROM=beheer@alphen.cloud
|
##SMTP_FROM=beheer@alphen.cloud
|
||||||
SMTP_STARTTLS=1
|
##SMTP_STARTTLS=1
|
||||||
SMTP_DEBUG=1
|
##SMTP_DEBUG=1
|
||||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System deps (kept minimal). Pillow may need some libs; for most cases this is fine on slim.
|
||||||
|
# If you hit Pillow build/runtime issues, consider adding: libjpeg62-turbo, zlib1g, etc.
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt \
|
||||||
|
&& pip install --no-cache-dir gunicorn
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Ensure entrypoint is executable
|
||||||
|
RUN chmod +x docker/entrypoint.sh
|
||||||
|
|
||||||
|
# Create runtime dirs (also mountable as volumes)
|
||||||
|
RUN mkdir -p instance app/static/uploads
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Default config (override at runtime)
|
||||||
|
ENV FLASK_ENV=production \
|
||||||
|
GUNICORN_WORKERS=2 \
|
||||||
|
GUNICORN_BIND=0.0.0.0:8000
|
||||||
|
|
||||||
|
# Run via WSGI entrypoint
|
||||||
|
CMD ["sh", "-c", "./docker/entrypoint.sh"]
|
||||||
146
README.md
146
README.md
@@ -33,15 +33,145 @@ flask run --debug
|
|||||||
|
|
||||||
Open http://127.0.0.1:5000
|
Open http://127.0.0.1:5000
|
||||||
|
|
||||||
|
## Production (WSGI)
|
||||||
|
|
||||||
|
This repo includes a `wsgi.py` entrypoint for production WSGI servers.
|
||||||
|
|
||||||
|
### Important (Windows)
|
||||||
|
|
||||||
|
If you try to run Gunicorn directly on Windows you will see an error like:
|
||||||
|
|
||||||
|
```
|
||||||
|
ModuleNotFoundError: No module named 'fcntl'
|
||||||
|
```
|
||||||
|
|
||||||
|
That’s expected: **Gunicorn is Unix-only**. On Windows, run the app via:
|
||||||
|
|
||||||
|
- **Docker** (recommended) so Gunicorn runs inside a Linux container, or
|
||||||
|
- **WSL2/Linux** (Gunicorn works), or
|
||||||
|
- use a Windows-native WSGI server (e.g. Waitress) instead of Gunicorn.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# gunicorn (Linux)
|
||||||
|
gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app
|
||||||
|
|
||||||
|
# uWSGI
|
||||||
|
uwsgi --http :8000 --wsgi-file wsgi.py --callable app
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: unlike `flask run`, WSGI servers typically don't auto-load `.env` / `.flaskenv`.
|
||||||
|
`wsgi.py` attempts to load `.env` (best-effort), but for real production you should set
|
||||||
|
environment variables via your process manager / secrets.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
### Docker Compose (recommended)
|
||||||
|
|
||||||
|
This repo includes a `docker-compose.yml` for a one-command startup.
|
||||||
|
|
||||||
|
On first run, the container will ensure the SQLite schema exists.
|
||||||
|
If you provide `ADMIN_PASS`, it will also create/update the initial admin user.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Create an admin on startup (recommended):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ADMIN_EMAIL="you@example.com"
|
||||||
|
$env:ADMIN_PASS="YourStrongPassword"
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Or put these in a `.env` file used by Compose.
|
||||||
|
|
||||||
|
Run in the background:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Data persistence:
|
||||||
|
|
||||||
|
- SQLite DB is mounted to `./instance` on your host
|
||||||
|
- uploads are mounted to `./app/static/uploads` on your host
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t signage:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run (with persistent SQLite DB + uploads):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8000:8000 \
|
||||||
|
-e SECRET_KEY="change-me" \
|
||||||
|
-v %cd%/instance:/app/instance \
|
||||||
|
-v %cd%/app/static/uploads:/app/app/static/uploads \
|
||||||
|
signage:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell variant (sometimes volume path quoting is easier):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker run --rm -p 8000:8000 `
|
||||||
|
-e SECRET_KEY="change-me" `
|
||||||
|
-v "${PWD}/instance:/app/instance" `
|
||||||
|
-v "${PWD}/app/static/uploads:/app/app/static/uploads" `
|
||||||
|
signage:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open: http://127.0.0.1:8000
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The container starts with Gunicorn using `wsgi:app`.
|
||||||
|
- You can override Gunicorn settings via env vars:
|
||||||
|
- `GUNICORN_WORKERS` (default: 2)
|
||||||
|
- `GUNICORN_BIND` (default: `0.0.0.0:8000`)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- SQLite DB is stored at `instance/signage.sqlite`.
|
- SQLite DB is stored at `instance/signage.sqlite`.
|
||||||
- Uploaded files go to `app/static/uploads/`.
|
- Uploaded files go to `app/static/uploads/`.
|
||||||
|
|
||||||
|
## Display player
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
- `http://<host>/display/<token>` for live playback (counts towards the concurrent display limit)
|
||||||
|
- `http://<host>/display/<token>?preview=1` for preview (does not count towards the concurrent display limit)
|
||||||
|
|
||||||
|
### Live updates
|
||||||
|
|
||||||
|
The player keeps itself up-to-date automatically:
|
||||||
|
|
||||||
|
- It listens to `GET /api/display/<token>/events` (Server-Sent Events) and reloads the playlist immediately when it changes.
|
||||||
|
- It also does a fallback playlist refresh every 5 minutes for networks/proxies that block SSE.
|
||||||
|
|
||||||
## SMTP / Forgot password
|
## SMTP / Forgot password
|
||||||
|
|
||||||
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
|
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
|
||||||
|
|
||||||
|
You can also configure SMTP settings from the UI: **Admin → Settings**.
|
||||||
|
Environment variables still take precedence over the database settings.
|
||||||
|
|
||||||
|
### Public domain for emails
|
||||||
|
|
||||||
|
If your app runs behind a reverse proxy (or the internal hostname differs from the public hostname),
|
||||||
|
set **Admin → Settings → Public domain** to e.g. `signage.example.com` so links in password reset
|
||||||
|
emails point to the correct address.
|
||||||
|
|
||||||
Recommended: put these in a local `.env` file in the repo root. Flask (via `python-dotenv`) will auto-load it on startup. `.env` is already gitignored.
|
Recommended: put these in a local `.env` file in the repo root. Flask (via `python-dotenv`) will auto-load it on startup. `.env` is already gitignored.
|
||||||
|
|
||||||
You can start from `.env.example`:
|
You can start from `.env.example`:
|
||||||
@@ -67,6 +197,11 @@ REM Option B: put the same keys/values in a .env file instead
|
|||||||
|
|
||||||
Security note: do **not** commit SMTP passwords to the repo. Prefer secrets management and rotate leaked credentials.
|
Security note: do **not** commit SMTP passwords to the repo. Prefer secrets management and rotate leaked credentials.
|
||||||
|
|
||||||
|
Note on the "From" address: some SMTP providers enforce that the authenticated mailbox
|
||||||
|
(`SMTP_USERNAME`) is used as the actual sender (envelope-from), even if a different
|
||||||
|
`SMTP_FROM` is provided. In that case the app sets a `Reply-To` header so replies still
|
||||||
|
go to `SMTP_FROM`, but the provider may still show the username address as the sender.
|
||||||
|
|
||||||
### Troubleshooting mail delivery
|
### Troubleshooting mail delivery
|
||||||
|
|
||||||
If the reset email is not received:
|
If the reset email is not received:
|
||||||
@@ -86,5 +221,16 @@ If the reset email is not received:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from flask import Flask, jsonify, request
|
|||||||
from werkzeug.exceptions import RequestEntityTooLarge
|
from werkzeug.exceptions import RequestEntityTooLarge
|
||||||
|
|
||||||
from .extensions import db, login_manager
|
from .extensions import db, login_manager
|
||||||
from .models import User
|
from .models import AppSettings, User
|
||||||
from .cli import init_db_command
|
from .cli import ensure_db_command, init_db_command
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
@@ -56,6 +56,99 @@ def create_app():
|
|||||||
if "description" not in display_cols:
|
if "description" not in display_cols:
|
||||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# Displays: optional transition between slides (none|fade|slide)
|
||||||
|
if "transition" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Companies: optional per-company storage quota
|
||||||
|
company_cols = [
|
||||||
|
r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()
|
||||||
|
]
|
||||||
|
if "storage_max_bytes" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# AppSettings: create settings table if missing.
|
||||||
|
# (PRAGMA returns empty if the table doesn't exist.)
|
||||||
|
settings_cols = [
|
||||||
|
r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()
|
||||||
|
]
|
||||||
|
if not settings_cols:
|
||||||
|
AppSettings.__table__.create(db.engine, checkfirst=True)
|
||||||
|
|
||||||
|
# AppSettings: add public_domain column if missing.
|
||||||
|
if settings_cols and "public_domain" not in settings_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# DisplayPlaylist: create association table for multi-playlist displays.
|
||||||
|
dp_cols = [
|
||||||
|
r[1] for r in db.session.execute(db.text("PRAGMA table_info(display_playlist)")).fetchall()
|
||||||
|
]
|
||||||
|
if not dp_cols:
|
||||||
|
# Create association table for multi-playlist displays.
|
||||||
|
# Keep schema compatible with older DBs that include an autoincrement id and position.
|
||||||
|
db.session.execute(
|
||||||
|
db.text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS display_playlist (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
display_id INTEGER NOT NULL,
|
||||||
|
playlist_id INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
UNIQUE(display_id, playlist_id),
|
||||||
|
FOREIGN KEY(display_id) REFERENCES display (id),
|
||||||
|
FOREIGN KEY(playlist_id) REFERENCES playlist (id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
else:
|
||||||
|
# Best-effort column additions for older/newer variants.
|
||||||
|
if "position" not in dp_cols:
|
||||||
|
db.session.execute(
|
||||||
|
db.text("ALTER TABLE display_playlist ADD COLUMN position INTEGER NOT NULL DEFAULT 1")
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
if "created_at" not in dp_cols:
|
||||||
|
# Use CURRENT_TIMESTAMP as a reasonable default for existing rows.
|
||||||
|
db.session.execute(
|
||||||
|
db.text(
|
||||||
|
"ALTER TABLE display_playlist ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
if "id" not in dp_cols:
|
||||||
|
# Cannot add PRIMARY KEY via ALTER TABLE; keep nullable for compatibility.
|
||||||
|
db.session.execute(db.text("ALTER TABLE display_playlist ADD COLUMN id INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
# Ensure uniqueness index exists (no-op if already present)
|
||||||
|
db.session.execute(
|
||||||
|
db.text(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_display_playlist_display_playlist ON display_playlist (display_id, playlist_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Playlists: schedule + priority flags
|
||||||
|
playlist_cols = [
|
||||||
|
r[1] for r in db.session.execute(db.text("PRAGMA table_info(playlist)")).fetchall()
|
||||||
|
]
|
||||||
|
if "schedule_start" not in playlist_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE playlist ADD COLUMN schedule_start DATETIME"))
|
||||||
|
db.session.commit()
|
||||||
|
if "schedule_end" not in playlist_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE playlist ADD COLUMN schedule_end DATETIME"))
|
||||||
|
db.session.commit()
|
||||||
|
if "is_priority" not in playlist_cols:
|
||||||
|
db.session.execute(
|
||||||
|
db.text("ALTER TABLE playlist ADD COLUMN is_priority BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
@@ -64,6 +157,7 @@ def create_app():
|
|||||||
return db.session.get(User, int(user_id))
|
return db.session.get(User, int(user_id))
|
||||||
|
|
||||||
# CLI
|
# CLI
|
||||||
|
app.cli.add_command(ensure_db_command)
|
||||||
app.cli.add_command(init_db_command)
|
app.cli.add_command(init_db_command)
|
||||||
|
|
||||||
# Blueprints
|
# Blueprints
|
||||||
|
|||||||
27
app/auth_tokens.py
Normal file
27
app/auth_tokens.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Shared auth token helpers.
|
||||||
|
|
||||||
|
We keep password reset/invite token logic in one place so it can be used by:
|
||||||
|
- the normal "forgot password" flow
|
||||||
|
- company "invite user" flow
|
||||||
|
|
||||||
|
Tokens are signed with Flask SECRET_KEY and time-limited.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itsdangerous import URLSafeTimedSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def _serializer(secret_key: str) -> URLSafeTimedSerializer:
|
||||||
|
return URLSafeTimedSerializer(secret_key, salt="password-reset")
|
||||||
|
|
||||||
|
|
||||||
|
def make_password_reset_token(*, secret_key: str, user_id: int) -> str:
|
||||||
|
s = _serializer(secret_key)
|
||||||
|
return s.dumps({"user_id": int(user_id)})
|
||||||
|
|
||||||
|
|
||||||
|
def load_password_reset_user_id(*, secret_key: str, token: str, max_age_seconds: int) -> int:
|
||||||
|
s = _serializer(secret_key)
|
||||||
|
data = s.loads(token, max_age=max_age_seconds)
|
||||||
|
return int(data.get("user_id"))
|
||||||
68
app/cli.py
68
app/cli.py
@@ -2,7 +2,55 @@ import click
|
|||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from .extensions import db
|
from .extensions import db
|
||||||
from .models import User
|
from .models import AppSettings, User
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_schema_and_settings() -> None:
|
||||||
|
"""Create tables + run lightweight SQLite migrations + ensure settings row exists."""
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
# Lightweight migration for older SQLite DBs: ensure columns exist.
|
||||||
|
# This avoids requiring Alembic for this small project.
|
||||||
|
try:
|
||||||
|
cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(user)")).fetchall()]
|
||||||
|
if "email" not in cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE user ADD COLUMN email VARCHAR(255)"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
display_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(display)")).fetchall()]
|
||||||
|
if "description" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
company_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()]
|
||||||
|
if "storage_max_bytes" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
settings_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()]
|
||||||
|
if settings_cols and "public_domain" not in settings_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
# Best-effort; if it fails we continue so fresh DBs still work.
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
# Ensure AppSettings row exists.
|
||||||
|
if not db.session.get(AppSettings, 1):
|
||||||
|
db.session.add(AppSettings(id=1))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("ensure-db")
|
||||||
|
@with_appcontext
|
||||||
|
def ensure_db_command():
|
||||||
|
"""Create tables / apply lightweight migrations.
|
||||||
|
|
||||||
|
This is useful for container startup where you want schema readiness,
|
||||||
|
without requiring admin credentials.
|
||||||
|
"""
|
||||||
|
_ensure_schema_and_settings()
|
||||||
|
click.echo("Database ready.")
|
||||||
|
|
||||||
|
|
||||||
@click.command("init-db")
|
@click.command("init-db")
|
||||||
@@ -17,23 +65,7 @@ from .models import User
|
|||||||
@with_appcontext
|
@with_appcontext
|
||||||
def init_db_command(admin_email: str, admin_pass: str):
|
def init_db_command(admin_email: str, admin_pass: str):
|
||||||
"""Create tables and ensure an admin account exists."""
|
"""Create tables and ensure an admin account exists."""
|
||||||
db.create_all()
|
_ensure_schema_and_settings()
|
||||||
|
|
||||||
# Lightweight migration for older SQLite DBs: ensure User.email column exists.
|
|
||||||
# This avoids requiring Alembic for this small project.
|
|
||||||
try:
|
|
||||||
cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(user)")).fetchall()]
|
|
||||||
if "email" not in cols:
|
|
||||||
db.session.execute(db.text("ALTER TABLE user ADD COLUMN email VARCHAR(255)"))
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
display_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(display)")).fetchall()]
|
|
||||||
if "description" not in display_cols:
|
|
||||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
|
|
||||||
db.session.commit()
|
|
||||||
except Exception:
|
|
||||||
# Best-effort; if it fails we continue so fresh DBs still work.
|
|
||||||
db.session.rollback()
|
|
||||||
|
|
||||||
admin_email = (admin_email or "").strip().lower()
|
admin_email = (admin_email or "").strip().lower()
|
||||||
if not admin_email:
|
if not admin_email:
|
||||||
|
|||||||
@@ -3,10 +3,27 @@ import smtplib
|
|||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
|
||||||
def send_email(*, to_email: str, subject: str, body_text: str):
|
def _truthy(v: str | None) -> bool:
|
||||||
"""Send a plain-text email using SMTP settings from environment variables.
|
if v is None:
|
||||||
|
return False
|
||||||
|
return v.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
Required env vars:
|
|
||||||
|
def send_email(*, to_email: str, subject: str, body_text: str):
|
||||||
|
"""Send a plain-text email using SMTP settings from:
|
||||||
|
|
||||||
|
1) Admin-configured settings stored in the database (AppSettings) (highest priority)
|
||||||
|
2) Environment variables (fallback)
|
||||||
|
|
||||||
|
If you *do* want environment variables to override DB settings (e.g. in production),
|
||||||
|
set SMTP_OVERRIDE_DB=1.
|
||||||
|
|
||||||
|
Required configuration (either from DB or env):
|
||||||
|
- host
|
||||||
|
- username
|
||||||
|
- password
|
||||||
|
|
||||||
|
When using env vars, the names are:
|
||||||
- SMTP_HOST
|
- SMTP_HOST
|
||||||
- SMTP_PORT
|
- SMTP_PORT
|
||||||
- SMTP_USERNAME
|
- SMTP_USERNAME
|
||||||
@@ -19,43 +36,109 @@ def send_email(*, to_email: str, subject: str, body_text: str):
|
|||||||
- SMTP_DEBUG (default: "0") - set to 1 to print SMTP conversation to console
|
- SMTP_DEBUG (default: "0") - set to 1 to print SMTP conversation to console
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Load DB settings (best-effort). If the DB has a complete SMTP config, we will use
|
||||||
|
# it even if environment variables are present.
|
||||||
|
#
|
||||||
|
# This fixes the common deployment situation where a .env/.flaskenv provides SMTP_*
|
||||||
|
# values that unintentionally override admin-configured settings.
|
||||||
|
db_defaults: dict[str, object] = {}
|
||||||
|
try:
|
||||||
|
# Local import to avoid import cycles and to keep this module lightweight.
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from .extensions import db
|
||||||
|
from .models import AppSettings
|
||||||
|
|
||||||
|
# Only try if app context exists (send_email is called inside requests/CLI normally).
|
||||||
|
# Accessing the LocalProxy will raise if there is no active app context.
|
||||||
|
current_app._get_current_object()
|
||||||
|
s = db.session.get(AppSettings, 1)
|
||||||
|
if s:
|
||||||
|
db_defaults = {
|
||||||
|
"host": s.smtp_host,
|
||||||
|
"port": s.smtp_port,
|
||||||
|
"username": s.smtp_username,
|
||||||
|
"password": s.smtp_password,
|
||||||
|
"from_email": s.smtp_from,
|
||||||
|
"starttls": s.smtp_starttls,
|
||||||
|
"timeout": s.smtp_timeout_seconds,
|
||||||
|
"debug": s.smtp_debug,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
# Best-effort; if DB isn't ready we fall back to env vars only.
|
||||||
|
db_defaults = {}
|
||||||
|
|
||||||
|
# DB-first selection rules:
|
||||||
|
# - If *any* SMTP field is configured in DB, treat DB as authoritative (and do NOT
|
||||||
|
# silently mix in env vars). This avoids confusion where partial DB config still
|
||||||
|
# results in env vars being used.
|
||||||
|
# - Env override is only possible with SMTP_OVERRIDE_DB=1.
|
||||||
|
override_db = _truthy(os.environ.get("SMTP_OVERRIDE_DB"))
|
||||||
|
db_config_present = bool(
|
||||||
|
db_defaults.get("host")
|
||||||
|
or db_defaults.get("username")
|
||||||
|
or db_defaults.get("password")
|
||||||
|
or db_defaults.get("from_email")
|
||||||
|
or (db_defaults.get("port") is not None)
|
||||||
|
)
|
||||||
|
|
||||||
|
if db_config_present and not override_db:
|
||||||
|
config_source = "db"
|
||||||
|
host = str(db_defaults.get("host") or "") or None
|
||||||
|
port = int(db_defaults.get("port") or 587)
|
||||||
|
username = str(db_defaults.get("username") or "") or None
|
||||||
|
password = str(db_defaults.get("password") or "") or None
|
||||||
|
from_email = (str(db_defaults.get("from_email")) if db_defaults.get("from_email") else None) or username
|
||||||
|
starttls = bool(db_defaults.get("starttls") if db_defaults.get("starttls") is not None else True)
|
||||||
|
timeout = float(db_defaults.get("timeout") or 10)
|
||||||
|
debug = bool(db_defaults.get("debug") or False)
|
||||||
|
else:
|
||||||
|
config_source = "env"
|
||||||
host = os.environ.get("SMTP_HOST")
|
host = os.environ.get("SMTP_HOST")
|
||||||
port = int(os.environ.get("SMTP_PORT", "587"))
|
port = int(os.environ.get("SMTP_PORT") or 587)
|
||||||
username = os.environ.get("SMTP_USERNAME")
|
username = os.environ.get("SMTP_USERNAME")
|
||||||
password = os.environ.get("SMTP_PASSWORD")
|
password = os.environ.get("SMTP_PASSWORD")
|
||||||
from_email = os.environ.get("SMTP_FROM") or username
|
from_email = os.environ.get("SMTP_FROM") or username
|
||||||
starttls = os.environ.get("SMTP_STARTTLS", "1").lower() in ("1", "true", "yes", "on")
|
starttls = _truthy(os.environ.get("SMTP_STARTTLS")) if os.environ.get("SMTP_STARTTLS") is not None else True
|
||||||
timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS", "10"))
|
timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS") or 10)
|
||||||
debug = os.environ.get("SMTP_DEBUG", "0").lower() in ("1", "true", "yes", "on")
|
debug = _truthy(os.environ.get("SMTP_DEBUG")) if os.environ.get("SMTP_DEBUG") is not None else False
|
||||||
|
|
||||||
missing = []
|
missing = []
|
||||||
if not host:
|
if not host:
|
||||||
missing.append("SMTP_HOST")
|
missing.append("host")
|
||||||
if not username:
|
if not username:
|
||||||
missing.append("SMTP_USERNAME")
|
missing.append("username")
|
||||||
if not password:
|
if not password:
|
||||||
missing.append("SMTP_PASSWORD")
|
missing.append("password")
|
||||||
if not from_email:
|
if not from_email:
|
||||||
missing.append("SMTP_FROM")
|
missing.append("from_email")
|
||||||
if missing:
|
if missing:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Missing SMTP configuration: "
|
f"Missing SMTP configuration ({config_source}): "
|
||||||
+ ", ".join(missing)
|
+ ", ".join(missing)
|
||||||
+ ". Set them as environment variables (or in a local .env file)."
|
+ ". Configure it in Admin → Settings (or set SMTP_* environment variables)."
|
||||||
)
|
)
|
||||||
|
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg["From"] = from_email
|
msg["From"] = from_email
|
||||||
msg["To"] = to_email
|
msg["To"] = to_email
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
|
# Helps when SMTP providers force the authenticated mailbox as envelope sender,
|
||||||
|
# but still allow replies to go to the desired address.
|
||||||
|
if from_email and username and from_email != username:
|
||||||
|
msg["Reply-To"] = from_email
|
||||||
msg.set_content(body_text)
|
msg.set_content(body_text)
|
||||||
|
|
||||||
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
||||||
if debug:
|
if debug:
|
||||||
smtp.set_debuglevel(1)
|
smtp.set_debuglevel(1)
|
||||||
|
print(f"[email_utils] Using SMTP config from: {config_source}")
|
||||||
smtp.ehlo()
|
smtp.ehlo()
|
||||||
if starttls:
|
if starttls:
|
||||||
smtp.starttls()
|
smtp.starttls()
|
||||||
smtp.ehlo()
|
smtp.ehlo()
|
||||||
smtp.login(username, password)
|
smtp.login(username, password)
|
||||||
smtp.send_message(msg)
|
|
||||||
|
# Pass explicit envelope-from to avoid falling back to the authenticated user.
|
||||||
|
# Note: some SMTP providers will still override this for anti-spoofing.
|
||||||
|
smtp.send_message(msg, from_addr=from_email, to_addrs=[to_email])
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class Company(db.Model):
|
|||||||
name = db.Column(db.String(120), unique=True, nullable=False)
|
name = db.Column(db.String(120), unique=True, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
# Optional per-company storage quota for uploaded media (bytes).
|
||||||
|
# If NULL or <=0: unlimited.
|
||||||
|
storage_max_bytes = db.Column(db.BigInteger, nullable=True)
|
||||||
|
|
||||||
users = db.relationship("User", back_populates="company", cascade="all, delete-orphan")
|
users = db.relationship("User", back_populates="company", cascade="all, delete-orphan")
|
||||||
displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan")
|
displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan")
|
||||||
playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan")
|
playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan")
|
||||||
@@ -45,6 +49,18 @@ class Playlist(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False)
|
company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False)
|
||||||
name = db.Column(db.String(120), nullable=False)
|
name = db.Column(db.String(120), nullable=False)
|
||||||
|
|
||||||
|
# Optional schedule window in UTC.
|
||||||
|
# - If both are NULL: playlist is always active.
|
||||||
|
# - If start is set: playlist is active from start onward.
|
||||||
|
# - If end is set: playlist is active until end.
|
||||||
|
schedule_start = db.Column(db.DateTime, nullable=True)
|
||||||
|
schedule_end = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# If true, this playlist's items take precedence over non-priority playlists
|
||||||
|
# when multiple playlists are assigned to a display.
|
||||||
|
is_priority = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
company = db.relationship("Company", back_populates="playlists")
|
company = db.relationship("Company", back_populates="playlists")
|
||||||
@@ -84,11 +100,28 @@ class Display(db.Model):
|
|||||||
name = db.Column(db.String(120), nullable=False)
|
name = db.Column(db.String(120), nullable=False)
|
||||||
# Optional short description (e.g. "entrance", "office")
|
# Optional short description (e.g. "entrance", "office")
|
||||||
description = db.Column(db.String(200), nullable=True)
|
description = db.Column(db.String(200), nullable=True)
|
||||||
|
# Transition animation between slides: none|fade|slide
|
||||||
|
transition = db.Column(db.String(20), nullable=True)
|
||||||
token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex)
|
token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex)
|
||||||
|
|
||||||
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)
|
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)
|
||||||
assigned_playlist = db.relationship("Playlist")
|
assigned_playlist = db.relationship("Playlist")
|
||||||
|
|
||||||
|
# Multi-playlist support (active playlists per display).
|
||||||
|
# If a display has any rows in display_playlist, those are used by the player.
|
||||||
|
# If not, we fall back to assigned_playlist_id for backwards compatibility.
|
||||||
|
display_playlists = db.relationship(
|
||||||
|
"DisplayPlaylist",
|
||||||
|
back_populates="display",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
playlists = db.relationship(
|
||||||
|
"Playlist",
|
||||||
|
secondary="display_playlist",
|
||||||
|
viewonly=True,
|
||||||
|
order_by="Playlist.name.asc()",
|
||||||
|
)
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
company = db.relationship("Company", back_populates="displays")
|
company = db.relationship("Company", back_populates="displays")
|
||||||
@@ -111,3 +144,64 @@ class DisplaySession(db.Model):
|
|||||||
display = db.relationship("Display")
|
display = db.relationship("Display")
|
||||||
|
|
||||||
__table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),)
|
__table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),)
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayPlaylist(db.Model):
|
||||||
|
"""Association table: which playlists are active on a display."""
|
||||||
|
|
||||||
|
# NOTE: Some existing databases include an `id` INTEGER PRIMARY KEY column and a
|
||||||
|
# NOT NULL `position` column on display_playlist. We keep the mapper primary key as
|
||||||
|
# (display_id, playlist_id) for portability, while allowing an optional `id` column
|
||||||
|
# to exist in the underlying table.
|
||||||
|
id = db.Column(db.Integer, nullable=True)
|
||||||
|
|
||||||
|
# Composite mapper PK ensures uniqueness per display.
|
||||||
|
display_id = db.Column(db.Integer, db.ForeignKey("display.id"), primary_key=True)
|
||||||
|
playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), primary_key=True)
|
||||||
|
|
||||||
|
# Ordering of playlists within a display.
|
||||||
|
position = db.Column(db.Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
display = db.relationship("Display", back_populates="display_playlists")
|
||||||
|
playlist = db.relationship("Playlist")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("display_id", "playlist_id", name="uq_display_playlist_display_playlist"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(db.Model):
|
||||||
|
"""Singleton-ish app-wide settings.
|
||||||
|
|
||||||
|
For this small project we avoid Alembic migrations; this table can be created via
|
||||||
|
`flask init-db` (db.create_all) and is also created best-effort on app startup.
|
||||||
|
|
||||||
|
NOTE: SMTP password is stored in plaintext in the database.
|
||||||
|
Prefer environment variables / secrets management in production when possible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
smtp_host = db.Column(db.String(255), nullable=True)
|
||||||
|
smtp_port = db.Column(db.Integer, nullable=True)
|
||||||
|
smtp_username = db.Column(db.String(255), nullable=True)
|
||||||
|
smtp_password = db.Column(db.String(255), nullable=True)
|
||||||
|
smtp_from = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
smtp_starttls = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
smtp_timeout_seconds = db.Column(db.Float, default=10.0, nullable=False)
|
||||||
|
smtp_debug = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# Public domain for generating absolute links in emails.
|
||||||
|
# Example: "signage.example.com" (no scheme)
|
||||||
|
public_domain = 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from flask import Blueprint, abort, current_app, flash, redirect, render_templat
|
|||||||
from flask_login import current_user, login_required, login_user
|
from flask_login import current_user, login_required, login_user
|
||||||
|
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User
|
from ..uploads import abs_upload_path, ensure_company_upload_dir, get_company_upload_bytes, is_valid_upload_relpath
|
||||||
|
from ..models import AppSettings, Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem, User
|
||||||
|
from ..email_utils import send_email
|
||||||
|
|
||||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
@@ -16,15 +18,28 @@ def admin_required():
|
|||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_app_settings() -> AppSettings:
|
||||||
|
"""Get the singleton-ish AppSettings row, creating it if needed."""
|
||||||
|
|
||||||
|
s = db.session.get(AppSettings, 1)
|
||||||
|
if s:
|
||||||
|
return s
|
||||||
|
s = AppSettings(id=1)
|
||||||
|
db.session.add(s)
|
||||||
|
db.session.commit()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
def _try_delete_upload(file_path: str | None, upload_folder: str):
|
def _try_delete_upload(file_path: str | None, upload_folder: str):
|
||||||
"""Best-effort delete of an uploaded media file."""
|
"""Best-effort delete of an uploaded media file."""
|
||||||
if not file_path:
|
if not file_path:
|
||||||
return
|
return
|
||||||
if not file_path.startswith("uploads/"):
|
if not is_valid_upload_relpath(file_path):
|
||||||
return
|
return
|
||||||
|
|
||||||
filename = file_path.split("/", 1)[1]
|
abs_path = abs_upload_path(upload_folder, file_path)
|
||||||
abs_path = os.path.join(upload_folder, filename)
|
if not abs_path:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
if os.path.isfile(abs_path):
|
if os.path.isfile(abs_path):
|
||||||
os.remove(abs_path)
|
os.remove(abs_path)
|
||||||
@@ -41,6 +56,188 @@ def dashboard():
|
|||||||
return render_template("admin/dashboard.html", companies=companies)
|
return render_template("admin/dashboard.html", companies=companies)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/settings")
|
||||||
|
@login_required
|
||||||
|
def settings():
|
||||||
|
admin_required()
|
||||||
|
settings = _get_app_settings()
|
||||||
|
admins = User.query.filter_by(is_admin=True).order_by(User.email.asc()).all()
|
||||||
|
return render_template("admin/settings.html", settings=settings, admins=admins)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/settings/smtp")
|
||||||
|
@login_required
|
||||||
|
def update_smtp_settings():
|
||||||
|
admin_required()
|
||||||
|
s = _get_app_settings()
|
||||||
|
|
||||||
|
smtp_host = (request.form.get("smtp_host") or "").strip() or None
|
||||||
|
smtp_port_raw = (request.form.get("smtp_port") or "").strip()
|
||||||
|
smtp_username = (request.form.get("smtp_username") or "").strip() or None
|
||||||
|
smtp_password = request.form.get("smtp_password")
|
||||||
|
smtp_from = (request.form.get("smtp_from") or "").strip() or None
|
||||||
|
smtp_starttls = (request.form.get("smtp_starttls") or "").lower() in ("1", "true", "yes", "on")
|
||||||
|
smtp_debug = (request.form.get("smtp_debug") or "").lower() in ("1", "true", "yes", "on")
|
||||||
|
smtp_timeout_raw = (request.form.get("smtp_timeout_seconds") or "").strip()
|
||||||
|
|
||||||
|
smtp_port: int | None = None
|
||||||
|
if smtp_port_raw:
|
||||||
|
try:
|
||||||
|
smtp_port = int(smtp_port_raw)
|
||||||
|
except ValueError:
|
||||||
|
flash("SMTP port must be a number", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
smtp_timeout: float = 10.0
|
||||||
|
if smtp_timeout_raw:
|
||||||
|
try:
|
||||||
|
smtp_timeout = float(smtp_timeout_raw)
|
||||||
|
except ValueError:
|
||||||
|
flash("SMTP timeout must be a number (seconds)", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
if smtp_port is not None and (smtp_port <= 0 or smtp_port > 65535):
|
||||||
|
flash("SMTP port must be between 1 and 65535", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
if smtp_timeout <= 0:
|
||||||
|
flash("SMTP timeout must be > 0", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
s.smtp_host = smtp_host
|
||||||
|
s.smtp_port = smtp_port
|
||||||
|
s.smtp_username = smtp_username
|
||||||
|
|
||||||
|
# Only overwrite password if a value was submitted.
|
||||||
|
# This allows editing other SMTP fields without having to re-enter the password.
|
||||||
|
if smtp_password is not None and smtp_password.strip() != "":
|
||||||
|
s.smtp_password = smtp_password
|
||||||
|
|
||||||
|
s.smtp_from = smtp_from
|
||||||
|
s.smtp_starttls = smtp_starttls
|
||||||
|
s.smtp_timeout_seconds = smtp_timeout
|
||||||
|
s.smtp_debug = smtp_debug
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("SMTP settings saved.", "success")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/settings/domain")
|
||||||
|
@login_required
|
||||||
|
def update_public_domain():
|
||||||
|
admin_required()
|
||||||
|
s = _get_app_settings()
|
||||||
|
raw = (request.form.get("public_domain") or "").strip().lower()
|
||||||
|
if raw:
|
||||||
|
# Normalize: user asked for domain-only (no scheme). Strip possible scheme anyway.
|
||||||
|
raw = raw.replace("http://", "").replace("https://", "")
|
||||||
|
raw = raw.strip().strip("/")
|
||||||
|
if "/" in raw:
|
||||||
|
flash("Public domain must not contain a path. Example: signage.example.com", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
if " " in raw:
|
||||||
|
flash("Public domain must not contain spaces", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
s.public_domain = raw
|
||||||
|
else:
|
||||||
|
s.public_domain = None
|
||||||
|
db.session.commit()
|
||||||
|
flash("Public domain saved.", "success")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/settings/test-email")
|
||||||
|
@login_required
|
||||||
|
def send_test_email():
|
||||||
|
admin_required()
|
||||||
|
to_email = (request.form.get("to_email") or "").strip().lower()
|
||||||
|
if not to_email:
|
||||||
|
flash("Test email recipient is required", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_email(
|
||||||
|
to_email=to_email,
|
||||||
|
subject="Signage SMTP test",
|
||||||
|
body_text="This is a test email from Signage (Admin → Settings).",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Show a short error to the admin (they requested a test).
|
||||||
|
flash(f"Failed to send test email: {e}", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
flash(f"Test email sent to {to_email}", "success")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/settings/admins")
|
||||||
|
@login_required
|
||||||
|
def create_admin_user():
|
||||||
|
admin_required()
|
||||||
|
email = (request.form.get("email") or "").strip().lower()
|
||||||
|
password = request.form.get("password") or ""
|
||||||
|
|
||||||
|
if not email or not password:
|
||||||
|
flash("Email and password are required", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
if len(password) < 8:
|
||||||
|
flash("Password must be at least 8 characters", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
existing = User.query.filter_by(email=email).first()
|
||||||
|
if existing:
|
||||||
|
if existing.is_admin:
|
||||||
|
flash("That user is already an admin", "warning")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
# Promote existing user to admin
|
||||||
|
existing.is_admin = True
|
||||||
|
existing.set_password(password)
|
||||||
|
existing.email = email
|
||||||
|
existing.username = email
|
||||||
|
existing.company_id = None
|
||||||
|
db.session.commit()
|
||||||
|
flash("User promoted to admin.", "success")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
u = User(is_admin=True)
|
||||||
|
u.email = email
|
||||||
|
u.username = email
|
||||||
|
u.set_password(password)
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Admin user created.", "success")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/settings/admins/<int:user_id>/demote")
|
||||||
|
@login_required
|
||||||
|
def demote_admin_user(user_id: int):
|
||||||
|
admin_required()
|
||||||
|
if current_user.id == user_id:
|
||||||
|
flash("You cannot demote yourself", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
u = db.session.get(User, user_id)
|
||||||
|
if not u:
|
||||||
|
abort(404)
|
||||||
|
if not u.is_admin:
|
||||||
|
flash("That user is not an admin", "warning")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
# Ensure we always keep at least one admin.
|
||||||
|
admin_count = User.query.filter_by(is_admin=True).count()
|
||||||
|
if admin_count <= 1:
|
||||||
|
flash("Cannot demote the last remaining admin", "danger")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
u.is_admin = False
|
||||||
|
db.session.commit()
|
||||||
|
flash("Admin user demoted.", "success")
|
||||||
|
return redirect(url_for("admin.settings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/companies")
|
@bp.post("/companies")
|
||||||
@login_required
|
@login_required
|
||||||
def create_company():
|
def create_company():
|
||||||
@@ -55,6 +252,13 @@ def create_company():
|
|||||||
c = Company(name=name)
|
c = Company(name=name)
|
||||||
db.session.add(c)
|
db.session.add(c)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# Create the per-company upload directory eagerly (best-effort).
|
||||||
|
try:
|
||||||
|
ensure_company_upload_dir(current_app.config["UPLOAD_FOLDER"], c.id)
|
||||||
|
except Exception:
|
||||||
|
# Upload directory is also created lazily on first upload.
|
||||||
|
pass
|
||||||
flash("Company created", "success")
|
flash("Company created", "success")
|
||||||
return redirect(url_for("admin.company_detail", company_id=c.id))
|
return redirect(url_for("admin.company_detail", company_id=c.id))
|
||||||
|
|
||||||
@@ -66,7 +270,52 @@ def company_detail(company_id: int):
|
|||||||
company = db.session.get(Company, company_id)
|
company = db.session.get(Company, company_id)
|
||||||
if not company:
|
if not company:
|
||||||
abort(404)
|
abort(404)
|
||||||
return render_template("admin/company_detail.html", company=company)
|
|
||||||
|
upload_root = current_app.config["UPLOAD_FOLDER"]
|
||||||
|
used_bytes = get_company_upload_bytes(upload_root, company.id)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/company_detail.html",
|
||||||
|
company=company,
|
||||||
|
storage={
|
||||||
|
"used_bytes": used_bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/companies/<int:company_id>/storage")
|
||||||
|
@login_required
|
||||||
|
def update_company_storage(company_id: int):
|
||||||
|
admin_required()
|
||||||
|
|
||||||
|
company = db.session.get(Company, company_id)
|
||||||
|
if not company:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
raw = (request.form.get("storage_max_mb") or "").strip()
|
||||||
|
if raw == "":
|
||||||
|
# Treat empty as unlimited
|
||||||
|
company.storage_max_bytes = None
|
||||||
|
db.session.commit()
|
||||||
|
flash("Storage limit cleared (unlimited).", "success")
|
||||||
|
return redirect(url_for("admin.company_detail", company_id=company_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
mb = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
flash("Invalid storage limit. Please enter a number (MB).", "danger")
|
||||||
|
return redirect(url_for("admin.company_detail", company_id=company_id))
|
||||||
|
|
||||||
|
if mb <= 0:
|
||||||
|
company.storage_max_bytes = None
|
||||||
|
db.session.commit()
|
||||||
|
flash("Storage limit cleared (unlimited).", "success")
|
||||||
|
return redirect(url_for("admin.company_detail", company_id=company_id))
|
||||||
|
|
||||||
|
company.storage_max_bytes = int(mb * 1024 * 1024)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Storage limit updated.", "success")
|
||||||
|
return redirect(url_for("admin.company_detail", company_id=company_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/companies/<int:company_id>/users")
|
@bp.post("/companies/<int:company_id>/users")
|
||||||
@@ -126,8 +375,12 @@ def delete_company(company_id: int):
|
|||||||
for d in list(company.displays):
|
for d in list(company.displays):
|
||||||
d.assigned_playlist_id = None
|
d.assigned_playlist_id = None
|
||||||
|
|
||||||
# 2) Delete display sessions referencing displays of this company
|
# 1b) Clear multi-playlist mappings
|
||||||
display_ids = [d.id for d in company.displays]
|
display_ids = [d.id for d in company.displays]
|
||||||
|
if display_ids:
|
||||||
|
DisplayPlaylist.query.filter(DisplayPlaylist.display_id.in_(display_ids)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# 2) Delete display sessions referencing displays of this company
|
||||||
if display_ids:
|
if display_ids:
|
||||||
DisplaySession.query.filter(DisplaySession.display_id.in_(display_ids)).delete(synchronize_session=False)
|
DisplaySession.query.filter(DisplaySession.display_id.in_(display_ids)).delete(synchronize_session=False)
|
||||||
|
|
||||||
@@ -193,6 +446,43 @@ def update_user_email(user_id: int):
|
|||||||
return redirect(url_for("admin.company_detail", company_id=u.company_id))
|
return redirect(url_for("admin.company_detail", company_id=u.company_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/users/<int:user_id>/delete")
|
||||||
|
@login_required
|
||||||
|
def delete_user(user_id: int):
|
||||||
|
"""Admin: delete a non-admin user."""
|
||||||
|
|
||||||
|
admin_required()
|
||||||
|
|
||||||
|
u = db.session.get(User, user_id)
|
||||||
|
if not u:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Safety checks
|
||||||
|
if u.is_admin:
|
||||||
|
flash("Cannot delete an admin user", "danger")
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
if u.id == current_user.id:
|
||||||
|
flash("You cannot delete yourself", "danger")
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
company_id = u.company_id
|
||||||
|
company_name = u.company.name if u.company else None
|
||||||
|
email = u.email
|
||||||
|
|
||||||
|
db.session.delete(u)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(
|
||||||
|
f"User '{email}' deleted" + (f" from '{company_name}'." if company_name else "."),
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
|
||||||
|
if company_id:
|
||||||
|
return redirect(url_for("admin.company_detail", company_id=company_id))
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/displays/<int:display_id>/name")
|
@bp.post("/displays/<int:display_id>/name")
|
||||||
@login_required
|
@login_required
|
||||||
def update_display_name(display_id: int):
|
def update_display_name(display_id: int):
|
||||||
@@ -212,3 +502,35 @@ def update_display_name(display_id: int):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Display name updated", "success")
|
flash("Display name updated", "success")
|
||||||
return redirect(url_for("admin.company_detail", company_id=display.company_id))
|
return redirect(url_for("admin.company_detail", company_id=display.company_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/displays/<int:display_id>/delete")
|
||||||
|
@login_required
|
||||||
|
def delete_display(display_id: int):
|
||||||
|
"""Admin: delete a display."""
|
||||||
|
|
||||||
|
admin_required()
|
||||||
|
|
||||||
|
display = db.session.get(Display, display_id)
|
||||||
|
if not display:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
company_id = display.company_id
|
||||||
|
display_name = display.name
|
||||||
|
|
||||||
|
# If FK constraints are enabled, delete in a safe order.
|
||||||
|
# 1) Unassign playlist
|
||||||
|
display.assigned_playlist_id = None
|
||||||
|
|
||||||
|
# 2) Clear multi-playlist mappings
|
||||||
|
DisplayPlaylist.query.filter_by(display_id=display.id).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# 3) Delete active sessions for this display
|
||||||
|
DisplaySession.query.filter_by(display_id=display.id).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# 4) Delete display
|
||||||
|
db.session.delete(display)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f"Display '{display_name}' deleted.", "success")
|
||||||
|
return redirect(url_for("admin.company_detail", company_id=company_id))
|
||||||
|
|||||||
@@ -1,39 +1,60 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
from flask import Blueprint, abort, jsonify, request, url_for
|
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
|
||||||
|
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..models import Display, DisplaySession
|
from ..models import Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem
|
||||||
|
|
||||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2
|
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
|
||||||
SESSION_TTL_SECONDS = 90
|
SESSION_TTL_SECONDS = 90
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/display/<token>/playlist")
|
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
|
||||||
def display_playlist(token: str):
|
"""Return True if playlist is active based on its optional schedule window."""
|
||||||
display = Display.query.filter_by(token=token).first()
|
|
||||||
if not display:
|
if p.schedule_start and now_utc < p.schedule_start:
|
||||||
abort(404)
|
return False
|
||||||
|
if p.schedule_end and now_utc > p.schedule_end:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_and_touch_display_session(display: Display, sid: str | None):
|
||||||
|
"""Enforce concurrent display viewer limit and touch last_seen.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(ok, response)
|
||||||
|
- ok=True: caller may proceed
|
||||||
|
- ok=False: response is a Flask response tuple to return
|
||||||
|
"""
|
||||||
|
|
||||||
|
sid = (sid or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return True, None
|
||||||
|
|
||||||
# Enforce: a display URL/token can be opened by max 2 concurrently active sessions.
|
|
||||||
# Player sends a stable `sid` via querystring.
|
|
||||||
sid = (request.args.get("sid") or "").strip()
|
|
||||||
if sid:
|
|
||||||
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS)
|
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS)
|
||||||
|
# Cleanup old sessions. Avoid committing if nothing was deleted (saves write locks on SQLite).
|
||||||
|
deleted = (
|
||||||
DisplaySession.query.filter(
|
DisplaySession.query.filter(
|
||||||
DisplaySession.display_id == display.id,
|
DisplaySession.display_id == display.id,
|
||||||
DisplaySession.last_seen_at < cutoff,
|
DisplaySession.last_seen_at < cutoff,
|
||||||
).delete(synchronize_session=False)
|
).delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
if deleted:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first()
|
existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first()
|
||||||
if existing:
|
if existing:
|
||||||
existing.last_seen_at = datetime.utcnow()
|
existing.last_seen_at = datetime.utcnow()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
else:
|
return True, None
|
||||||
|
|
||||||
active_count = (
|
active_count = (
|
||||||
DisplaySession.query.filter(
|
DisplaySession.query.filter(
|
||||||
DisplaySession.display_id == display.id,
|
DisplaySession.display_id == display.id,
|
||||||
@@ -42,6 +63,8 @@ def display_playlist(token: str):
|
|||||||
)
|
)
|
||||||
if active_count >= MAX_ACTIVE_SESSIONS_PER_DISPLAY:
|
if active_count >= MAX_ACTIVE_SESSIONS_PER_DISPLAY:
|
||||||
return (
|
return (
|
||||||
|
False,
|
||||||
|
(
|
||||||
jsonify(
|
jsonify(
|
||||||
{
|
{
|
||||||
"error": "display_limit_reached",
|
"error": "display_limit_reached",
|
||||||
@@ -49,6 +72,7 @@ def display_playlist(token: str):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
429,
|
429,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
s = DisplaySession(
|
s = DisplaySession(
|
||||||
@@ -60,15 +84,171 @@ def display_playlist(token: str):
|
|||||||
)
|
)
|
||||||
db.session.add(s)
|
db.session.add(s)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
return True, None
|
||||||
|
|
||||||
playlist = display.assigned_playlist
|
|
||||||
if not playlist:
|
def _playlist_signature(display: Display) -> tuple[int | None, str]:
|
||||||
return jsonify({"display": display.name, "playlist": None, "items": []})
|
"""Compute a stable hash for what the player should be showing.
|
||||||
|
|
||||||
|
We include enough information so that changing the assigned playlist, reordering,
|
||||||
|
duration changes, and item adds/deletes trigger an update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Determine active playlists. If display_playlist has any rows, use those.
|
||||||
|
# Otherwise fall back to the legacy assigned_playlist_id.
|
||||||
|
mapped_ids = [
|
||||||
|
r[0]
|
||||||
|
for r in db.session.query(DisplayPlaylist.playlist_id)
|
||||||
|
.filter(DisplayPlaylist.display_id == display.id)
|
||||||
|
.order_by(DisplayPlaylist.position.asc(), DisplayPlaylist.playlist_id.asc())
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
use_mapping = bool(mapped_ids)
|
||||||
|
active_ids = mapped_ids
|
||||||
|
if not active_ids and display.assigned_playlist_id:
|
||||||
|
active_ids = [display.assigned_playlist_id]
|
||||||
|
use_mapping = False
|
||||||
|
|
||||||
|
if not active_ids:
|
||||||
|
raw = "no-playlist"
|
||||||
|
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
# Apply scheduling + priority rule so a schedule change triggers a player refresh.
|
||||||
|
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
scheduled = [p for p in playlists if _is_playlist_active_now(p, now_utc)]
|
||||||
|
if any(p.is_priority for p in scheduled):
|
||||||
|
scheduled = [p for p in scheduled if p.is_priority]
|
||||||
|
active_ids = [x for x in active_ids if any(p.id == x for p in scheduled)]
|
||||||
|
|
||||||
|
if not active_ids:
|
||||||
|
raw = "no-active-playlist"
|
||||||
|
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
# Pull items in a stable order so reordering affects signature.
|
||||||
|
if use_mapping:
|
||||||
|
items = (
|
||||||
|
PlaylistItem.query.join(DisplayPlaylist, DisplayPlaylist.playlist_id == PlaylistItem.playlist_id)
|
||||||
|
.filter(
|
||||||
|
DisplayPlaylist.display_id == display.id,
|
||||||
|
PlaylistItem.playlist_id.in_(active_ids),
|
||||||
|
)
|
||||||
|
.order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
items = (
|
||||||
|
PlaylistItem.query.filter(PlaylistItem.playlist_id == active_ids[0])
|
||||||
|
.order_by(PlaylistItem.position.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"playlist_ids": list(active_ids),
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": it.id,
|
||||||
|
"playlist_id": it.playlist_id,
|
||||||
|
"pos": it.position,
|
||||||
|
"type": it.item_type,
|
||||||
|
"title": it.title,
|
||||||
|
"duration": it.duration_seconds,
|
||||||
|
"file_path": it.file_path,
|
||||||
|
"url": it.url,
|
||||||
|
}
|
||||||
|
for it in items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||||
|
# signature returns a single playlist_id previously; now return None when multiple.
|
||||||
|
# callers only use it for changed-detection.
|
||||||
|
if len(set(active_ids)) == 1:
|
||||||
|
return active_ids[0], hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/display/<token>/playlist")
|
||||||
|
def display_playlist(token: str):
|
||||||
|
display = Display.query.filter_by(token=token).first()
|
||||||
|
if not display:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Enforce: a display URL/token can be opened by max 3 concurrently active sessions.
|
||||||
|
# Player sends a stable `sid` via querystring.
|
||||||
|
sid = request.args.get("sid")
|
||||||
|
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||||
|
if not ok:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# Determine active playlists. If display_playlist has any rows, use those.
|
||||||
|
# Otherwise fall back to the legacy assigned_playlist_id.
|
||||||
|
mapped_ids = [
|
||||||
|
r[0]
|
||||||
|
for r in db.session.query(DisplayPlaylist.playlist_id)
|
||||||
|
.filter(DisplayPlaylist.display_id == display.id)
|
||||||
|
.order_by(DisplayPlaylist.position.asc(), DisplayPlaylist.playlist_id.asc())
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
use_mapping = bool(mapped_ids)
|
||||||
|
active_ids = mapped_ids
|
||||||
|
if not active_ids and display.assigned_playlist_id:
|
||||||
|
active_ids = [display.assigned_playlist_id]
|
||||||
|
use_mapping = False
|
||||||
|
|
||||||
|
if not active_ids:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"display": display.name,
|
||||||
|
"transition": display.transition or "none",
|
||||||
|
"playlists": [],
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
|
||||||
|
|
||||||
|
# Filter playlists by schedule
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
scheduled = [p for p in playlists if _is_playlist_active_now(p, now_utc)]
|
||||||
|
|
||||||
|
# Priority rule:
|
||||||
|
# If any active (scheduled) playlist is marked priority, only play priority playlists.
|
||||||
|
any_priority = any(p.is_priority for p in scheduled)
|
||||||
|
if any_priority:
|
||||||
|
scheduled = [p for p in scheduled if p.is_priority]
|
||||||
|
|
||||||
|
pl_by_id = {p.id: p for p in scheduled}
|
||||||
|
scheduled_ids = [x for x in active_ids if x in pl_by_id]
|
||||||
|
ordered_playlists = [pl_by_id[x] for x in scheduled_ids]
|
||||||
|
|
||||||
|
# Merge items across active playlists.
|
||||||
|
if use_mapping:
|
||||||
|
merged = (
|
||||||
|
PlaylistItem.query.join(DisplayPlaylist, DisplayPlaylist.playlist_id == PlaylistItem.playlist_id)
|
||||||
|
.filter(
|
||||||
|
DisplayPlaylist.display_id == display.id,
|
||||||
|
PlaylistItem.playlist_id.in_(scheduled_ids),
|
||||||
|
)
|
||||||
|
.order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# single-playlist fallback; apply schedule filter too.
|
||||||
|
if scheduled_ids:
|
||||||
|
merged = (
|
||||||
|
PlaylistItem.query.filter(PlaylistItem.playlist_id == scheduled_ids[0])
|
||||||
|
.order_by(PlaylistItem.position.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
merged = []
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for item in playlist.items:
|
for item in merged:
|
||||||
payload = {
|
payload = {
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
|
"playlist_id": item.playlist_id,
|
||||||
|
"playlist_name": (pl_by_id.get(item.playlist_id).name if pl_by_id.get(item.playlist_id) else None),
|
||||||
"type": item.item_type,
|
"type": item.item_type,
|
||||||
"title": item.title,
|
"title": item.title,
|
||||||
"duration": item.duration_seconds,
|
"duration": item.duration_seconds,
|
||||||
@@ -82,7 +262,84 @@ def display_playlist(token: str):
|
|||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"display": display.name,
|
"display": display.name,
|
||||||
"playlist": {"id": playlist.id, "name": playlist.name},
|
"transition": display.transition or "none",
|
||||||
|
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/display/<token>/events")
|
||||||
|
def display_events(token: str):
|
||||||
|
"""Server-Sent Events stream to notify the player when its playlist changes."""
|
||||||
|
|
||||||
|
display = Display.query.filter_by(token=token).first()
|
||||||
|
if not display:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
sid = request.args.get("sid")
|
||||||
|
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||||
|
if not ok:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
display_id = display.id
|
||||||
|
sid = (sid or "").strip() or None
|
||||||
|
|
||||||
|
@stream_with_context
|
||||||
|
def _gen():
|
||||||
|
last_hash = None
|
||||||
|
last_touch = 0.0
|
||||||
|
keepalive_counter = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Refresh from DB each loop so changes become visible.
|
||||||
|
d = Display.query.filter_by(id=display_id).first()
|
||||||
|
if not d:
|
||||||
|
yield "event: closed\ndata: {}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
playlist_id, h = _playlist_signature(d)
|
||||||
|
if h != last_hash:
|
||||||
|
last_hash = h
|
||||||
|
payload = json.dumps({"playlist_id": playlist_id, "hash": h})
|
||||||
|
yield f"event: changed\ndata: {payload}\n\n"
|
||||||
|
|
||||||
|
# Touch session periodically so SSE-only viewers don't time out.
|
||||||
|
now = time.time()
|
||||||
|
if sid and (now - last_touch) >= 30:
|
||||||
|
last_touch = now
|
||||||
|
existing = DisplaySession.query.filter_by(display_id=display_id, sid=sid).first()
|
||||||
|
if existing:
|
||||||
|
existing.last_seen_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Keep-alive comment (prevents some proxies from closing idle streams).
|
||||||
|
keepalive_counter += 1
|
||||||
|
if keepalive_counter >= 10: # ~20s with the sleep below
|
||||||
|
keepalive_counter = 0
|
||||||
|
yield ": keep-alive\n\n"
|
||||||
|
|
||||||
|
# Release DB connections between iterations.
|
||||||
|
db.session.remove()
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
except GeneratorExit:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
# Avoid tight error loops.
|
||||||
|
try:
|
||||||
|
db.session.remove()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
_gen(),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,30 +7,27 @@ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|||||||
|
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..email_utils import send_email
|
from ..email_utils import send_email
|
||||||
from ..models import User
|
from ..models import AppSettings, User
|
||||||
|
from ..auth_tokens import load_password_reset_user_id, make_password_reset_token
|
||||||
|
|
||||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _reset_serializer_v2() -> URLSafeTimedSerializer:
|
def _make_reset_token(user: User) -> str:
|
||||||
# Use Flask SECRET_KEY; fallback to app config via current_app.
|
|
||||||
# (defined as separate function to keep import cycle minimal)
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="password-reset")
|
return make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=user.id)
|
||||||
|
|
||||||
|
|
||||||
def _make_reset_token(user: User) -> str:
|
|
||||||
s = _reset_serializer_v2()
|
|
||||||
return s.dumps({"user_id": user.id})
|
|
||||||
|
|
||||||
|
|
||||||
def _load_reset_token(token: str, *, max_age_seconds: int) -> int:
|
def _load_reset_token(token: str, *, max_age_seconds: int) -> int:
|
||||||
s = _reset_serializer_v2()
|
from flask import current_app
|
||||||
data = s.loads(token, max_age=max_age_seconds)
|
|
||||||
user_id = int(data.get("user_id"))
|
return load_password_reset_user_id(
|
||||||
return user_id
|
secret_key=current_app.config["SECRET_KEY"],
|
||||||
|
token=token,
|
||||||
|
max_age_seconds=max_age_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/forgot-password")
|
@bp.get("/forgot-password")
|
||||||
@@ -51,6 +48,17 @@ def forgot_password_post():
|
|||||||
user = User.query.filter_by(email=email).first()
|
user = User.query.filter_by(email=email).first()
|
||||||
if user:
|
if user:
|
||||||
token = _make_reset_token(user)
|
token = _make_reset_token(user)
|
||||||
|
|
||||||
|
# By default Flask uses the request host when building _external URLs.
|
||||||
|
# For deployments behind proxies or where the public host differs, allow
|
||||||
|
# admins to configure a public domain used in email links.
|
||||||
|
settings = db.session.get(AppSettings, 1)
|
||||||
|
if settings and settings.public_domain:
|
||||||
|
# Flask's url_for doesn't support overriding the host per-call.
|
||||||
|
# We generate the relative path and prefix it with the configured domain.
|
||||||
|
path = url_for("auth.reset_password", token=token, _external=False)
|
||||||
|
reset_url = f"https://{settings.public_domain}{path}"
|
||||||
|
else:
|
||||||
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
||||||
body = (
|
body = (
|
||||||
"Someone requested a password reset for your account.\n\n"
|
"Someone requested a password reset for your account.\n\n"
|
||||||
|
|||||||
@@ -2,14 +2,25 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, url_for
|
from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, url_for
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..models import Display, Playlist, PlaylistItem
|
from ..uploads import (
|
||||||
|
abs_upload_path,
|
||||||
|
compute_storage_usage,
|
||||||
|
ensure_company_upload_dir,
|
||||||
|
get_company_upload_bytes,
|
||||||
|
is_valid_upload_relpath,
|
||||||
|
)
|
||||||
|
from ..models import AppSettings, Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem, User
|
||||||
|
from ..email_utils import send_email
|
||||||
|
from ..auth_tokens import make_password_reset_token
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
|
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
|
||||||
@@ -75,35 +86,88 @@ def _normalize_youtube_embed_url(raw: str) -> str | None:
|
|||||||
return f"https://www.youtube-nocookie.com/embed/{video_id}"
|
return f"https://www.youtube-nocookie.com/embed/{video_id}"
|
||||||
|
|
||||||
|
|
||||||
def _save_compressed_image(uploaded_file, upload_folder: str) -> str:
|
def _center_crop_to_aspect(img: Image.Image, aspect_w: int, aspect_h: int) -> Image.Image:
|
||||||
|
"""Return a center-cropped copy of img to the desired aspect ratio."""
|
||||||
|
|
||||||
|
w, h = img.size
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return img
|
||||||
|
|
||||||
|
target = aspect_w / aspect_h
|
||||||
|
current = w / h
|
||||||
|
|
||||||
|
# If image is wider than target: crop width; else crop height.
|
||||||
|
if current > target:
|
||||||
|
new_w = max(1, int(h * target))
|
||||||
|
left = max(0, (w - new_w) // 2)
|
||||||
|
return img.crop((left, 0, left + new_w, h))
|
||||||
|
else:
|
||||||
|
new_h = max(1, int(w / target))
|
||||||
|
top = max(0, (h - new_h) // 2)
|
||||||
|
return img.crop((0, top, w, top + new_h))
|
||||||
|
|
||||||
|
|
||||||
|
def _save_compressed_image(
|
||||||
|
uploaded_file,
|
||||||
|
upload_root: str,
|
||||||
|
company_id: int | None,
|
||||||
|
crop_mode: str | None = None,
|
||||||
|
) -> str:
|
||||||
"""Save an uploaded image as a compressed WEBP file.
|
"""Save an uploaded image as a compressed WEBP file.
|
||||||
|
|
||||||
|
crop_mode:
|
||||||
|
- "16:9" : center-crop to landscape
|
||||||
|
- "9:16" : center-crop to portrait
|
||||||
|
- "none" : no crop
|
||||||
|
|
||||||
Returns relative file path under /static (e.g. uploads/<uuid>.webp)
|
Returns relative file path under /static (e.g. uploads/<uuid>.webp)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
unique = f"{uuid.uuid4().hex}.webp"
|
unique = f"{uuid.uuid4().hex}.webp"
|
||||||
save_path = os.path.join(upload_folder, unique)
|
company_dir = ensure_company_upload_dir(upload_root, company_id)
|
||||||
|
save_path = os.path.join(company_dir, unique)
|
||||||
|
|
||||||
|
cm = (crop_mode or "16:9").strip().lower()
|
||||||
|
if cm not in {"16:9", "9:16", "none"}:
|
||||||
|
cm = "16:9"
|
||||||
|
|
||||||
img = Image.open(uploaded_file)
|
img = Image.open(uploaded_file)
|
||||||
|
# Respect EXIF orientation (common for phone photos)
|
||||||
|
img = ImageOps.exif_transpose(img)
|
||||||
|
|
||||||
# Normalize mode for webp
|
# Normalize mode for webp
|
||||||
if img.mode not in ("RGB", "RGBA"):
|
if img.mode not in ("RGB", "RGBA"):
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
# Optional crop
|
||||||
|
if cm == "16:9":
|
||||||
|
img = _center_crop_to_aspect(img, 16, 9)
|
||||||
|
max_box = (1920, 1080)
|
||||||
|
elif cm == "9:16":
|
||||||
|
img = _center_crop_to_aspect(img, 9, 16)
|
||||||
|
max_box = (1080, 1920)
|
||||||
|
else:
|
||||||
|
# No crop: allow both portrait and landscape up to 1920px on the longest side.
|
||||||
|
max_box = (1920, 1920)
|
||||||
|
|
||||||
# Resize down if very large (keeps aspect ratio)
|
# Resize down if very large (keeps aspect ratio)
|
||||||
img.thumbnail((1920, 1080))
|
img.thumbnail(max_box)
|
||||||
|
|
||||||
img.save(save_path, format="WEBP", quality=80, method=6)
|
img.save(save_path, format="WEBP", quality=80, method=6)
|
||||||
return f"uploads/{unique}"
|
company_seg = str(int(company_id)) if company_id is not None else "0"
|
||||||
|
return f"uploads/{company_seg}/{unique}"
|
||||||
|
|
||||||
|
|
||||||
def _try_delete_upload(file_path: str | None, upload_folder: str):
|
def _try_delete_upload(file_path: str | None, upload_root: str):
|
||||||
"""Best-effort delete of an uploaded media file."""
|
"""Best-effort delete of an uploaded media file."""
|
||||||
if not file_path:
|
if not file_path:
|
||||||
return
|
return
|
||||||
if not file_path.startswith("uploads/"):
|
if not is_valid_upload_relpath(file_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
abs_path = abs_upload_path(upload_root, file_path)
|
||||||
|
if not abs_path:
|
||||||
return
|
return
|
||||||
filename = file_path.split("/", 1)[1]
|
|
||||||
abs_path = os.path.join(upload_folder, filename)
|
|
||||||
try:
|
try:
|
||||||
if os.path.isfile(abs_path):
|
if os.path.isfile(abs_path):
|
||||||
os.remove(abs_path)
|
os.remove(abs_path)
|
||||||
@@ -114,6 +178,39 @@ def _try_delete_upload(file_path: str | None, upload_folder: str):
|
|||||||
bp = Blueprint("company", __name__, url_prefix="/company")
|
bp = Blueprint("company", __name__, url_prefix="/company")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_schedule_local_to_utc(*, date_str: str | None, time_str: str | None) -> datetime | None:
|
||||||
|
"""Parse local date+time form inputs into a naive UTC datetime.
|
||||||
|
|
||||||
|
Inputs come from <input type="date"> and <input type="time">.
|
||||||
|
We interpret them as *local* time of the server.
|
||||||
|
|
||||||
|
Note: this project currently does not store per-company timezone; in most deployments
|
||||||
|
server timezone matches users. If you need per-company timezone later, we can extend
|
||||||
|
this function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
d = (date_str or "").strip()
|
||||||
|
t = (time_str or "").strip()
|
||||||
|
if not d and not t:
|
||||||
|
return None
|
||||||
|
if not d or not t:
|
||||||
|
# Require both parts for clarity
|
||||||
|
raise ValueError("Both date and time are required")
|
||||||
|
|
||||||
|
# Basic parsing: YYYY-MM-DD and HH:MM
|
||||||
|
try:
|
||||||
|
year, month, day = [int(x) for x in d.split("-")]
|
||||||
|
hh, mm = [int(x) for x in t.split(":")[:2]]
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("Invalid date/time")
|
||||||
|
|
||||||
|
# Interpret as local time, convert to UTC naive
|
||||||
|
local_dt = datetime(year, month, day, hh, mm)
|
||||||
|
# local_dt.timestamp() uses local timezone when naive.
|
||||||
|
utc_ts = local_dt.timestamp()
|
||||||
|
return datetime.utcfromtimestamp(utc_ts)
|
||||||
|
|
||||||
|
|
||||||
def company_user_required():
|
def company_user_required():
|
||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
abort(403)
|
abort(403)
|
||||||
@@ -123,13 +220,170 @@ def company_user_required():
|
|||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_bytes(num: int) -> str:
|
||||||
|
num = max(0, int(num or 0))
|
||||||
|
units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
size = float(num)
|
||||||
|
idx = 0
|
||||||
|
while size >= 1024.0 and idx < len(units) - 1:
|
||||||
|
size /= 1024.0
|
||||||
|
idx += 1
|
||||||
|
if idx == 0:
|
||||||
|
return f"{int(size)} {units[idx]}"
|
||||||
|
return f"{size:.1f} {units[idx]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _storage_limit_error_message(*, storage_max_human: str | None) -> str:
|
||||||
|
if storage_max_human:
|
||||||
|
return f"Storage limit reached. Maximum allowed storage is {storage_max_human}. Please delete items to free space."
|
||||||
|
return "Storage limit reached. Please delete items to free space."
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/my-company")
|
||||||
|
@login_required
|
||||||
|
def my_company():
|
||||||
|
company_user_required()
|
||||||
|
|
||||||
|
company = db.session.get(Company, current_user.company_id)
|
||||||
|
if not company:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
display_count = Display.query.filter_by(company_id=company.id).count()
|
||||||
|
playlist_count = Playlist.query.filter_by(company_id=company.id).count()
|
||||||
|
user_count = User.query.filter_by(company_id=company.id, is_admin=False).count()
|
||||||
|
|
||||||
|
item_count = (
|
||||||
|
PlaylistItem.query.join(Playlist, PlaylistItem.playlist_id == Playlist.id)
|
||||||
|
.filter(Playlist.company_id == company.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Active display sessions (best-effort, based on same TTL as /api)
|
||||||
|
cutoff = datetime.utcnow() - timedelta(seconds=90)
|
||||||
|
active_sessions = (
|
||||||
|
DisplaySession.query.join(Display, DisplaySession.display_id == Display.id)
|
||||||
|
.filter(Display.company_id == company.id, DisplaySession.last_seen_at >= cutoff)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Storage usage
|
||||||
|
upload_root = current_app.config["UPLOAD_FOLDER"]
|
||||||
|
used_bytes = get_company_upload_bytes(upload_root, company.id)
|
||||||
|
|
||||||
|
usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes)
|
||||||
|
max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None
|
||||||
|
|
||||||
|
users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"company/my_company.html",
|
||||||
|
company=company,
|
||||||
|
users=users,
|
||||||
|
stats={
|
||||||
|
"users": user_count,
|
||||||
|
"displays": display_count,
|
||||||
|
"playlists": playlist_count,
|
||||||
|
"items": item_count,
|
||||||
|
"active_sessions": active_sessions,
|
||||||
|
"storage_bytes": used_bytes,
|
||||||
|
"storage_human": _format_bytes(used_bytes),
|
||||||
|
"storage_max_bytes": usage.get("max_bytes"),
|
||||||
|
"storage_max_human": max_human,
|
||||||
|
"storage_used_percent": usage.get("used_percent"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/my-company/invite")
|
||||||
|
@login_required
|
||||||
|
def invite_user():
|
||||||
|
company_user_required()
|
||||||
|
|
||||||
|
email = (request.form.get("email", "") or "").strip().lower()
|
||||||
|
if not email:
|
||||||
|
flash("Email is required", "danger")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
if User.query.filter_by(email=email).first():
|
||||||
|
flash("Email already exists", "danger")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
company = db.session.get(Company, current_user.company_id)
|
||||||
|
if not company:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Create user without password; they must set it via reset link.
|
||||||
|
u = User(is_admin=False, company=company)
|
||||||
|
u.email = email
|
||||||
|
u.username = email # keep backwards-compatible username column in sync
|
||||||
|
u.password_hash = None
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
token = make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=u.id)
|
||||||
|
|
||||||
|
settings = db.session.get(AppSettings, 1)
|
||||||
|
if settings and settings.public_domain:
|
||||||
|
path = url_for("auth.reset_password", token=token, _external=False)
|
||||||
|
reset_url = f"https://{settings.public_domain}{path}"
|
||||||
|
else:
|
||||||
|
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
||||||
|
body = (
|
||||||
|
f"You have been invited to {company.name} on Signage.\n\n"
|
||||||
|
"Set your password using this link (valid for 30 minutes):\n"
|
||||||
|
f"{reset_url}\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
send_email(to_email=u.email, subject=f"Invite: {company.name} (set your password)", body_text=body)
|
||||||
|
except Exception:
|
||||||
|
# Roll back created user if we cannot send invite email, to avoid orphan accounts.
|
||||||
|
db.session.delete(u)
|
||||||
|
db.session.commit()
|
||||||
|
flash(
|
||||||
|
"Failed to send invite email. Please check SMTP configuration (SMTP_* env vars).",
|
||||||
|
"danger",
|
||||||
|
)
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
flash(f"Invite sent to {email}", "success")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/my-company/users/<int:user_id>/delete")
|
||||||
|
@login_required
|
||||||
|
def delete_company_user(user_id: int):
|
||||||
|
company_user_required()
|
||||||
|
|
||||||
|
if int(user_id) == int(current_user.id):
|
||||||
|
flash("You cannot delete yourself", "danger")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
u = db.session.get(User, user_id)
|
||||||
|
if not u or u.is_admin or u.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
email = u.email
|
||||||
|
db.session.delete(u)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"User '{email}' deleted", "success")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
company_user_required()
|
company_user_required()
|
||||||
playlists = Playlist.query.filter_by(company_id=current_user.company_id).order_by(Playlist.name.asc()).all()
|
playlists = Playlist.query.filter_by(company_id=current_user.company_id).order_by(Playlist.name.asc()).all()
|
||||||
displays = Display.query.filter_by(company_id=current_user.company_id).order_by(Display.name.asc()).all()
|
displays = Display.query.filter_by(company_id=current_user.company_id).order_by(Display.name.asc()).all()
|
||||||
return render_template("company/dashboard.html", playlists=playlists, displays=displays)
|
playlists_json = [{"id": p.id, "name": p.name} for p in playlists]
|
||||||
|
return render_template(
|
||||||
|
"company/dashboard.html",
|
||||||
|
playlists=playlists,
|
||||||
|
now_utc=datetime.utcnow(),
|
||||||
|
playlists_json=playlists_json,
|
||||||
|
displays=displays,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/playlists")
|
@bp.post("/playlists")
|
||||||
@@ -154,7 +408,118 @@ def playlist_detail(playlist_id: int):
|
|||||||
playlist = db.session.get(Playlist, playlist_id)
|
playlist = db.session.get(Playlist, playlist_id)
|
||||||
if not playlist or playlist.company_id != current_user.company_id:
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
abort(404)
|
abort(404)
|
||||||
return render_template("company/playlist_detail.html", playlist=playlist)
|
return render_template("company/playlist_detail.html", playlist=playlist, now_utc=datetime.utcnow())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/playlists/<int:playlist_id>")
|
||||||
|
@login_required
|
||||||
|
def update_playlist(playlist_id: int):
|
||||||
|
"""Update playlist metadata.
|
||||||
|
|
||||||
|
Currently supports renaming the playlist from the playlist detail (edit) page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
playlist = db.session.get(Playlist, playlist_id)
|
||||||
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
name = (request.form.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
flash("Playlist name required", "danger")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
# Keep within DB column limit (String(120))
|
||||||
|
if len(name) > 120:
|
||||||
|
name = name[:120]
|
||||||
|
|
||||||
|
playlist.name = name
|
||||||
|
db.session.commit()
|
||||||
|
flash("Playlist renamed", "success")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/playlists/<int:playlist_id>/schedule")
|
||||||
|
@login_required
|
||||||
|
def update_playlist_schedule(playlist_id: int):
|
||||||
|
"""Update playlist schedule window + priority flag."""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
playlist = db.session.get(Playlist, playlist_id)
|
||||||
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = _parse_schedule_local_to_utc(
|
||||||
|
date_str=request.form.get("schedule_start_date"),
|
||||||
|
time_str=request.form.get("schedule_start_time"),
|
||||||
|
)
|
||||||
|
end = _parse_schedule_local_to_utc(
|
||||||
|
date_str=request.form.get("schedule_end_date"),
|
||||||
|
time_str=request.form.get("schedule_end_time"),
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
flash(str(e), "danger")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
if start and end and end < start:
|
||||||
|
flash("End must be after start", "danger")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
playlist.schedule_start = start
|
||||||
|
playlist.schedule_end = end
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("Schedule updated", "success")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/playlists/<int:playlist_id>/schedule/delete")
|
||||||
|
@login_required
|
||||||
|
def clear_playlist_schedule(playlist_id: int):
|
||||||
|
"""Clear schedule for a playlist (sets start/end to NULL)."""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
playlist = db.session.get(Playlist, playlist_id)
|
||||||
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
playlist.schedule_start = None
|
||||||
|
playlist.schedule_end = None
|
||||||
|
db.session.commit()
|
||||||
|
flash("Schedule removed", "success")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/playlists/<int:playlist_id>/priority")
|
||||||
|
@login_required
|
||||||
|
def update_playlist_priority(playlist_id: int):
|
||||||
|
"""Update playlist priority flag."""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
playlist = db.session.get(Playlist, playlist_id)
|
||||||
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
wants_json = (
|
||||||
|
(request.headers.get("X-Requested-With") == "XMLHttpRequest")
|
||||||
|
or ("application/json" in (request.headers.get("Accept") or ""))
|
||||||
|
or request.is_json
|
||||||
|
)
|
||||||
|
|
||||||
|
# Accept both form and JSON payloads.
|
||||||
|
raw = request.form.get("is_priority")
|
||||||
|
if raw is None and request.is_json:
|
||||||
|
raw = (request.get_json(silent=True) or {}).get("is_priority")
|
||||||
|
|
||||||
|
playlist.is_priority = bool((raw or "").strip())
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if wants_json:
|
||||||
|
return jsonify({"ok": True, "is_priority": bool(playlist.is_priority)})
|
||||||
|
|
||||||
|
flash("Priority updated", "success")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/playlists/<int:playlist_id>/delete")
|
@bp.post("/playlists/<int:playlist_id>/delete")
|
||||||
@@ -170,6 +535,15 @@ def delete_playlist(playlist_id: int):
|
|||||||
{"assigned_playlist_id": None}
|
{"assigned_playlist_id": None}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Remove from any display multi-playlist mappings in this company.
|
||||||
|
# Use a subquery to avoid a JOIN-based DELETE which is not supported on SQLite.
|
||||||
|
display_ids = [d.id for d in Display.query.filter_by(company_id=current_user.company_id).all()]
|
||||||
|
if display_ids:
|
||||||
|
DisplayPlaylist.query.filter(
|
||||||
|
DisplayPlaylist.display_id.in_(display_ids),
|
||||||
|
DisplayPlaylist.playlist_id == playlist.id,
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
# cleanup uploaded files for image/video items
|
# cleanup uploaded files for image/video items
|
||||||
for it in list(playlist.items):
|
for it in list(playlist.items):
|
||||||
if it.item_type in ("image", "video"):
|
if it.item_type in ("image", "video"):
|
||||||
@@ -271,7 +645,29 @@ def add_playlist_item(playlist_id: int):
|
|||||||
position=pos,
|
position=pos,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Enforce storage quota for uploads (image/video).
|
||||||
|
# Webpage/YouTube do not consume local storage.
|
||||||
|
# Note: querying the DB triggers an autoflush by default. Because `item` is not yet in the
|
||||||
|
# session, SQLAlchemy may emit warnings about relationship operations. We explicitly avoid
|
||||||
|
# autoflush while checking quota.
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
company = db.session.get(Company, current_user.company_id)
|
||||||
|
if not company:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
upload_root = current_app.config["UPLOAD_FOLDER"]
|
||||||
|
used_bytes = get_company_upload_bytes(upload_root, company.id)
|
||||||
|
usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes)
|
||||||
|
storage_max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None
|
||||||
|
|
||||||
if item_type in ("image", "video"):
|
if item_type in ("image", "video"):
|
||||||
|
if usage.get("is_exceeded"):
|
||||||
|
msg = _storage_limit_error_message(storage_max_human=storage_max_human)
|
||||||
|
if wants_json:
|
||||||
|
return _json_error(msg, 403)
|
||||||
|
flash(msg, "danger")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
f = request.files.get("file")
|
f = request.files.get("file")
|
||||||
if not f or not f.filename:
|
if not f or not f.filename:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
@@ -283,6 +679,7 @@ def add_playlist_item(playlist_id: int):
|
|||||||
ext = os.path.splitext(filename)[1].lower()
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
|
||||||
if item_type == "image":
|
if item_type == "image":
|
||||||
|
crop_mode = (request.form.get("crop_mode") or "16:9").strip().lower()
|
||||||
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return _json_error(
|
return _json_error(
|
||||||
@@ -291,7 +688,36 @@ def add_playlist_item(playlist_id: int):
|
|||||||
flash("Unsupported image type", "danger")
|
flash("Unsupported image type", "danger")
|
||||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
try:
|
try:
|
||||||
item.file_path = _save_compressed_image(f, current_app.config["UPLOAD_FOLDER"])
|
item.file_path = _save_compressed_image(
|
||||||
|
f,
|
||||||
|
current_app.config["UPLOAD_FOLDER"],
|
||||||
|
current_user.company_id,
|
||||||
|
crop_mode=crop_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Post-save quota check for images as well.
|
||||||
|
# (We can't reliably estimate image size before compression.)
|
||||||
|
if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0:
|
||||||
|
try:
|
||||||
|
used_after = get_company_upload_bytes(upload_root, company.id)
|
||||||
|
except Exception:
|
||||||
|
used_after = None
|
||||||
|
if used_after is not None:
|
||||||
|
usage_after = compute_storage_usage(
|
||||||
|
used_bytes=used_after,
|
||||||
|
max_bytes=company.storage_max_bytes,
|
||||||
|
)
|
||||||
|
if usage_after.get("is_exceeded"):
|
||||||
|
# Remove the saved file and reject.
|
||||||
|
try:
|
||||||
|
_try_delete_upload(item.file_path, upload_root)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
msg = _storage_limit_error_message(storage_max_human=storage_max_human)
|
||||||
|
if wants_json:
|
||||||
|
return _json_error(msg, 403)
|
||||||
|
flash(msg, "danger")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
except Exception:
|
except Exception:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return _json_error("Failed to process image upload", 500)
|
return _json_error("Failed to process image upload", 500)
|
||||||
@@ -330,9 +756,33 @@ def add_playlist_item(playlist_id: int):
|
|||||||
|
|
||||||
# Keep as-is but always rename to a UUID.
|
# Keep as-is but always rename to a UUID.
|
||||||
unique = uuid.uuid4().hex + ext
|
unique = uuid.uuid4().hex + ext
|
||||||
save_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique)
|
company_dir = ensure_company_upload_dir(current_app.config["UPLOAD_FOLDER"], current_user.company_id)
|
||||||
|
save_path = os.path.join(company_dir, unique)
|
||||||
f.save(save_path)
|
f.save(save_path)
|
||||||
|
|
||||||
|
# Post-save quota check: clients may not report size reliably.
|
||||||
|
# If quota is exceeded after saving, delete file and reject.
|
||||||
|
if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0:
|
||||||
|
try:
|
||||||
|
used_after = get_company_upload_bytes(upload_root, company.id)
|
||||||
|
except Exception:
|
||||||
|
used_after = None
|
||||||
|
if used_after is not None:
|
||||||
|
usage_after = compute_storage_usage(
|
||||||
|
used_bytes=used_after,
|
||||||
|
max_bytes=company.storage_max_bytes,
|
||||||
|
)
|
||||||
|
if usage_after.get("is_exceeded"):
|
||||||
|
try:
|
||||||
|
os.remove(save_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
msg = _storage_limit_error_message(storage_max_human=storage_max_human)
|
||||||
|
if wants_json:
|
||||||
|
return _json_error(msg, 403)
|
||||||
|
flash(msg, "danger")
|
||||||
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
# Safety check: validate using the actual saved file size.
|
# Safety check: validate using the actual saved file size.
|
||||||
# (Some clients/framework layers don't reliably report per-part size.)
|
# (Some clients/framework layers don't reliably report per-part size.)
|
||||||
try:
|
try:
|
||||||
@@ -351,7 +801,7 @@ def add_playlist_item(playlist_id: int):
|
|||||||
flash(msg, "danger")
|
flash(msg, "danger")
|
||||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
item.file_path = f"uploads/{unique}"
|
item.file_path = f"uploads/{int(current_user.company_id)}/{unique}"
|
||||||
|
|
||||||
elif item_type == "webpage":
|
elif item_type == "webpage":
|
||||||
url = request.form.get("url", "").strip()
|
url = request.form.get("url", "").strip()
|
||||||
@@ -517,6 +967,14 @@ def update_display(display_id: int):
|
|||||||
def _json_error(message: str, status: int = 400):
|
def _json_error(message: str, status: int = 400):
|
||||||
return jsonify({"ok": False, "error": message}), status
|
return jsonify({"ok": False, "error": message}), status
|
||||||
|
|
||||||
|
def _normalize_transition(val: str | None) -> str | None:
|
||||||
|
v = (val or "").strip().lower()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
if v not in {"none", "fade", "slide"}:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
# Inputs from either form or JSON
|
# Inputs from either form or JSON
|
||||||
payload = request.get_json(silent=True) if request.is_json else None
|
payload = request.get_json(silent=True) if request.is_json else None
|
||||||
|
|
||||||
@@ -536,6 +994,16 @@ def update_display(display_id: int):
|
|||||||
desc = desc[:200]
|
desc = desc[:200]
|
||||||
display.description = desc
|
display.description = desc
|
||||||
|
|
||||||
|
# Slide transition
|
||||||
|
if request.is_json:
|
||||||
|
if payload is None:
|
||||||
|
return _json_error("Invalid JSON")
|
||||||
|
if "transition" in payload:
|
||||||
|
display.transition = _normalize_transition(payload.get("transition"))
|
||||||
|
else:
|
||||||
|
# Form POST implies full update
|
||||||
|
display.transition = _normalize_transition(request.form.get("transition"))
|
||||||
|
|
||||||
# Playlist assignment
|
# Playlist assignment
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
if "playlist_id" in payload:
|
if "playlist_id" in payload:
|
||||||
@@ -575,6 +1043,7 @@ def update_display(display_id: int):
|
|||||||
"id": display.id,
|
"id": display.id,
|
||||||
"name": display.name,
|
"name": display.name,
|
||||||
"description": display.description,
|
"description": display.description,
|
||||||
|
"transition": display.transition,
|
||||||
"assigned_playlist_id": display.assigned_playlist_id,
|
"assigned_playlist_id": display.assigned_playlist_id,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -582,3 +1051,87 @@ def update_display(display_id: int):
|
|||||||
|
|
||||||
flash("Display updated", "success")
|
flash("Display updated", "success")
|
||||||
return redirect(url_for("company.dashboard"))
|
return redirect(url_for("company.dashboard"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/displays/<int:display_id>/playlists")
|
||||||
|
@login_required
|
||||||
|
def update_display_playlists(display_id: int):
|
||||||
|
"""Set active playlists for a display.
|
||||||
|
|
||||||
|
Expects JSON: { playlist_ids: [1,2,3] }
|
||||||
|
Returns JSON with the updated assigned playlist ids.
|
||||||
|
|
||||||
|
Note: if playlist_ids is empty, the display will have no active playlists.
|
||||||
|
For backwards compatibility, this does NOT modify Display.assigned_playlist_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
|
||||||
|
display = db.session.get(Display, display_id)
|
||||||
|
if not display or display.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if not request.is_json:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
raw_ids = payload.get("playlist_ids")
|
||||||
|
if raw_ids is None:
|
||||||
|
return jsonify({"ok": False, "error": "playlist_ids is required"}), 400
|
||||||
|
|
||||||
|
if not isinstance(raw_ids, list):
|
||||||
|
return jsonify({"ok": False, "error": "playlist_ids must be a list"}), 400
|
||||||
|
|
||||||
|
playlist_ids: list[int] = []
|
||||||
|
try:
|
||||||
|
for x in raw_ids:
|
||||||
|
if x in (None, ""):
|
||||||
|
continue
|
||||||
|
playlist_ids.append(int(x))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "error": "Invalid playlist id"}), 400
|
||||||
|
|
||||||
|
# Ensure playlists belong to this company.
|
||||||
|
if playlist_ids:
|
||||||
|
allowed = {
|
||||||
|
p.id
|
||||||
|
for p in Playlist.query.filter(
|
||||||
|
Playlist.company_id == current_user.company_id,
|
||||||
|
Playlist.id.in_(playlist_ids),
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
if len(allowed) != len(set(playlist_ids)):
|
||||||
|
return jsonify({"ok": False, "error": "One or more playlists are invalid"}), 400
|
||||||
|
|
||||||
|
# Replace mapping rows.
|
||||||
|
DisplayPlaylist.query.filter_by(display_id=display.id).delete(synchronize_session=False)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
for pos, pid in enumerate(dict.fromkeys(playlist_ids), start=1):
|
||||||
|
db.session.add(
|
||||||
|
DisplayPlaylist(
|
||||||
|
display_id=display.id,
|
||||||
|
playlist_id=pid,
|
||||||
|
position=pos,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
active_ids = [
|
||||||
|
r[0]
|
||||||
|
for r in db.session.query(DisplayPlaylist.playlist_id)
|
||||||
|
.filter(DisplayPlaylist.display_id == display.id)
|
||||||
|
.order_by(DisplayPlaylist.position.asc(), DisplayPlaylist.playlist_id.asc())
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"display": {
|
||||||
|
"id": display.id,
|
||||||
|
"active_playlist_ids": active_ids,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
BIN
app/static/favicon.png
Normal file
BIN
app/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
139
app/static/logo.svg
Normal file
139
app/static/logo.svg
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="150.74998mm"
|
||||||
|
height="46.903393mm"
|
||||||
|
viewBox="0 0 150.74998 46.903393"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||||
|
sodipodi:docname="opencast logo.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="0.78840488"
|
||||||
|
inkscape:cx="397.00414"
|
||||||
|
inkscape:cy="561.25984"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1129"
|
||||||
|
inkscape:window-x="-8"
|
||||||
|
inkscape:window-y="-8"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="g35">
|
||||||
|
<inkscape:page
|
||||||
|
x="-3.5699601e-22"
|
||||||
|
y="0"
|
||||||
|
width="150.74998"
|
||||||
|
height="46.903393"
|
||||||
|
id="page2"
|
||||||
|
margin="0"
|
||||||
|
bleed="0" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs1">
|
||||||
|
<rect
|
||||||
|
x="319.63272"
|
||||||
|
y="230.84586"
|
||||||
|
width="465.49686"
|
||||||
|
height="96.397171"
|
||||||
|
id="rect4" />
|
||||||
|
<filter
|
||||||
|
style="color-interpolation-filters:sRGB"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
id="filter36"
|
||||||
|
x="-0.16131238"
|
||||||
|
y="-0.23838385"
|
||||||
|
width="1.358472"
|
||||||
|
height="1.5529181">
|
||||||
|
<feFlood
|
||||||
|
result="flood"
|
||||||
|
in="SourceGraphic"
|
||||||
|
flood-opacity="0.498039"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
id="feFlood35" />
|
||||||
|
<feGaussianBlur
|
||||||
|
result="blur"
|
||||||
|
in="SourceGraphic"
|
||||||
|
stdDeviation="3.000000"
|
||||||
|
id="feGaussianBlur35" />
|
||||||
|
<feOffset
|
||||||
|
result="offset"
|
||||||
|
in="blur"
|
||||||
|
dx="1.600000"
|
||||||
|
dy="2.300000"
|
||||||
|
id="feOffset35" />
|
||||||
|
<feComposite
|
||||||
|
result="comp1"
|
||||||
|
operator="in"
|
||||||
|
in="flood"
|
||||||
|
in2="offset"
|
||||||
|
id="feComposite35" />
|
||||||
|
<feComposite
|
||||||
|
result="comp2"
|
||||||
|
operator="over"
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="comp1"
|
||||||
|
id="feComposite36" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
inkscape:label="Laag 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-19.647457,-50.186441)">
|
||||||
|
<g
|
||||||
|
id="g35"
|
||||||
|
style="filter:url(#filter36)"
|
||||||
|
inkscape:export-filename="rssfeed\app\static\logo.svg"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
transform="translate(-1.6779659,-0.67118643)">
|
||||||
|
<ellipse
|
||||||
|
style="fill:#ffe511;fill-opacity:1;stroke-width:0.212298"
|
||||||
|
id="path1"
|
||||||
|
cx="43.582642"
|
||||||
|
cy="76.934746"
|
||||||
|
rx="15.057219"
|
||||||
|
ry="11.056598"
|
||||||
|
inkscape:export-filename="rssfeed\app\static\logo.svg"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96" />
|
||||||
|
<ellipse
|
||||||
|
style="fill:#ffef6e;fill-opacity:1;stroke-width:0.212298"
|
||||||
|
id="path2"
|
||||||
|
cx="51.380131"
|
||||||
|
cy="68.574883"
|
||||||
|
rx="13.175065"
|
||||||
|
ry="10.517252" />
|
||||||
|
<ellipse
|
||||||
|
style="fill:#fff5a3;fill-opacity:1;stroke-width:0.212298"
|
||||||
|
id="path3"
|
||||||
|
cx="57.833221"
|
||||||
|
cy="78.148277"
|
||||||
|
rx="15.326097"
|
||||||
|
ry="10.112741" />
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,-4.5436437,1.7684327)"
|
||||||
|
id="text3"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:74.6667px;font-family:Georgia;-inkscape-font-specification:'Georgia, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect4);display:inline;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.13386;stroke-dasharray:none;stroke-opacity:1"><tspan
|
||||||
|
x="319.63281"
|
||||||
|
y="298.21152"
|
||||||
|
id="tspan2"><tspan
|
||||||
|
style="stroke:#000000"
|
||||||
|
id="tspan1">OpenSlide</tspan></tspan></text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -56,6 +56,12 @@ body {
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 160px;
|
||||||
|
height: 45px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -284,3 +290,20 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
|
|||||||
.toast {
|
.toast {
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Small status dot used on company dashboard next to the schedule icon */
|
||||||
|
.schedule-status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-status-dot.active {
|
||||||
|
background: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-status-dot.inactive {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,17 @@
|
|||||||
<td class="monospace small">{{ d.token }}</td>
|
<td class="monospace small">{{ d.token }}</td>
|
||||||
<td class="text-muted">{{ d.assigned_playlist.name if d.assigned_playlist else "(none)" }}</td>
|
<td class="text-muted">{{ d.assigned_playlist.name if d.assigned_playlist else "(none)" }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
|
<div class="d-inline-flex gap-2">
|
||||||
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('display.display_player', token=d.token) }}" target="_blank">Open</a>
|
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('display.display_player', token=d.token) }}" target="_blank">Open</a>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('admin.delete_display', display_id=d.id) }}"
|
||||||
|
data-confirm="Delete display {{ d.name }}? This cannot be undone."
|
||||||
|
onsubmit="return confirm(this.dataset.confirm);"
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -59,6 +69,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="card card-elevated">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Storage limit</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small mb-2">
|
||||||
|
Used: <strong>{{ storage.used_bytes }}</strong> bytes
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('admin.update_company_storage', company_id=company.id) }}" class="d-flex gap-2 flex-wrap align-items-end">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Max storage (MB)</label>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
type="number"
|
||||||
|
name="storage_max_mb"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value="{{ (company.storage_max_bytes / (1024*1024))|int if company.storage_max_bytes else '' }}"
|
||||||
|
placeholder="(empty = unlimited)"
|
||||||
|
/>
|
||||||
|
<div class="text-muted small">Set to 0 or empty to disable the limit.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-brand" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<div class="card card-elevated">
|
<div class="card card-elevated">
|
||||||
@@ -93,9 +135,20 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ u.email or "(no email)" }}</strong>
|
<strong>{{ u.email or "(no email)" }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
|
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
|
||||||
<button class="btn btn-brand btn-sm" type="submit">Impersonate</button>
|
<button class="btn btn-brand btn-sm" type="submit">Impersonate</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('admin.delete_user', user_id=u.id) }}"
|
||||||
|
data-confirm="Delete user {{ u.email or '(no email)' }}? This cannot be undone."
|
||||||
|
onsubmit="return confirm(this.dataset.confirm);"
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="list-group-item text-muted">No users.</div>
|
<div class="list-group-item text-muted">No users.</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h1 class="page-title">Admin</h1>
|
<h1 class="page-title">Admin</h1>
|
||||||
|
<a class="btn btn-outline-ink" href="{{ url_for('admin.settings') }}">Settings</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
|
|||||||
205
app/templates/admin/settings.html
Normal file
205
app/templates/admin/settings.html
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h1 class="page-title">Admin settings</h1>
|
||||||
|
<a class="btn btn-outline-ink" href="{{ url_for('admin.dashboard') }}">Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4 g-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card card-elevated">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">SMTP settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('admin.update_smtp_settings') }}" class="vstack gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Host</label>
|
||||||
|
<input class="form-control" name="smtp_host" value="{{ settings.smtp_host or '' }}" placeholder="smtp.example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Port</label>
|
||||||
|
<input class="form-control" name="smtp_port" value="{{ settings.smtp_port or '' }}" placeholder="587" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">From address (optional)</label>
|
||||||
|
<input class="form-control" name="smtp_from" value="{{ settings.smtp_from or '' }}" placeholder="no-reply@example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input class="form-control" name="smtp_username" value="{{ settings.smtp_username or '' }}" placeholder="user@example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
name="smtp_password"
|
||||||
|
type="password"
|
||||||
|
value=""
|
||||||
|
placeholder="Leave empty to keep current password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<div class="form-text">
|
||||||
|
For security, the current password is never shown. Leave empty to keep it unchanged.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Timeout (seconds)</label>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
name="smtp_timeout_seconds"
|
||||||
|
value="{{ settings.smtp_timeout_seconds }}"
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex align-items-end">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
name="smtp_starttls"
|
||||||
|
value="1"
|
||||||
|
id="smtp_starttls"
|
||||||
|
{% if settings.smtp_starttls %}checked{% endif %}
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="smtp_starttls">Use STARTTLS</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
name="smtp_debug"
|
||||||
|
value="1"
|
||||||
|
id="smtp_debug"
|
||||||
|
{% if settings.smtp_debug %}checked{% endif %}
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="smtp_debug">Enable SMTP debug logging</label>
|
||||||
|
<div class="form-text">Prints SMTP conversation to the Flask console (useful for troubleshooting).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-brand" type="submit">Save SMTP settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-elevated mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Send test email</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('admin.send_test_email') }}" class="vstack gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Recipient email</label>
|
||||||
|
<input class="form-control" name="to_email" placeholder="you@example.com" required />
|
||||||
|
<div class="form-text">Sends a short test email using the current SMTP configuration.</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-outline-ink" type="submit">Send test email</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-elevated mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Public domain</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('admin.update_public_domain') }}" class="vstack gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Domain used in emails</label>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
name="public_domain"
|
||||||
|
value="{{ settings.public_domain or '' }}"
|
||||||
|
placeholder="signage.example.com"
|
||||||
|
/>
|
||||||
|
<div class="form-text">
|
||||||
|
Used to generate absolute links (like password reset). Do not include <code>http(s)://</code>.
|
||||||
|
Leave empty to use the current request host.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-brand" type="submit">Save domain</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card card-elevated">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Admin users</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('admin.create_admin_user') }}" class="vstack gap-2 mb-3">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<input class="form-control" name="email" placeholder="Email" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<input class="form-control" name="password" type="password" placeholder="Password (min 8 chars)" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-brand" type="submit">Add admin</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th class="text-end">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in admins %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ u.email }}</strong>
|
||||||
|
{% if u.id == current_user.id %}
|
||||||
|
<span class="badge text-bg-secondary ms-2">You</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if u.id != current_user.id %}
|
||||||
|
<form method="post" action="{{ url_for('admin.demote_admin_user', user_id=u.id) }}" class="d-inline">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit">Demote</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">(cannot demote yourself)</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-muted">No admin users found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-text mt-2">
|
||||||
|
Safety: the last remaining admin cannot be demoted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{{ title or "Signage" }}</title>
|
<title>{{ title or "Signage" }}</title>
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/png" />
|
||||||
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='favicon.png') }}" />
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||||
</head>
|
</head>
|
||||||
@@ -11,8 +13,14 @@
|
|||||||
<nav class="navbar navbar-expand-lg navbar-light fixed-top app-navbar">
|
<nav class="navbar navbar-expand-lg navbar-light fixed-top app-navbar">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
|
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
|
||||||
<span class="brand-mark" aria-hidden="true">S</span>
|
<img
|
||||||
<span>Signage</span>
|
class="brand-logo"
|
||||||
|
src="{{ url_for('static', filename='logo.svg') }}"
|
||||||
|
alt="Signage"
|
||||||
|
width="34"
|
||||||
|
height="34"
|
||||||
|
/>
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -28,10 +36,23 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="mainNav">
|
<div class="collapse navbar-collapse" id="mainNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
{# Dashboard link removed: users can click the logo to go to the dashboard. #}
|
||||||
|
{% else %}
|
||||||
|
{# Dashboard link removed: users can click the logo to go to the dashboard. #}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="d-flex align-items-lg-center flex-column flex-lg-row gap-2 ms-lg-auto">
|
<div class="d-flex align-items-lg-center flex-column flex-lg-row gap-2 ms-lg-auto">
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<div class="small text-muted">{{ current_user.email }}</div>
|
<div class="small text-muted">{{ current_user.email }}</div>
|
||||||
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('auth.change_password') }}">Change password</a>
|
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('auth.change_password') }}">Change password</a>
|
||||||
|
{% if not current_user.is_admin %}
|
||||||
|
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('company.my_company') }}">My company</a>
|
||||||
|
{% endif %}
|
||||||
{% if session.get('impersonator_admin_id') %}
|
{% if session.get('impersonator_admin_id') %}
|
||||||
<a class="btn btn-brand btn-sm" href="{{ url_for('auth.stop_impersonation') }}">Stop impersonation</a>
|
<a class="btn btn-brand btn-sm" href="{{ url_for('auth.stop_impersonation') }}">Stop impersonation</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -24,14 +24,26 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for p in playlists %}
|
{% for p in playlists %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ p.name }}</strong></td>
|
<td>
|
||||||
|
<strong>{{ p.name }}</strong>
|
||||||
|
{# Indicators: schedule + priority #}
|
||||||
|
{% set has_schedule = (p.schedule_start is not none) or (p.schedule_end is not none) %}
|
||||||
|
{% if has_schedule %}
|
||||||
|
{% set is_active = (not p.schedule_start or p.schedule_start <= now_utc) and (not p.schedule_end or now_utc <= p.schedule_end) %}
|
||||||
|
<span class="ms-2" title="Scheduled playlist" style="font-weight:700;">📅</span>
|
||||||
|
<span
|
||||||
|
class="ms-1 schedule-status-dot {{ 'active' if is_active else 'inactive' }}"
|
||||||
|
title="{{ 'Schedule active' if is_active else 'Schedule inactive' }}"
|
||||||
|
></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if p.is_priority %}
|
||||||
|
<span class="ms-1" title="Priority playlist" style="color:#dc3545; font-weight:700;">❗</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="text-end">{{ p.items|length }}</td>
|
<td class="text-end">{{ p.items|length }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="d-inline-flex gap-2">
|
<div class="d-inline-flex gap-2">
|
||||||
<a class="btn btn-ink btn-sm" href="{{ url_for('company.playlist_detail', playlist_id=p.id) }}">Open</a>
|
<a class="btn btn-ink btn-sm" href="{{ url_for('company.playlist_detail', playlist_id=p.id) }}">Open</a>
|
||||||
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=p.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
|
|
||||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -59,6 +71,7 @@
|
|||||||
<div class="display-preview">
|
<div class="display-preview">
|
||||||
<iframe
|
<iframe
|
||||||
title="Preview — {{ d.name }}"
|
title="Preview — {{ d.name }}"
|
||||||
|
data-display-id="{{ d.id }}"
|
||||||
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
|
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
@@ -74,27 +87,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex flex-column gap-2 mt-auto">
|
<div class="d-flex flex-column gap-2 mt-auto">
|
||||||
<select
|
{# Multi-playlist selector: button opens modal with playlist checkboxes #}
|
||||||
class="form-select form-select-sm js-playlist-select"
|
<div class="d-flex gap-2 align-items-center">
|
||||||
data-display-id="{{ d.id }}"
|
|
||||||
aria-label="Playlist selection"
|
|
||||||
>
|
|
||||||
<option value="">(none)</option>
|
|
||||||
{% for p in playlists %}
|
|
||||||
<option value="{{ p.id }}" {% if d.assigned_playlist_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-end">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ink btn-sm js-edit-desc"
|
class="btn btn-ink btn-sm js-edit-playlists"
|
||||||
data-display-id="{{ d.id }}"
|
data-display-id="{{ d.id }}"
|
||||||
data-display-name="{{ d.name }}"
|
data-display-name="{{ d.name }}"
|
||||||
data-current-desc="{{ d.description or '' }}"
|
data-current-desc="{{ d.description or '' }}"
|
||||||
|
data-current-transition="{{ d.transition or 'none' }}"
|
||||||
|
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
|
||||||
|
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
|
||||||
>
|
>
|
||||||
Edit description
|
Configure display
|
||||||
</button>
|
</button>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<span class="js-active-playlists-summary" data-display-id="{{ d.id }}">—</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,31 +127,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit description modal -->
|
<!-- Edit playlists modal -->
|
||||||
<div class="modal fade" id="editDescModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="editPlaylistsModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="editDescModalTitle">Edit description</h5>
|
<h5 class="modal-title" id="editPlaylistsModalTitle">Configure display</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<label class="form-label" for="editDescInput">Description</label>
|
<div class="mb-3">
|
||||||
<textarea class="form-control" id="editDescInput" maxlength="200" rows="3" placeholder="Optional description (max 200 chars)"></textarea>
|
<label class="form-label" for="editPlaylistsDescInput">Description</label>
|
||||||
<div class="form-text"><span id="editDescCount">0</span>/200</div>
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="editPlaylistsDescInput"
|
||||||
|
maxlength="200"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Optional description (max 200 chars)"
|
||||||
|
></textarea>
|
||||||
|
<div class="form-text"><span id="editPlaylistsDescCount">0</span>/200</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="editPlaylistsTransitionSelect">Slide transition</label>
|
||||||
|
<select class="form-select" id="editPlaylistsTransitionSelect">
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="fade">Fade</option>
|
||||||
|
<option value="slide">Slide</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Applied on the display when switching between playlist items.</div>
|
||||||
|
</div>
|
||||||
|
<hr class="my-3" />
|
||||||
|
<div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div>
|
||||||
|
<div id="editPlaylistsList" class="d-flex flex-column gap-2"></div>
|
||||||
|
<div class="form-text mt-2" id="editPlaylistsHint"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-brand" id="editDescSaveBtn">Save</button>
|
<button type="button" class="btn btn-brand" id="editPlaylistsSaveBtn">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Embed playlists list as JSON to avoid templating inside JS (keeps JS linters happy). #}
|
||||||
|
<script type="application/json" id="allPlaylistsJson">{{ playlists_json|tojson }}</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page_scripts %}
|
{% block page_scripts %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
let ALL_PLAYLISTS = [];
|
||||||
|
try {
|
||||||
|
const el = document.getElementById('allPlaylistsJson');
|
||||||
|
ALL_PLAYLISTS = el ? JSON.parse(el.textContent || '[]') : [];
|
||||||
|
} catch (e) {
|
||||||
|
ALL_PLAYLISTS = [];
|
||||||
|
}
|
||||||
|
|
||||||
const toastEl = document.getElementById('companyToast');
|
const toastEl = document.getElementById('companyToast');
|
||||||
const toastBodyEl = document.getElementById('companyToastBody');
|
const toastBodyEl = document.getElementById('companyToastBody');
|
||||||
const toast = toastEl ? new bootstrap.Toast(toastEl, { delay: 2200 }) : null;
|
const toast = toastEl ? new bootstrap.Toast(toastEl, { delay: 2200 }) : null;
|
||||||
@@ -173,81 +215,204 @@
|
|||||||
return data.display;
|
return data.display;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playlist auto-save
|
function refreshPreviewIframe(displayId) {
|
||||||
document.querySelectorAll('.js-playlist-select').forEach((sel) => {
|
const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`);
|
||||||
sel.addEventListener('change', async () => {
|
if (!iframe || !iframe.src) return;
|
||||||
const displayId = sel.dataset.displayId;
|
|
||||||
const playlistId = sel.value || null;
|
|
||||||
sel.disabled = true;
|
|
||||||
try {
|
try {
|
||||||
await postDisplayUpdate(displayId, { playlist_id: playlistId });
|
const u = new URL(iframe.src, window.location.origin);
|
||||||
showToast('Playlist saved', 'text-bg-success');
|
// Ensure preview flag is present (and bust cache).
|
||||||
|
u.searchParams.set('preview', '1');
|
||||||
|
u.searchParams.set('_ts', String(Date.now()));
|
||||||
|
iframe.src = u.toString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
// Fallback: naive cache buster
|
||||||
} finally {
|
const sep = iframe.src.includes('?') ? '&' : '?';
|
||||||
sel.disabled = false;
|
iframe.src = `${iframe.src}${sep}_ts=${Date.now()}`;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function parseIds(csv) {
|
||||||
|
const s = (csv || '').trim();
|
||||||
|
if (!s) return [];
|
||||||
|
return s.split(',').map(x => parseInt(x, 10)).filter(n => Number.isFinite(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeActiveIdsFromDataset(btn) {
|
||||||
|
// If display_playlist table has rows, we use that.
|
||||||
|
// Otherwise fall back to legacy single playlist assignment.
|
||||||
|
const active = parseIds(btn.dataset.activePlaylistIds);
|
||||||
|
if (active.length) return active;
|
||||||
|
const legacy = parseInt(btn.dataset.legacyPlaylistId || '', 10);
|
||||||
|
return Number.isFinite(legacy) ? [legacy] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveIdsOnButton(btn, ids) {
|
||||||
|
btn.dataset.activePlaylistIds = (ids || []).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function playlistNameById(id) {
|
||||||
|
const p = (ALL_PLAYLISTS || []).find(x => x.id === id);
|
||||||
|
return p ? p.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshActivePlaylistSummary(displayId, ids) {
|
||||||
|
const el = document.querySelector(`.js-active-playlists-summary[data-display-id="${displayId}"]`);
|
||||||
|
if (!el) return;
|
||||||
|
if (!ids || ids.length === 0) {
|
||||||
|
el.textContent = '(none)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const names = ids.map(playlistNameById).filter(Boolean);
|
||||||
|
el.textContent = names.length ? names.join(', ') : `${ids.length} selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize summary labels on page load.
|
||||||
|
document.querySelectorAll('.js-edit-playlists').forEach((btn) => {
|
||||||
|
const displayId = btn.dataset.displayId;
|
||||||
|
const ids = computeActiveIdsFromDataset(btn);
|
||||||
|
refreshActivePlaylistSummary(displayId, ids);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Description modal
|
async function postDisplayPlaylists(displayId, playlistIds) {
|
||||||
const modalEl = document.getElementById('editDescModal');
|
const res = await fetch(`/company/displays/${displayId}/playlists`, {
|
||||||
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
method: 'POST',
|
||||||
const titleEl = document.getElementById('editDescModalTitle');
|
headers: {
|
||||||
const inputEl = document.getElementById('editDescInput');
|
'Content-Type': 'application/json',
|
||||||
const countEl = document.getElementById('editDescCount');
|
'Accept': 'application/json',
|
||||||
const saveBtn = document.getElementById('editDescSaveBtn');
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
let activeDisplayId = null;
|
body: JSON.stringify({ playlist_ids: playlistIds })
|
||||||
|
});
|
||||||
function updateCount() {
|
const data = await res.json().catch(() => null);
|
||||||
if (!inputEl || !countEl) return;
|
if (!res.ok || !data || !data.ok) {
|
||||||
countEl.textContent = String((inputEl.value || '').length);
|
const msg = (data && data.error) ? data.error : 'Save failed';
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return data.display;
|
||||||
}
|
}
|
||||||
if (inputEl) inputEl.addEventListener('input', updateCount);
|
|
||||||
|
|
||||||
document.querySelectorAll('.js-edit-desc').forEach((btn) => {
|
// Playlists modal
|
||||||
|
const plModalEl = document.getElementById('editPlaylistsModal');
|
||||||
|
const plModal = plModalEl ? new bootstrap.Modal(plModalEl) : null;
|
||||||
|
const plTitleEl = document.getElementById('editPlaylistsModalTitle');
|
||||||
|
const plListEl = document.getElementById('editPlaylistsList');
|
||||||
|
const plHintEl = document.getElementById('editPlaylistsHint');
|
||||||
|
const plSaveBtn = document.getElementById('editPlaylistsSaveBtn');
|
||||||
|
const plDescInputEl = document.getElementById('editPlaylistsDescInput');
|
||||||
|
const plDescCountEl = document.getElementById('editPlaylistsDescCount');
|
||||||
|
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
|
||||||
|
let activePlDisplayId = null;
|
||||||
|
let activePlButton = null;
|
||||||
|
|
||||||
|
function updatePlDescCount() {
|
||||||
|
if (!plDescInputEl || !plDescCountEl) return;
|
||||||
|
plDescCountEl.textContent = String((plDescInputEl.value || '').length);
|
||||||
|
}
|
||||||
|
if (plDescInputEl) plDescInputEl.addEventListener('input', updatePlDescCount);
|
||||||
|
|
||||||
|
function renderPlaylistCheckboxes(selectedIds) {
|
||||||
|
if (!plListEl) return;
|
||||||
|
plListEl.innerHTML = '';
|
||||||
|
const selectedSet = new Set(selectedIds || []);
|
||||||
|
const pls = (ALL_PLAYLISTS || []).slice().sort((a,b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
if (pls.length === 0) {
|
||||||
|
plListEl.innerHTML = '<div class="text-muted">No playlists available.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pls.forEach((p) => {
|
||||||
|
const id = `pl_cb_${p.id}`;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'form-check';
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'form-check-input';
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.id = id;
|
||||||
|
input.value = String(p.id);
|
||||||
|
input.checked = selectedSet.has(p.id);
|
||||||
|
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'form-check-label';
|
||||||
|
label.setAttribute('for', id);
|
||||||
|
label.textContent = p.name;
|
||||||
|
|
||||||
|
row.appendChild(input);
|
||||||
|
row.appendChild(label);
|
||||||
|
plListEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedPlaylistIdsFromModal() {
|
||||||
|
if (!plListEl) return [];
|
||||||
|
return Array.from(plListEl.querySelectorAll('input[type="checkbox"]'))
|
||||||
|
.filter(cb => cb.checked)
|
||||||
|
.map(cb => parseInt(cb.value, 10))
|
||||||
|
.filter(n => Number.isFinite(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.js-edit-playlists').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
activeDisplayId = btn.dataset.displayId;
|
activePlDisplayId = btn.dataset.displayId;
|
||||||
|
activePlButton = btn;
|
||||||
const displayName = btn.dataset.displayName || 'Display';
|
const displayName = btn.dataset.displayName || 'Display';
|
||||||
|
if (plTitleEl) plTitleEl.textContent = `Configure display — ${displayName}`;
|
||||||
|
|
||||||
const currentDesc = btn.dataset.currentDesc || '';
|
const currentDesc = btn.dataset.currentDesc || '';
|
||||||
if (titleEl) titleEl.textContent = `Edit description — ${displayName}`;
|
if (plDescInputEl) plDescInputEl.value = currentDesc;
|
||||||
if (inputEl) inputEl.value = currentDesc;
|
updatePlDescCount();
|
||||||
updateCount();
|
|
||||||
if (modal) modal.show();
|
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
|
||||||
|
if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none';
|
||||||
|
|
||||||
|
const selected = computeActiveIdsFromDataset(btn);
|
||||||
|
renderPlaylistCheckboxes(selected);
|
||||||
|
if (plHintEl) {
|
||||||
|
plHintEl.textContent = selected.length ? `${selected.length} currently selected.` : 'No playlists currently selected.';
|
||||||
|
}
|
||||||
|
if (plModal) plModal.show();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function saveDescription() {
|
async function savePlaylists() {
|
||||||
if (!activeDisplayId || !inputEl) return;
|
if (!activePlDisplayId || !activePlButton || !plSaveBtn) return;
|
||||||
const desc = (inputEl.value || '').trim();
|
const ids = getSelectedPlaylistIdsFromModal();
|
||||||
saveBtn.disabled = true;
|
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
|
||||||
|
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
|
||||||
|
plSaveBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const updated = await postDisplayUpdate(activeDisplayId, { description: desc });
|
const [updatedPlaylists, updatedDesc] = await Promise.all([
|
||||||
// Update visible description
|
postDisplayPlaylists(activePlDisplayId, ids),
|
||||||
const descEl = document.querySelector(`.js-display-desc[data-display-id="${activeDisplayId}"]`);
|
postDisplayUpdate(activePlDisplayId, { description: desc, transition })
|
||||||
if (descEl) descEl.textContent = updated.description ? updated.description : '—';
|
]);
|
||||||
// Update button's stored value
|
|
||||||
const btn = document.querySelector(`.js-edit-desc[data-display-id="${activeDisplayId}"]`);
|
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
|
||||||
if (btn) btn.dataset.currentDesc = updated.description || '';
|
? updatedPlaylists.active_playlist_ids
|
||||||
showToast('Description saved', 'text-bg-success');
|
: ids;
|
||||||
if (modal) modal.hide();
|
setActiveIdsOnButton(activePlButton, newIds);
|
||||||
|
refreshActivePlaylistSummary(activePlDisplayId, newIds);
|
||||||
|
|
||||||
|
const descEl = document.querySelector(`.js-display-desc[data-display-id="${activePlDisplayId}"]`);
|
||||||
|
const newDesc = updatedDesc && typeof updatedDesc.description === 'string' ? updatedDesc.description : desc;
|
||||||
|
if (descEl) descEl.textContent = newDesc ? newDesc : '—';
|
||||||
|
activePlButton.dataset.currentDesc = newDesc || '';
|
||||||
|
|
||||||
|
// Keep button dataset in sync so reopening modal shows correct value.
|
||||||
|
const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition;
|
||||||
|
activePlButton.dataset.currentTransition = newTransition || 'none';
|
||||||
|
|
||||||
|
showToast('Display updated', 'text-bg-success');
|
||||||
|
refreshPreviewIframe(activePlDisplayId);
|
||||||
|
if (plModal) plModal.hide();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
||||||
} finally {
|
} finally {
|
||||||
saveBtn.disabled = false;
|
plSaveBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (saveBtn) {
|
|
||||||
saveBtn.addEventListener('click', saveDescription);
|
if (plSaveBtn) {
|
||||||
}
|
plSaveBtn.addEventListener('click', savePlaylists);
|
||||||
if (modalEl) {
|
|
||||||
modalEl.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
saveDescription();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
141
app/templates/company/my_company.html
Normal file
141
app/templates/company/my_company.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">My Company</h1>
|
||||||
|
<div class="text-muted">{{ company.name }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a class="btn btn-outline-ink" href="{{ url_for('company.dashboard') }}">Back</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4 g-3">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card card-elevated h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Company stats</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Users</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['users'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Displays</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['displays'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Playlists</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['playlists'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Playlist items</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['items'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Active display sessions</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['active_sessions'] }}</div>
|
||||||
|
<div class="text-muted small">(last ~90 seconds)</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Storage used</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['storage_human'] }}</div>
|
||||||
|
<div class="text-muted small">({{ stats['storage_bytes'] }} bytes)</div>
|
||||||
|
{% if stats.get('storage_max_human') %}
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
Limit: <strong>{{ stats['storage_max_human'] }}</strong>
|
||||||
|
{% if stats.get('storage_used_percent') is not none %}
|
||||||
|
— Used: <strong>{{ stats['storage_used_percent'] }}%</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if stats.get('storage_used_percent') is not none %}
|
||||||
|
<div class="progress mt-1" style="height: 8px;">
|
||||||
|
<div
|
||||||
|
class="progress-bar {% if stats['storage_used_percent'] >= 100 %}bg-danger{% elif stats['storage_used_percent'] >= 90 %}bg-warning{% else %}bg-success{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ [stats['storage_used_percent'], 100]|min }}%"
|
||||||
|
aria-valuenow="{{ stats['storage_used_percent'] }}"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted small mt-1">Limit: <strong>Unlimited</strong></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card card-elevated h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Invite user</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('company.invite_user') }}" class="d-flex gap-2 flex-wrap">
|
||||||
|
<input class="form-control" type="email" name="email" placeholder="Email address" required />
|
||||||
|
<button class="btn btn-brand" type="submit">Send invite</button>
|
||||||
|
</form>
|
||||||
|
<div class="text-muted small mt-2">
|
||||||
|
The user will receive an email with a password set link (valid for 30 minutes).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-elevated mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Users</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th class="text-muted">Created</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ u.email or "(no email)" }}</strong>
|
||||||
|
{% if u.id == current_user.id %}
|
||||||
|
<span class="badge bg-secondary ms-2">you</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else "—" }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if u.id != current_user.id %}
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('company.delete_company_user', user_id=u.id) }}"
|
||||||
|
class="d-inline"
|
||||||
|
data-confirm="Delete user {{ u.email or "(no email)" }}? This cannot be undone."
|
||||||
|
onsubmit="return confirm(this.dataset.confirm);"
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-muted">No users.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -61,10 +61,24 @@
|
|||||||
/* Modal step visibility */
|
/* Modal step visibility */
|
||||||
.step { display: none; }
|
.step { display: none; }
|
||||||
.step.active { display: block; }
|
.step.active { display: block; }
|
||||||
|
|
||||||
|
/* Tiny status pills/icons */
|
||||||
|
.priority-pill { color: #dc3545; font-weight: 700; }
|
||||||
|
.schedule-pill { font-weight: 700; }
|
||||||
|
.schedule-pill.active { color: #198754; }
|
||||||
|
.schedule-pill.inactive { color: #dc3545; }
|
||||||
</style>
|
</style>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
|
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#renamePlaylistModal"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=playlist.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
|
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=playlist.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
|
||||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete playlist</button>
|
<button class="btn btn-outline-danger btn-sm" type="submit">Delete playlist</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -72,6 +86,145 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Priority + schedule indicators #}
|
||||||
|
{% set has_schedule = (playlist.schedule_start is not none) or (playlist.schedule_end is not none) %}
|
||||||
|
{% set schedule_active = (not playlist.schedule_start or playlist.schedule_start <= now_utc) and (not playlist.schedule_end or now_utc <= playlist.schedule_end) %}
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2 mt-2">
|
||||||
|
<div class="small">
|
||||||
|
{% if playlist.is_priority %}
|
||||||
|
<span class="me-2 priority-pill" title="Priority playlist">❗ Priority</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted me-2">Not priority</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_schedule %}
|
||||||
|
<span class="me-2" title="Scheduled">
|
||||||
|
<span class="schedule-pill {{ 'active' if schedule_active else 'inactive' }}">📅 Scheduled</span>
|
||||||
|
<span class="text-muted">(<span id="scheduleSummary">…</span>)</span>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not scheduled</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{# Priority toggle: auto-saves (no Save button) #}
|
||||||
|
<form
|
||||||
|
id="priorityForm"
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('company.update_playlist_priority', playlist_id=playlist.id) }}"
|
||||||
|
class="d-flex align-items-center gap-2"
|
||||||
|
>
|
||||||
|
<div class="form-check mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" value="1" id="priorityMain" name="is_priority" {% if playlist.is_priority %}checked{% endif %} />
|
||||||
|
<label class="form-check-label" for="priorityMain">Priority playlist</label>
|
||||||
|
</div>
|
||||||
|
<span class="small text-muted" id="prioritySaveStatus" aria-live="polite"></span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Schedule button moved to where Save button used to be #}
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#playlistScheduleModal"
|
||||||
|
>
|
||||||
|
Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Schedule Modal #}
|
||||||
|
<div class="modal fade" id="playlistScheduleModal" tabindex="-1" aria-labelledby="playlistScheduleModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="playlistScheduleModalLabel">Schedule</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form id="playlistScheduleForm" method="post" action="{{ url_for('company.update_playlist_schedule', playlist_id=playlist.id) }}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-info py-2 mb-3" role="note">
|
||||||
|
Scheduling uses your browser's local time. Empty values mean “always active”.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="schedule_start_date">Start date</label>
|
||||||
|
<input class="form-control" type="date" id="schedule_start_date" name="schedule_start_date" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="schedule_start_time">Start time</label>
|
||||||
|
<input class="form-control" type="time" id="schedule_start_time" name="schedule_start_time" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="schedule_end_date">End date</label>
|
||||||
|
<input class="form-control" type="date" id="schedule_end_date" name="schedule_end_date" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="schedule_end_time">End time</label>
|
||||||
|
<input class="form-control" type="time" id="schedule_end_time" name="schedule_end_time" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# JS uses these to populate initial values #}
|
||||||
|
<input type="hidden" id="schedule_start_iso" value="{{ playlist.schedule_start.isoformat() if playlist.schedule_start else '' }}" />
|
||||||
|
<input type="hidden" id="schedule_end_iso" value="{{ playlist.schedule_end.isoformat() if playlist.schedule_end else '' }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
{% if has_schedule %}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-outline-danger me-auto"
|
||||||
|
formaction="{{ url_for('company.clear_playlist_schedule', playlist_id=playlist.id) }}"
|
||||||
|
formmethod="post"
|
||||||
|
onclick="return confirm('Remove schedule? This playlist will become always active.');"
|
||||||
|
>
|
||||||
|
Delete schedule
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-outline-ink" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-brand">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Rename Playlist Modal #}
|
||||||
|
<div class="modal fade" id="renamePlaylistModal" tabindex="-1" aria-labelledby="renamePlaylistModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="renamePlaylistModalLabel">Rename playlist</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('company.update_playlist', playlist_id=playlist.id) }}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="form-label" for="playlist-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="playlist-name"
|
||||||
|
class="form-control"
|
||||||
|
name="name"
|
||||||
|
value="{{ playlist.name }}"
|
||||||
|
placeholder="Playlist name"
|
||||||
|
maxlength="120"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<div class="text-muted small mt-2">Rename only changes the playlist title; items and assignments stay the same.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-ink" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-brand">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h5 mb-0">Items</h2>
|
<h2 class="h5 mb-0">Items</h2>
|
||||||
@@ -164,6 +317,7 @@
|
|||||||
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data">
|
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="response" value="json" />
|
<input type="hidden" name="response" value="json" />
|
||||||
<input type="hidden" name="item_type" id="item_type" value="image" />
|
<input type="hidden" name="item_type" id="item_type" value="image" />
|
||||||
|
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
|
||||||
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Title (optional)</label>
|
<label class="form-label">Title (optional)</label>
|
||||||
@@ -192,6 +346,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" id="crop-mode-group">
|
||||||
|
<label class="form-label">Image crop</label>
|
||||||
|
<div class="btn-group w-100" role="group" aria-label="Crop mode">
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
|
||||||
|
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
|
||||||
|
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.dropzone {
|
.dropzone {
|
||||||
border: 2px dashed #6c757d;
|
border: 2px dashed #6c757d;
|
||||||
@@ -285,11 +454,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<input id="video-file-input" class="form-control d-none" type="file" name="file" accept="video/*" />
|
<input id="video-file-input" class="form-control d-none" type="file" name="file" accept="video/*" />
|
||||||
<div class="text-muted small" id="video-select-status"></div>
|
<div class="text-muted small" id="video-select-status"></div>
|
||||||
|
|
||||||
|
{# Upload progress (for large videos) #}
|
||||||
|
<div id="video-upload-progress" class="d-none mt-2" aria-live="polite">
|
||||||
|
<div class="progress" style="height: 10px;">
|
||||||
|
<div
|
||||||
|
id="video-upload-progress-bar"
|
||||||
|
class="progress-bar"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: 0%"
|
||||||
|
aria-valuenow="0"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-1" id="video-upload-progress-text">Uploading…</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="step-crop" class="step">
|
<div id="step-crop" class="step">
|
||||||
<div class="text-muted small mb-2">Crop to <strong>16:9</strong> (recommended for display screens).</div>
|
<div class="text-muted small mb-2" id="crop-step-hint">Crop to <strong>16:9</strong> (recommended for display screens).</div>
|
||||||
<div style="width: 100%; background: #111; border-radius: .25rem; overflow: hidden;">
|
<div style="width: 100%; background: #111; border-radius: .25rem; overflow: hidden;">
|
||||||
<img id="image-crop-target" alt="Crop" style="max-width: 100%; display: block;" />
|
<img id="image-crop-target" alt="Crop" style="max-width: 100%; display: block;" />
|
||||||
</div>
|
</div>
|
||||||
@@ -301,6 +486,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
<div class="small text-danger me-auto" id="add-item-error" aria-live="polite"></div>
|
||||||
<button type="button" class="btn btn-outline-ink" id="add-item-back">Back</button>
|
<button type="button" class="btn btn-outline-ink" id="add-item-back">Back</button>
|
||||||
<button type="button" class="btn btn-brand" id="add-item-submit">Add</button>
|
<button type="button" class="btn btn-brand" id="add-item-submit">Add</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,6 +499,121 @@
|
|||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
(function() {
|
(function() {
|
||||||
|
// -------------------------
|
||||||
|
// Priority toggle: auto-save
|
||||||
|
// -------------------------
|
||||||
|
const priorityForm = document.getElementById('priorityForm');
|
||||||
|
const priorityCb = document.getElementById('priorityMain');
|
||||||
|
const priorityStatus = document.getElementById('prioritySaveStatus');
|
||||||
|
let priorityReqId = 0;
|
||||||
|
|
||||||
|
async function savePriority() {
|
||||||
|
if (!priorityForm || !priorityCb) return;
|
||||||
|
if (priorityStatus) priorityStatus.textContent = 'Saving…';
|
||||||
|
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
// Mirror server behavior: send "1" when checked, send empty when unchecked.
|
||||||
|
if (priorityCb.checked) body.set('is_priority', '1');
|
||||||
|
|
||||||
|
const reqId = ++priorityReqId;
|
||||||
|
const res = await fetch(priorityForm.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reqId !== priorityReqId) return; // newer request in flight
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (priorityStatus) priorityStatus.textContent = 'Failed to save';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!data || !data.ok) {
|
||||||
|
if (priorityStatus) priorityStatus.textContent = 'Failed to save';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priorityStatus) {
|
||||||
|
priorityStatus.textContent = 'Saved';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (priorityStatus.textContent === 'Saved') priorityStatus.textContent = '';
|
||||||
|
}, 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent accidental full-page POST when user hits Enter inside the form.
|
||||||
|
priorityForm?.addEventListener('submit', (e) => e.preventDefault());
|
||||||
|
priorityCb?.addEventListener('change', () => {
|
||||||
|
savePriority().catch((err) => {
|
||||||
|
console.warn('Failed to save priority', err);
|
||||||
|
if (priorityStatus) priorityStatus.textContent = 'Failed to save';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Schedule modal: populate existing UTC timestamps into local date/time inputs
|
||||||
|
// -------------------------
|
||||||
|
const schedModalEl = document.getElementById('playlistScheduleModal');
|
||||||
|
if (schedModalEl) {
|
||||||
|
const startIso = document.getElementById('schedule_start_iso')?.value || '';
|
||||||
|
const endIso = document.getElementById('schedule_end_iso')?.value || '';
|
||||||
|
|
||||||
|
const scheduleSummary = document.getElementById('scheduleSummary');
|
||||||
|
|
||||||
|
const startDate = document.getElementById('schedule_start_date');
|
||||||
|
const startTime = document.getElementById('schedule_start_time');
|
||||||
|
const endDate = document.getElementById('schedule_end_date');
|
||||||
|
const endTime = document.getElementById('schedule_end_time');
|
||||||
|
|
||||||
|
function pad2(n) { return String(n).padStart(2, '0'); }
|
||||||
|
function toLocalDateStr(d) { return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`; }
|
||||||
|
function toLocalTimeStr(d) { return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; }
|
||||||
|
|
||||||
|
function toSummary() {
|
||||||
|
const parts = [];
|
||||||
|
if (startIso) {
|
||||||
|
const d = new Date(startIso);
|
||||||
|
if (!isNaN(d.getTime())) parts.push(`from ${toLocalDateStr(d)} ${toLocalTimeStr(d)}`);
|
||||||
|
}
|
||||||
|
if (endIso) {
|
||||||
|
const d = new Date(endIso);
|
||||||
|
if (!isNaN(d.getTime())) parts.push(`until ${toLocalDateStr(d)} ${toLocalTimeStr(d)}`);
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill() {
|
||||||
|
if (startIso) {
|
||||||
|
const d = new Date(startIso);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
if (startDate) startDate.value = toLocalDateStr(d);
|
||||||
|
if (startTime) startTime.value = toLocalTimeStr(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (endIso) {
|
||||||
|
const d = new Date(endIso);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
if (endDate) endDate.value = toLocalDateStr(d);
|
||||||
|
if (endTime) endTime.value = toLocalTimeStr(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduleSummary) scheduleSummary.textContent = toSummary() || 'scheduled';
|
||||||
|
}
|
||||||
|
|
||||||
|
schedModalEl.addEventListener('shown.bs.modal', fill);
|
||||||
|
|
||||||
|
// Populate summary immediately on page load
|
||||||
|
if (scheduleSummary) scheduleSummary.textContent = toSummary() || 'scheduled';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the card layout in ONE place to ensure newly-added items match server-rendered items.
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Add-item modal + steps
|
// Add-item modal + steps
|
||||||
// -------------------------
|
// -------------------------
|
||||||
@@ -324,8 +625,11 @@
|
|||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
const typeHidden = document.getElementById('item_type');
|
const typeHidden = document.getElementById('item_type');
|
||||||
|
const cropModeHidden = document.getElementById('crop_mode');
|
||||||
const submitBtn = document.getElementById('add-item-submit');
|
const submitBtn = document.getElementById('add-item-submit');
|
||||||
const durationGroup = document.getElementById('duration-group');
|
const durationGroup = document.getElementById('duration-group');
|
||||||
|
const cropModeGroup = document.getElementById('crop-mode-group');
|
||||||
|
const cropHint = document.getElementById('crop-step-hint');
|
||||||
|
|
||||||
const sectionImage = document.getElementById('section-image');
|
const sectionImage = document.getElementById('section-image');
|
||||||
const sectionWebpage = document.getElementById('section-webpage');
|
const sectionWebpage = document.getElementById('section-webpage');
|
||||||
@@ -335,6 +639,12 @@
|
|||||||
const stepSelect = document.getElementById('step-select');
|
const stepSelect = document.getElementById('step-select');
|
||||||
const stepCrop = document.getElementById('step-crop');
|
const stepCrop = document.getElementById('step-crop');
|
||||||
const backBtn = document.getElementById('add-item-back');
|
const backBtn = document.getElementById('add-item-back');
|
||||||
|
const errorEl = document.getElementById('add-item-error');
|
||||||
|
|
||||||
|
function setError(msg) {
|
||||||
|
if (!errorEl) return;
|
||||||
|
errorEl.textContent = (msg || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
function showStep(which) {
|
function showStep(which) {
|
||||||
stepSelect?.classList.toggle('active', which === 'select');
|
stepSelect?.classList.toggle('active', which === 'select');
|
||||||
@@ -351,12 +661,14 @@
|
|||||||
|
|
||||||
function setType(t) {
|
function setType(t) {
|
||||||
typeHidden.value = t;
|
typeHidden.value = t;
|
||||||
|
setError('');
|
||||||
sectionImage.classList.toggle('d-none', t !== 'image');
|
sectionImage.classList.toggle('d-none', t !== 'image');
|
||||||
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
|
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
|
||||||
sectionYoutube.classList.toggle('d-none', t !== 'youtube');
|
sectionYoutube.classList.toggle('d-none', t !== 'youtube');
|
||||||
sectionVideo.classList.toggle('d-none', t !== 'video');
|
sectionVideo.classList.toggle('d-none', t !== 'video');
|
||||||
// duration applies to image/webpage/youtube. Video plays until ended.
|
// duration applies to image/webpage/youtube. Video plays until ended.
|
||||||
durationGroup.classList.toggle('d-none', t === 'video');
|
durationGroup.classList.toggle('d-none', t === 'video');
|
||||||
|
cropModeGroup?.classList.toggle('d-none', t !== 'image');
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.title = '';
|
submitBtn.title = '';
|
||||||
|
|
||||||
@@ -373,6 +685,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentCropMode() {
|
||||||
|
// crop_mode_choice is only UI; we submit hidden crop_mode for server fallback
|
||||||
|
return (cropModeHidden?.value || '16:9').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCropHint() {
|
||||||
|
const cm = currentCropMode();
|
||||||
|
if (!cropHint) return;
|
||||||
|
if (cm === 'none') {
|
||||||
|
cropHint.innerHTML = 'No crop selected. The image will be resized/compressed, keeping its original aspect ratio.';
|
||||||
|
if (cropResetBtn) cropResetBtn.disabled = true;
|
||||||
|
} else if (cm === '9:16') {
|
||||||
|
cropHint.innerHTML = 'Crop to <strong>9:16</strong> (portrait).';
|
||||||
|
if (cropResetBtn) cropResetBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
cropHint.innerHTML = 'Crop to <strong>16:9</strong> (landscape, recommended for display screens).';
|
||||||
|
if (cropResetBtn) cropResetBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
|
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
|
||||||
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
|
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
|
||||||
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
|
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
|
||||||
@@ -437,13 +769,19 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create cropper only when cropping is enabled
|
||||||
|
const cm = currentCropMode();
|
||||||
|
if (cm !== 'none') {
|
||||||
cropper = new window.Cropper(cropImg, {
|
cropper = new window.Cropper(cropImg, {
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: (cm === '9:16') ? (9 / 16) : (16 / 9),
|
||||||
viewMode: 1,
|
viewMode: 1,
|
||||||
autoCropArea: 1,
|
autoCropArea: 1,
|
||||||
responsive: true,
|
responsive: true,
|
||||||
background: false,
|
background: false,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCropHint();
|
||||||
|
|
||||||
// Enable Add button now that cropper exists
|
// Enable Add button now that cropper exists
|
||||||
if (typeHidden.value === 'image') submitBtn.disabled = false;
|
if (typeHidden.value === 'image') submitBtn.disabled = false;
|
||||||
@@ -483,18 +821,25 @@
|
|||||||
async function submitViaAjax() {
|
async function submitViaAjax() {
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
cropStatus.textContent = '';
|
cropStatus.textContent = '';
|
||||||
|
setError('');
|
||||||
|
|
||||||
// If image, replace file with cropped version before sending.
|
// If image, replace file with cropped version before sending.
|
||||||
if (typeHidden.value === 'image') {
|
if (typeHidden.value === 'image') {
|
||||||
|
const cm = currentCropMode();
|
||||||
|
|
||||||
|
// If no crop is selected, just upload the original file.
|
||||||
|
if (cm !== 'none') {
|
||||||
if (!cropper) {
|
if (!cropper) {
|
||||||
cropStatus.textContent = 'Please select an image first.';
|
cropStatus.textContent = 'Please select an image first.';
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cropStatus.textContent = 'Preparing cropped image…';
|
cropStatus.textContent = 'Preparing cropped image…';
|
||||||
|
const isPortrait = cm === '9:16';
|
||||||
const canvas = cropper.getCroppedCanvas({
|
const canvas = cropper.getCroppedCanvas({
|
||||||
width: 1280,
|
width: isPortrait ? 720 : 1280,
|
||||||
height: 720,
|
height: isPortrait ? 1280 : 720,
|
||||||
imageSmoothingQuality: 'high',
|
imageSmoothingQuality: 'high',
|
||||||
});
|
});
|
||||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||||
@@ -507,9 +852,87 @@
|
|||||||
setFileOnInput(fileInput, croppedFile);
|
setFileOnInput(fileInput, croppedFile);
|
||||||
cropStatus.textContent = '';
|
cropStatus.textContent = '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fd = new FormData(form);
|
const fd = new FormData(form);
|
||||||
|
|
||||||
|
// For upload progress we need XHR (fetch does not provide upload progress reliably).
|
||||||
|
const useXhrProgress = (typeHidden.value === 'video');
|
||||||
|
|
||||||
|
function setVideoProgressVisible(visible) {
|
||||||
|
const wrap = document.getElementById('video-upload-progress');
|
||||||
|
wrap?.classList.toggle('d-none', !visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVideoProgress(percent, text) {
|
||||||
|
const bar = document.getElementById('video-upload-progress-bar');
|
||||||
|
const txt = document.getElementById('video-upload-progress-text');
|
||||||
|
const p = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
|
||||||
|
if (bar) {
|
||||||
|
bar.style.width = `${p}%`;
|
||||||
|
bar.setAttribute('aria-valuenow', String(p));
|
||||||
|
}
|
||||||
|
if (txt) txt.textContent = text || `${p}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetVideoProgress() {
|
||||||
|
setVideoProgress(0, 'Uploading…');
|
||||||
|
setVideoProgressVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resOk = false;
|
||||||
|
let data = null;
|
||||||
|
let errorText = null;
|
||||||
|
|
||||||
|
if (useXhrProgress) {
|
||||||
|
// Show progress UI immediately for video uploads.
|
||||||
|
setVideoProgressVisible(true);
|
||||||
|
setVideoProgress(0, 'Uploading…');
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const xhrPromise = new Promise((resolve) => {
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', form.action, true);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json');
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (!e || !e.lengthComputable) {
|
||||||
|
setVideoProgress(0, 'Uploading…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pct = (e.total > 0) ? ((e.loaded / e.total) * 100) : 0;
|
||||||
|
setVideoProgress(pct, `Uploading… ${Math.round(pct)}%`);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
errorText = 'Upload failed (network error).';
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(fd);
|
||||||
|
await xhrPromise;
|
||||||
|
|
||||||
|
// When upload is done, server may still process the file. Give a hint.
|
||||||
|
setVideoProgress(100, 'Processing…');
|
||||||
|
|
||||||
|
const status = xhr.status;
|
||||||
|
const text = xhr.responseText || '';
|
||||||
|
let json = null;
|
||||||
|
try { json = JSON.parse(text); } catch (e) {}
|
||||||
|
|
||||||
|
resOk = (status >= 200 && status < 300);
|
||||||
|
data = json;
|
||||||
|
|
||||||
|
if (!resOk) {
|
||||||
|
// Prefer any earlier error (e.g. xhr.onerror network failure)
|
||||||
|
errorText = errorText || ((json && json.error) ? json.error : `Failed to add item (HTTP ${status}).`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const res = await fetch(form.action, {
|
const res = await fetch(form.action, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -519,21 +942,39 @@
|
|||||||
body: fd
|
body: fd
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
resOk = res.ok;
|
||||||
|
if (!resOk) {
|
||||||
let errText = 'Failed to add item.';
|
let errText = 'Failed to add item.';
|
||||||
try {
|
try {
|
||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
if (j && j.error) errText = j.error;
|
if (j && j.error) errText = j.error;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
errorText = errText;
|
||||||
|
} else {
|
||||||
|
data = await res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling shared between fetch/XHR paths
|
||||||
|
if (!resOk) {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
cropStatus.textContent = errText;
|
setError(errorText || 'Failed to add item.');
|
||||||
|
resetVideoProgress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For XHR path we may not have parsed JSON (bad response)
|
||||||
|
if (!data) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
setError('Failed to add item.');
|
||||||
|
resetVideoProgress();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
cropStatus.textContent = data.error || 'Failed to add item.';
|
setError(data.error || 'Failed to add item.');
|
||||||
|
resetVideoProgress();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,9 +994,12 @@
|
|||||||
form.reset();
|
form.reset();
|
||||||
typeHidden.value = 'image';
|
typeHidden.value = 'image';
|
||||||
document.getElementById('type-image')?.click();
|
document.getElementById('type-image')?.click();
|
||||||
|
if (cropModeHidden) cropModeHidden.value = '16:9';
|
||||||
|
document.getElementById('crop-16-9')?.click();
|
||||||
destroyCropper();
|
destroyCropper();
|
||||||
showStep('select');
|
showStep('select');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
|
resetVideoProgress();
|
||||||
modal?.hide();
|
modal?.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,6 +1092,9 @@
|
|||||||
setEnabled(urlInput, t === 'webpage');
|
setEnabled(urlInput, t === 'webpage');
|
||||||
setEnabled(youtubeUrlInput, t === 'youtube');
|
setEnabled(youtubeUrlInput, t === 'youtube');
|
||||||
|
|
||||||
|
// Crop mode only applies to images
|
||||||
|
setEnabled(cropModeHidden, t === 'image');
|
||||||
|
|
||||||
if (t === 'webpage') {
|
if (t === 'webpage') {
|
||||||
// Keep preview behavior
|
// Keep preview behavior
|
||||||
schedulePreview();
|
schedulePreview();
|
||||||
@@ -661,8 +1108,10 @@
|
|||||||
|
|
||||||
// Set initial state
|
// Set initial state
|
||||||
setType('image');
|
setType('image');
|
||||||
|
if (cropModeHidden) cropModeHidden.value = '16:9';
|
||||||
showStep('select');
|
showStep('select');
|
||||||
syncEnabledInputs();
|
syncEnabledInputs();
|
||||||
|
updateCropHint();
|
||||||
|
|
||||||
// Modal open
|
// Modal open
|
||||||
openBtn?.addEventListener('click', () => {
|
openBtn?.addEventListener('click', () => {
|
||||||
@@ -678,6 +1127,43 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Crop mode selection
|
||||||
|
function setCropMode(mode) {
|
||||||
|
if (!cropModeHidden) return;
|
||||||
|
cropModeHidden.value = mode;
|
||||||
|
|
||||||
|
// If cropper exists, update aspect ratio or destroy it
|
||||||
|
const cm = currentCropMode();
|
||||||
|
if (typeHidden.value !== 'image') return;
|
||||||
|
if (!cropImg?.src) {
|
||||||
|
updateCropHint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure cropper state matches selection
|
||||||
|
if (cm === 'none') {
|
||||||
|
destroyCropper();
|
||||||
|
// Re-load the selected file into the preview without cropper.
|
||||||
|
const f = fileInput?.files?.[0];
|
||||||
|
if (f) loadImageFile(f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cropper) {
|
||||||
|
cropper.setAspectRatio((cm === '9:16') ? (9 / 16) : (16 / 9));
|
||||||
|
updateCropHint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cropper yet (e.g. user changed crop mode after selecting file but before cropper init)
|
||||||
|
const f = fileInput?.files?.[0];
|
||||||
|
if (f) loadImageFile(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('crop-16-9')?.addEventListener('change', () => setCropMode('16:9'));
|
||||||
|
document.getElementById('crop-9-16')?.addEventListener('change', () => setCropMode('9:16'));
|
||||||
|
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
|
||||||
|
|
||||||
// Whenever type changes, keep enabled inputs in sync
|
// Whenever type changes, keep enabled inputs in sync
|
||||||
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => {
|
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => {
|
||||||
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
|
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
|
||||||
@@ -743,7 +1229,7 @@
|
|||||||
<strong>#${i.position}</strong>
|
<strong>#${i.position}</strong>
|
||||||
${badge}
|
${badge}
|
||||||
</div>
|
</div>
|
||||||
${safeTitle ? `<div class="small">${safeTitle}</div>` : ''}
|
${safeTitle ? `<div class="small">${safeTitle}</div>` : `<div class="small">.</div>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="${deleteAction}" onsubmit="return confirm('Delete item?');">
|
<form method="post" action="${deleteAction}" onsubmit="return confirm('Delete item?');">
|
||||||
@@ -751,11 +1237,11 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<div class="thumb">${thumb}</div>
|
||||||
<div class="text-muted small d-flex align-items-center gap-2 flex-wrap">
|
<div class="text-muted small d-flex align-items-center gap-2 flex-wrap">
|
||||||
<!-- Intentionally do NOT show file names or URLs for privacy/clean UI -->
|
<!-- Intentionally do NOT show file names or URLs for privacy/clean UI -->
|
||||||
${durationInput}
|
${durationInput}
|
||||||
</div>
|
</div>
|
||||||
<div class="thumb">${thumb}</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,115 @@
|
|||||||
<style>
|
<style>
|
||||||
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
||||||
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
||||||
|
|
||||||
|
/* Slide transitions (applied by JS via classes) */
|
||||||
|
#stage .slide {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#stage .slide.enter {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
#stage.transition-none .slide.enter {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
#stage.transition-fade .slide {
|
||||||
|
transition: opacity 420ms ease;
|
||||||
|
}
|
||||||
|
#stage.transition-fade .slide.enter {
|
||||||
|
opacity: 0;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
#stage.transition-fade .slide.enter.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
#stage.transition-fade .slide.exit {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 420ms ease;
|
||||||
|
}
|
||||||
|
#stage.transition-fade .slide.exit.active {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage.transition-slide .slide {
|
||||||
|
transition: transform 420ms ease, opacity 420ms ease;
|
||||||
|
}
|
||||||
|
#stage.transition-slide .slide.enter {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(48px);
|
||||||
|
}
|
||||||
|
#stage.transition-slide .slide.enter.active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
#stage.transition-slide .slide.exit {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
#stage.transition-slide .slide.exit.active {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-48px);
|
||||||
|
}
|
||||||
|
#notice {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.86);
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||||
|
z-index: 10;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#notice .box {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
#notice .title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
#notice .msg {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
|
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
|
||||||
.notice { position: fixed; left: 12px; bottom: 12px; color: #bbb; font: 14px/1.3 sans-serif; }
|
/* removed bottom-left status text */
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="notice" role="alert" aria-live="assertive">
|
||||||
|
<div class="box">
|
||||||
|
<p class="title" id="noticeTitle">Notice</p>
|
||||||
|
<p class="msg" id="noticeText"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="stage"></div>
|
<div id="stage"></div>
|
||||||
<div class="notice" id="notice"></div>
|
|
||||||
<script>
|
<script>
|
||||||
const token = "{{ display.token }}";
|
const token = "{{ display.token }}";
|
||||||
const stage = document.getElementById('stage');
|
const stage = document.getElementById('stage');
|
||||||
const notice = document.getElementById('notice');
|
const noticeEl = document.getElementById('notice');
|
||||||
|
const noticeTitleEl = document.getElementById('noticeTitle');
|
||||||
|
const noticeTextEl = document.getElementById('noticeText');
|
||||||
|
function setNotice(text, { title } = {}) {
|
||||||
|
const t = (text || '').trim();
|
||||||
|
if (!t) {
|
||||||
|
noticeEl.style.display = 'none';
|
||||||
|
noticeTextEl.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noticeTitleEl.textContent = title || 'Notice';
|
||||||
|
noticeTextEl.textContent = t;
|
||||||
|
noticeEl.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
|
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
|
||||||
|
|
||||||
@@ -39,6 +137,18 @@
|
|||||||
let idx = 0;
|
let idx = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
|
||||||
|
const ANIM_MS = 420;
|
||||||
|
|
||||||
|
function getTransitionMode(pl) {
|
||||||
|
const v = (pl && pl.transition ? String(pl.transition) : 'none').toLowerCase();
|
||||||
|
return (v === 'fade' || v === 'slide' || v === 'none') ? v : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransitionClass(mode) {
|
||||||
|
stage.classList.remove('transition-none', 'transition-fade', 'transition-slide');
|
||||||
|
stage.classList.add(`transition-${mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPlaylist() {
|
async function fetchPlaylist() {
|
||||||
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
||||||
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
|
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
|
||||||
@@ -54,24 +164,11 @@
|
|||||||
stage.innerHTML = '';
|
stage.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function next() {
|
function setSlideContent(container, item) {
|
||||||
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
|
||||||
notice.textContent = 'No playlist assigned.';
|
|
||||||
clearStage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = playlist.items[idx % playlist.items.length];
|
|
||||||
idx = (idx + 1) % playlist.items.length;
|
|
||||||
|
|
||||||
clearStage();
|
|
||||||
notice.textContent = playlist.playlist ? `${playlist.display} — ${playlist.playlist.name}` : playlist.display;
|
|
||||||
|
|
||||||
if (item.type === 'image') {
|
if (item.type === 'image') {
|
||||||
const el = document.createElement('img');
|
const el = document.createElement('img');
|
||||||
el.src = item.src;
|
el.src = item.src;
|
||||||
stage.appendChild(el);
|
container.appendChild(el);
|
||||||
timer = setTimeout(next, (item.duration || 10) * 1000);
|
|
||||||
} else if (item.type === 'video') {
|
} else if (item.type === 'video') {
|
||||||
const el = document.createElement('video');
|
const el = document.createElement('video');
|
||||||
el.src = item.src;
|
el.src = item.src;
|
||||||
@@ -79,12 +176,11 @@
|
|||||||
el.muted = true;
|
el.muted = true;
|
||||||
el.playsInline = true;
|
el.playsInline = true;
|
||||||
el.onended = next;
|
el.onended = next;
|
||||||
stage.appendChild(el);
|
container.appendChild(el);
|
||||||
} else if (item.type === 'webpage') {
|
} else if (item.type === 'webpage') {
|
||||||
const el = document.createElement('iframe');
|
const el = document.createElement('iframe');
|
||||||
el.src = item.url;
|
el.src = item.url;
|
||||||
stage.appendChild(el);
|
container.appendChild(el);
|
||||||
timer = setTimeout(next, (item.duration || 10) * 1000);
|
|
||||||
} else if (item.type === 'youtube') {
|
} else if (item.type === 'youtube') {
|
||||||
const el = document.createElement('iframe');
|
const el = document.createElement('iframe');
|
||||||
// item.url is a base embed URL produced server-side (https://www.youtube-nocookie.com/embed/<id>)
|
// item.url is a base embed URL produced server-side (https://www.youtube-nocookie.com/embed/<id>)
|
||||||
@@ -92,8 +188,77 @@
|
|||||||
const u = item.url || '';
|
const u = item.url || '';
|
||||||
const sep = u.includes('?') ? '&' : '?';
|
const sep = u.includes('?') ? '&' : '?';
|
||||||
el.src = `${u}${sep}autoplay=1&mute=1&controls=0&rel=0&playsinline=1`;
|
el.src = `${u}${sep}autoplay=1&mute=1&controls=0&rel=0&playsinline=1`;
|
||||||
stage.appendChild(el);
|
container.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showItemWithTransition(item) {
|
||||||
|
const mode = getTransitionMode(playlist);
|
||||||
|
applyTransitionClass(mode);
|
||||||
|
|
||||||
|
// Create new slide container.
|
||||||
|
const slide = document.createElement('div');
|
||||||
|
slide.className = 'slide enter';
|
||||||
|
setSlideContent(slide, item);
|
||||||
|
|
||||||
|
// Determine previous slide (if any).
|
||||||
|
const prev = stage.querySelector('.slide');
|
||||||
|
|
||||||
|
// First render: no animation needed.
|
||||||
|
if (!prev || mode === 'none') {
|
||||||
|
stage.innerHTML = '';
|
||||||
|
slide.classList.remove('enter');
|
||||||
|
stage.appendChild(slide);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition: keep both on stage and animate.
|
||||||
|
stage.appendChild(slide);
|
||||||
|
|
||||||
|
// Trigger transition.
|
||||||
|
// In some browsers the style changes can get coalesced into a single paint (no animation),
|
||||||
|
// especially on fast/fullscreen pages. We force a layout read before activating.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
prev.classList.add('exit');
|
||||||
|
// Force reflow so the browser commits initial (enter) styles.
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
slide.offsetHeight;
|
||||||
|
slide.classList.add('active');
|
||||||
|
prev.classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup after animation.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (prev && prev.parentNode === stage) stage.removeChild(prev);
|
||||||
|
slide.classList.remove('enter');
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
|
}, ANIM_MS + 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
||||||
|
setNotice('No playlists assigned.');
|
||||||
|
clearStage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = playlist.items[idx % playlist.items.length];
|
||||||
|
idx = (idx + 1) % playlist.items.length;
|
||||||
|
|
||||||
|
// Clear any active timers (but keep DOM for transition).
|
||||||
|
if (timer) { clearTimeout(timer); timer = null; }
|
||||||
|
setNotice('');
|
||||||
|
|
||||||
|
showItemWithTransition(item);
|
||||||
|
|
||||||
|
if (item.type === 'image') {
|
||||||
|
timer = setTimeout(next, (item.duration || 10) * 1000);
|
||||||
|
} else if (item.type === 'video') {
|
||||||
|
// next() is called on video end.
|
||||||
|
} else if (item.type === 'webpage') {
|
||||||
|
timer = setTimeout(next, (item.duration || 10) * 1000);
|
||||||
|
} else if (item.type === 'youtube') {
|
||||||
// YouTube iframes don't reliably emit an "ended" event without the JS API.
|
// YouTube iframes don't reliably emit an "ended" event without the JS API.
|
||||||
// We keep it simple: play for the configured duration (default 30s).
|
// We keep it simple: play for the configured duration (default 30s).
|
||||||
timer = setTimeout(next, (item.duration || 30) * 1000);
|
timer = setTimeout(next, (item.duration || 30) * 1000);
|
||||||
@@ -106,25 +271,58 @@
|
|||||||
try {
|
try {
|
||||||
playlist = await fetchPlaylist();
|
playlist = await fetchPlaylist();
|
||||||
idx = 0;
|
idx = 0;
|
||||||
|
applyTransitionClass(getTransitionMode(playlist));
|
||||||
next();
|
next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearStage();
|
clearStage();
|
||||||
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
|
if (e && e.code === 'LIMIT') {
|
||||||
|
setNotice(
|
||||||
|
(e && e.message) ? e.message : 'This display cannot start because the concurrent display limit has been reached.',
|
||||||
|
{ title: 'Display limit reached' }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setNotice(e && e.message ? e.message : 'Unable to load playlist.', { title: 'Playback error' });
|
||||||
|
}
|
||||||
// keep retrying; if a slot frees up the display will start automatically.
|
// keep retrying; if a slot frees up the display will start automatically.
|
||||||
}
|
}
|
||||||
// refresh playlist every 60s
|
|
||||||
|
// Poll for updates instead of SSE.
|
||||||
|
// This scales better for 100s of displays because it avoids long-lived HTTP
|
||||||
|
// connections (which otherwise tie up gunicorn sync workers).
|
||||||
|
// Default: check every 20s (can be overridden via ?poll=seconds).
|
||||||
|
const pollParam = parseInt(new URLSearchParams(window.location.search).get('poll') || '', 10);
|
||||||
|
const pollSeconds = Number.isFinite(pollParam) && pollParam > 0 ? pollParam : 20;
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
playlist = await fetchPlaylist();
|
const newPlaylist = await fetchPlaylist();
|
||||||
|
|
||||||
|
// Restart if something changed.
|
||||||
|
const oldStr = JSON.stringify(playlist);
|
||||||
|
const newStr = JSON.stringify(newPlaylist);
|
||||||
|
playlist = newPlaylist;
|
||||||
|
if (oldStr !== newStr) {
|
||||||
|
idx = 0;
|
||||||
|
applyTransitionClass(getTransitionMode(playlist));
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If player is blank (e.g. after a temporary error), kick it.
|
||||||
if (!stage.firstChild) {
|
if (!stage.firstChild) {
|
||||||
idx = 0;
|
idx = 0;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
clearStage();
|
clearStage();
|
||||||
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
|
if (e && e.code === 'LIMIT') {
|
||||||
|
setNotice(
|
||||||
|
(e && e.message) ? e.message : 'This display cannot start because the concurrent display limit has been reached.',
|
||||||
|
{ title: 'Display limit reached' }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setNotice(e && e.message ? e.message : 'Unable to load playlist.', { title: 'Playback error' });
|
||||||
}
|
}
|
||||||
}, 60000);
|
}
|
||||||
|
}, pollSeconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
start();
|
start();
|
||||||
|
|||||||
150
app/uploads.py
Normal file
150
app/uploads.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_company_segment(company_id: int | None) -> str:
|
||||||
|
"""Return the directory name for a company's upload folder.
|
||||||
|
|
||||||
|
We intentionally use the numeric company_id (not company name) to avoid
|
||||||
|
rename issues and any path traversal concerns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid = int(company_id) if company_id is not None else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cid = 0
|
||||||
|
return str(max(0, cid))
|
||||||
|
|
||||||
|
|
||||||
|
def get_company_upload_dir(upload_root: str, company_id: int | None) -> str:
|
||||||
|
"""Return absolute directory path for a company's uploads."""
|
||||||
|
|
||||||
|
return os.path.join(upload_root, _safe_company_segment(company_id))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_company_upload_dir(upload_root: str, company_id: int | None) -> str:
|
||||||
|
"""Ensure the company's upload directory exists; return its absolute path."""
|
||||||
|
|
||||||
|
d = get_company_upload_dir(upload_root, company_id)
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def get_company_upload_bytes(upload_root: str, company_id: int | None) -> int:
|
||||||
|
"""Return best-effort total bytes used by a company's upload directory.
|
||||||
|
|
||||||
|
This walks the directory tree under uploads/<company_id> and sums file sizes.
|
||||||
|
Any errors (missing directories, permission issues, broken links) are ignored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
root = get_company_upload_dir(upload_root, company_id)
|
||||||
|
try:
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for base, _dirs, files in os.walk(root):
|
||||||
|
for fn in files:
|
||||||
|
try:
|
||||||
|
p = os.path.join(base, fn)
|
||||||
|
if os.path.isfile(p):
|
||||||
|
total += os.path.getsize(p)
|
||||||
|
except Exception:
|
||||||
|
# Ignore unreadable files
|
||||||
|
continue
|
||||||
|
return int(total)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_storage_usage(*, used_bytes: int, max_bytes: int | None):
|
||||||
|
"""Compute storage usage info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
used_bytes: current usage
|
||||||
|
max_bytes: quota; if None or <=0: unlimited
|
||||||
|
|
||||||
|
Returns dict:
|
||||||
|
{
|
||||||
|
"max_bytes": int|None,
|
||||||
|
"used_bytes": int,
|
||||||
|
"is_limited": bool,
|
||||||
|
"is_exceeded": bool,
|
||||||
|
"used_ratio": float|None, # 0..1 when limited
|
||||||
|
"used_percent": int|None, # rounded percent when limited
|
||||||
|
"remaining_bytes": int|None,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
used = max(0, int(used_bytes or 0))
|
||||||
|
mx = None if max_bytes is None else int(max_bytes)
|
||||||
|
if mx is None or mx <= 0:
|
||||||
|
return {
|
||||||
|
"max_bytes": None,
|
||||||
|
"used_bytes": used,
|
||||||
|
"is_limited": False,
|
||||||
|
"is_exceeded": False,
|
||||||
|
"used_ratio": None,
|
||||||
|
"used_percent": None,
|
||||||
|
"remaining_bytes": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
ratio = used / mx if mx > 0 else 1.0
|
||||||
|
percent = int(round(ratio * 100.0))
|
||||||
|
return {
|
||||||
|
"max_bytes": mx,
|
||||||
|
"used_bytes": used,
|
||||||
|
"is_limited": True,
|
||||||
|
"is_exceeded": used >= mx,
|
||||||
|
# Keep percent un-clamped so the UI can show e.g. 132% when exceeded.
|
||||||
|
# Clamp only the ratio (for progress bars, etc.).
|
||||||
|
"used_ratio": max(0.0, min(1.0, ratio)),
|
||||||
|
"used_percent": max(0, percent),
|
||||||
|
"remaining_bytes": max(0, mx - used),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_upload_relpath(file_path: str | None) -> bool:
|
||||||
|
"""True if file_path looks like a path we manage under /static.
|
||||||
|
|
||||||
|
Supports both layouts:
|
||||||
|
- uploads/<filename>
|
||||||
|
- uploads/<company_id>/<filename>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return False
|
||||||
|
fp = (file_path or "").replace("\\", "/")
|
||||||
|
if not fp.startswith("uploads/"):
|
||||||
|
return False
|
||||||
|
# Prevent weird absolute/relative tricks; we only allow a normal relative path.
|
||||||
|
if fp.startswith("uploads//") or ":" in fp:
|
||||||
|
return False
|
||||||
|
# No parent dir segments.
|
||||||
|
if "../" in fp or fp.endswith("/..") or fp.startswith("../"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def abs_upload_path(upload_root: str, file_path: str | None) -> str | None:
|
||||||
|
"""Resolve an item.file_path (uploads/...) to an absolute file path.
|
||||||
|
|
||||||
|
Returns None if file_path is not a managed uploads path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not is_valid_upload_relpath(file_path):
|
||||||
|
return None
|
||||||
|
rel = (file_path or "").replace("\\", "/")
|
||||||
|
rel = rel.split("uploads/", 1)[1]
|
||||||
|
|
||||||
|
# Split into segments and harden against traversal.
|
||||||
|
parts = [p for p in rel.split("/") if p]
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidate = os.path.abspath(os.path.join(upload_root, *parts))
|
||||||
|
root_abs = os.path.abspath(upload_root)
|
||||||
|
|
||||||
|
# Ensure resolved path stays inside upload_root.
|
||||||
|
if os.path.commonpath([candidate, root_abs]) != root_abs:
|
||||||
|
return None
|
||||||
|
return candidate
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
image: signage:latest
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
# Override in a .env file or your shell; this default is only for convenience.
|
||||||
|
SECRET_KEY: "change-me"
|
||||||
|
# Optional bootstrap (only runs if ADMIN_PASS is set)
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@admin.admin}
|
||||||
|
ADMIN_PASS: ${ADMIN_PASS:-}
|
||||||
|
# Optional overrides (the Dockerfile already defaults these)
|
||||||
|
GUNICORN_WORKERS: "2"
|
||||||
|
GUNICORN_BIND: "0.0.0.0:8000"
|
||||||
|
# Entrypoint (from Dockerfile) runs:
|
||||||
|
# - `flask ensure-db`
|
||||||
|
# - optional `flask init-db` when ADMIN_PASS is set
|
||||||
|
# - gunicorn
|
||||||
|
volumes:
|
||||||
|
# Persist SQLite DB and uploads on the host
|
||||||
|
- data/fossign/instance:/app/instance
|
||||||
|
- data/fossign/uploads:/app/app/static/uploads
|
||||||
12
docker/entrypoint.sh
Normal file
12
docker/entrypoint.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Ensure DB schema exists
|
||||||
|
flask --app app ensure-db
|
||||||
|
|
||||||
|
# Optional: create/update initial admin account
|
||||||
|
if [ -n "${ADMIN_PASS:-}" ]; then
|
||||||
|
flask --app app init-db --admin-email "${ADMIN_EMAIL:-admin@admin.admin}" --admin-pass "${ADMIN_PASS}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec gunicorn -w "${GUNICORN_WORKERS:-2}" -b "${GUNICORN_BIND:-0.0.0.0:8000}" wsgi:app
|
||||||
54
scripts/display_session_limit_test.py
Normal file
54
scripts/display_session_limit_test.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure repo root is on sys.path when running as a script.
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Company, Display
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
# Create a company + display
|
||||||
|
c = Company(name="TestCo_DisplayLimit")
|
||||||
|
db.session.add(c)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
d = Display(company_id=c.id, name="Lobby")
|
||||||
|
db.session.add(d)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
token = d.token
|
||||||
|
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
def hit(sid: str):
|
||||||
|
return client.get(f"/api/display/{token}/playlist?sid={sid}")
|
||||||
|
|
||||||
|
# First 3 should be accepted (200 with JSON)
|
||||||
|
for sid in ("s1", "s2", "s3"):
|
||||||
|
r = hit(sid)
|
||||||
|
assert r.status_code == 200, (sid, r.status_code, r.data)
|
||||||
|
|
||||||
|
# 4th should be rejected with 429 and a clear message
|
||||||
|
r4 = hit("s4")
|
||||||
|
assert r4.status_code == 429, (r4.status_code, r4.data)
|
||||||
|
payload = r4.get_json(silent=True) or {}
|
||||||
|
assert payload.get("error") == "display_limit_reached", payload
|
||||||
|
msg = payload.get("message") or ""
|
||||||
|
assert "open on 3" in msg, msg
|
||||||
|
|
||||||
|
print("OK: display session limit allows 3 sessions; 4th is rejected with 429.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -20,13 +20,18 @@ def main():
|
|||||||
|
|
||||||
required = {
|
required = {
|
||||||
"/admin/companies/<int:company_id>/delete",
|
"/admin/companies/<int:company_id>/delete",
|
||||||
|
"/admin/displays/<int:display_id>/delete",
|
||||||
"/admin/displays/<int:display_id>/name",
|
"/admin/displays/<int:display_id>/name",
|
||||||
|
"/admin/settings",
|
||||||
"/company/displays/<int:display_id>",
|
"/company/displays/<int:display_id>",
|
||||||
"/company/items/<int:item_id>/duration",
|
"/company/items/<int:item_id>/duration",
|
||||||
"/company/playlists/<int:playlist_id>/items/reorder",
|
"/company/playlists/<int:playlist_id>/items/reorder",
|
||||||
"/auth/change-password",
|
"/auth/change-password",
|
||||||
"/auth/forgot-password",
|
"/auth/forgot-password",
|
||||||
"/auth/reset-password/<token>",
|
"/auth/reset-password/<token>",
|
||||||
|
"/company/my-company",
|
||||||
|
"/company/my-company/invite",
|
||||||
|
"/company/my-company/users/<int:user_id>/delete",
|
||||||
}
|
}
|
||||||
missing = sorted(required.difference(rules))
|
missing = sorted(required.difference(rules))
|
||||||
if missing:
|
if missing:
|
||||||
|
|||||||
93
scripts/storage_quota_test.py
Normal file
93
scripts/storage_quota_test.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure repo root is on sys.path when running as a script.
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Company, Playlist, User
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Use a temporary SQLite DB so this doesn't touch your real instance DB.
|
||||||
|
fd, path = tempfile.mkstemp(prefix="rssfeed-test-", suffix=".sqlite")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
os.environ["DATABASE_URL"] = f"sqlite:///{path}"
|
||||||
|
os.environ["SECRET_KEY"] = "test-secret"
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
c = Company(name="TestCo")
|
||||||
|
# 1 byte quota so any non-empty upload should be rejected.
|
||||||
|
c.storage_max_bytes = 1
|
||||||
|
db.session.add(c)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
u = User(username="test@example.com", email="test@example.com", is_admin=False, company_id=c.id)
|
||||||
|
u.set_password("passw0rd123")
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
p = Playlist(company_id=c.id, name="Playlist")
|
||||||
|
db.session.add(p)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
# Login
|
||||||
|
res = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"email": "test@example.com", "password": "passw0rd123"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
if res.status_code not in (302, 303):
|
||||||
|
raise SystemExit(f"Login failed: {res.status_code}")
|
||||||
|
|
||||||
|
# Try to upload a small image payload.
|
||||||
|
data = {
|
||||||
|
"item_type": "image",
|
||||||
|
"title": "Small",
|
||||||
|
"duration_seconds": "10",
|
||||||
|
"crop_mode": "none",
|
||||||
|
"response": "json",
|
||||||
|
# Not a real image; should fail processing OR quota depending on Pillow.
|
||||||
|
# Use a tiny valid PNG header so Pillow can parse it.
|
||||||
|
"file": (
|
||||||
|
io.BytesIO(
|
||||||
|
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x01\x01\x01\x00\x18\xdd\x8d\x18\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
|
),
|
||||||
|
"tiny.png",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
url = f"/company/playlists/{p.id}/items"
|
||||||
|
res = client.post(url, data=data, content_type="multipart/form-data")
|
||||||
|
|
||||||
|
if res.status_code != 403:
|
||||||
|
raise SystemExit(f"Expected 403 for quota exceeded, got: {res.status_code} {res.data!r}")
|
||||||
|
js = res.get_json(silent=True) or {}
|
||||||
|
if js.get("ok") is not False:
|
||||||
|
raise SystemExit(f"Unexpected response: {js}")
|
||||||
|
if "Storage limit" not in (js.get("error") or ""):
|
||||||
|
raise SystemExit(f"Unexpected error message: {js}")
|
||||||
|
|
||||||
|
print("OK: storage quota prevents uploads when exceeded")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
34
wsgi.py
Normal file
34
wsgi.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""WSGI entrypoint for production servers (gunicorn/uWSGI/etc.).
|
||||||
|
|
||||||
|
This file exposes a module-level WSGI callable named `app` (and `application`)
|
||||||
|
so common servers can run the project without relying on Flask's dev server.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app
|
||||||
|
uwsgi --http :8000 --wsgi-file wsgi.py --callable app
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# `flask run` loads .env/.flaskenv automatically via python-dotenv.
|
||||||
|
# Production WSGI servers typically *don't*, so we best-effort load `.env` here.
|
||||||
|
# In real production, prefer setting environment variables via your process manager.
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(os.environ.get("DOTENV_PATH", ".env"), override=False)
|
||||||
|
except Exception:
|
||||||
|
# If python-dotenv isn't installed (or any other issue occurs), continue.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
# Some servers (and hosting platforms) look specifically for `application`.
|
||||||
|
application = app
|
||||||
Reference in New Issue
Block a user