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//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/') @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//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//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//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/') @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//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//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/') @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//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/', 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//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//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/', 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//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)