Update app
This commit is contained in:
@@ -77,6 +77,57 @@ def create_app():
|
|||||||
if settings_cols and "public_domain" not in settings_cols:
|
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.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))
|
||||||
db.session.commit()
|
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()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,21 @@ class Display(db.Model):
|
|||||||
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)
|
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)
|
||||||
assigned_playlist = db.relationship("Playlist")
|
assigned_playlist = db.relationship("Playlist")
|
||||||
|
|
||||||
|
# Multi-playlist support (active playlists per display).
|
||||||
|
# If a display has any rows in display_playlist, those are used by the player.
|
||||||
|
# If not, we fall back to assigned_playlist_id for backwards compatibility.
|
||||||
|
display_playlists = db.relationship(
|
||||||
|
"DisplayPlaylist",
|
||||||
|
back_populates="display",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
playlists = db.relationship(
|
||||||
|
"Playlist",
|
||||||
|
secondary="display_playlist",
|
||||||
|
viewonly=True,
|
||||||
|
order_by="Playlist.name.asc()",
|
||||||
|
)
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
company = db.relationship("Company", back_populates="displays")
|
company = db.relationship("Company", back_populates="displays")
|
||||||
@@ -117,6 +132,32 @@ class DisplaySession(db.Model):
|
|||||||
__table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),)
|
__table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),)
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayPlaylist(db.Model):
|
||||||
|
"""Association table: which playlists are active on a display."""
|
||||||
|
|
||||||
|
# NOTE: Some existing databases include an `id` INTEGER PRIMARY KEY column and a
|
||||||
|
# NOT NULL `position` column on display_playlist. We keep the mapper primary key as
|
||||||
|
# (display_id, playlist_id) for portability, while allowing an optional `id` column
|
||||||
|
# to exist in the underlying table.
|
||||||
|
id = db.Column(db.Integer, nullable=True)
|
||||||
|
|
||||||
|
# Composite mapper PK ensures uniqueness per display.
|
||||||
|
display_id = db.Column(db.Integer, db.ForeignKey("display.id"), primary_key=True)
|
||||||
|
playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), primary_key=True)
|
||||||
|
|
||||||
|
# Ordering of playlists within a display.
|
||||||
|
position = db.Column(db.Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
display = db.relationship("Display", back_populates="display_playlists")
|
||||||
|
playlist = db.relationship("Playlist")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("display_id", "playlist_id", name="uq_display_playlist_display_playlist"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AppSettings(db.Model):
|
class AppSettings(db.Model):
|
||||||
"""Singleton-ish app-wide settings.
|
"""Singleton-ish app-wide settings.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from flask_login import current_user, login_required, login_user
|
|||||||
|
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..uploads import abs_upload_path, ensure_company_upload_dir, get_company_upload_bytes, is_valid_upload_relpath
|
from ..uploads import abs_upload_path, ensure_company_upload_dir, get_company_upload_bytes, is_valid_upload_relpath
|
||||||
from ..models import AppSettings, Company, Display, DisplaySession, Playlist, PlaylistItem, User
|
from ..models import AppSettings, Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem, User
|
||||||
from ..email_utils import send_email
|
from ..email_utils import send_email
|
||||||
|
|
||||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
@@ -375,8 +375,12 @@ def delete_company(company_id: int):
|
|||||||
for d in list(company.displays):
|
for d in list(company.displays):
|
||||||
d.assigned_playlist_id = None
|
d.assigned_playlist_id = None
|
||||||
|
|
||||||
# 2) Delete display sessions referencing displays of this company
|
# 1b) Clear multi-playlist mappings
|
||||||
display_ids = [d.id for d in company.displays]
|
display_ids = [d.id for d in company.displays]
|
||||||
|
if display_ids:
|
||||||
|
DisplayPlaylist.query.filter(DisplayPlaylist.display_id.in_(display_ids)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# 2) Delete display sessions referencing displays of this company
|
||||||
if display_ids:
|
if display_ids:
|
||||||
DisplaySession.query.filter(DisplaySession.display_id.in_(display_ids)).delete(synchronize_session=False)
|
DisplaySession.query.filter(DisplaySession.display_id.in_(display_ids)).delete(synchronize_session=False)
|
||||||
|
|
||||||
@@ -518,10 +522,13 @@ def delete_display(display_id: int):
|
|||||||
# 1) Unassign playlist
|
# 1) Unassign playlist
|
||||||
display.assigned_playlist_id = None
|
display.assigned_playlist_id = None
|
||||||
|
|
||||||
# 2) Delete active sessions for this display
|
# 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)
|
DisplaySession.query.filter_by(display_id=display.id).delete(synchronize_session=False)
|
||||||
|
|
||||||
# 3) Delete display
|
# 4) Delete display
|
||||||
db.session.delete(display)
|
db.session.delete(display)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import time
|
|||||||
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
|
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
|
||||||
|
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..models import Display, DisplaySession
|
from ..models import Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem
|
||||||
|
|
||||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2
|
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
|
||||||
SESSION_TTL_SECONDS = 90
|
SESSION_TTL_SECONDS = 90
|
||||||
|
|
||||||
|
|
||||||
@@ -84,16 +84,49 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]:
|
|||||||
duration changes, and item adds/deletes trigger an update.
|
duration changes, and item adds/deletes trigger an update.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
playlist = display.assigned_playlist
|
# Determine active playlists. If display_playlist has any rows, use those.
|
||||||
if not playlist:
|
# 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"
|
raw = "no-playlist"
|
||||||
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
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 = {
|
payload = {
|
||||||
"playlist_id": playlist.id,
|
"playlist_ids": list(active_ids),
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": it.id,
|
"id": it.id,
|
||||||
|
"playlist_id": it.playlist_id,
|
||||||
"pos": it.position,
|
"pos": it.position,
|
||||||
"type": it.item_type,
|
"type": it.item_type,
|
||||||
"title": it.title,
|
"title": it.title,
|
||||||
@@ -101,11 +134,15 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]:
|
|||||||
"file_path": it.file_path,
|
"file_path": it.file_path,
|
||||||
"url": it.url,
|
"url": it.url,
|
||||||
}
|
}
|
||||||
for it in playlist.items
|
for it in items
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||||
return playlist.id, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
# signature returns a single playlist_id previously; now return None when multiple.
|
||||||
|
# callers only use it for changed-detection.
|
||||||
|
if len(set(active_ids)) == 1:
|
||||||
|
return active_ids[0], hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/display/<token>/playlist")
|
@bp.get("/display/<token>/playlist")
|
||||||
@@ -114,21 +151,59 @@ def display_playlist(token: str):
|
|||||||
if not display:
|
if not display:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
# Enforce: a display URL/token can be opened by max 2 concurrently active sessions.
|
# Enforce: a display URL/token can be opened by max 3 concurrently active sessions.
|
||||||
# Player sends a stable `sid` via querystring.
|
# Player sends a stable `sid` via querystring.
|
||||||
sid = request.args.get("sid")
|
sid = request.args.get("sid")
|
||||||
ok, resp = _enforce_and_touch_display_session(display, sid)
|
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
playlist = display.assigned_playlist
|
# Determine active playlists. If display_playlist has any rows, use those.
|
||||||
if not playlist:
|
# Otherwise fall back to the legacy assigned_playlist_id.
|
||||||
return jsonify({"display": display.name, "playlist": None, "items": []})
|
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, "playlists": [], "items": []})
|
||||||
|
|
||||||
|
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
|
||||||
|
pl_by_id = {p.id: p for p in playlists}
|
||||||
|
ordered_playlists = [pl_by_id[x] for x in active_ids if x in pl_by_id]
|
||||||
|
|
||||||
|
# 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_(active_ids),
|
||||||
|
)
|
||||||
|
.order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
merged = (
|
||||||
|
PlaylistItem.query.filter(PlaylistItem.playlist_id == active_ids[0])
|
||||||
|
.order_by(PlaylistItem.position.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for item in playlist.items:
|
for item in merged:
|
||||||
payload = {
|
payload = {
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
|
"playlist_id": item.playlist_id,
|
||||||
|
"playlist_name": (pl_by_id.get(item.playlist_id).name if pl_by_id.get(item.playlist_id) else None),
|
||||||
"type": item.item_type,
|
"type": item.item_type,
|
||||||
"title": item.title,
|
"title": item.title,
|
||||||
"duration": item.duration_seconds,
|
"duration": item.duration_seconds,
|
||||||
@@ -142,7 +217,7 @@ def display_playlist(token: str):
|
|||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"display": display.name,
|
"display": display.name,
|
||||||
"playlist": {"id": playlist.id, "name": playlist.name},
|
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from ..uploads import (
|
|||||||
get_company_upload_bytes,
|
get_company_upload_bytes,
|
||||||
is_valid_upload_relpath,
|
is_valid_upload_relpath,
|
||||||
)
|
)
|
||||||
from ..models import AppSettings, Company, Display, DisplaySession, Playlist, PlaylistItem, User
|
from ..models import AppSettings, Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem, User
|
||||||
from ..email_utils import send_email
|
from ..email_utils import send_email
|
||||||
from ..auth_tokens import make_password_reset_token
|
from ..auth_tokens import make_password_reset_token
|
||||||
|
|
||||||
@@ -343,7 +343,13 @@ def dashboard():
|
|||||||
company_user_required()
|
company_user_required()
|
||||||
playlists = Playlist.query.filter_by(company_id=current_user.company_id).order_by(Playlist.name.asc()).all()
|
playlists = Playlist.query.filter_by(company_id=current_user.company_id).order_by(Playlist.name.asc()).all()
|
||||||
displays = Display.query.filter_by(company_id=current_user.company_id).order_by(Display.name.asc()).all()
|
displays = Display.query.filter_by(company_id=current_user.company_id).order_by(Display.name.asc()).all()
|
||||||
return render_template("company/dashboard.html", playlists=playlists, displays=displays)
|
playlists_json = [{"id": p.id, "name": p.name} for p in playlists]
|
||||||
|
return render_template(
|
||||||
|
"company/dashboard.html",
|
||||||
|
playlists=playlists,
|
||||||
|
playlists_json=playlists_json,
|
||||||
|
displays=displays,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/playlists")
|
@bp.post("/playlists")
|
||||||
@@ -412,6 +418,15 @@ def delete_playlist(playlist_id: int):
|
|||||||
{"assigned_playlist_id": None}
|
{"assigned_playlist_id": None}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Remove from any display multi-playlist mappings in this company.
|
||||||
|
# Use a subquery to avoid a JOIN-based DELETE which is not supported on SQLite.
|
||||||
|
display_ids = [d.id for d in Display.query.filter_by(company_id=current_user.company_id).all()]
|
||||||
|
if display_ids:
|
||||||
|
DisplayPlaylist.query.filter(
|
||||||
|
DisplayPlaylist.display_id.in_(display_ids),
|
||||||
|
DisplayPlaylist.playlist_id == playlist.id,
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
# cleanup uploaded files for image/video items
|
# cleanup uploaded files for image/video items
|
||||||
for it in list(playlist.items):
|
for it in list(playlist.items):
|
||||||
if it.item_type in ("image", "video"):
|
if it.item_type in ("image", "video"):
|
||||||
@@ -900,3 +915,87 @@ def update_display(display_id: int):
|
|||||||
|
|
||||||
flash("Display updated", "success")
|
flash("Display updated", "success")
|
||||||
return redirect(url_for("company.dashboard"))
|
return redirect(url_for("company.dashboard"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/displays/<int:display_id>/playlists")
|
||||||
|
@login_required
|
||||||
|
def update_display_playlists(display_id: int):
|
||||||
|
"""Set active playlists for a display.
|
||||||
|
|
||||||
|
Expects JSON: { playlist_ids: [1,2,3] }
|
||||||
|
Returns JSON with the updated assigned playlist ids.
|
||||||
|
|
||||||
|
Note: if playlist_ids is empty, the display will have no active playlists.
|
||||||
|
For backwards compatibility, this does NOT modify Display.assigned_playlist_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
|
||||||
|
display = db.session.get(Display, display_id)
|
||||||
|
if not display or display.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if not request.is_json:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
raw_ids = payload.get("playlist_ids")
|
||||||
|
if raw_ids is None:
|
||||||
|
return jsonify({"ok": False, "error": "playlist_ids is required"}), 400
|
||||||
|
|
||||||
|
if not isinstance(raw_ids, list):
|
||||||
|
return jsonify({"ok": False, "error": "playlist_ids must be a list"}), 400
|
||||||
|
|
||||||
|
playlist_ids: list[int] = []
|
||||||
|
try:
|
||||||
|
for x in raw_ids:
|
||||||
|
if x in (None, ""):
|
||||||
|
continue
|
||||||
|
playlist_ids.append(int(x))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "error": "Invalid playlist id"}), 400
|
||||||
|
|
||||||
|
# Ensure playlists belong to this company.
|
||||||
|
if playlist_ids:
|
||||||
|
allowed = {
|
||||||
|
p.id
|
||||||
|
for p in Playlist.query.filter(
|
||||||
|
Playlist.company_id == current_user.company_id,
|
||||||
|
Playlist.id.in_(playlist_ids),
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
if len(allowed) != len(set(playlist_ids)):
|
||||||
|
return jsonify({"ok": False, "error": "One or more playlists are invalid"}), 400
|
||||||
|
|
||||||
|
# Replace mapping rows.
|
||||||
|
DisplayPlaylist.query.filter_by(display_id=display.id).delete(synchronize_session=False)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
for pos, pid in enumerate(dict.fromkeys(playlist_ids), start=1):
|
||||||
|
db.session.add(
|
||||||
|
DisplayPlaylist(
|
||||||
|
display_id=display.id,
|
||||||
|
playlist_id=pid,
|
||||||
|
position=pos,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
active_ids = [
|
||||||
|
r[0]
|
||||||
|
for r in db.session.query(DisplayPlaylist.playlist_id)
|
||||||
|
.filter(DisplayPlaylist.display_id == display.id)
|
||||||
|
.order_by(DisplayPlaylist.position.asc(), DisplayPlaylist.playlist_id.asc())
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"display": {
|
||||||
|
"id": display.id,
|
||||||
|
"active_playlist_ids": active_ids,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -72,16 +72,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex flex-column gap-2 mt-auto">
|
<div class="d-flex flex-column gap-2 mt-auto">
|
||||||
<select
|
{# Multi-playlist selector: button opens modal with playlist checkboxes #}
|
||||||
class="form-select form-select-sm js-playlist-select"
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ink btn-sm js-edit-playlists"
|
||||||
data-display-id="{{ d.id }}"
|
data-display-id="{{ d.id }}"
|
||||||
aria-label="Playlist selection"
|
data-display-name="{{ d.name }}"
|
||||||
|
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
|
||||||
|
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
|
||||||
>
|
>
|
||||||
<option value="">(none)</option>
|
Select playlists
|
||||||
{% for p in playlists %}
|
</button>
|
||||||
<option value="{{ p.id }}" {% if d.assigned_playlist_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
<div class="small text-muted">
|
||||||
{% endfor %}
|
<span class="js-active-playlists-summary" data-display-id="{{ d.id }}">—</span>
|
||||||
</select>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
<button
|
<button
|
||||||
@@ -136,11 +142,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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="editPlaylistsModalTitle">Select playlists</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<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="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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page_scripts %}
|
{% block page_scripts %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
let ALL_PLAYLISTS = [];
|
||||||
|
try {
|
||||||
|
const el = document.getElementById('allPlaylistsJson');
|
||||||
|
ALL_PLAYLISTS = el ? JSON.parse(el.textContent || '[]') : [];
|
||||||
|
} catch (e) {
|
||||||
|
ALL_PLAYLISTS = [];
|
||||||
|
}
|
||||||
|
|
||||||
const toastEl = document.getElementById('companyToast');
|
const toastEl = document.getElementById('companyToast');
|
||||||
const toastBodyEl = document.getElementById('companyToastBody');
|
const toastBodyEl = document.getElementById('companyToastBody');
|
||||||
const toast = toastEl ? new bootstrap.Toast(toastEl, { delay: 2200 }) : null;
|
const toast = toastEl ? new bootstrap.Toast(toastEl, { delay: 2200 }) : null;
|
||||||
@@ -187,23 +225,65 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playlist auto-save
|
function parseIds(csv) {
|
||||||
document.querySelectorAll('.js-playlist-select').forEach((sel) => {
|
const s = (csv || '').trim();
|
||||||
sel.addEventListener('change', async () => {
|
if (!s) return [];
|
||||||
const displayId = sel.dataset.displayId;
|
return s.split(',').map(x => parseInt(x, 10)).filter(n => Number.isFinite(n));
|
||||||
const playlistId = sel.value || null;
|
|
||||||
sel.disabled = true;
|
|
||||||
try {
|
|
||||||
await postDisplayUpdate(displayId, { playlist_id: playlistId });
|
|
||||||
showToast('Playlist saved', 'text-bg-success');
|
|
||||||
refreshPreviewIframe(displayId);
|
|
||||||
} catch (e) {
|
|
||||||
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
|
||||||
} finally {
|
|
||||||
sel.disabled = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Description modal
|
// Description modal
|
||||||
const modalEl = document.getElementById('editDescModal');
|
const modalEl = document.getElementById('editDescModal');
|
||||||
@@ -264,6 +344,95 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
let activePlDisplayId = null;
|
||||||
|
let activePlButton = null;
|
||||||
|
|
||||||
|
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 = `Select playlists — ${displayName}`;
|
||||||
|
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();
|
||||||
|
plSaveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const updated = await postDisplayPlaylists(activePlDisplayId, ids);
|
||||||
|
const newIds = (updated && updated.active_playlist_ids) ? updated.active_playlist_ids : ids;
|
||||||
|
setActiveIdsOnButton(activePlButton, newIds);
|
||||||
|
refreshActivePlaylistSummary(activePlDisplayId, newIds);
|
||||||
|
showToast('Playlists saved', 'text-bg-success');
|
||||||
|
refreshPreviewIframe(activePlDisplayId);
|
||||||
|
if (plModal) plModal.hide();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
||||||
|
} finally {
|
||||||
|
plSaveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plSaveBtn) {
|
||||||
|
plSaveBtn.addEventListener('click', savePlaylists);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -369,6 +369,7 @@
|
|||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
(function() {
|
(function() {
|
||||||
|
// Keep the card layout in ONE place to ensure newly-added items match server-rendered items.
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Add-item modal + steps
|
// Add-item modal + steps
|
||||||
// -------------------------
|
// -------------------------
|
||||||
@@ -880,7 +881,7 @@
|
|||||||
<strong>#${i.position}</strong>
|
<strong>#${i.position}</strong>
|
||||||
${badge}
|
${badge}
|
||||||
</div>
|
</div>
|
||||||
${safeTitle ? `<div class="small">${safeTitle}</div>` : ''}
|
${safeTitle ? `<div class="small">${safeTitle}</div>` : `<div class="small">.</div>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="${deleteAction}" onsubmit="return confirm('Delete item?');">
|
<form method="post" action="${deleteAction}" onsubmit="return confirm('Delete item?');">
|
||||||
@@ -888,11 +889,11 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<div class="thumb">${thumb}</div>
|
||||||
<div class="text-muted small d-flex align-items-center gap-2 flex-wrap">
|
<div class="text-muted small d-flex align-items-center gap-2 flex-wrap">
|
||||||
<!-- Intentionally do NOT show file names or URLs for privacy/clean UI -->
|
<!-- Intentionally do NOT show file names or URLs for privacy/clean UI -->
|
||||||
${durationInput}
|
${durationInput}
|
||||||
</div>
|
</div>
|
||||||
<div class="thumb">${thumb}</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,62 @@
|
|||||||
<style>
|
<style>
|
||||||
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
||||||
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
||||||
|
#notice {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.86);
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||||
|
z-index: 10;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#notice .box {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
#notice .title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
#notice .msg {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
|
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
|
||||||
/* removed bottom-left status text */
|
/* removed bottom-left status text */
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="notice" role="alert" aria-live="assertive">
|
||||||
|
<div class="box">
|
||||||
|
<p class="title" id="noticeTitle">Notice</p>
|
||||||
|
<p class="msg" id="noticeText"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="stage"></div>
|
<div id="stage"></div>
|
||||||
<script>
|
<script>
|
||||||
const token = "{{ display.token }}";
|
const token = "{{ display.token }}";
|
||||||
const stage = document.getElementById('stage');
|
const stage = document.getElementById('stage');
|
||||||
function setNotice(_text) { /* intentionally no-op: notice UI removed */ }
|
const noticeEl = document.getElementById('notice');
|
||||||
|
const noticeTitleEl = document.getElementById('noticeTitle');
|
||||||
|
const noticeTextEl = document.getElementById('noticeText');
|
||||||
|
function setNotice(text, { title } = {}) {
|
||||||
|
const t = (text || '').trim();
|
||||||
|
if (!t) {
|
||||||
|
noticeEl.style.display = 'none';
|
||||||
|
noticeTextEl.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noticeTitleEl.textContent = title || 'Notice';
|
||||||
|
noticeTextEl.textContent = t;
|
||||||
|
noticeEl.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
|
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
|
||||||
|
|
||||||
@@ -55,7 +101,7 @@
|
|||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
||||||
setNotice('No playlist assigned.');
|
setNotice('No playlists assigned.');
|
||||||
clearStage();
|
clearStage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,7 +110,7 @@
|
|||||||
idx = (idx + 1) % playlist.items.length;
|
idx = (idx + 1) % playlist.items.length;
|
||||||
|
|
||||||
clearStage();
|
clearStage();
|
||||||
setNotice(playlist.playlist ? `${playlist.display} — ${playlist.playlist.name}` : playlist.display);
|
setNotice('');
|
||||||
|
|
||||||
if (item.type === 'image') {
|
if (item.type === 'image') {
|
||||||
const el = document.createElement('img');
|
const el = document.createElement('img');
|
||||||
@@ -108,7 +154,14 @@
|
|||||||
next();
|
next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearStage();
|
clearStage();
|
||||||
setNotice(e && e.message ? e.message : 'Unable to load playlist.');
|
if (e && e.code === 'LIMIT') {
|
||||||
|
setNotice(
|
||||||
|
(e && e.message) ? e.message : 'This display cannot start because the concurrent display limit has been reached.',
|
||||||
|
{ title: 'Display limit reached' }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setNotice(e && e.message ? e.message : 'Unable to load playlist.', { title: 'Playback error' });
|
||||||
|
}
|
||||||
// keep retrying; if a slot frees up the display will start automatically.
|
// keep retrying; if a slot frees up the display will start automatically.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +191,14 @@
|
|||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
clearStage();
|
clearStage();
|
||||||
setNotice(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' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, pollSeconds * 1000);
|
}, pollSeconds * 1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ services:
|
|||||||
# - gunicorn
|
# - gunicorn
|
||||||
volumes:
|
volumes:
|
||||||
# Persist SQLite DB and uploads on the host
|
# Persist SQLite DB and uploads on the host
|
||||||
- ./instance:/app/instance
|
- data/fossign/instance:/app/instance
|
||||||
- ./app/static/uploads:/app/app/static/uploads
|
- data/fossign/uploads:/app/app/static/uploads
|
||||||
|
|||||||
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()
|
||||||
Reference in New Issue
Block a user