Add company dashboard improvements and upload/auth features
This commit is contained in:
@@ -6,6 +6,7 @@ from flask import Blueprint, abort, current_app, flash, redirect, render_templat
|
||||
from flask_login import current_user, login_required, login_user
|
||||
|
||||
from ..extensions import db
|
||||
from ..uploads import abs_upload_path, ensure_company_upload_dir, is_valid_upload_relpath
|
||||
from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
@@ -20,11 +21,12 @@ def _try_delete_upload(file_path: str | None, upload_folder: str):
|
||||
"""Best-effort delete of an uploaded media file."""
|
||||
if not file_path:
|
||||
return
|
||||
if not file_path.startswith("uploads/"):
|
||||
if not is_valid_upload_relpath(file_path):
|
||||
return
|
||||
|
||||
filename = file_path.split("/", 1)[1]
|
||||
abs_path = os.path.join(upload_folder, filename)
|
||||
abs_path = abs_upload_path(upload_folder, file_path)
|
||||
if not abs_path:
|
||||
return
|
||||
try:
|
||||
if os.path.isfile(abs_path):
|
||||
os.remove(abs_path)
|
||||
@@ -55,6 +57,13 @@ def create_company():
|
||||
c = Company(name=name)
|
||||
db.session.add(c)
|
||||
db.session.commit()
|
||||
|
||||
# Create the per-company upload directory eagerly (best-effort).
|
||||
try:
|
||||
ensure_company_upload_dir(current_app.config["UPLOAD_FOLDER"], c.id)
|
||||
except Exception:
|
||||
# Upload directory is also created lazily on first upload.
|
||||
pass
|
||||
flash("Company created", "success")
|
||||
return redirect(url_for("admin.company_detail", company_id=c.id))
|
||||
|
||||
@@ -193,6 +202,43 @@ def update_user_email(user_id: int):
|
||||
return redirect(url_for("admin.company_detail", company_id=u.company_id))
|
||||
|
||||
|
||||
@bp.post("/users/<int:user_id>/delete")
|
||||
@login_required
|
||||
def delete_user(user_id: int):
|
||||
"""Admin: delete a non-admin user."""
|
||||
|
||||
admin_required()
|
||||
|
||||
u = db.session.get(User, user_id)
|
||||
if not u:
|
||||
abort(404)
|
||||
|
||||
# Safety checks
|
||||
if u.is_admin:
|
||||
flash("Cannot delete an admin user", "danger")
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
|
||||
if u.id == current_user.id:
|
||||
flash("You cannot delete yourself", "danger")
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
|
||||
company_id = u.company_id
|
||||
company_name = u.company.name if u.company else None
|
||||
email = u.email
|
||||
|
||||
db.session.delete(u)
|
||||
db.session.commit()
|
||||
|
||||
flash(
|
||||
f"User '{email}' deleted" + (f" from '{company_name}'." if company_name else "."),
|
||||
"success",
|
||||
)
|
||||
|
||||
if company_id:
|
||||
return redirect(url_for("admin.company_detail", company_id=company_id))
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
|
||||
|
||||
@bp.post("/displays/<int:display_id>/name")
|
||||
@login_required
|
||||
def update_display_name(display_id: int):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
|
||||
from flask import Blueprint, abort, jsonify, request, url_for
|
||||
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
|
||||
|
||||
from ..extensions import db
|
||||
from ..models import Display, DisplaySession
|
||||
@@ -12,6 +15,95 @@ MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2
|
||||
SESSION_TTL_SECONDS = 90
|
||||
|
||||
|
||||
def _enforce_and_touch_display_session(display: Display, sid: str | None):
|
||||
"""Enforce concurrent display viewer limit and touch last_seen.
|
||||
|
||||
Returns:
|
||||
(ok, response)
|
||||
- ok=True: caller may proceed
|
||||
- ok=False: response is a Flask response tuple to return
|
||||
"""
|
||||
|
||||
sid = (sid or "").strip()
|
||||
if not sid:
|
||||
return True, None
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS)
|
||||
DisplaySession.query.filter(
|
||||
DisplaySession.display_id == display.id,
|
||||
DisplaySession.last_seen_at < cutoff,
|
||||
).delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first()
|
||||
if existing:
|
||||
existing.last_seen_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return True, None
|
||||
|
||||
active_count = (
|
||||
DisplaySession.query.filter(
|
||||
DisplaySession.display_id == display.id,
|
||||
DisplaySession.last_seen_at >= cutoff,
|
||||
).count()
|
||||
)
|
||||
if active_count >= MAX_ACTIVE_SESSIONS_PER_DISPLAY:
|
||||
return (
|
||||
False,
|
||||
(
|
||||
jsonify(
|
||||
{
|
||||
"error": "display_limit_reached",
|
||||
"message": f"This display URL is already open on {MAX_ACTIVE_SESSIONS_PER_DISPLAY} displays.",
|
||||
}
|
||||
),
|
||||
429,
|
||||
),
|
||||
)
|
||||
|
||||
s = DisplaySession(
|
||||
display_id=display.id,
|
||||
sid=sid,
|
||||
last_seen_at=datetime.utcnow(),
|
||||
ip=request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||
user_agent=(request.headers.get("User-Agent") or "")[:300],
|
||||
)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
return True, None
|
||||
|
||||
|
||||
def _playlist_signature(display: Display) -> tuple[int | None, str]:
|
||||
"""Compute a stable hash for what the player should be showing.
|
||||
|
||||
We include enough information so that changing the assigned playlist, reordering,
|
||||
duration changes, and item adds/deletes trigger an update.
|
||||
"""
|
||||
|
||||
playlist = display.assigned_playlist
|
||||
if not playlist:
|
||||
raw = "no-playlist"
|
||||
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
payload = {
|
||||
"playlist_id": playlist.id,
|
||||
"items": [
|
||||
{
|
||||
"id": it.id,
|
||||
"pos": it.position,
|
||||
"type": it.item_type,
|
||||
"title": it.title,
|
||||
"duration": it.duration_seconds,
|
||||
"file_path": it.file_path,
|
||||
"url": it.url,
|
||||
}
|
||||
for it in playlist.items
|
||||
],
|
||||
}
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return playlist.id, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@bp.get("/display/<token>/playlist")
|
||||
def display_playlist(token: str):
|
||||
display = Display.query.filter_by(token=token).first()
|
||||
@@ -20,46 +112,10 @@ def display_playlist(token: str):
|
||||
|
||||
# Enforce: a display URL/token can be opened by max 2 concurrently active sessions.
|
||||
# Player sends a stable `sid` via querystring.
|
||||
sid = (request.args.get("sid") or "").strip()
|
||||
if sid:
|
||||
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS)
|
||||
DisplaySession.query.filter(
|
||||
DisplaySession.display_id == display.id,
|
||||
DisplaySession.last_seen_at < cutoff,
|
||||
).delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first()
|
||||
if existing:
|
||||
existing.last_seen_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
else:
|
||||
active_count = (
|
||||
DisplaySession.query.filter(
|
||||
DisplaySession.display_id == display.id,
|
||||
DisplaySession.last_seen_at >= cutoff,
|
||||
).count()
|
||||
)
|
||||
if active_count >= MAX_ACTIVE_SESSIONS_PER_DISPLAY:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "display_limit_reached",
|
||||
"message": f"This display URL is already open on {MAX_ACTIVE_SESSIONS_PER_DISPLAY} displays.",
|
||||
}
|
||||
),
|
||||
429,
|
||||
)
|
||||
|
||||
s = DisplaySession(
|
||||
display_id=display.id,
|
||||
sid=sid,
|
||||
last_seen_at=datetime.utcnow(),
|
||||
ip=request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||
user_agent=(request.headers.get("User-Agent") or "")[:300],
|
||||
)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
sid = request.args.get("sid")
|
||||
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||
if not ok:
|
||||
return resp
|
||||
|
||||
playlist = display.assigned_playlist
|
||||
if not playlist:
|
||||
@@ -86,3 +142,79 @@ def display_playlist(token: str):
|
||||
"items": items,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/display/<token>/events")
|
||||
def display_events(token: str):
|
||||
"""Server-Sent Events stream to notify the player when its playlist changes."""
|
||||
|
||||
display = Display.query.filter_by(token=token).first()
|
||||
if not display:
|
||||
abort(404)
|
||||
|
||||
sid = request.args.get("sid")
|
||||
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||
if not ok:
|
||||
return resp
|
||||
|
||||
display_id = display.id
|
||||
sid = (sid or "").strip() or None
|
||||
|
||||
@stream_with_context
|
||||
def _gen():
|
||||
last_hash = None
|
||||
last_touch = 0.0
|
||||
keepalive_counter = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Refresh from DB each loop so changes become visible.
|
||||
d = Display.query.filter_by(id=display_id).first()
|
||||
if not d:
|
||||
yield "event: closed\ndata: {}\n\n"
|
||||
return
|
||||
|
||||
playlist_id, h = _playlist_signature(d)
|
||||
if h != last_hash:
|
||||
last_hash = h
|
||||
payload = json.dumps({"playlist_id": playlist_id, "hash": h})
|
||||
yield f"event: changed\ndata: {payload}\n\n"
|
||||
|
||||
# Touch session periodically so SSE-only viewers don't time out.
|
||||
now = time.time()
|
||||
if sid and (now - last_touch) >= 30:
|
||||
last_touch = now
|
||||
existing = DisplaySession.query.filter_by(display_id=display_id, sid=sid).first()
|
||||
if existing:
|
||||
existing.last_seen_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Keep-alive comment (prevents some proxies from closing idle streams).
|
||||
keepalive_counter += 1
|
||||
if keepalive_counter >= 10: # ~20s with the sleep below
|
||||
keepalive_counter = 0
|
||||
yield ": keep-alive\n\n"
|
||||
|
||||
# Release DB connections between iterations.
|
||||
db.session.remove()
|
||||
|
||||
time.sleep(2)
|
||||
except GeneratorExit:
|
||||
return
|
||||
except Exception:
|
||||
# Avoid tight error loops.
|
||||
try:
|
||||
db.session.remove()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
|
||||
return Response(
|
||||
_gen(),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,29 +8,26 @@ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
from ..extensions import db
|
||||
from ..email_utils import send_email
|
||||
from ..models import User
|
||||
from ..auth_tokens import load_password_reset_user_id, make_password_reset_token
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _reset_serializer_v2() -> URLSafeTimedSerializer:
|
||||
# Use Flask SECRET_KEY; fallback to app config via current_app.
|
||||
# (defined as separate function to keep import cycle minimal)
|
||||
def _make_reset_token(user: User) -> str:
|
||||
from flask import current_app
|
||||
|
||||
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="password-reset")
|
||||
|
||||
|
||||
def _make_reset_token(user: User) -> str:
|
||||
s = _reset_serializer_v2()
|
||||
return s.dumps({"user_id": user.id})
|
||||
return make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=user.id)
|
||||
|
||||
|
||||
def _load_reset_token(token: str, *, max_age_seconds: int) -> int:
|
||||
s = _reset_serializer_v2()
|
||||
data = s.loads(token, max_age=max_age_seconds)
|
||||
user_id = int(data.get("user_id"))
|
||||
return user_id
|
||||
from flask import current_app
|
||||
|
||||
return load_password_reset_user_id(
|
||||
secret_key=current_app.config["SECRET_KEY"],
|
||||
token=token,
|
||||
max_age_seconds=max_age_seconds,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/forgot-password")
|
||||
|
||||
@@ -2,14 +2,19 @@ import os
|
||||
import uuid
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_login import current_user, login_required
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from ..extensions import db
|
||||
from ..models import Display, Playlist, PlaylistItem
|
||||
from ..uploads import abs_upload_path, ensure_company_upload_dir, get_company_upload_bytes, is_valid_upload_relpath
|
||||
from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User
|
||||
from ..email_utils import send_email
|
||||
from ..auth_tokens import make_password_reset_token
|
||||
|
||||
|
||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
|
||||
@@ -75,35 +80,88 @@ def _normalize_youtube_embed_url(raw: str) -> str | None:
|
||||
return f"https://www.youtube-nocookie.com/embed/{video_id}"
|
||||
|
||||
|
||||
def _save_compressed_image(uploaded_file, upload_folder: str) -> str:
|
||||
def _center_crop_to_aspect(img: Image.Image, aspect_w: int, aspect_h: int) -> Image.Image:
|
||||
"""Return a center-cropped copy of img to the desired aspect ratio."""
|
||||
|
||||
w, h = img.size
|
||||
if w <= 0 or h <= 0:
|
||||
return img
|
||||
|
||||
target = aspect_w / aspect_h
|
||||
current = w / h
|
||||
|
||||
# If image is wider than target: crop width; else crop height.
|
||||
if current > target:
|
||||
new_w = max(1, int(h * target))
|
||||
left = max(0, (w - new_w) // 2)
|
||||
return img.crop((left, 0, left + new_w, h))
|
||||
else:
|
||||
new_h = max(1, int(w / target))
|
||||
top = max(0, (h - new_h) // 2)
|
||||
return img.crop((0, top, w, top + new_h))
|
||||
|
||||
|
||||
def _save_compressed_image(
|
||||
uploaded_file,
|
||||
upload_root: str,
|
||||
company_id: int | None,
|
||||
crop_mode: str | None = None,
|
||||
) -> str:
|
||||
"""Save an uploaded image as a compressed WEBP file.
|
||||
|
||||
crop_mode:
|
||||
- "16:9" : center-crop to landscape
|
||||
- "9:16" : center-crop to portrait
|
||||
- "none" : no crop
|
||||
|
||||
Returns relative file path under /static (e.g. uploads/<uuid>.webp)
|
||||
"""
|
||||
|
||||
unique = f"{uuid.uuid4().hex}.webp"
|
||||
save_path = os.path.join(upload_folder, unique)
|
||||
company_dir = ensure_company_upload_dir(upload_root, company_id)
|
||||
save_path = os.path.join(company_dir, unique)
|
||||
|
||||
cm = (crop_mode or "16:9").strip().lower()
|
||||
if cm not in {"16:9", "9:16", "none"}:
|
||||
cm = "16:9"
|
||||
|
||||
img = Image.open(uploaded_file)
|
||||
# Respect EXIF orientation (common for phone photos)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
|
||||
# Normalize mode for webp
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Optional crop
|
||||
if cm == "16:9":
|
||||
img = _center_crop_to_aspect(img, 16, 9)
|
||||
max_box = (1920, 1080)
|
||||
elif cm == "9:16":
|
||||
img = _center_crop_to_aspect(img, 9, 16)
|
||||
max_box = (1080, 1920)
|
||||
else:
|
||||
# No crop: allow both portrait and landscape up to 1920px on the longest side.
|
||||
max_box = (1920, 1920)
|
||||
|
||||
# Resize down if very large (keeps aspect ratio)
|
||||
img.thumbnail((1920, 1080))
|
||||
img.thumbnail(max_box)
|
||||
|
||||
img.save(save_path, format="WEBP", quality=80, method=6)
|
||||
return f"uploads/{unique}"
|
||||
company_seg = str(int(company_id)) if company_id is not None else "0"
|
||||
return f"uploads/{company_seg}/{unique}"
|
||||
|
||||
|
||||
def _try_delete_upload(file_path: str | None, upload_folder: str):
|
||||
def _try_delete_upload(file_path: str | None, upload_root: str):
|
||||
"""Best-effort delete of an uploaded media file."""
|
||||
if not file_path:
|
||||
return
|
||||
if not file_path.startswith("uploads/"):
|
||||
if not is_valid_upload_relpath(file_path):
|
||||
return
|
||||
|
||||
abs_path = abs_upload_path(upload_root, file_path)
|
||||
if not abs_path:
|
||||
return
|
||||
filename = file_path.split("/", 1)[1]
|
||||
abs_path = os.path.join(upload_folder, filename)
|
||||
try:
|
||||
if os.path.isfile(abs_path):
|
||||
os.remove(abs_path)
|
||||
@@ -123,6 +181,138 @@ def company_user_required():
|
||||
abort(403)
|
||||
|
||||
|
||||
def _format_bytes(num: int) -> str:
|
||||
num = max(0, int(num or 0))
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
size = float(num)
|
||||
idx = 0
|
||||
while size >= 1024.0 and idx < len(units) - 1:
|
||||
size /= 1024.0
|
||||
idx += 1
|
||||
if idx == 0:
|
||||
return f"{int(size)} {units[idx]}"
|
||||
return f"{size:.1f} {units[idx]}"
|
||||
|
||||
|
||||
@bp.get("/my-company")
|
||||
@login_required
|
||||
def my_company():
|
||||
company_user_required()
|
||||
|
||||
company = db.session.get(Company, current_user.company_id)
|
||||
if not company:
|
||||
abort(404)
|
||||
|
||||
# Stats
|
||||
display_count = Display.query.filter_by(company_id=company.id).count()
|
||||
playlist_count = Playlist.query.filter_by(company_id=company.id).count()
|
||||
user_count = User.query.filter_by(company_id=company.id, is_admin=False).count()
|
||||
|
||||
item_count = (
|
||||
PlaylistItem.query.join(Playlist, PlaylistItem.playlist_id == Playlist.id)
|
||||
.filter(Playlist.company_id == company.id)
|
||||
.count()
|
||||
)
|
||||
|
||||
# Active display sessions (best-effort, based on same TTL as /api)
|
||||
cutoff = datetime.utcnow() - timedelta(seconds=90)
|
||||
active_sessions = (
|
||||
DisplaySession.query.join(Display, DisplaySession.display_id == Display.id)
|
||||
.filter(Display.company_id == company.id, DisplaySession.last_seen_at >= cutoff)
|
||||
.count()
|
||||
)
|
||||
|
||||
# Storage usage
|
||||
upload_root = current_app.config["UPLOAD_FOLDER"]
|
||||
used_bytes = get_company_upload_bytes(upload_root, company.id)
|
||||
|
||||
users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all()
|
||||
|
||||
return render_template(
|
||||
"company/my_company.html",
|
||||
company=company,
|
||||
users=users,
|
||||
stats={
|
||||
"users": user_count,
|
||||
"displays": display_count,
|
||||
"playlists": playlist_count,
|
||||
"items": item_count,
|
||||
"active_sessions": active_sessions,
|
||||
"storage_bytes": used_bytes,
|
||||
"storage_human": _format_bytes(used_bytes),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/my-company/invite")
|
||||
@login_required
|
||||
def invite_user():
|
||||
company_user_required()
|
||||
|
||||
email = (request.form.get("email", "") or "").strip().lower()
|
||||
if not email:
|
||||
flash("Email is required", "danger")
|
||||
return redirect(url_for("company.my_company"))
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash("Email already exists", "danger")
|
||||
return redirect(url_for("company.my_company"))
|
||||
|
||||
company = db.session.get(Company, current_user.company_id)
|
||||
if not company:
|
||||
abort(404)
|
||||
|
||||
# Create user without password; they must set it via reset link.
|
||||
u = User(is_admin=False, company=company)
|
||||
u.email = email
|
||||
u.username = email # keep backwards-compatible username column in sync
|
||||
u.password_hash = None
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
|
||||
token = make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=u.id)
|
||||
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
||||
body = (
|
||||
f"You have been invited to {company.name} on Signage.\n\n"
|
||||
"Set your password using this link (valid for 30 minutes):\n"
|
||||
f"{reset_url}\n"
|
||||
)
|
||||
try:
|
||||
send_email(to_email=u.email, subject=f"Invite: {company.name} (set your password)", body_text=body)
|
||||
except Exception:
|
||||
# Roll back created user if we cannot send invite email, to avoid orphan accounts.
|
||||
db.session.delete(u)
|
||||
db.session.commit()
|
||||
flash(
|
||||
"Failed to send invite email. Please check SMTP configuration (SMTP_* env vars).",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("company.my_company"))
|
||||
|
||||
flash(f"Invite sent to {email}", "success")
|
||||
return redirect(url_for("company.my_company"))
|
||||
|
||||
|
||||
@bp.post("/my-company/users/<int:user_id>/delete")
|
||||
@login_required
|
||||
def delete_company_user(user_id: int):
|
||||
company_user_required()
|
||||
|
||||
if int(user_id) == int(current_user.id):
|
||||
flash("You cannot delete yourself", "danger")
|
||||
return redirect(url_for("company.my_company"))
|
||||
|
||||
u = db.session.get(User, user_id)
|
||||
if not u or u.is_admin or u.company_id != current_user.company_id:
|
||||
abort(404)
|
||||
|
||||
email = u.email
|
||||
db.session.delete(u)
|
||||
db.session.commit()
|
||||
flash(f"User '{email}' deleted", "success")
|
||||
return redirect(url_for("company.my_company"))
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@login_required
|
||||
def dashboard():
|
||||
@@ -157,6 +347,34 @@ def playlist_detail(playlist_id: int):
|
||||
return render_template("company/playlist_detail.html", playlist=playlist)
|
||||
|
||||
|
||||
@bp.post("/playlists/<int:playlist_id>")
|
||||
@login_required
|
||||
def update_playlist(playlist_id: int):
|
||||
"""Update playlist metadata.
|
||||
|
||||
Currently supports renaming the playlist from the playlist detail (edit) page.
|
||||
"""
|
||||
|
||||
company_user_required()
|
||||
playlist = db.session.get(Playlist, playlist_id)
|
||||
if not playlist or playlist.company_id != current_user.company_id:
|
||||
abort(404)
|
||||
|
||||
name = (request.form.get("name") or "").strip()
|
||||
if not name:
|
||||
flash("Playlist name required", "danger")
|
||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||
|
||||
# Keep within DB column limit (String(120))
|
||||
if len(name) > 120:
|
||||
name = name[:120]
|
||||
|
||||
playlist.name = name
|
||||
db.session.commit()
|
||||
flash("Playlist renamed", "success")
|
||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||
|
||||
|
||||
@bp.post("/playlists/<int:playlist_id>/delete")
|
||||
@login_required
|
||||
def delete_playlist(playlist_id: int):
|
||||
@@ -283,6 +501,7 @@ def add_playlist_item(playlist_id: int):
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
|
||||
if item_type == "image":
|
||||
crop_mode = (request.form.get("crop_mode") or "16:9").strip().lower()
|
||||
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
||||
if wants_json:
|
||||
return _json_error(
|
||||
@@ -291,7 +510,12 @@ def add_playlist_item(playlist_id: int):
|
||||
flash("Unsupported image type", "danger")
|
||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||
try:
|
||||
item.file_path = _save_compressed_image(f, current_app.config["UPLOAD_FOLDER"])
|
||||
item.file_path = _save_compressed_image(
|
||||
f,
|
||||
current_app.config["UPLOAD_FOLDER"],
|
||||
current_user.company_id,
|
||||
crop_mode=crop_mode,
|
||||
)
|
||||
except Exception:
|
||||
if wants_json:
|
||||
return _json_error("Failed to process image upload", 500)
|
||||
@@ -330,7 +554,8 @@ def add_playlist_item(playlist_id: int):
|
||||
|
||||
# Keep as-is but always rename to a UUID.
|
||||
unique = uuid.uuid4().hex + ext
|
||||
save_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique)
|
||||
company_dir = ensure_company_upload_dir(current_app.config["UPLOAD_FOLDER"], current_user.company_id)
|
||||
save_path = os.path.join(company_dir, unique)
|
||||
f.save(save_path)
|
||||
|
||||
# Safety check: validate using the actual saved file size.
|
||||
@@ -351,7 +576,7 @@ def add_playlist_item(playlist_id: int):
|
||||
flash(msg, "danger")
|
||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||
|
||||
item.file_path = f"uploads/{unique}"
|
||||
item.file_path = f"uploads/{int(current_user.company_id)}/{unique}"
|
||||
|
||||
elif item_type == "webpage":
|
||||
url = request.form.get("url", "").strip()
|
||||
|
||||
Reference in New Issue
Block a user