edited230126
This commit is contained in:
@@ -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:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
31
app/cli.py
31
app/cli.py
@@ -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}'.")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
existing = User.query.filter(User.email == email, User.id != u.id).first()
|
flash("Email is required", "danger")
|
||||||
if existing:
|
return redirect(url_for("admin.company_detail", company_id=u.company_id))
|
||||||
flash("Email already exists", "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()
|
||||||
|
if existing:
|
||||||
|
flash("Email already exists", "danger")
|
||||||
|
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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
<select class="form-select form-select-sm" name="playlist_id">
|
<input
|
||||||
<option value="">(none)</option>
|
class="form-control form-control-sm"
|
||||||
{% for p in playlists %}
|
name="description"
|
||||||
<option value="{{ p.id }}" {% if d.assigned_playlist_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
placeholder="Description (e.g. entrance, office)"
|
||||||
{% endfor %}
|
value="{{ d.description or '' }}"
|
||||||
</select>
|
maxlength="200"
|
||||||
<button class="btn btn-primary btn-sm" type="submit">Assign</button>
|
/>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<select class="form-select form-select-sm" name="playlist_id">
|
||||||
|
<option value="">(none)</option>
|
||||||
|
{% for p in playlists %}
|
||||||
|
<option value="{{ p.id }}" {% if d.assigned_playlist_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
93
scripts/reorder_endpoint_test.py
Normal file
93
scripts/reorder_endpoint_test.py
Normal 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()
|
||||||
@@ -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>",
|
||||||
|
|||||||
91
scripts/video_upload_limit_test.py
Normal file
91
scripts/video_upload_limit_test.py
Normal 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()
|
||||||
Reference in New Issue
Block a user