first commit

This commit is contained in:
2026-01-23 13:54:58 +01:00
commit 32312fe4f2
29 changed files with 2172 additions and 0 deletions

9
.flaskenv Normal file
View File

@@ -0,0 +1,9 @@
FLASK_APP=app:create_app
FLASK_DEBUG=1
SMTP_HOST=smtp.strato.de
SMTP_PORT=465
SMTP_USERNAME=beheer@alphen.cloud
SMTP_PASSWORD=Fr@nkrijk2024!
SMTP_FROM=beheer@alphen.cloud
SMTP_STARTTLS=1
SMTP_DEBUG=1

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.env
.venv/
venv/
instance/
app/static/uploads/

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# Flask Digital Signage (simple)
Lightweight digital signage platform using **Flask + SQLite**.
## Features
- Central **admin** can manage companies, users, displays.
- Admin can **impersonate** any company user (no password).
- Company users can:
- Create playlists
- Add slides (image/video/webpage)
- Assign playlists to displays
- Displays are public **16:9 player webpages** suitable for kiosk browsers.
## Quickstart (Windows)
```bat
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
set FLASK_APP=app
flask init-db --admin-user admin --admin-pass admin
flask run --debug
```
If Flask can't discover the app automatically, use:
```bat
set FLASK_APP=app:create_app
flask run --debug
```
Open http://127.0.0.1:5000
## Notes
- SQLite DB is stored at `instance/signage.sqlite`.
- Uploaded files go to `app/static/uploads/`.
## SMTP / Forgot password
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
Recommended: put these in a local `.env` file in the repo root. Flask (via `python-dotenv`) will auto-load it on startup. `.env` is already gitignored.
You can start from `.env.example`:
```bat
copy .env.example .env
```
### Example
```bat
REM Option A: set env vars in the same terminal where you run `flask run`
set SMTP_HOST=smtp.strato.de
set SMTP_PORT=587
set SMTP_USERNAME=beheer@alphen.cloud
set SMTP_PASSWORD=***
set SMTP_FROM=beheer@alphen.cloud
set SMTP_STARTTLS=1
set SMTP_DEBUG=1
REM Option B: put the same keys/values in a .env file instead
```
Security note: do **not** commit SMTP passwords to the repo. Prefer secrets management and rotate leaked credentials.
### Troubleshooting mail delivery
If the reset email is not received:
1. Set `SMTP_DEBUG=1` and request a reset again.
2. Watch the Flask console output for SMTP responses / errors.
3. Verify:
- `SMTP_USERNAME` and `SMTP_FROM` are allowed by your provider.
- You are using STARTTLS (port 587).
- The recipient mailbox isnt filtering it (spam/quarantine).

78
app/__init__.py Normal file
View File

@@ -0,0 +1,78 @@
import os
from flask import Flask
from .extensions import db, login_manager
from .models import User
from .cli import init_db_command
def create_app():
app = Flask(__name__, instance_relative_config=True)
# Basic config
# Flask ships with SECRET_KEY=None by default, so setdefault() won't override it.
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY") or "dev-secret-change-me"
app.config.setdefault(
"SQLALCHEMY_DATABASE_URI",
os.environ.get("DATABASE_URL", "sqlite:///" + os.path.join(app.instance_path, "signage.sqlite")),
)
app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False)
app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads"))
app.config.setdefault("MAX_CONTENT_LENGTH", 500 * 1024 * 1024) # 500MB
os.makedirs(app.instance_path, exist_ok=True)
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
# Init extensions
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = "auth.login"
# Lightweight migration(s) for SQLite DBs created before new columns existed.
# This avoids requiring Alembic for this small project.
with app.app_context():
try:
uri = app.config.get("SQLALCHEMY_DATABASE_URI", "") or ""
if uri.startswith("sqlite:"):
cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(user)")).fetchall()]
if "email" not in cols:
db.session.execute(db.text("ALTER TABLE user ADD COLUMN email VARCHAR(255)"))
# 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.commit()
except Exception:
db.session.rollback()
@login_manager.user_loader
def load_user(user_id: str):
return db.session.get(User, int(user_id))
# CLI
app.cli.add_command(init_db_command)
# Blueprints
from .routes.auth import bp as auth_bp
from .routes.admin import bp as admin_bp
from .routes.company import bp as company_bp
from .routes.display import bp as display_bp
from .routes.api import bp as api_bp
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(company_bp)
app.register_blueprint(display_bp)
app.register_blueprint(api_bp)
# Home
from flask import redirect, url_for
from flask_login import current_user
@app.get("/")
def index():
if not current_user.is_authenticated:
return redirect(url_for("auth.login"))
if current_user.is_admin:
return redirect(url_for("admin.dashboard"))
return redirect(url_for("company.dashboard"))
return app

6
app/__main__.py Normal file
View File

@@ -0,0 +1,6 @@
from . import create_app
app = create_app()
if __name__ == "__main__":
app.run(debug=True)

40
app/cli.py Normal file
View File

@@ -0,0 +1,40 @@
import click
from flask.cli import with_appcontext
from .extensions import db
from .models import User
@click.command("init-db")
@click.option("--admin-user", required=True, help="Username for the initial admin")
@click.option("--admin-pass", required=True, help="Password for the initial admin")
@with_appcontext
def init_db_command(admin_user: str, admin_pass: str):
"""Create tables and ensure an admin account exists."""
db.create_all()
# Lightweight migration for older SQLite DBs: ensure User.email column exists.
# This avoids requiring Alembic for this small project.
try:
cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(user)")).fetchall()]
if "email" not in cols:
db.session.execute(db.text("ALTER TABLE user ADD COLUMN email VARCHAR(255)"))
db.session.commit()
except Exception:
# Best-effort; if it fails we continue so fresh DBs still work.
db.session.rollback()
existing = User.query.filter_by(username=admin_user).first()
if existing:
if not existing.is_admin:
existing.is_admin = True
existing.set_password(admin_pass)
db.session.commit()
click.echo(f"Updated admin user '{admin_user}'.")
return
u = User(username=admin_user, is_admin=True)
u.set_password(admin_pass)
db.session.add(u)
db.session.commit()
click.echo(f"Created admin user '{admin_user}'.")

