Initial import
This commit is contained in:
@@ -15,6 +15,16 @@ MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
|
||||
SESSION_TTL_SECONDS = 90
|
||||
|
||||
|
||||
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
|
||||
"""Return True if playlist is active based on its optional schedule window."""
|
||||
|
||||
if p.schedule_start and now_utc < p.schedule_start:
|
||||
return False
|
||||
if p.schedule_end and now_utc > p.schedule_end:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _enforce_and_touch_display_session(display: Display, sid: str | None):
|
||||
"""Enforce concurrent display viewer limit and touch last_seen.
|
||||
|
||||
@@ -103,6 +113,18 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]:
|
||||
raw = "no-playlist"
|
||||
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
# Apply scheduling + priority rule so a schedule change triggers a player refresh.
|
||||
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
|
||||
now_utc = datetime.utcnow()
|
||||
scheduled = [p for p in playlists if _is_playlist_active_now(p, now_utc)]
|
||||
if any(p.is_priority for p in scheduled):
|
||||
scheduled = [p for p in scheduled if p.is_priority]
|
||||
active_ids = [x for x in active_ids if any(p.id == x for p in scheduled)]
|
||||
|
||||
if not active_ids:
|
||||
raw = "no-active-playlist"
|
||||
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
# Pull items in a stable order so reordering affects signature.
|
||||
if use_mapping:
|
||||
items = (
|
||||
@@ -174,11 +196,30 @@ def display_playlist(token: str):
|
||||
use_mapping = False
|
||||
|
||||
if not active_ids:
|
||||
return jsonify({"display": display.name, "playlists": [], "items": []})
|
||||
return jsonify(
|
||||
{
|
||||
"display": display.name,
|
||||
"transition": display.transition or "none",
|
||||
"playlists": [],
|
||||
"items": [],
|
||||
}
|
||||
)
|
||||
|
||||
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
|
||||
pl_by_id = {p.id: p for p in playlists}
|
||||
ordered_playlists = [pl_by_id[x] for x in active_ids if x in pl_by_id]
|
||||
|
||||
# Filter playlists by schedule
|
||||
now_utc = datetime.utcnow()
|
||||
scheduled = [p for p in playlists if _is_playlist_active_now(p, now_utc)]
|
||||
|
||||
# Priority rule:
|
||||
# If any active (scheduled) playlist is marked priority, only play priority playlists.
|
||||
any_priority = any(p.is_priority for p in scheduled)
|
||||
if any_priority:
|
||||
scheduled = [p for p in scheduled if p.is_priority]
|
||||
|
||||
pl_by_id = {p.id: p for p in scheduled}
|
||||
scheduled_ids = [x for x in active_ids if x in pl_by_id]
|
||||
ordered_playlists = [pl_by_id[x] for x in scheduled_ids]
|
||||
|
||||
# Merge items across active playlists.
|
||||
if use_mapping:
|
||||
@@ -186,17 +227,21 @@ def display_playlist(token: str):
|
||||
PlaylistItem.query.join(DisplayPlaylist, DisplayPlaylist.playlist_id == PlaylistItem.playlist_id)
|
||||
.filter(
|
||||
DisplayPlaylist.display_id == display.id,
|
||||
PlaylistItem.playlist_id.in_(active_ids),
|
||||
PlaylistItem.playlist_id.in_(scheduled_ids),
|
||||
)
|
||||
.order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc())
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
merged = (
|
||||
PlaylistItem.query.filter(PlaylistItem.playlist_id == active_ids[0])
|
||||
.order_by(PlaylistItem.position.asc())
|
||||
.all()
|
||||
)
|
||||
# single-playlist fallback; apply schedule filter too.
|
||||
if scheduled_ids:
|
||||
merged = (
|
||||
PlaylistItem.query.filter(PlaylistItem.playlist_id == scheduled_ids[0])
|
||||
.order_by(PlaylistItem.position.asc())
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
merged = []
|
||||
|
||||
items = []
|
||||
for item in merged:
|
||||
@@ -217,6 +262,7 @@ def display_playlist(token: str):
|
||||
return jsonify(
|
||||
{
|
||||
"display": display.name,
|
||||
"transition": display.transition or "none",
|
||||
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
|
||||
"items": items,
|
||||
}
|
||||
|
||||
@@ -178,6 +178,39 @@ def _try_delete_upload(file_path: str | None, upload_root: str):
|
||||
bp = Blueprint("company", __name__, url_prefix="/company")
|
||||
|
||||
|
||||
def _parse_schedule_local_to_utc(*, date_str: str | None, time_str: str | None) -> datetime | None:
|
||||
"""Parse local date+time form inputs into a naive UTC datetime.
|
||||
|
||||
Inputs come from <input type="date"> and <input type="time">.
|
||||
We interpret them as *local* time of the server.
|
||||
|
||||
Note: this project currently does not store per-company timezone; in most deployments
|
||||
server timezone matches users. If you need per-company timezone later, we can extend
|
||||
this function.
|
||||
"""
|
||||
|
||||
d = (date_str or "").strip()
|
||||
t = (time_str or "").strip()
|
||||
if not d and not t:
|
||||
return None
|
||||
if not d or not t:
|
||||
# Require both parts for clarity
|
||||
raise ValueError("Both date and time are required")
|
||||
|
||||
# Basic parsing: YYYY-MM-DD and HH:MM
|
||||
try:
|
||||
year, month, day = [int(x) for x in d.split("-")]
|
||||
hh, mm = [int(x) for x in t.split(":")[:2]]
|
||||
except Exception:
|
||||
raise ValueError("Invalid date/time")
|
||||
|
||||
# Interpret as local time, convert to UTC naive
|
||||
local_dt = datetime(year, month, day, hh, mm)
|
||||
# local_dt.timestamp() uses local timezone when naive.
|
||||
utc_ts = local_dt.timestamp()
|
||||
return datetime.utcfromtimestamp(utc_ts)
|
||||
|
||||
|
||||
def company_user_required():
|
||||
if not current_user.is_authenticated:
|
||||
abort(403)
|
||||
@@ -347,6 +380,7 @@ def dashboard():
|
||||
return render_template(
|
||||
"company/dashboard.html",
|
||||
playlists=playlists,
|
||||
now_utc=datetime.utcnow(),
|
||||
playlists_json=playlists_json,
|
||||
displays=displays,
|
||||
)
|
||||
@@ -374,7 +408,7 @@ def playlist_detail(playlist_id: int):
|
||||
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)
|
||||
return render_template("company/playlist_detail.html", playlist=playlist, now_utc=datetime.utcnow())
|
||||
|
||||
|
||||
@bp.post("/playlists/<int:playlist_id>")
|
||||
@@ -405,6 +439,89 @@ def update_playlist(playlist_id: int):
|
||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||
|
||||
|
||||
@bp.post("/playlists/<int:playlist_id>/schedule")
|
||||
@login_required
|
||||
def update_playlist_schedule(playlist_id: int):
|
||||
"""Update playlist schedule window + priority flag."""
|
||||
|
||||
company_user_required()
|
||||
playlist = db.session.get(Playlist, playlist_id)
|
||||
if not playlist or playlist.company_id != current_user.company_id:
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
start = _parse_schedule_local_to_utc(
|
||||
date_str=request.form.get("schedule_start_date"),
|
||||
time_str=request.form.get("schedule_start_time"),
|
||||
)
|
||||
end = _parse_schedule_local_to_utc(
|
||||
date_str=request.form.get("schedule_end_date"),
|
||||
time_str=request.form.get("schedule_end_time"),
|
||||
)
|
||||
except ValueError as e:
|
||||
flash(str(e), "danger")
|
||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||
|
||||
if start and end and end < start:
|
||||
flash("End must be after start", "danger")
|
||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||
|
||||
playlist.schedule_start = start
|
||||
playlist.schedule_end = end
|
||||
db.session.commit()
|
||||
|
||||
flash("Schedule updated", "success")
|
||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||
|
||||
|
||||
@bp.post("/playlists/<int:playlist_id>/schedule/delete")
|
||||
@login_required
|
||||
def clear_playlist_schedule(playlist_id: int):
|
||||
"""Clear schedule for a playlist (sets start/end to NULL)."""
|
||||
|
||||
company_user_required()
|
||||
playlist = db.session.get(Playlist, playlist_id)
|
||||
if not playlist or playlist.company_id != current_user.company_id:
|
||||
abort(404)
|
||||
|
||||
playlist.schedule_start = None
|
||||
playlist.schedule_end = None
|
||||
db.session.commit()
|
||||
flash("Schedule removed", "success")
|
||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||
|
||||
|
||||
@bp.post("/playlists/<int:playlist_id>/priority")
|
||||
@login_required
|
||||
def update_playlist_priority(playlist_id: int):
|
||||
"""Update playlist priority flag."""
|
||||
|
||||
company_user_required()
|
||||
playlist = db.session.get(Playlist, playlist_id)
|
||||
if not playlist or playlist.company_id != current_user.company_id:
|
||||
abort(404)
|
||||
|
||||
wants_json = (
|
||||
(request.headers.get("X-Requested-With") == "XMLHttpRequest")
|
||||
or ("application/json" in (request.headers.get("Accept") or ""))
|
||||
or request.is_json
|
||||
)
|
||||
|
||||
# Accept both form and JSON payloads.
|
||||
raw = request.form.get("is_priority")
|
||||
if raw is None and request.is_json:
|
||||
raw = (request.get_json(silent=True) or {}).get("is_priority")
|
||||
|
||||
playlist.is_priority = bool((raw or "").strip())
|
||||
db.session.commit()
|
||||
|
||||
if wants_json:
|
||||
return jsonify({"ok": True, "is_priority": bool(playlist.is_priority)})
|
||||
|
||||
flash("Priority updated", "success")
|
||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||
|
||||
|
||||
@bp.post("/playlists/<int:playlist_id>/delete")
|
||||
@login_required
|
||||
def delete_playlist(playlist_id: int):
|
||||
@@ -850,6 +967,14 @@ def update_display(display_id: int):
|
||||
def _json_error(message: str, status: int = 400):
|
||||
return jsonify({"ok": False, "error": message}), status
|
||||
|
||||
def _normalize_transition(val: str | None) -> str | None:
|
||||
v = (val or "").strip().lower()
|
||||
if not v:
|
||||
return None
|
||||
if v not in {"none", "fade", "slide"}:
|
||||
return None
|
||||
return v
|
||||
|
||||
# Inputs from either form or JSON
|
||||
payload = request.get_json(silent=True) if request.is_json else None
|
||||
|
||||
@@ -869,6 +994,16 @@ def update_display(display_id: int):
|
||||
desc = desc[:200]
|
||||
display.description = desc
|
||||
|
||||
# Slide transition
|
||||
if request.is_json:
|
||||
if payload is None:
|
||||
return _json_error("Invalid JSON")
|
||||
if "transition" in payload:
|
||||
display.transition = _normalize_transition(payload.get("transition"))
|
||||
else:
|
||||
# Form POST implies full update
|
||||
display.transition = _normalize_transition(request.form.get("transition"))
|
||||
|
||||
# Playlist assignment
|
||||
if request.is_json:
|
||||
if "playlist_id" in payload:
|
||||
@@ -908,6 +1043,7 @@ def update_display(display_id: int):
|
||||
"id": display.id,
|
||||
"name": display.name,
|
||||
"description": display.description,
|
||||
"transition": display.transition,
|
||||
"assigned_playlist_id": display.assigned_playlist_id,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user