edited230126

This commit is contained in:
2026-01-23 18:07:29 +01:00
parent 32312fe4f2
commit 138136e835
18 changed files with 1354 additions and 283 deletions

View File

@@ -20,7 +20,7 @@ python -m venv .venv
pip install -r requirements.txt pip install -r requirements.txt
set FLASK_APP=app set FLASK_APP=app
flask init-db --admin-user admin --admin-pass admin flask init-db --admin-email beheer@alphen.cloud --admin-pass admin
flask run --debug flask run --debug
``` ```
@@ -85,3 +85,6 @@ If the reset email is not received:

View File

@@ -1,5 +1,6 @@
import os import os
from flask import Flask from flask import Flask, jsonify, request
from werkzeug.exceptions import RequestEntityTooLarge
from .extensions import db, login_manager from .extensions import db, login_manager
from .models import User from .models import User
@@ -18,7 +19,14 @@ def create_app():
) )
app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False) app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False)
app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads")) app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads"))
app.config.setdefault("MAX_CONTENT_LENGTH", 500 * 1024 * 1024) # 500MB
# 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
# blocking excessively large uploads early.
app.config.setdefault("MAX_CONTENT_LENGTH", 260 * 1024 * 1024) # ~260MB request cap
# Explicit per-video validation lives in the upload route; this app-wide cap is a safety net.
os.makedirs(app.instance_path, exist_ok=True) os.makedirs(app.instance_path, exist_ok=True)
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
@@ -40,6 +48,14 @@ def create_app():
# Best-effort unique index (SQLite doesn't support adding unique constraints after the fact). # Best-effort unique index (SQLite doesn't support adding unique constraints after the fact).
db.session.execute(db.text("CREATE UNIQUE INDEX IF NOT EXISTS ix_user_email ON user (email)")) db.session.execute(db.text("CREATE UNIQUE INDEX IF NOT EXISTS ix_user_email ON user (email)"))
db.session.commit() db.session.commit()
# Displays: ensure optional description column exists.
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: except Exception:
db.session.rollback() db.session.rollback()
@@ -75,4 +91,25 @@ def create_app():
return redirect(url_for("admin.dashboard")) return redirect(url_for("admin.dashboard"))
return redirect(url_for("company.dashboard")) return redirect(url_for("company.dashboard"))
@app.errorhandler(RequestEntityTooLarge)
def handle_request_too_large(e):
"""Return a user-friendly message when uploads exceed MAX_CONTENT_LENGTH."""
# Keep behavior consistent with our AJAX endpoints.
wants_json = (
(request.headers.get("X-Requested-With") == "XMLHttpRequest")
or ("application/json" in (request.headers.get("Accept") or ""))
or request.is_json
or (request.form.get("response") == "json")
)
msg = "Upload too large. Videos must be 250MB or smaller."
if wants_json:
return jsonify({"ok": False, "error": msg}), 413
# For non-AJAX form posts, redirect back with a flash message.
from flask import flash, redirect
flash(msg, "danger")
return redirect(request.referrer or url_for("company.dashboard")), 413
return app return app

View File

@@ -6,10 +6,16 @@ from .models import User
@click.command("init-db") @click.command("init-db")
@click.option("--admin-user", required=True, help="Username for the initial admin") @click.option(
"--admin-email",
required=False,
default="beheer@alphen.cloud",
show_default=True,
help="Email for the initial admin",
)
@click.option("--admin-pass", required=True, help="Password for the initial admin") @click.option("--admin-pass", required=True, help="Password for the initial admin")
@with_appcontext @with_appcontext
def init_db_command(admin_user: str, admin_pass: str): def init_db_command(admin_email: str, admin_pass: str):
"""Create tables and ensure an admin account exists.""" """Create tables and ensure an admin account exists."""
db.create_all() db.create_all()
@@ -20,21 +26,34 @@ def init_db_command(admin_user: str, admin_pass: str):
if "email" not in cols: if "email" not in cols:
db.session.execute(db.text("ALTER TABLE user ADD COLUMN email VARCHAR(255)")) db.session.execute(db.text("ALTER TABLE user ADD COLUMN email VARCHAR(255)"))
db.session.commit() 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: except Exception:
# Best-effort; if it fails we continue so fresh DBs still work. # Best-effort; if it fails we continue so fresh DBs still work.
db.session.rollback() db.session.rollback()
existing = User.query.filter_by(username=admin_user).first() admin_email = (admin_email or "").strip().lower()
if not admin_email:
raise click.UsageError("--admin-email is required")
existing = User.query.filter_by(email=admin_email).first()
if existing: if existing:
if not existing.is_admin: if not existing.is_admin:
existing.is_admin = True existing.is_admin = True
existing.email = admin_email
existing.username = admin_email
existing.set_password(admin_pass) existing.set_password(admin_pass)
db.session.commit() db.session.commit()
click.echo(f"Updated admin user '{admin_user}'.") click.echo(f"Updated admin user '{admin_email}'.")
return return
u = User(username=admin_user, is_admin=True) u = User(is_admin=True)
u.email = admin_email
u.username = admin_email
u.set_password(admin_pass) u.set_password(admin_pass)
db.session.add(u) db.session.add(u)
db.session.commit() db.session.commit()
click.echo(f"Created admin user '{admin_user}'.") click.echo(f"Created admin user '{admin_email}'.")

View File

@@ -19,8 +19,11 @@ class Company(db.Model):
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False) # Backwards compatibility: older SQLite DBs (and some templates) expect a username column.
email = db.Column(db.String(255), unique=True, nullable=True) # The app no longer uses username for login/display, but we keep it populated (= email)
# to avoid integrity errors without introducing Alembic migrations.
username = db.Column(db.String(255), unique=True, nullable=False)
email = db.Column(db.String(255), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=True) password_hash = db.Column(db.String(255), nullable=True)
is_admin = db.Column(db.Boolean, default=False, nullable=False) is_admin = db.Column(db.Boolean, default=False, nullable=False)
@@ -57,7 +60,7 @@ class PlaylistItem(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=False) playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=False)
# image|video|webpage # image|video|webpage|youtube
item_type = db.Column(db.String(20), nullable=False) item_type = db.Column(db.String(20), nullable=False)
title = db.Column(db.String(200), nullable=True) title = db.Column(db.String(200), nullable=True)
@@ -79,6 +82,8 @@ class Display(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False) company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False)
name = db.Column(db.String(120), nullable=False) name = db.Column(db.String(120), nullable=False)
# Optional short description (e.g. "entrance", "office")
description = db.Column(db.String(200), nullable=True)
token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex) token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex)
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True) assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)

View File

