Compare commits

...

12 Commits

Author SHA1 Message Date
56760e380d Release v1.3 2026-01-25 15:57:38 +01:00
47aca9d64d Update playlist detail UI (priority/schedule/add-item) 2026-01-25 13:50:22 +01:00
f4b7fb62f5 Initial import 2026-01-25 13:26:45 +01:00
a5fe0f73a0 Update app 2026-01-25 12:03:08 +01:00
4df004c18a Scale display player by switching from SSE to polling 2026-01-24 19:59:00 +01:00
a9a1a6cdbe Add display deletion endpoint and admin UI tweaks 2026-01-24 19:28:25 +01:00
4d4ab086c9 Update app templates and routes 2026-01-24 10:09:33 +01:00
3684d98456 prodv1 2026-01-23 22:23:31 +01:00
f01de7a8e6 mail fix 2026-01-23 22:00:12 +01:00
97e17854b9 Update settings/admin UI and misc fixes 2026-01-23 21:21:56 +01:00
7f0092ff10 v1 2026-01-23 20:48:30 +01:00
ea3d0164f2 Add company dashboard improvements and upload/auth features 2026-01-23 20:21:11 +01:00
32 changed files with 4366 additions and 375 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.git/
.gitignore
.venv/
venv/
instance/
app/static/uploads/
.env
.flaskenv
*.sqlite
scripts/
README.md

View File

@@ -1,9 +1,9 @@
FLASK_APP=app:create_app
FLASK_DEBUG=1
SMTP_HOST=smtp.strato.de
SMTP_PORT=465
SMTP_USERNAME=beheer@alphen.cloud
SMTP_PASSWORD=Fr@nkrijk2024!
SMTP_FROM=beheer@alphen.cloud
SMTP_STARTTLS=1
SMTP_DEBUG=1
##SMTP_HOST=smtp.strato.de
##SMTP_PORT=465
##SMTP_USERNAME=beheer@alphen.cloud
##SMTP_PASSWORD=***
##SMTP_FROM=beheer@alphen.cloud
##SMTP_STARTTLS=1
##SMTP_DEBUG=1

33
Dockerfile Normal file
View 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
View File

