Add company dashboard improvements and upload/auth features

This commit is contained in:
2026-01-23 20:21:11 +01:00
parent 1394ef6f67
commit ea3d0164f2
14 changed files with 1004 additions and 112 deletions

View File

@@ -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()