Files
docupulse/routes/admin.py
2025-06-26 15:15:16 +02:00

834 lines
33 KiB
Python

from flask import Blueprint, jsonify, request, render_template, flash, redirect, url_for, current_app
from flask_login import login_required, current_user
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle, Customer
from extensions import csrf
from utils.event_logger import log_event
import os
from datetime import datetime
import json
from routes.auth import require_password_change
admin = Blueprint('admin', __name__)
@admin.route('/api/admin/sync-files', methods=['POST'])
@login_required
def sync_files():
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
DATA_ROOT = '/app/uploads/rooms'
admin_user = User.query.filter_by(is_admin=True).first()
if not admin_user:
return jsonify({'error': 'No admin user found'}), 500
rooms = Room.query.all()
for room in rooms:
room_dir = os.path.join(DATA_ROOT, str(room.id))
if not os.path.exists(room_dir):
continue
for root, dirs, files in os.walk(room_dir):
rel_root = os.path.relpath(root, room_dir)
rel_path = '' if rel_root == '.' else rel_root.replace('\\', '/')
# Folders
for d in dirs:
exists = RoomFile.query.filter_by(room_id=room.id, name=d, path=rel_path, type='folder').first()
folder_path = os.path.join(root, d)
stat = os.stat(folder_path)
if not exists:
rf = RoomFile(
room_id=room.id,
name=d,
path=rel_path,
type='folder',
size=None,
modified=stat.st_mtime,
uploaded_by=admin_user.id,
uploaded_at=datetime.utcfromtimestamp(stat.st_mtime)
)
db.session.add(rf)
# Files
for f in files:
exists = RoomFile.query.filter_by(room_id=room.id, name=f, path=rel_path, type='file').first()
file_path = os.path.join(root, f)
stat = os.stat(file_path)
if not exists:
rf = RoomFile(
room_id=room.id,
name=f,
path=rel_path,
type='file',
size=stat.st_size,
modified=stat.st_mtime,
uploaded_by=admin_user.id,
uploaded_at=datetime.utcfromtimestamp(stat.st_mtime)
)
db.session.add(rf)
db.session.commit()
return jsonify({'success': True, 'message': 'File system synchronized successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/verify-db-state', methods=['GET'])
@login_required
def verify_db_state():
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
DATA_ROOT = '/app/uploads/rooms'
verification_results = {
'rooms_checked': 0,
'files_in_db_not_fs': [],
'files_in_fs_not_db': [],
'permission_mismatches': [],
'size_mismatches': [],
'modified_time_mismatches': [],
'total_files_checked': 0,
'total_folders_checked': 0
}
rooms = Room.query.all()
for room in rooms:
verification_results['rooms_checked'] += 1
room_dir = os.path.join(DATA_ROOT, str(room.id))
# Get all files and folders from database for this room
db_files = RoomFile.query.filter_by(room_id=room.id, deleted=False).all()
db_paths = {(f.path, f.name): f for f in db_files}
# Check filesystem if directory exists
if os.path.exists(room_dir):
for root, dirs, files in os.walk(room_dir):
rel_root = os.path.relpath(root, room_dir)
rel_path = '' if rel_root == '.' else rel_root.replace('\\', '/')
# Check folders
for d in dirs:
verification_results['total_folders_checked'] += 1
folder_path = os.path.join(root, d)
stat = os.stat(folder_path)
# Check if folder exists in database
db_file = db_paths.get((rel_path, d))
if not db_file:
verification_results['files_in_fs_not_db'].append({
'room_id': room.id,
'room_name': room.name,
'path': rel_path,
'name': d,
'type': 'folder'
})
else:
# Verify folder metadata
if abs(stat.st_mtime - db_file.modified) > 1: # Allow 1 second difference
verification_results['modified_time_mismatches'].append({
'room_id': room.id,
'room_name': room.name,
'path': rel_path,
'name': d,
'type': 'folder',
'fs_modified': stat.st_mtime,
'db_modified': db_file.modified
})
# Remove from db_paths as we've checked it
db_paths.pop((rel_path, d), None)
# Check files
for f in files:
verification_results['total_files_checked'] += 1
file_path = os.path.join(root, f)
stat = os.stat(file_path)
# Check if file exists in database
db_file = db_paths.get((rel_path, f))
if not db_file:
verification_results['files_in_fs_not_db'].append({
'room_id': room.id,
'room_name': room.name,
'path': rel_path,
'name': f,
'type': 'file'
})
else:
# Verify file metadata
if abs(stat.st_mtime - db_file.modified) > 1: # Allow 1 second difference
verification_results['modified_time_mismatches'].append({
'room_id': room.id,
'room_name': room.name,
'path': rel_path,
'name': f,
'type': 'file',
'fs_modified': stat.st_mtime,
'db_modified': db_file.modified
})
if stat.st_size != db_file.size:
verification_results['size_mismatches'].append({
'room_id': room.id,
'room_name': room.name,
'path': rel_path,
'name': f,
'type': 'file',
'fs_size': stat.st_size,
'db_size': db_file.size
})
# Remove from db_paths as we've checked it
db_paths.pop((rel_path, f), None)
# Any remaining items in db_paths are in DB but not in filesystem
for (path, name), db_file in db_paths.items():
verification_results['files_in_db_not_fs'].append({
'room_id': room.id,
'room_name': room.name,
'path': path,
'name': name,
'type': db_file.type
})
# Calculate total issues
total_issues = (
len(verification_results['files_in_db_not_fs']) +
len(verification_results['files_in_fs_not_db']) +
len(verification_results['permission_mismatches']) +
len(verification_results['size_mismatches']) +
len(verification_results['modified_time_mismatches'])
)
# Add summary statistics
verification_results['summary'] = {
'total_issues': total_issues,
'status': 'healthy' if total_issues == 0 else 'issues_found'
}
return jsonify(verification_results)
except Exception as e:
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/cleanup-orphaned-records', methods=['POST'])
@login_required
def cleanup_orphaned_records():
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
DATA_ROOT = '/app/uploads/rooms'
rooms = Room.query.all()
cleaned_records = []
for room in rooms:
room_dir = os.path.join(DATA_ROOT, str(room.id))
# Get all files and folders from database for this room
db_files = RoomFile.query.filter_by(room_id=room.id, deleted=False).all()
for db_file in db_files:
file_path = os.path.join(room_dir, db_file.path, db_file.name) if db_file.path else os.path.join(room_dir, db_file.name)
# If file doesn't exist in filesystem, mark it as deleted in database
if not os.path.exists(file_path):
db_file.deleted = True
cleaned_records.append({
'room_id': room.id,
'room_name': room.name,
'path': db_file.path,
'name': db_file.name,
'type': db_file.type
})
db.session.commit()
return jsonify({
'success': True,
'message': f'Cleaned up {len(cleaned_records)} orphaned records',
'cleaned_records': cleaned_records
})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/usage-stats', methods=['GET'])
@login_required
def get_usage_stats():
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
stats = DocuPulseSettings.get_usage_stats()
return jsonify(stats)
except Exception as e:
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['PUT'])
@login_required
@csrf.exempt
def update_help_article(article_id):
"""Update a help article"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
article = HelpArticle.query.get_or_404(article_id)
title = request.form.get('title')
category = request.form.get('category')
body = request.form.get('body')
order_index = int(request.form.get('order_index', 0))
is_published = request.form.get('is_published') == 'true'
if not title or not category or not body:
return jsonify({'error': 'Title, category, and body are required'}), 400
# Validate category
valid_categories = HelpArticle.get_categories().keys()
if category not in valid_categories:
return jsonify({'error': 'Invalid category'}), 400
article.title = title
article.category = category
article.body = body
article.order_index = order_index
article.is_published = is_published
article.updated_at = datetime.utcnow()
db.session.commit()
# Log the event
log_event(
event_type='help_article_update',
details={
'article_id': article.id,
'title': article.title,
'category': article.category,
'updated_by': f"{current_user.username} {current_user.last_name}"
},
user_id=current_user.id
)
db.session.commit()
return jsonify({'success': True, 'message': 'Article updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['DELETE'])
@login_required
@csrf.exempt
def delete_help_article(article_id):
"""Delete a help article"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
article = HelpArticle.query.get_or_404(article_id)
# Log the event before deletion
log_event(
event_type='help_article_delete',
details={
'article_id': article.id,
'title': article.title,
'category': article.category,
'deleted_by': f"{current_user.username} {current_user.last_name}"
},
user_id=current_user.id
)
db.session.delete(article)
db.session.commit()
return jsonify({'success': True, 'message': 'Article deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
# Help Articles API endpoints
@admin.route('/api/admin/help-articles', methods=['GET'])
@login_required
@csrf.exempt
def get_help_articles():
"""Get all help articles"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
articles = HelpArticle.query.order_by(HelpArticle.category.asc(), HelpArticle.order_index.asc(), HelpArticle.created_at.desc()).all()
articles_data = []
for article in articles:
articles_data.append({
'id': article.id,
'title': article.title,
'category': article.category,
'body': article.body,
'created_at': article.created_at.isoformat() if article.created_at else None,
'updated_at': article.updated_at.isoformat() if article.updated_at else None,
'created_by': article.created_by,
'is_published': article.is_published,
'order_index': article.order_index
})
return jsonify({'articles': articles_data})
@admin.route('/api/admin/help-articles', methods=['POST'])
@login_required
@csrf.exempt
def create_help_article():
"""Create a new help article"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
title = request.form.get('title')
category = request.form.get('category')
body = request.form.get('body')
order_index = int(request.form.get('order_index', 0))
is_published = request.form.get('is_published') == 'true'
if not title or not category or not body:
return jsonify({'error': 'Title, category, and body are required'}), 400
# Validate category
valid_categories = HelpArticle.get_categories().keys()
if category not in valid_categories:
return jsonify({'error': 'Invalid category'}), 400
article = HelpArticle(
title=title,
category=category,
body=body,
order_index=order_index,
is_published=is_published,
created_by=current_user.id
)
db.session.add(article)
db.session.commit()
# Log the event
log_event(
event_type='help_article_create',
details={
'article_id': article.id,
'title': article.title,
'category': article.category,
'created_by': f"{current_user.username} {current_user.last_name}"
},
user_id=current_user.id
)
db.session.commit()
return jsonify({'success': True, 'message': 'Article created successfully', 'article_id': article.id})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['GET'])
@login_required
@csrf.exempt
def get_help_article(article_id):
"""Get a specific help article"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
article = HelpArticle.query.get_or_404(article_id)
article_data = {
'id': article.id,
'title': article.title,
'category': article.category,
'body': article.body,
'created_at': article.created_at.isoformat() if article.created_at else None,
'updated_at': article.updated_at.isoformat() if article.updated_at else None,
'created_by': article.created_by,
'is_published': article.is_published,
'order_index': article.order_index
}
return jsonify({'article': article_data})
@admin.route('/api/admin/pricing-plans', methods=['POST'])
@login_required
def create_pricing_plan():
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403
try:
from models import PricingPlan
from utils.stripe_utils import create_stripe_product
# Get form data
name = request.form.get('name')
description = request.form.get('description')
monthly_price = float(request.form.get('monthly_price'))
annual_price = float(request.form.get('annual_price'))
features = json.loads(request.form.get('features', '[]'))
button_text = request.form.get('button_text', 'Get Started')
is_popular = request.form.get('is_popular') == 'true'
is_custom = request.form.get('is_custom') == 'true'
is_active = request.form.get('is_active') == 'true'
# Get Stripe ID fields
stripe_product_id = request.form.get('stripe_product_id', '')
stripe_monthly_price_id = request.form.get('stripe_monthly_price_id', '')
stripe_annual_price_id = request.form.get('stripe_annual_price_id', '')
# Get quota fields
room_quota = int(request.form.get('room_quota', 0))
conversation_quota = int(request.form.get('conversation_quota', 0))
storage_quota_gb = int(request.form.get('storage_quota_gb', 0))
manager_quota = int(request.form.get('manager_quota', 0))
admin_quota = int(request.form.get('admin_quota', 0))
# Validate required fields
if not name or not features:
return jsonify({'error': 'Name and features are required'}), 400
# Get the highest order index
max_order = db.session.query(db.func.max(PricingPlan.order_index)).scalar() or 0
# Create new pricing plan
plan = PricingPlan(
name=name,
description=description,
monthly_price=monthly_price,
annual_price=annual_price,
features=features,
button_text=button_text,
stripe_product_id=stripe_product_id,
stripe_monthly_price_id=stripe_monthly_price_id,
stripe_annual_price_id=stripe_annual_price_id,
is_popular=is_popular,
is_custom=is_custom,
is_active=is_active,
room_quota=room_quota,
conversation_quota=conversation_quota,
storage_quota_gb=storage_quota_gb,
manager_quota=manager_quota,
admin_quota=admin_quota,
order_index=max_order + 1,
created_by=current_user.id
)
db.session.add(plan)
db.session.commit()
# If no Stripe IDs provided and plan is not custom, try to create Stripe product
if not is_custom and not stripe_product_id:
try:
stripe_data = create_stripe_product(plan)
plan.stripe_product_id = stripe_data['product_id']
plan.stripe_monthly_price_id = stripe_data['monthly_price_id']
plan.stripe_annual_price_id = stripe_data['annual_price_id']
db.session.commit()
except Exception as stripe_error:
# Log the error but don't fail the plan creation
current_app.logger.warning(f"Failed to create Stripe product for plan {plan.name}: {str(stripe_error)}")
return jsonify({'success': True, 'message': 'Pricing plan created successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/pricing-plans/<int:plan_id>', methods=['GET'])
@login_required
def get_pricing_plan(plan_id):
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
from models import PricingPlan
plan = PricingPlan.query.get(plan_id)
if not plan:
return jsonify({'error': 'Pricing plan not found'}), 404
return jsonify({
'success': True,
'plan': {
'id': plan.id,
'name': plan.name,
'description': plan.description,
'monthly_price': plan.monthly_price,
'annual_price': plan.annual_price,
'features': plan.features,
'button_text': plan.button_text,
'button_url': plan.button_url,
'stripe_product_id': plan.stripe_product_id,
'stripe_monthly_price_id': plan.stripe_monthly_price_id,
'stripe_annual_price_id': plan.stripe_annual_price_id,
'is_popular': plan.is_popular,
'is_custom': plan.is_custom,
'is_active': plan.is_active,
'order_index': plan.order_index,
'room_quota': plan.room_quota,
'conversation_quota': plan.conversation_quota,
'storage_quota_gb': plan.storage_quota_gb,
'manager_quota': plan.manager_quota,
'admin_quota': plan.admin_quota
}
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/pricing-plans/<int:plan_id>', methods=['PUT'])
@login_required
def update_pricing_plan(plan_id):
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403
try:
from models import PricingPlan
from utils.stripe_utils import update_stripe_product
plan = PricingPlan.query.get(plan_id)
if not plan:
return jsonify({'error': 'Pricing plan not found'}), 404
# Get form data
name = request.form.get('name')
description = request.form.get('description')
monthly_price = float(request.form.get('monthly_price'))
annual_price = float(request.form.get('annual_price'))
features = json.loads(request.form.get('features', '[]'))
button_text = request.form.get('button_text', 'Get Started')
is_popular = request.form.get('is_popular') == 'true'
is_custom = request.form.get('is_custom') == 'true'
is_active = request.form.get('is_active') == 'true'
# Get Stripe ID fields
stripe_product_id = request.form.get('stripe_product_id', '')
stripe_monthly_price_id = request.form.get('stripe_monthly_price_id', '')
stripe_annual_price_id = request.form.get('stripe_annual_price_id', '')
# Get quota fields
room_quota = int(request.form.get('room_quota', 0))
conversation_quota = int(request.form.get('conversation_quota', 0))
storage_quota_gb = int(request.form.get('storage_quota_gb', 0))
manager_quota = int(request.form.get('manager_quota', 0))
admin_quota = int(request.form.get('admin_quota', 0))
# Validate required fields
if not name or not features:
return jsonify({'error': 'Name and features are required'}), 400
# Update plan
plan.name = name
plan.description = description
plan.monthly_price = monthly_price
plan.annual_price = annual_price
plan.features = features
plan.button_text = button_text
plan.stripe_product_id = stripe_product_id
plan.stripe_monthly_price_id = stripe_monthly_price_id
plan.stripe_annual_price_id = stripe_annual_price_id
plan.is_popular = is_popular
plan.is_custom = is_custom
plan.is_active = is_active
plan.room_quota = room_quota
plan.conversation_quota = conversation_quota
plan.storage_quota_gb = storage_quota_gb
plan.manager_quota = manager_quota
plan.admin_quota = admin_quota
db.session.commit()
# If plan has existing Stripe product and is not custom, try to update it
if not is_custom and plan.stripe_product_id:
try:
stripe_data = update_stripe_product(plan)
plan.stripe_product_id = stripe_data['product_id']
plan.stripe_monthly_price_id = stripe_data['monthly_price_id']
plan.stripe_annual_price_id = stripe_data['annual_price_id']
db.session.commit()
except Exception as stripe_error:
# Log the error but don't fail the plan update
current_app.logger.warning(f"Failed to update Stripe product for plan {plan.name}: {str(stripe_error)}")
return jsonify({'success': True, 'message': 'Pricing plan updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/pricing-plans/<int:plan_id>', methods=['DELETE'])
@login_required
def delete_pricing_plan(plan_id):
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403
try:
from models import PricingPlan
plan = PricingPlan.query.get(plan_id)
if not plan:
return jsonify({'error': 'Pricing plan not found'}), 404
db.session.delete(plan)
db.session.commit()
return jsonify({'success': True, 'message': 'Pricing plan deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/pricing-plans/<int:plan_id>/status', methods=['PATCH'])
@login_required
def update_pricing_plan_status(plan_id):
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403
try:
from models import PricingPlan
plan = PricingPlan.query.get(plan_id)
if not plan:
return jsonify({'error': 'Pricing plan not found'}), 404
data = request.get_json()
field = data.get('field')
value = data.get('value')
if field not in ['is_active', 'is_popular', 'is_custom']:
return jsonify({'error': 'Invalid field'}), 400
setattr(plan, field, value)
db.session.commit()
return jsonify({'success': True, 'message': 'Plan status updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/pricing-plans', methods=['GET'])
@login_required
def get_pricing_plans():
"""Get all active pricing plans for instance launch"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
from models import PricingPlan
# Get all active pricing plans ordered by order_index
plans = PricingPlan.query.filter_by(is_active=True).order_by(PricingPlan.order_index).all()
plans_data = []
for plan in plans:
plans_data.append({
'id': plan.id,
'name': plan.name,
'description': plan.description,
'monthly_price': plan.monthly_price,
'annual_price': plan.annual_price,
'features': plan.features,
'button_text': plan.button_text,
'button_url': plan.button_url,
'is_popular': plan.is_popular,
'is_custom': plan.is_custom,
'is_active': plan.is_active,
'order_index': plan.order_index,
'room_quota': plan.room_quota,
'conversation_quota': plan.conversation_quota,
'storage_quota_gb': plan.storage_quota_gb,
'manager_quota': plan.manager_quota,
'admin_quota': plan.admin_quota,
'format_quota_display': {
'room_quota': plan.format_quota_display('room_quota'),
'conversation_quota': plan.format_quota_display('conversation_quota'),
'storage_quota_gb': plan.format_quota_display('storage_quota_gb'),
'manager_quota': plan.format_quota_display('manager_quota'),
'admin_quota': plan.format_quota_display('admin_quota')
}
})
return jsonify({
'success': True,
'plans': plans_data
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@admin.route('/customers')
@login_required
@require_password_change
def customers():
"""View all customers"""
if not current_user.is_admin:
flash('Access denied. Admin privileges required.', 'error')
return redirect(url_for('main.dashboard'))
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
flash('Access denied. Master admin privileges required.', 'error')
return redirect(url_for('main.dashboard'))
# Get all customers with pagination
page = request.args.get('page', 1, type=int)
per_page = 20
customers = Customer.query.order_by(Customer.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template('admin/customers.html', customers=customers)
@admin.route('/customers/<int:customer_id>')
@login_required
@require_password_change
def get_customer_details(customer_id):
"""Get customer details for modal"""
if not current_user.is_admin:
return jsonify({'error': 'Access denied'}), 403
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
return jsonify({'error': 'Access denied'}), 403
try:
customer = Customer.query.get_or_404(customer_id)
# Get the associated plan
plan = None
if customer.subscription_plan_id:
from models import PricingPlan
plan = PricingPlan.query.get(customer.subscription_plan_id)
html = render_template('admin/customer_details_modal.html', customer=customer, plan=plan)
return jsonify({
'success': True,
'html': html
})
except Exception as e:
return jsonify({'error': str(e)}), 500