@@ -33,15 +33,145 @@ flask run --debug
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'
```
Thats 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
- SQLite DB is stored at `instance/signage.sqlite`.
- 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
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.
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.
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
If the reset email is not received:
@@ -86,5 +221,16 @@ If the reset email is not received:

View File

@@ -3,8 +3,8 @@ from flask import Flask, jsonify, request
from werkzeug.exceptions import RequestEntityTooLarge
from .extensions import db, login_manager
from .models import User
from .cli import init_db_command
from .models import AppSettings, User
from .cli import ensure_db_command, init_db_command
def create_app():
@@ -56,6 +56,111 @@ def create_app():
if "description" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
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()
# Displays: per-display overlay toggle
if "show_overlay" not in display_cols:
db.session.execute(
db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0")
)
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()
# Companies: optional overlay file path
if "overlay_file_path" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
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:
db.session.rollback()
@@ -64,6 +169,7 @@ def create_app():
return db.session.get(User, int(user_id))
# CLI
app.cli.add_command(ensure_db_command)
app.cli.add_command(init_db_command)
# Blueprints

27
app/auth_tokens.py Normal file
View 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"))

View File

@@ -2,7 +2,67 @@ import click
from flask.cli import with_appcontext
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()
if "transition" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
db.session.commit()
if "show_overlay" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0"))
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()
if "overlay_file_path" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
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")
@@ -17,23 +77,7 @@ from .models import User
@with_appcontext
def init_db_command(admin_email: str, admin_pass: str):
"""Create tables and ensure an admin account exists."""
db.create_all()
# 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()
_ensure_schema_and_settings()
admin_email = (admin_email or "").strip().lower()
if not admin_email:

View File

@@ -3,10 +3,27 @@ import smtplib
from email.message import EmailMessage
def send_email(*, to_email: str, subject: str, body_text: str):
"""Send a plain-text email using SMTP settings from environment variables.
def _truthy(v: str | None) -> bool:
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_PORT
- 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
"""
# 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")
port = int(os.environ.get("SMTP_PORT", "587"))
port = int(os.environ.get("SMTP_PORT") or 587)
username = os.environ.get("SMTP_USERNAME")
password = os.environ.get("SMTP_PASSWORD")
from_email = os.environ.get("SMTP_FROM") or username
starttls = os.environ.get("SMTP_STARTTLS", "1").lower() in ("1", "true", "yes", "on")
timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS", "10"))
debug = os.environ.get("SMTP_DEBUG", "0").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") or 10)
debug = _truthy(os.environ.get("SMTP_DEBUG")) if os.environ.get("SMTP_DEBUG") is not None else False
missing = []
if not host:
missing.append("SMTP_HOST")
missing.append("host")
if not username:
missing.append("SMTP_USERNAME")
missing.append("username")
if not password:
missing.append("SMTP_PASSWORD")
missing.append("password")
if not from_email:
missing.append("SMTP_FROM")
missing.append("from_email")
if missing:
raise RuntimeError(
"Missing SMTP configuration: "
f"Missing SMTP configuration ({config_source}): "
+ ", ".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["From"] = from_email
msg["To"] = to_email
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)
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
if debug:
smtp.set_debuglevel(1)
print(f"[email_utils] Using SMTP config from: {config_source}")
smtp.ehlo()
if starttls:
smtp.starttls()
smtp.ehlo()
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])

View File

@@ -12,6 +12,14 @@ class Company(db.Model):
name = db.Column(db.String(120), unique=True, 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)
# Optional per-company 16:9 PNG overlay (stored under /static/uploads/...)
# Example: uploads/<company_id>/overlay_<uuid>.png
overlay_file_path = db.Column(db.String(400), nullable=True)
users = db.relationship("User", 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")
@@ -45,6 +53,18 @@ class Playlist(db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey("company.id"), 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)
company = db.relationship("Company", back_populates="playlists")
@@ -84,11 +104,31 @@ class Display(db.Model):
name = db.Column(db.String(120), nullable=False)
# Optional short description (e.g. "entrance", "office")
description = db.Column(db.String(200), nullable=True)
# Transition animation between slides: none|fade|slide
transition = db.Column(db.String(20), nullable=True)
# If true, show the company's overlay PNG on top of the display content.
show_overlay = db.Column(db.Boolean, default=False, nullable=False)
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 = 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)
company = db.relationship("Company", back_populates="displays")
@@ -111,3 +151,64 @@ class DisplaySession(db.Model):
display = db.relationship("Display")
__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,
)

View File

@@ -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 ..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")
@@ -16,15 +18,28 @@ def admin_required():
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):
"""Best-effort delete of an uploaded media file."""
if not file_path:
return
if not file_path.startswith("uploads/"):
if not is_valid_upload_relpath(file_path):
return
filename = file_path.split("/", 1)[1]
abs_path = os.path.join(upload_folder, filename)
abs_path = abs_upload_path(upload_folder, file_path)
if not abs_path:
return
try:
if os.path.isfile(abs_path):
os.remove(abs_path)
@@ -41,6 +56,188 @@ def dashboard():
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")
@login_required
def create_company():
@@ -55,6 +252,13 @@ def create_company():
c = Company(name=name)
db.session.add(c)
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")
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)
if not company:
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")
@@ -126,8 +375,12 @@ def delete_company(company_id: int):
for d in list(company.displays):
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]
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:
DisplaySession.query.filter(DisplaySession.display_id.in_(display_ids)).delete(synchronize_session=False)
@@ -142,6 +395,10 @@ def delete_company(company_id: int):
if it.item_type in ("image", "video"):
_try_delete_upload(it.file_path, upload_folder)
# 3b) Clean up uploaded overlay (if any)
if company.overlay_file_path:
_try_delete_upload(company.overlay_file_path, upload_folder)
# 4) Delete the company; cascades will delete users/displays/playlists/items.
company_name = company.name
db.session.delete(company)
@@ -193,6 +450,43 @@ def update_user_email(user_id: int):
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")
@login_required
def update_display_name(display_id: int):
@@ -212,3 +506,35 @@ def update_display_name(display_id: int):
db.session.commit()
flash("Display name updated", "success")
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))

View File

@@ -1,39 +1,61 @@
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 ..models import Display, DisplaySession
from ..models import Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem
from ..uploads import is_valid_upload_relpath
bp = Blueprint("api", __name__, url_prefix="/api")
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
SESSION_TTL_SECONDS = 90
@bp.get("/display/<token>/playlist")
def display_playlist(token: str):
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
"""Return True if playlist is active based on its optional schedule window."""
if p.schedule_start and now_utc < p.schedule_start:
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)
# Cleanup old sessions. Avoid committing if nothing was deleted (saves write locks on SQLite).
deleted = (
DisplaySession.query.filter(
DisplaySession.display_id == display.id,
DisplaySession.last_seen_at < cutoff,
).delete(synchronize_session=False)
)
if deleted:
db.session.commit()
existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first()
if existing:
existing.last_seen_at = datetime.utcnow()
db.session.commit()
else:
return True, None
active_count = (
DisplaySession.query.filter(
DisplaySession.display_id == display.id,
@@ -42,6 +64,8 @@ def display_playlist(token: str):
)
if active_count >= MAX_ACTIVE_SESSIONS_PER_DISPLAY:
return (
False,
(
jsonify(
{
"error": "display_limit_reached",
@@ -49,6 +73,7 @@ def display_playlist(token: str):
}
),
429,
),
)
s = DisplaySession(
@@ -60,15 +85,179 @@ def display_playlist(token: str):
)
db.session.add(s)
db.session.commit()
return True, None
playlist = display.assigned_playlist
if not playlist:
return jsonify({"display": display.name, "playlist": None, "items": []})
def _playlist_signature(display: Display) -> tuple[int | None, str]:
"""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)
# Optional overlay URL (per-company) when enabled on this display.
overlay_src = None
if display.show_overlay:
company = Company.query.filter_by(id=display.company_id).first()
if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
overlay_src = url_for("static", filename=company.overlay_file_path)
# 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",
"overlay_src": overlay_src,
"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 = []
for item in playlist.items:
for item in merged:
payload = {
"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,
"title": item.title,
"duration": item.duration_seconds,
@@ -82,7 +271,85 @@ def display_playlist(token: str):
return jsonify(
{
"display": display.name,
"playlist": {"id": playlist.id, "name": playlist.name},
"transition": display.transition or "none",
"overlay_src": overlay_src,
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
"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",
},
)

View File

@@ -7,30 +7,27 @@ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from ..extensions import db
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")
logger = logging.getLogger(__name__)
def _reset_serializer_v2() -> URLSafeTimedSerializer:
# Use Flask SECRET_KEY; fallback to app config via current_app.
# (defined as separate function to keep import cycle minimal)
def _make_reset_token(user: User) -> str:
from flask import current_app
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="password-reset")
def _make_reset_token(user: User) -> str:
s = _reset_serializer_v2()
return s.dumps({"user_id": user.id})
return make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=user.id)
def _load_reset_token(token: str, *, max_age_seconds: int) -> int:
s = _reset_serializer_v2()
data = s.loads(token, max_age=max_age_seconds)
user_id = int(data.get("user_id"))
return user_id
from flask import current_app
return load_password_reset_user_id(
secret_key=current_app.config["SECRET_KEY"],
token=token,
max_age_seconds=max_age_seconds,
)
@bp.get("/forgot-password")
@@ -51,6 +48,17 @@ def forgot_password_post():
user = User.query.filter_by(email=email).first()
if 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)
body = (
"Someone requested a password reset for your account.\n\n"

View File

@@ -2,19 +2,37 @@ import os
import uuid
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_login import current_user, login_required
from werkzeug.utils import secure_filename
from PIL import Image
from PIL import Image, ImageOps
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_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"}
# Overlay is a transparent PNG that sits on top of a display.
ALLOWED_OVERLAY_EXTENSIONS = {".png"}
# Keep overlay size reasonable; it will be stretched to fit anyway.
# (PNG overlays are typically small-ish; 10MB is generous.)
MAX_OVERLAY_BYTES = 10 * 1024 * 1024
# Videos should have a maximum upload size of 250MB
MAX_VIDEO_BYTES = 250 * 1024 * 1024
@@ -75,35 +93,88 @@ def _normalize_youtube_embed_url(raw: str) -> str | None:
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.
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)
"""
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)
# Respect EXIF orientation (common for phone photos)
img = ImageOps.exif_transpose(img)
# Normalize mode for webp
if img.mode not in ("RGB", "RGBA"):
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)
img.thumbnail((1920, 1080))
img.thumbnail(max_box)
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."""
if not file_path:
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
filename = file_path.split("/", 1)[1]
abs_path = os.path.join(upload_folder, filename)
try:
if os.path.isfile(abs_path):
os.remove(abs_path)
@@ -111,9 +182,104 @@ def _try_delete_upload(file_path: str | None, upload_folder: str):
# Ignore cleanup failures
pass
def _save_overlay_png(
uploaded_file,
upload_root: str,
company_id: int | None,
) -> str:
"""Save a company overlay as PNG under the company's upload dir.
Returns relative file path under /static (uploads/<company_id>/overlay_<uuid>.png)
"""
unique = f"overlay_{uuid.uuid4().hex}.png"
company_dir = ensure_company_upload_dir(upload_root, company_id)
save_path = os.path.join(company_dir, unique)
# Validate file is a PNG and is 16:9-ish.
# Use magic bytes (signature) instead of relying on Pillow's img.format,
# which can be unreliable if the stream position isn't at 0.
try:
if hasattr(uploaded_file, "stream"):
uploaded_file.stream.seek(0)
except Exception:
pass
try:
sig = uploaded_file.stream.read(8) if hasattr(uploaded_file, "stream") else uploaded_file.read(8)
except Exception:
sig = b""
# PNG file signature: 89 50 4E 47 0D 0A 1A 0A
if sig != b"\x89PNG\r\n\x1a\n":
raise ValueError("not_png")
# Rewind before Pillow parses.
try:
if hasattr(uploaded_file, "stream"):
uploaded_file.stream.seek(0)
except Exception:
pass
img = Image.open(uploaded_file)
img = ImageOps.exif_transpose(img)
w, h = img.size
if not w or not h:
raise ValueError("invalid")
# Allow some tolerance (overlays may include extra transparent padding).
aspect = w / h
target = 16 / 9
if abs(aspect - target) > 0.15: # ~15% tolerance
raise ValueError("not_16_9")
# Ensure we preserve alpha; normalize mode.
if img.mode not in ("RGBA", "LA"):
# Convert to RGBA so transparency is supported consistently.
img = img.convert("RGBA")
img.save(save_path, format="PNG", optimize=True)
company_seg = str(int(company_id)) if company_id is not None else "0"
return f"uploads/{company_seg}/{unique}"
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():
if not current_user.is_authenticated:
abort(403)
@@ -123,13 +289,280 @@ def company_user_required():
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()
overlay_url = None
if company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
overlay_url = url_for("static", filename=company.overlay_file_path)
return render_template(
"company/my_company.html",
company=company,
users=users,
overlay_url=overlay_url,
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/overlay")
@login_required
def upload_company_overlay():
"""Upload/replace the per-company 16:9 PNG overlay."""
company_user_required()
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
f = request.files.get("overlay")
if not f or not f.filename:
flash("Overlay file is required", "danger")
return redirect(url_for("company.my_company"))
filename = secure_filename(f.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_OVERLAY_EXTENSIONS:
flash("Unsupported overlay type. Please upload a PNG file.", "danger")
return redirect(url_for("company.my_company"))
# Enforce size limit best-effort.
size = None
try:
size = getattr(f, "content_length", None)
if (size is None or size <= 0) and hasattr(f, "stream"):
pos = f.stream.tell()
f.stream.seek(0, os.SEEK_END)
size = f.stream.tell()
f.stream.seek(pos, os.SEEK_SET)
except Exception:
size = None
if size is not None and size > MAX_OVERLAY_BYTES:
flash("Overlay file too large. Maximum allowed size is 10MB.", "danger")
return redirect(url_for("company.my_company"))
# Enforce storage quota too (overlay is stored in the same uploads folder).
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 usage.get("is_exceeded"):
flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger")
return redirect(url_for("company.my_company"))
old_path = company.overlay_file_path
try:
new_relpath = _save_overlay_png(f, upload_root, company.id)
except ValueError as e:
code = str(e)
if code == "not_png":
flash("Overlay must be a PNG file.", "danger")
elif code == "not_16_9":
flash("Overlay should be 16:9 (landscape).", "danger")
else:
flash("Failed to process overlay upload.", "danger")
return redirect(url_for("company.my_company"))
except Exception:
flash("Failed to process overlay upload.", "danger")
return redirect(url_for("company.my_company"))
# Post-save quota check (like images) because PNG size is unknown until saved.
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_delete_upload(new_relpath, upload_root)
flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger")
return redirect(url_for("company.my_company"))
company.overlay_file_path = new_relpath
db.session.commit()
# Clean up the old overlay file.
if old_path and old_path != new_relpath:
_try_delete_upload(old_path, upload_root)
flash("Overlay updated.", "success")
return redirect(url_for("company.my_company"))
@bp.post("/my-company/overlay/delete")
@login_required
def delete_company_overlay():
company_user_required()
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
upload_root = current_app.config["UPLOAD_FOLDER"]
old_path = company.overlay_file_path
company.overlay_file_path = None
db.session.commit()
_try_delete_upload(old_path, upload_root)
flash("Overlay removed.", "success")
return redirect(url_for("company.my_company"))
@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("/")
@login_required
def dashboard():
company_user_required()
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()
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")
@@ -154,7 +587,118 @@ def playlist_detail(playlist_id: int):
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
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")
@@ -170,6 +714,15 @@ def delete_playlist(playlist_id: int):
{"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
for it in list(playlist.items):
if it.item_type in ("image", "video"):
@@ -271,7 +824,29 @@ def add_playlist_item(playlist_id: int):
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 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")
if not f or not f.filename:
if wants_json:
@@ -283,6 +858,7 @@ def add_playlist_item(playlist_id: int):
ext = os.path.splitext(filename)[1].lower()
if item_type == "image":
crop_mode = (request.form.get("crop_mode") or "16:9").strip().lower()
if ext not in ALLOWED_IMAGE_EXTENSIONS:
if wants_json:
return _json_error(
@@ -291,7 +867,36 @@ def add_playlist_item(playlist_id: int):
flash("Unsupported image type", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
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:
if wants_json:
return _json_error("Failed to process image upload", 500)
@@ -330,9 +935,33 @@ def add_playlist_item(playlist_id: int):
# Keep as-is but always rename to a UUID.
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)
# 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.
# (Some clients/framework layers don't reliably report per-part size.)
try:
@@ -351,7 +980,7 @@ def add_playlist_item(playlist_id: int):
flash(msg, "danger")
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":
url = request.form.get("url", "").strip()
@@ -517,6 +1146,14 @@ def update_display(display_id: int):
def _json_error(message: str, status: int = 400):
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
payload = request.get_json(silent=True) if request.is_json else None
@@ -536,6 +1173,36 @@ def update_display(display_id: int):
desc = desc[:200]
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"))
# Overlay toggle
if request.is_json:
if payload is None:
return _json_error("Invalid JSON")
if "show_overlay" in payload:
raw = payload.get("show_overlay")
# Accept common truthy representations.
if isinstance(raw, bool):
display.show_overlay = raw
elif raw in (1, 0):
display.show_overlay = bool(raw)
else:
s = ("" if raw is None else str(raw)).strip().lower()
display.show_overlay = s in {"1", "true", "yes", "on"}
else:
# Form POST implies full update
raw = request.form.get("show_overlay")
if raw is not None:
display.show_overlay = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
# Playlist assignment
if request.is_json:
if "playlist_id" in payload:
@@ -575,6 +1242,8 @@ def update_display(display_id: int):
"id": display.id,
"name": display.name,
"description": display.description,
"transition": display.transition,
"show_overlay": bool(display.show_overlay),
"assigned_playlist_id": display.assigned_playlist_id,
},
}
@@ -582,3 +1251,87 @@ def update_display(display_id: int):
flash("Display updated", "success")
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,
},
}
)

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, abort, render_template
from flask import Blueprint, abort, render_template, url_for
from ..models import Display
from ..models import Company, Display
from ..uploads import is_valid_upload_relpath
bp = Blueprint("display", __name__, url_prefix="/display")
@@ -10,4 +11,11 @@ def display_player(token: str):
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
return render_template("display/player.html", display=display)
overlay_url = None
if display.show_overlay:
company = Company.query.filter_by(id=display.company_id).first()
if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
overlay_url = url_for("static", filename=company.overlay_file_path)
return render_template("display/player.html", display=display, overlay_url=overlay_url)

BIN
app/static/favicon.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

139
app/static/logo.svg Normal file
View 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

View File

@@ -56,6 +56,12 @@ body {
letter-spacing: -0.02em;
}
.brand-logo {
width: 160px;
height: 45px;
display: block;
}
.navbar-brand {
font-weight: 700;
}
@@ -284,3 +290,20 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
.toast {
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;
}

View File

@@ -45,7 +45,17 @@
<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-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>
<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>
</tr>
{% else %}
@@ -59,6 +69,38 @@
</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="col-12 col-lg-6">
<div class="card card-elevated">
@@ -93,9 +135,20 @@
<div>
<strong>{{ u.email or "(no email)" }}</strong>
</div>
<div class="d-flex gap-2">
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
<button class="btn btn-brand btn-sm" type="submit">Impersonate</button>
</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>
{% else %}
<div class="list-group-item text-muted">No users.</div>

View File

@@ -2,6 +2,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center">
<h1 class="page-title">Admin</h1>
<a class="btn btn-outline-ink" href="{{ url_for('admin.settings') }}">Settings</a>
</div>
<div class="row mt-4">

View 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 %}

View File

@@ -4,6 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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 rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
</head>
@@ -11,8 +13,14 @@
<nav class="navbar navbar-expand-lg navbar-light fixed-top app-navbar">
<div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
<span class="brand-mark" aria-hidden="true">S</span>
<span>Signage</span>
<img
class="brand-logo"
src="{{ url_for('static', filename='logo.svg') }}"
alt="Signage"
width="34"
height="34"
/>
</a>
<button
@@ -28,10 +36,23 @@
</button>
<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">
{% if current_user.is_authenticated %}
<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>
{% 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') %}
<a class="btn btn-brand btn-sm" href="{{ url_for('auth.stop_impersonation') }}">Stop impersonation</a>
{% endif %}

View File

@@ -24,14 +24,26 @@
<tbody>
{% for p in playlists %}
<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">
<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>
<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>
</td>
</tr>
@@ -59,6 +71,7 @@
<div class="display-preview">
<iframe
title="Preview — {{ d.name }}"
data-display-id="{{ d.id }}"
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
loading="lazy"
referrerpolicy="no-referrer"
@@ -74,27 +87,24 @@
</div>
<div class="d-flex flex-column gap-2 mt-auto">
<select
class="form-select form-select-sm js-playlist-select"
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">
{# Multi-playlist selector: button opens modal with playlist checkboxes #}
<div class="d-flex gap-2 align-items-center">
<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-name="{{ d.name }}"
data-current-desc="{{ d.description or '' }}"
data-current-transition="{{ d.transition or 'none' }}"
data-current-show-overlay="{{ '1' if d.show_overlay else '0' }}"
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>
<div class="small text-muted">
<span class="js-active-playlists-summary" data-display-id="{{ d.id }}"></span>
</div>
</div>
</div>
</div>
@@ -118,31 +128,70 @@
</div>
</div>
<!-- Edit description modal -->
<div class="modal fade" id="editDescModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<!-- Edit playlists modal -->
<div class="modal fade" id="editPlaylistsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<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>
</div>
<div class="modal-body">
<label class="form-label" for="editDescInput">Description</label>
<textarea class="form-control" id="editDescInput" maxlength="200" rows="3" placeholder="Optional description (max 200 chars)"></textarea>
<div class="form-text"><span id="editDescCount">0</span>/200</div>
<div class="mb-3">
<label class="form-label" for="editPlaylistsDescInput">Description</label>
<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>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="editPlaylistsShowOverlayCheck" />
<label class="form-check-label" for="editPlaylistsShowOverlayCheck">Show company overlay</label>
<div class="form-text">If your company has an overlay uploaded, it will be displayed on top of the content.</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 class="modal-footer">
<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>
{# 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 %}
{% block page_scripts %}
<script>
(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 toastBodyEl = document.getElementById('companyToastBody');
const toast = toastEl ? new bootstrap.Toast(toastEl, { delay: 2200 }) : null;
@@ -173,81 +222,216 @@
return data.display;
}
// Playlist auto-save
document.querySelectorAll('.js-playlist-select').forEach((sel) => {
sel.addEventListener('change', async () => {
const displayId = sel.dataset.displayId;
const playlistId = sel.value || null;
sel.disabled = true;
function refreshPreviewIframe(displayId) {
const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`);
if (!iframe || !iframe.src) return;
try {
await postDisplayUpdate(displayId, { playlist_id: playlistId });
showToast('Playlist saved', 'text-bg-success');
const u = new URL(iframe.src, window.location.origin);
// 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) {
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
} finally {
sel.disabled = false;
// Fallback: naive cache buster
const sep = iframe.src.includes('?') ? '&' : '?';
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
const modalEl = document.getElementById('editDescModal');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const titleEl = document.getElementById('editDescModalTitle');
const inputEl = document.getElementById('editDescInput');
const countEl = document.getElementById('editDescCount');
const saveBtn = document.getElementById('editDescSaveBtn');
let activeDisplayId = null;
function updateCount() {
if (!inputEl || !countEl) return;
countEl.textContent = String((inputEl.value || '').length);
async function postDisplayPlaylists(displayId, playlistIds) {
const res = await fetch(`/company/displays/${displayId}/playlists`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ playlist_ids: playlistIds })
});
const data = await res.json().catch(() => null);
if (!res.ok || !data || !data.ok) {
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');
const plShowOverlayEl = document.getElementById('editPlaylistsShowOverlayCheck');
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', () => {
activeDisplayId = btn.dataset.displayId;
activePlDisplayId = btn.dataset.displayId;
activePlButton = btn;
const displayName = btn.dataset.displayName || 'Display';
if (plTitleEl) plTitleEl.textContent = `Configure display — ${displayName}`;
const currentDesc = btn.dataset.currentDesc || '';
if (titleEl) titleEl.textContent = `Edit description — ${displayName}`;
if (inputEl) inputEl.value = currentDesc;
updateCount();
if (modal) modal.show();
if (plDescInputEl) plDescInputEl.value = currentDesc;
updatePlDescCount();
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none';
if (plShowOverlayEl) {
const raw = (btn.dataset.currentShowOverlay || '').toLowerCase();
plShowOverlayEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
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() {
if (!activeDisplayId || !inputEl) return;
const desc = (inputEl.value || '').trim();
saveBtn.disabled = true;
async function savePlaylists() {
if (!activePlDisplayId || !activePlButton || !plSaveBtn) return;
const ids = getSelectedPlaylistIdsFromModal();
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
const showOverlay = plShowOverlayEl ? !!plShowOverlayEl.checked : false;
plSaveBtn.disabled = true;
try {
const updated = await postDisplayUpdate(activeDisplayId, { description: desc });
// Update visible description
const descEl = document.querySelector(`.js-display-desc[data-display-id="${activeDisplayId}"]`);
if (descEl) descEl.textContent = updated.description ? updated.description : '—';
// Update button's stored value
const btn = document.querySelector(`.js-edit-desc[data-display-id="${activeDisplayId}"]`);
if (btn) btn.dataset.currentDesc = updated.description || '';
showToast('Description saved', 'text-bg-success');
if (modal) modal.hide();
const [updatedPlaylists, updatedDesc] = await Promise.all([
postDisplayPlaylists(activePlDisplayId, ids),
postDisplayUpdate(activePlDisplayId, { description: desc, transition, show_overlay: showOverlay })
]);
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
? updatedPlaylists.active_playlist_ids
: ids;
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';
const newShowOverlay = updatedDesc && typeof updatedDesc.show_overlay !== 'undefined'
? !!updatedDesc.show_overlay
: showOverlay;
activePlButton.dataset.currentShowOverlay = newShowOverlay ? '1' : '0';
showToast('Display updated', 'text-bg-success');
refreshPreviewIframe(activePlDisplayId);
if (plModal) plModal.hide();
} catch (e) {
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
} finally {
saveBtn.disabled = false;
plSaveBtn.disabled = false;
}
}
if (saveBtn) {
saveBtn.addEventListener('click', saveDescription);
}
if (modalEl) {
modalEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
saveDescription();
}
});
if (plSaveBtn) {
plSaveBtn.addEventListener('click', savePlaylists);
}
})();
</script>

