diff --git a/app/__init__.py b/app/__init__.py index 7d507cb..2e6d109 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -57,6 +57,11 @@ def create_app(): db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)")) db.session.commit() + # Displays: optional transition between slides (none|fade|slide) + if "transition" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)")) + db.session.commit() + # Companies: optional per-company storage quota company_cols = [ r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall() @@ -128,6 +133,22 @@ def create_app(): ) ) db.session.commit() + + # Playlists: schedule + priority flags + playlist_cols = [ + r[1] for r in db.session.execute(db.text("PRAGMA table_info(playlist)")).fetchall() + ] + if "schedule_start" not in playlist_cols: + db.session.execute(db.text("ALTER TABLE playlist ADD COLUMN schedule_start DATETIME")) + db.session.commit() + if "schedule_end" not in playlist_cols: + db.session.execute(db.text("ALTER TABLE playlist ADD COLUMN schedule_end DATETIME")) + db.session.commit() + if "is_priority" not in playlist_cols: + db.session.execute( + db.text("ALTER TABLE playlist ADD COLUMN is_priority BOOLEAN NOT NULL DEFAULT 0") + ) + db.session.commit() except Exception: db.session.rollback() diff --git a/app/models.py b/app/models.py index caf7fe1..f6c946a 100644 --- a/app/models.py +++ b/app/models.py @@ -49,6 +49,18 @@ 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) + + # Optional schedule window in UTC. + # - If both are NULL: playlist is always active. + # - If start is set: playlist is active from start onward. + # - If end is set: playlist is active until end. + schedule_start = db.Column(db.DateTime, nullable=True) + schedule_end = db.Column(db.DateTime, nullable=True) + + # If true, this playlist's items take precedence over non-priority playlists + # when multiple playlists are assigned to a display. + is_priority = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) company = db.relationship("Company", back_populates="playlists") @@ -88,6 +100,8 @@ class Display(db.Model): name = db.Column(db.String(120), nullable=False) # Optional short description (e.g. "entrance", "office") description = db.Column(db.String(200), nullable=True) + # Transition animation between slides: none|fade|slide + transition = db.Column(db.String(20), nullable=True) 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) diff --git a/app/routes/api.py b/app/routes/api.py index 2394cc7..a0a2703 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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, } diff --git a/app/routes/company.py b/app/routes/company.py index cc50e99..c94a3f3 100644 --- a/app/routes/company.py +++ b/app/routes/company.py @@ -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 and . + 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/") @@ -405,6 +439,89 @@ def update_playlist(playlist_id: int): return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) +@bp.post("/playlists//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//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//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//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, }, } diff --git a/app/static/styles.css b/app/static/styles.css index 7318433..e713e1c 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -290,3 +290,20 @@ h1, h2, h3, .display-1, .display-2, .display-3 { .toast { border-radius: var(--radius); } + +/* Small status dot used on company dashboard next to the schedule icon */ +.schedule-status-dot { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 50%; + vertical-align: middle; +} + +.schedule-status-dot.active { + background: #198754; +} + +.schedule-status-dot.inactive { + background: #dc3545; +} diff --git a/app/templates/company/dashboard.html b/app/templates/company/dashboard.html index 51213c3..ffde3a9 100644 --- a/app/templates/company/dashboard.html +++ b/app/templates/company/dashboard.html @@ -24,7 +24,22 @@ {% for p in playlists %} - {{ p.name }} + + {{ p.name }} + {# Indicators: schedule + priority #} + {% set has_schedule = (p.schedule_start is not none) or (p.schedule_end is not none) %} + {% if has_schedule %} + {% set is_active = (not p.schedule_start or p.schedule_start <= now_utc) and (not p.schedule_end or now_utc <= p.schedule_end) %} + đź“… + + {% endif %} + {% if p.is_priority %} + âť— + {% endif %} + {{ p.items|length }}
@@ -79,27 +94,17 @@ class="btn btn-ink btn-sm js-edit-playlists" data-display-id="{{ d.id }}" data-display-name="{{ d.name }}" + data-current-desc="{{ d.description or '' }}" + data-current-transition="{{ d.transition or 'none' }}" data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}" data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}" > - Select playlists + Configure display
—
- -
- -
@@ -122,36 +127,37 @@ - - -