@@ -38,8 +38,7 @@ def _try_delete_upload(file_path: str | None, upload_folder: str):
def dashboard(): def dashboard():
admin_required() admin_required()
companies = Company.query.order_by(Company.name.asc()).all() companies = Company.query.order_by(Company.name.asc()).all()
users = User.query.order_by(User.username.asc()).all() return render_template("admin/dashboard.html", companies=companies)
return render_template("admin/dashboard.html", companies=companies, users=users)
@bp.post("/companies") @bp.post("/companies")
@@ -77,22 +76,19 @@ def create_company_user(company_id: int):
company = db.session.get(Company, company_id) company = db.session.get(Company, company_id)
if not company: if not company:
abort(404) abort(404)
username = request.form.get("username", "").strip()
email = (request.form.get("email", "") or "").strip().lower() or None email = (request.form.get("email", "") or "").strip().lower() or None
password = request.form.get("password", "") password = request.form.get("password", "")
if not username or not email or not password: if not email or not password:
flash("Username, email and password required", "danger") flash("Email and password required", "danger")
return redirect(url_for("admin.company_detail", company_id=company_id))
if User.query.filter_by(username=username).first():
flash("Username already exists", "danger")
return redirect(url_for("admin.company_detail", company_id=company_id)) return redirect(url_for("admin.company_detail", company_id=company_id))
if User.query.filter_by(email=email).first(): if User.query.filter_by(email=email).first():
flash("Email already exists", "danger") flash("Email already exists", "danger")
return redirect(url_for("admin.company_detail", company_id=company_id)) return redirect(url_for("admin.company_detail", company_id=company_id))
u = User(username=username, is_admin=False, company=company) u = User(is_admin=False, company=company)
u.email = email u.email = email
u.username = email
u.set_password(password) u.set_password(password)
db.session.add(u) db.session.add(u)
db.session.commit() db.session.commit()
@@ -167,7 +163,7 @@ def impersonate(user_id: int):
# Save admin id in session so we can return without any password. # Save admin id in session so we can return without any password.
session["impersonator_admin_id"] = current_user.id session["impersonator_admin_id"] = current_user.id
login_user(target) login_user(target)
flash(f"Impersonating {target.username}.", "warning") flash(f"Impersonating {target.email or '(no email)'}.", "warning")
return redirect(url_for("company.dashboard")) return redirect(url_for("company.dashboard"))
@@ -179,14 +175,40 @@ def update_user_email(user_id: int):
if not u: if not u:
abort(404) abort(404)
email = (request.form.get("email", "") or "").strip().lower() or None email = (request.form.get("email", "") or "").strip().lower()
if email: if not email:
flash("Email is required", "danger")
return redirect(url_for("admin.company_detail", company_id=u.company_id))
existing = User.query.filter(User.email == email, User.id != u.id).first() existing = User.query.filter(User.email == email, User.id != u.id).first()
if existing: if existing:
flash("Email already exists", "danger") flash("Email already exists", "danger")
return redirect(url_for("admin.company_detail", company_id=u.company_id)) return redirect(url_for("admin.company_detail", company_id=u.company_id))
u.email = email u.email = email
# keep backwards-compatible username column in sync
u.username = email
db.session.commit() db.session.commit()
flash("Email updated", "success") flash("Email updated", "success")
return redirect(url_for("admin.company_detail", company_id=u.company_id)) return redirect(url_for("admin.company_detail", company_id=u.company_id))
@bp.post("/displays/<int:display_id>/name")
@login_required
def update_display_name(display_id: int):
"""Admin: rename a display."""
admin_required()
display = db.session.get(Display, display_id)
if not display:
abort(404)
name = (request.form.get("name") or "").strip()
if not name:
flash("Display name is required", "danger")
return redirect(url_for("admin.company_detail", company_id=display.company_id))
display.name = name[:120]
db.session.commit()
flash("Display name updated", "success")
return redirect(url_for("admin.company_detail", company_id=display.company_id))

View File

@@ -75,7 +75,7 @@ def display_playlist(token: str):
} }
if item.item_type in ("image", "video") and item.file_path: if item.item_type in ("image", "video") and item.file_path:
payload["src"] = url_for("static", filename=item.file_path) payload["src"] = url_for("static", filename=item.file_path)
if item.item_type == "webpage": if item.item_type in ("webpage", "youtube"):
payload["url"] = item.url payload["url"] = item.url
items.append(payload) items.append(payload)

View File

@@ -173,12 +173,12 @@ def login():
@bp.post("/login") @bp.post("/login")
def login_post(): def login_post():
username = request.form.get("username", "").strip() email = (request.form.get("email", "") or "").strip().lower()
password = request.form.get("password", "") password = request.form.get("password", "")
user = User.query.filter_by(username=username).first() user = User.query.filter_by(email=email).first()
if not user or not user.check_password(password): if not user or not user.check_password(password):
flash("Invalid username/password", "danger") flash("Invalid email/password", "danger")
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
# clear impersonation marker, if any # clear impersonation marker, if any

View File