View File

@@ -0,0 +1,185 @@
{% 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">Overlay</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Upload a <strong>16:9 PNG</strong> overlay. It will be rendered on top of the display content.
Transparent areas will show the content underneath.
</div>
{% if overlay_url %}
<div class="mb-3">
<div class="text-muted small mb-2">Current overlay:</div>
<div style="max-width: 520px; border: 1px solid rgba(0,0,0,0.15); border-radius: 8px; overflow: hidden;">
<img
src="{{ overlay_url }}"
alt="Company overlay"
style="display:block; width:100%; height:auto; background: repeating-linear-gradient(45deg, #eee 0 12px, #fff 12px 24px);"
/>
</div>
</div>
{% else %}
<div class="text-muted mb-3">No overlay uploaded.</div>
{% endif %}
<form method="post" action="{{ url_for('company.upload_company_overlay') }}" enctype="multipart/form-data" class="d-flex gap-2 flex-wrap align-items-end">
<div>
<label class="form-label">Upload overlay (PNG)</label>
<input class="form-control" type="file" name="overlay" accept="image/png" required />
<div class="form-text">Tip: export at 1920×1080 (or any 16:9 size).</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save overlay</button>
</div>
</form>
{% if overlay_url %}
<form method="post" action="{{ url_for('company.delete_company_overlay') }}" class="mt-3" onsubmit="return confirm('Remove the overlay?');">
<button class="btn btn-outline-danger" type="submit">Remove overlay</button>
</form>
{% endif %}
</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 %}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -7,17 +7,130 @@
<style>
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
/* Optional company overlay (transparent PNG) */
#overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 5;
object-fit: contain;
}
/* 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; }
.notice { position: fixed; left: 12px; bottom: 12px; color: #bbb; font: 14px/1.3 sans-serif; }
/* removed bottom-left status text */
</style>
</head>
<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 class="notice" id="notice"></div>
{% if overlay_url %}
<img id="overlay" src="{{ overlay_url }}" alt="Overlay" />
{% endif %}
<script>
const token = "{{ display.token }}";
const stage = document.getElementById('stage');
const notice = document.getElementById('notice');
let overlayEl = document.getElementById('overlay');
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';
@@ -39,6 +152,18 @@
let idx = 0;
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() {
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
@@ -54,24 +179,43 @@
stage.innerHTML = '';
}
function next() {
if (!playlist || !playlist.items || playlist.items.length === 0) {
notice.textContent = 'No playlist assigned.';
clearStage();
function setOverlaySrc(src) {
const val = (src || '').trim();
if (!val) {
if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl);
overlayEl = null;
return;
}
const item = playlist.items[idx % playlist.items.length];
idx = (idx + 1) % playlist.items.length;
if (!overlayEl) {
overlayEl = document.createElement('img');
overlayEl.id = 'overlay';
overlayEl.alt = 'Overlay';
document.body.appendChild(overlayEl);
}
clearStage();
notice.textContent = playlist.playlist ? `${playlist.display}${playlist.playlist.name}` : playlist.display;
// Cache-bust in preview mode so changes show up instantly.
if (isPreview) {
try {
const u = new URL(val, window.location.origin);
u.searchParams.set('_ts', String(Date.now()));
overlayEl.src = u.toString();
return;
} catch(e) {
// fallthrough
}
}
overlayEl.src = val;
}
// Initialize overlay from server-side render.
if (overlayEl && overlayEl.src) setOverlaySrc(overlayEl.src);
function setSlideContent(container, item) {
if (item.type === 'image') {
const el = document.createElement('img');
el.src = item.src;
stage.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000);
container.appendChild(el);
} else if (item.type === 'video') {
const el = document.createElement('video');
el.src = item.src;
@@ -79,12 +223,11 @@
el.muted = true;
el.playsInline = true;
el.onended = next;
stage.appendChild(el);
container.appendChild(el);
} else if (item.type === 'webpage') {
const el = document.createElement('iframe');
el.src = item.url;
stage.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000);
container.appendChild(el);
} else if (item.type === 'youtube') {
const el = document.createElement('iframe');
// item.url is a base embed URL produced server-side (https://www.youtube-nocookie.com/embed/<id>)
@@ -92,8 +235,77 @@
const u = item.url || '';
const sep = u.includes('?') ? '&' : '?';
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.
// We keep it simple: play for the configured duration (default 30s).
timer = setTimeout(next, (item.duration || 30) * 1000);
@@ -106,25 +318,60 @@
try {
playlist = await fetchPlaylist();
idx = 0;
applyTransitionClass(getTransitionMode(playlist));
setOverlaySrc(playlist && playlist.overlay_src);
next();
} catch (e) {
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.
}
// 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 () => {
try {
playlist = await fetchPlaylist();
const newPlaylist = await fetchPlaylist();
// Restart if something changed.
const oldStr = JSON.stringify(playlist);
const newStr = JSON.stringify(newPlaylist);
playlist = newPlaylist;
setOverlaySrc(playlist && playlist.overlay_src);
if (oldStr !== newStr) {
idx = 0;
applyTransitionClass(getTransitionMode(playlist));
next();
}
// If player is blank (e.g. after a temporary error), kick it.
if (!stage.firstChild) {
idx = 0;
next();
}
} catch(e) {
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();

150
app/uploads.py Normal file
View 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
View 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
View 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

View 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()

View File

@@ -20,13 +20,18 @@ def main():
required = {
"/admin/companies/<int:company_id>/delete",
"/admin/displays/<int:display_id>/delete",
"/admin/displays/<int:display_id>/name",
"/admin/settings",
"/company/displays/<int:display_id>",
"/company/items/<int:item_id>/duration",
"/company/playlists/<int:playlist_id>/items/reorder",
"/auth/change-password",
"/auth/forgot-password",
"/auth/reset-password/<token>",
"/company/my-company",
"/company/my-company/invite",
"/company/my-company/users/<int:user_id>/delete",
}
missing = sorted(required.difference(rules))
if missing:

View 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
View 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