from flask import Flask, send_from_directory, request, render_template, redirect, url_for, flash, jsonify from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.utils import secure_filename from datetime import datetime, date, time import os import uuid app = Flask(__name__) app.config['SECRET_KEY'] = 'your-secret-key-here' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///beamer.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['UPLOAD_FOLDER'] = 'uploads' app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB max file size # Ensure upload directory exists os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) db = SQLAlchemy(app) login_manager = LoginManager() login_manager.init_app(app) login_manager.login_view = 'login' # Database Models 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(120), unique=True, nullable=False) password_hash = db.Column(db.String(255), nullable=False) role = db.Column(db.String(20), default='user') # 'admin' or 'user' def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) class StreamingChannel(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) description = db.Column(db.String(500)) is_default = db.Column(db.Boolean, default=False) is_active = db.Column(db.Boolean, default=True) created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) created_date = db.Column(db.DateTime, default=datetime.utcnow) # Relationships creator = db.relationship('User', backref='created_channels') content = db.relationship('ChannelContent', back_populates='channel', cascade='all, delete-orphan') players = db.relationship('Player', back_populates='channel') class ChannelContent(db.Model): id = db.Column(db.Integer, primary_key=True) channel_id = db.Column(db.Integer, db.ForeignKey('streaming_channel.id'), nullable=False) media_file_id = db.Column(db.Integer, db.ForeignKey('media_file.id'), nullable=False) order_index = db.Column(db.Integer, default=0) display_duration = db.Column(db.Integer, default=10) is_active = db.Column(db.Boolean, default=True) added_date = db.Column(db.DateTime, default=datetime.utcnow) # Relationships channel = db.relationship('StreamingChannel', back_populates='content') media_file = db.relationship('MediaFile', backref='channel_assignments') class Player(db.Model): id = db.Column(db.Integer, primary_key=True) device_id = db.Column(db.String(100), unique=True, nullable=False) name = db.Column(db.String(100)) last_seen = db.Column(db.DateTime) is_active = db.Column(db.Boolean, default=True) channel_id = db.Column(db.Integer, db.ForeignKey('streaming_channel.id')) # Relationships channel = db.relationship('StreamingChannel', back_populates='players') class MediaFile(db.Model): id = db.Column(db.Integer, primary_key=True) filename = db.Column(db.String(255), nullable=False) original_name = db.Column(db.String(255), nullable=False) file_type = db.Column(db.String(10), nullable=False) file_size = db.Column(db.Integer) upload_date = db.Column(db.DateTime, default=datetime.utcnow) uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) is_active = db.Column(db.Boolean, default=True) # Relationships uploader = db.relationship('User', backref='uploaded_files') class ActivityLog(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) action = db.Column(db.String(100), nullable=False) details = db.Column(db.String(500)) timestamp = db.Column(db.DateTime, default=datetime.utcnow) ip_address = db.Column(db.String(45)) # Relationships user = db.relationship('User', backref='activity_logs') @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) def log_activity(action, details=None): """Helper function to log user activity""" if current_user.is_authenticated: activity = ActivityLog( user_id=current_user.id, action=action, details=details, ip_address=request.remote_addr ) db.session.add(activity) db.session.commit() def allowed_file(filename): ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'mp4', 'avi', 'mov', 'pdf', 'json'} return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS # Authentication Routes @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': username = request.form['username'] password = request.form['password'] user = User.query.filter_by(username=username).first() if user and user.check_password(password): login_user(user) log_activity('User logged in') flash('Logged in successfully!', 'success') return redirect(url_for('admin' if user.role == 'admin' else 'user_dashboard')) else: flash('Invalid username or password.', 'error') return render_template('login.html') @app.route('/logout') @login_required def logout(): log_activity('User logged out') logout_user() flash('You have been logged out.', 'info') return redirect(url_for('login')) # Dashboard Routes @app.route('/') @login_required def index(): if current_user.role == 'admin': return redirect(url_for('admin')) else: return redirect(url_for('user_dashboard')) @app.route('/admin') @login_required def admin(): if current_user.role != 'admin': flash('Access denied. Admin privileges required.', 'error') return redirect(url_for('user_dashboard')) # Get statistics total_files = MediaFile.query.filter_by(is_active=True).count() total_users = User.query.count() total_players = Player.query.count() total_channels = StreamingChannel.query.filter_by(is_active=True).count() # Get recent activity recent_activity = ActivityLog.query.order_by(ActivityLog.timestamp.desc()).limit(10).all() # Get recent files recent_files = MediaFile.query.filter_by(is_active=True).order_by(MediaFile.upload_date.desc()).limit(5).all() # Get all files for management all_files = MediaFile.query.filter_by(is_active=True).order_by(MediaFile.upload_date.desc()).all() # Get all users all_users = User.query.order_by(User.username).all() # Get all players all_players = Player.query.order_by(Player.device_id).all() # Get all channels for player assignment all_channels = StreamingChannel.query.filter_by(is_active=True).order_by(StreamingChannel.name).all() return render_template('admin.html', total_files=total_files, total_users=total_users, total_players=total_players, total_channels=total_channels, recent_activity=recent_activity, recent_files=recent_files, all_files=all_files, all_users=all_users, all_players=all_players, all_channels=all_channels) @app.route('/user') @login_required def user_dashboard(): # Get all active files (for signage systems, users should see all available media) user_files = MediaFile.query.filter_by(is_active=True).order_by(MediaFile.upload_date.desc()).all() # Get all active channels channels = StreamingChannel.query.filter_by(is_active=True).order_by(StreamingChannel.name).all() # Get all users for display in file info users = User.query.all() # Get user's activity user_activity = ActivityLog.query.filter_by(user_id=current_user.id).order_by(ActivityLog.timestamp.desc()).limit(10).all() return render_template('user.html', user_files=user_files, user_activity=user_activity, channels=channels, users=users) # File Management Routes @app.route('/upload', methods=['POST']) @login_required def upload_file(): if 'file' not in request.files: flash('No file selected.', 'error') return redirect(request.referrer) file = request.files['file'] if file.filename == '': flash('No file selected.', 'error') return redirect(request.referrer) if file and allowed_file(file.filename): # Generate unique filename file_ext = file.filename.rsplit('.', 1)[1].lower() unique_filename = f"{uuid.uuid4().hex}.{file_ext}" # Save file file_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename) file.save(file_path) # Get file size file_size = os.path.getsize(file_path) # Save to database media_file = MediaFile( filename=unique_filename, original_name=file.filename, file_type=file_ext, file_size=file_size, uploaded_by=current_user.id ) db.session.add(media_file) db.session.commit() # If channel is selected, add to channel channel_id = request.form.get('channel_id') display_duration = request.form.get('display_duration', 10) if channel_id and channel_id.strip(): try: channel_id = int(channel_id) channel = StreamingChannel.query.get(channel_id) if channel and channel.is_active: # Get next order number for this channel max_order = db.session.query(db.func.max(ChannelContent.order_index)).filter_by(channel_id=channel_id).scalar() or 0 # Add to channel channel_content = ChannelContent( channel_id=channel_id, media_file_id=media_file.id, display_duration=int(display_duration), order_index=max_order + 1 ) db.session.add(channel_content) db.session.commit() log_activity('Content added to channel', f'Added {file.filename} to channel {channel.name}') flash(f'File "{file.filename}" uploaded and added to channel "{channel.name}"!', 'success') else: flash(f'File "{file.filename}" uploaded but channel not found!', 'warning') except (ValueError, TypeError): flash(f'File "{file.filename}" uploaded but invalid channel selected!', 'warning') else: flash(f'File "{file.filename}" uploaded successfully!', 'success') log_activity('File uploaded', f'Uploaded: {file.filename}') else: flash('Invalid file type. Please upload images, videos, PDFs, or JSON files.', 'error') return redirect(request.referrer) @app.route('/uploads/') def uploaded_file(filename): return send_from_directory(app.config['UPLOAD_FOLDER'], filename) @app.route('/delete/') @login_required def delete_file(file_id): media_file = MediaFile.query.get_or_404(file_id) # Check permissions if current_user.role != 'admin' and media_file.uploaded_by != current_user.id: flash('You can only delete your own files.', 'error') return redirect(request.referrer) # Mark as inactive instead of deleting media_file.is_active = False db.session.commit() log_activity('File deleted', f'Deleted: {media_file.original_name}') flash(f'File "{media_file.original_name}" deleted successfully!', 'success') return redirect(request.referrer) # Schedule Management Routes @app.route('/schedules') @login_required def view_schedules(): # Since we're moving to channels, redirect to channels page return redirect(url_for('view_channels')) # Channel Management Routes @app.route('/channels') @login_required def view_channels(): if current_user.role == 'admin': channels = StreamingChannel.query.filter_by(is_active=True).order_by(StreamingChannel.created_date.desc()).all() else: channels = StreamingChannel.query.filter_by(created_by=current_user.id, is_active=True).order_by(StreamingChannel.created_date.desc()).all() return render_template('channels.html', channels=channels) @app.route('/add_channel', methods=['POST']) @login_required def add_channel(): try: # If setting as default, unset other defaults is_default = 'is_default' in request.form if is_default: StreamingChannel.query.filter_by(is_default=True).update({'is_default': False}) channel = StreamingChannel( name=request.form['name'], description=request.form.get('description', ''), is_default=is_default, created_by=current_user.id ) db.session.add(channel) db.session.commit() log_activity('Channel created', f'Created channel: {channel.name}') flash(f'Channel "{channel.name}" created successfully!', 'success') except Exception as e: flash(f'Error creating channel: {str(e)}', 'error') return redirect(url_for('view_channels')) @app.route('/delete_channel/') @login_required def delete_channel(channel_id): channel = StreamingChannel.query.get_or_404(channel_id) # Check permissions if current_user.role != 'admin' and channel.created_by != current_user.id: flash('Access denied. You can only delete your own channels.', 'error') return redirect(url_for('view_channels')) # Prevent deleting default channel if channel.is_default: flash('Cannot delete the default channel.', 'error') return redirect(url_for('view_channels')) # Check if any players are assigned to this channel assigned_players = Player.query.filter_by(channel_id=channel_id).count() if assigned_players > 0: flash(f'Cannot delete channel. {assigned_players} player(s) are assigned to this channel.', 'error') return redirect(url_for('view_channels')) channel_name = channel.name db.session.delete(channel) db.session.commit() log_activity('Channel deleted', f'Deleted channel: {channel_name}') flash(f'Channel "{channel_name}" deleted successfully!', 'success') return redirect(url_for('view_channels')) @app.route('/edit_channel/', methods=['GET', 'POST']) @login_required def edit_channel(channel_id): channel = StreamingChannel.query.get_or_404(channel_id) # Check permissions if current_user.role != 'admin' and channel.created_by != current_user.id: flash('Access denied. You can only edit your own channels.', 'error') return redirect(url_for('view_channels')) if request.method == 'POST': try: # If setting as default, unset other defaults is_default = 'is_default' in request.form if is_default and not channel.is_default: StreamingChannel.query.filter_by(is_default=True).update({'is_default': False}) channel.name = request.form['name'] channel.description = request.form.get('description', '') channel.is_default = is_default channel.is_active = 'is_active' in request.form db.session.commit() log_activity('Channel updated', f'Updated channel: {channel.name}') flash(f'Channel "{channel.name}" updated successfully!', 'success') except Exception as e: flash(f'Error updating channel: {str(e)}', 'error') return redirect(url_for('view_channels')) return render_template('edit_channel.html', channel=channel) @app.route('/manage_content/') @login_required def manage_content(channel_id): channel = StreamingChannel.query.get_or_404(channel_id) # Check permissions if current_user.role != 'admin' and channel.created_by != current_user.id: flash('Access denied. You can only manage content of your own channels.', 'error') return redirect(url_for('view_channels')) # Get all available media files available_media = MediaFile.query.filter_by(is_active=True).order_by(MediaFile.upload_date.desc()).all() # Get current channel content with media file details channel_content = ChannelContent.query.filter_by(channel_id=channel_id).order_by(ChannelContent.order_index).all() return render_template('manage_content.html', channel=channel, available_media=available_media, channel_content=channel_content) @app.route('/add_content_to_channel', methods=['POST']) @login_required def add_content_to_channel(): channel_id = request.form['channel_id'] media_file_id = request.form['media_file_id'] duration = request.form.get('duration', 10) channel = StreamingChannel.query.get_or_404(channel_id) # Check permissions if current_user.role != 'admin' and channel.created_by != current_user.id: flash('Access denied.', 'error') return redirect(url_for('view_channels')) # Get next order number max_order = db.session.query(db.func.max(ChannelContent.order_index)).filter_by(channel_id=channel_id).scalar() or 0 # Add content content = ChannelContent( channel_id=channel_id, media_file_id=media_file_id, display_duration=int(duration), order_index=max_order + 1 ) db.session.add(content) db.session.commit() media_file = MediaFile.query.get(media_file_id) log_activity('Content added to channel', f'Added {media_file.original_name} to {channel.name}') flash('Content added to channel successfully!', 'success') return redirect(url_for('manage_content', channel_id=channel_id)) @app.route('/remove_content_from_channel/') @login_required def remove_content_from_channel(content_id): content = ChannelContent.query.get_or_404(content_id) channel = content.channel # Check permissions if current_user.role != 'admin' and channel.created_by != current_user.id: flash('Access denied.', 'error') return redirect(url_for('view_channels')) db.session.delete(content) db.session.commit() log_activity('Content removed from channel', f'Removed content from {channel.name}') flash('Content removed from channel successfully!', 'success') return redirect(url_for('manage_content', channel_id=channel.id)) # User Management Routes @app.route('/add_user', methods=['POST']) @login_required def add_user(): if current_user.role != 'admin': flash('Access denied. Admin privileges required.', 'error') return redirect(request.referrer) try: # Check if username already exists existing_user = User.query.filter_by(username=request.form['username']).first() if existing_user: flash('Username already exists.', 'error') return redirect(request.referrer) # Check if email already exists existing_email = User.query.filter_by(email=request.form['email']).first() if existing_email: flash('Email already exists.', 'error') return redirect(request.referrer) user = User( username=request.form['username'], email=request.form['email'], role=request.form.get('role', 'user') ) user.set_password(request.form['password']) db.session.add(user) db.session.commit() log_activity('User created', f'Created user: {user.username} ({user.role})') flash(f'User "{user.username}" created successfully!', 'success') except Exception as e: flash(f'Error creating user: {str(e)}', 'error') return redirect(url_for('admin')) @app.route('/delete_user/') @login_required def delete_user(user_id): if current_user.role != 'admin': flash('Access denied. Admin privileges required.', 'error') return redirect(request.referrer) user = User.query.get_or_404(user_id) # Prevent deleting yourself if user.id == current_user.id: flash('You cannot delete your own account.', 'error') return redirect(request.referrer) # Prevent deleting the only admin if user.role == 'admin' and User.query.filter_by(role='admin').count() <= 1: flash('Cannot delete the only admin user.', 'error') return redirect(request.referrer) username = user.username db.session.delete(user) db.session.commit() log_activity('User deleted', f'Deleted user: {username}') flash(f'User "{username}" deleted successfully!', 'success') return redirect(url_for('admin')) # Player Management Routes @app.route('/add_player', methods=['POST']) @login_required def add_player(): try: # Check if device_id already exists existing_player = Player.query.filter_by(device_id=request.form['device_id']).first() if existing_player: flash('Device ID already exists.', 'error') return redirect(request.referrer) player = Player( device_id=request.form['device_id'], name=request.form.get('name', ''), channel_id=int(request.form['channel_id']) if request.form.get('channel_id') else None ) db.session.add(player) db.session.commit() log_activity('Player added', f'Added player: {player.device_id}') flash(f'Player "{player.device_id}" added successfully!', 'success') except Exception as e: flash(f'Error adding player: {str(e)}', 'error') return redirect(url_for('admin')) @app.route('/delete_player/') @login_required def delete_player(player_id): if current_user.role != 'admin': flash('Access denied. Admin privileges required.', 'error') return redirect(request.referrer) player = Player.query.get_or_404(player_id) device_id = player.device_id db.session.delete(player) db.session.commit() log_activity('Player deleted', f'Deleted player: {device_id}') flash(f'Player "{device_id}" deleted successfully!', 'success') return redirect(url_for('admin')) @app.route('/assign_player_channel/', methods=['POST']) @login_required def assign_player_channel(player_id): try: player = Player.query.get_or_404(player_id) channel_id = int(request.form['channel_id']) if request.form.get('channel_id') else None old_channel = player.channel.name if player.channel else 'None' player.channel_id = channel_id new_channel = StreamingChannel.query.get(channel_id).name if channel_id else 'None' db.session.commit() log_activity('Player channel assigned', f'Player {player.device_id}: {old_channel} → {new_channel}') flash(f'Player "{player.device_id}" assigned to channel successfully!', 'success') except Exception as e: flash(f'Error assigning player: {str(e)}', 'error') return redirect(request.referrer) # API Routes @app.route('/api/content') def api_content(): """API endpoint for Info-Beamer devices to get content list""" active_files = MediaFile.query.filter_by(is_active=True).all() content = [] for file in active_files: content.append({ 'filename': file.filename, 'original_name': file.original_name, 'type': file.file_type, 'url': f"{request.host_url}uploads/{file.filename}", 'upload_date': file.upload_date.isoformat() }) return jsonify({ 'content': content, 'updated': datetime.utcnow().isoformat() }) @app.route('/api/playlist') def api_playlist(): """Legacy API endpoint for Info-Beamer devices""" # Return default channel content or all files default_channel = StreamingChannel.query.filter_by(is_default=True).first() if default_channel: content_items = ChannelContent.query.filter_by( channel_id=default_channel.id, is_active=True ).join(MediaFile).order_by(ChannelContent.order_index).all() playlist = { 'duration': 10, 'items': [] } for content in content_items: media_file = content.media_file if media_file and media_file.is_active: if media_file.file_type in ['jpg', 'jpeg', 'png', 'gif']: playlist['items'].append({ 'type': 'image', 'asset': media_file.filename, 'duration': content.display_duration }) elif media_file.file_type in ['mp4', 'avi', 'mov']: playlist['items'].append({ 'type': 'video', 'asset': media_file.filename }) return jsonify(playlist) # If no channels exist, return all media files active_files = MediaFile.query.filter_by(is_active=True).all() playlist = { 'duration': 10, 'items': [] } for file in active_files: if file.file_type in ['jpg', 'jpeg', 'png', 'gif']: playlist['items'].append({ 'type': 'image', 'asset': file.filename, 'duration': 10 }) elif file.file_type in ['mp4', 'avi', 'mov']: playlist['items'].append({ 'type': 'video', 'asset': file.filename }) return jsonify(playlist) def init_db(): """Initialize database and create default data""" with app.app_context(): db.create_all() # Create default admin user if no users exist if User.query.count() == 0: admin = User( username='admin', email='admin@localhost', role='admin' ) admin.set_password('admin') # Change this! db.session.add(admin) db.session.commit() print("Created default admin user: admin/admin (CHANGE PASSWORD!)") # Create default streaming channel if none exists if not StreamingChannel.query.first(): default_channel = StreamingChannel( name='Default Channel', description='Main content channel', is_default=True, is_active=True, created_by=1 # Admin user ) db.session.add(default_channel) db.session.commit() print("Created default streaming channel") # API Endpoints for Info-Beamer Integration @app.route('/api/player//content') def api_player_content(device_id): """Get content for a specific player""" player = Player.query.filter_by(device_id=device_id).first() if not player: return jsonify({'error': 'Player not found'}), 404 if not player.channel_id: return jsonify({'content': []}) # Get content from the player's assigned channel channel_content = ChannelContent.query.filter_by(channel_id=player.channel_id).order_by(ChannelContent.order).all() content_list = [] for content in channel_content: media_file = MediaFile.query.get(content.media_file_id) if media_file: content_list.append({ 'filename': media_file.filename, 'type': 'image' if media_file.file_type.startswith('image/') else 'video', 'duration': content.duration or 10, 'order': content.order }) return jsonify({'content': content_list}) @app.route('/api/player//channel') def api_player_channel(device_id): """Get channel information for a specific player""" player = Player.query.filter_by(device_id=device_id).first() if not player: return jsonify({'error': 'Player not found'}), 404 if not player.channel_id: return jsonify({'name': 'No Channel Assigned', 'description': 'No channel assigned to this player'}) channel = StreamingChannel.query.get(player.channel_id) if not channel: return jsonify({'error': 'Channel not found'}), 404 return jsonify({ 'name': channel.name, 'description': channel.description, 'is_active': channel.is_active }) @app.route('/api/player//heartbeat', methods=['POST']) def api_player_heartbeat(device_id): """Update player last seen timestamp""" player = Player.query.filter_by(device_id=device_id).first() if not player: return jsonify({'error': 'Player not found'}), 404 player.last_seen = datetime.utcnow() db.session.commit() return jsonify({'status': 'ok'}) if __name__ == '__main__': init_db() app.run(host='0.0.0.0', port=80, debug=True)