Compare commits
19 Commits
1394ef6f67
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 613f53ba9e | |||
| 5221f9f670 | |||
| 0c2720618a | |||
| c5aa8a5156 | |||
| 9fd3f03b87 | |||
| 860679d119 | |||
| 78f0f379fc | |||
| 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"]
|
||||
207
README.md
207
README.md
@@ -33,15 +33,201 @@ 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`)
|
||||
|
||||
## Release helper (git + docker publish)
|
||||
|
||||
This repo includes a small helper to:
|
||||
|
||||
1) ask for a **commit message** and **version**
|
||||
2) commit + push to the `openslide` git remote
|
||||
3) build + push Docker images:
|
||||
- `git.alphen.cloud/bramval/openslide:<version>`
|
||||
- `git.alphen.cloud/bramval/openslide:latest`
|
||||
|
||||
Run (interactive):
|
||||
|
||||
```bash
|
||||
python scripts/release.py
|
||||
```
|
||||
|
||||
Run (non-interactive):
|
||||
|
||||
```bash
|
||||
python scripts/release.py --version 1.2.3 --message "Release 1.2.3"
|
||||
```
|
||||
|
||||
Dry-run (prints commands only):
|
||||
|
||||
```bash
|
||||
python scripts/release.py --version 1.2.3 --message "Release 1.2.3" --dry-run
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## Ticker tape (RSS headlines)
|
||||
|
||||
Each display can optionally show a **bottom ticker tape** with scrolling news headlines.
|
||||
|
||||
Configure RSS + styling as a company user via:
|
||||
|
||||
- **My Company → Ticker tape (RSS)**
|
||||
|
||||
Company-level options:
|
||||
|
||||
- RSS feed URL (public http/https)
|
||||
- Text color (picker)
|
||||
- Background color + opacity
|
||||
- Font (dropdown)
|
||||
- Font size
|
||||
- Speed
|
||||
|
||||
Per-display option:
|
||||
|
||||
- Enable/disable ticker on that display (Dashboard → Displays → Configure display)
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Headlines are fetched server-side via `GET /api/display/<token>/ticker` and cached in-memory.
|
||||
- The player reads the company ticker settings via `GET /api/display/<token>/playlist`.
|
||||
- The player auto-refreshes headlines without restart on a **long interval** (default: **12 hours**, override via `?ticker_poll=seconds`).
|
||||
- Server-side cache TTL defaults to **6 hours** (override via env var `TICKER_CACHE_TTL_SECONDS`).
|
||||
|
||||
## 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 +253,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:
|
||||
@@ -84,6 +275,22 @@ If the reset email is not received:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
179
app/__init__.py
179
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():
|
||||
@@ -20,6 +20,24 @@ def create_app():
|
||||
app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False)
|
||||
app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads"))
|
||||
|
||||
# Target output resolution for cropped images.
|
||||
# This is used by the client-side cropper (to generate an upload) and by the server-side
|
||||
# image processing (to cap the resulting WEBP size).
|
||||
#
|
||||
# Defaults to Full HD landscape (1920x1080). Portrait is derived by swapping.
|
||||
# Override via env vars, e.g.:
|
||||
# IMAGE_CROP_TARGET_W=1920
|
||||
# IMAGE_CROP_TARGET_H=1080
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
v = int(os.environ.get(name, "") or default)
|
||||
except (TypeError, ValueError):
|
||||
v = default
|
||||
return max(1, v)
|
||||
|
||||
app.config.setdefault("IMAGE_CROP_TARGET_W", _env_int("IMAGE_CROP_TARGET_W", 1920))
|
||||
app.config.setdefault("IMAGE_CROP_TARGET_H", _env_int("IMAGE_CROP_TARGET_H", 1080))
|
||||
|
||||
# NOTE: Videos should be max 250MB.
|
||||
# Flask's MAX_CONTENT_LENGTH applies to the full request payload (multipart includes overhead).
|
||||
# We set this slightly above 250MB to allow for multipart/form fields overhead, while still
|
||||
@@ -56,6 +74,162 @@ 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()
|
||||
|
||||
# Displays: optional ticker tape (RSS headlines)
|
||||
if "ticker_enabled" not in display_cols:
|
||||
db.session.execute(
|
||||
db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0")
|
||||
)
|
||||
db.session.commit()
|
||||
if "ticker_rss_url" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)"))
|
||||
db.session.commit()
|
||||
if "ticker_color" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)"))
|
||||
db.session.commit()
|
||||
if "ticker_bg_color" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)"))
|
||||
db.session.commit()
|
||||
if "ticker_bg_opacity" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER"))
|
||||
db.session.commit()
|
||||
if "ticker_font_family" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)"))
|
||||
db.session.commit()
|
||||
if "ticker_font_size_px" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER"))
|
||||
db.session.commit()
|
||||
if "ticker_speed" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER"))
|
||||
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()
|
||||
|
||||
# Companies: ticker tape settings (RSS + styling)
|
||||
if "ticker_rss_url" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)"))
|
||||
db.session.commit()
|
||||
if "ticker_color" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)"))
|
||||
db.session.commit()
|
||||
if "ticker_bg_color" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)"))
|
||||
db.session.commit()
|
||||
if "ticker_bg_opacity" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER"))
|
||||
db.session.commit()
|
||||
if "ticker_font_family" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)"))
|
||||
db.session.commit()
|
||||
if "ticker_font_size_px" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER"))
|
||||
db.session.commit()
|
||||
if "ticker_speed" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER"))
|
||||
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 +238,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"))
|
||||
129
app/cli.py
129
app/cli.py
@@ -2,7 +2,116 @@ 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()
|
||||
|
||||
# Optional ticker tape (RSS headlines)
|
||||
if "ticker_enabled" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0"))
|
||||
db.session.commit()
|
||||
if "ticker_rss_url" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)"))
|
||||
db.session.commit()
|
||||
if "ticker_color" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)"))
|
||||
db.session.commit()
|
||||
if "ticker_bg_color" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)"))
|
||||
db.session.commit()
|
||||
if "ticker_bg_opacity" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER"))
|
||||
db.session.commit()
|
||||
if "ticker_font_family" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)"))
|
||||
db.session.commit()
|
||||
if "ticker_font_size_px" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER"))
|
||||
db.session.commit()
|
||||
if "ticker_speed" not in display_cols:
|
||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER"))
|
||||
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()
|
||||
|
||||
# Companies: ticker tape settings (RSS + styling)
|
||||
if "ticker_rss_url" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)"))
|
||||
db.session.commit()
|
||||
if "ticker_color" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)"))
|
||||
db.session.commit()
|
||||
if "ticker_bg_color" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)"))
|
||||
db.session.commit()
|
||||
if "ticker_bg_opacity" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER"))
|
||||
db.session.commit()
|
||||
if "ticker_font_family" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)"))
|
||||
db.session.commit()
|
||||
if "ticker_font_size_px" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER"))
|
||||
db.session.commit()
|
||||
if "ticker_speed" not in company_cols:
|
||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER"))
|
||||
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 +126,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])
|
||||
|
||||
122
app/models.py
122
app/models.py
@@ -12,6 +12,24 @@ 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)
|
||||
|
||||
# Optional per-company ticker tape (RSS headlines) settings.
|
||||
# Displays can enable/disable the ticker individually.
|
||||
ticker_rss_url = db.Column(db.String(1000), nullable=True)
|
||||
ticker_color = db.Column(db.String(32), nullable=True)
|
||||
ticker_bg_color = db.Column(db.String(32), nullable=True)
|
||||
ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100
|
||||
ticker_font_family = db.Column(db.String(120), nullable=True)
|
||||
ticker_font_size_px = db.Column(db.Integer, nullable=True)
|
||||
ticker_speed = db.Column(db.Integer, nullable=True) # 1-100
|
||||
|
||||
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 +63,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 +114,42 @@ 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)
|
||||
|
||||
# Optional ticker tape (RSS headlines) rendered on the display.
|
||||
# Note: for this small project we avoid a JSON config blob; we store a few explicit columns.
|
||||
ticker_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||
ticker_rss_url = db.Column(db.String(1000), nullable=True)
|
||||
ticker_color = db.Column(db.String(32), nullable=True) # CSS color, e.g. "#ffffff"
|
||||
ticker_bg_color = db.Column(db.String(32), nullable=True) # hex (without alpha); opacity in ticker_bg_opacity
|
||||
ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100
|
||||
ticker_font_family = db.Column(db.String(120), nullable=True) # CSS font-family
|
||||
ticker_font_size_px = db.Column(db.Integer, nullable=True) # px
|
||||
ticker_speed = db.Column(db.Integer, nullable=True) # 1-100 (UI slider); higher = faster
|
||||
|
||||
# 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 +172,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,16 +1,313 @@
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
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
|
||||
|
||||
# RSS ticker cache (in-memory; OK for this small app; avoids hammering feeds)
|
||||
#
|
||||
# Default is intentionally long because displays can refresh headlines on a long interval
|
||||
# (e.g., twice per day) and we don't want many displays to re-fetch the same feed.
|
||||
# Override via env var `TICKER_CACHE_TTL_SECONDS`.
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, "") or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
TICKER_CACHE_TTL_SECONDS = max(10, _env_int("TICKER_CACHE_TTL_SECONDS", 6 * 60 * 60))
|
||||
_TICKER_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def _is_http_url_allowed(url: str) -> bool:
|
||||
"""Basic SSRF hardening: only allow http(s) and disallow obvious local targets."""
|
||||
|
||||
try:
|
||||
u = urlparse(url)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if u.scheme not in {"http", "https"}:
|
||||
return False
|
||||
|
||||
host = (u.hostname or "").strip().lower()
|
||||
if not host:
|
||||
return False
|
||||
|
||||
# Block localhost and common local domains.
|
||||
if host in {"localhost", "127.0.0.1", "::1"}:
|
||||
return False
|
||||
|
||||
# Block RFC1918-ish and link-local targets when host is an IP.
|
||||
# Note: this is best-effort; proper SSRF protection would require DNS resolution too.
|
||||
if re.match(r"^\d+\.\d+\.\d+\.\d+$", host):
|
||||
parts = [int(x) for x in host.split(".")]
|
||||
if parts[0] == 10:
|
||||
return False
|
||||
if parts[0] == 127:
|
||||
return False
|
||||
if parts[0] == 169 and parts[1] == 254:
|
||||
return False
|
||||
if parts[0] == 192 and parts[1] == 168:
|
||||
return False
|
||||
if parts[0] == 172 and 16 <= parts[1] <= 31:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _strip_text(s: str) -> str:
|
||||
s = (s or "").strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
return s
|
||||
|
||||
|
||||
def _fetch_rss_titles(url: str, *, limit: int = 20) -> list[str]:
|
||||
"""Fetch RSS/Atom titles from a feed URL.
|
||||
|
||||
We intentionally avoid adding dependencies (feedparser) for this project.
|
||||
This implementation is tolerant enough for typical RSS2/Atom feeds.
|
||||
"""
|
||||
|
||||
req = Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "SignageTicker/1.0 (+https://example.invalid)",
|
||||
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
with urlopen(req, timeout=8) as resp:
|
||||
# Basic size cap (avoid reading huge responses into memory)
|
||||
raw = resp.read(2_000_000) # 2MB
|
||||
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
titles: list[str] = []
|
||||
|
||||
# RSS2: <rss><channel><item><title>
|
||||
for el in root.findall(".//item/title"):
|
||||
t = _strip_text("".join(el.itertext()))
|
||||
if t:
|
||||
titles.append(t)
|
||||
|
||||
# Atom: <feed><entry><title>
|
||||
if not titles:
|
||||
for el in root.findall(".//{*}entry/{*}title"):
|
||||
t = _strip_text("".join(el.itertext()))
|
||||
if t:
|
||||
titles.append(t)
|
||||
|
||||
# Some feeds may have <channel><title> etc; we only want entry titles.
|
||||
# Deduplicate while preserving order.
|
||||
deduped: list[str] = []
|
||||
seen = set()
|
||||
for t in titles:
|
||||
if t in seen:
|
||||
continue
|
||||
seen.add(t)
|
||||
deduped.append(t)
|
||||
if len(deduped) >= limit:
|
||||
break
|
||||
|
||||
return deduped
|
||||
|
||||
|
||||
def _get_ticker_titles_cached(url: str) -> tuple[list[str], bool]:
|
||||
"""Return (titles, from_cache)."""
|
||||
|
||||
now = time.time()
|
||||
key = (url or "").strip()
|
||||
if not key:
|
||||
return [], True
|
||||
|
||||
entry = _TICKER_CACHE.get(key)
|
||||
if entry and (now - float(entry.get("ts") or 0)) < TICKER_CACHE_TTL_SECONDS:
|
||||
return (entry.get("titles") or []), True
|
||||
|
||||
titles: list[str] = []
|
||||
try:
|
||||
if _is_http_url_allowed(key):
|
||||
titles = _fetch_rss_titles(key)
|
||||
except Exception:
|
||||
titles = []
|
||||
|
||||
_TICKER_CACHE[key] = {"ts": now, "titles": titles}
|
||||
return titles, False
|
||||
|
||||
|
||||
@bp.get("/display/<token>/playlist")
|
||||
def display_playlist(token: str):
|
||||
@@ -18,57 +315,104 @@ def display_playlist(token: str):
|
||||
if not display:
|
||||
abort(404)
|
||||
|
||||
# Enforce: a display URL/token can be opened by max 2 concurrently active sessions.
|
||||
company = Company.query.filter_by(id=display.company_id).first()
|
||||
|
||||
# Optional overlay URL (per-company) when enabled on this display.
|
||||
overlay_src = None
|
||||
if display.show_overlay:
|
||||
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()
|
||||
# Ticker settings are configured per-company; displays can enable/disable individually.
|
||||
ticker_cfg = {
|
||||
"enabled": bool(display.ticker_enabled),
|
||||
"rss_url": (company.ticker_rss_url if company else None),
|
||||
"color": (company.ticker_color if company else None),
|
||||
"bg_color": (company.ticker_bg_color if company else None),
|
||||
"bg_opacity": (company.ticker_bg_opacity if company else None),
|
||||
"font_family": (company.ticker_font_family if company else None),
|
||||
"font_size_px": (company.ticker_font_size_px if company else None),
|
||||
"speed": (company.ticker_speed if company else None),
|
||||
}
|
||||
|
||||
# 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,
|
||||
"ticker": ticker_cfg,
|
||||
"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 +426,123 @@ 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,
|
||||
"ticker": ticker_cfg,
|
||||
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
|
||||
"items": items,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/display/<token>/ticker")
|
||||
def display_ticker(token: str):
|
||||
"""Return ticker headlines for a display.
|
||||
|
||||
We keep it separate from /playlist so the player can refresh headlines on its own interval.
|
||||
"""
|
||||
|
||||
display = Display.query.filter_by(token=token).first()
|
||||
if not display:
|
||||
abort(404)
|
||||
|
||||
company = Company.query.filter_by(id=display.company_id).first()
|
||||
|
||||
# Enforce concurrent session limit the same way as /playlist.
|
||||
sid = request.args.get("sid")
|
||||
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||
if not ok:
|
||||
return resp
|
||||
|
||||
if not display.ticker_enabled:
|
||||
return jsonify({"enabled": False, "headlines": []})
|
||||
|
||||
rss_url = ((company.ticker_rss_url if company else None) or "").strip()
|
||||
if not rss_url:
|
||||
return jsonify({"enabled": True, "headlines": []})
|
||||
|
||||
titles, from_cache = _get_ticker_titles_cached(rss_url)
|
||||
return jsonify(
|
||||
{
|
||||
"enabled": True,
|
||||
"rss_url": rss_url,
|
||||
"headlines": titles,
|
||||
"cached": bool(from_cache),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -274,6 +280,13 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Mobile: dashboard display previews are heavy (iframes). Hide them on small screens. */
|
||||
@media (max-width: 768px) {
|
||||
.display-gallery-card .display-preview {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.display-gallery-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -284,3 +297,26 @@ 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;
|
||||
}
|
||||
|
||||
/* Dropzone disabled state (used by bulk upload) */
|
||||
.dropzone.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1 class="page-title">Welcome{% if current_user and current_user.email %}, {{ current_user.email }}{% endif %}!</h1>
|
||||
<h1 class="page-title">Dashboard</h1>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
@@ -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>
|
||||
@@ -57,12 +69,20 @@
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card display-gallery-card h-100">
|
||||
<div class="display-preview">
|
||||
<iframe
|
||||
title="Preview — {{ d.name }}"
|
||||
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
<div
|
||||
class="display-preview-scale"
|
||||
style="width: 1000%; height: 1000%; transform: scale(0.1); transform-origin: top left;"
|
||||
>
|
||||
<iframe
|
||||
title="Preview — {{ d.name }}"
|
||||
data-display-id="{{ d.id }}"
|
||||
class="js-display-preview"
|
||||
data-preview-src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
style="width: 100%; height: 100%; border: 0;"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body d-flex flex-column gap-2">
|
||||
@@ -74,27 +94,25 @@
|
||||
</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-current-ticker-enabled="{{ '1' if d.ticker_enabled 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 +136,80 @@
|
||||
</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>
|
||||
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="editTickerEnabled" />
|
||||
<label class="form-check-label" for="editTickerEnabled">
|
||||
Enable ticker tape on this display
|
||||
</label>
|
||||
<div class="form-text">
|
||||
RSS feed + styling is configured in <a href="{{ url_for('company.my_company') }}">My Company</a>.
|
||||
</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,82 +240,265 @@
|
||||
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}"]`);
|
||||
// Previews are disabled on mobile.
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 768px)').matches) return;
|
||||
if (!iframe) 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 baseSrc = iframe.dataset.previewSrc || iframe.src;
|
||||
if (!baseSrc) return;
|
||||
const u = new URL(baseSrc, 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 baseSrc = iframe.dataset.previewSrc || iframe.src;
|
||||
if (!baseSrc) return;
|
||||
const sep = baseSrc.includes('?') ? '&' : '?';
|
||||
iframe.src = `${baseSrc}${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');
|
||||
const tickerEnabledEl = document.getElementById('editTickerEnabled');
|
||||
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';
|
||||
}
|
||||
|
||||
if (tickerEnabledEl) {
|
||||
const raw = (btn.dataset.currentTickerEnabled || '').toLowerCase();
|
||||
tickerEnabledEl.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;
|
||||
const tickerEnabled = tickerEnabledEl ? !!tickerEnabledEl.checked : false;
|
||||
plSaveBtn.disabled = true;
|
||||
try {
|
||||
const [updatedPlaylists, updatedDesc] = await Promise.all([
|
||||
postDisplayPlaylists(activePlDisplayId, ids),
|
||||
postDisplayUpdate(activePlDisplayId, {
|
||||
description: desc,
|
||||
transition,
|
||||
show_overlay: showOverlay,
|
||||
ticker_enabled: tickerEnabled
|
||||
})
|
||||
]);
|
||||
|
||||
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';
|
||||
|
||||
const newTickerEnabled = updatedDesc && typeof updatedDesc.ticker_enabled !== 'undefined'
|
||||
? !!updatedDesc.ticker_enabled
|
||||
: tickerEnabled;
|
||||
activePlButton.dataset.currentTickerEnabled = newTickerEnabled ? '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 (plSaveBtn) {
|
||||
plSaveBtn.addEventListener('click', savePlaylists);
|
||||
}
|
||||
if (modalEl) {
|
||||
modalEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
saveDescription();
|
||||
|
||||
// Disable dashboard previews on small screens (mobile): don't even set iframe src.
|
||||
function loadDashboardPreviewsIfDesktop() {
|
||||
const isMobile = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
||||
if (isMobile) return;
|
||||
document.querySelectorAll('iframe.js-display-preview[data-preview-src]').forEach((iframe) => {
|
||||
if (!iframe.src || iframe.src === 'about:blank') {
|
||||
iframe.src = iframe.dataset.previewSrc;
|
||||
}
|
||||
});
|
||||
}
|
||||
loadDashboardPreviewsIfDesktop();
|
||||
|
||||
// If user rotates/resizes from mobile -> desktop, load previews then.
|
||||
if (window.matchMedia) {
|
||||
const mql = window.matchMedia('(max-width: 768px)');
|
||||
const onChange = () => {
|
||||
if (!mql.matches) loadDashboardPreviewsIfDesktop();
|
||||
};
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', onChange);
|
||||
} else if (typeof mql.addListener === 'function') {
|
||||
mql.addListener(onChange);
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
314
app/templates/company/my_company.html
Normal file
314
app/templates/company/my_company.html
Normal file
@@ -0,0 +1,314 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 class="page-title">My Company</h1>
|
||||
<div class="text-muted">{{ company.name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<a class="btn btn-outline-ink" href="{{ url_for('company.dashboard') }}">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4 g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card card-elevated h-100">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">Company stats</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Users</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['users'] }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Displays</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['displays'] }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Playlists</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['playlists'] }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Playlist items</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['items'] }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Active display sessions</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['active_sessions'] }}</div>
|
||||
<div class="text-muted small">(last ~90 seconds)</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small">Storage used</div>
|
||||
<div class="fs-4 fw-bold">{{ stats['storage_human'] }}</div>
|
||||
<div class="text-muted small">({{ stats['storage_bytes'] }} bytes)</div>
|
||||
{% if stats.get('storage_max_human') %}
|
||||
<div class="text-muted small mt-1">
|
||||
Limit: <strong>{{ stats['storage_max_human'] }}</strong>
|
||||
{% if stats.get('storage_used_percent') is not none %}
|
||||
— Used: <strong>{{ stats['storage_used_percent'] }}%</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if stats.get('storage_used_percent') is not none %}
|
||||
<div class="progress mt-1" style="height: 8px;">
|
||||
<div
|
||||
class="progress-bar {% if stats['storage_used_percent'] >= 100 %}bg-danger{% elif stats['storage_used_percent'] >= 90 %}bg-warning{% else %}bg-success{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ [stats['storage_used_percent'], 100]|min }}%"
|
||||
aria-valuenow="{{ stats['storage_used_percent'] }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-muted small mt-1">Limit: <strong>Unlimited</strong></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card card-elevated h-100">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">Invite user</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('company.invite_user') }}" class="d-flex gap-2 flex-wrap">
|
||||
<input class="form-control" type="email" name="email" placeholder="Email address" required />
|
||||
<button class="btn btn-brand" type="submit">Send invite</button>
|
||||
</form>
|
||||
<div class="text-muted small mt-2">
|
||||
The user will receive an email with a password set link (valid for 30 minutes).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-elevated mt-4">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0">Users</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th class="text-muted">Created</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ u.email or "(no email)" }}</strong>
|
||||
{% if u.id == current_user.id %}
|
||||
<span class="badge bg-secondary ms-2">you</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted">{{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else "—" }}</td>
|
||||
<td class="text-end">
|
||||
{% if u.id != current_user.id %}
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url_for('company.delete_company_user', user_id=u.id) }}"
|
||||
class="d-inline"
|
||||
data-confirm="Delete user {{ u.email or "(no email)" }}? This cannot be undone."
|
||||
onsubmit="return confirm(this.dataset.confirm);"
|
||||
>
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="text-muted small">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted">No users.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<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">Ticker tape (RSS)</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-muted small mb-3">
|
||||
Configure the RSS feed and styling for the ticker tape. Individual displays can enable/disable the ticker from the Dashboard.
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('company.update_company_ticker_settings') }}" class="d-flex flex-column gap-3">
|
||||
<div>
|
||||
<label class="form-label" for="companyTickerRssUrl">RSS feed URL</label>
|
||||
<input
|
||||
class="form-control"
|
||||
id="companyTickerRssUrl"
|
||||
name="ticker_rss_url"
|
||||
type="url"
|
||||
value="{{ company.ticker_rss_url or '' }}"
|
||||
placeholder="https://example.com/feed.xml"
|
||||
/>
|
||||
<div class="form-text">Leave empty to disable headlines (even if displays have ticker enabled).</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="companyTickerColor">Text color</label>
|
||||
<input
|
||||
class="form-control form-control-color"
|
||||
id="companyTickerColor"
|
||||
name="ticker_color"
|
||||
type="color"
|
||||
value="{{ company.ticker_color or '#ffffff' }}"
|
||||
title="Choose text color"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="companyTickerBgColor">Background color</label>
|
||||
<input
|
||||
class="form-control form-control-color"
|
||||
id="companyTickerBgColor"
|
||||
name="ticker_bg_color"
|
||||
type="color"
|
||||
value="{{ company.ticker_bg_color or '#000000' }}"
|
||||
title="Choose background color"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="companyTickerBgOpacity">Background opacity</label>
|
||||
<input
|
||||
class="form-range"
|
||||
id="companyTickerBgOpacity"
|
||||
name="ticker_bg_opacity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value="{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}"
|
||||
/>
|
||||
<div class="form-text"><span id="companyTickerBgOpacityLabel">{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}</span>%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="companyTickerFontFamily">Font</label>
|
||||
<select class="form-select" id="companyTickerFontFamily" name="ticker_font_family">
|
||||
{% set ff = company.ticker_font_family or 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' %}
|
||||
<option value="system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" {{ 'selected' if ff == 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' else '' }}>System (default)</option>
|
||||
<option value="Arial, Helvetica, sans-serif" {{ 'selected' if ff == 'Arial, Helvetica, sans-serif' else '' }}>Arial</option>
|
||||
<option value="Segoe UI, Arial, sans-serif" {{ 'selected' if ff == 'Segoe UI, Arial, sans-serif' else '' }}>Segoe UI</option>
|
||||
<option value="Roboto, Arial, sans-serif" {{ 'selected' if ff == 'Roboto, Arial, sans-serif' else '' }}>Roboto</option>
|
||||
<option value="Georgia, serif" {{ 'selected' if ff == 'Georgia, serif' else '' }}>Georgia</option>
|
||||
<option value="Times New Roman, Times, serif" {{ 'selected' if ff == 'Times New Roman, Times, serif' else '' }}>Times New Roman</option>
|
||||
<option value="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace" {{ 'selected' if ff == 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace' else '' }}>Monospace</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label" for="companyTickerFontSize">Font size (px)</label>
|
||||
<input
|
||||
class="form-control"
|
||||
id="companyTickerFontSize"
|
||||
name="ticker_font_size_px"
|
||||
type="number"
|
||||
min="10"
|
||||
max="200"
|
||||
step="1"
|
||||
value="{{ company.ticker_font_size_px if company.ticker_font_size_px is not none else 28 }}"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label" for="companyTickerSpeed">Speed</label>
|
||||
<input
|
||||
class="form-range"
|
||||
id="companyTickerSpeed"
|
||||
name="ticker_speed"
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
value="{{ company.ticker_speed if company.ticker_speed is not none else 25 }}"
|
||||
/>
|
||||
<div class="form-text">Slower ⟷ Faster</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-brand" type="submit">Save ticker settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const opacityEl = document.getElementById('companyTickerBgOpacity');
|
||||
const opacityLabelEl = document.getElementById('companyTickerBgOpacityLabel');
|
||||
function syncOpacity() {
|
||||
if (!opacityEl || !opacityLabelEl) return;
|
||||
opacityLabelEl.textContent = String(opacityEl.value || '0');
|
||||
}
|
||||
if (opacityEl) opacityEl.addEventListener('input', syncOpacity);
|
||||
syncOpacity();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,17 +7,174 @@
|
||||
<style>
|
||||
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
||||
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
||||
|
||||
/* When ticker is shown, keep content from being visually covered.
|
||||
(We pad the stage; video/img/iframe inside will keep aspect.) */
|
||||
body.has-ticker #stage {
|
||||
bottom: var(--ticker-height, 54px);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Ticker tape */
|
||||
#ticker {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: var(--ticker-height, 54px);
|
||||
background: rgba(0, 0, 0, 0.75); /* overridden by JS via style */
|
||||
display: none;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
z-index: 6; /* above stage, below notice */
|
||||
pointer-events: none;
|
||||
}
|
||||
#ticker .track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
will-change: transform;
|
||||
animation: ticker-scroll linear infinite;
|
||||
animation-duration: var(--ticker-duration, 60s);
|
||||
transform: translateX(0);
|
||||
}
|
||||
#ticker .item {
|
||||
padding: 0 26px;
|
||||
}
|
||||
#ticker .sep {
|
||||
opacity: 0.65;
|
||||
}
|
||||
@keyframes ticker-scroll {
|
||||
/* We duplicate the content twice, so shifting -50% effectively loops. */
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(calc(-1 * var(--ticker-shift, 50%))); }
|
||||
}
|
||||
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>
|
||||
<div id="ticker" aria-hidden="true">
|
||||
<div class="track" id="tickerTrack"></div>
|
||||
</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 +196,33 @@
|
||||
let idx = 0;
|
||||
let timer = null;
|
||||
|
||||
// Ticker DOM
|
||||
const tickerEl = document.getElementById('ticker');
|
||||
const tickerTrackEl = document.getElementById('tickerTrack');
|
||||
let tickerConfig = null;
|
||||
let tickerInterval = null;
|
||||
let tickerLastHeadlines = [];
|
||||
|
||||
function getTickerPollSeconds() {
|
||||
// Refresh headlines on a long interval.
|
||||
// Default: 12 hours (twice per day).
|
||||
// Override via ?ticker_poll=seconds.
|
||||
const tp = parseInt(new URLSearchParams(window.location.search).get('ticker_poll') || '', 10);
|
||||
return Number.isFinite(tp) && tp > 0 ? tp : (12 * 60 * 60);
|
||||
}
|
||||
|
||||
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' });
|
||||
@@ -49,29 +233,221 @@
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function fetchTickerHeadlines() {
|
||||
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
||||
const res = await fetch(`/api/display/${token}/ticker${qs}`, { cache: 'no-store' });
|
||||
if (res.status === 429) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw Object.assign(new Error(data?.message || 'Display limit reached'), { code: 'LIMIT', data });
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function safeCss(val) {
|
||||
return (val || '').toString().replace(/[\n\r"']/g, ' ').trim();
|
||||
}
|
||||
|
||||
function applyTickerStyle(cfg) {
|
||||
if (!tickerEl) return;
|
||||
const color = safeCss(cfg && cfg.color);
|
||||
const bgColor = safeCss(cfg && cfg.bg_color);
|
||||
const bgOpacityRaw = parseInt((cfg && cfg.bg_opacity) || '', 10);
|
||||
const bgOpacity = Number.isFinite(bgOpacityRaw) ? Math.max(0, Math.min(100, bgOpacityRaw)) : 75;
|
||||
const fontFamily = safeCss(cfg && cfg.font_family);
|
||||
const sizePx = parseInt((cfg && cfg.font_size_px) || '', 10);
|
||||
const fontSize = Number.isFinite(sizePx) ? Math.max(10, Math.min(200, sizePx)) : 28;
|
||||
|
||||
// Height is slightly larger than font size.
|
||||
const height = Math.max(36, Math.min(120, fontSize + 26));
|
||||
|
||||
tickerEl.style.color = color || '#ffffff';
|
||||
tickerEl.style.fontFamily = fontFamily || 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
|
||||
tickerEl.style.fontSize = `${fontSize}px`;
|
||||
tickerEl.style.setProperty('--ticker-height', `${height}px`);
|
||||
|
||||
// Background color + opacity
|
||||
tickerEl.style.backgroundColor = toRgba(bgColor || '#000000', bgOpacity);
|
||||
}
|
||||
|
||||
function toRgba(hexColor, opacityPercent) {
|
||||
const s = (hexColor || '').toString().trim().toLowerCase();
|
||||
const a = Math.max(0, Math.min(100, parseInt(opacityPercent || '0', 10))) / 100;
|
||||
// Accept #rgb or #rrggbb. Fallback to black.
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (s.startsWith('#')) {
|
||||
const h = s.slice(1);
|
||||
if (h.length === 3) {
|
||||
r = parseInt(h[0] + h[0], 16);
|
||||
g = parseInt(h[1] + h[1], 16);
|
||||
b = parseInt(h[2] + h[2], 16);
|
||||
} else if (h.length === 6) {
|
||||
r = parseInt(h.slice(0,2), 16);
|
||||
g = parseInt(h.slice(2,4), 16);
|
||||
b = parseInt(h.slice(4,6), 16);
|
||||
}
|
||||
}
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
function computeTickerDurationPx(copyWidthPx) {
|
||||
const w = Math.max(1, parseInt(copyWidthPx || '0', 10) || 0);
|
||||
|
||||
// Speed slider (1..100): higher => faster.
|
||||
const rawSpeed = parseInt((tickerConfig && tickerConfig.speed) || '', 10);
|
||||
const speed = Number.isFinite(rawSpeed) ? Math.max(1, Math.min(100, rawSpeed)) : 25;
|
||||
|
||||
// Map speed to pixels/second. (tuned to be readable on signage)
|
||||
// speed=25 => ~38 px/s, speed=100 => ~128 px/s
|
||||
const pxPerSecond = Math.max(8, Math.min(180, 8 + (speed * 1.2)));
|
||||
const seconds = w / pxPerSecond;
|
||||
return Math.max(12, Math.min(600, seconds));
|
||||
}
|
||||
|
||||
function buildTickerCopyHtml(list) {
|
||||
// No trailing separator at the end.
|
||||
return list.map((t, i) => {
|
||||
const sep = (i === list.length - 1) ? '' : '<span class="sep">•</span>';
|
||||
return `<span class="item">${escapeHtml(t)}</span>${sep}`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setTickerHeadlines(headlines) {
|
||||
if (!tickerEl || !tickerTrackEl) return;
|
||||
const list = Array.isArray(headlines) ? headlines.map(x => (x || '').toString().trim()).filter(Boolean) : [];
|
||||
if (!list.length) {
|
||||
tickerEl.style.display = 'none';
|
||||
tickerTrackEl.innerHTML = '';
|
||||
document.body.classList.remove('has-ticker');
|
||||
return;
|
||||
}
|
||||
|
||||
tickerLastHeadlines = list.slice();
|
||||
|
||||
// Show first so measurements work.
|
||||
tickerEl.style.display = 'flex';
|
||||
document.body.classList.add('has-ticker');
|
||||
|
||||
// Build one copy.
|
||||
const oneCopyHtml = buildTickerCopyHtml(list);
|
||||
tickerTrackEl.innerHTML = oneCopyHtml;
|
||||
|
||||
// Ensure we repeat enough so there is never an empty gap, even when the
|
||||
// total headline width is smaller than the viewport.
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
const viewportW = tickerEl.clientWidth || 1;
|
||||
const copyW = tickerTrackEl.scrollWidth || 1;
|
||||
|
||||
// Want at least 2x viewport width in total track content.
|
||||
const repeats = Math.max(2, Math.ceil((viewportW * 2) / copyW) + 1);
|
||||
tickerTrackEl.innerHTML = oneCopyHtml.repeat(repeats);
|
||||
|
||||
// Shift by exactly one copy width. In % of total track width that is 100/repeats.
|
||||
const shiftPercent = 100 / repeats;
|
||||
tickerEl.style.setProperty('--ticker-shift', `${shiftPercent}%`);
|
||||
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(copyW)}s`);
|
||||
} catch (e) {
|
||||
// fallback: 2 copies
|
||||
tickerTrackEl.innerHTML = oneCopyHtml + oneCopyHtml;
|
||||
tickerEl.style.setProperty('--ticker-shift', '50%');
|
||||
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(2000)}s`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return (s || '').toString()
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function refreshTickerOnce() {
|
||||
// Disabled or missing URL: hide and clear immediately.
|
||||
if (!tickerConfig || !tickerConfig.enabled) {
|
||||
setTickerHeadlines([]);
|
||||
return;
|
||||
}
|
||||
if (!tickerConfig.rss_url || !String(tickerConfig.rss_url).trim()) {
|
||||
setTickerHeadlines([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await fetchTickerHeadlines();
|
||||
if (!data || !data.enabled) {
|
||||
setTickerHeadlines([]);
|
||||
return;
|
||||
}
|
||||
setTickerHeadlines(data.headlines || []);
|
||||
} catch (e) {
|
||||
// Soft-fail: keep old headlines if any.
|
||||
}
|
||||
}
|
||||
|
||||
function rerenderTickerFromCache() {
|
||||
if (!tickerLastHeadlines || !tickerLastHeadlines.length) return;
|
||||
setTickerHeadlines(tickerLastHeadlines);
|
||||
}
|
||||
|
||||
function startTickerPolling() {
|
||||
if (tickerInterval) {
|
||||
clearInterval(tickerInterval);
|
||||
tickerInterval = null;
|
||||
}
|
||||
tickerInterval = setInterval(refreshTickerOnce, getTickerPollSeconds() * 1000);
|
||||
}
|
||||
|
||||
function stopTickerPolling() {
|
||||
if (tickerInterval) {
|
||||
clearInterval(tickerInterval);
|
||||
tickerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearStage() {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
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 +455,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 +467,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 +550,100 @@
|
||||
try {
|
||||
playlist = await fetchPlaylist();
|
||||
idx = 0;
|
||||
applyTransitionClass(getTransitionMode(playlist));
|
||||
setOverlaySrc(playlist && playlist.overlay_src);
|
||||
tickerConfig = (playlist && playlist.ticker) ? playlist.ticker : null;
|
||||
applyTickerStyle(tickerConfig);
|
||||
await refreshTickerOnce();
|
||||
if (tickerConfig && tickerConfig.enabled) {
|
||||
startTickerPolling();
|
||||
} else {
|
||||
stopTickerPolling();
|
||||
}
|
||||
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);
|
||||
|
||||
// Apply ticker settings (and refresh if settings changed)
|
||||
const newTickerCfg = (playlist && playlist.ticker) ? playlist.ticker : null;
|
||||
const oldTickerStr = JSON.stringify(tickerConfig);
|
||||
const newTickerStr = JSON.stringify(newTickerCfg);
|
||||
const oldEnabled = !!(tickerConfig && tickerConfig.enabled);
|
||||
const newEnabled = !!(newTickerCfg && newTickerCfg.enabled);
|
||||
const oldRssUrl = (tickerConfig && tickerConfig.rss_url) ? String(tickerConfig.rss_url) : '';
|
||||
const newRssUrl = (newTickerCfg && newTickerCfg.rss_url) ? String(newTickerCfg.rss_url) : '';
|
||||
tickerConfig = newTickerCfg;
|
||||
applyTickerStyle(tickerConfig);
|
||||
if (oldTickerStr !== newTickerStr) {
|
||||
// Ensure enable/disable toggles are applied immediately (no reload required).
|
||||
if (oldEnabled !== newEnabled) {
|
||||
if (!newEnabled) {
|
||||
// Hide and stop polling.
|
||||
tickerLastHeadlines = [];
|
||||
stopTickerPolling();
|
||||
setTickerHeadlines([]);
|
||||
} else {
|
||||
// Re-enable: fetch now and restart headline polling.
|
||||
await refreshTickerOnce();
|
||||
startTickerPolling();
|
||||
}
|
||||
} else if (oldRssUrl !== newRssUrl) {
|
||||
// RSS URL changed: refetch now.
|
||||
await refreshTickerOnce();
|
||||
} else {
|
||||
// Style/speed change only: rerender from cache to apply instantly.
|
||||
if (newEnabled) rerenderTickerFromCache();
|
||||
}
|
||||
}
|
||||
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()
|
||||
136
scripts/release.py
Normal file
136
scripts/release.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Release helper.
|
||||
|
||||
What it does (in order):
|
||||
1) Ask/provide a commit message and version.
|
||||
2) Commit & push to the `openslide` git remote.
|
||||
3) Build + push Docker image tags:
|
||||
- git.alphen.cloud/bramval/openslide:<version>
|
||||
- git.alphen.cloud/bramval/openslide:latest
|
||||
|
||||
Usage examples:
|
||||
python scripts/release.py --version 1.2.3 --message "Release 1.2.3"
|
||||
python scripts/release.py # interactive prompts
|
||||
|
||||
Notes:
|
||||
- Assumes you are already authenticated for git + the Docker registry (docker login).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
|
||||
DEFAULT_GIT_REMOTE = "openslide"
|
||||
DEFAULT_IMAGE = "git.alphen.cloud/bramval/openslide"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInfo:
|
||||
version: str
|
||||
message: str
|
||||
|
||||
|
||||
_DOCKER_TAG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
|
||||
|
||||
def _run(cmd: Sequence[str], *, dry_run: bool = False) -> None:
|
||||
printable = " ".join(cmd)
|
||||
print(f"> {printable}")
|
||||
if dry_run:
|
||||
return
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
def _capture(cmd: Sequence[str]) -> str:
|
||||
return subprocess.check_output(cmd, text=True).strip()
|
||||
|
||||
|
||||
def _require_tool(name: str) -> None:
|
||||
try:
|
||||
subprocess.run([name, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
||||
except FileNotFoundError as e:
|
||||
raise SystemExit(f"Required tool not found in PATH: {name}") from e
|
||||
|
||||
|
||||
def _validate_version(tag: str) -> str:
|
||||
tag = tag.strip()
|
||||
if not tag:
|
||||
raise ValueError("Version may not be empty")
|
||||
if not _DOCKER_TAG_RE.match(tag):
|
||||
raise ValueError(
|
||||
"Invalid docker tag for version. Use only letters, digits, '.', '_' or '-'. "
|
||||
"(1..128 chars, must start with [A-Za-z0-9])"
|
||||
)
|
||||
return tag
|
||||
|
||||
|
||||
def get_release_info(*, version: str | None, message: str | None) -> ReleaseInfo:
|
||||
"""Collect commit message + version before doing the rest."""
|
||||
if version is None:
|
||||
version = input("Version tag (e.g. 1.2.3): ").strip()
|
||||
version = _validate_version(version)
|
||||
|
||||
if message is None:
|
||||
message = input(f"Commit message [Release {version}]: ").strip() or f"Release {version}"
|
||||
|
||||
return ReleaseInfo(version=version, message=message)
|
||||
|
||||
|
||||
def git_commit_and_push(*, remote: str, message: str, dry_run: bool = False) -> None:
|
||||
# Stage all changes
|
||||
_run(["git", "add", "-A"], dry_run=dry_run)
|
||||
|
||||
# Only commit if there is something to commit
|
||||
porcelain = _capture(["git", "status", "--porcelain"]) # empty => clean
|
||||
if porcelain:
|
||||
_run(["git", "commit", "-m", message], dry_run=dry_run)
|
||||
else:
|
||||
print("No working tree changes detected; skipping git commit.")
|
||||
|
||||
branch = _capture(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
||||
_run(["git", "push", remote, branch], dry_run=dry_run)
|
||||
|
||||
|
||||
def docker_build_and_push(*, image: str, version: str, dry_run: bool = False) -> None:
|
||||
version_tag = f"{image}:{version}"
|
||||
latest_tag = f"{image}:latest"
|
||||
|
||||
_run(["docker", "build", "-t", version_tag, "-t", latest_tag, "."], dry_run=dry_run)
|
||||
_run(["docker", "push", version_tag], dry_run=dry_run)
|
||||
_run(["docker", "push", latest_tag], dry_run=dry_run)
|
||||
|
||||
|
||||
def main(argv: Iterable[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="Commit + push + docker publish helper.")
|
||||
parser.add_argument("--version", "-v", help="Docker version tag (e.g. 1.2.3)")
|
||||
parser.add_argument("--message", "-m", help="Git commit message")
|
||||
parser.add_argument("--remote", default=DEFAULT_GIT_REMOTE, help=f"Git remote to push to (default: {DEFAULT_GIT_REMOTE})")
|
||||
parser.add_argument("--image", default=DEFAULT_IMAGE, help=f"Docker image name (default: {DEFAULT_IMAGE})")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing")
|
||||
args = parser.parse_args(list(argv))
|
||||
|
||||
_require_tool("git")
|
||||
_require_tool("docker")
|
||||
|
||||
info = get_release_info(version=args.version, message=args.message)
|
||||
|
||||
print(f"\nReleasing version: {info.version}")
|
||||
print(f"Commit message: {info.message}")
|
||||
print(f"Git remote: {args.remote}")
|
||||
print(f"Docker image: {args.image}\n")
|
||||
|
||||
git_commit_and_push(remote=args.remote, message=info.message, dry_run=args.dry_run)
|
||||
docker_build_and_push(image=args.image, version=info.version, dry_run=args.dry_run)
|
||||
|
||||
print("\nDone.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -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