2679 lines
116 KiB
Python
2679 lines
116 KiB
Python
from flask import render_template, Blueprint, redirect, url_for, request, flash, Response, jsonify, session, current_app
|
|
from flask_login import current_user, login_required
|
|
from models import User, db, Room, RoomFile, RoomMemberPermission, SiteSettings, Event, Conversation, Message, MessageAttachment, Notif, EmailTemplate, Mail, KeyValueSettings, DocuPulseSettings, PasswordSetupToken, Instance, ManagementAPIKey, Customer, PricingPlan
|
|
from routes.auth import require_password_change
|
|
from extensions import csrf
|
|
import os
|
|
from werkzeug.utils import secure_filename
|
|
from sqlalchemy import func, case, literal_column, text
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
import sys
|
|
import time
|
|
from forms import CompanySettingsForm
|
|
from utils import log_event, create_notification, get_unread_count
|
|
from io import StringIO
|
|
import csv
|
|
from flask_wtf.csrf import generate_csrf
|
|
import json
|
|
import smtplib
|
|
import requests
|
|
from functools import wraps
|
|
import socket
|
|
from urllib.parse import urlparse
|
|
import stripe
|
|
|
|
# Set up logging to show in console
|
|
logging.basicConfig(
|
|
level=logging.DEBUG,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
handlers=[
|
|
logging.StreamHandler(sys.stdout)
|
|
]
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def init_routes(main_bp):
|
|
@main_bp.context_processor
|
|
def inject_site_settings():
|
|
site_settings = SiteSettings.query.first()
|
|
return dict(site_settings=site_settings)
|
|
|
|
@main_bp.context_processor
|
|
def inject_unread_notifications():
|
|
if current_user.is_authenticated:
|
|
unread_count = get_unread_count(current_user.id)
|
|
return {'unread_notifications': unread_count}
|
|
return {'unread_notifications': 0}
|
|
|
|
@main_bp.route('/')
|
|
def public_home():
|
|
"""Public homepage for the master instance - client-facing website"""
|
|
# Check if this is a master instance
|
|
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
|
|
|
|
if is_master:
|
|
# For master instance, show the public homepage
|
|
return render_template('home.html')
|
|
else:
|
|
# For child instances, redirect to login if not authenticated, otherwise dashboard
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('main.dashboard'))
|
|
else:
|
|
return redirect(url_for('auth.login'))
|
|
|
|
@main_bp.route('/dashboard')
|
|
@login_required
|
|
@require_password_change
|
|
def dashboard():
|
|
logger.info("Loading dashboard...")
|
|
# Get 3 most recent users
|
|
recent_contacts = User.query.order_by(User.created_at.desc()).limit(3).all()
|
|
# Count active and inactive users
|
|
active_count = User.query.filter_by(is_active=True).count()
|
|
inactive_count = User.query.filter_by(is_active=False).count()
|
|
|
|
# Get recent notifications
|
|
recent_notifications = Notif.query.filter_by(user_id=current_user.id).order_by(Notif.timestamp.desc()).limit(5).all()
|
|
|
|
# Get recent events (last 7)
|
|
if current_user.is_admin:
|
|
recent_events = Event.query.order_by(Event.timestamp.desc()).limit(7).all()
|
|
else:
|
|
# Get events where user is the actor OR events from conversations they are a member of
|
|
recent_events = Event.query.filter(
|
|
db.or_(
|
|
Event.user_id == current_user.id, # User's own actions
|
|
db.and_(
|
|
Event.event_type.in_(['conversation_create', 'message_create']), # Conversation-related events
|
|
db.cast(text("(details->>'conversation_id')::integer"), db.Integer).in_(
|
|
db.session.query(Conversation.id)
|
|
.join(Conversation.members)
|
|
.filter(User.id == current_user.id)
|
|
)
|
|
)
|
|
)
|
|
).order_by(Event.timestamp.desc()).limit(7).all()
|
|
|
|
# Get usage stats
|
|
usage_stats = DocuPulseSettings.get_usage_stats()
|
|
|
|
# Room count and size logic
|
|
if current_user.is_admin:
|
|
logger.info("Loading admin dashboard...")
|
|
room_count = Room.query.count()
|
|
# Get total file and folder counts for admin
|
|
file_count = RoomFile.query.filter_by(type='file').count()
|
|
folder_count = RoomFile.query.filter_by(type='folder').count()
|
|
logger.info(f"Admin stats - Files: {file_count}, Folders: {folder_count}")
|
|
|
|
# Get total size of all files including trash
|
|
total_size = db.session.query(func.sum(RoomFile.size)).filter(RoomFile.type == 'file').scalar() or 0
|
|
# Get recent activity for all files
|
|
recent_activity = db.session.query(
|
|
RoomFile,
|
|
Room,
|
|
User
|
|
).join(
|
|
Room, RoomFile.room_id == Room.id
|
|
).join(
|
|
User, RoomFile.uploaded_by == User.id
|
|
).filter(
|
|
RoomFile.deleted == False,
|
|
RoomFile.uploaded_at.isnot(None)
|
|
).order_by(
|
|
RoomFile.uploaded_at.desc()
|
|
).limit(10).all()
|
|
|
|
logger.info(f"Recent activity query results: {len(recent_activity)}")
|
|
if len(recent_activity) == 0:
|
|
# Debug query to see what files exist
|
|
all_files = RoomFile.query.filter_by(deleted=False).all()
|
|
logger.info(f"Total non-deleted files: {len(all_files)}")
|
|
for file in all_files[:5]: # Log first 5 files for debugging
|
|
logger.info(f"File: {file.name}, Uploaded: {file.uploaded_at}, Type: {file.type}")
|
|
|
|
# Format the activity data
|
|
formatted_activity = []
|
|
for file, room, user in recent_activity:
|
|
activity = {
|
|
'name': file.name,
|
|
'type': file.type,
|
|
'room': room,
|
|
'uploader': user,
|
|
'uploaded_at': file.uploaded_at,
|
|
'is_starred': current_user in file.starred_by,
|
|
'is_deleted': file.deleted,
|
|
'can_download': True # Admin can download everything
|
|
}
|
|
formatted_activity.append(activity)
|
|
formatted_activities = formatted_activity
|
|
logger.info(f"Formatted activities: {len(formatted_activities)}")
|
|
# Get storage usage by file type including trash
|
|
storage_by_type = db.session.query(
|
|
case(
|
|
(RoomFile.name.like('%.%'),
|
|
func.substring(RoomFile.name, func.strpos(RoomFile.name, '.') + 1)),
|
|
else_=literal_column("'unknown'")
|
|
).label('extension'),
|
|
func.count(RoomFile.id).label('count'),
|
|
func.sum(RoomFile.size).label('total_size')
|
|
).filter(
|
|
RoomFile.type == 'file'
|
|
).group_by('extension').all()
|
|
|
|
# Get trash and starred stats for admin
|
|
trash_count = RoomFile.query.filter_by(deleted=True).count()
|
|
starred_count = RoomFile.query.filter(RoomFile.starred_by.contains(current_user)).count()
|
|
# Get oldest trash date and total trash size
|
|
oldest_trash = RoomFile.query.filter_by(deleted=True).order_by(RoomFile.deleted_at.asc()).first()
|
|
oldest_trash_date = oldest_trash.deleted_at.strftime('%Y-%m-%d') if oldest_trash and oldest_trash.deleted_at else None
|
|
trash_size = db.session.query(db.func.sum(RoomFile.size)).filter(RoomFile.deleted==True).scalar() or 0
|
|
|
|
# Get files that will be deleted in next 7 days
|
|
seven_days_from_now = datetime.utcnow() + timedelta(days=7)
|
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
|
pending_deletion = RoomFile.query.filter(
|
|
RoomFile.deleted==True,
|
|
RoomFile.deleted_at <= thirty_days_ago,
|
|
RoomFile.deleted_at > thirty_days_ago - timedelta(days=7)
|
|
).count()
|
|
|
|
# Get trash file type breakdown
|
|
trash_by_type = db.session.query(
|
|
case(
|
|
(RoomFile.name.like('%.%'),
|
|
func.substring(RoomFile.name, func.strpos(RoomFile.name, '.') + 1)),
|
|
else_=literal_column("'unknown'")
|
|
).label('extension'),
|
|
func.count(RoomFile.id).label('count')
|
|
).filter(
|
|
RoomFile.deleted==True
|
|
).group_by('extension').all()
|
|
else:
|
|
# Get rooms the user has access to
|
|
accessible_rooms = Room.query.filter(Room.members.any(id=current_user.id)).all()
|
|
room_count = len(accessible_rooms)
|
|
# Get file and folder counts for accessible rooms
|
|
room_ids = [room.id for room in accessible_rooms]
|
|
file_count = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.type == 'file').count()
|
|
folder_count = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.type == 'folder').count()
|
|
# Get total size of files in accessible rooms including trash
|
|
total_size = db.session.query(func.sum(RoomFile.size)).filter(
|
|
RoomFile.room_id.in_(room_ids),
|
|
RoomFile.type == 'file'
|
|
).scalar() or 0
|
|
# Get recent activity for accessible rooms
|
|
recent_activity = db.session.query(
|
|
RoomFile,
|
|
Room,
|
|
User
|
|
).join(
|
|
Room, RoomFile.room_id == Room.id
|
|
).join(
|
|
User, RoomFile.uploaded_by == User.id
|
|
).filter(
|
|
RoomFile.room_id.in_(room_ids),
|
|
RoomFile.deleted == False,
|
|
RoomFile.uploaded_at.isnot(None) # Ensure uploaded_at is not null
|
|
).order_by(
|
|
RoomFile.uploaded_at.desc()
|
|
).limit(10).all()
|
|
|
|
# Format the activity data
|
|
formatted_activity = []
|
|
for file, room, user in recent_activity:
|
|
# Check if user has download permission
|
|
permission = RoomMemberPermission.query.filter_by(
|
|
room_id=room.id,
|
|
user_id=current_user.id
|
|
).first()
|
|
can_download = permission and permission.can_download if permission else False
|
|
|
|
activity = {
|
|
'name': file.name,
|
|
'type': file.type,
|
|
'room': room,
|
|
'uploader': user,
|
|
'uploaded_at': file.uploaded_at,
|
|
'is_starred': current_user in file.starred_by,
|
|
'is_deleted': file.deleted,
|
|
'can_download': can_download
|
|
}
|
|
formatted_activity.append(activity)
|
|
formatted_activities = formatted_activity
|
|
|
|
# Get storage usage by file type for accessible rooms
|
|
storage_by_type = db.session.query(
|
|
case(
|
|
(RoomFile.name.like('%.%'),
|
|
func.substring(RoomFile.name, func.strpos(RoomFile.name, '.') + 1)),
|
|
else_=literal_column("'unknown'")
|
|
).label('extension'),
|
|
func.count(RoomFile.id).label('count'),
|
|
func.sum(RoomFile.size).label('total_size')
|
|
).filter(
|
|
RoomFile.room_id.in_(room_ids),
|
|
RoomFile.type == 'file'
|
|
).group_by('extension').all()
|
|
|
|
# Get trash and starred stats for user's accessible rooms
|
|
trash_count = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.deleted==True).count()
|
|
starred_count = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.starred_by.contains(current_user)).count()
|
|
# Get oldest trash date and total trash size for accessible rooms
|
|
oldest_trash = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.deleted==True).order_by(RoomFile.deleted_at.asc()).first()
|
|
oldest_trash_date = oldest_trash.deleted_at.strftime('%Y-%m-%d') if oldest_trash and oldest_trash.deleted_at else None
|
|
trash_size = db.session.query(db.func.sum(RoomFile.size)).filter(RoomFile.room_id.in_(room_ids), RoomFile.deleted==True).scalar() or 0
|
|
|
|
# Get files that will be deleted in next 7 days for accessible rooms
|
|
seven_days_from_now = datetime.utcnow() + timedelta(days=7)
|
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
|
pending_deletion = RoomFile.query.filter(
|
|
RoomFile.room_id.in_(room_ids),
|
|
RoomFile.deleted==True,
|
|
RoomFile.deleted_at <= thirty_days_ago,
|
|
RoomFile.deleted_at > thirty_days_ago - timedelta(days=7)
|
|
).count()
|
|
|
|
# Get trash file type breakdown for accessible rooms
|
|
trash_by_type = db.session.query(
|
|
case(
|
|
(RoomFile.name.like('%.%'),
|
|
func.substring(RoomFile.name, func.strpos(RoomFile.name, '.') + 1)),
|
|
else_=literal_column("'unknown'")
|
|
).label('extension'),
|
|
func.count(RoomFile.id).label('count')
|
|
).filter(
|
|
RoomFile.room_id.in_(room_ids),
|
|
RoomFile.deleted==True
|
|
).group_by('extension').all()
|
|
|
|
# Get conversation stats
|
|
if current_user.is_admin:
|
|
conversation_count = Conversation.query.count()
|
|
message_count = Message.query.count()
|
|
attachment_count = MessageAttachment.query.count()
|
|
conversation_total_size = db.session.query(func.sum(MessageAttachment.size)).scalar() or 0
|
|
recent_conversations = Conversation.query.order_by(Conversation.created_at.desc()).limit(5).all()
|
|
else:
|
|
# Get conversations where user is a member
|
|
user_conversations = Conversation.query.filter(Conversation.members.any(id=current_user.id)).all()
|
|
conversation_count = len(user_conversations)
|
|
|
|
# Get message count for user's conversations
|
|
conversation_ids = [conv.id for conv in user_conversations]
|
|
message_count = Message.query.filter(Message.conversation_id.in_(conversation_ids)).count()
|
|
|
|
# Get attachment count and size for user's conversations
|
|
attachment_stats = db.session.query(
|
|
func.count(MessageAttachment.id).label('count'),
|
|
func.sum(MessageAttachment.size).label('total_size')
|
|
).filter(MessageAttachment.message_id.in_(
|
|
db.session.query(Message.id).filter(Message.conversation_id.in_(conversation_ids))
|
|
)).first()
|
|
|
|
attachment_count = attachment_stats.count or 0
|
|
conversation_total_size = attachment_stats.total_size or 0
|
|
|
|
# Get recent conversations for the user
|
|
recent_conversations = Conversation.query.filter(
|
|
Conversation.members.any(id=current_user.id)
|
|
).order_by(Conversation.created_at.desc()).limit(5).all()
|
|
|
|
return render_template('dashboard/dashboard.html',
|
|
room_count=room_count,
|
|
file_count=file_count,
|
|
folder_count=folder_count,
|
|
total_size=total_size,
|
|
storage_by_type=storage_by_type,
|
|
recent_activities=formatted_activities,
|
|
trash_count=trash_count,
|
|
pending_deletion=pending_deletion,
|
|
oldest_trash_date=oldest_trash_date,
|
|
trash_size=trash_size,
|
|
trash_by_type=trash_by_type,
|
|
starred_count=starred_count,
|
|
recent_events=recent_events,
|
|
recent_contacts=recent_contacts,
|
|
active_count=active_count,
|
|
inactive_count=inactive_count,
|
|
recent_notifications=recent_notifications,
|
|
unread_notifications=get_unread_count(current_user.id),
|
|
conversation_count=conversation_count,
|
|
message_count=message_count,
|
|
attachment_count=attachment_count,
|
|
conversation_total_size=conversation_total_size,
|
|
recent_conversations=recent_conversations,
|
|
usage_stats=usage_stats,
|
|
is_admin=current_user.is_admin
|
|
)
|
|
|
|
def check_instance_status(instance):
|
|
"""Check the status of an instance by contacting its health endpoint"""
|
|
try:
|
|
# Construct the health check URL
|
|
health_url = f"{instance.main_url.rstrip('/')}/health"
|
|
response = requests.get(health_url, timeout=30) # Increased timeout to 30 seconds
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return {
|
|
'status': 'active' if data.get('status') == 'healthy' else 'inactive',
|
|
'details': json.dumps(data) # Convert dictionary to JSON string
|
|
}
|
|
else:
|
|
return {
|
|
'status': 'inactive',
|
|
'details': f"Health check returned status code {response.status_code}"
|
|
}
|
|
except requests.RequestException as e:
|
|
return {
|
|
'status': 'inactive',
|
|
'details': str(e)
|
|
}
|
|
|
|
@main_bp.route('/instances')
|
|
@login_required
|
|
@require_password_change
|
|
def instances():
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
flash('This page is only available in master instances.', 'error')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
instances = Instance.query.order_by(Instance.name.asc()).all()
|
|
|
|
# Get Git settings
|
|
git_settings = KeyValueSettings.get_value('git_settings')
|
|
gitea_url = git_settings.get('url') if git_settings else None
|
|
gitea_token = git_settings.get('token') if git_settings else None
|
|
gitea_repo = git_settings.get('repo') if git_settings else None
|
|
|
|
for instance in instances:
|
|
# Check status
|
|
status_info = check_instance_status(instance)
|
|
instance.status = status_info['status']
|
|
instance.status_details = status_info['details']
|
|
|
|
db.session.commit()
|
|
|
|
portainer_settings = KeyValueSettings.get_value('portainer_settings')
|
|
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
|
cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings')
|
|
|
|
return render_template('main/instances.html',
|
|
instances=instances,
|
|
portainer_settings=portainer_settings,
|
|
nginx_settings=nginx_settings,
|
|
git_settings=git_settings,
|
|
cloudflare_settings=cloudflare_settings)
|
|
|
|
@main_bp.route('/instances/add', methods=['POST'])
|
|
@login_required
|
|
@require_password_change
|
|
def add_instance():
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
try:
|
|
# Ensure company is not None - use "Unknown" as default
|
|
company = data.get('company', 'Unknown')
|
|
if not company:
|
|
company = 'Unknown'
|
|
|
|
instance = Instance(
|
|
name=data['name'],
|
|
company=company,
|
|
payment_plan=data['payment_plan'],
|
|
main_url=data['main_url'],
|
|
status='inactive' # New instances start as inactive
|
|
)
|
|
db.session.add(instance)
|
|
db.session.commit()
|
|
return jsonify({
|
|
'message': 'Instance added successfully',
|
|
'instance': {
|
|
'id': instance.id,
|
|
'name': instance.name,
|
|
'company': instance.company,
|
|
'rooms_count': instance.rooms_count,
|
|
'conversations_count': instance.conversations_count,
|
|
'data_size': instance.data_size,
|
|
'payment_plan': instance.payment_plan,
|
|
'main_url': instance.main_url,
|
|
'status': instance.status
|
|
}
|
|
})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
@main_bp.route('/instances/<int:instance_id>', methods=['PUT'])
|
|
@login_required
|
|
@require_password_change
|
|
def update_instance(instance_id):
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
instance = Instance.query.get_or_404(instance_id)
|
|
data = request.get_json()
|
|
|
|
try:
|
|
instance.name = data.get('name', instance.name)
|
|
# Ensure company is not None - use current value or "Unknown" as default
|
|
company = data.get('company', instance.company)
|
|
instance.company = company if company else "Unknown"
|
|
instance.payment_plan = data.get('payment_plan', instance.payment_plan)
|
|
instance.main_url = data.get('main_url', instance.main_url)
|
|
instance.status = data.get('status', instance.status)
|
|
|
|
db.session.commit()
|
|
return jsonify({
|
|
'message': 'Instance updated successfully',
|
|
'instance': {
|
|
'id': instance.id,
|
|
'name': instance.name,
|
|
'company': instance.company,
|
|
'rooms_count': instance.rooms_count,
|
|
'conversations_count': instance.conversations_count,
|
|
'data_size': instance.data_size,
|
|
'payment_plan': instance.payment_plan,
|
|
'main_url': instance.main_url,
|
|
'status': instance.status
|
|
}
|
|
})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
@main_bp.route('/instances/<int:instance_id>', methods=['DELETE'])
|
|
@login_required
|
|
@require_password_change
|
|
def delete_instance(instance_id):
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
instance = Instance.query.get_or_404(instance_id)
|
|
|
|
# Get Portainer settings
|
|
portainer_settings = KeyValueSettings.get_value('portainer_settings')
|
|
if not portainer_settings:
|
|
current_app.logger.warning(f"Portainer settings not configured, proceeding with database deletion only for instance {instance.name}")
|
|
# Continue with database deletion even if Portainer is not configured
|
|
try:
|
|
db.session.delete(instance)
|
|
db.session.commit()
|
|
current_app.logger.info(f"Successfully deleted instance from database: {instance.name}")
|
|
return jsonify({'message': 'Instance deleted from database (Portainer not configured)'})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
current_app.logger.error(f"Error deleting instance {instance.name} from database: {str(e)}")
|
|
return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500
|
|
|
|
try:
|
|
# First, delete the Portainer stack and its volumes if stack information exists
|
|
if instance.portainer_stack_id and instance.portainer_stack_name:
|
|
current_app.logger.info(f"Deleting Portainer stack: {instance.portainer_stack_name} (ID: {instance.portainer_stack_id})")
|
|
|
|
# Get Portainer endpoint ID (assuming it's the first endpoint)
|
|
try:
|
|
endpoint_response = requests.get(
|
|
f"{portainer_settings['url'].rstrip('/')}/api/endpoints",
|
|
headers={
|
|
'X-API-Key': portainer_settings['api_key'],
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout=30
|
|
)
|
|
|
|
if not endpoint_response.ok:
|
|
current_app.logger.error(f"Failed to get Portainer endpoints: {endpoint_response.text}")
|
|
return jsonify({'error': 'Failed to get Portainer endpoints'}), 500
|
|
|
|
endpoints = endpoint_response.json()
|
|
if not endpoints:
|
|
current_app.logger.error("No Portainer endpoints found")
|
|
return jsonify({'error': 'No Portainer endpoints found'}), 400
|
|
|
|
endpoint_id = endpoints[0]['Id']
|
|
|
|
# Delete the stack (this will also remove associated volumes)
|
|
delete_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{instance.portainer_stack_id}"
|
|
delete_response = requests.delete(
|
|
delete_url,
|
|
headers={
|
|
'X-API-Key': portainer_settings['api_key'],
|
|
'Accept': 'application/json'
|
|
},
|
|
params={'endpointId': endpoint_id},
|
|
timeout=60 # Give more time for stack deletion
|
|
)
|
|
|
|
if delete_response.ok:
|
|
current_app.logger.info(f"Successfully deleted Portainer stack: {instance.portainer_stack_name}")
|
|
else:
|
|
current_app.logger.warning(f"Failed to delete Portainer stack: {delete_response.status_code} - {delete_response.text}")
|
|
# Continue with database deletion even if Portainer deletion fails
|
|
|
|
# Also try to delete any orphaned volumes associated with this stack
|
|
try:
|
|
volumes_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/volumes"
|
|
volumes_response = requests.get(
|
|
volumes_url,
|
|
headers={
|
|
'X-API-Key': portainer_settings['api_key'],
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout=30
|
|
)
|
|
|
|
if volumes_response.ok:
|
|
volumes = volumes_response.json().get('Volumes', [])
|
|
stack_volumes = [vol for vol in volumes if vol.get('Labels', {}).get('com.docker.stack.namespace') == instance.portainer_stack_name]
|
|
|
|
for volume in stack_volumes:
|
|
volume_name = volume.get('Name')
|
|
if volume_name:
|
|
delete_volume_url = f"{volumes_url}/{volume_name}"
|
|
volume_delete_response = requests.delete(
|
|
delete_volume_url,
|
|
headers={
|
|
'X-API-Key': portainer_settings['api_key'],
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout=30
|
|
)
|
|
|
|
if volume_delete_response.ok:
|
|
current_app.logger.info(f"Successfully deleted volume: {volume_name}")
|
|
else:
|
|
current_app.logger.warning(f"Failed to delete volume {volume_name}: {volume_delete_response.status_code}")
|
|
else:
|
|
current_app.logger.warning(f"Failed to get volumes list: {volumes_response.status_code}")
|
|
except Exception as volume_error:
|
|
current_app.logger.warning(f"Error cleaning up volumes: {str(volume_error)}")
|
|
|
|
except requests.exceptions.RequestException as req_error:
|
|
current_app.logger.error(f"Network error during Portainer operations: {str(req_error)}")
|
|
# Continue with database deletion even if Portainer operations fail
|
|
else:
|
|
current_app.logger.info(f"No Portainer stack information found for instance {instance.name}, proceeding with database deletion only")
|
|
|
|
# Clean up NGINX proxy host if NGINX settings are configured
|
|
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
|
if nginx_settings and instance.main_url:
|
|
current_app.logger.info(f"Cleaning up NGINX proxy host for instance {instance.name}")
|
|
try:
|
|
# Extract domain from main_url
|
|
parsed_url = urlparse(instance.main_url)
|
|
domain = parsed_url.netloc
|
|
|
|
if domain:
|
|
# Get NGINX JWT token
|
|
token_response = requests.post(
|
|
f"{nginx_settings['url'].rstrip('/')}/api/tokens",
|
|
json={
|
|
'identity': nginx_settings['username'],
|
|
'secret': nginx_settings['password']
|
|
},
|
|
headers={'Content-Type': 'application/json'},
|
|
timeout=30
|
|
)
|
|
|
|
if token_response.ok:
|
|
token_data = token_response.json()
|
|
token = token_data.get('token')
|
|
|
|
if token:
|
|
# Get all proxy hosts to find the one matching this domain
|
|
proxy_hosts_response = requests.get(
|
|
f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts",
|
|
headers={
|
|
'Authorization': f'Bearer {token}',
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout=30
|
|
)
|
|
|
|
if proxy_hosts_response.ok:
|
|
proxy_hosts = proxy_hosts_response.json()
|
|
|
|
# Find proxy host with matching domain
|
|
matching_proxy = None
|
|
for proxy_host in proxy_hosts:
|
|
if proxy_host.get('domain_names') and domain in proxy_host['domain_names']:
|
|
matching_proxy = proxy_host
|
|
break
|
|
|
|
if matching_proxy:
|
|
# Delete the proxy host
|
|
delete_response = requests.delete(
|
|
f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts/{matching_proxy['id']}",
|
|
headers={
|
|
'Authorization': f'Bearer {token}',
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout=30
|
|
)
|
|
|
|
if delete_response.ok:
|
|
current_app.logger.info(f"Successfully deleted NGINX proxy host for domain: {domain}")
|
|
else:
|
|
current_app.logger.warning(f"Failed to delete NGINX proxy host: {delete_response.status_code} - {delete_response.text}")
|
|
else:
|
|
current_app.logger.info(f"No NGINX proxy host found for domain: {domain}")
|
|
else:
|
|
current_app.logger.warning(f"Failed to get NGINX proxy hosts: {proxy_hosts_response.status_code}")
|
|
else:
|
|
current_app.logger.warning("No NGINX token received")
|
|
else:
|
|
current_app.logger.warning(f"Failed to authenticate with NGINX: {token_response.status_code}")
|
|
except Exception as nginx_error:
|
|
current_app.logger.warning(f"Error cleaning up NGINX proxy host: {str(nginx_error)}")
|
|
# Continue with database deletion even if NGINX cleanup fails
|
|
else:
|
|
current_app.logger.info(f"No NGINX settings configured or no main_url for instance {instance.name}, skipping NGINX cleanup")
|
|
|
|
# Now delete the instance from the database
|
|
db.session.delete(instance)
|
|
db.session.commit()
|
|
|
|
current_app.logger.info(f"Successfully deleted instance: {instance.name}")
|
|
return jsonify({'message': 'Instance and all associated resources (Portainer stack, volumes, NGINX proxy host) deleted successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
current_app.logger.error(f"Error deleting instance {instance.name}: {str(e)}")
|
|
return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500
|
|
|
|
@main_bp.route('/instances/<int:instance_id>/status')
|
|
@login_required
|
|
def check_status(instance_id):
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
instance = Instance.query.get_or_404(instance_id)
|
|
status_info = check_instance_status(instance)
|
|
|
|
# Update instance status in database
|
|
instance.status = status_info['status']
|
|
instance.status_details = status_info['details']
|
|
db.session.commit()
|
|
|
|
return jsonify(status_info)
|
|
|
|
@main_bp.route('/instances/<int:instance_id>/save-token', methods=['POST'])
|
|
@login_required
|
|
@require_password_change
|
|
def save_instance_token(instance_id):
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
instance = Instance.query.get_or_404(instance_id)
|
|
data = request.get_json()
|
|
|
|
if not data or 'token' not in data:
|
|
return jsonify({'error': 'Token is required'}), 400
|
|
|
|
try:
|
|
instance.connection_token = data['token']
|
|
db.session.commit()
|
|
return jsonify({'message': 'Token saved successfully'})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
@main_bp.route('/instances/<int:instance_id>/detail')
|
|
@login_required
|
|
@require_password_change
|
|
def instance_detail(instance_id):
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
flash('This page is only available in master instances.', 'error')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
instance = Instance.query.get_or_404(instance_id)
|
|
|
|
# Check instance status
|
|
status_info = check_instance_status(instance)
|
|
instance.status = status_info['status']
|
|
instance.status_details = status_info['details']
|
|
|
|
# Fetch company name from instance settings
|
|
try:
|
|
if instance.connection_token:
|
|
# First get JWT token
|
|
jwt_response = requests.post(
|
|
f"{instance.main_url.rstrip('/')}/api/admin/management-token",
|
|
headers={
|
|
'X-API-Key': instance.connection_token,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout=5
|
|
)
|
|
if jwt_response.status_code == 200:
|
|
jwt_data = jwt_response.json()
|
|
jwt_token = jwt_data.get('token')
|
|
|
|
if jwt_token:
|
|
# Then fetch settings with JWT token
|
|
response = requests.get(
|
|
f"{instance.main_url.rstrip('/')}/api/admin/settings",
|
|
headers={
|
|
'Authorization': f'Bearer {jwt_token}',
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout=5
|
|
)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
if 'company_name' in data:
|
|
# Set company to "Unknown" if company_name is None or empty
|
|
company_name = data['company_name']
|
|
instance.company = company_name if company_name else "Unknown"
|
|
db.session.commit()
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error fetching instance settings: {str(e)}")
|
|
|
|
return render_template('main/instance_detail.html', instance=instance)
|
|
|
|
@main_bp.route('/api/instances/<int:instance_id>')
|
|
@login_required
|
|
@require_password_change
|
|
def get_instance_data(instance_id):
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
instance = Instance.query.get_or_404(instance_id)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'instance': {
|
|
'id': instance.id,
|
|
'name': instance.name,
|
|
'company': instance.company,
|
|
'main_url': instance.main_url,
|
|
'status': instance.status,
|
|
'payment_plan': instance.payment_plan,
|
|
'portainer_stack_id': instance.portainer_stack_id,
|
|
'portainer_stack_name': instance.portainer_stack_name,
|
|
'deployed_version': instance.deployed_version,
|
|
'deployed_branch': instance.deployed_branch,
|
|
'connection_token': instance.connection_token
|
|
}
|
|
})
|
|
|
|
@main_bp.route('/instances/<int:instance_id>/auth-status')
|
|
@login_required
|
|
@require_password_change
|
|
def instance_auth_status(instance_id):
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
instance = Instance.query.get_or_404(instance_id)
|
|
|
|
# Check if instance has a connection token
|
|
has_token = bool(instance.connection_token)
|
|
|
|
# If there's a token, verify it's still valid
|
|
is_valid = False
|
|
if has_token:
|
|
try:
|
|
# Try to get a JWT token using the connection token
|
|
response = requests.post(
|
|
f"{instance.main_url.rstrip('/')}/api/admin/management-token",
|
|
headers={
|
|
'X-API-Key': instance.connection_token,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout=5
|
|
)
|
|
is_valid = response.status_code == 200
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error verifying token: {str(e)}")
|
|
is_valid = False
|
|
|
|
return jsonify({
|
|
'authenticated': has_token and is_valid,
|
|
'has_token': has_token,
|
|
'is_valid': is_valid
|
|
})
|
|
|
|
@main_bp.route('/instances/<int:instance_id>/version-info')
|
|
@login_required
|
|
@require_password_change
|
|
def instance_version_info(instance_id):
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
instance = Instance.query.get_or_404(instance_id)
|
|
|
|
# Check if instance has a connection token
|
|
if not instance.connection_token:
|
|
return jsonify({
|
|
'error': 'Instance not authenticated',
|
|
'deployed_version': instance.deployed_version,
|
|
'deployed_branch': instance.deployed_branch
|
|
})
|
|
|
|
try:
|
|
# Get JWT token using the connection token
|
|
jwt_response = requests.post(
|
|
f"{instance.main_url.rstrip('/')}/api/admin/management-token",
|
|
headers={
|
|
'X-API-Key': instance.connection_token,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout=5
|
|
)
|
|
|
|
if jwt_response.status_code != 200:
|
|
return jsonify({
|
|
'error': 'Failed to authenticate with instance',
|
|
'deployed_version': instance.deployed_version,
|
|
'deployed_branch': instance.deployed_branch
|
|
})
|
|
|
|
jwt_data = jwt_response.json()
|
|
jwt_token = jwt_data.get('token')
|
|
|
|
if not jwt_token:
|
|
return jsonify({
|
|
'error': 'No JWT token received',
|
|
'deployed_version': instance.deployed_version,
|
|
'deployed_branch': instance.deployed_branch
|
|
})
|
|
|
|
# Fetch version information from the instance
|
|
response = requests.get(
|
|
f"{instance.main_url.rstrip('/')}/api/admin/version-info",
|
|
headers={
|
|
'Authorization': f'Bearer {jwt_token}',
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout=5
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
version_data = response.json()
|
|
|
|
# Update the instance with the fetched version information
|
|
instance.deployed_version = version_data.get('app_version', instance.deployed_version)
|
|
instance.deployed_branch = version_data.get('git_branch', instance.deployed_branch)
|
|
instance.version_checked_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'deployed_version': instance.deployed_version,
|
|
'deployed_branch': instance.deployed_branch,
|
|
'git_commit': version_data.get('git_commit'),
|
|
'deployed_at': version_data.get('deployed_at'),
|
|
'version_checked_at': instance.version_checked_at.isoformat() if instance.version_checked_at else None
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'error': f'Failed to fetch version info: {response.status_code}',
|
|
'deployed_version': instance.deployed_version,
|
|
'deployed_branch': instance.deployed_branch
|
|
})
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error fetching version info: {str(e)}")
|
|
return jsonify({
|
|
'error': f'Error fetching version info: {str(e)}',
|
|
'deployed_version': instance.deployed_version,
|
|
'deployed_branch': instance.deployed_branch
|
|
})
|
|
|
|
@main_bp.route('/api/latest-version')
|
|
@login_required
|
|
@require_password_change
|
|
def get_latest_version():
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
try:
|
|
# Get Git settings
|
|
git_settings = KeyValueSettings.get_value('git_settings')
|
|
if not git_settings:
|
|
return jsonify({
|
|
'error': 'Git settings not configured',
|
|
'latest_version': 'unknown',
|
|
'latest_commit': 'unknown',
|
|
'last_checked': None
|
|
})
|
|
|
|
latest_tag = None
|
|
latest_commit = None
|
|
|
|
if git_settings['provider'] == 'gitea':
|
|
headers = {
|
|
'Accept': 'application/json',
|
|
'Authorization': f'token {git_settings["token"]}'
|
|
}
|
|
|
|
# Get the latest tag
|
|
tags_response = requests.get(
|
|
f'{git_settings["url"]}/api/v1/repos/{git_settings["repo"]}/tags',
|
|
headers=headers,
|
|
timeout=10
|
|
)
|
|
|
|
if tags_response.status_code == 200:
|
|
tags_data = tags_response.json()
|
|
if tags_data:
|
|
# Sort tags by commit date (newest first) and get the latest
|
|
sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True)
|
|
if sorted_tags:
|
|
latest_tag = sorted_tags[0].get('name')
|
|
latest_commit = sorted_tags[0].get('commit', {}).get('id')
|
|
else:
|
|
# Try token as query parameter if header auth fails
|
|
tags_response = requests.get(
|
|
f'{git_settings["url"]}/api/v1/repos/{git_settings["repo"]}/tags?token={git_settings["token"]}',
|
|
headers={'Accept': 'application/json'},
|
|
timeout=10
|
|
)
|
|
if tags_response.status_code == 200:
|
|
tags_data = tags_response.json()
|
|
if tags_data:
|
|
sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True)
|
|
if sorted_tags:
|
|
latest_tag = sorted_tags[0].get('name')
|
|
latest_commit = sorted_tags[0].get('commit', {}).get('id')
|
|
|
|
elif git_settings['provider'] == 'gitlab':
|
|
headers = {
|
|
'PRIVATE-TOKEN': git_settings['token'],
|
|
'Accept': 'application/json'
|
|
}
|
|
|
|
# Get the latest tag
|
|
tags_response = requests.get(
|
|
f'{git_settings["url"]}/api/v4/projects/{git_settings["repo"].replace("/", "%2F")}/repository/tags',
|
|
headers=headers,
|
|
params={'order_by': 'version', 'sort': 'desc', 'per_page': 1},
|
|
timeout=10
|
|
)
|
|
|
|
if tags_response.status_code == 200:
|
|
tags_data = tags_response.json()
|
|
if tags_data:
|
|
latest_tag = tags_data[0].get('name')
|
|
latest_commit = tags_data[0].get('commit', {}).get('id')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'latest_version': latest_tag or 'unknown',
|
|
'latest_commit': latest_commit or 'unknown',
|
|
'repository': git_settings.get('repo', 'unknown'),
|
|
'provider': git_settings.get('provider', 'unknown'),
|
|
'last_checked': datetime.utcnow().isoformat()
|
|
})
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error fetching latest version: {str(e)}")
|
|
return jsonify({
|
|
'error': f'Error fetching latest version: {str(e)}',
|
|
'latest_version': 'unknown',
|
|
'latest_commit': 'unknown',
|
|
'last_checked': datetime.utcnow().isoformat()
|
|
}), 500
|
|
|
|
UPLOAD_FOLDER = '/app/uploads/profile_pics'
|
|
if not os.path.exists(UPLOAD_FOLDER):
|
|
os.makedirs(UPLOAD_FOLDER)
|
|
|
|
@main_bp.route('/profile', methods=['GET', 'POST'])
|
|
@login_required
|
|
@require_password_change
|
|
def profile():
|
|
if request.method == 'POST':
|
|
logger.debug(f"Profile form submitted with data: {request.form}")
|
|
logger.debug(f"Files in request: {request.files}")
|
|
|
|
try:
|
|
# Handle profile picture removal
|
|
if 'remove_picture' in request.form:
|
|
logger.debug("Removing profile picture")
|
|
if current_user.profile_picture:
|
|
# Delete the old profile picture file
|
|
old_picture_path = os.path.join(UPLOAD_FOLDER, current_user.profile_picture)
|
|
if os.path.exists(old_picture_path):
|
|
os.remove(old_picture_path)
|
|
current_user.profile_picture = None
|
|
db.session.commit()
|
|
flash('Profile picture removed successfully!', 'success')
|
|
return redirect(url_for('main.profile'))
|
|
|
|
new_email = request.form.get('email')
|
|
logger.debug(f"New email: {new_email}")
|
|
# Check if the new email is already used by another user
|
|
if new_email != current_user.email:
|
|
existing_user = User.query.filter_by(email=new_email).first()
|
|
if existing_user:
|
|
flash('A user with this email already exists.', 'error')
|
|
return render_template('profile/profile.html')
|
|
|
|
# Handle profile picture upload
|
|
file = request.files.get('profile_picture')
|
|
if file and file.filename:
|
|
logger.debug(f"Uploading new profile picture: {file.filename}")
|
|
filename = secure_filename(file.filename)
|
|
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
|
file.save(file_path)
|
|
current_user.profile_picture = filename
|
|
|
|
# Update user information
|
|
current_user.username = request.form.get('first_name')
|
|
current_user.last_name = request.form.get('last_name')
|
|
current_user.email = new_email
|
|
current_user.phone = request.form.get('phone')
|
|
current_user.company = request.form.get('company')
|
|
current_user.position = request.form.get('position')
|
|
current_user.notes = request.form.get('notes')
|
|
|
|
logger.debug(f"Updated user data: username={current_user.username}, last_name={current_user.last_name}, email={current_user.email}")
|
|
|
|
# Handle password change if provided
|
|
new_password = request.form.get('new_password')
|
|
confirm_password = request.form.get('confirm_password')
|
|
if new_password:
|
|
if not confirm_password:
|
|
flash('Please confirm your new password.', 'error')
|
|
return render_template('profile/profile.html')
|
|
if new_password != confirm_password:
|
|
flash('Passwords do not match.', 'error')
|
|
return render_template('profile/profile.html')
|
|
current_user.set_password(new_password)
|
|
|
|
# Create password change notification
|
|
create_notification(
|
|
notif_type='password_changed',
|
|
user_id=current_user.id,
|
|
details={
|
|
'message': 'Your password has been changed successfully.',
|
|
'timestamp': datetime.utcnow().isoformat()
|
|
}
|
|
)
|
|
|
|
flash('Password updated successfully.', 'success')
|
|
elif confirm_password:
|
|
flash('Please enter a new password.', 'error')
|
|
return render_template('profile/profile.html')
|
|
|
|
# Create event details
|
|
event_details = {
|
|
'user_id': current_user.id,
|
|
'email': current_user.email,
|
|
'update_type': 'profile_update',
|
|
'updated_fields': {
|
|
'username': current_user.username,
|
|
'last_name': current_user.last_name,
|
|
'email': current_user.email,
|
|
'phone': current_user.phone,
|
|
'company': current_user.company,
|
|
'position': current_user.position,
|
|
'notes': current_user.notes,
|
|
'profile_picture': bool(current_user.profile_picture)
|
|
},
|
|
'changes': {
|
|
'username': request.form.get('first_name'),
|
|
'last_name': request.form.get('last_name'),
|
|
'email': request.form.get('email'),
|
|
'phone': request.form.get('phone'),
|
|
'company': request.form.get('company'),
|
|
'position': request.form.get('position'),
|
|
'notes': request.form.get('notes'),
|
|
'password_changed': bool(new_password)
|
|
}
|
|
}
|
|
logger.debug(f"Preparing to create profile update event with details: {event_details}")
|
|
|
|
# Commit all changes
|
|
db.session.commit()
|
|
logger.debug("Profile changes and event committed to database successfully")
|
|
|
|
flash('Profile updated successfully!', 'success')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating profile: {str(e)}")
|
|
logger.error(f"Full error details: {str(e.__class__.__name__)}: {str(e)}")
|
|
db.session.rollback()
|
|
flash('An error occurred while updating your profile.', 'error')
|
|
return render_template('profile/profile.html')
|
|
|
|
return render_template('profile/profile.html')
|
|
|
|
@main_bp.route('/starred')
|
|
@login_required
|
|
@require_password_change
|
|
def starred():
|
|
return render_template('starred/starred.html')
|
|
|
|
@main_bp.route('/conversations')
|
|
@login_required
|
|
@require_password_change
|
|
def conversations():
|
|
return redirect(url_for('conversations.conversations'))
|
|
|
|
@main_bp.route('/trash')
|
|
@login_required
|
|
@require_password_change
|
|
def trash():
|
|
return render_template('trash/trash.html')
|
|
|
|
@main_bp.route('/notifications')
|
|
@login_required
|
|
@require_password_change
|
|
def notifications():
|
|
# Get filter parameters
|
|
notif_type = request.args.get('notif_type', '')
|
|
date_range = request.args.get('date_range', '7d')
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 10
|
|
|
|
# Calculate date range
|
|
end_date = datetime.utcnow()
|
|
if date_range == '24h':
|
|
start_date = end_date - timedelta(days=1)
|
|
elif date_range == '7d':
|
|
start_date = end_date - timedelta(days=7)
|
|
elif date_range == '30d':
|
|
start_date = end_date - timedelta(days=30)
|
|
else:
|
|
start_date = None
|
|
|
|
# Build query
|
|
query = Notif.query.filter_by(user_id=current_user.id)
|
|
|
|
if notif_type:
|
|
query = query.filter_by(notif_type=notif_type)
|
|
if start_date:
|
|
query = query.filter(Notif.timestamp >= start_date)
|
|
|
|
# Get total count for pagination
|
|
total_notifs = query.count()
|
|
total_pages = (total_notifs + per_page - 1) // per_page
|
|
|
|
# Get paginated notifications
|
|
notifications = query.order_by(Notif.timestamp.desc()).paginate(page=page, per_page=per_page)
|
|
|
|
# Check if this is an AJAX request
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
return jsonify({
|
|
'notifications': [{
|
|
'id': notif.id,
|
|
'notif_type': notif.notif_type,
|
|
'timestamp': notif.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
|
'read': notif.read,
|
|
'details': notif.details,
|
|
'sender': {
|
|
'username': notif.sender.username,
|
|
'last_name': notif.sender.last_name
|
|
} if notif.sender else None
|
|
} for notif in notifications.items],
|
|
'total_pages': total_pages,
|
|
'current_page': page
|
|
})
|
|
|
|
return render_template('notifications/notifications.html',
|
|
notifications=notifications.items,
|
|
total_pages=total_pages,
|
|
current_page=page)
|
|
|
|
@main_bp.route('/api/notifications')
|
|
@login_required
|
|
def get_notifications():
|
|
# Get filter parameters
|
|
notif_type = request.args.get('notif_type', '')
|
|
date_range = request.args.get('date_range', '7d')
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 10
|
|
|
|
# Calculate date range
|
|
end_date = datetime.utcnow()
|
|
if date_range == '24h':
|
|
start_date = end_date - timedelta(days=1)
|
|
elif date_range == '7d':
|
|
start_date = end_date - timedelta(days=7)
|
|
elif date_range == '30d':
|
|
start_date = end_date - timedelta(days=30)
|
|
else:
|
|
start_date = None
|
|
|
|
# Build query
|
|
query = Notif.query.filter_by(user_id=current_user.id)
|
|
|
|
if notif_type:
|
|
query = query.filter_by(notif_type=notif_type)
|
|
if start_date:
|
|
query = query.filter(Notif.timestamp >= start_date)
|
|
|
|
# Get total count for pagination
|
|
total_notifs = query.count()
|
|
total_pages = (total_notifs + per_page - 1) // per_page
|
|
|
|
# Get paginated notifications
|
|
notifications = query.order_by(Notif.timestamp.desc()).paginate(page=page, per_page=per_page)
|
|
|
|
return jsonify({
|
|
'notifications': [{
|
|
'id': notif.id,
|
|
'notif_type': notif.notif_type,
|
|
'timestamp': notif.timestamp.isoformat(),
|
|
'read': notif.read,
|
|
'details': notif.details,
|
|
'sender': {
|
|
'id': notif.sender.id,
|
|
'username': notif.sender.username,
|
|
'last_name': notif.sender.last_name
|
|
} if notif.sender else None
|
|
} for notif in notifications.items],
|
|
'total_pages': total_pages,
|
|
'current_page': page
|
|
})
|
|
|
|
@main_bp.route('/api/notifications/<int:notif_id>')
|
|
@login_required
|
|
def get_notification_details(notif_id):
|
|
notif = Notif.query.get_or_404(notif_id)
|
|
if notif.user_id != current_user.id:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
return jsonify({
|
|
'id': notif.id,
|
|
'notif_type': notif.notif_type,
|
|
'timestamp': notif.timestamp.isoformat(),
|
|
'read': notif.read,
|
|
'details': notif.details,
|
|
'sender': {
|
|
'id': notif.sender.id,
|
|
'username': notif.sender.username
|
|
} if notif.sender else None
|
|
})
|
|
|
|
@main_bp.route('/api/notifications/<int:notif_id>/read', methods=['POST'])
|
|
@login_required
|
|
def mark_notification_read(notif_id):
|
|
notif = Notif.query.get_or_404(notif_id)
|
|
if notif.user_id != current_user.id:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
notif.read = True
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True})
|
|
|
|
@main_bp.route('/api/notifications/mark-all-read', methods=['POST'])
|
|
@login_required
|
|
def mark_all_notifications_read():
|
|
result = Notif.query.filter_by(user_id=current_user.id, read=False).update({'read': True})
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'count': result})
|
|
|
|
@main_bp.route('/api/notifications/<int:notif_id>', methods=['DELETE'])
|
|
@login_required
|
|
def delete_notification(notif_id):
|
|
notif = Notif.query.get_or_404(notif_id)
|
|
if notif.user_id != current_user.id:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
db.session.delete(notif)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True})
|
|
|
|
@main_bp.route('/settings')
|
|
@login_required
|
|
def settings():
|
|
if not current_user.is_admin:
|
|
flash('You do not have permission to access settings.', 'error')
|
|
return redirect(url_for('main.home'))
|
|
|
|
active_tab = request.args.get('tab', 'colors')
|
|
# Validate tab parameter
|
|
valid_tabs = ['colors', 'general', 'email_templates', 'mails', 'security', 'events', 'debugging', 'smtp', 'connections', 'pricing']
|
|
if active_tab not in valid_tabs:
|
|
active_tab = 'colors'
|
|
|
|
site_settings = SiteSettings.get_settings()
|
|
company_form = CompanySettingsForm()
|
|
|
|
# Get SMTP settings for the SMTP tab
|
|
smtp_settings = None
|
|
if active_tab == 'smtp':
|
|
smtp_settings = KeyValueSettings.get_value('smtp_settings')
|
|
|
|
# Get connection settings
|
|
portainer_settings = KeyValueSettings.get_value('portainer_settings')
|
|
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
|
git_settings = KeyValueSettings.get_value('git_settings')
|
|
cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings')
|
|
stripe_settings = KeyValueSettings.get_value('stripe_settings')
|
|
|
|
# Get management API key for the connections tab
|
|
management_api_key = ManagementAPIKey.query.filter_by(is_active=True).first()
|
|
if management_api_key:
|
|
site_settings.management_api_key = management_api_key.api_key
|
|
|
|
# Get events for the events tab
|
|
events = None
|
|
total_pages = 0
|
|
current_page = 1
|
|
users = {}
|
|
|
|
if active_tab == 'events':
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 10
|
|
events = Event.query.order_by(Event.timestamp.desc()).paginate(page=page, per_page=per_page)
|
|
total_pages = events.pages
|
|
current_page = events.page
|
|
|
|
# Get all users for the events
|
|
user_ids = set()
|
|
for event in events.items:
|
|
user_ids.add(event.user_id)
|
|
if event.details and 'target_user_id' in event.details:
|
|
user_ids.add(event.details['target_user_id'])
|
|
|
|
users = {user.id: user for user in User.query.filter(User.id.in_(user_ids)).all()}
|
|
|
|
# Get email templates for the email templates tab
|
|
email_templates = EmailTemplate.query.filter_by(is_active=True).all()
|
|
|
|
# Get mails for the mails tab
|
|
mails = None
|
|
if active_tab == 'mails':
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 10
|
|
mails = Mail.query.order_by(Mail.created_at.desc()).paginate(page=page, per_page=per_page)
|
|
total_pages = mails.pages
|
|
current_page = mails.page
|
|
users = User.query.order_by(User.username).all()
|
|
|
|
# Get pricing plans for the pricing tab (only for MASTER instances)
|
|
pricing_plans = []
|
|
if active_tab == 'pricing':
|
|
from models import PricingPlan
|
|
pricing_plans = PricingPlan.query.order_by(PricingPlan.order_index).all()
|
|
|
|
if request.method == 'GET':
|
|
company_form.company_name.data = site_settings.company_name
|
|
company_form.company_website.data = site_settings.company_website
|
|
company_form.company_email.data = site_settings.company_email
|
|
company_form.company_phone.data = site_settings.company_phone
|
|
company_form.company_address.data = site_settings.company_address
|
|
company_form.company_city.data = site_settings.company_city
|
|
company_form.company_state.data = site_settings.company_state
|
|
company_form.company_zip.data = site_settings.company_zip
|
|
company_form.company_country.data = site_settings.company_country
|
|
company_form.company_description.data = site_settings.company_description
|
|
company_form.company_industry.data = site_settings.company_industry
|
|
|
|
return render_template('settings/settings.html',
|
|
primary_color=site_settings.primary_color,
|
|
secondary_color=site_settings.secondary_color,
|
|
active_tab=active_tab,
|
|
site_settings=site_settings,
|
|
events=events.items if events else None,
|
|
mails=mails,
|
|
total_pages=total_pages,
|
|
current_page=current_page,
|
|
users=users,
|
|
email_templates=email_templates,
|
|
form=company_form,
|
|
smtp_settings=smtp_settings,
|
|
portainer_settings=portainer_settings,
|
|
nginx_settings=nginx_settings,
|
|
git_settings=git_settings,
|
|
cloudflare_settings=cloudflare_settings,
|
|
stripe_settings=stripe_settings,
|
|
pricing_plans=pricing_plans,
|
|
csrf_token=generate_csrf())
|
|
|
|
@main_bp.route('/settings/update-smtp', methods=['POST'])
|
|
@login_required
|
|
def update_smtp_settings():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
try:
|
|
# Get SMTP settings from form
|
|
smtp_settings = {
|
|
'smtp_host': request.form.get('smtp_host'),
|
|
'smtp_port': int(request.form.get('smtp_port')),
|
|
'smtp_security': request.form.get('smtp_security'),
|
|
'smtp_username': request.form.get('smtp_username'),
|
|
'smtp_password': request.form.get('smtp_password'),
|
|
'smtp_from_email': request.form.get('smtp_from_email'),
|
|
'smtp_from_name': request.form.get('smtp_from_name')
|
|
}
|
|
|
|
# Save to database using KeyValueSettings
|
|
KeyValueSettings.set_value('smtp_settings', smtp_settings)
|
|
|
|
flash('SMTP settings updated successfully.', 'success')
|
|
return redirect(url_for('main.settings', tab='smtp'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
flash(f'Error updating SMTP settings: {str(e)}', 'error')
|
|
return redirect(url_for('main.settings', tab='smtp'))
|
|
|
|
@main_bp.route('/settings/test-smtp', methods=['POST'])
|
|
@login_required
|
|
def test_smtp_connection():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
try:
|
|
data = request.get_json()
|
|
|
|
# Create SMTP connection
|
|
if data['smtp_security'] == 'ssl':
|
|
smtp = smtplib.SMTP_SSL(data['smtp_host'], int(data['smtp_port']))
|
|
else:
|
|
smtp = smtplib.SMTP(data['smtp_host'], int(data['smtp_port']))
|
|
|
|
# Start TLS if needed
|
|
if data['smtp_security'] == 'tls':
|
|
smtp.starttls()
|
|
|
|
# Login
|
|
smtp.login(data['smtp_username'], data['smtp_password'])
|
|
|
|
# Close connection
|
|
smtp.quit()
|
|
|
|
return jsonify({'success': True})
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
})
|
|
|
|
@main_bp.route('/settings/colors', methods=['POST'])
|
|
@login_required
|
|
def update_colors():
|
|
if not current_user.is_admin:
|
|
flash('Only administrators can update settings.', 'error')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
logger.debug(f"Form data: {request.form}")
|
|
logger.debug(f"CSRF token: {request.form.get('csrf_token')}")
|
|
|
|
primary_color = request.form.get('primary_color')
|
|
secondary_color = request.form.get('secondary_color')
|
|
|
|
logger.debug(f"Primary color: {primary_color}")
|
|
logger.debug(f"Secondary color: {secondary_color}")
|
|
|
|
if not primary_color or not secondary_color:
|
|
flash('Both primary and secondary colors are required.', 'error')
|
|
return redirect(url_for('main.settings'))
|
|
|
|
site_settings = SiteSettings.get_settings()
|
|
site_settings.primary_color = primary_color
|
|
site_settings.secondary_color = secondary_color
|
|
|
|
try:
|
|
db.session.commit()
|
|
logger.debug("Colors updated successfully in database")
|
|
|
|
# Log the color settings update
|
|
log_event(
|
|
event_type='settings_update',
|
|
details={
|
|
'user_id': current_user.id,
|
|
'user_name': f"{current_user.username} {current_user.last_name}",
|
|
'update_type': 'colors',
|
|
'changes': {
|
|
'primary_color': primary_color,
|
|
'secondary_color': secondary_color
|
|
}
|
|
}
|
|
)
|
|
db.session.commit()
|
|
|
|
flash('Color settings updated successfully!', 'success')
|
|
except Exception as e:
|
|
logger.error(f"Error updating colors: {str(e)}")
|
|
db.session.rollback()
|
|
flash('An error occurred while updating color settings.', 'error')
|
|
|
|
return redirect(url_for('main.settings'))
|
|
|
|
@main_bp.route('/settings/colors/reset', methods=['POST'])
|
|
@login_required
|
|
def reset_colors():
|
|
if not current_user.is_admin:
|
|
flash('Only administrators can update settings.', 'error')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
site_settings = SiteSettings.get_settings()
|
|
site_settings.primary_color = '#16767b' # Default from colors.css
|
|
site_settings.secondary_color = '#741b5f' # Default from colors.css
|
|
|
|
try:
|
|
db.session.commit()
|
|
flash('Colors reset to defaults successfully!', 'success')
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
flash('An error occurred while resetting colors.', 'error')
|
|
|
|
return redirect(url_for('main.settings'))
|
|
|
|
@main_bp.route('/settings/company', methods=['POST'])
|
|
@login_required
|
|
def update_company_settings():
|
|
if not current_user.is_admin:
|
|
flash('Only administrators can update settings.', 'error')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
form = CompanySettingsForm()
|
|
if not form.validate():
|
|
flash('Please check the form for errors.', 'error')
|
|
return redirect(url_for('main.settings'))
|
|
|
|
site_settings = SiteSettings.get_settings()
|
|
|
|
# Handle logo upload
|
|
if form.company_logo.data:
|
|
logo_file = form.company_logo.data
|
|
if logo_file.filename:
|
|
# Delete old logo if it exists
|
|
if site_settings.company_logo:
|
|
old_logo_path = os.path.join('static', 'uploads', 'company_logos', site_settings.company_logo)
|
|
if os.path.exists(old_logo_path):
|
|
os.remove(old_logo_path)
|
|
|
|
# Save new logo
|
|
filename = secure_filename(logo_file.filename)
|
|
# Add timestamp to filename to prevent caching issues
|
|
filename = f"{int(time.time())}_{filename}"
|
|
logo_path = os.path.join('static', 'uploads', 'company_logos', filename)
|
|
logo_file.save(logo_path)
|
|
site_settings.company_logo = filename
|
|
|
|
# Update all company fields
|
|
site_settings.company_name = form.company_name.data
|
|
site_settings.company_website = form.company_website.data
|
|
site_settings.company_email = form.company_email.data
|
|
site_settings.company_phone = form.company_phone.data
|
|
site_settings.company_address = form.company_address.data
|
|
site_settings.company_city = form.company_city.data
|
|
site_settings.company_state = form.company_state.data
|
|
site_settings.company_zip = form.company_zip.data
|
|
site_settings.company_country = form.company_country.data
|
|
site_settings.company_description = form.company_description.data
|
|
site_settings.company_industry = form.company_industry.data
|
|
|
|
try:
|
|
db.session.commit()
|
|
|
|
# Log the company settings update
|
|
log_event(
|
|
event_type='settings_update',
|
|
details={
|
|
'user_id': current_user.id,
|
|
'user_name': f"{current_user.username} {current_user.last_name}",
|
|
'update_type': 'company_settings',
|
|
'changes': {
|
|
'company_name': site_settings.company_name,
|
|
'company_website': site_settings.company_website,
|
|
'company_email': site_settings.company_email,
|
|
'company_phone': site_settings.company_phone,
|
|
'company_address': site_settings.company_address,
|
|
'company_city': site_settings.company_city,
|
|
'company_state': site_settings.company_state,
|
|
'company_zip': site_settings.company_zip,
|
|
'company_country': site_settings.company_country,
|
|
'company_description': site_settings.company_description,
|
|
'company_industry': site_settings.company_industry,
|
|
'logo_updated': bool(form.company_logo.data)
|
|
}
|
|
}
|
|
)
|
|
db.session.commit()
|
|
|
|
flash('Company settings updated successfully!', 'success')
|
|
except Exception as e:
|
|
logger.error(f"Error updating company settings: {str(e)}")
|
|
db.session.rollback()
|
|
flash('An error occurred while updating company settings.', 'error')
|
|
|
|
return redirect(url_for('main.settings'))
|
|
|
|
@main_bp.route('/dynamic-colors.css')
|
|
def dynamic_colors():
|
|
"""Generate dynamic CSS variables based on site settings"""
|
|
logger.info(f"[Dynamic Colors] Request from: {request.referrer}")
|
|
|
|
# Get colors from settings
|
|
site_settings = SiteSettings.get_settings()
|
|
primary_color = site_settings.primary_color
|
|
secondary_color = site_settings.secondary_color
|
|
|
|
logger.info(f"[Dynamic Colors] Current colors - Primary: {primary_color}, Secondary: {secondary_color}")
|
|
|
|
# Convert hex to RGB for opacity calculations
|
|
def hex_to_rgb(hex_color):
|
|
hex_color = hex_color.lstrip('#')
|
|
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
|
|
|
def rgb_to_hex(rgb):
|
|
return '#{:02x}{:02x}{:02x}'.format(rgb[0], rgb[1], rgb[2])
|
|
|
|
def lighten_color(hex_color, amount=0.2):
|
|
rgb = hex_to_rgb(hex_color)
|
|
rgb = tuple(min(255, int(c + (255 - c) * amount)) for c in rgb)
|
|
return rgb_to_hex(rgb)
|
|
|
|
# Calculate derived colors
|
|
primary_rgb = hex_to_rgb(primary_color)
|
|
secondary_rgb = hex_to_rgb(secondary_color)
|
|
|
|
# Lighten colors for hover states
|
|
primary_light = lighten_color(primary_color, 0.2)
|
|
secondary_light = lighten_color(secondary_color, 0.2)
|
|
|
|
# Generate CSS with opacity variables
|
|
css = f"""
|
|
:root {{
|
|
/* Primary Colors */
|
|
--primary-color: {primary_color};
|
|
--primary-light: {primary_light};
|
|
--primary-rgb: {primary_rgb[0]}, {primary_rgb[1]}, {primary_rgb[2]};
|
|
--primary-opacity-8: rgba({primary_rgb[0]}, {primary_rgb[1]}, {primary_rgb[2]}, 0.08);
|
|
--primary-opacity-15: rgba({primary_rgb[0]}, {primary_rgb[1]}, {primary_rgb[2]}, 0.15);
|
|
--primary-opacity-25: rgba({primary_rgb[0]}, {primary_rgb[1]}, {primary_rgb[2]}, 0.25);
|
|
--primary-opacity-50: rgba({primary_rgb[0]}, {primary_rgb[1]}, {primary_rgb[2]}, 0.5);
|
|
|
|
/* Secondary Colors */
|
|
--secondary-color: {secondary_color};
|
|
--secondary-light: {secondary_light};
|
|
--secondary-rgb: {secondary_rgb[0]}, {secondary_rgb[1]}, {secondary_rgb[2]};
|
|
--secondary-opacity-8: rgba({secondary_rgb[0]}, {secondary_rgb[1]}, {secondary_rgb[2]}, 0.08);
|
|
--secondary-opacity-15: rgba({secondary_rgb[0]}, {secondary_rgb[1]}, {secondary_rgb[2]}, 0.15);
|
|
--secondary-opacity-25: rgba({secondary_rgb[0]}, {secondary_rgb[1]}, {secondary_rgb[2]}, 0.25);
|
|
--secondary-opacity-50: rgba({secondary_rgb[0]}, {secondary_rgb[1]}, {secondary_rgb[2]}, 0.5);
|
|
|
|
/* Chart Colors */
|
|
--chart-color-1: {primary_color};
|
|
--chart-color-2: {secondary_color};
|
|
--chart-color-3: {lighten_color(primary_color, 0.4)};
|
|
--chart-color-4: {lighten_color(secondary_color, 0.4)};
|
|
}}
|
|
"""
|
|
|
|
logger.info(f"[Dynamic Colors] Generated CSS with primary color: {primary_color}")
|
|
logger.info(f"[Dynamic Colors] Cache version: {site_settings.updated_at.timestamp()}")
|
|
|
|
return Response(css, mimetype='text/css')
|
|
|
|
@main_bp.route('/settings/events')
|
|
@login_required
|
|
def events():
|
|
if not current_user.is_admin:
|
|
flash('Only administrators can access event logs.', 'error')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
# Get filter parameters
|
|
event_type = request.args.get('event_type')
|
|
date_range = request.args.get('date_range', '7d')
|
|
user_id = request.args.get('user_id')
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 10
|
|
|
|
# Calculate date range
|
|
end_date = datetime.utcnow()
|
|
if date_range == '24h':
|
|
start_date = end_date - timedelta(days=1)
|
|
elif date_range == '7d':
|
|
start_date = end_date - timedelta(days=7)
|
|
elif date_range == '30d':
|
|
start_date = end_date - timedelta(days=30)
|
|
else:
|
|
start_date = None
|
|
|
|
# Build query
|
|
query = Event.query
|
|
|
|
if event_type:
|
|
query = query.filter_by(event_type=event_type)
|
|
if start_date:
|
|
query = query.filter(Event.timestamp >= start_date)
|
|
if user_id:
|
|
query = query.filter_by(user_id=user_id)
|
|
|
|
# Get total count for pagination
|
|
total_events = query.count()
|
|
total_pages = (total_events + per_page - 1) // per_page
|
|
|
|
# Get paginated events
|
|
events = query.order_by(Event.timestamp.desc()).paginate(page=page, per_page=per_page)
|
|
|
|
# Get all users for filter dropdown
|
|
users = User.query.order_by(User.username).all()
|
|
|
|
# Check if this is an AJAX request
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
logger.info(f"Processing AJAX request for events. Found {len(events.items)} events")
|
|
return render_template('settings/tabs/events.html',
|
|
events=events.items,
|
|
total_pages=total_pages,
|
|
current_page=page,
|
|
event_type=event_type,
|
|
date_range=date_range,
|
|
user_id=user_id,
|
|
users=users,
|
|
csrf_token=generate_csrf())
|
|
|
|
# For full page requests, render the full settings page
|
|
site_settings = SiteSettings.get_settings()
|
|
return render_template('settings/settings.html',
|
|
primary_color=site_settings.primary_color,
|
|
secondary_color=site_settings.secondary_color,
|
|
active_tab='events',
|
|
site_settings=site_settings,
|
|
events=events.items,
|
|
total_pages=total_pages,
|
|
current_page=page,
|
|
users=users,
|
|
csrf_token=generate_csrf())
|
|
|
|
@main_bp.route('/api/events')
|
|
@login_required
|
|
def get_events():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
# Get filter parameters
|
|
event_type = request.args.get('event_type')
|
|
date_range = request.args.get('date_range', '7d')
|
|
user_id = request.args.get('user_id')
|
|
page = request.args.get('page', 1, type=int)
|
|
per_page = 10
|
|
|
|
# Calculate date range
|
|
end_date = datetime.utcnow()
|
|
if date_range == '24h':
|
|
start_date = end_date - timedelta(days=1)
|
|
elif date_range == '7d':
|
|
start_date = end_date - timedelta(days=7)
|
|
elif date_range == '30d':
|
|
start_date = end_date - timedelta(days=30)
|
|
else:
|
|
start_date = None
|
|
|
|
# Build query
|
|
query = Event.query
|
|
|
|
if event_type:
|
|
query = query.filter_by(event_type=event_type)
|
|
if start_date:
|
|
query = query.filter(Event.timestamp >= start_date)
|
|
if user_id:
|
|
query = query.filter_by(user_id=user_id)
|
|
|
|
# Get total count for pagination
|
|
total_events = query.count()
|
|
total_pages = (total_events + per_page - 1) // per_page
|
|
|
|
# Get paginated events
|
|
events = query.order_by(Event.timestamp.desc()).paginate(page=page, per_page=per_page)
|
|
|
|
return jsonify({
|
|
'events': [{
|
|
'id': event.id,
|
|
'event_type': event.event_type,
|
|
'timestamp': event.timestamp.isoformat(),
|
|
'user': {
|
|
'id': event.user.id,
|
|
'username': event.user.username,
|
|
'last_name': event.user.last_name
|
|
} if event.user else None,
|
|
'ip_address': event.ip_address,
|
|
'details': event.details
|
|
} for event in events.items],
|
|
'current_page': page,
|
|
'total_pages': total_pages
|
|
})
|
|
|
|
@main_bp.route('/api/events/<int:event_id>')
|
|
@login_required
|
|
def get_event_details(event_id):
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
event = Event.query.get_or_404(event_id)
|
|
|
|
return jsonify({
|
|
'id': event.id,
|
|
'event_type': event.event_type,
|
|
'timestamp': event.timestamp.isoformat(),
|
|
'details': event.details,
|
|
'ip_address': event.ip_address,
|
|
'user_agent': event.user_agent,
|
|
'user': {
|
|
'id': event.user.id,
|
|
'username': event.user.username,
|
|
'last_name': event.user.last_name,
|
|
'email': event.user.email
|
|
} if event.user else None
|
|
})
|
|
|
|
@main_bp.route('/settings/events/download')
|
|
@login_required
|
|
def download_events():
|
|
if not current_user.is_admin:
|
|
flash('Only administrators can download event logs.', 'error')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
# Get filter parameters
|
|
event_type = request.args.get('event_type')
|
|
date_range = request.args.get('date_range', '7d')
|
|
user_id = request.args.get('user_id')
|
|
|
|
# Calculate date range
|
|
end_date = datetime.utcnow()
|
|
if date_range == '24h':
|
|
start_date = end_date - timedelta(days=1)
|
|
elif date_range == '7d':
|
|
start_date = end_date - timedelta(days=7)
|
|
elif date_range == '30d':
|
|
start_date = end_date - timedelta(days=30)
|
|
else:
|
|
start_date = None
|
|
|
|
# Build query
|
|
query = Event.query
|
|
|
|
if event_type:
|
|
query = query.filter_by(event_type=event_type)
|
|
if start_date:
|
|
query = query.filter(Event.timestamp >= start_date)
|
|
if user_id:
|
|
query = query.filter_by(user_id=user_id)
|
|
|
|
# Get all events
|
|
events = query.order_by(Event.timestamp.desc()).all()
|
|
|
|
# Create CSV content
|
|
import csv
|
|
import io
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
# Write header
|
|
writer.writerow(['Timestamp', 'Event Type', 'User', 'Details', 'IP Address'])
|
|
|
|
# Write data
|
|
for event in events:
|
|
user_name = f"{event.user.username} {event.user.last_name}" if event.user else "System"
|
|
writer.writerow([
|
|
event.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
|
event.event_type,
|
|
user_name,
|
|
str(event.details),
|
|
event.ip_address
|
|
])
|
|
|
|
# Create the response
|
|
output.seek(0)
|
|
return Response(
|
|
output,
|
|
mimetype='text/csv',
|
|
headers={
|
|
'Content-Disposition': f'attachment; filename=event_log_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}.csv'
|
|
}
|
|
)
|
|
|
|
@main_bp.route('/settings/save-portainer-connection', methods=['POST'])
|
|
@login_required
|
|
def save_portainer_connection():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
url = data.get('url')
|
|
api_key = data.get('api_key')
|
|
|
|
if not url or not api_key:
|
|
return jsonify({'error': 'Missing required fields'}), 400
|
|
|
|
try:
|
|
# Save Portainer settings
|
|
KeyValueSettings.set_value('portainer_settings', {
|
|
'url': url,
|
|
'api_key': api_key
|
|
})
|
|
|
|
return jsonify({'message': 'Settings saved successfully'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@main_bp.route('/settings/save-nginx-connection', methods=['POST'])
|
|
@login_required
|
|
def save_nginx_connection():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
url = data.get('url')
|
|
username = data.get('username')
|
|
password = data.get('password')
|
|
|
|
if not url or not username or not password:
|
|
return jsonify({'error': 'Missing required fields'}), 400
|
|
|
|
try:
|
|
# Save NGINX Proxy Manager settings
|
|
KeyValueSettings.set_value('nginx_settings', {
|
|
'url': url,
|
|
'username': username,
|
|
'password': password
|
|
})
|
|
|
|
return jsonify({'message': 'Settings saved successfully'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@main_bp.route('/settings/save-git-connection', methods=['POST'])
|
|
@login_required
|
|
def save_git_connection():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
provider = data.get('provider')
|
|
url = data.get('url')
|
|
username = data.get('username')
|
|
token = data.get('token')
|
|
repo = data.get('repo')
|
|
|
|
if not provider or not url or not username or not token or not repo:
|
|
return jsonify({'error': 'Missing required fields'}), 400
|
|
|
|
if provider not in ['gitea', 'gitlab']:
|
|
return jsonify({'error': 'Invalid provider'}), 400
|
|
|
|
try:
|
|
# Save Git settings
|
|
KeyValueSettings.set_value('git_settings', {
|
|
'provider': provider,
|
|
'url': url,
|
|
'username': username,
|
|
'token': token,
|
|
'repo': repo
|
|
})
|
|
|
|
return jsonify({'message': 'Settings saved successfully'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@main_bp.route('/settings/test-git-connection', methods=['POST'])
|
|
@login_required
|
|
def test_git_connection():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
provider = data.get('provider')
|
|
url = data.get('url')
|
|
username = data.get('username')
|
|
token = data.get('token')
|
|
|
|
if not provider or not url or not username or not token:
|
|
return jsonify({'error': 'Missing required fields'}), 400
|
|
|
|
if provider not in ['gitea', 'gitlab']:
|
|
return jsonify({'error': 'Invalid provider'}), 400
|
|
|
|
try:
|
|
if provider == 'gitea':
|
|
# Test Gitea connection with different authentication methods
|
|
headers = {
|
|
'Accept': 'application/json'
|
|
}
|
|
|
|
# First try token in Authorization header
|
|
headers['Authorization'] = f'token {token}'
|
|
|
|
# Try to get user information
|
|
response = requests.get(
|
|
f'{url.rstrip("/")}/api/v1/user',
|
|
headers=headers,
|
|
timeout=5
|
|
)
|
|
|
|
# If that fails, try token as query parameter
|
|
if response.status_code != 200:
|
|
response = requests.get(
|
|
f'{url.rstrip("/")}/api/v1/user?token={token}',
|
|
headers={'Accept': 'application/json'},
|
|
timeout=5
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
return jsonify({'message': 'Connection successful'})
|
|
else:
|
|
return jsonify({'error': f'Connection failed: {response.json().get("message", "Unknown error")}'}), 400
|
|
|
|
elif provider == 'gitlab':
|
|
# Test GitLab connection
|
|
headers = {
|
|
'PRIVATE-TOKEN': token,
|
|
'Accept': 'application/json'
|
|
}
|
|
|
|
# Try to get user information
|
|
response = requests.get(
|
|
f'{url.rstrip("/")}/api/v4/user',
|
|
headers=headers,
|
|
timeout=5
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
return jsonify({'message': 'Connection successful'})
|
|
else:
|
|
return jsonify({'error': f'Connection failed: {response.json().get("message", "Unknown error")}'}), 400
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': f'Connection failed: {str(e)}'}), 400
|
|
|
|
@main_bp.route('/settings/save-cloudflare-connection', methods=['POST'])
|
|
@login_required
|
|
def save_cloudflare_connection():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
email = data.get('email')
|
|
api_key = data.get('api_key')
|
|
zone_id = data.get('zone_id')
|
|
server_ip = data.get('server_ip')
|
|
|
|
if not email or not api_key or not zone_id or not server_ip:
|
|
return jsonify({'error': 'Missing required fields'}), 400
|
|
|
|
try:
|
|
# Save Cloudflare settings
|
|
KeyValueSettings.set_value('cloudflare_settings', {
|
|
'email': email,
|
|
'api_key': api_key,
|
|
'zone_id': zone_id,
|
|
'server_ip': server_ip
|
|
})
|
|
|
|
return jsonify({'message': 'Settings saved successfully'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@main_bp.route('/settings/test-cloudflare-connection', methods=['POST'])
|
|
@login_required
|
|
def test_cloudflare_connection():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
email = data.get('email')
|
|
api_key = data.get('api_key')
|
|
zone_id = data.get('zone_id')
|
|
|
|
if not email or not api_key or not zone_id:
|
|
return jsonify({'error': 'Missing required fields'}), 400
|
|
|
|
try:
|
|
# Test Cloudflare connection
|
|
headers = {
|
|
'X-Auth-Email': email,
|
|
'X-Auth-Key': api_key,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
|
|
# Try to get zone information
|
|
response = requests.get(
|
|
f'https://api.cloudflare.com/client/v4/zones/{zone_id}',
|
|
headers=headers,
|
|
timeout=5
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
return jsonify({'message': 'Connection successful'})
|
|
else:
|
|
return jsonify({'error': f'Connection failed: {response.json().get("errors", [{}])[0].get("message", "Unknown error")}'}), 400
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': f'Connection failed: {str(e)}'}), 400
|
|
|
|
@main_bp.route('/settings/save-stripe-connection', methods=['POST'])
|
|
@login_required
|
|
def save_stripe_connection():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
publishable_key = data.get('publishable_key')
|
|
secret_key = data.get('secret_key')
|
|
webhook_secret = data.get('webhook_secret')
|
|
test_mode = data.get('test_mode', False)
|
|
customer_portal_url = data.get('customer_portal_url', '')
|
|
|
|
if not publishable_key or not secret_key:
|
|
return jsonify({'error': 'Missing required fields'}), 400
|
|
|
|
try:
|
|
# Save Stripe settings
|
|
KeyValueSettings.set_value('stripe_settings', {
|
|
'publishable_key': publishable_key,
|
|
'secret_key': secret_key,
|
|
'webhook_secret': webhook_secret,
|
|
'test_mode': test_mode,
|
|
'customer_portal_url': customer_portal_url
|
|
})
|
|
|
|
return jsonify({'message': 'Settings saved successfully'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@main_bp.route('/settings/test-stripe-connection', methods=['POST'])
|
|
@login_required
|
|
def test_stripe_connection():
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
secret_key = data.get('secret_key')
|
|
|
|
if not secret_key:
|
|
return jsonify({'error': 'Missing required fields'}), 400
|
|
|
|
try:
|
|
# Test Stripe connection by making a simple API call
|
|
import stripe
|
|
stripe.api_key = secret_key
|
|
|
|
# Try to get account information
|
|
account = stripe.Account.retrieve()
|
|
|
|
return jsonify({'message': 'Connection successful'})
|
|
|
|
except stripe.error.AuthenticationError:
|
|
return jsonify({'error': 'Invalid API key'}), 400
|
|
except stripe.error.StripeError as e:
|
|
return jsonify({'error': f'Stripe error: {str(e)}'}), 400
|
|
except Exception as e:
|
|
return jsonify({'error': f'Connection failed: {str(e)}'}), 400
|
|
|
|
@main_bp.route('/instances/launch-progress')
|
|
@login_required
|
|
@require_password_change
|
|
def launch_progress():
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
flash('This page is only available in master instances.', 'error')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
# Get update parameters if this is an update operation
|
|
is_update = request.args.get('update', 'false').lower() == 'true'
|
|
instance_id = request.args.get('instance_id')
|
|
repo_id = request.args.get('repo')
|
|
branch = request.args.get('branch')
|
|
|
|
# Get NGINX settings
|
|
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
|
# Get Portainer settings
|
|
portainer_settings = KeyValueSettings.get_value('portainer_settings')
|
|
# Get Cloudflare settings
|
|
cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings')
|
|
|
|
return render_template('main/launch_progress.html',
|
|
nginx_settings=nginx_settings,
|
|
portainer_settings=portainer_settings,
|
|
cloudflare_settings=cloudflare_settings,
|
|
is_update=is_update,
|
|
instance_id=instance_id,
|
|
repo_id=repo_id,
|
|
branch=branch)
|
|
|
|
@main_bp.route('/api/check-dns', methods=['POST'])
|
|
@login_required
|
|
@require_password_change
|
|
def check_dns():
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
if not data or 'domains' not in data:
|
|
return jsonify({'error': 'No domains provided'}), 400
|
|
|
|
domains = data['domains']
|
|
results = {}
|
|
|
|
for domain in domains:
|
|
try:
|
|
# Try to resolve the domain
|
|
ip_address = socket.gethostbyname(domain)
|
|
results[domain] = {
|
|
'resolved': True,
|
|
'ip': ip_address
|
|
}
|
|
except socket.gaierror:
|
|
results[domain] = {
|
|
'resolved': False,
|
|
'error': 'No DNS record found'
|
|
}
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'results': results
|
|
})
|
|
|
|
@main_bp.route('/api/check-cloudflare-connection', methods=['POST'])
|
|
@login_required
|
|
@require_password_change
|
|
def check_cloudflare_connection():
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
# Get Cloudflare settings
|
|
cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings')
|
|
if not cloudflare_settings:
|
|
return jsonify({'error': 'Cloudflare settings not configured'}), 400
|
|
|
|
try:
|
|
# Test Cloudflare connection by getting zone details
|
|
headers = {
|
|
'X-Auth-Email': cloudflare_settings['email'],
|
|
'X-Auth-Key': cloudflare_settings['api_key'],
|
|
'Content-Type': 'application/json'
|
|
}
|
|
|
|
# Try to get zone information
|
|
response = requests.get(
|
|
f'https://api.cloudflare.com/client/v4/zones/{cloudflare_settings["zone_id"]}',
|
|
headers=headers,
|
|
timeout=10
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
zone_data = response.json()
|
|
if zone_data.get('success'):
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Cloudflare connection successful',
|
|
'zone_name': zone_data['result']['name']
|
|
})
|
|
else:
|
|
return jsonify({'error': f'API error: {zone_data.get("errors", [{}])[0].get("message", "Unknown error")}'}), 400
|
|
else:
|
|
return jsonify({'error': f'Connection failed: HTTP {response.status_code}'}), 400
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': f'Connection failed: {str(e)}'}), 400
|
|
|
|
@main_bp.route('/api/create-dns-records', methods=['POST'])
|
|
@login_required
|
|
@require_password_change
|
|
def create_dns_records():
|
|
"""
|
|
Create or update DNS A records in Cloudflare.
|
|
|
|
Important: DNS records are created with proxied=False to avoid conflicts
|
|
with NGINX Proxy Manager. This ensures direct DNS resolution without
|
|
Cloudflare's proxy layer interfering with the NGINX configuration.
|
|
"""
|
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
data = request.get_json()
|
|
if not data or 'domains' not in data:
|
|
return jsonify({'error': 'No domains provided'}), 400
|
|
|
|
domains = data['domains']
|
|
|
|
# Get Cloudflare settings
|
|
cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings')
|
|
if not cloudflare_settings:
|
|
return jsonify({'error': 'Cloudflare settings not configured'}), 400
|
|
|
|
try:
|
|
headers = {
|
|
'X-Auth-Email': cloudflare_settings['email'],
|
|
'X-Auth-Key': cloudflare_settings['api_key'],
|
|
'Content-Type': 'application/json'
|
|
}
|
|
|
|
results = {}
|
|
for domain in domains:
|
|
# Check if DNS record already exists
|
|
response = requests.get(
|
|
f'https://api.cloudflare.com/client/v4/zones/{cloudflare_settings["zone_id"]}/dns_records',
|
|
headers=headers,
|
|
params={'name': domain},
|
|
timeout=10
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
dns_data = response.json()
|
|
existing_records = dns_data.get('result', [])
|
|
|
|
# Filter for A records
|
|
a_records = [record for record in existing_records if record['type'] == 'A' and record['name'] == domain]
|
|
|
|
if a_records:
|
|
# Update existing A record
|
|
record_id = a_records[0]['id']
|
|
update_data = {
|
|
'type': 'A',
|
|
'name': domain,
|
|
'content': cloudflare_settings['server_ip'],
|
|
'ttl': 1, # Auto TTL
|
|
'proxied': False # DNS only - no Cloudflare proxy to avoid conflicts with NGINX
|
|
}
|
|
|
|
update_response = requests.put(
|
|
f'https://api.cloudflare.com/client/v4/zones/{cloudflare_settings["zone_id"]}/dns_records/{record_id}',
|
|
headers=headers,
|
|
json=update_data,
|
|
timeout=10
|
|
)
|
|
|
|
if update_response.status_code == 200:
|
|
results[domain] = {'status': 'updated', 'message': 'DNS record updated'}
|
|
else:
|
|
results[domain] = {'status': 'error', 'message': f'Failed to update DNS record: {update_response.status_code}'}
|
|
else:
|
|
# Create new A record
|
|
create_data = {
|
|
'type': 'A',
|
|
'name': domain,
|
|
'content': cloudflare_settings['server_ip'],
|
|
'ttl': 1, # Auto TTL
|
|
'proxied': False # DNS only - no Cloudflare proxy to avoid conflicts with NGINX
|
|
}
|
|
|
|
create_response = requests.post(
|
|
f'https://api.cloudflare.com/client/v4/zones/{cloudflare_settings["zone_id"]}/dns_records',
|
|
headers=headers,
|
|
json=create_data,
|
|
timeout=10
|
|
)
|
|
|
|
if create_response.status_code == 200:
|
|
results[domain] = {'status': 'created', 'message': 'DNS record created'}
|
|
else:
|
|
results[domain] = {'status': 'error', 'message': f'Failed to create DNS record: {create_response.status_code}'}
|
|
else:
|
|
results[domain] = {'status': 'error', 'message': f'Failed to check existing records: {response.status_code}'}
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'results': results
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': f'DNS operation failed: {str(e)}'}), 400
|
|
|
|
@main_bp.route('/api/mails/<int:mail_id>')
|
|
@login_required
|
|
def get_mail_details(mail_id):
|
|
if not current_user.is_admin:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
mail = Mail.query.get_or_404(mail_id)
|
|
|
|
return jsonify({
|
|
'id': mail.id,
|
|
'recipient': mail.recipient,
|
|
'subject': mail.subject,
|
|
'body': mail.body,
|
|
'status': mail.status,
|
|
'created_at': mail.created_at.isoformat(),
|
|
'sent_at': mail.sent_at.isoformat() if mail.sent_at else None,
|
|
'template': {
|
|
'id': mail.template.id,
|
|
'name': mail.template.name
|
|
} if mail.template else None
|
|
})
|
|
|
|
@main_bp.route('/development-wiki')
|
|
@login_required
|
|
@require_password_change
|
|
def development_wiki():
|
|
return render_template('wiki/base.html')
|
|
|
|
@main_bp.route('/support-articles')
|
|
@login_required
|
|
@require_password_change
|
|
def support_articles():
|
|
# Check if this is a master instance
|
|
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
|
|
if not is_master:
|
|
flash('This page is only available on the master instance.', 'error')
|
|
return redirect(url_for('main.dashboard'))
|
|
return render_template('admin/support_articles.html')
|
|
|
|
@main_bp.route('/api/version')
|
|
def api_version():
|
|
# Get version information from environment variables
|
|
version = os.getenv('APP_VERSION', 'unknown')
|
|
commit = os.getenv('GIT_COMMIT', 'unknown')
|
|
branch = os.getenv('GIT_BRANCH', 'unknown')
|
|
deployed_at = os.getenv('DEPLOYED_AT', 'unknown')
|
|
|
|
return jsonify({
|
|
'version': version,
|
|
'tag': version,
|
|
'commit': commit,
|
|
'branch': branch,
|
|
'deployed_at': deployed_at
|
|
})
|
|
|
|
@main_bp.route('/api/create-checkout-session', methods=['POST'])
|
|
@csrf.exempt
|
|
def create_checkout_session():
|
|
"""Create a Stripe checkout session for a pricing plan"""
|
|
current_app.logger.info("=== CHECKOUT SESSION REQUEST START ===")
|
|
current_app.logger.info(f"Request method: {request.method}")
|
|
current_app.logger.info(f"Request headers: {dict(request.headers)}")
|
|
current_app.logger.info(f"Request data: {request.get_data()}")
|
|
|
|
try:
|
|
from utils.stripe_utils import create_checkout_session
|
|
|
|
data = request.get_json()
|
|
current_app.logger.info(f"Parsed JSON data: {data}")
|
|
|
|
plan_id = data.get('plan_id')
|
|
billing_cycle = data.get('billing_cycle', 'monthly')
|
|
|
|
current_app.logger.info(f"Plan ID: {plan_id}")
|
|
current_app.logger.info(f"Billing cycle: {billing_cycle}")
|
|
|
|
if not plan_id:
|
|
current_app.logger.error("Plan ID is missing")
|
|
return jsonify({'error': 'Plan ID is required'}), 400
|
|
|
|
if billing_cycle not in ['monthly', 'annual']:
|
|
current_app.logger.error(f"Invalid billing cycle: {billing_cycle}")
|
|
return jsonify({'error': 'Invalid billing cycle'}), 400
|
|
|
|
current_app.logger.info("Calling create_checkout_session function...")
|
|
|
|
# Create checkout session
|
|
checkout_url = create_checkout_session(
|
|
plan_id=plan_id,
|
|
billing_cycle=billing_cycle,
|
|
success_url=url_for('main.checkout_success', _external=True),
|
|
cancel_url=url_for('main.public_home', _external=True)
|
|
)
|
|
|
|
current_app.logger.info(f"Checkout URL created: {checkout_url}")
|
|
|
|
response_data = {
|
|
'success': True,
|
|
'checkout_url': checkout_url
|
|
}
|
|
current_app.logger.info(f"Returning response: {response_data}")
|
|
|
|
return jsonify(response_data)
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error creating checkout session: {str(e)}")
|
|
current_app.logger.error(f"Exception type: {type(e)}")
|
|
import traceback
|
|
current_app.logger.error(f"Traceback: {traceback.format_exc()}")
|
|
return jsonify({'error': str(e)}), 500
|
|
finally:
|
|
current_app.logger.info("=== CHECKOUT SESSION REQUEST END ===")
|
|
|
|
@main_bp.route('/api/checkout-success')
|
|
def checkout_success():
|
|
"""Handle successful checkout"""
|
|
session_id = request.args.get('session_id')
|
|
subscription_info = None
|
|
|
|
# Get Stripe settings for customer portal link
|
|
stripe_settings = KeyValueSettings.get_value('stripe_settings')
|
|
|
|
if session_id:
|
|
try:
|
|
from utils.stripe_utils import get_subscription_info
|
|
from models import Customer, PricingPlan
|
|
|
|
subscription_info = get_subscription_info(session_id)
|
|
|
|
# Log the subscription info for debugging
|
|
current_app.logger.info(f"Checkout success - Session ID: {session_id}")
|
|
current_app.logger.info(f"Subscription info: {subscription_info}")
|
|
|
|
# Save or update customer information
|
|
if 'customer_details' in subscription_info:
|
|
customer_details = subscription_info['customer_details']
|
|
current_app.logger.info(f"Customer details: {customer_details}")
|
|
|
|
# Try to find existing customer by email
|
|
customer = Customer.query.filter_by(email=customer_details.get('email')).first()
|
|
|
|
if customer:
|
|
# Update existing customer
|
|
current_app.logger.info(f"Updating existing customer: {customer.email}")
|
|
else:
|
|
# Create new customer
|
|
customer = Customer()
|
|
current_app.logger.info(f"Creating new customer: {customer_details.get('email')}")
|
|
|
|
# Update customer information
|
|
customer.email = customer_details.get('email')
|
|
customer.name = customer_details.get('name')
|
|
customer.phone = customer_details.get('phone')
|
|
|
|
# Update billing address
|
|
if customer_details.get('address'):
|
|
address = customer_details['address']
|
|
customer.billing_address_line1 = address.get('line1')
|
|
customer.billing_address_line2 = address.get('line2')
|
|
customer.billing_city = address.get('city')
|
|
customer.billing_state = address.get('state')
|
|
customer.billing_postal_code = address.get('postal_code')
|
|
customer.billing_country = address.get('country')
|
|
|
|
# Update shipping address
|
|
if customer_details.get('shipping'):
|
|
shipping = customer_details['shipping']
|
|
customer.shipping_address_line1 = shipping.get('address', {}).get('line1')
|
|
customer.shipping_address_line2 = shipping.get('address', {}).get('line2')
|
|
customer.shipping_city = shipping.get('address', {}).get('city')
|
|
customer.shipping_state = shipping.get('address', {}).get('state')
|
|
customer.shipping_postal_code = shipping.get('address', {}).get('postal_code')
|
|
customer.shipping_country = shipping.get('address', {}).get('country')
|
|
|
|
# Update tax information
|
|
if customer_details.get('tax_ids'):
|
|
tax_ids = customer_details['tax_ids']
|
|
if tax_ids:
|
|
# Store the first tax ID (most common case)
|
|
customer.tax_id_type = tax_ids[0].get('type')
|
|
customer.tax_id_value = tax_ids[0].get('value')
|
|
|
|
# Update Stripe and subscription information
|
|
customer.stripe_customer_id = subscription_info.get('customer_id')
|
|
customer.stripe_subscription_id = subscription_info.get('subscription_id')
|
|
customer.subscription_status = subscription_info.get('status')
|
|
customer.subscription_plan_id = subscription_info.get('plan_id')
|
|
customer.subscription_billing_cycle = subscription_info.get('billing_cycle')
|
|
customer.subscription_current_period_start = subscription_info.get('current_period_start')
|
|
customer.subscription_current_period_end = subscription_info.get('current_period_end')
|
|
|
|
# Save to database
|
|
if not customer.id:
|
|
db.session.add(customer)
|
|
db.session.commit()
|
|
|
|
current_app.logger.info(f"Customer saved successfully: {customer.email}")
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error processing checkout success: {str(e)}")
|
|
flash('Payment successful, but there was an issue processing your subscription. Please contact support.', 'warning')
|
|
|
|
# Render the success page with subscription info and stripe settings
|
|
return render_template('checkout_success.html', subscription_info=subscription_info, stripe_settings=stripe_settings)
|
|
|
|
@main_bp.route('/api/debug/pricing-plans')
|
|
@login_required
|
|
def debug_pricing_plans():
|
|
"""Debug endpoint to check pricing plans"""
|
|
try:
|
|
from models import PricingPlan
|
|
|
|
plans = PricingPlan.query.all()
|
|
plans_data = []
|
|
|
|
for plan in plans:
|
|
plans_data.append({
|
|
'id': plan.id,
|
|
'name': plan.name,
|
|
'monthly_price': plan.monthly_price,
|
|
'annual_price': plan.annual_price,
|
|
'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_custom': plan.is_custom,
|
|
'button_text': plan.button_text
|
|
})
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'plans': plans_data,
|
|
'count': len(plans_data)
|
|
})
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error getting pricing plans: {str(e)}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@main_bp.route('/preview-success')
|
|
def preview_success():
|
|
"""Preview the checkout success page with sample data"""
|
|
# Get Stripe settings for customer portal link
|
|
stripe_settings = KeyValueSettings.get_value('stripe_settings')
|
|
|
|
sample_subscription_info = {
|
|
'plan_name': 'Professional Plan',
|
|
'billing_cycle': 'monthly',
|
|
'status': 'active',
|
|
'amount': 29.99,
|
|
'currency': 'usd'
|
|
}
|
|
return render_template('checkout_success.html', subscription_info=sample_subscription_info, stripe_settings=stripe_settings) |