88827be7be
Admin-only page reachable from a new card on the admin panel (replacing the Quick Actions card) that centralises housekeeping operations: * Database integrity: PRAGMA integrity_check / quick_check / foreign_key_check, plus VACUUM + ANALYZE to reclaim free space. * Leftover records: detects and removes orphaned rows left behind by deleted players/content (player_feedback, player_edit, playlist_content, dangling player.playlist_id) and orphaned edited-media folders on disk. * Player authentication tokens: manual reset (single or all), a configurable automatic reset interval, and cancellation of a pending reset. Resetting a token rotates player.auth_code and sets auth_reset_required, so the next heartbeat (POST /api/player-feedback) answers 401 + auth_required and the client restarts its authentication flow. A successful authentication clears the flag and stamps auth_token_issued_at, from which the interval is measured. New: PlayerAuthConfig model, add_player_auth_reset migration (registered in docker-entrypoint.sh), admin/maintenance.html.
192 lines
7.7 KiB
Python
192 lines
7.7 KiB
Python
"""Player model for digital signage players."""
|
|
from datetime import datetime
|
|
from typing import Optional, List
|
|
|
|
from app.extensions import db
|
|
|
|
|
|
class Player(db.Model):
|
|
"""Player model representing a digital signage device.
|
|
|
|
Attributes:
|
|
id: Primary key
|
|
name: Display name for the player
|
|
hostname: Unique hostname/identifier for the player
|
|
location: Physical location description
|
|
auth_code: Authentication code for API access (legacy)
|
|
password_hash: Hashed password for player authentication
|
|
quickconnect_code: Hashed quick connect code for easy pairing
|
|
orientation: Display orientation (Landscape/Portrait)
|
|
status: Current player status (online, offline, error)
|
|
last_seen: Last activity timestamp
|
|
auth_token_issued_at: When the current auth token was issued
|
|
auth_reset_required: Whether the player must re-authenticate
|
|
playlist_id: Assigned playlist (sync version comes from Playlist.version)
|
|
created_at: Player creation timestamp
|
|
"""
|
|
__tablename__ = 'player'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(255), nullable=False)
|
|
hostname = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
location = db.Column(db.String(255), nullable=True)
|
|
auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
password_hash = db.Column(db.String(255), nullable=False)
|
|
quickconnect_code = db.Column(db.String(255), nullable=True)
|
|
orientation = db.Column(db.String(16), default='Landscape', nullable=False)
|
|
status = db.Column(db.String(50), default='offline', index=True)
|
|
last_seen = db.Column(db.DateTime, nullable=True, index=True)
|
|
last_heartbeat = db.Column(db.DateTime, nullable=True, index=True)
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
|
|
|
# Authentication token lifecycle. ``auth_token_issued_at`` records when the
|
|
# current ``auth_code`` was handed out, so the maintenance page can expire
|
|
# tokens by age. ``auth_reset_required`` is set when the token has been
|
|
# invalidated; the next heartbeat then tells the player to re-authenticate.
|
|
auth_token_issued_at = db.Column(db.DateTime, nullable=True)
|
|
auth_reset_required = db.Column(db.Boolean, default=False, nullable=False)
|
|
|
|
# Playlist assignment
|
|
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
|
nullable=True, index=True)
|
|
|
|
# Deployment tracking
|
|
deployment_status = db.Column(db.String(50), default='pending', nullable=True) # pending, deployed, failed
|
|
last_deployment_at = db.Column(db.DateTime, nullable=True)
|
|
last_deployment_status = db.Column(db.String(50), nullable=True) # success, failed
|
|
last_deployment_message = db.Column(db.Text, nullable=True)
|
|
|
|
# Relationships
|
|
playlist = db.relationship('Playlist', back_populates='players')
|
|
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
|
cascade='all, delete-orphan', lazy='dynamic')
|
|
|
|
def __repr__(self) -> str:
|
|
"""String representation of Player."""
|
|
return f'<Player {self.name} (ID={self.id}, Status={self.status})>'
|
|
|
|
@property
|
|
def is_online(self) -> bool:
|
|
"""Check if player is online (seen in last 5 minutes)."""
|
|
if not self.last_seen:
|
|
return False
|
|
delta = datetime.utcnow() - self.last_seen
|
|
return delta.total_seconds() < 300 # 5 minutes
|
|
|
|
def update_status(self, status: str) -> None:
|
|
"""Update player status and last seen timestamp.
|
|
|
|
Args:
|
|
status: New status value
|
|
"""
|
|
self.status = status
|
|
self.last_seen = datetime.utcnow()
|
|
|
|
def set_password(self, password: str) -> None:
|
|
"""Set player password with bcrypt hashing.
|
|
|
|
Args:
|
|
password: Plain text password
|
|
"""
|
|
from app.extensions import bcrypt
|
|
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
|
|
|
def check_password(self, password: str) -> bool:
|
|
"""Verify player password.
|
|
|
|
Args:
|
|
password: Plain text password to check
|
|
|
|
Returns:
|
|
True if password matches, False otherwise
|
|
"""
|
|
from app.extensions import bcrypt
|
|
return bcrypt.check_password_hash(self.password_hash, password)
|
|
|
|
def set_quickconnect_code(self, code: str) -> None:
|
|
"""Set quick connect code with bcrypt hashing.
|
|
|
|
Args:
|
|
code: Plain text quick connect code
|
|
"""
|
|
from app.extensions import bcrypt
|
|
self.quickconnect_code = bcrypt.generate_password_hash(code).decode('utf-8')
|
|
|
|
def check_quickconnect_code(self, code: str) -> bool:
|
|
"""Verify quick connect code.
|
|
|
|
Args:
|
|
code: Plain text code to check
|
|
|
|
Returns:
|
|
True if code matches, False otherwise
|
|
"""
|
|
if not self.quickconnect_code:
|
|
return False
|
|
from app.extensions import bcrypt
|
|
return bcrypt.check_password_hash(self.quickconnect_code, code)
|
|
|
|
def rotate_auth_token(self, force_reauth: bool = True) -> str:
|
|
"""Issue a brand new authentication token for this player.
|
|
|
|
Rotating the token invalidates any token the player currently holds:
|
|
endpoints guarded by ``verify_player_auth`` will start returning 401
|
|
for the old value. Combined with ``auth_reset_required`` this makes the
|
|
player restart its authentication flow on the next heartbeat.
|
|
|
|
Args:
|
|
force_reauth: When True (default) also flag the player so the next
|
|
heartbeat asks it to re-authenticate.
|
|
|
|
Returns:
|
|
The newly generated auth code.
|
|
"""
|
|
import secrets
|
|
self.auth_code = secrets.token_urlsafe(16)
|
|
self.auth_token_issued_at = datetime.utcnow()
|
|
if force_reauth:
|
|
self.auth_reset_required = True
|
|
return self.auth_code
|
|
|
|
def mark_authenticated(self) -> None:
|
|
"""Record a successful authentication.
|
|
|
|
Stamps the token issue time and clears any pending reset request, so a
|
|
player that re-authenticates stops being told to authenticate again.
|
|
"""
|
|
self.auth_token_issued_at = datetime.utcnow()
|
|
self.auth_reset_required = False
|
|
|
|
def auth_token_age_days(self) -> Optional[float]:
|
|
"""Return the age of the current token in days, or ``None`` if unset."""
|
|
if not self.auth_token_issued_at:
|
|
return None
|
|
delta = datetime.utcnow() - self.auth_token_issued_at
|
|
return delta.total_seconds() / 86400
|
|
|
|
@staticmethod
|
|
def authenticate(hostname: str, password: str = None, quickconnect_code: str = None) -> Optional['Player']:
|
|
"""Authenticate a player by hostname and password or quickconnect code.
|
|
|
|
Args:
|
|
hostname: Player hostname
|
|
password: Player password (optional if using quickconnect)
|
|
quickconnect_code: Quick connect code (optional if using password)
|
|
|
|
Returns:
|
|
Player instance if authentication successful, None otherwise
|
|
"""
|
|
player = Player.query.filter_by(hostname=hostname).first()
|
|
if not player:
|
|
return None
|
|
|
|
# Try password authentication first
|
|
if password and player.check_password(password):
|
|
return player
|
|
|
|
# Try quickconnect code authentication
|
|
if quickconnect_code and player.check_quickconnect_code(quickconnect_code):
|
|
return player
|
|
|
|
return None
|