@@ -1,7 +1,8 @@
import os import os
import uuid import uuid
from urllib.parse import urlparse, parse_qs
from flask import Blueprint, abort, current_app, flash, redirect, render_template, request, url_for from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
@@ -11,6 +12,69 @@ from ..extensions import db
from ..models import Display, Playlist, PlaylistItem from ..models import Display, Playlist, PlaylistItem
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"}
# Videos should have a maximum upload size of 250MB
MAX_VIDEO_BYTES = 250 * 1024 * 1024
def _normalize_youtube_embed_url(raw: str) -> str | None:
"""Normalize a user-provided YouTube URL into a privacy-friendly embed base URL.
Returns:
https://www.youtube-nocookie.com/embed/<VIDEO_ID>
or None if we cannot parse a valid video id.
"""
val = (raw or "").strip()
if not val:
return None
# Be forgiving for inputs like "youtu.be/<id>".
if not val.startswith("http://") and not val.startswith("https://"):
val = "https://" + val
try:
u = urlparse(val)
except Exception:
return None
host = (u.netloc or "").lower()
host = host[4:] if host.startswith("www.") else host
video_id: str | None = None
path = (u.path or "").strip("/")
if host in {"youtube.com", "m.youtube.com"}:
# /watch?v=<id>
if path == "watch":
v = (parse_qs(u.query).get("v") or [None])[0]
video_id = v
# /embed/<id>
elif path.startswith("embed/"):
video_id = path.split("/", 1)[1]
# /shorts/<id>
elif path.startswith("shorts/"):
video_id = path.split("/", 1)[1]
elif host == "youtu.be":
# /<id>
if path:
video_id = path.split("/", 1)[0]
# Basic validation: YouTube IDs are typically 11 chars (letters/digits/_/-)
if not video_id:
return None
video_id = video_id.strip()
if len(video_id) != 11:
return None
for ch in video_id:
if not (ch.isalnum() or ch in {"_", "-"}):
return None
return f"https://www.youtube-nocookie.com/embed/{video_id}"
def _save_compressed_image(uploaded_file, upload_folder: str) -> str: def _save_compressed_image(uploaded_file, upload_folder: str) -> str:
"""Save an uploaded image as a compressed WEBP file. """Save an uploaded image as a compressed WEBP file.
@@ -129,7 +193,10 @@ def reorder_playlist_items(playlist_id: int):
if not playlist or playlist.company_id != current_user.company_id: if not playlist or playlist.company_id != current_user.company_id:
abort(404) abort(404)
# Accept both form and JSON payloads.
order = (request.form.get("order") or "").strip() order = (request.form.get("order") or "").strip()
if not order and request.is_json:
order = ((request.get_json(silent=True) or {}).get("order") or "").strip()
if not order: if not order:
abort(400) abort(400)
@@ -150,6 +217,16 @@ def reorder_playlist_items(playlist_id: int):
id_to_item[item_id].position = pos id_to_item[item_id].position = pos
db.session.commit() db.session.commit()
# Client currently doesn't require JSON, but returning JSON if requested
# helps debugging and future enhancements.
wants_json = (
(request.headers.get("X-Requested-With") == "XMLHttpRequest")
or ("application/json" in (request.headers.get("Accept") or ""))
or request.is_json
)
if wants_json:
return jsonify({"ok": True})
return ("", 204) return ("", 204)
@@ -161,9 +238,25 @@ def add_playlist_item(playlist_id: int):
if not playlist or playlist.company_id != current_user.company_id: if not playlist or playlist.company_id != current_user.company_id:
abort(404) abort(404)
item_type = request.form.get("item_type") # Support AJAX/modal usage: return JSON when requested.
wants_json = (
(request.headers.get("X-Requested-With") == "XMLHttpRequest")
or ("application/json" in (request.headers.get("Accept") or ""))
or (request.form.get("response") == "json")
)
def _json_error(message: str, status: int = 400):
return jsonify({"ok": False, "error": message}), status
item_type = (request.form.get("item_type") or "").strip().lower()
title = request.form.get("title", "").strip() or None title = request.form.get("title", "").strip() or None
duration = int(request.form.get("duration_seconds") or 10)
# Duration is only used for image/webpage. Video/YouTube plays until ended.
raw_duration = request.form.get("duration_seconds")
try:
duration = int(raw_duration) if raw_duration is not None else 10
except (TypeError, ValueError):
duration = 10
max_pos = ( max_pos = (
db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0 db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0
@@ -181,37 +274,137 @@ def add_playlist_item(playlist_id: int):
if item_type in ("image", "video"): if item_type in ("image", "video"):
f = request.files.get("file") f = request.files.get("file")
if not f or not f.filename: if not f or not f.filename:
if wants_json:
return _json_error("File required")
flash("File required", "danger") flash("File required", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
filename = secure_filename(f.filename) filename = secure_filename(f.filename)
ext = os.path.splitext(filename)[1].lower() ext = os.path.splitext(filename)[1].lower()
if item_type == "image" and ext in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"): if item_type == "image":
if ext not in ALLOWED_IMAGE_EXTENSIONS:
if wants_json:
return _json_error(
"Unsupported image type. Please upload one of: " + ", ".join(sorted(ALLOWED_IMAGE_EXTENSIONS))
)
flash("Unsupported image type", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
try: try:
item.file_path = _save_compressed_image(f, current_app.config["UPLOAD_FOLDER"]) item.file_path = _save_compressed_image(f, current_app.config["UPLOAD_FOLDER"])
except Exception: except Exception:
if wants_json:
return _json_error("Failed to process image upload", 500)
flash("Failed to process image upload", "danger") flash("Failed to process image upload", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
else: else:
# Videos (and unknown image types): keep as-is but always rename to a UUID if ext not in ALLOWED_VIDEO_EXTENSIONS:
if wants_json:
return _json_error(
"Unsupported video type. Please upload one of: " + ", ".join(sorted(ALLOWED_VIDEO_EXTENSIONS))
)
flash("Unsupported video type", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
# Enforce video size limit (250MB) with a clear error message.
# This is separate from Flask's MAX_CONTENT_LENGTH, which caps the full request.
size = None
try:
size = getattr(f, "content_length", None)
# Werkzeug may report 0 for unknown per-part length.
if (size is None or size <= 0) and hasattr(f, "stream"):
# Measure by seeking in the file-like stream.
pos = f.stream.tell()
f.stream.seek(0, os.SEEK_END)
size = f.stream.tell()
f.stream.seek(pos, os.SEEK_SET)
except Exception:
size = None
if size is not None and size > MAX_VIDEO_BYTES:
msg = "Video file too large. Maximum allowed size is 250MB."
if wants_json:
return _json_error(msg, 413)
flash(msg, "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
# Keep as-is but always rename to a UUID.
unique = uuid.uuid4().hex + ext unique = uuid.uuid4().hex + ext
save_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique) save_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique)
f.save(save_path) f.save(save_path)
# Safety check: validate using the actual saved file size.
# (Some clients/framework layers don't reliably report per-part size.)
try:
saved_size = os.path.getsize(save_path)
except OSError:
saved_size = None
if saved_size is not None and saved_size > MAX_VIDEO_BYTES:
try:
os.remove(save_path)
except OSError:
pass
msg = "Video file too large. Maximum allowed size is 250MB."
if wants_json:
return _json_error(msg, 413)
flash(msg, "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
item.file_path = f"uploads/{unique}" item.file_path = f"uploads/{unique}"
elif item_type == "webpage": elif item_type == "webpage":
url = request.form.get("url", "").strip() url = request.form.get("url", "").strip()
if not url: if not url:
if wants_json:
return _json_error("URL required")
flash("URL required", "danger") flash("URL required", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
item.url = url item.url = url
elif item_type == "youtube":
raw = request.form.get("url", "").strip()
if not raw:
if wants_json:
return _json_error("YouTube URL required")
flash("YouTube URL required", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
embed_url = _normalize_youtube_embed_url(raw)
if not embed_url:
if wants_json:
return _json_error("Invalid YouTube URL")
flash("Invalid YouTube URL", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
item.url = embed_url
else: else:
if wants_json:
return _json_error("Invalid item type")
flash("Invalid item type", "danger") flash("Invalid item type", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
db.session.add(item) db.session.add(item)
db.session.commit() db.session.commit()
if wants_json:
return jsonify(
{
"ok": True,
"item": {
"id": item.id,
"playlist_id": item.playlist_id,
"position": item.position,
"item_type": item.item_type,
"title": item.title,
"file_path": item.file_path,
"url": item.url,
"duration_seconds": item.duration_seconds,
},
}
)
flash("Item added", "success") flash("Item added", "success")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@@ -234,6 +427,52 @@ def delete_item(item_id: int):
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/items/<int:item_id>/duration")
@login_required
def update_item_duration(item_id: int):
"""Update duration_seconds for a playlist item.
Used from the playlist overview (inline edit).
"""
company_user_required()
item = db.session.get(PlaylistItem, item_id)
if not item or item.playlist.company_id != current_user.company_id:
abort(404)
# Duration only applies to images/webpages; videos play until ended.
if item.item_type == "video":
return jsonify({"ok": False, "error": "Duration cannot be set for video items"}), 400
wants_json = (
(request.headers.get("X-Requested-With") == "XMLHttpRequest")
or ("application/json" in (request.headers.get("Accept") or ""))
or request.is_json
)
def _json_error(message: str, status: int = 400):
return jsonify({"ok": False, "error": message}), status
raw = request.form.get("duration_seconds")
if raw is None and request.is_json:
raw = (request.get_json(silent=True) or {}).get("duration_seconds")
try:
duration = int(raw)
except (TypeError, ValueError):
if wants_json:
return _json_error("Invalid duration")
abort(400)
item.duration_seconds = max(1, duration)
db.session.commit()
if wants_json:
return jsonify({"ok": True, "duration_seconds": item.duration_seconds})
return ("", 204)
@bp.post("/displays/<int:display_id>/assign") @bp.post("/displays/<int:display_id>/assign")
@login_required @login_required
def assign_playlist(display_id: int): def assign_playlist(display_id: int):
@@ -252,3 +491,42 @@ def assign_playlist(display_id: int):
db.session.commit() db.session.commit()
flash("Display assignment updated", "success") flash("Display assignment updated", "success")
return redirect(url_for("company.dashboard")) return redirect(url_for("company.dashboard"))
@bp.post("/displays/<int:display_id>")
@login_required
def update_display(display_id: int):
"""Update display metadata (description + assigned playlist).
Company users should be able to set a short description per display and assign a playlist.
"""
company_user_required()
display = db.session.get(Display, display_id)
if not display or display.company_id != current_user.company_id:
abort(404)
# Description (short, optional)
desc = (request.form.get("description") or "").strip() or None
if desc is not None:
desc = desc[:200]
display.description = desc
# Playlist assignment
playlist_id = (request.form.get("playlist_id") or "").strip()
if not playlist_id:
display.assigned_playlist_id = None
else:
try:
playlist_id_int = int(playlist_id)
except ValueError:
abort(400)
playlist = db.session.get(Playlist, playlist_id_int)
if not playlist or playlist.company_id != current_user.company_id:
abort(400)
display.assigned_playlist_id = playlist.id
db.session.commit()
flash("Display updated", "success")
return redirect(url_for("company.dashboard"))

View File

@@ -27,10 +27,6 @@
<div class="col-md-6"> <div class="col-md-6">
<h2 class="h5">Users</h2> <h2 class="h5">Users</h2>
<form method="post" action="{{ url_for('admin.create_company_user', company_id=company.id) }}" class="card card-body mb-3"> <form method="post" action="{{ url_for('admin.create_company_user', company_id=company.id) }}" class="card card-body mb-3">
<div class="mb-2">
<label class="form-label">Username</label>
<input class="form-control" name="username" required />
</div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Email</label> <label class="form-label">Email</label>
<input class="form-control" type="email" name="email" required /> <input class="form-control" type="email" name="email" required />
@@ -46,7 +42,7 @@
{% for u in company.users %} {% for u in company.users %}
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item d-flex justify-content-between align-items-center">
<div> <div>
<strong>{{ u.username }}</strong> <strong>{{ u.email or "(no email)" }}</strong>
<div class="text-muted">{{ u.email or "(no email set)" }}</div> <div class="text-muted">{{ u.email or "(no email set)" }}</div>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
@@ -79,7 +75,17 @@
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div> <div>
<strong>{{ d.name }}</strong> <form method="post" action="{{ url_for('admin.update_display_name', display_id=d.id) }}" class="d-flex gap-2 align-items-center">
<input
class="form-control form-control-sm"
style="max-width: 260px"
name="name"
value="{{ d.name }}"
required
maxlength="120"
/>
<button class="btn btn-outline-primary btn-sm" type="submit">Save</button>
</form>
<div class="text-muted monospace">Token: {{ d.token }}</div> <div class="text-muted monospace">Token: {{ d.token }}</div>
<div class="text-muted">Player URL: <a href="{{ url_for('display.display_player', token=d.token) }}" target="_blank">{{ url_for('display.display_player', token=d.token, _external=true) }}</a></div> <div class="text-muted">Player URL: <a href="{{ url_for('display.display_player', token=d.token) }}" target="_blank">{{ url_for('display.display_player', token=d.token, _external=true) }}</a></div>
</div> </div>

View File

@@ -5,7 +5,7 @@
</div> </div>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-6"> <div class="col-md-12">
<h2 class="h5">Companies</h2> <h2 class="h5">Companies</h2>
<form method="post" action="{{ url_for('admin.create_company') }}" class="card card-body mb-3"> <form method="post" action="{{ url_for('admin.create_company') }}" class="card card-body mb-3">
<div class="input-group"> <div class="input-group">
@@ -25,30 +25,5 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="col-md-6">
<h2 class="h5">Users</h2>
<div class="list-group">
{% for u in users %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div><strong>{{ u.username }}</strong> {% if u.is_admin %}<span class="badge bg-dark">admin</span>{% endif %}</div>
<div class="text-muted">
{% if u.company %}Company: {{ u.company.name }}{% else %}No company{% endif %}
</div>
</div>
<div>
{% if not u.is_admin %}
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
<button class="btn btn-warning btn-sm" type="submit">Impersonate</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<div class="text-muted">No users yet.</div>
{% endfor %}
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -5,8 +5,8 @@
<h1 class="h3 mb-3">Login</h1> <h1 class="h3 mb-3">Login</h1>
<form method="post" class="card card-body"> <form method="post" class="card card-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Username</label> <label class="form-label">Email</label>
<input class="form-control" name="username" autocomplete="username" required /> <input class="form-control" type="email" name="email" autocomplete="email" required />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label class="form-label">Password</label>

View File

@@ -24,7 +24,7 @@
</ul> </ul>
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item"><span class="navbar-text me-3">Logged in as <strong>{{ current_user.username }}</strong></span></li> <li class="nav-item"><span class="navbar-text me-3">Logged in as <strong>{{ current_user.email }}</strong></span></li>
<li class="nav-item"><a class="btn btn-outline-light btn-sm me-2" href="{{ url_for('auth.change_password') }}">Change password</a></li> <li class="nav-item"><a class="btn btn-outline-light btn-sm me-2" href="{{ url_for('auth.change_password') }}">Change password</a></li>
{% if session.get('impersonator_admin_id') %} {% if session.get('impersonator_admin_id') %}
<li class="nav-item"><a class="btn btn-warning btn-sm me-2" href="{{ url_for('auth.stop_impersonation') }}">Stop impersonation</a></li> <li class="nav-item"><a class="btn btn-warning btn-sm me-2" href="{{ url_for('auth.stop_impersonation') }}">Stop impersonation</a></li>

View File

@@ -38,17 +38,29 @@
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<div><strong>{{ d.name }}</strong></div> <div><strong>{{ d.name }}</strong></div>
<div class="text-muted">Player URL: <a href="{{ url_for('display.display_player', token=d.token, _external=true) }}" target="_blank">open</a></div> {% if d.description %}
<div class="text-muted">{{ d.description }}</div>
{% endif %}
</div> </div>
<div style="min-width: 220px;"> <div style="min-width: 220px;">
<form method="post" action="{{ url_for('company.assign_playlist', display_id=d.id) }}" class="d-flex gap-2"> <form method="post" action="{{ url_for('company.update_display', display_id=d.id) }}" class="d-flex flex-column gap-2">
<input
class="form-control form-control-sm"
name="description"
placeholder="Description (e.g. entrance, office)"
value="{{ d.description or '' }}"
maxlength="200"
/>
<div class="d-flex gap-2">
<select class="form-select form-select-sm" name="playlist_id"> <select class="form-select form-select-sm" name="playlist_id">
<option value="">(none)</option> <option value="">(none)</option>
{% for p in playlists %} {% for p in playlists %}
<option value="{{ p.id }}" {% if d.assigned_playlist_id == p.id %}selected{% endif %}>{{ p.name }}</option> <option value="{{ p.id }}" {% if d.assigned_playlist_id == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<button class="btn btn-primary btn-sm" type="submit">Assign</button> <button class="btn btn-primary btn-sm" type="submit">Save</button>
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,66 @@
{% block content %} {% block content %}
{# Cropper.js (used for image cropping) #} {# Cropper.js (used for image cropping) #}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/cropperjs/cropper.min.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/cropperjs/cropper.min.css') }}" />
<style>
/* Gallery grid for playlist items */
#playlist-items.playlist-gallery,
#playlist-items.playlist-gallery:not(.list-group) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.playlist-card {
background: #fff;
border: 1px solid rgba(0,0,0,.125);
border-radius: .5rem;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
}
.playlist-card.dragging {
opacity: .5;
}
.playlist-card .card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: .5rem;
padding: .5rem .75rem;
border-bottom: 1px solid rgba(0,0,0,.08);
}
.playlist-card .drag-handle {
width: 26px;
cursor: grab;
user-select: none;
color: #6c757d;
flex: 0 0 auto;
}
.playlist-card .card-body {
padding: .5rem .75rem .75rem;
}
.playlist-card .thumb {
width: 100%;
aspect-ratio: 16 / 9;
background: #111;
border-radius: .35rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
margin-top: .5rem;
}
.playlist-card .thumb img,
.playlist-card .thumb video,
.playlist-card .thumb iframe {
width: 100%;
height: 100%;
object-fit: cover;
border: 0;
}
/* Modal step visibility */
.step { display: none; }
.step.active { display: block; }
</style>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h1 class="h3">Playlist: {{ playlist.name }}</h1> <h1 class="h3">Playlist: {{ playlist.name }}</h1>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
@@ -12,10 +72,126 @@
</div> </div>
</div> </div>
<div class="row mt-4"> <div class="d-flex justify-content-between align-items-center mt-4">
<div class="col-md-5"> <div>
<h2 class="h5">Add item</h2> <h2 class="h5 mb-0">Items</h2>
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data" class="card card-body"> <div class="text-muted small">Tip: drag items to reorder. Changes save automatically.</div>
</div>
<button class="btn btn-success" type="button" id="open-add-item">Add item</button>
</div>
<div class="mt-3">
<div class="small text-muted mb-2" id="reorder-status" aria-live="polite"></div>
<div
class="playlist-gallery"
id="playlist-items"
data-reorder-url="{{ url_for('company.reorder_playlist_items', playlist_id=playlist.id) }}"
data-delete-base="{{ url_for('company.delete_item', item_id=0) }}"
data-duration-base="{{ url_for('company.update_item_duration', item_id=0) }}"
>
{% for i in playlist.items %}
<div class="playlist-card" draggable="true" data-item-id="{{ i.id }}">
<div class="card-top">
<div class="d-flex gap-2">
<div class="drag-handle" title="Drag to reorder"></div>
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<strong>#{{ i.position }}</strong>
<span class="badge bg-secondary">{{ i.item_type }}</span>
</div>
{% if i.title %}
<div class="small">{{ i.title }}</div>
{% else %}
<div class="small">.</div>
{% endif %}
</div>
</div>
<form method="post" action="{{ url_for('company.delete_item', item_id=i.id) }}" onsubmit="return confirm('Delete item?');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</div>
<div class="card-body">
<div class="thumb">
{% if i.item_type == 'image' and i.file_path %}
<img src="{{ url_for('static', filename=i.file_path) }}" alt="{{ i.title or 'image' }}" loading="lazy" />
{% elif i.item_type == 'video' and i.file_path %}
<video src="{{ url_for('static', filename=i.file_path) }}" muted controls preload="metadata"></video>
{% elif i.item_type == 'webpage' and i.url %}
<iframe src="{{ i.url }}" title="{{ i.title or 'webpage' }}" loading="lazy" referrerpolicy="no-referrer"></iframe>
{% elif i.item_type == 'youtube' and i.url %}
<iframe src="{{ i.url }}" title="{{ i.title or 'youtube' }}" loading="lazy" referrerpolicy="no-referrer"></iframe>
{% else %}
<div class="text-muted">No preview</div>
{% endif %}
</div>
<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 #}
{% if i.item_type != 'video' %}
<label class="text-nowrap" style="margin: 0;">
<span class="me-1">Duration</span>
<input
class="form-control form-control-sm d-inline-block js-duration-input"
style="width: 92px;"
type="number"
min="1"
value="{{ i.duration_seconds }}"
data-item-id="{{ i.id }}"
aria-label="Duration seconds"
/>
<span class="ms-1">s</span>
</label>
<span class="small text-muted js-duration-status" data-item-id="{{ i.id }}"></span>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="text-muted">No items.</div>
{% endfor %}
</div>
{# Add Item Modal (multi-step) #}
<div class="modal fade" id="addItemModal" tabindex="-1" aria-labelledby="addItemModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addItemModalLabel">Add item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data">
<input type="hidden" name="response" value="json" />
<input type="hidden" name="item_type" id="item_type" value="image" />
<div class="mb-2">
<label class="form-label">Title (optional)</label>
<input class="form-control" name="title" />
</div>
<div class="mb-2" id="duration-group">
<label class="form-label">Duration (seconds, for images/webpages/YouTube)</label>
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<div class="btn-group w-100" role="group" aria-label="Item type">
<input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="type-image">Image</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-webpage" autocomplete="off">
<label class="btn btn-outline-primary" for="type-webpage">Webpage</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-youtube" autocomplete="off">
<label class="btn btn-outline-primary" for="type-youtube">YouTube</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off">
<label class="btn btn-outline-primary" for="type-video">Video</label>
</div>
</div>
<style> <style>
.dropzone { .dropzone {
border: 2px dashed #6c757d; border: 2px dashed #6c757d;
@@ -48,30 +224,8 @@
} }
</style> </style>
<div class="mb-3"> <div id="step-select" class="step active">
<label class="form-label">Type</label> <div class="text-muted small mb-2">Select or upload your media. If you upload an image, youll crop it next.</div>
<div class="btn-group w-100" role="group" aria-label="Item type">
<input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="type-image">Image</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-webpage" autocomplete="off">
<label class="btn btn-outline-primary" for="type-webpage">Webpage</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off">
<label class="btn btn-outline-primary" for="type-video">Video</label>
</div>
<input type="hidden" name="item_type" id="item_type" value="image" />
</div>
<div class="mb-2">
<label class="form-label">Title (optional)</label>
<input class="form-control" name="title" />
</div>
<div class="mb-2" id="duration-group">
<label class="form-label">Duration (seconds, for images/webpages)</label>
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
</div>
{# Image section #} {# Image section #}
<div id="section-image" class="item-type-section"> <div id="section-image" class="item-type-section">
@@ -81,17 +235,7 @@
<div class="text-muted small">or click to select a file</div> <div class="text-muted small">or click to select a file</div>
</div> </div>
<input id="image-file-input" class="form-control d-none" type="file" name="file" accept="image/*" /> <input id="image-file-input" class="form-control d-none" type="file" name="file" accept="image/*" />
<div class="text-muted small" id="image-select-status"></div>
<div id="image-crop-container" class="d-none">
<div class="text-muted small mb-2">Crop to <strong>16:9</strong> (recommended for display screens).</div>
<div style="width: 100%; background: #111; border-radius: .25rem; overflow: hidden;">
<img id="image-crop-target" alt="Crop" style="max-width: 100%; display: block;" />
</div>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-outline-secondary btn-sm" type="button" id="image-crop-reset">Reset crop</button>
<div class="text-muted small align-self-center" id="image-crop-status"></div>
</div>
</div>
</div> </div>
{# Webpage section #} {# Webpage section #}
@@ -120,94 +264,64 @@
</div> </div>
</div> </div>
{# YouTube section #}
<div id="section-youtube" class="item-type-section d-none">
<div class="mb-2">
<label class="form-label">YouTube URL</label>
<input id="youtube-url" class="form-control" name="url" placeholder="https://www.youtube.com/watch?v=..." inputmode="url" />
<div class="text-muted small mt-1">
Paste a YouTube link (watch / shorts / youtu.be). We embed using youtube-nocookie.com.
</div>
</div>
<div class="text-muted small">Tip: set a duration; YouTube embeds will advance after that time.</div>
</div>
{# Video section #} {# Video section #}
<div id="section-video" class="item-type-section d-none"> <div id="section-video" class="item-type-section d-none">
<div class="alert alert-warning mb-2"> <label class="form-label">Video</label>
<strong>In production:</strong> video support is currently being worked on. <div id="video-dropzone" class="dropzone mb-2">
<div><strong>Drag & drop</strong> a video here</div>
<div class="text-muted small">or click to select a file</div>
</div>
<input id="video-file-input" class="form-control d-none" type="file" name="file" accept="video/*" />
<div class="text-muted small" id="video-select-status"></div>
</div> </div>
</div> </div>
<button class="btn btn-success" id="add-item-submit" type="submit">Add</button> <div id="step-crop" class="step">
<div class="text-muted small mb-2">Crop to <strong>16:9</strong> (recommended for display screens).</div>
<div style="width: 100%; background: #111; border-radius: .25rem; overflow: hidden;">
<img id="image-crop-target" alt="Crop" style="max-width: 100%; display: block;" />
</div>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-outline-secondary btn-sm" type="button" id="image-crop-reset">Reset crop</button>
<div class="text-muted small align-self-center" id="image-crop-status"></div>
</div>
</div>
</form> </form>
</div> </div>
<div class="modal-footer">
<div class="col-md-7"> <button type="button" class="btn btn-outline-secondary" id="add-item-back">Back</button>
<h2 class="h5">Items</h2> <button type="button" class="btn btn-success" id="add-item-submit">Add</button>
<div class="text-muted small mb-2">Tip: drag items to reorder. Changes save automatically.</div>
<div class="list-group" id="playlist-items" data-reorder-url="{{ url_for('company.reorder_playlist_items', playlist_id=playlist.id) }}">
{% for i in playlist.items %}
<div class="list-group-item" draggable="true" data-item-id="{{ i.id }}">
<div class="d-flex justify-content-between align-items-start gap-3">
<div style="width: 26px; cursor: grab;" class="text-muted" title="Drag to reorder"></div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>#{{ i.position }}</strong>
<span class="badge bg-secondary">{{ i.item_type }}</span>
<span>{{ i.title or '' }}</span>
</div>
<form method="post" action="{{ url_for('company.delete_item', item_id=i.id) }}" onsubmit="return confirm('Delete item?');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</div>
<div class="text-muted small">
{% if i.item_type in ['image','video'] %}
File: {{ i.file_path }}
{% else %}
URL: {{ i.url }}
{% endif %}
· Duration: {{ i.duration_seconds }}s
</div>
<div class="mt-2">
{% if i.item_type == 'image' and i.file_path %}
<img
src="{{ url_for('static', filename=i.file_path) }}"
alt="{{ i.title or 'image' }}"
style="max-width: 100%; max-height: 200px; display: block; background: #111;"
loading="lazy"
/>
{% elif i.item_type == 'video' and i.file_path %}
<video
src="{{ url_for('static', filename=i.file_path) }}"
style="max-width: 100%; max-height: 220px; display: block; background: #111;"
muted
controls
preload="metadata"
></video>
{% elif i.item_type == 'webpage' and i.url %}
<div class="d-flex gap-2 align-items-center">
<a href="{{ i.url }}" target="_blank" rel="noopener noreferrer">Open URL</a>
<span class="text-muted">(opens in new tab)</span>
</div>
<iframe
src="{{ i.url }}"
title="{{ i.title or i.url }}"
style="width: 100%; height: 200px; border: 1px solid #333; background: #111;"
loading="lazy"
referrerpolicy="no-referrer"
></iframe>
{% else %}
<div class="text-muted">No preview available.</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% else %}
<div class="text-muted">No items.</div>
{% endfor %}
</div>
{# Load Cropper.js BEFORE our inline script so window.Cropper is available #} {# Load Cropper.js BEFORE our inline script so window.Cropper is available #}
<script src="{{ url_for('static', filename='vendor/cropperjs/cropper.min.js') }}"></script> <script src="{{ url_for('static', filename='vendor/cropperjs/cropper.min.js') }}"></script>
{# Bootstrap JS required for modal (already included via base.html CSS only) #}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
(function() { (function() {
// ------------------------- // -------------------------
// Add-item UI enhancements // Add-item modal + steps
// ------------------------- // -------------------------
const openBtn = document.getElementById('open-add-item');
const modalEl = document.getElementById('addItemModal');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const form = document.getElementById('add-item-form'); const form = document.getElementById('add-item-form');
if (!form) return; if (!form) return;
@@ -217,24 +331,53 @@
const sectionImage = document.getElementById('section-image'); const sectionImage = document.getElementById('section-image');
const sectionWebpage = document.getElementById('section-webpage'); const sectionWebpage = document.getElementById('section-webpage');
const sectionYoutube = document.getElementById('section-youtube');
const sectionVideo = document.getElementById('section-video'); const sectionVideo = document.getElementById('section-video');
const stepSelect = document.getElementById('step-select');
const stepCrop = document.getElementById('step-crop');
const backBtn = document.getElementById('add-item-back');
function showStep(which) {
stepSelect?.classList.toggle('active', which === 'select');
stepCrop?.classList.toggle('active', which === 'crop');
const isCrop = which === 'crop';
backBtn.disabled = !isCrop;
// For image: allow Add only in crop step (so we always crop if image)
if (typeHidden.value === 'image') {
submitBtn.disabled = !isCrop;
}
}
function setType(t) { function setType(t) {
typeHidden.value = t; typeHidden.value = t;
sectionImage.classList.toggle('d-none', t !== 'image'); sectionImage.classList.toggle('d-none', t !== 'image');
sectionWebpage.classList.toggle('d-none', t !== 'webpage'); sectionWebpage.classList.toggle('d-none', t !== 'webpage');
sectionYoutube.classList.toggle('d-none', t !== 'youtube');
sectionVideo.classList.toggle('d-none', t !== 'video'); sectionVideo.classList.toggle('d-none', t !== 'video');
// duration applies to image/webpage/youtube. Video plays until ended.
durationGroup.classList.toggle('d-none', t === 'video'); durationGroup.classList.toggle('d-none', t === 'video');
submitBtn.disabled = (t === 'video'); submitBtn.disabled = false;
submitBtn.title = (t === 'video') ? 'Video is in production' : ''; submitBtn.title = '';
if (t !== 'image') { if (t !== 'image') {
destroyCropper(); destroyCropper();
showStep('select');
backBtn.disabled = true;
}
// For images we enforce crop step before allowing submit.
if (t === 'image') {
submitBtn.disabled = true;
backBtn.disabled = true;
} }
} }
document.getElementById('type-image')?.addEventListener('change', () => setType('image')); document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage')); document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
document.getElementById('type-video')?.addEventListener('change', () => setType('video')); document.getElementById('type-video')?.addEventListener('change', () => setType('video'));
// ------------------------- // -------------------------
@@ -242,10 +385,10 @@
// ------------------------- // -------------------------
const dropzone = document.getElementById('image-dropzone'); const dropzone = document.getElementById('image-dropzone');
const fileInput = document.getElementById('image-file-input'); const fileInput = document.getElementById('image-file-input');
const cropContainer = document.getElementById('image-crop-container');
const cropImg = document.getElementById('image-crop-target'); const cropImg = document.getElementById('image-crop-target');
const cropResetBtn = document.getElementById('image-crop-reset'); const cropResetBtn = document.getElementById('image-crop-reset');
const cropStatus = document.getElementById('image-crop-status'); const cropStatus = document.getElementById('image-crop-status');
const imageSelectStatus = document.getElementById('image-select-status');
let cropper = null; let cropper = null;
let currentObjectUrl = null; let currentObjectUrl = null;
@@ -259,8 +402,8 @@
URL.revokeObjectURL(currentObjectUrl); URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = null; currentObjectUrl = null;
} }
if (cropContainer) cropContainer.classList.add('d-none');
if (cropStatus) cropStatus.textContent = ''; if (cropStatus) cropStatus.textContent = '';
if (imageSelectStatus) imageSelectStatus.textContent = '';
} }
function setFileOnInput(input, file) { function setFileOnInput(input, file) {
@@ -271,15 +414,18 @@
async function loadImageFile(file) { async function loadImageFile(file) {
if (!file || !file.type || !file.type.startsWith('image/')) { if (!file || !file.type || !file.type.startsWith('image/')) {
cropStatus.textContent = 'Please choose an image file.'; if (imageSelectStatus) imageSelectStatus.textContent = 'Please choose an image file.';
return; return;
} }
destroyCropper(); destroyCropper();
currentObjectUrl = URL.createObjectURL(file); currentObjectUrl = URL.createObjectURL(file);
cropImg.src = currentObjectUrl; cropImg.src = currentObjectUrl;
cropContainer.classList.remove('d-none');
cropStatus.textContent = ''; cropStatus.textContent = '';
if (imageSelectStatus) imageSelectStatus.textContent = `Selected: ${file.name}`;
// Move to crop step after image selection (requested behavior)
showStep('crop');
// Wait for image to be ready // Wait for image to be ready
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -300,6 +446,9 @@
responsive: true, responsive: true,
background: false, background: false,
}); });
// Enable Add button now that cropper exists
if (typeHidden.value === 'image') submitBtn.disabled = false;
} }
dropzone?.addEventListener('click', () => { dropzone?.addEventListener('click', () => {
@@ -333,21 +482,24 @@
cropper?.reset(); cropper?.reset();
}); });
// On submit: if image selected and we have a cropper, replace the file with the cropped 16:9 output. async function submitViaAjax() {
form.addEventListener('submit', (e) => {
if (typeHidden.value !== 'image') return;
if (!cropper) return; // no cropper initialized; let the form submit normally
e.preventDefault();
submitBtn.disabled = true; submitBtn.disabled = true;
cropStatus.textContent = 'Preparing cropped image…'; cropStatus.textContent = '';
// If image, replace file with cropped version before sending.
if (typeHidden.value === 'image') {
if (!cropper) {
cropStatus.textContent = 'Please select an image first.';
submitBtn.disabled = false;
return;
}
cropStatus.textContent = 'Preparing cropped image…';
const canvas = cropper.getCroppedCanvas({ const canvas = cropper.getCroppedCanvas({
width: 1280, width: 1280,
height: 720, height: 720,
imageSmoothingQuality: 'high', imageSmoothingQuality: 'high',
}); });
canvas.toBlob((blob) => { const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) { if (!blob) {
cropStatus.textContent = 'Failed to crop image.'; cropStatus.textContent = 'Failed to crop image.';
submitBtn.disabled = false; submitBtn.disabled = false;
@@ -356,14 +508,69 @@
const croppedFile = new File([blob], 'cropped.png', { type: 'image/png' }); const croppedFile = new File([blob], 'cropped.png', { type: 'image/png' });
setFileOnInput(fileInput, croppedFile); setFileOnInput(fileInput, croppedFile);
cropStatus.textContent = ''; cropStatus.textContent = '';
form.submit(); }
}, 'image/png');
const fd = new FormData(form);
const res = await fetch(form.action, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
body: fd
}); });
if (!res.ok) {
let errText = 'Failed to add item.';
try {
const j = await res.json();
if (j && j.error) errText = j.error;
} catch (e) {}
submitBtn.disabled = false;
cropStatus.textContent = errText;
return;
}
const data = await res.json();
if (!data.ok) {
submitBtn.disabled = false;
cropStatus.textContent = data.error || 'Failed to add item.';
return;
}
// Add the new item card to the gallery (append at end)
const item = data.item;
const list = document.getElementById('playlist-items');
if (list && item) {
const el = document.createElement('div');
el.className = 'playlist-card';
el.setAttribute('draggable', 'true');
el.setAttribute('data-item-id', item.id);
el.innerHTML = renderCardInnerHtml(item);
list.appendChild(el);
}
// Reset modal state + close
form.reset();
typeHidden.value = 'image';
document.getElementById('type-image')?.click();
destroyCropper();
showStep('select');
submitBtn.disabled = true;
modal?.hide();
}
function setEnabled(el, enabled) {
if (!el) return;
el.disabled = !enabled;
}
// ------------------------- // -------------------------
// Webpage: live preview // Webpage: live preview
// ------------------------- // -------------------------
const urlInput = document.getElementById('webpage-url'); const urlInput = document.getElementById('webpage-url');
const youtubeUrlInput = document.getElementById('youtube-url');
const preview = document.getElementById('webpage-preview'); const preview = document.getElementById('webpage-preview');
const iframe = document.getElementById('webpage-iframe'); const iframe = document.getElementById('webpage-iframe');
const openLink = document.getElementById('webpage-open'); const openLink = document.getElementById('webpage-open');
@@ -395,52 +602,276 @@
urlInput?.addEventListener('input', schedulePreview); urlInput?.addEventListener('input', schedulePreview);
// -------------------------
// Video: drag/drop select
// -------------------------
const videoDropzone = document.getElementById('video-dropzone');
const videoInput = document.getElementById('video-file-input');
const videoStatus = document.getElementById('video-select-status');
function setVideoOnInput(file) {
const dt = new DataTransfer();
dt.items.add(file);
videoInput.files = dt.files;
}
function loadVideoFile(file) {
if (!file || !file.type || !file.type.startsWith('video/')) {
if (videoStatus) videoStatus.textContent = 'Please choose a video file.';
return;
}
if (videoStatus) videoStatus.textContent = `Selected: ${file.name}`;
}
videoDropzone?.addEventListener('click', () => videoInput?.click());
videoDropzone?.addEventListener('dragover', (e) => { e.preventDefault(); videoDropzone.classList.add('dragover'); });
videoDropzone?.addEventListener('dragleave', () => videoDropzone.classList.remove('dragover'));
videoDropzone?.addEventListener('drop', (e) => {
e.preventDefault();
videoDropzone.classList.remove('dragover');
const f = e.dataTransfer?.files?.[0];
if (!f) return;
setVideoOnInput(f);
loadVideoFile(f);
});
videoInput?.addEventListener('change', () => {
const f = videoInput.files?.[0];
if (!f) return;
loadVideoFile(f);
});
// Ensure only inputs from the active section are enabled (so form fields don't clash)
function syncEnabledInputs() {
const t = typeHidden.value;
// Disable all optional inputs by default
setEnabled(fileInput, t === 'image');
setEnabled(videoInput, t === 'video');
setEnabled(urlInput, t === 'webpage');
setEnabled(youtubeUrlInput, t === 'youtube');
if (t === 'webpage') {
// Keep preview behavior
schedulePreview();
} else {
// Hide webpage preview if not active
preview?.classList.add('d-none');
if (iframe) iframe.src = 'about:blank';
if (openLink) openLink.href = '#';
}
}
// Set initial state // Set initial state
setType('image'); setType('image');
showStep('select');
syncEnabledInputs();
// Modal open
openBtn?.addEventListener('click', () => {
modal?.show();
});
// Back button: only relevant for image crop step
backBtn?.addEventListener('click', () => {
if (typeHidden.value === 'image') {
showStep('select');
submitBtn.disabled = true;
backBtn.disabled = true;
}
});
// Whenever type changes, keep enabled inputs in sync
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
});
// Add button
submitBtn?.addEventListener('click', async () => {
try {
await submitViaAjax();
} catch (err) {
console.warn(err);
submitBtn.disabled = false;
cropStatus.textContent = 'Failed to add item.';
}
});
// Render helper for newly appended card
function renderCardInnerHtml(i) {
const safeTitle = (i.title || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const type = i.item_type;
const badge = `<span class="badge bg-secondary">${type}</span>`;
const durationInput = (type === 'video')
? ''
: `
<label class="text-nowrap" style="margin: 0;">
<span class="me-1">Duration</span>
<input
class="form-control form-control-sm d-inline-block js-duration-input"
style="width: 92px;"
type="number"
min="1"
value="${i.duration_seconds || 10}"
data-item-id="${i.id}"
aria-label="Duration seconds"
/>
<span class="ms-1">s</span>
</label>
<span class="small text-muted js-duration-status" data-item-id="${i.id}"></span>
`;
let thumb = `<div class="text-muted">No preview</div>`;
if (type === 'image' && i.file_path) {
thumb = `<img src="/static/${i.file_path}" alt="${safeTitle || 'image'}" loading="lazy" />`;
} else if (type === 'video' && i.file_path) {
thumb = `<video src="/static/${i.file_path}" muted controls preload="metadata"></video>`;
} else if (type === 'webpage' && i.url) {
thumb = `<iframe src="${i.url}" title="${safeTitle || 'webpage'}" loading="lazy" referrerpolicy="no-referrer"></iframe>`;
} else if (type === 'youtube' && i.url) {
thumb = `<iframe src="${i.url}" title="${safeTitle || 'youtube'}" loading="lazy" referrerpolicy="no-referrer"></iframe>`;
}
const list = document.getElementById('playlist-items');
const base = list?.getAttribute('data-delete-base') || '';
// base will be something like /company/items/0/delete
const deleteAction = base.replace(/0\/?delete$/, `${i.id}/delete`).replace(/0\/delete$/, `${i.id}/delete`);
return `
<div class="card-top">
<div class="d-flex gap-2">
<div class="drag-handle" title="Drag to reorder">≡</div>
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<strong>#${i.position}</strong>
${badge}
</div>
${safeTitle ? `<div class="small">${safeTitle}</div>` : ''}
</div>
</div>
<form method="post" action="${deleteAction}" onsubmit="return confirm('Delete item?');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</div>
<div class="card-body">
<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 -->
${durationInput}
</div>
<div class="thumb">${thumb}</div>
</div>
`;
}
})(); })();
(function() { (function() {
const list = document.getElementById('playlist-items'); const list = document.getElementById('playlist-items');
if (!list) return; if (!list) return;
const statusEl = document.getElementById('reorder-status');
let dragged = null; let dragged = null;
let lastSavedOrder = null;
let persistTimer = null;
function setStatus(msg, kind) {
if (!statusEl) return;
statusEl.textContent = msg || '';
statusEl.classList.toggle('text-success', kind === 'ok');
statusEl.classList.toggle('text-danger', kind === 'err');
statusEl.classList.toggle('text-muted', !kind);
}
function items() { function items() {
return Array.from(list.querySelectorAll('[data-item-id]')); // Only the draggable cards should participate in ordering.
// (duration inputs/status spans also have data-item-id)
return Array.from(list.querySelectorAll('.playlist-card[data-item-id]'));
} }
function computeOrder() { function computeOrder() {
return items().map(el => el.getAttribute('data-item-id')).join(','); return items().map(el => el.getAttribute('data-item-id')).join(',');
} }
function refreshVisiblePositions() {
items().forEach((el, idx) => {
const posEl = el.querySelector('.card-top strong');
if (posEl) posEl.textContent = `#${idx + 1}`;
});
}
async function persist() { async function persist() {
const url = list.getAttribute('data-reorder-url'); const url = list.getAttribute('data-reorder-url');
const body = new URLSearchParams(); const body = new URLSearchParams();
body.set('order', computeOrder()); body.set('order', computeOrder());
await fetch(url, { setStatus('Saving order…');
const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
body body
}); });
if (!res.ok) {
let details = '';
try {
const txt = await res.text();
details = (txt || '').slice(0, 140).replace(/\s+/g, ' ').trim();
} catch (e) {}
throw new Error(`Failed to persist: ${res.status}${details ? ` (${details})` : ''}`);
} }
lastSavedOrder = computeOrder();
setStatus('Order saved', 'ok');
window.setTimeout(() => {
if (statusEl && statusEl.textContent === 'Order saved') setStatus('');
}, 900);
}
function schedulePersistSoon() {
// Debounce to avoid spamming on rapid reorder.
if (persistTimer) window.clearTimeout(persistTimer);
persistTimer = window.setTimeout(async () => {
const current = computeOrder();
if (current && current !== lastSavedOrder) {
try {
await persist();
} catch (err) {
console.warn('Failed to persist order', err);
setStatus('Failed to save order', 'err');
}
}
}, 200);
}
// Initialize baseline order
lastSavedOrder = computeOrder();
list.addEventListener('dragstart', (e) => { list.addEventListener('dragstart', (e) => {
const el = e.target.closest('[data-item-id]'); const el = e.target.closest('.playlist-card[data-item-id]');
if (!el) return; if (!el) return;
dragged = el; dragged = el;
el.style.opacity = '0.5'; el.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
// Some browsers (Safari) require data to be set.
try { e.dataTransfer.setData('text/plain', el.getAttribute('data-item-id') || ''); } catch (err) {}
}); });
list.addEventListener('dragend', (e) => { list.addEventListener('dragend', (e) => {
const el = e.target.closest('[data-item-id]'); const el = e.target.closest('.playlist-card[data-item-id]');
if (el) el.style.opacity = ''; if (el) el.classList.remove('dragging');
dragged = null; dragged = null;
// Persist on dragend as well; drop may not fire if released outside container.
refreshVisiblePositions();
schedulePersistSoon();
}); });
list.addEventListener('dragover', (e) => { list.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
const over = e.target.closest('[data-item-id]'); const over = e.target.closest('.playlist-card[data-item-id]');
if (!dragged || !over || over === dragged) return; if (!dragged || !over || over === dragged) return;
const rect = over.getBoundingClientRect(); const rect = over.getBoundingClientRect();
@@ -458,10 +889,93 @@
list.addEventListener('drop', async (e) => { list.addEventListener('drop', async (e) => {
e.preventDefault(); e.preventDefault();
try { await persist(); } catch (err) { console.warn('Failed to persist order', err); } refreshVisiblePositions();
// Debounced persist (drop is not reliable cross-browser)
schedulePersistSoon();
});
})();
(function() {
// Inline duration editing
const list = document.getElementById('playlist-items');
if (!list) return;
const durationBase = list.getAttribute('data-duration-base') || '';
function durationAction(itemId) {
// base will be something like /company/items/0/duration
return durationBase.replace(/0\/?duration$/, `${itemId}/duration`).replace(/0\/duration$/, `${itemId}/duration`);
}
function statusEl(itemId) {
return list.querySelector(`.js-duration-status[data-item-id="${itemId}"]`);
}
let saveTimer = null;
let lastRequestId = 0;
async function saveDuration(itemId, value) {
const st = statusEl(itemId);
if (st) st.textContent = 'Saving…';
const body = new URLSearchParams();
body.set('duration_seconds', String(value));
const reqId = ++lastRequestId;
const res = await fetch(durationAction(itemId), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
body
});
if (reqId !== lastRequestId) {
// newer request is in flight; ignore this response
return;
}
if (!res.ok) {
if (st) st.textContent = 'Failed to save';
return;
}
let data = null;
try { data = await res.json(); } catch (e) {}
if (!data || !data.ok) {
if (st) st.textContent = (data && data.error) ? data.error : 'Failed to save';
return;
}
if (st) {
st.textContent = 'Saved';
window.setTimeout(() => {
if (st.textContent === 'Saved') st.textContent = '';
}, 900);
}
}
list.addEventListener('input', (e) => {
const input = e.target.closest('.js-duration-input');
if (!input) return;
const itemId = input.getAttribute('data-item-id');
if (!itemId) return;
const v = Math.max(1, parseInt(input.value || '1', 10));
if (String(v) !== String(input.value)) input.value = String(v);
if (saveTimer) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveDuration(itemId, v).catch((err) => {
console.warn('Failed to save duration', err);
const st = statusEl(itemId);
if (st) st.textContent = 'Failed to save';
});
}, 450);
}); });
})(); })();
</script> </script>
</div> </div>
</div>
{% endblock %} {% endblock %}

View File

@@ -82,6 +82,18 @@
el.src = item.url; el.src = item.url;
stage.appendChild(el); stage.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000); timer = setTimeout(next, (item.duration || 10) * 1000);
} 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>)
// Add common playback params client-side.
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);
// 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);
} else { } else {
timer = setTimeout(next, 5000); timer = setTimeout(next, 5000);
} }

