first commit
This commit is contained in:
9
.flaskenv
Normal file
9
.flaskenv
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
instance/
|
||||||
|
app/static/uploads/
|
||||||
87
README.md
Normal file
87
README.md
Normal 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 isn’t filtering it (spam/quarantine).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
78
app/__init__.py
Normal file
78
app/__init__.py
Normal 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
6
app/__main__.py
Normal 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
40
app/cli.py
Normal 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
61
app/email_utils.py
Normal 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
5
app/extensions.py
Normal 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
108
app/models.py
Normal 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
1
app/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Package marker for routes.
|
||||||
192
app/routes/admin.py
Normal file
192
app/routes/admin.py
Normal 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
88
app/routes/api.py
Normal 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
209
app/routes/auth.py
Normal 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
254
app/routes/company.py
Normal 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
13
app/routes/display.py
Normal 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)
|
||||||
9
app/static/vendor/cropperjs/cropper.min.css
vendored
Normal file
9
app/static/vendor/cropperjs/cropper.min.css
vendored
Normal 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}
|
||||||
10
app/static/vendor/cropperjs/cropper.min.js
vendored
Normal file
10
app/static/vendor/cropperjs/cropper.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
95
app/templates/admin/company_detail.html
Normal file
95
app/templates/admin/company_detail.html
Normal 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 %}
|
||||||
54
app/templates/admin/dashboard.html
Normal file
54
app/templates/admin/dashboard.html
Normal 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 %}
|
||||||
37
app/templates/auth_change_password.html
Normal file
37
app/templates/auth_change_password.html
Normal 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 %}
|
||||||
20
app/templates/auth_forgot_password.html
Normal file
20
app/templates/auth_forgot_password.html
Normal 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 we’ll 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 %}
|
||||||
22
app/templates/auth_login.html
Normal file
22
app/templates/auth_login.html
Normal 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 %}
|
||||||
27
app/templates/auth_reset_password.html
Normal file
27
app/templates/auth_reset_password.html
Normal 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
54
app/templates/base.html
Normal 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>
|
||||||
62
app/templates/company/dashboard.html
Normal file
62
app/templates/company/dashboard.html
Normal 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 %}
|
||||||
467
app/templates/company/playlist_detail.html
Normal file
467
app/templates/company/playlist_detail.html
Normal 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 %}
|
||||||
118
app/templates/display/player.html
Normal file
118
app/templates/display/player.html
Normal 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
6
requirements.txt
Normal 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
33
scripts/smoke_test.py
Normal 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()
|
||||||
Reference in New Issue
Block a user