import uuid import os from flask import Blueprint, abort, current_app, flash, redirect, render_template, request, session, url_for 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") def admin_required(): if not current_user.is_authenticated or not current_user.is_admin: abort(403) 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 is_valid_upload_relpath(file_path): return 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) except Exception: # Ignore cleanup failures pass @bp.get("/") @login_required def dashboard(): admin_required() companies = Company.query.order_by(Company.name.asc()).all() return render_template("admin/dashboard.html", companies=companies) @bp.post("/companies") @login_required def create_company(): admin_required() name = request.form.get("name", "").strip() if not name: flash("Company name required", "danger") return redirect(url_for("admin.dashboard")) if Company.query.filter_by(name=name).first(): flash("Company name already exists", "danger") return redirect(url_for("admin.dashboard")) 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)) @bp.get("/companies/") @login_required def company_detail(company_id: int): admin_required() company = db.session.get(Company, company_id) if not company: abort(404) return render_template("admin/company_detail.html", company=company) @bp.post("/companies//users") @login_required def create_company_user(company_id: int): admin_required() company = db.session.get(Company, company_id) if not company: abort(404) email = (request.form.get("email", "") or "").strip().lower() or None password = request.form.get("password", "") if not email or not password: flash("Email and password required", "danger") return redirect(url_for("admin.company_detail", company_id=company_id)) if User.query.filter_by(email=email).first(): flash("Email already exists", "danger") return redirect(url_for("admin.company_detail", company_id=company_id)) u = User(is_admin=False, company=company) u.email = email u.username = email u.set_password(password) db.session.add(u) db.session.commit() flash("User created", "success") return redirect(url_for("admin.company_detail", company_id=company_id)) @bp.post("/companies//displays") @login_required def create_display(company_id: int): admin_required() company = db.session.get(Company, company_id) if not company: abort(404) name = request.form.get("name", "").strip() or "Display" token = uuid.uuid4().hex d = Display(company=company, name=name, token=token) db.session.add(d) db.session.commit() flash("Display created", "success") return redirect(url_for("admin.company_detail", company_id=company_id)) @bp.post("/companies//delete") @login_required def delete_company(company_id: int): admin_required() company = db.session.get(Company, company_id) if not company: abort(404) # If FK constraints are enabled, we must delete in a safe order. # 1) Detach displays from playlists (Display.assigned_playlist_id -> Playlist.id) for d in list(company.displays): d.assigned_playlist_id = None # 2) Delete display sessions referencing displays of this company display_ids = [d.id for d in company.displays] if display_ids: DisplaySession.query.filter(DisplaySession.display_id.in_(display_ids)).delete(synchronize_session=False) # 3) Clean up uploaded media files for all playlist items upload_folder = current_app.config["UPLOAD_FOLDER"] items = ( PlaylistItem.query.join(Playlist, PlaylistItem.playlist_id == Playlist.id) .filter(Playlist.company_id == company.id) .all() ) for it in items: if it.item_type in ("image", "video"): _try_delete_upload(it.file_path, upload_folder) # 4) Delete the company; cascades will delete users/displays/playlists/items. company_name = company.name db.session.delete(company) db.session.commit() flash(f"Company '{company_name}' deleted (including users, displays and playlists).", "success") return redirect(url_for("admin.dashboard")) @bp.post("/impersonate/") @login_required def impersonate(user_id: int): admin_required() target = db.session.get(User, user_id) if not target or target.is_admin: flash("Cannot impersonate this user", "danger") return redirect(url_for("admin.dashboard")) # Save admin id in session so we can return without any password. session["impersonator_admin_id"] = current_user.id login_user(target) flash(f"Impersonating {target.email or '(no email)'}.", "warning") return redirect(url_for("company.dashboard")) @bp.post("/users//email") @login_required def update_user_email(user_id: int): admin_required() u = db.session.get(User, user_id) if not u: abort(404) email = (request.form.get("email", "") or "").strip().lower() 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() if existing: flash("Email already exists", "danger") return redirect(url_for("admin.company_detail", company_id=u.company_id)) u.email = email # keep backwards-compatible username column in sync u.username = email db.session.commit() flash("Email updated", "success") return redirect(url_for("admin.company_detail", company_id=u.company_id)) @bp.post("/users//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//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))