61
app/email_utils.py Normal file
View File

@@ -0,0 +1,61 @@
import os
import smtplib
from email.message import EmailMessage
def send_email(*, to_email: str, subject: str, body_text: str):
"""Send a plain-text email using SMTP settings from environment variables.
Required env vars:
- SMTP_HOST
- SMTP_PORT
- SMTP_USERNAME
- SMTP_PASSWORD
- SMTP_FROM (defaults to SMTP_USERNAME)
Optional:
- SMTP_STARTTLS (default: "1")
- SMTP_TIMEOUT_SECONDS (default: "10")
- SMTP_DEBUG (default: "0") - set to 1 to print SMTP conversation to console
"""
host = os.environ.get("SMTP_HOST")
port = int(os.environ.get("SMTP_PORT", "587"))
username = os.environ.get("SMTP_USERNAME")
password = os.environ.get("SMTP_PASSWORD")
from_email = os.environ.get("SMTP_FROM") or username
starttls = os.environ.get("SMTP_STARTTLS", "1").lower() in ("1", "true", "yes", "on")
timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS", "10"))
debug = os.environ.get("SMTP_DEBUG", "0").lower() in ("1", "true", "yes", "on")
missing = []
if not host:
missing.append("SMTP_HOST")
if not username:
missing.append("SMTP_USERNAME")
if not password:
missing.append("SMTP_PASSWORD")
if not from_email:
missing.append("SMTP_FROM")
if missing:
raise RuntimeError(
"Missing SMTP configuration: "
+ ", ".join(missing)
+ ". Set them as environment variables (or in a local .env file)."
)
msg = EmailMessage()
msg["From"] = from_email
msg["To"] = to_email
msg["Subject"] = subject
msg.set_content(body_text)
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
if debug:
smtp.set_debuglevel(1)
smtp.ehlo()
if starttls:
smtp.starttls()
smtp.ehlo()
smtp.login(username, password)
smtp.send_message(msg)

5
app/extensions.py Normal file
View File

@@ -0,0 +1,5 @@
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
login_manager = LoginManager()

108
app/models.py Normal file
View File

@@ -0,0 +1,108 @@
import uuid
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import check_password_hash, generate_password_hash
from .extensions import db
class Company(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
users = db.relationship("User", back_populates="company", cascade="all, delete-orphan")
displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan")
playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan")
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(255), unique=True, nullable=True)
password_hash = db.Column(db.String(255), nullable=True)
is_admin = db.Column(db.Boolean, default=False, nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=True)
company = db.relationship("Company", back_populates="users")
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
def set_password(self, password: str):
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
if not self.password_hash:
return False
return check_password_hash(self.password_hash, password)
class Playlist(db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False)
name = db.Column(db.String(120), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
company = db.relationship("Company", back_populates="playlists")
items = db.relationship(
"PlaylistItem",
back_populates="playlist",
cascade="all, delete-orphan",
order_by="PlaylistItem.position.asc()",
)
class PlaylistItem(db.Model):
id = db.Column(db.Integer, primary_key=True)
playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=False)
# image|video|webpage
item_type = db.Column(db.String(20), nullable=False)
title = db.Column(db.String(200), nullable=True)
# For image/video: stored under /static/uploads
file_path = db.Column(db.String(400), nullable=True)
# For webpage
url = db.Column(db.String(1000), nullable=True)
# For image/webpage duration (seconds). For video we play until ended.
duration_seconds = db.Column(db.Integer, default=10, nullable=False)
position = db.Column(db.Integer, default=0, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
playlist = db.relationship("Playlist", back_populates="items")
class Display(db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False)
name = db.Column(db.String(120), nullable=False)
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 = db.relationship("Playlist")
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
company = db.relationship("Company", back_populates="displays")
class DisplaySession(db.Model):
"""Tracks active viewers of a display token so we can limit concurrent usage."""
id = db.Column(db.Integer, primary_key=True)
display_id = db.Column(db.Integer, db.ForeignKey("display.id"), nullable=False, index=True)
sid = db.Column(db.String(64), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
# Optional diagnostics
ip = db.Column(db.String(64), nullable=True)
user_agent = db.Column(db.String(300), nullable=True)
display = db.relationship("Display")
__table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),)

1
app/routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Package marker for routes.

192
app/routes/admin.py Normal file
View File

