Features Added: 🔥 Modern Chat System: - Real-time messaging with modern Tailwind CSS design - Post-linked discussions for adventure sharing - Chat categories (general, technical-support, adventure-planning) - Mobile-responsive interface with gradient backgrounds - JavaScript polling for live message updates 🎯 Comprehensive Admin Panel: - Chat room management with merge capabilities - Password reset system with email templates - User management with admin controls - Chat statistics and analytics dashboard - Room binding to posts and categorization �� Mobile API Integration: - RESTful API endpoints at /api/v1/chat - Session-based authentication for mobile apps - Comprehensive endpoints for rooms, messages, users - Mobile app compatibility (React Native, Flutter) 🛠️ Technical Improvements: - Enhanced database models with ChatRoom categories - Password reset token system with email verification - Template synchronization fixes for Docker deployment - Migration scripts for database schema updates - Improved error handling and validation 🎨 UI/UX Enhancements: - Modern card-based layouts matching app design - Consistent styling across chat and admin interfaces - Mobile-optimized touch interactions - Professional gradient designs and glass morphism effects 📚 Documentation: - Updated README with comprehensive API documentation - Added deployment instructions for Docker (port 8100) - Configuration guide for production environments - Mobile integration examples and endpoints This update transforms the platform into a comprehensive motorcycle adventure community with modern chat capabilities and professional admin management tools.
896 lines
32 KiB
Python
896 lines
32 KiB
Python
|
|
|
|
from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify, current_app
|
|
from flask_mail import Message
|
|
from flask_login import login_required, current_user
|
|
from functools import wraps
|
|
from datetime import datetime, timedelta
|
|
from sqlalchemy import func, desc
|
|
import secrets
|
|
import uuid
|
|
from app.routes.mail import mail
|
|
from app.extensions import db
|
|
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MailSettings, SentEmail, PasswordResetRequest, PasswordResetToken, ChatRoom, ChatMessage
|
|
|
|
admin = Blueprint('admin', __name__, url_prefix='/admin')
|
|
|
|
def admin_required(f):
|
|
"""Decorator to require admin access"""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
if not current_user.is_authenticated or not current_user.is_admin:
|
|
flash('Admin access required.', 'error')
|
|
return redirect(url_for('auth.login'))
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
|
|
@admin.route('/mail-settings', methods=['GET', 'POST'])
|
|
@login_required
|
|
@admin_required
|
|
def mail_settings():
|
|
settings = MailSettings.query.first()
|
|
if request.method == 'POST':
|
|
enabled = bool(request.form.get('enabled'))
|
|
provider = request.form.get('provider')
|
|
server = request.form.get('server')
|
|
port = int(request.form.get('port') or 0)
|
|
use_tls = bool(request.form.get('use_tls'))
|
|
username = request.form.get('username')
|
|
password = request.form.get('password')
|
|
default_sender = request.form.get('default_sender')
|
|
if not settings:
|
|
settings = MailSettings()
|
|
db.session.add(settings)
|
|
settings.enabled = enabled
|
|
settings.provider = provider
|
|
settings.server = server
|
|
settings.port = port
|
|
settings.use_tls = use_tls
|
|
settings.username = username
|
|
settings.password = password
|
|
settings.default_sender = default_sender
|
|
db.session.commit()
|
|
flash('Mail settings updated.', 'success')
|
|
sent_emails = SentEmail.query.order_by(SentEmail.sent_at.desc()).limit(50).all()
|
|
return render_template('admin/mail_settings.html', settings=settings, sent_emails=sent_emails)
|
|
|
|
|
|
## Duplicate imports and Blueprint definitions removed
|
|
|
|
# Password reset token generator (simple, for demonstration)
|
|
def generate_reset_token(user):
|
|
# In production, use itsdangerous or Flask-Security for secure tokens
|
|
return secrets.token_urlsafe(32)
|
|
|
|
# Admin: Send password reset email to user
|
|
@admin.route('/users/<int:user_id>/reset-password', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def reset_user_password(user_id):
|
|
user = User.query.get_or_404(user_id)
|
|
token = generate_reset_token(user)
|
|
# In production, save token to DB or cache, and validate on reset
|
|
reset_url = url_for('auth.reset_password', token=token, _external=True)
|
|
msg = Message(
|
|
subject="Password Reset Request",
|
|
recipients=[user.email],
|
|
body=f"Hello {user.nickname},\n\nAn admin has requested a password reset for your account. Click the link below to reset your password:\n{reset_url}\n\nIf you did not request this, please ignore this email."
|
|
)
|
|
try:
|
|
mail.send(msg)
|
|
flash(f"Password reset email sent to {user.email}.", "success")
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error sending reset email: {e}")
|
|
flash(f"Failed to send password reset email: {e}", "danger")
|
|
return redirect(url_for('admin.user_detail', user_id=user.id))
|
|
|
|
def admin_required(f):
|
|
"""Decorator to require admin access"""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
if not current_user.is_authenticated or not current_user.is_admin:
|
|
flash('Admin access required.', 'error')
|
|
return redirect(url_for('auth.login'))
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
|
|
@admin.route('/')
|
|
@login_required
|
|
@admin_required
|
|
def dashboard():
|
|
"""Admin dashboard with site statistics"""
|
|
# Get basic statistics
|
|
total_users = User.query.count()
|
|
total_posts = Post.query.count()
|
|
published_posts = Post.query.filter_by(published=True).count()
|
|
pending_posts = Post.query.filter_by(published=False).count()
|
|
total_comments = Comment.query.count()
|
|
total_likes = Like.query.count()
|
|
|
|
# Active users (users who created posts or comments in the last 30 days)
|
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
|
active_users = db.session.query(User.id).filter(
|
|
db.or_(
|
|
User.posts.any(Post.created_at >= thirty_days_ago),
|
|
User.comments.any(Comment.created_at >= thirty_days_ago)
|
|
)
|
|
).distinct().count()
|
|
|
|
# Recent activity
|
|
recent_posts = Post.query.order_by(Post.created_at.desc()).limit(5).all()
|
|
recent_users = User.query.order_by(User.created_at.desc()).limit(5).all()
|
|
|
|
# Page views statistics
|
|
today = datetime.utcnow().date()
|
|
yesterday = today - timedelta(days=1)
|
|
week_ago = today - timedelta(days=7)
|
|
|
|
views_today = PageView.query.filter(
|
|
func.date(PageView.created_at) == today
|
|
).count()
|
|
|
|
views_yesterday = PageView.query.filter(
|
|
func.date(PageView.created_at) == yesterday
|
|
).count()
|
|
|
|
views_this_week = PageView.query.filter(
|
|
PageView.created_at >= week_ago
|
|
).count()
|
|
|
|
# Most viewed posts
|
|
most_viewed_posts = db.session.query(
|
|
Post.id, Post.title, func.count(PageView.id).label('view_count')
|
|
).join(PageView, Post.id == PageView.post_id)\
|
|
.group_by(Post.id, Post.title)\
|
|
.order_by(desc('view_count'))\
|
|
.limit(10).all()
|
|
|
|
# Most viewed pages
|
|
most_viewed_pages = db.session.query(
|
|
PageView.path, func.count(PageView.id).label('view_count')
|
|
).group_by(PageView.path)\
|
|
.order_by(desc('view_count'))\
|
|
.limit(10).all()
|
|
|
|
# Password reset statistics
|
|
pending_password_requests = PasswordResetRequest.query.filter_by(status='pending').count()
|
|
active_reset_tokens = PasswordResetToken.query.filter_by(is_used=False).filter(
|
|
PasswordResetToken.expires_at > datetime.utcnow()
|
|
).count()
|
|
|
|
# Chat statistics
|
|
total_chat_rooms = ChatRoom.query.count()
|
|
linked_chat_rooms = ChatRoom.query.filter(ChatRoom.related_post_id.isnot(None)).count()
|
|
active_chat_rooms = db.session.query(ChatRoom.id).filter(
|
|
ChatRoom.messages.any(ChatMessage.created_at >= thirty_days_ago)
|
|
).distinct().count()
|
|
recent_chat_messages = ChatMessage.query.filter(
|
|
ChatMessage.created_at >= thirty_days_ago
|
|
).count()
|
|
|
|
return render_template('admin/dashboard.html',
|
|
total_users=total_users,
|
|
total_posts=total_posts,
|
|
published_posts=published_posts,
|
|
pending_posts=pending_posts,
|
|
total_comments=total_comments,
|
|
total_likes=total_likes,
|
|
active_users=active_users,
|
|
recent_posts=recent_posts,
|
|
recent_users=recent_users,
|
|
views_today=views_today,
|
|
views_yesterday=views_yesterday,
|
|
views_this_week=views_this_week,
|
|
most_viewed_posts=most_viewed_posts,
|
|
most_viewed_pages=most_viewed_pages,
|
|
pending_password_requests=pending_password_requests,
|
|
active_reset_tokens=active_reset_tokens,
|
|
total_chat_rooms=total_chat_rooms,
|
|
linked_chat_rooms=linked_chat_rooms,
|
|
active_chat_rooms=active_chat_rooms,
|
|
recent_chat_messages=recent_chat_messages)
|
|
|
|
@admin.route('/posts')
|
|
@login_required
|
|
@admin_required
|
|
def posts():
|
|
"""Admin post management - review posts"""
|
|
page = request.args.get('page', 1, type=int)
|
|
status = request.args.get('status', 'all') # pending, published, all
|
|
|
|
query = Post.query
|
|
|
|
if status == 'pending':
|
|
query = query.filter_by(published=False)
|
|
elif status == 'published':
|
|
query = query.filter_by(published=True)
|
|
# 'all' shows all posts
|
|
|
|
posts = query.order_by(Post.created_at.desc()).paginate(
|
|
page=page, per_page=20, error_out=False
|
|
)
|
|
|
|
return render_template('admin/posts.html', posts=posts, status=status)
|
|
|
|
@admin.route('/posts/<int:post_id>')
|
|
@login_required
|
|
@admin_required
|
|
def post_detail(post_id):
|
|
"""View post details for review"""
|
|
post = Post.query.get_or_404(post_id)
|
|
return render_template('admin/post_detail.html', post=post)
|
|
|
|
@admin.route('/posts/<int:post_id>/publish', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def publish_post(post_id):
|
|
"""Publish a post and create map routes if GPX files exist"""
|
|
post = Post.query.get_or_404(post_id)
|
|
post.published = True
|
|
post.updated_at = datetime.utcnow()
|
|
|
|
try:
|
|
db.session.commit()
|
|
|
|
# Create map routes for GPX files when post is published
|
|
from app.utils.gpx_processor import process_post_approval
|
|
success = process_post_approval(post_id)
|
|
|
|
if success:
|
|
flash(f'Post "{post.title}" has been published and map routes created.', 'success')
|
|
else:
|
|
flash(f'Post "{post.title}" has been published. No GPX files found or error creating map routes.', 'warning')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
current_app.logger.error(f'Error publishing post {post_id}: {str(e)}')
|
|
flash(f'Error publishing post: {str(e)}', 'error')
|
|
|
|
return redirect(url_for('admin.posts'))
|
|
|
|
@admin.route('/posts/<int:post_id>/unpublish', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def unpublish_post(post_id):
|
|
"""Unpublish a post and remove from map"""
|
|
post = Post.query.get_or_404(post_id)
|
|
post.published = False
|
|
post.updated_at = datetime.utcnow()
|
|
|
|
try:
|
|
# Note: We keep the MapRoute data for potential re-publishing
|
|
# Only the API will filter by published status
|
|
db.session.commit()
|
|
flash(f'Post "{post.title}" has been unpublished.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
current_app.logger.error(f'Error unpublishing post {post_id}: {str(e)}')
|
|
flash(f'Error unpublishing post: {str(e)}', 'error')
|
|
|
|
return redirect(url_for('admin.posts'))
|
|
|
|
@admin.route('/posts/<int:post_id>/delete', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def delete_post(post_id):
|
|
"""Delete a post"""
|
|
current_app.logger.info(f'Admin {current_user.id} attempting to delete post {post_id}')
|
|
|
|
# Get all posts before deletion for debugging
|
|
all_posts_before = Post.query.all()
|
|
current_app.logger.info(f'Posts before deletion: {[p.id for p in all_posts_before]}')
|
|
|
|
post = Post.query.get_or_404(post_id)
|
|
title = post.title
|
|
|
|
current_app.logger.info(f'Found post to delete: ID={post.id}, Title="{title}"')
|
|
|
|
|
|
try:
|
|
# Delete associated map route if exists
|
|
from app.models import MapRoute
|
|
map_route = MapRoute.query.filter_by(post_id=post.id).first()
|
|
if map_route:
|
|
db.session.delete(map_route)
|
|
current_app.logger.info(f'Deleted MapRoute for post {post.id}')
|
|
|
|
# Delete associated files and records
|
|
db.session.delete(post)
|
|
db.session.commit()
|
|
|
|
# Clean up orphaned media folders
|
|
from app.utils.clean_orphan_media import clean_orphan_post_media
|
|
clean_orphan_post_media()
|
|
|
|
# Check posts after deletion
|
|
all_posts_after = Post.query.all()
|
|
current_app.logger.info(f'Posts after deletion: {[p.id for p in all_posts_after]}')
|
|
|
|
current_app.logger.info(f'Successfully deleted post {post_id}: "{title}"')
|
|
flash(f'Post "{title}" has been deleted.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
current_app.logger.error(f'Error deleting post {post_id}: {str(e)}')
|
|
flash(f'Error deleting post: {str(e)}', 'error')
|
|
|
|
return redirect(url_for('admin.posts'))
|
|
|
|
@admin.route('/users')
|
|
@login_required
|
|
@admin_required
|
|
def users():
|
|
"""Admin user management"""
|
|
page = request.args.get('page', 1, type=int)
|
|
|
|
users = User.query.order_by(User.created_at.desc()).paginate(
|
|
page=page, per_page=50, error_out=False
|
|
)
|
|
|
|
return render_template('admin/users.html', users=users)
|
|
|
|
@admin.route('/users/<int:user_id>')
|
|
@login_required
|
|
@admin_required
|
|
def user_detail(user_id):
|
|
"""View user details"""
|
|
user = User.query.get_or_404(user_id)
|
|
user_posts = Post.query.filter_by(author_id=user_id).order_by(Post.created_at.desc()).all()
|
|
user_comments = Comment.query.filter_by(author_id=user_id).order_by(Comment.created_at.desc()).all()
|
|
|
|
return render_template('admin/user_detail.html',
|
|
user=user,
|
|
user_posts=user_posts,
|
|
user_comments=user_comments)
|
|
|
|
@admin.route('/users/<int:user_id>/toggle-status', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def toggle_user_status(user_id):
|
|
"""Toggle user active/inactive status"""
|
|
user = User.query.get_or_404(user_id)
|
|
|
|
# Prevent self-modification if current user is admin
|
|
if user.is_admin and user.id == current_user.id:
|
|
return jsonify({'success': False, 'error': 'Cannot deactivate your own admin account'})
|
|
|
|
# Toggle status
|
|
user.is_active = not user.is_active
|
|
status = "activated" if user.is_active else "deactivated"
|
|
|
|
try:
|
|
db.session.commit()
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'User {user.nickname} has been {status}',
|
|
'is_active': user.is_active
|
|
})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'error': f'Failed to update user status: {str(e)}'})
|
|
|
|
@admin.route('/users/<int:user_id>/delete', methods=['DELETE', 'POST'])
|
|
@login_required
|
|
@admin_required
|
|
def delete_user(user_id):
|
|
"""Delete user and transfer their posts to admin"""
|
|
user = User.query.get_or_404(user_id)
|
|
|
|
# Prevent self-deletion
|
|
if user.id == current_user.id:
|
|
return jsonify({'success': False, 'error': 'Cannot delete your own account'})
|
|
|
|
# Prevent deletion of other admins
|
|
if user.is_admin:
|
|
return jsonify({'success': False, 'error': 'Cannot delete admin accounts'})
|
|
|
|
try:
|
|
# Transfer all user's posts to current admin
|
|
user_posts = Post.query.filter_by(author_id=user.id).all()
|
|
for post in user_posts:
|
|
post.author_id = current_user.id
|
|
|
|
# Transfer all user's comments to current admin
|
|
user_comments = Comment.query.filter_by(author_id=user.id).all()
|
|
for comment in user_comments:
|
|
comment.author_id = current_user.id
|
|
|
|
# Delete user's likes (they will be orphaned)
|
|
Like.query.filter_by(user_id=user.id).delete()
|
|
|
|
# Delete user's page views
|
|
PageView.query.filter_by(user_id=user.id).delete()
|
|
|
|
username = user.nickname
|
|
user_posts_count = len(user_posts)
|
|
|
|
# Delete the user
|
|
db.session.delete(user)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'User {username} has been deleted. {user_posts_count} posts transferred to {current_user.nickname}.'
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'error': f'Failed to delete user: {str(e)}'})
|
|
|
|
@admin.route('/analytics')
|
|
@login_required
|
|
@admin_required
|
|
def analytics():
|
|
"""Detailed analytics page"""
|
|
# Get date range from query params
|
|
days = request.args.get('days', 30, type=int)
|
|
end_date = datetime.utcnow().date()
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
# Daily views for the chart
|
|
daily_views = db.session.query(
|
|
func.date(PageView.created_at).label('date'),
|
|
func.count(PageView.id).label('views')
|
|
).filter(
|
|
func.date(PageView.created_at) >= start_date
|
|
).group_by(func.date(PageView.created_at))\
|
|
.order_by('date').all()
|
|
|
|
# Top posts by views
|
|
top_posts = db.session.query(
|
|
Post.id, Post.title, Post.author, func.count(PageView.id).label('view_count')
|
|
).join(PageView, Post.id == PageView.post_id)\
|
|
.filter(PageView.created_at >= start_date)\
|
|
.group_by(Post.id, Post.title)\
|
|
.order_by(desc('view_count'))\
|
|
.limit(20).all()
|
|
|
|
# User activity - separate queries to avoid cartesian product
|
|
user_posts = db.session.query(
|
|
User.id,
|
|
User.nickname,
|
|
func.count(Post.id).label('post_count')
|
|
).outerjoin(Post, User.id == Post.author_id)\
|
|
.group_by(User.id, User.nickname).subquery()
|
|
|
|
user_comments = db.session.query(
|
|
User.id,
|
|
func.count(Comment.id).label('comment_count')
|
|
).outerjoin(Comment, User.id == Comment.author_id)\
|
|
.group_by(User.id).subquery()
|
|
|
|
user_activity = db.session.query(
|
|
user_posts.c.nickname,
|
|
user_posts.c.post_count,
|
|
func.coalesce(user_comments.c.comment_count, 0).label('comment_count')
|
|
).outerjoin(user_comments, user_posts.c.id == user_comments.c.id)\
|
|
.order_by(desc(user_posts.c.post_count))\
|
|
.limit(20).all()
|
|
|
|
# Get overall statistics
|
|
total_views = PageView.query.count()
|
|
unique_visitors = db.session.query(PageView.ip_address).distinct().count()
|
|
today_views = PageView.query.filter(
|
|
func.date(PageView.created_at) == datetime.utcnow().date()
|
|
).count()
|
|
week_start = datetime.utcnow().date() - timedelta(days=7)
|
|
week_views = PageView.query.filter(
|
|
func.date(PageView.created_at) >= week_start
|
|
).count()
|
|
|
|
# Popular pages
|
|
popular_pages = db.session.query(
|
|
PageView.path,
|
|
func.count(PageView.id).label('view_count')
|
|
).group_by(PageView.path)\
|
|
.order_by(desc('view_count'))\
|
|
.limit(10).all()
|
|
|
|
# Recent activity
|
|
recent_views = PageView.query.join(User, PageView.user_id == User.id, isouter=True)\
|
|
.order_by(desc(PageView.created_at))\
|
|
.limit(20).all()
|
|
|
|
# Browser statistics (extract from user agent)
|
|
browser_stats = db.session.query(
|
|
func.substr(PageView.user_agent, 1, 50).label('browser'),
|
|
func.count(PageView.id).label('view_count')
|
|
).filter(PageView.user_agent.isnot(None))\
|
|
.group_by(func.substr(PageView.user_agent, 1, 50))\
|
|
.order_by(desc('view_count'))\
|
|
.limit(10).all()
|
|
|
|
return render_template('admin/analytics.html',
|
|
total_views=total_views,
|
|
unique_visitors=unique_visitors,
|
|
today_views=today_views,
|
|
week_views=week_views,
|
|
popular_pages=popular_pages,
|
|
recent_views=recent_views,
|
|
browser_stats=browser_stats,
|
|
daily_views=daily_views,
|
|
top_posts=top_posts,
|
|
user_activity=user_activity,
|
|
days=days)
|
|
|
|
# API endpoints for AJAX requests
|
|
@admin.route('/api/quick-stats')
|
|
@login_required
|
|
@admin_required
|
|
def api_quick_stats():
|
|
"""Quick stats for dashboard widgets"""
|
|
pending_count = Post.query.filter_by(published=False).count()
|
|
today_views = PageView.query.filter(
|
|
func.date(PageView.created_at) == datetime.utcnow().date()
|
|
).count()
|
|
|
|
return jsonify({
|
|
'pending_posts': pending_count,
|
|
'today_views': today_views
|
|
})
|
|
|
|
|
|
# Password Reset Management Routes
|
|
|
|
@admin.route('/password-reset-requests')
|
|
@login_required
|
|
@admin_required
|
|
def password_reset_requests():
|
|
"""View all password reset requests"""
|
|
page = request.args.get('page', 1, type=int)
|
|
status = request.args.get('status', 'all')
|
|
|
|
query = PasswordResetRequest.query
|
|
|
|
if status != 'all':
|
|
query = query.filter_by(status=status)
|
|
|
|
requests = query.order_by(PasswordResetRequest.created_at.desc()).paginate(
|
|
page=page, per_page=20, error_out=False
|
|
)
|
|
|
|
return render_template('admin/password_reset_requests.html',
|
|
requests=requests, status=status)
|
|
|
|
|
|
@admin.route('/password-reset-requests/<int:request_id>')
|
|
@login_required
|
|
@admin_required
|
|
def password_reset_request_detail(request_id):
|
|
"""View individual password reset request details"""
|
|
reset_request = PasswordResetRequest.query.get_or_404(request_id)
|
|
|
|
# Get associated tokens
|
|
tokens = PasswordResetToken.query.filter_by(request_id=request_id).order_by(
|
|
PasswordResetToken.created_at.desc()
|
|
).all()
|
|
|
|
return render_template('admin/password_reset_request_detail.html',
|
|
request=reset_request, tokens=tokens)
|
|
|
|
|
|
@admin.route('/password-reset-requests/<int:request_id>/generate-token', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def generate_password_reset_token(request_id):
|
|
"""Generate a new password reset token for a request"""
|
|
reset_request = PasswordResetRequest.query.get_or_404(request_id)
|
|
|
|
# Create token
|
|
token = str(uuid.uuid4())
|
|
expires_at = datetime.utcnow() + timedelta(hours=24) # 24 hour expiry
|
|
|
|
reset_token = PasswordResetToken(
|
|
token=token,
|
|
request_id=request_id,
|
|
user_id=reset_request.user.id,
|
|
created_by_admin_id=current_user.id,
|
|
expires_at=expires_at
|
|
)
|
|
|
|
db.session.add(reset_token)
|
|
reset_request.status = 'token_generated'
|
|
reset_request.updated_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
flash('Password reset token generated successfully!', 'success')
|
|
return redirect(url_for('admin.password_reset_token_template', token_id=reset_token.id))
|
|
|
|
|
|
@admin.route('/password-reset-tokens')
|
|
@login_required
|
|
@admin_required
|
|
def password_reset_tokens():
|
|
"""View all password reset tokens"""
|
|
page = request.args.get('page', 1, type=int)
|
|
status = request.args.get('status', 'all')
|
|
|
|
query = PasswordResetToken.query.join(User).order_by(PasswordResetToken.created_at.desc())
|
|
|
|
if status == 'active':
|
|
query = query.filter_by(is_used=False).filter(PasswordResetToken.expires_at > datetime.utcnow())
|
|
elif status == 'used':
|
|
query = query.filter_by(is_used=True)
|
|
elif status == 'expired':
|
|
query = query.filter(PasswordResetToken.expires_at <= datetime.utcnow(), PasswordResetToken.is_used == False)
|
|
|
|
tokens = query.paginate(page=page, per_page=20, error_out=False)
|
|
|
|
# Get counts for statistics
|
|
active_count = PasswordResetToken.query.filter_by(is_used=False).filter(
|
|
PasswordResetToken.expires_at > datetime.utcnow()
|
|
).count()
|
|
used_count = PasswordResetToken.query.filter_by(is_used=True).count()
|
|
expired_count = PasswordResetToken.query.filter(
|
|
PasswordResetToken.expires_at <= datetime.utcnow(),
|
|
PasswordResetToken.is_used == False
|
|
).count()
|
|
|
|
return render_template('admin/password_reset_tokens.html',
|
|
tokens=tokens, status=status,
|
|
active_count=active_count, used_count=used_count, expired_count=expired_count)
|
|
|
|
|
|
@admin.route('/manage-chats')
|
|
@login_required
|
|
@admin_required
|
|
def manage_chats():
|
|
"""Admin chat room management"""
|
|
page = request.args.get('page', 1, type=int)
|
|
category = request.args.get('category', '')
|
|
status = request.args.get('status', '')
|
|
search = request.args.get('search', '')
|
|
|
|
# Base query with message count
|
|
query = db.session.query(
|
|
ChatRoom,
|
|
func.count(ChatMessage.id).label('message_count'),
|
|
func.max(ChatMessage.created_at).label('last_activity')
|
|
).outerjoin(ChatMessage).group_by(ChatRoom.id)
|
|
|
|
# Apply filters
|
|
if category:
|
|
query = query.filter(ChatRoom.category == category)
|
|
|
|
if status == 'linked':
|
|
query = query.filter(ChatRoom.related_post_id.isnot(None))
|
|
elif status == 'unlinked':
|
|
query = query.filter(ChatRoom.related_post_id.is_(None))
|
|
elif status == 'active':
|
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
|
query = query.having(func.max(ChatMessage.created_at) >= thirty_days_ago)
|
|
elif status == 'inactive':
|
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
|
query = query.having(
|
|
db.or_(
|
|
func.max(ChatMessage.created_at) < thirty_days_ago,
|
|
func.max(ChatMessage.created_at).is_(None)
|
|
)
|
|
)
|
|
|
|
if search:
|
|
query = query.filter(
|
|
db.or_(
|
|
ChatRoom.name.contains(search),
|
|
ChatRoom.description.contains(search)
|
|
)
|
|
)
|
|
|
|
# Order by last activity
|
|
query = query.order_by(func.max(ChatMessage.created_at).desc().nullslast())
|
|
|
|
# Paginate
|
|
results = query.paginate(page=page, per_page=20, error_out=False)
|
|
|
|
# Process results to add message count and last activity to room objects
|
|
chat_rooms = []
|
|
for room, message_count, last_activity in results.items:
|
|
room.message_count = message_count
|
|
room.last_activity = last_activity
|
|
chat_rooms.append(room)
|
|
|
|
# Get statistics
|
|
total_rooms = ChatRoom.query.count()
|
|
linked_rooms = ChatRoom.query.filter(ChatRoom.related_post_id.isnot(None)).count()
|
|
|
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
|
active_today = db.session.query(ChatRoom.id).filter(
|
|
ChatRoom.messages.any(
|
|
func.date(ChatMessage.created_at) == datetime.utcnow().date()
|
|
)
|
|
).distinct().count()
|
|
|
|
total_messages = ChatMessage.query.count()
|
|
|
|
# Get available posts for linking
|
|
available_posts = Post.query.filter_by(published=True).order_by(Post.title).all()
|
|
|
|
# Create pagination object with processed rooms
|
|
class PaginationWrapper:
|
|
def __init__(self, original_pagination, items):
|
|
self.page = original_pagination.page
|
|
self.per_page = original_pagination.per_page
|
|
self.total = original_pagination.total
|
|
self.pages = original_pagination.pages
|
|
self.has_prev = original_pagination.has_prev
|
|
self.prev_num = original_pagination.prev_num
|
|
self.has_next = original_pagination.has_next
|
|
self.next_num = original_pagination.next_num
|
|
self.items = items
|
|
|
|
def iter_pages(self):
|
|
return range(1, self.pages + 1)
|
|
|
|
pagination = PaginationWrapper(results, chat_rooms)
|
|
|
|
return render_template('admin/manage_chats.html',
|
|
chat_rooms=chat_rooms,
|
|
pagination=pagination,
|
|
total_rooms=total_rooms,
|
|
linked_rooms=linked_rooms,
|
|
active_today=active_today,
|
|
total_messages=total_messages,
|
|
available_posts=available_posts)
|
|
|
|
|
|
@admin.route('/api/chat-rooms')
|
|
@login_required
|
|
@admin_required
|
|
def api_chat_rooms():
|
|
"""API endpoint for chat rooms (for AJAX calls)"""
|
|
exclude_id = request.args.get('exclude', type=int)
|
|
|
|
query = ChatRoom.query
|
|
if exclude_id:
|
|
query = query.filter(ChatRoom.id != exclude_id)
|
|
|
|
rooms = query.all()
|
|
|
|
# Get message counts
|
|
room_data = []
|
|
for room in rooms:
|
|
message_count = ChatMessage.query.filter_by(room_id=room.id).count()
|
|
room_data.append({
|
|
'id': room.id,
|
|
'name': room.name,
|
|
'description': room.description,
|
|
'category': room.category,
|
|
'message_count': message_count,
|
|
'created_at': room.created_at.isoformat() if room.created_at else None
|
|
})
|
|
|
|
return jsonify({'success': True, 'rooms': room_data})
|
|
|
|
|
|
@admin.route('/api/chat-rooms/<int:room_id>', methods=['PUT'])
|
|
@login_required
|
|
@admin_required
|
|
def api_update_chat_room(room_id):
|
|
"""Update chat room details"""
|
|
room = ChatRoom.query.get_or_404(room_id)
|
|
|
|
try:
|
|
data = request.get_json()
|
|
|
|
room.name = data.get('name', room.name)
|
|
room.description = data.get('description', room.description)
|
|
room.category = data.get('category', room.category)
|
|
|
|
# Handle post linking
|
|
related_post_id = data.get('related_post_id')
|
|
if related_post_id:
|
|
post = Post.query.get(related_post_id)
|
|
if post:
|
|
room.related_post_id = related_post_id
|
|
else:
|
|
return jsonify({'success': False, 'error': 'Post not found'})
|
|
else:
|
|
room.related_post_id = None
|
|
|
|
db.session.commit()
|
|
return jsonify({'success': True, 'message': 'Room updated successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
|
|
@admin.route('/api/chat-rooms/<int:room_id>/link-post', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def api_link_chat_room_to_post(room_id):
|
|
"""Link chat room to a post"""
|
|
room = ChatRoom.query.get_or_404(room_id)
|
|
|
|
try:
|
|
data = request.get_json()
|
|
post_id = data.get('post_id')
|
|
|
|
if post_id:
|
|
post = Post.query.get_or_404(post_id)
|
|
room.related_post_id = post_id
|
|
else:
|
|
room.related_post_id = None
|
|
|
|
db.session.commit()
|
|
return jsonify({'success': True, 'message': 'Room linked to post successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
|
|
@admin.route('/api/chat-rooms/<int:source_room_id>/merge', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def api_merge_chat_rooms(source_room_id):
|
|
"""Merge source room into target room"""
|
|
source_room = ChatRoom.query.get_or_404(source_room_id)
|
|
|
|
try:
|
|
data = request.get_json()
|
|
target_room_id = data.get('target_room_id')
|
|
target_room = ChatRoom.query.get_or_404(target_room_id)
|
|
|
|
# Move all messages from source to target room
|
|
messages = ChatMessage.query.filter_by(room_id=source_room_id).all()
|
|
for message in messages:
|
|
message.room_id = target_room_id
|
|
|
|
# Add system message about the merge
|
|
merge_message = ChatMessage(
|
|
room_id=target_room_id,
|
|
sender_id=current_user.id,
|
|
content=f"Room '{source_room.name}' has been merged into this room by admin {current_user.nickname}",
|
|
message_type='system',
|
|
is_system_message=True
|
|
)
|
|
db.session.add(merge_message)
|
|
|
|
# Delete the source room
|
|
db.session.delete(source_room)
|
|
|
|
db.session.commit()
|
|
return jsonify({'success': True, 'message': 'Rooms merged successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
|
|
@admin.route('/api/chat-rooms/<int:room_id>', methods=['DELETE'])
|
|
@login_required
|
|
@admin_required
|
|
def api_delete_chat_room(room_id):
|
|
"""Delete chat room and all its messages"""
|
|
room = ChatRoom.query.get_or_404(room_id)
|
|
|
|
try:
|
|
# Delete all messages first
|
|
ChatMessage.query.filter_by(room_id=room_id).delete()
|
|
|
|
# Delete the room
|
|
db.session.delete(room)
|
|
|
|
db.session.commit()
|
|
return jsonify({'success': True, 'message': 'Room deleted successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
|
|
@admin.route('/password-reset-tokens/<int:token_id>/template')
|
|
@login_required
|
|
@admin_required
|
|
def password_reset_token_template(token_id):
|
|
"""Display email template for password reset token"""
|
|
token = PasswordResetToken.query.get_or_404(token_id)
|
|
|
|
# Generate the reset URL
|
|
reset_url = url_for('auth.reset_password_with_token', token=token.token, _external=True)
|
|
|
|
return render_template('admin/password_reset_email_template.html',
|
|
token=token, reset_url=reset_url)
|