View File

@@ -0,0 +1,93 @@
import os
import tempfile
# Ensure repo root is on sys.path when running as a script.
import sys
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, PlaylistItem, 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()
with app.app_context():
db.create_all()
c = Company(name="TestCo")
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()
i1 = PlaylistItem(playlist_id=p.id, item_type="webpage", url="https://example.com/1", position=1)
i2 = PlaylistItem(playlist_id=p.id, item_type="webpage", url="https://example.com/2", position=2)
i3 = PlaylistItem(playlist_id=p.id, item_type="webpage", url="https://example.com/3", position=3)
db.session.add_all([i1, i2, i3])
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}")
# Reorder: 3,2,1
reorder_url = f"/company/playlists/{p.id}/items/reorder"
res = client.post(
reorder_url,
data={"order": f"{i3.id},{i2.id},{i1.id}"},
headers={"Accept": "application/json", "X-Requested-With": "XMLHttpRequest"},
)
if res.status_code != 200:
raise SystemExit(f"Reorder failed: {res.status_code} {res.data!r}")
data = res.get_json(silent=True) or {}
if not data.get("ok"):
raise SystemExit(f"Unexpected reorder response: {data}")
db.session.expire_all()
ordered = (
PlaylistItem.query.filter_by(playlist_id=p.id)
.order_by(PlaylistItem.position.asc())
.all()
)
got = [x.id for x in ordered]
expected = [i3.id, i2.id, i1.id]
if got != expected:
raise SystemExit(f"Order not persisted. expected={expected} got={got}")
print("OK: reorder endpoint persists positions")
finally:
try:
os.remove(path)
except OSError:
pass
if __name__ == "__main__":
main()