@@ -0,0 +1,192 @@
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 ..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 file_path.startswith("uploads/"):
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)
except Exception:
# Ignore cleanup failures
pass
@bp.get("/")
@login_required
def dashboard():
admin_required()
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, users=users)
@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()
flash("Company created", "success")
return redirect(url_for("admin.company_detail", company_id=c.id))
@bp.get("/companies/<int:company_id>")
@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/<int:company_id>/users")
@login_required
def create_company_user(company_id: int):
admin_required()
company = db.session.get(Company, company_id)
if not company:
abort(404)
username = request.form.get("username", "").strip()
email = (request.form.get("email", "") or "").strip().lower() or None
password = request.form.get("password", "")
if not username or not email or not password:
flash("Username, 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))
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(username=username, is_admin=False, company=company)
u.email = 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/<int:company_id>/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/<int:company_id>/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/<int:user_id>")
@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.username}.", "warning")
return redirect(url_for("company.dashboard"))
@bp.post("/users/<int:user_id>/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() or None
if email:
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
db.session.commit()
flash("Email updated", "success")
return redirect(url_for("admin.company_detail", company_id=u.company_id))

88
app/routes/api.py Normal file
View File

@@ -0,0 +1,88 @@
from datetime import datetime, timedelta
from flask import Blueprint, abort, jsonify, request, url_for
from ..extensions import db
from ..models import Display, DisplaySession
bp = Blueprint("api", __name__, url_prefix="/api")
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2
SESSION_TTL_SECONDS = 90
@bp.get("/display/<token>/playlist")
def display_playlist(token: str):
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
# 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()
playlist = display.assigned_playlist
if not playlist:
return jsonify({"display": display.name, "playlist": None, "items": []})
items = []
for item in playlist.items:
payload = {
"id": item.id,
"type": item.item_type,
"title": item.title,
"duration": item.duration_seconds,
}
if item.item_type in ("image", "video") and item.file_path:
payload["src"] = url_for("static", filename=item.file_path)
if item.item_type == "webpage":
payload["url"] = item.url
items.append(payload)
return jsonify(
{
"display": display.name,
"playlist": {"id": playlist.id, "name": playlist.name},
"items": items,
}
)

209
app/routes/auth.py Normal file
View File

@@ -0,0 +1,209 @@
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
from flask_login import current_user, login_required, login_user, logout_user
import logging
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from ..extensions import db
from ..email_utils import send_email
from ..models import User
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)
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})
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
@bp.get("/forgot-password")
def forgot_password():
if current_user.is_authenticated:
return redirect(url_for("index"))
return render_template("auth_forgot_password.html")
@bp.post("/forgot-password")
def forgot_password_post():
# Always respond with a generic message to avoid user enumeration.
email = (request.form.get("email", "") or "").strip().lower()
if not email:
flash("If that email exists, you will receive a reset link.", "info")
return redirect(url_for("auth.forgot_password"))
user = User.query.filter_by(email=email).first()
if user:
token = _make_reset_token(user)
reset_url = url_for("auth.reset_password", token=token, _external=True)
body = (
"Someone requested a password reset for your account.\n\n"
f"Reset your password using this link (valid for 30 minutes):\n{reset_url}\n\n"
"If you did not request this, you can ignore this email."
)
try:
send_email(to_email=user.email, subject="Password reset", body_text=body)
except Exception:
# Keep message generic to user (avoid leaking SMTP issues), but log for admins.
logger.exception("Failed to send password reset email")
flash("If that email exists, you will receive a reset link.", "info")
return redirect(url_for("auth.login"))
@bp.get("/reset-password/<token>")
def reset_password(token: str):
if current_user.is_authenticated:
return redirect(url_for("index"))
# Validate token up-front so UI can show a friendly error.
try:
_load_reset_token(token, max_age_seconds=30 * 60)
except SignatureExpired:
return render_template("auth_reset_password.html", token=None, token_error="Reset link has expired.")
except BadSignature:
return render_template("auth_reset_password.html", token=None, token_error="Invalid reset link.")
return render_template("auth_reset_password.html", token=token, token_error=None)
@bp.post("/reset-password/<token>")
def reset_password_post(token: str):
if current_user.is_authenticated:
return redirect(url_for("index"))
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
try:
user_id = _load_reset_token(token, max_age_seconds=30 * 60)
except SignatureExpired:
flash("Reset link has expired. Please request a new one.", "danger")
return redirect(url_for("auth.forgot_password"))
except BadSignature:
abort(400)
if not new_password:
flash("New password is required", "danger")
return redirect(url_for("auth.reset_password", token=token))
if len(new_password) < 8:
flash("New password must be at least 8 characters", "danger")
return redirect(url_for("auth.reset_password", token=token))
if new_password != confirm_password:
flash("New password and confirmation do not match", "danger")
return redirect(url_for("auth.reset_password", token=token))
user = db.session.get(User, user_id)
if not user:
# Generic response: treat as invalid.
abort(400)
user.set_password(new_password)
db.session.commit()
flash("Password updated. You can now log in.", "success")
return redirect(url_for("auth.login"))
@bp.get("/change-password")
@login_required
def change_password():
return render_template("auth_change_password.html")
@bp.post("/change-password")
@login_required
def change_password_post():
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if not current_user.check_password(current_password):
flash("Current password is incorrect", "danger")
return redirect(url_for("auth.change_password"))
if not new_password:
flash("New password is required", "danger")
return redirect(url_for("auth.change_password"))
if len(new_password) < 8:
flash("New password must be at least 8 characters", "danger")
return redirect(url_for("auth.change_password"))
if new_password != confirm_password:
flash("New password and confirmation do not match", "danger")
return redirect(url_for("auth.change_password"))
# Avoid no-op changes (helps catch accidental submits)
if current_user.check_password(new_password):
flash("New password must be different from the current password", "danger")
return redirect(url_for("auth.change_password"))
current_user.set_password(new_password)
db.session.commit()
flash("Password updated", "success")
# Send user back to their home area.
return redirect(url_for("index"))
@bp.get("/login")
def login():
if current_user.is_authenticated:
return redirect(url_for("index"))
return render_template("auth_login.html")
@bp.post("/login")
def login_post():
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
flash("Invalid username/password", "danger")
return redirect(url_for("auth.login"))
# clear impersonation marker, if any
session.pop("impersonator_admin_id", None)
login_user(user)
return redirect(url_for("index"))
@bp.get("/logout")
@login_required
def logout():
logout_user()
session.pop("impersonator_admin_id", None)
return redirect(url_for("auth.login"))
@bp.get("/stop-impersonation")
@login_required
def stop_impersonation():
admin_id = session.get("impersonator_admin_id")
if not admin_id:
return redirect(url_for("index"))
admin = db.session.get(User, int(admin_id))
session.pop("impersonator_admin_id", None)
if admin:
login_user(admin)
return redirect(url_for("admin.dashboard"))

254
app/routes/company.py Normal file
View File

