Final: Complete modernization - Option 1 deployment, unified persistence, migration scripts
- Implement Docker image-based deployment (Option 1) * Code immutable in image, no volume override * Eliminated init-data.sh manual step * Simplified deployment process - Unified persistence in data/ folder * Moved nginx.conf and nginx-custom-domains.conf to data/ * All runtime configs and data in single location * Clear separation: repo (source) vs data/ (runtime) - Archive legacy features * Groups blueprint and templates removed * Legacy playlist routes redirected to content area * Organized in old_code_documentation/ - Added network migration support * New migrate_network.sh script for IP changes * Regenerates SSL certs for new IP * Updates database configuration * Tested workflow: clone → deploy → migrate - Enhanced deploy.sh * Creates data directories * Copies nginx configs from repo to data/ * Validates file existence before deployment * Prevents incomplete deployments - Updated documentation * QUICK_DEPLOYMENT.md shows 4-step workflow * Complete deployment workflow documented * Migration procedures included - Production ready deployment workflow: 1. Clone & setup (.env configuration) 2. Deploy (./deploy.sh) 3. Migrate network (./migrate_network.sh if needed) 4. Normal operations (docker compose restart)
This commit is contained in:
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models import User, Player, Group, Content, ServerLog, Playlist, HTTPSConfig
|
||||
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.caddy_manager import CaddyConfigGenerator
|
||||
from app.utils.nginx_config_reader import get_nginx_status
|
||||
|
||||
@@ -7,7 +7,7 @@ import bcrypt
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Group, Content, PlayerFeedback, ServerLog
|
||||
from app.models import Player, Content, PlayerFeedback, ServerLog
|
||||
from app.utils.logger import log_action
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
@@ -599,31 +599,33 @@ def system_info():
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/groups', methods=['GET'])
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def list_groups():
|
||||
"""List all groups with basic information."""
|
||||
try:
|
||||
groups = Group.query.order_by(Group.name).all()
|
||||
|
||||
groups_data = []
|
||||
for group in groups:
|
||||
groups_data.append({
|
||||
'id': group.id,
|
||||
'name': group.name,
|
||||
'description': group.description,
|
||||
'player_count': group.players.count(),
|
||||
'content_count': group.contents.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'groups': groups_data,
|
||||
'count': len(groups_data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error listing groups: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
# DEPRECATED: Groups functionality has been archived
|
||||
# @api_bp.route('/groups', methods=['GET'])
|
||||
# @rate_limit(max_requests=60, window=60)
|
||||
# def list_groups():
|
||||
# """List all groups with basic information."""
|
||||
# try:
|
||||
# groups = Group.query.order_by(Group.name).all()
|
||||
#
|
||||
# groups_data = []
|
||||
# for group in groups:
|
||||
# groups_data.append({
|
||||
# 'id': group.id,
|
||||
# 'name': group.name,
|
||||
# 'description': group.description,
|
||||
# 'player_count': group.players.count(),
|
||||
# 'content_count': group.contents.count()
|
||||
# })
|
||||
#
|
||||
# return jsonify({
|
||||
# 'groups': groups_data,
|
||||
# 'count': len(groups_data)
|
||||
# })
|
||||
#
|
||||
# except Exception as e:
|
||||
# log_action('error', f'Error listing groups: {str(e)}')
|
||||
# return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/content', methods=['GET'])
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
"""Groups blueprint for group management and player assignments."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required
|
||||
from typing import List, Dict
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Group, Player, Content
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.group_player_management import get_player_status_info, get_group_statistics
|
||||
|
||||
groups_bp = Blueprint('groups', __name__, url_prefix='/groups')
|
||||
|
||||
|
||||
@groups_bp.route('/')
|
||||
@login_required
|
||||
def groups_list():
|
||||
"""Display list of all groups."""
|
||||
try:
|
||||
groups = Group.query.order_by(Group.name).all()
|
||||
|
||||
# Get statistics for each group
|
||||
group_stats = {}
|
||||
for group in groups:
|
||||
stats = get_group_statistics(group.id)
|
||||
group_stats[group.id] = stats
|
||||
|
||||
return render_template('groups/groups_list.html',
|
||||
groups=groups,
|
||||
group_stats=group_stats)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading groups list: {str(e)}')
|
||||
flash('Error loading groups list.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@groups_bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_group():
|
||||
"""Create a new group."""
|
||||
if request.method == 'GET':
|
||||
available_content = Content.query.order_by(Content.filename).all()
|
||||
return render_template('groups/create_group.html', available_content=available_content)
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
content_ids = request.form.getlist('content_ids')
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
flash('Group name must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('groups.create_group'))
|
||||
|
||||
# Check if group name exists
|
||||
existing_group = Group.query.filter_by(name=name).first()
|
||||
if existing_group:
|
||||
flash(f'Group "{name}" already exists.', 'warning')
|
||||
return redirect(url_for('groups.create_group'))
|
||||
|
||||
# Create group
|
||||
new_group = Group(
|
||||
name=name,
|
||||
description=description or None
|
||||
)
|
||||
|
||||
# Add content to group
|
||||
if content_ids:
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(int(content_id))
|
||||
if content:
|
||||
new_group.contents.append(content)
|
||||
|
||||
db.session.add(new_group)
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Group "{name}" created with {len(content_ids)} content items')
|
||||
flash(f'Group "{name}" created successfully.', 'success')
|
||||
|
||||
return redirect(url_for('groups.groups_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error creating group: {str(e)}')
|
||||
flash('Error creating group. Please try again.', 'danger')
|
||||
return redirect(url_for('groups.create_group'))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_group(group_id: int):
|
||||
"""Edit group details."""
|
||||
group = Group.query.get_or_404(group_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
available_content = Content.query.order_by(Content.filename).all()
|
||||
return render_template('groups/edit_group.html',
|
||||
group=group,
|
||||
available_content=available_content)
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
content_ids = request.form.getlist('content_ids')
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
flash('Group name must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('groups.edit_group', group_id=group_id))
|
||||
|
||||
# Check if group name exists (excluding current group)
|
||||
existing_group = Group.query.filter(Group.name == name, Group.id != group_id).first()
|
||||
if existing_group:
|
||||
flash(f'Group name "{name}" is already in use.', 'warning')
|
||||
return redirect(url_for('groups.edit_group', group_id=group_id))
|
||||
|
||||
# Update group
|
||||
group.name = name
|
||||
group.description = description or None
|
||||
|
||||
# Update content
|
||||
group.contents = []
|
||||
if content_ids:
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(int(content_id))
|
||||
if content:
|
||||
group.contents.append(content)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for all players in this group
|
||||
for player in group.players:
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
log_action('info', f'Group "{name}" (ID: {group_id}) updated')
|
||||
flash(f'Group "{name}" updated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('groups.groups_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating group: {str(e)}')
|
||||
flash('Error updating group. Please try again.', 'danger')
|
||||
return redirect(url_for('groups.edit_group', group_id=group_id))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_group(group_id: int):
|
||||
"""Delete a group."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
group_name = group.name
|
||||
|
||||
# Unassign players from group
|
||||
for player in group.players:
|
||||
player.group_id = None
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
db.session.delete(group)
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Group "{group_name}" (ID: {group_id}) deleted')
|
||||
flash(f'Group "{group_name}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting group: {str(e)}')
|
||||
flash('Error deleting group. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('groups.groups_list'))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/manage')
|
||||
@login_required
|
||||
def manage_group(group_id: int):
|
||||
"""Manage group with player status cards and content."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
|
||||
# Get all players in this group
|
||||
players = group.players.order_by(Player.name).all()
|
||||
|
||||
# Get player status for each player
|
||||
player_statuses = {}
|
||||
for player in players:
|
||||
status_info = get_player_status_info(player.id)
|
||||
player_statuses[player.id] = status_info
|
||||
|
||||
# Get group content
|
||||
contents = group.contents.order_by(Content.position).all()
|
||||
|
||||
# Get available players (not in this group)
|
||||
available_players = Player.query.filter(
|
||||
(Player.group_id == None) | (Player.group_id != group_id)
|
||||
).order_by(Player.name).all()
|
||||
|
||||
# Get available content (not in this group)
|
||||
all_content = Content.query.order_by(Content.filename).all()
|
||||
|
||||
return render_template('groups/manage_group.html',
|
||||
group=group,
|
||||
players=players,
|
||||
player_statuses=player_statuses,
|
||||
contents=contents,
|
||||
available_players=available_players,
|
||||
all_content=all_content)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading manage group page: {str(e)}')
|
||||
flash('Error loading manage group page.', 'danger')
|
||||
return redirect(url_for('groups.groups_list'))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/fullscreen')
|
||||
def group_fullscreen(group_id: int):
|
||||
"""Display group fullscreen view with all player status cards."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
|
||||
# Get all players in this group
|
||||
players = group.players.order_by(Player.name).all()
|
||||
|
||||
# Get player status for each player
|
||||
player_statuses = {}
|
||||
for player in players:
|
||||
status_info = get_player_status_info(player.id)
|
||||
player_statuses[player.id] = status_info
|
||||
|
||||
return render_template('groups/group_fullscreen.html',
|
||||
group=group,
|
||||
players=players,
|
||||
player_statuses=player_statuses)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading group fullscreen: {str(e)}')
|
||||
return "Error loading group fullscreen", 500
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/add-player', methods=['POST'])
|
||||
@login_required
|
||||
def add_player_to_group(group_id: int):
|
||||
"""Add a player to a group."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
player_id = request.form.get('player_id')
|
||||
|
||||
if not player_id:
|
||||
flash('No player selected.', 'warning')
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
player = Player.query.get_or_404(int(player_id))
|
||||
player.group_id = group_id
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
log_action('info', f'Player "{player.name}" added to group "{group.name}"')
|
||||
flash(f'Player "{player.name}" added to group successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding player to group: {str(e)}')
|
||||
flash('Error adding player to group. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/remove-player/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_player_from_group(group_id: int, player_id: int):
|
||||
"""Remove a player from a group."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if player.group_id != group_id:
|
||||
flash('Player is not in this group.', 'warning')
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
player_name = player.name
|
||||
player.group_id = None
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache
|
||||
cache.delete_memoized('get_player_playlist', player_id)
|
||||
|
||||
log_action('info', f'Player "{player_name}" removed from group {group_id}')
|
||||
flash(f'Player "{player_name}" removed from group successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error removing player from group: {str(e)}')
|
||||
flash('Error removing player from group. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/add-content', methods=['POST'])
|
||||
@login_required
|
||||
def add_content_to_group(group_id: int):
|
||||
"""Add content to a group."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content_ids = request.form.getlist('content_ids')
|
||||
|
||||
if not content_ids:
|
||||
flash('No content selected.', 'warning')
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
# Add content
|
||||
added_count = 0
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(int(content_id))
|
||||
if content and content not in group.contents:
|
||||
group.contents.append(content)
|
||||
added_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for all players in this group
|
||||
for player in group.players:
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
log_action('info', f'{added_count} content items added to group "{group.name}"')
|
||||
flash(f'{added_count} content items added successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding content to group: {str(e)}')
|
||||
flash('Error adding content to group. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/remove-content/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_content_from_group(group_id: int, content_id: int):
|
||||
"""Remove content from a group."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if content not in group.contents:
|
||||
flash('Content is not in this group.', 'warning')
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
group.contents.remove(content)
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for all players in this group
|
||||
for player in group.players:
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
log_action('info', f'Content "{content.filename}" removed from group "{group.name}"')
|
||||
flash('Content removed from group successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error removing content from group: {str(e)}')
|
||||
flash('Error removing content from group. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/reorder-content', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_group_content(group_id: int):
|
||||
"""Reorder content within a group."""
|
||||
try:
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content_order = request.json.get('order', [])
|
||||
|
||||
# Update positions
|
||||
for idx, content_id in enumerate(content_order):
|
||||
content = Content.query.get(content_id)
|
||||
if content and content in group.contents:
|
||||
content.position = idx
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for all players in this group
|
||||
for player in group.players:
|
||||
cache.delete_memoized('get_player_playlist', player.id)
|
||||
|
||||
log_action('info', f'Content reordered for group "{group.name}"')
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error reordering group content: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@groups_bp.route('/<int:group_id>/stats')
|
||||
@login_required
|
||||
def group_stats(group_id: int):
|
||||
"""Get group statistics as JSON."""
|
||||
try:
|
||||
stats = get_group_statistics(group_id)
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting group stats: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -16,25 +16,16 @@ playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
|
||||
@playlist_bp.route('/<int:player_id>')
|
||||
@login_required
|
||||
def manage_playlist(player_id: int):
|
||||
"""Manage playlist for a specific player."""
|
||||
"""Legacy route - redirect to new content management area."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Get content from player's assigned playlist
|
||||
playlist_items = []
|
||||
if player.playlist_id:
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
if playlist:
|
||||
playlist_items = playlist.get_content_ordered()
|
||||
|
||||
# Get available content (all content not in current playlist)
|
||||
all_content = Content.query.all()
|
||||
playlist_content_ids = {item.id for item in playlist_items}
|
||||
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
||||
|
||||
return render_template('playlist/manage_playlist.html',
|
||||
player=player,
|
||||
playlist_content=playlist_items,
|
||||
available_content=available_content)
|
||||
# Redirect to the new content management interface
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=player.playlist_id))
|
||||
else:
|
||||
# Player has no playlist assigned
|
||||
flash('This player has no playlist assigned.', 'warning')
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
|
||||
|
||||
Reference in New Issue
Block a user