View File

@@ -20,6 +20,10 @@ def main():
required = { required = {
"/admin/companies/<int:company_id>/delete", "/admin/companies/<int:company_id>/delete",
"/admin/displays/<int:display_id>/name",
"/company/displays/<int:display_id>",
"/company/items/<int:item_id>/duration",
"/company/playlists/<int:playlist_id>/items/reorder",
"/auth/change-password", "/auth/change-password",
"/auth/forgot-password", "/auth/forgot-password",
"/auth/reset-password/<token>", "/auth/reset-password/<token>",

View File

@@ -0,0 +1,91 @@
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")
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}")
# We don't want to allocate a 250MB+ buffer in a test.
# Instead, temporarily monkeypatch the route's MAX_VIDEO_BYTES down to 1 byte,
# then upload a 2-byte payload.
import app.routes.company as company_routes
old_limit = company_routes.MAX_VIDEO_BYTES
company_routes.MAX_VIDEO_BYTES = 1
try:
data = {
"item_type": "video",
"title": "Big",
"duration_seconds": "10",
"response": "json",
"file": (io.BytesIO(b"xx"), "big.mp4"),
}
url = f"/company/playlists/{p.id}/items"
res = client.post(url, data=data, content_type="multipart/form-data")
finally:
company_routes.MAX_VIDEO_BYTES = old_limit
if res.status_code != 413:
raise SystemExit(f"Expected 413 for too-large video, 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 "250" not in (js.get("error") or ""):
raise SystemExit(f"Unexpected error message: {js}")
print("OK: video uploads >250MB are rejected with 413")
finally:
try:
os.remove(path)
except OSError:
pass
if __name__ == "__main__":
main()