@@ -0,0 +1,254 @@
import os
import uuid
from flask import Blueprint, abort, current_app, flash, 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 ..extensions import db
from ..models import Display, Playlist, PlaylistItem
def _save_compressed_image(uploaded_file, upload_folder: str) -> str:
"""Save an uploaded image as a compressed WEBP file.
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)
img = Image.open(uploaded_file)
# Normalize mode for webp
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
# Resize down if very large (keeps aspect ratio)
img.thumbnail((1920, 1080))
img.save(save_path, format="WEBP", quality=80, method=6)
return f"uploads/{unique}"
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/"):
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)
except Exception:
# Ignore cleanup failures
pass
bp = Blueprint("company", __name__, url_prefix="/company")
def company_user_required():
if not current_user.is_authenticated:
abort(403)
if current_user.is_admin:
abort(403)
if not current_user.company_id:
abort(403)
@bp.get("/")
@login_required
def dashboard():
company_user_required()
playlists = Playlist.query.filter_by(company_id=current_user.company_id).order_by(Playlist.name.asc()).all()
displays = Display.query.filter_by(company_id=current_user.company_id).order_by(Display.name.asc()).all()
return render_template("company/dashboard.html", playlists=playlists, displays=displays)
@bp.post("/playlists")
@login_required
def create_playlist():
company_user_required()
name = request.form.get("name", "").strip()
if not name:
flash("Playlist name required", "danger")
return redirect(url_for("company.dashboard"))
p = Playlist(company_id=current_user.company_id, name=name)
db.session.add(p)
db.session.commit()
flash("Playlist created", "success")
return redirect(url_for("company.playlist_detail", playlist_id=p.id))
@bp.get("/playlists/<int:playlist_id>")
@login_required
def playlist_detail(playlist_id: int):
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
return render_template("company/playlist_detail.html", playlist=playlist)
@bp.post("/playlists/<int:playlist_id>/delete")
@login_required
def delete_playlist(playlist_id: int):
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
# Unassign from any displays in this company
Display.query.filter_by(company_id=current_user.company_id, assigned_playlist_id=playlist.id).update(
{"assigned_playlist_id": None}
)
# cleanup uploaded files for image/video items
for it in list(playlist.items):
if it.item_type in ("image", "video"):
_try_delete_upload(it.file_path, current_app.config["UPLOAD_FOLDER"])
db.session.delete(playlist)
db.session.commit()
flash("Playlist deleted", "success")
return redirect(url_for("company.dashboard"))
@bp.post("/playlists/<int:playlist_id>/items/reorder")
@login_required
def reorder_playlist_items(playlist_id: int):
"""Persist new ordering for playlist items.
Expects form data: order=<comma-separated item ids>.
"""
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
order = (request.form.get("order") or "").strip()
if not order:
abort(400)
try:
ids = [int(x) for x in order.split(",") if x.strip()]
except ValueError:
abort(400)
# Ensure ids belong to this playlist
existing = PlaylistItem.query.filter(PlaylistItem.playlist_id == playlist_id, PlaylistItem.id.in_(ids)).all()
existing_ids = {i.id for i in existing}
if len(existing_ids) != len(ids):
abort(400)
# Re-number positions starting at 1
id_to_item = {i.id: i for i in existing}
for pos, item_id in enumerate(ids, start=1):
id_to_item[item_id].position = pos
db.session.commit()
return ("", 204)
@bp.post("/playlists/<int:playlist_id>/items")
@login_required
def add_playlist_item(playlist_id: int):
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
item_type = request.form.get("item_type")
title = request.form.get("title", "").strip() or None
duration = int(request.form.get("duration_seconds") or 10)
max_pos = (
db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0
)
pos = max_pos + 1
item = PlaylistItem(
playlist=playlist,
item_type=item_type,
title=title,
duration_seconds=max(1, duration),
position=pos,
)
if item_type in ("image", "video"):
f = request.files.get("file")
if not f or not f.filename:
flash("File required", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
filename = secure_filename(f.filename)
ext = os.path.splitext(filename)[1].lower()
if item_type == "image" and ext in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"):
try:
item.file_path = _save_compressed_image(f, current_app.config["UPLOAD_FOLDER"])
except Exception:
flash("Failed to process image upload", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
else:
# Videos (and unknown image types): 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)
f.save(save_path)
item.file_path = f"uploads/{unique}"
elif item_type == "webpage":
url = request.form.get("url", "").strip()
if not url:
flash("URL required", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
item.url = url
else:
flash("Invalid item type", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
db.session.add(item)
db.session.commit()
flash("Item added", "success")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/items/<int:item_id>/delete")
@login_required
def delete_item(item_id: int):
company_user_required()
item = db.session.get(PlaylistItem, item_id)
if not item or item.playlist.company_id != current_user.company_id:
abort(404)
playlist_id = item.playlist_id
if item.item_type in ("image", "video"):
_try_delete_upload(item.file_path, current_app.config["UPLOAD_FOLDER"])
db.session.delete(item)
db.session.commit()
flash("Item deleted", "success")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/displays/<int:display_id>/assign")
@login_required
def assign_playlist(display_id: int):
company_user_required()
display = db.session.get(Display, display_id)
if not display or display.company_id != current_user.company_id:
abort(404)
playlist_id = request.form.get("playlist_id")
if not playlist_id:
display.assigned_playlist_id = None
else:
playlist = db.session.get(Playlist, int(playlist_id))
if not playlist or playlist.company_id != current_user.company_id:
abort(400)
display.assigned_playlist_id = playlist.id
db.session.commit()
flash("Display assignment updated", "success")
return redirect(url_for("company.dashboard"))

13
app/routes/display.py Normal file
View File

@@ -0,0 +1,13 @@
from flask import Blueprint, abort, render_template
from ..models import Display
bp = Blueprint("display", __name__, url_prefix="/display")
@bp.get("/<token>")
def display_player(token: str):
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
return render_template("display/player.html", display=display)

View File

@@ -0,0 +1,9 @@
/*!
* Cropper.js v1.6.2
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2024-04-21T07:43:02.731Z
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}

View File

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-start">
<h1 class="h3">Company: {{ company.name }}</h1>
<div>
<a class="btn btn-outline-secondary" href="{{ url_for('admin.dashboard') }}">Back</a>
</div>
</div>
<div class="card border-danger mt-3">
<div class="card-body">
<h2 class="h6 text-danger mb-2">Danger zone</h2>
<p class="mb-2 text-muted">
Deleting a company will permanently remove <strong>all</strong> its users, displays, playlists and uploaded media.
</p>
<form
method="post"
action="{{ url_for('admin.delete_company', company_id=company.id) }}"
onsubmit="return confirm('Delete company \'{{ company.name }}\'? This will delete all its users, displays, playlists and media. This cannot be undone.');"
>
<button class="btn btn-danger" type="submit">Delete company</button>
</form>
</div>
</div>
<div class="row mt-4">
<div class="col-md-6">
<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">
<div class="mb-2">
<label class="form-label">Username</label>
<input class="form-control" name="username" required />
</div>
<div class="mb-2">
<label class="form-label">Email</label>
<input class="form-control" type="email" name="email" required />
</div>
<div class="mb-2">
<label class="form-label">Password</label>
<input class="form-control" type="password" name="password" required />
</div>
<button class="btn btn-success" type="submit">Create user</button>
</form>
<div class="list-group">
{% for u in company.users %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ u.username }}</strong>
<div class="text-muted">{{ u.email or "(no email set)" }}</div>
</div>
<div class="d-flex align-items-center gap-2">
<form method="post" action="{{ url_for('admin.update_user_email', user_id=u.id) }}" class="d-flex gap-2">
<input class="form-control form-control-sm" style="width: 240px" type="email" name="email" placeholder="email" value="{{ u.email or '' }}" />
<button class="btn btn-outline-primary btn-sm" type="submit">Save</button>
</form>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
<button class="btn btn-warning btn-sm" type="submit">Impersonate</button>
</form>
</div>
</div>
{% else %}
<div class="text-muted">No users.</div>
{% endfor %}
</div>
</div>
<div class="col-md-6">
<h2 class="h5">Displays</h2>
<form method="post" action="{{ url_for('admin.create_display', company_id=company.id) }}" class="card card-body mb-3">
<div class="input-group">
<input class="form-control" name="name" placeholder="Display name" />
<button class="btn btn-success" type="submit">Add display</button>
</div>
</form>
<div class="list-group">
{% for d in company.displays %}
<div class="list-group-item">
<div class="d-flex justify-content-between">
<div>
<strong>{{ d.name }}</strong>
<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>
<div class="text-muted">Assigned: {{ d.assigned_playlist.name if d.assigned_playlist else "(none)" }}</div>
</div>
</div>
{% else %}
<div class="text-muted">No displays.</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3">Admin dashboard</h1>
</div>
<div class="row mt-4">
<div class="col-md-6">
<h2 class="h5">Companies</h2>
<form method="post" action="{{ url_for('admin.create_company') }}" class="card card-body mb-3">
<div class="input-group">
<input class="form-control" name="name" placeholder="New company name" required />
<button class="btn btn-success" type="submit">Add</button>
</div>
</form>
<div class="list-group">
{% for c in companies %}
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.company_detail', company_id=c.id) }}">
{{ c.name }}
<span class="text-muted">(users: {{ c.users|length }}, displays: {{ c.displays|length }}, playlists: {{ c.playlists|length }})</span>
</a>
{% else %}
<div class="text-muted">No companies yet.</div>
{% endfor %}
</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>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center mt-4">
<div class="col-12 col-md-6">
<div class="card">
<div class="card-header">
<h1 class="h5 mb-0">Change password</h1>
</div>
<div class="card-body">
<form method="post" action="{{ url_for('auth.change_password_post') }}">
<div class="mb-3">
<label for="current_password" class="form-label">Current password</label>
<input id="current_password" name="current_password" type="password" class="form-control" autocomplete="current-password" required />
</div>
<div class="mb-3">
<label for="new_password" class="form-label">New password</label>
<input id="new_password" name="new_password" type="password" class="form-control" autocomplete="new-password" required minlength="8" />
<div class="form-text">Minimum 8 characters.</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Confirm new password</label>
<input id="confirm_password" name="confirm_password" type="password" class="form-control" autocomplete="new-password" required minlength="8" />
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Update password</button>
<a class="btn btn-outline-secondary" href="{{ url_for('index') }}">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<h1 class="h3 mb-3">Forgot password</h1>
<form method="post" class="card card-body">
<p class="text-muted">Enter your email address and well send you a password reset link.</p>
<div class="mb-3">
<label class="form-label">Email</label>
<input class="form-control" name="email" type="email" autocomplete="email" required />
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" type="submit">Send reset link</button>
<a class="btn btn-outline-secondary" href="{{ url_for('auth.login') }}">Back to login</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<h1 class="h3 mb-3">Login</h1>
<form method="post" class="card card-body">
<div class="mb-3">
<label class="form-label">Username</label>
<input class="form-control" name="username" autocomplete="username" required />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input class="form-control" type="password" name="password" autocomplete="current-password" required />
</div>
<button class="btn btn-primary" type="submit">Login</button>
<div class="mt-3">
<a href="{{ url_for('auth.forgot_password') }}">Forgot password?</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<h1 class="h3 mb-3">Reset password</h1>
{% if token_error %}
<div class="alert alert-danger">{{ token_error }}</div>
<a class="btn btn-outline-secondary" href="{{ url_for('auth.forgot_password') }}">Request a new reset link</a>
{% else %}
<form method="post" class="card card-body">
<div class="mb-3">
<label class="form-label">New password</label>
<input class="form-control" type="password" name="new_password" autocomplete="new-password" minlength="8" required />
<div class="form-text">Minimum 8 characters.</div>
</div>
<div class="mb-3">
<label class="form-label">Confirm new password</label>
<input class="form-control" type="password" name="confirm_password" autocomplete="new-password" minlength="8" required />
</div>
<button class="btn btn-primary" type="submit">Set new password</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}

54
app/templates/base.html Normal file
View File

@@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title or "Signage" }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<style>
body { padding-top: 4.5rem; }
.monospace { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/">Signage</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
{% if current_user.is_authenticated and current_user.is_admin %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.dashboard') }}">Admin</a></li>
{% elif current_user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('company.dashboard') }}">Company</a></li>
{% endif %}
</ul>
<ul class="navbar-nav ms-auto">
{% 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"><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') %}
<li class="nav-item"><a class="btn btn-warning btn-sm me-2" href="{{ url_for('auth.stop_impersonation') }}">Stop impersonation</a></li>
{% endif %}
<li class="nav-item"><a class="btn btn-outline-light btn-sm" href="{{ url_for('auth.logout') }}">Logout</a></li>
{% else %}
<li class="nav-item"><a class="btn btn-outline-light btn-sm" href="{{ url_for('auth.login') }}">Login</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mt-2">
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block content %}
<h1 class="h3">Company dashboard</h1>
<div class="row mt-4">
<div class="col-md-6">
<h2 class="h5">Playlists</h2>
<form method="post" action="{{ url_for('company.create_playlist') }}" class="card card-body mb-3">
<div class="input-group">
<input class="form-control" name="name" placeholder="New playlist name" required />
<button class="btn btn-success" type="submit">Add</button>
</div>
</form>
<div class="list-group">
{% for p in playlists %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<a class="text-decoration-none" href="{{ url_for('company.playlist_detail', playlist_id=p.id) }}">
<strong>{{ p.name }}</strong> <span class="text-muted">({{ p.items|length }} items)</span>
</a>
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=p.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</div>
</div>
{% else %}
<div class="text-muted">No playlists yet.</div>
{% endfor %}
</div>
</div>
<div class="col-md-6">
<h2 class="h5">Displays</h2>
<div class="list-group">
{% for d in displays %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<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>
</div>
<div style="min-width: 220px;">
<form method="post" action="{{ url_for('company.assign_playlist', display_id=d.id) }}" 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">Assign</button>
</form>
</div>
</div>
</div>
{% else %}
<div class="text-muted">No displays. Ask admin to add displays.</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,467 @@
{% extends "base.html" %}
{% block content %}
{# Cropper.js (used for image cropping) #}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/cropperjs/cropper.min.css') }}" />
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
<div class="d-flex gap-2">
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=playlist.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete playlist</button>
</form>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('company.dashboard') }}">Back</a>
</div>
</div>
<div class="row mt-4">
<div class="col-md-5">
<h2 class="h5">Add item</h2>
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data" class="card card-body">
<style>
.dropzone {
border: 2px dashed #6c757d;
border-radius: .5rem;
padding: 1rem;
text-align: center;
background: rgba(0,0,0,.02);
cursor: pointer;
user-select: none;
}
.dropzone.dragover {
border-color: #0d6efd;
background: rgba(13,110,253,.08);
}
.webpage-preview-frame {
width: 1200px;
height: 675px; /* 16:9 */
border: 0;
transform: scale(0.25);
transform-origin: 0 0;
background: #111;
}
.webpage-preview-wrap {
width: 100%;
height: 170px; /* 675 * 0.25 = ~168.75 */
overflow: hidden;
border: 1px solid #333;
border-radius: .25rem;
background: #111;
}
</style>
<div class="mb-3">
<label class="form-label">Type</label>
<div class="btn-group w-100" role="group" aria-label="Item type">
<input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="type-image">Image</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-webpage" autocomplete="off">
<label class="btn btn-outline-primary" for="type-webpage">Webpage</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off">
<label class="btn btn-outline-primary" for="type-video">Video</label>
</div>
<input type="hidden" name="item_type" id="item_type" value="image" />
</div>
<div class="mb-2">
<label class="form-label">Title (optional)</label>
<input class="form-control" name="title" />
</div>
<div class="mb-2" id="duration-group">
<label class="form-label">Duration (seconds, for images/webpages)</label>
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
</div>
{# Image section #}
<div id="section-image" class="item-type-section">
<label class="form-label">Image</label>
<div id="image-dropzone" class="dropzone mb-2">
<div><strong>Drag & drop</strong> an image here</div>
<div class="text-muted small">or click to select a file</div>
</div>
<input id="image-file-input" class="form-control d-none" type="file" name="file" accept="image/*" />
<div id="image-crop-container" class="d-none">
<div class="text-muted small mb-2">Crop to <strong>16:9</strong> (recommended for display screens).</div>
<div style="width: 100%; background: #111; border-radius: .25rem; overflow: hidden;">
<img id="image-crop-target" alt="Crop" style="max-width: 100%; display: block;" />
</div>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-outline-secondary btn-sm" type="button" id="image-crop-reset">Reset crop</button>
<div class="text-muted small align-self-center" id="image-crop-status"></div>
</div>
</div>
</div>
{# Webpage section #}
<div id="section-webpage" class="item-type-section d-none">
<div class="mb-2">
<label class="form-label">URL</label>
<input id="webpage-url" class="form-control" name="url" placeholder="https://..." inputmode="url" />
<div class="text-muted small mt-1">Preview might not work for all sites (some block embedding).</div>
</div>
<div id="webpage-preview" class="d-none">
<div class="d-flex justify-content-between align-items-center mb-1">
<div class="text-muted small">Preview</div>
<a id="webpage-open" href="#" target="_blank" rel="noopener noreferrer" class="small">Open</a>
</div>
<div class="webpage-preview-wrap">
<iframe
id="webpage-iframe"
class="webpage-preview-frame"
src="about:blank"
title="Webpage preview"
loading="lazy"
referrerpolicy="no-referrer"
></iframe>
</div>
</div>
</div>
{# Video section #}
<div id="section-video" class="item-type-section d-none">
<div class="alert alert-warning mb-2">
<strong>In production:</strong> video support is currently being worked on.
</div>
</div>
<button class="btn btn-success" id="add-item-submit" type="submit">Add</button>
</form>
</div>
<div class="col-md-7">
<h2 class="h5">Items</h2>
<div class="text-muted small mb-2">Tip: drag items to reorder. Changes save automatically.</div>
<div class="list-group" id="playlist-items" data-reorder-url="{{ url_for('company.reorder_playlist_items', playlist_id=playlist.id) }}">
{% for i in playlist.items %}
<div class="list-group-item" draggable="true" data-item-id="{{ i.id }}">
<div class="d-flex justify-content-between align-items-start gap-3">
<div style="width: 26px; cursor: grab;" class="text-muted" title="Drag to reorder"></div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>#{{ i.position }}</strong>
<span class="badge bg-secondary">{{ i.item_type }}</span>
<span>{{ i.title or '' }}</span>
</div>
<form method="post" action="{{ url_for('company.delete_item', item_id=i.id) }}" onsubmit="return confirm('Delete item?');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</div>
<div class="text-muted small">
{% if i.item_type in ['image','video'] %}
File: {{ i.file_path }}
{% else %}
URL: {{ i.url }}
{% endif %}
· Duration: {{ i.duration_seconds }}s
</div>
<div class="mt-2">
{% if i.item_type == 'image' and i.file_path %}
<img
src="{{ url_for('static', filename=i.file_path) }}"
alt="{{ i.title or 'image' }}"
style="max-width: 100%; max-height: 200px; display: block; background: #111;"
loading="lazy"
/>
{% elif i.item_type == 'video' and i.file_path %}
<video
src="{{ url_for('static', filename=i.file_path) }}"
style="max-width: 100%; max-height: 220px; display: block; background: #111;"
muted
controls
preload="metadata"
></video>
{% elif i.item_type == 'webpage' and i.url %}
<div class="d-flex gap-2 align-items-center">
<a href="{{ i.url }}" target="_blank" rel="noopener noreferrer">Open URL</a>
<span class="text-muted">(opens in new tab)</span>
</div>
<iframe
src="{{ i.url }}"
title="{{ i.title or i.url }}"
style="width: 100%; height: 200px; border: 1px solid #333; background: #111;"
loading="lazy"
referrerpolicy="no-referrer"
></iframe>
{% else %}
<div class="text-muted">No preview available.</div>
{% endif %}
</div>
</div>
</div>
</div>
{% else %}
<div class="text-muted">No items.</div>
{% endfor %}
</div>
{# Load Cropper.js BEFORE our inline script so window.Cropper is available #}
<script src="{{ url_for('static', filename='vendor/cropperjs/cropper.min.js') }}"></script>
<script>
(function() {
// -------------------------
// Add-item UI enhancements
// -------------------------
const form = document.getElementById('add-item-form');
if (!form) return;
const typeHidden = document.getElementById('item_type');
const submitBtn = document.getElementById('add-item-submit');
const durationGroup = document.getElementById('duration-group');
const sectionImage = document.getElementById('section-image');
const sectionWebpage = document.getElementById('section-webpage');
const sectionVideo = document.getElementById('section-video');
function setType(t) {
typeHidden.value = t;
sectionImage.classList.toggle('d-none', t !== 'image');
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
sectionVideo.classList.toggle('d-none', t !== 'video');
durationGroup.classList.toggle('d-none', t === 'video');
submitBtn.disabled = (t === 'video');
submitBtn.title = (t === 'video') ? 'Video is in production' : '';
if (t !== 'image') {
destroyCropper();
}
}
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
document.getElementById('type-video')?.addEventListener('change', () => setType('video'));
// -------------------------
// Image: drag/drop + crop
// -------------------------
const dropzone = document.getElementById('image-dropzone');
const fileInput = document.getElementById('image-file-input');
const cropContainer = document.getElementById('image-crop-container');
const cropImg = document.getElementById('image-crop-target');
const cropResetBtn = document.getElementById('image-crop-reset');
const cropStatus = document.getElementById('image-crop-status');
let cropper = null;
let currentObjectUrl = null;
function destroyCropper() {
try {
if (cropper) cropper.destroy();
} catch (e) {}
cropper = null;
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = null;
}
if (cropContainer) cropContainer.classList.add('d-none');
if (cropStatus) cropStatus.textContent = '';
}
function setFileOnInput(input, file) {
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
}
async function loadImageFile(file) {
if (!file || !file.type || !file.type.startsWith('image/')) {
cropStatus.textContent = 'Please choose an image file.';
return;
}
destroyCropper();
currentObjectUrl = URL.createObjectURL(file);
cropImg.src = currentObjectUrl;
cropContainer.classList.remove('d-none');
cropStatus.textContent = '';
// Wait for image to be ready
await new Promise((resolve, reject) => {
cropImg.onload = () => resolve();
cropImg.onerror = () => reject(new Error('Failed to load image'));
});
// Cropper.js is loaded from CDN, so window.Cropper should exist
if (!window.Cropper) {
cropStatus.textContent = 'Cropper failed to load. Check your network connection.';
return;
}
cropper = new window.Cropper(cropImg, {
aspectRatio: 16 / 9,
viewMode: 1,
autoCropArea: 1,
responsive: true,
background: false,
});
}
dropzone?.addEventListener('click', () => {
fileInput?.click();
});
dropzone?.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('dragover');
});
dropzone?.addEventListener('dragleave', () => {
dropzone.classList.remove('dragover');
});
dropzone?.addEventListener('drop', async (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
const f = e.dataTransfer?.files?.[0];
if (!f) return;
// Put the original file in the input (will be replaced by cropped version on submit)
setFileOnInput(fileInput, f);
await loadImageFile(f);
});
fileInput?.addEventListener('change', async () => {
const f = fileInput.files?.[0];
if (!f) return;
await loadImageFile(f);
});
cropResetBtn?.addEventListener('click', () => {
cropper?.reset();
});
// On submit: if image selected and we have a cropper, replace the file with the cropped 16:9 output.
form.addEventListener('submit', (e) => {
if (typeHidden.value !== 'image') return;
if (!cropper) return; // no cropper initialized; let the form submit normally
e.preventDefault();
submitBtn.disabled = true;
cropStatus.textContent = 'Preparing cropped image…';
const canvas = cropper.getCroppedCanvas({
width: 1280,
height: 720,
imageSmoothingQuality: 'high',
});
canvas.toBlob((blob) => {
if (!blob) {
cropStatus.textContent = 'Failed to crop image.';
submitBtn.disabled = false;
return;
}
const croppedFile = new File([blob], 'cropped.png', { type: 'image/png' });
setFileOnInput(fileInput, croppedFile);
cropStatus.textContent = '';
form.submit();
}, 'image/png');
});
// -------------------------
// Webpage: live preview
// -------------------------
const urlInput = document.getElementById('webpage-url');
const preview = document.getElementById('webpage-preview');
const iframe = document.getElementById('webpage-iframe');
const openLink = document.getElementById('webpage-open');
function normalizeUrl(raw) {
const val = (raw || '').trim();
if (!val) return '';
if (/^https?:\/\//i.test(val)) return val;
// Be forgiving: if user enters "example.com", treat it as https://example.com
return 'https://' + val;
}
let previewTimer = null;
function schedulePreview() {
if (previewTimer) window.clearTimeout(previewTimer);
previewTimer = window.setTimeout(() => {
const url = normalizeUrl(urlInput.value);
if (!url) {
preview.classList.add('d-none');
iframe.src = 'about:blank';
openLink.href = '#';
return;
}
preview.classList.remove('d-none');
iframe.src = url;
openLink.href = url;
}, 450);
}
urlInput?.addEventListener('input', schedulePreview);
// Set initial state
setType('image');
})();
(function() {
const list = document.getElementById('playlist-items');
if (!list) return;
let dragged = null;
function items() {
return Array.from(list.querySelectorAll('[data-item-id]'));
}
function computeOrder() {
return items().map(el => el.getAttribute('data-item-id')).join(',');
}
async function persist() {
const url = list.getAttribute('data-reorder-url');
const body = new URLSearchParams();
body.set('order', computeOrder());
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
});
}
list.addEventListener('dragstart', (e) => {
const el = e.target.closest('[data-item-id]');
if (!el) return;
dragged = el;
el.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragend', (e) => {
const el = e.target.closest('[data-item-id]');
if (el) el.style.opacity = '';
dragged = null;
});
list.addEventListener('dragover', (e) => {
e.preventDefault();
const over = e.target.closest('[data-item-id]');
if (!dragged || !over || over === dragged) return;
const rect = over.getBoundingClientRect();
const after = (e.clientY - rect.top) > (rect.height / 2);
if (after) {
if (over.nextSibling !== dragged) {
list.insertBefore(dragged, over.nextSibling);
}
} else {
if (over.previousSibling !== dragged) {
list.insertBefore(dragged, over);
}
}
});
list.addEventListener('drop', async (e) => {
e.preventDefault();
try { await persist(); } catch (err) { console.warn('Failed to persist order', err); }
});
})();
</script>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,118 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ display.name }}</title>
<style>
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
.notice { position: fixed; left: 12px; bottom: 12px; color: #bbb; font: 14px/1.3 sans-serif; }
</style>
</head>
<body>
<div id="stage"></div>
<div class="notice" id="notice"></div>
<script>
const token = "{{ display.token }}";
const stage = document.getElementById('stage');
const notice = document.getElementById('notice');
// Stable session id per browser (used to enforce max concurrent viewers per display token)
const SID_KEY = `display_sid_${token}`;
function getSid() {
let sid = null;
try { sid = localStorage.getItem(SID_KEY); } catch(e) { /* ignore */ }
if (!sid) {
sid = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(16).slice(2) + Date.now().toString(16));
try { localStorage.setItem(SID_KEY, sid); } catch(e) { /* ignore */ }
}
return sid;
}
const sid = getSid();
let playlist = null;
let idx = 0;
let timer = null;
async function fetchPlaylist() {
const res = await fetch(`/api/display/${token}/playlist?sid=${encodeURIComponent(sid)}`, { cache: 'no-store' });
if (res.status === 429) {
const data = await res.json().catch(() => null);
throw Object.assign(new Error(data?.message || 'Display limit reached'), { code: 'LIMIT', data });
}
return await res.json();
}
function clearStage() {
if (timer) { clearTimeout(timer); timer = null; }
stage.innerHTML = '';
}
function next() {
if (!playlist || !playlist.items || playlist.items.length === 0) {
notice.textContent = 'No playlist assigned.';
clearStage();
return;
}
const item = playlist.items[idx % playlist.items.length];
idx = (idx + 1) % playlist.items.length;
clearStage();
notice.textContent = playlist.playlist ? `${playlist.display}${playlist.playlist.name}` : playlist.display;
if (item.type === 'image') {
const el = document.createElement('img');
el.src = item.src;
stage.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000);
} else if (item.type === 'video') {
const el = document.createElement('video');
el.src = item.src;
el.autoplay = true;
el.muted = true;
el.playsInline = true;
el.onended = next;
stage.appendChild(el);
} else if (item.type === 'webpage') {
const el = document.createElement('iframe');
el.src = item.url;
stage.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000);
} else {
timer = setTimeout(next, 5000);
}
}
async function start() {
try {
playlist = await fetchPlaylist();
idx = 0;
next();
} catch (e) {
clearStage();
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
// keep retrying; if a slot frees up the display will start automatically.
}
// refresh playlist every 60s
setInterval(async () => {
try {
playlist = await fetchPlaylist();
if (!stage.firstChild) {
idx = 0;
next();
}
} catch(e) {
clearStage();
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
}
}, 60000);
}
start();
</script>
</body>
</html>

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
Flask-WTF==1.2.1
python-dotenv==1.0.0
Pillow==11.3.0

33
scripts/smoke_test.py Normal file
View File

@@ -0,0 +1,33 @@
import os
import sys
# 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
def main():
app = create_app()
rules = sorted({str(r) for r in app.url_map.iter_rules()})
print("App created:", app.name)
print("Routes:")
for r in rules:
print(" -", r)
required = {
"/admin/companies/<int:company_id>/delete",
"/auth/change-password",
"/auth/forgot-password",
"/auth/reset-password/<token>",
}
missing = sorted(required.difference(rules))
if missing:
raise SystemExit(f"Missing expected routes: {missing}")
if __name__ == "__main__":
main()