Compare commits
12 Commits
1394ef6f67
...
v1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 56760e380d | |||
| 47aca9d64d | |||
| f4b7fb62f5 | |||
| a5fe0f73a0 | |||
| 4df004c18a | |||
| a9a1a6cdbe | |||
| 4d4ab086c9 | |||
| 3684d98456 | |||
| f01de7a8e6 | |||
| 97e17854b9 | |||
| 7f0092ff10 | |||
| ea3d0164f2 |
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
instance/
|
||||
app/static/uploads/
|
||||
|
||||
.env
|
||||
.flaskenv
|
||||
|
||||
*.sqlite
|
||||
|
||||
scripts/
|
||||
|
||||
README.md
|
||||
14
.flaskenv
14
.flaskenv
@@ -1,9 +1,9 @@
|
||||
FLASK_APP=app:create_app
|
||||
FLASK_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
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps (kept minimal). Pillow may need some libs; for most cases this is fine on slim.
|
||||
# If you hit Pillow build/runtime issues, consider adding: libjpeg62-turbo, zlib1g, etc.
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
&& pip install --no-cache-dir gunicorn
|
||||
|
||||
COPY . .
|
||||
|
||||
# Ensure entrypoint is executable
|
||||
RUN chmod +x docker/entrypoint.sh
|
||||
|
||||
# Create runtime dirs (also mountable as volumes)
|
||||
RUN mkdir -p instance app/static/uploads
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Default config (override at runtime)
|
||||
ENV FLASK_ENV=production \
|
||||
GUNICORN_WORKERS=2 \
|
||||
GUNICORN_BIND=0.0.0.0:8000
|
||||
|
||||
# Run via WSGI entrypoint
|
||||
CMD ["sh", "-c", "./docker/entrypoint.sh"]
|
||||
146
README.md
146
README.md
@@ -33,15 +33,145 @@ flask run --debug
|
||||
|
||||
Open http://127.0.0.1:5000
|
||||
|
||||
## Production (WSGI)
|
||||
|
||||
This repo includes a `wsgi.py` entrypoint for production WSGI servers.
|
||||
|
||||
### Important (Windows)
|
||||
|
||||
If you try to run Gunicorn directly on Windows you will see an error like:
|
||||
|
||||
```
|
||||
ModuleNotFoundError: No module named 'fcntl'
|
||||
```
|
||||
|
||||
That’s expected: **Gunicorn is Unix-only**. On Windows, run the app via:
|
||||
|
||||
- **Docker** (recommended) so Gunicorn runs inside a Linux container, or
|
||||
- **WSL2/Linux** (Gunicorn works), or
|
||||
- use a Windows-native WSGI server (e.g. Waitress) instead of Gunicorn.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# gunicorn (Linux)
|
||||
gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app
|
||||
|
||||
# uWSGI
|
||||
uwsgi --http :8000 --wsgi-file wsgi.py --callable app
|
||||
```
|
||||
|
||||
Note: unlike `flask run`, WSGI servers typically don't auto-load `.env` / `.flaskenv`.
|
||||
`wsgi.py` attempts to load `.env` (best-effort), but for real production you should set
|
||||
environment variables via your process manager / secrets.
|
||||
|
||||
## Docker
|
||||
|
||||
### Docker Compose (recommended)
|
||||
|
||||
This repo includes a `docker-compose.yml` for a one-command startup.
|
||||
|
||||
On first run, the container will ensure the SQLite schema exists.
|
||||
If you provide `ADMIN_PASS`, it will also create/update the initial admin user.
|
||||
|
||||
```powershell
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Create an admin on startup (recommended):
|
||||
|
||||
```powershell
|
||||
$env:ADMIN_EMAIL="you@example.com"
|
||||
$env:ADMIN_PASS="YourStrongPassword"
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Or put these in a `.env` file used by Compose.
|
||||
|
||||
Run in the background:
|
||||
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Stop:
|
||||
|
||||
```powershell
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Data persistence:
|
||||
|
||||
- SQLite DB is mounted to `./instance` on your host
|
||||
- uploads are mounted to `./app/static/uploads` on your host
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
docker build -t signage:latest .
|
||||
```
|
||||
|
||||
Run (with persistent SQLite DB + uploads):
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8000:8000 \
|
||||
-e SECRET_KEY="change-me" \
|
||||
-v %cd%/instance:/app/instance \
|
||||
-v %cd%/app/static/uploads:/app/app/static/uploads \
|
||||
signage:latest
|
||||
```
|
||||
|
||||
PowerShell variant (sometimes volume path quoting is easier):
|
||||
|
||||
```powershell
|
||||
docker run --rm -p 8000:8000 `
|
||||
-e SECRET_KEY="change-me" `
|
||||
-v "${PWD}/instance:/app/instance" `
|
||||
-v "${PWD}/app/static/uploads:/app/app/static/uploads" `
|
||||
signage:latest
|
||||
```
|
||||
|
||||
Then open: http://127.0.0.1:8000
|
||||
|
||||
Notes:
|
||||
|
||||
- The container starts with Gunicorn using `wsgi:app`.
|
||||
- You can override Gunicorn settings via env vars:
|
||||
- `GUNICORN_WORKERS` (default: 2)
|
||||
- `GUNICORN_BIND` (default: `0.0.0.0:8000`)
|
||||
|
||||
## Notes
|
||||
|
||||
- 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:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
110
app/__init__.py
110
app/__init__.py
@@ -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
27
app/auth_tokens.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Shared auth token helpers.
|
||||
|
||||
We keep password reset/invite token logic in one place so it can be used by:
|
||||
- the normal "forgot password" flow
|
||||
- company "invite user" flow
|
||||
|
||||
Tokens are signed with Flask SECRET_KEY and time-limited.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
|
||||
|
||||
def _serializer(secret_key: str) -> URLSafeTimedSerializer:
|
||||
return URLSafeTimedSerializer(secret_key, salt="password-reset")
|
||||
|
||||
|
||||
def make_password_reset_token(*, secret_key: str, user_id: int) -> str:
|
||||
s = _serializer(secret_key)
|
||||
return s.dumps({"user_id": int(user_id)})
|
||||
|
||||
|
||||
def load_password_reset_user_id(*, secret_key: str, token: str, max_age_seconds: int) -> int:
|
||||
s = _serializer(secret_key)
|
||||
data = s.loads(token, max_age=max_age_seconds)
|
||||
return int(data.get("user_id"))
|
||||
80
app/cli.py
80
app/cli.py
@@ -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:
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
host = os.environ.get("SMTP_HOST")
|
||||
port = int(os.environ.get("SMTP_PORT", "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")
|
||||
# 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") or 587)
|
||||
username = os.environ.get("SMTP_USERNAME")
|
||||
password = os.environ.get("SMTP_PASSWORD")
|
||||
from_email = os.environ.get("SMTP_FROM") or username
|
||||
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])
|
||||
|
||||
101
app/models.py
101
app/models.py
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,74 +1,263 @@
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
return True, None
|
||||
|
||||
active_count = (
|
||||
DisplaySession.query.filter(
|
||||
DisplaySession.display_id == display.id,
|
||||
DisplaySession.last_seen_at >= cutoff,
|
||||
).count()
|
||||
)
|
||||
if active_count >= MAX_ACTIVE_SESSIONS_PER_DISPLAY:
|
||||
return (
|
||||
False,
|
||||
(
|
||||
jsonify(
|
||||
{
|
||||
"error": "display_limit_reached",
|
||||
"message": f"This display URL is already open on {MAX_ACTIVE_SESSIONS_PER_DISPLAY} displays.",
|
||||
}
|
||||
),
|
||||
429,
|
||||
),
|
||||
)
|
||||
|
||||
s = DisplaySession(
|
||||
display_id=display.id,
|
||||
sid=sid,
|
||||
last_seen_at=datetime.utcnow(),
|
||||
ip=request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||
user_agent=(request.headers.get("User-Agent") or "")[:300],
|
||||
)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
return True, None
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Enforce: a display URL/token can be opened by max 2 concurrently active sessions.
|
||||
# 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") or "").strip()
|
||||
if sid:
|
||||
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS)
|
||||
DisplaySession.query.filter(
|
||||
DisplaySession.display_id == display.id,
|
||||
DisplaySession.last_seen_at < cutoff,
|
||||
).delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
sid = request.args.get("sid")
|
||||
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||
if not ok:
|
||||
return resp
|
||||
|
||||
existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first()
|
||||
if existing:
|
||||
existing.last_seen_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
# 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:
|
||||
active_count = (
|
||||
DisplaySession.query.filter(
|
||||
DisplaySession.display_id == display.id,
|
||||
DisplaySession.last_seen_at >= cutoff,
|
||||
).count()
|
||||
)
|
||||
if active_count >= MAX_ACTIVE_SESSIONS_PER_DISPLAY:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "display_limit_reached",
|
||||
"message": f"This display URL is already open on {MAX_ACTIVE_SESSIONS_PER_DISPLAY} displays.",
|
||||
}
|
||||
),
|
||||
429,
|
||||
)
|
||||
|
||||
s = DisplaySession(
|
||||
display_id=display.id,
|
||||
sid=sid,
|
||||
last_seen_at=datetime.utcnow(),
|
||||
ip=request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||
user_agent=(request.headers.get("User-Agent") or "")[:300],
|
||||
)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
|
||||
playlist = display.assigned_playlist
|
||||
if not playlist:
|
||||
return jsonify({"display": display.name, "playlist": None, "items": []})
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,7 +48,18 @@ def forgot_password_post():
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if user:
|
||||
token = _make_reset_token(user)
|
||||
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
||||
|
||||
# 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"
|
||||
f"Reset your password using this link (valid for 30 minutes):\n{reset_url}\n\n"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
BIN
app/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
139
app/static/logo.svg
Normal file
139
app/static/logo.svg
Normal file
@@ -0,0 +1,139 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="150.74998mm"
|
||||
height="46.903393mm"
|
||||
viewBox="0 0 150.74998 46.903393"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||
sodipodi:docname="opencast logo.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="0.78840488"
|
||||
inkscape:cx="397.00414"
|
||||
inkscape:cy="561.25984"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1129"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g35">
|
||||
<inkscape:page
|
||||
x="-3.5699601e-22"
|
||||
y="0"
|
||||
width="150.74998"
|
||||
height="46.903393"
|
||||
id="page2"
|
||||
margin="0"
|
||||
bleed="0" />
|
||||
</sodipodi:namedview>
|
||||
<defs
|
||||
id="defs1">
|
||||
<rect
|
||||
x="319.63272"
|
||||
y="230.84586"
|
||||
width="465.49686"
|
||||
height="96.397171"
|
||||
id="rect4" />
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter36"
|
||||
x="-0.16131238"
|
||||
y="-0.23838385"
|
||||
width="1.358472"
|
||||
height="1.5529181">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood35" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="3.000000"
|
||||
id="feGaussianBlur35" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.600000"
|
||||
dy="2.300000"
|
||||
id="feOffset35" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite35" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite36" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Laag 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-19.647457,-50.186441)">
|
||||
<g
|
||||
id="g35"
|
||||
style="filter:url(#filter36)"
|
||||
inkscape:export-filename="rssfeed\app\static\logo.svg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
transform="translate(-1.6779659,-0.67118643)">
|
||||
<ellipse
|
||||
style="fill:#ffe511;fill-opacity:1;stroke-width:0.212298"
|
||||
id="path1"
|
||||
cx="43.582642"
|
||||
cy="76.934746"
|
||||
rx="15.057219"
|
||||
ry="11.056598"
|
||||
inkscape:export-filename="rssfeed\app\static\logo.svg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" />
|
||||
<ellipse
|
||||
style="fill:#ffef6e;fill-opacity:1;stroke-width:0.212298"
|
||||
id="path2"
|
||||
cx="51.380131"
|
||||
cy="68.574883"
|
||||
rx="13.175065"
|
||||
ry="10.517252" />
|
||||
<ellipse
|
||||
style="fill:#fff5a3;fill-opacity:1;stroke-width:0.212298"
|
||||
id="path3"
|
||||
cx="57.833221"
|
||||
cy="78.148277"
|
||||
rx="15.326097"
|
||||
ry="10.112741" />
|
||||
</g>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,-4.5436437,1.7684327)"
|
||||
id="text3"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:74.6667px;font-family:Georgia;-inkscape-font-specification:'Georgia, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect4);display:inline;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.13386;stroke-dasharray:none;stroke-opacity:1"><tspan
|
||||
x="319.63281"
|
||||
y="298.21152"
|
||||
id="tspan2"><tspan
|
||||
style="stroke:#000000"
|
||||
id="tspan1">OpenSlide</tspan></tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -56,6 +56,12 @@ body {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('display.display_player', token=d.token) }}" target="_blank">Open</a>
|
||||
<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>
|
||||
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
|
||||
<button class="btn btn-brand btn-sm" type="submit">Impersonate</button>
|
||||
</form>
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
205
app/templates/admin/settings.html
Normal file
205
app/templates/admin/settings.html
Normal file
@@ -0,0 +1,205 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="page-title">Admin settings</h1>
|
||||
<a class="btn btn-outline-ink" href="{{ url_for('admin.dashboard') }}">Back</a>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4 g-3">
|
||||
<div class="col-lg-6">
|
||||
<div class="card card-elevated">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">SMTP settings</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('admin.update_smtp_settings') }}" class="vstack gap-3">
|
||||
<div>
|
||||
<label class="form-label">Host</label>
|
||||
<input class="form-control" name="smtp_host" value="{{ settings.smtp_host or '' }}" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input class="form-control" name="smtp_port" value="{{ settings.smtp_port or '' }}" placeholder="587" />
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">From address (optional)</label>
|
||||
<input class="form-control" name="smtp_from" value="{{ settings.smtp_from or '' }}" placeholder="no-reply@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Username</label>
|
||||
<input class="form-control" name="smtp_username" value="{{ settings.smtp_username or '' }}" placeholder="user@example.com" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Password</label>
|
||||
<input
|
||||
class="form-control"
|
||||
name="smtp_password"
|
||||
type="password"
|
||||
value=""
|
||||
placeholder="Leave empty to keep current password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="form-text">
|
||||
For security, the current password is never shown. Leave empty to keep it unchanged.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<input
|
||||
class="form-control"
|
||||
name="smtp_timeout_seconds"
|
||||
value="{{ settings.smtp_timeout_seconds }}"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
name="smtp_starttls"
|
||||
value="1"
|
||||
id="smtp_starttls"
|
||||
{% if settings.smtp_starttls %}checked{% endif %}
|
||||
/>
|
||||
<label class="form-check-label" for="smtp_starttls">Use STARTTLS</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
name="smtp_debug"
|
||||
value="1"
|
||||
id="smtp_debug"
|
||||
{% if settings.smtp_debug %}checked{% endif %}
|
||||
/>
|
||||
<label class="form-check-label" for="smtp_debug">Enable SMTP debug logging</label>
|
||||
<div class="form-text">Prints SMTP conversation to the Flask console (useful for troubleshooting).</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-brand" type="submit">Save SMTP settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-elevated mt-3">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">Send test email</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('admin.send_test_email') }}" class="vstack gap-2">
|
||||
<div>
|
||||
<label class="form-label">Recipient email</label>
|
||||
<input class="form-control" name="to_email" placeholder="you@example.com" required />
|
||||
<div class="form-text">Sends a short test email using the current SMTP configuration.</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-outline-ink" type="submit">Send test email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-elevated mt-3">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">Public domain</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('admin.update_public_domain') }}" class="vstack gap-2">
|
||||
<div>
|
||||
<label class="form-label">Domain used in emails</label>
|
||||
<input
|
||||
class="form-control"
|
||||
name="public_domain"
|
||||
value="{{ settings.public_domain or '' }}"
|
||||
placeholder="signage.example.com"
|
||||
/>
|
||||
<div class="form-text">
|
||||
Used to generate absolute links (like password reset). Do not include <code>http(s)://</code>.
|
||||
Leave empty to use the current request host.
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-brand" type="submit">Save domain</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card card-elevated">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">Admin users</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('admin.create_admin_user') }}" class="vstack gap-2 mb-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<input class="form-control" name="email" placeholder="Email" required />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<input class="form-control" name="password" type="password" placeholder="Password (min 8 chars)" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-brand" type="submit">Add admin</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in admins %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ u.email }}</strong>
|
||||
{% if u.id == current_user.id %}
|
||||
<span class="badge text-bg-secondary ms-2">You</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if u.id != current_user.id %}
|
||||
<form method="post" action="{{ url_for('admin.demote_admin_user', user_id=u.id) }}" class="d-inline">
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Demote</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="text-muted small">(cannot demote yourself)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2" class="text-muted">No admin users found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="form-text mt-2">
|
||||
Safety: the last remaining admin cannot be demoted.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta 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 %}
|
||||
|
||||
@@ -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;
|
||||
try {
|
||||
await postDisplayUpdate(displayId, { playlist_id: playlistId });
|
||||
showToast('Playlist saved', 'text-bg-success');
|
||||
} catch (e) {
|
||||
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
||||
} finally {
|
||||
sel.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (inputEl) inputEl.addEventListener('input', updateCount);
|
||||
|
||||
document.querySelectorAll('.js-edit-desc').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
activeDisplayId = btn.dataset.displayId;
|
||||
const displayName = btn.dataset.displayName || 'Display';
|
||||
const currentDesc = btn.dataset.currentDesc || '';
|
||||
if (titleEl) titleEl.textContent = `Edit description — ${displayName}`;
|
||||
if (inputEl) inputEl.value = currentDesc;
|
||||
updateCount();
|
||||
if (modal) modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
async function saveDescription() {
|
||||
if (!activeDisplayId || !inputEl) return;
|
||||
const desc = (inputEl.value || '').trim();
|
||||
saveBtn.disabled = true;
|
||||
function refreshPreviewIframe(displayId) {
|
||||
const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`);
|
||||
if (!iframe || !iframe.src) return;
|
||||
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 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) {
|
||||
// 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);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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', () => {
|
||||
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 (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 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 [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>
|
||||
|
||||
185
app/templates/company/my_company.html
Normal file
185
app/templates/company/my_company.html
Normal 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 %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
150
app/uploads.py
Normal file
@@ -0,0 +1,150 @@
|
||||
import os
|
||||
|
||||
|
||||
def _safe_company_segment(company_id: int | None) -> str:
|
||||
"""Return the directory name for a company's upload folder.
|
||||
|
||||
We intentionally use the numeric company_id (not company name) to avoid
|
||||
rename issues and any path traversal concerns.
|
||||
"""
|
||||
|
||||
try:
|
||||
cid = int(company_id) if company_id is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
cid = 0
|
||||
return str(max(0, cid))
|
||||
|
||||
|
||||
def get_company_upload_dir(upload_root: str, company_id: int | None) -> str:
|
||||
"""Return absolute directory path for a company's uploads."""
|
||||
|
||||
return os.path.join(upload_root, _safe_company_segment(company_id))
|
||||
|
||||
|
||||
def ensure_company_upload_dir(upload_root: str, company_id: int | None) -> str:
|
||||
"""Ensure the company's upload directory exists; return its absolute path."""
|
||||
|
||||
d = get_company_upload_dir(upload_root, company_id)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def get_company_upload_bytes(upload_root: str, company_id: int | None) -> int:
|
||||
"""Return best-effort total bytes used by a company's upload directory.
|
||||
|
||||
This walks the directory tree under uploads/<company_id> and sums file sizes.
|
||||
Any errors (missing directories, permission issues, broken links) are ignored.
|
||||
"""
|
||||
|
||||
total = 0
|
||||
root = get_company_upload_dir(upload_root, company_id)
|
||||
try:
|
||||
if not os.path.isdir(root):
|
||||
return 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
for base, _dirs, files in os.walk(root):
|
||||
for fn in files:
|
||||
try:
|
||||
p = os.path.join(base, fn)
|
||||
if os.path.isfile(p):
|
||||
total += os.path.getsize(p)
|
||||
except Exception:
|
||||
# Ignore unreadable files
|
||||
continue
|
||||
return int(total)
|
||||
|
||||
|
||||
def compute_storage_usage(*, used_bytes: int, max_bytes: int | None):
|
||||
"""Compute storage usage info.
|
||||
|
||||
Args:
|
||||
used_bytes: current usage
|
||||
max_bytes: quota; if None or <=0: unlimited
|
||||
|
||||
Returns dict:
|
||||
{
|
||||
"max_bytes": int|None,
|
||||
"used_bytes": int,
|
||||
"is_limited": bool,
|
||||
"is_exceeded": bool,
|
||||
"used_ratio": float|None, # 0..1 when limited
|
||||
"used_percent": int|None, # rounded percent when limited
|
||||
"remaining_bytes": int|None,
|
||||
}
|
||||
"""
|
||||
|
||||
used = max(0, int(used_bytes or 0))
|
||||
mx = None if max_bytes is None else int(max_bytes)
|
||||
if mx is None or mx <= 0:
|
||||
return {
|
||||
"max_bytes": None,
|
||||
"used_bytes": used,
|
||||
"is_limited": False,
|
||||
"is_exceeded": False,
|
||||
"used_ratio": None,
|
||||
"used_percent": None,
|
||||
"remaining_bytes": None,
|
||||
}
|
||||
|
||||
ratio = used / mx if mx > 0 else 1.0
|
||||
percent = int(round(ratio * 100.0))
|
||||
return {
|
||||
"max_bytes": mx,
|
||||
"used_bytes": used,
|
||||
"is_limited": True,
|
||||
"is_exceeded": used >= mx,
|
||||
# Keep percent un-clamped so the UI can show e.g. 132% when exceeded.
|
||||
# Clamp only the ratio (for progress bars, etc.).
|
||||
"used_ratio": max(0.0, min(1.0, ratio)),
|
||||
"used_percent": max(0, percent),
|
||||
"remaining_bytes": max(0, mx - used),
|
||||
}
|
||||
|
||||
|
||||
def is_valid_upload_relpath(file_path: str | None) -> bool:
|
||||
"""True if file_path looks like a path we manage under /static.
|
||||
|
||||
Supports both layouts:
|
||||
- uploads/<filename>
|
||||
- uploads/<company_id>/<filename>
|
||||
"""
|
||||
|
||||
if not file_path:
|
||||
return False
|
||||
fp = (file_path or "").replace("\\", "/")
|
||||
if not fp.startswith("uploads/"):
|
||||
return False
|
||||
# Prevent weird absolute/relative tricks; we only allow a normal relative path.
|
||||
if fp.startswith("uploads//") or ":" in fp:
|
||||
return False
|
||||
# No parent dir segments.
|
||||
if "../" in fp or fp.endswith("/..") or fp.startswith("../"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def abs_upload_path(upload_root: str, file_path: str | None) -> str | None:
|
||||
"""Resolve an item.file_path (uploads/...) to an absolute file path.
|
||||
|
||||
Returns None if file_path is not a managed uploads path.
|
||||
"""
|
||||
|
||||
if not is_valid_upload_relpath(file_path):
|
||||
return None
|
||||
rel = (file_path or "").replace("\\", "/")
|
||||
rel = rel.split("uploads/", 1)[1]
|
||||
|
||||
# Split into segments and harden against traversal.
|
||||
parts = [p for p in rel.split("/") if p]
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
candidate = os.path.abspath(os.path.join(upload_root, *parts))
|
||||
root_abs = os.path.abspath(upload_root)
|
||||
|
||||
# Ensure resolved path stays inside upload_root.
|
||||
if os.path.commonpath([candidate, root_abs]) != root_abs:
|
||||
return None
|
||||
return candidate
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
image: signage:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
# Override in a .env file or your shell; this default is only for convenience.
|
||||
SECRET_KEY: "change-me"
|
||||
# Optional bootstrap (only runs if ADMIN_PASS is set)
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@admin.admin}
|
||||
ADMIN_PASS: ${ADMIN_PASS:-}
|
||||
# Optional overrides (the Dockerfile already defaults these)
|
||||
GUNICORN_WORKERS: "2"
|
||||
GUNICORN_BIND: "0.0.0.0:8000"
|
||||
# Entrypoint (from Dockerfile) runs:
|
||||
# - `flask ensure-db`
|
||||
# - optional `flask init-db` when ADMIN_PASS is set
|
||||
# - gunicorn
|
||||
volumes:
|
||||
# Persist SQLite DB and uploads on the host
|
||||
- data/fossign/instance:/app/instance
|
||||
- data/fossign/uploads:/app/app/static/uploads
|
||||
12
docker/entrypoint.sh
Normal file
12
docker/entrypoint.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Ensure DB schema exists
|
||||
flask --app app ensure-db
|
||||
|
||||
# Optional: create/update initial admin account
|
||||
if [ -n "${ADMIN_PASS:-}" ]; then
|
||||
flask --app app init-db --admin-email "${ADMIN_EMAIL:-admin@admin.admin}" --admin-pass "${ADMIN_PASS}"
|
||||
fi
|
||||
|
||||
exec gunicorn -w "${GUNICORN_WORKERS:-2}" -b "${GUNICORN_BIND:-0.0.0.0:8000}" wsgi:app
|
||||
54
scripts/display_session_limit_test.py
Normal file
54
scripts/display_session_limit_test.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
# Ensure repo root is on sys.path when running as a script.
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
from app.models import Company, Display
|
||||
|
||||
|
||||
def main():
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Create a company + display
|
||||
c = Company(name="TestCo_DisplayLimit")
|
||||
db.session.add(c)
|
||||
db.session.commit()
|
||||
|
||||
d = Display(company_id=c.id, name="Lobby")
|
||||
db.session.add(d)
|
||||
db.session.commit()
|
||||
|
||||
token = d.token
|
||||
|
||||
client = app.test_client()
|
||||
|
||||
def hit(sid: str):
|
||||
return client.get(f"/api/display/{token}/playlist?sid={sid}")
|
||||
|
||||
# First 3 should be accepted (200 with JSON)
|
||||
for sid in ("s1", "s2", "s3"):
|
||||
r = hit(sid)
|
||||
assert r.status_code == 200, (sid, r.status_code, r.data)
|
||||
|
||||
# 4th should be rejected with 429 and a clear message
|
||||
r4 = hit("s4")
|
||||
assert r4.status_code == 429, (r4.status_code, r4.data)
|
||||
payload = r4.get_json(silent=True) or {}
|
||||
assert payload.get("error") == "display_limit_reached", payload
|
||||
msg = payload.get("message") or ""
|
||||
assert "open on 3" in msg, msg
|
||||
|
||||
print("OK: display session limit allows 3 sessions; 4th is rejected with 429.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -20,13 +20,18 @@ def main():
|
||||
|
||||
required = {
|
||||
"/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:
|
||||
|
||||
93
scripts/storage_quota_test.py
Normal file
93
scripts/storage_quota_test.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
# Ensure repo root is on sys.path when running as a script.
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
from app.models import Company, Playlist, User
|
||||
|
||||
|
||||
def main():
|
||||
# Use a temporary SQLite DB so this doesn't touch your real instance DB.
|
||||
fd, path = tempfile.mkstemp(prefix="rssfeed-test-", suffix=".sqlite")
|
||||
os.close(fd)
|
||||
try:
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{path}"
|
||||
os.environ["SECRET_KEY"] = "test-secret"
|
||||
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
c = Company(name="TestCo")
|
||||
# 1 byte quota so any non-empty upload should be rejected.
|
||||
c.storage_max_bytes = 1
|
||||
db.session.add(c)
|
||||
db.session.commit()
|
||||
|
||||
u = User(username="test@example.com", email="test@example.com", is_admin=False, company_id=c.id)
|
||||
u.set_password("passw0rd123")
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
|
||||
p = Playlist(company_id=c.id, name="Playlist")
|
||||
db.session.add(p)
|
||||
db.session.commit()
|
||||
|
||||
client = app.test_client()
|
||||
|
||||
# Login
|
||||
res = client.post(
|
||||
"/auth/login",
|
||||
data={"email": "test@example.com", "password": "passw0rd123"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
if res.status_code not in (302, 303):
|
||||
raise SystemExit(f"Login failed: {res.status_code}")
|
||||
|
||||
# Try to upload a small image payload.
|
||||
data = {
|
||||
"item_type": "image",
|
||||
"title": "Small",
|
||||
"duration_seconds": "10",
|
||||
"crop_mode": "none",
|
||||
"response": "json",
|
||||
# Not a real image; should fail processing OR quota depending on Pillow.
|
||||
# Use a tiny valid PNG header so Pillow can parse it.
|
||||
"file": (
|
||||
io.BytesIO(
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x01\x01\x01\x00\x18\xdd\x8d\x18\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
),
|
||||
"tiny.png",
|
||||
),
|
||||
}
|
||||
url = f"/company/playlists/{p.id}/items"
|
||||
res = client.post(url, data=data, content_type="multipart/form-data")
|
||||
|
||||
if res.status_code != 403:
|
||||
raise SystemExit(f"Expected 403 for quota exceeded, got: {res.status_code} {res.data!r}")
|
||||
js = res.get_json(silent=True) or {}
|
||||
if js.get("ok") is not False:
|
||||
raise SystemExit(f"Unexpected response: {js}")
|
||||
if "Storage limit" not in (js.get("error") or ""):
|
||||
raise SystemExit(f"Unexpected error message: {js}")
|
||||
|
||||
print("OK: storage quota prevents uploads when exceeded")
|
||||
finally:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
34
wsgi.py
Normal file
34
wsgi.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""WSGI entrypoint for production servers (gunicorn/uWSGI/etc.).
|
||||
|
||||
This file exposes a module-level WSGI callable named `app` (and `application`)
|
||||
so common servers can run the project without relying on Flask's dev server.
|
||||
|
||||
Examples:
|
||||
gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app
|
||||
uwsgi --http :8000 --wsgi-file wsgi.py --callable app
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
# `flask run` loads .env/.flaskenv automatically via python-dotenv.
|
||||
# Production WSGI servers typically *don't*, so we best-effort load `.env` here.
|
||||
# In real production, prefer setting environment variables via your process manager.
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.environ.get("DOTENV_PATH", ".env"), override=False)
|
||||
except Exception:
|
||||
# If python-dotenv isn't installed (or any other issue occurs), continue.
|
||||
pass
|
||||
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
# Some servers (and hosting platforms) look specifically for `application`.
|
||||
application = app
|
||||
Reference in New Issue
Block a user