Major Feature Update: Modern Chat System & Admin Management

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.
This commit is contained in:
ske087
2025-08-10 00:22:33 +03:00
parent 1661f5f588
commit 30bd4c62ad
20 changed files with 3649 additions and 349 deletions

View File

@@ -7,9 +7,10 @@ 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
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MailSettings, SentEmail, PasswordResetRequest, PasswordResetToken, ChatRoom, ChatMessage
admin = Blueprint('admin', __name__, url_prefix='/admin')
@@ -151,6 +152,22 @@ def dashboard():
.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,
@@ -165,7 +182,13 @@ def dashboard():
views_yesterday=views_yesterday,
views_this_week=views_this_week,
most_viewed_posts=most_viewed_posts,
most_viewed_pages=most_viewed_pages)
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
@@ -506,3 +529,367 @@ def api_quick_stats():
'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)

View File

@@ -1,7 +1,7 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash
from app.models import User, db
from app.models import User, db, PasswordResetToken
from app.routes.reset_password import RequestResetForm, ResetPasswordForm
from flask_mail import Message
from app.routes.mail import mail
@@ -161,26 +161,6 @@ def forgot_password():
flash('Your password reset request has been sent to administrators. They will contact you soon to help reset your password.', 'info')
return redirect(url_for('auth.login'))
return render_template('auth/forgot_password.html', form=form)
# Password reset route
@auth.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.index'))
email = verify_reset_token(token)
if not email:
flash('Invalid or expired reset link.', 'danger')
return redirect(url_for('auth.forgot_password'))
user = User.query.filter_by(email=email).first()
if not user:
flash('Invalid or expired reset link.', 'danger')
return redirect(url_for('auth.forgot_password'))
form = ResetPasswordForm()
if form.validate_on_submit():
user.set_password(form.password.data)
db.session.commit()
flash('Your password has been reset. You can now log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', form=form)
@auth.route('/change-password', methods=['POST'])
@login_required
@@ -235,3 +215,59 @@ def is_valid_password(password):
if not re.search(r'\d', password):
return False
return True
class ResetPasswordWithTokenForm(FlaskForm):
password = PasswordField('New Password', validators=[DataRequired(), Length(min=8)])
password2 = PasswordField('Confirm New Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Reset Password')
@auth.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password_with_token(token):
"""Reset password using admin-generated token"""
# Find the token in database
reset_token = PasswordResetToken.query.filter_by(token=token).first()
if not reset_token:
flash('Invalid or expired reset link.', 'error')
return redirect(url_for('auth.login'))
# Check if token is expired
if reset_token.is_expired:
flash('This reset link has expired. Please request a new one.', 'error')
return redirect(url_for('auth.login'))
# Check if token is already used
if reset_token.is_used:
flash('This reset link has already been used.', 'error')
return redirect(url_for('auth.login'))
form = ResetPasswordWithTokenForm()
if form.validate_on_submit():
user = reset_token.user
# Validate password strength
if not is_valid_password(form.password.data):
flash('Password must be at least 8 characters long and contain both letters and numbers.', 'error')
return render_template('auth/reset_password_with_token.html', form=form, token=token)
# Update password
user.set_password(form.password.data)
# Mark token as used
reset_token.used_at = datetime.utcnow()
reset_token.user_ip = request.environ.get('REMOTE_ADDR')
# Update request status
if reset_token.request:
reset_token.request.status = 'completed'
reset_token.request.updated_at = datetime.utcnow()
db.session.commit()
flash('Your password has been reset successfully! You can now log in with your new password.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password_with_token.html', form=form, token=token, user=reset_token.user)

View File

@@ -5,6 +5,7 @@ Provides HTML templates and endpoints for web-based chat
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from sqlalchemy import desc, and_
from datetime import datetime, timedelta
from app.extensions import db
from app.models import ChatRoom, ChatMessage, ChatParticipant, Post
@@ -90,7 +91,109 @@ def create_room_form():
desc(Post.created_at)
).limit(20).all()
return render_template('chat/create_room.html', posts=recent_posts)
# Check if a specific post was requested
pre_selected_post = request.args.get('post_id')
if pre_selected_post:
try:
pre_selected_post = int(pre_selected_post)
except ValueError:
pre_selected_post = None
return render_template('chat/create_room.html', posts=recent_posts, pre_selected_post=pre_selected_post)
@chat.route('/create', methods=['POST'])
@login_required
def create_room():
"""Create a new chat room"""
room_name = request.form.get('room_name')
description = request.form.get('description', '')
room_type = request.form.get('room_type', 'general')
is_private = bool(request.form.get('is_private'))
related_post_id = request.form.get('related_post_id')
if not room_name:
flash('Room name is required.', 'error')
return redirect(url_for('chat.create_room_form'))
# Convert to integer if post ID is provided
if related_post_id:
try:
related_post_id = int(related_post_id)
# Verify the post exists
related_post = Post.query.get(related_post_id)
if not related_post:
flash('Selected post does not exist.', 'error')
return redirect(url_for('chat.create_room_form'))
# If post is selected, set room type to post_discussion
room_type = 'post_discussion'
except ValueError:
related_post_id = None
else:
related_post_id = None
# If no post selected, ensure it's general discussion
if room_type == 'post_discussion':
room_type = 'general'
# Check if room name already exists
existing_room = ChatRoom.query.filter_by(name=room_name).first()
if existing_room:
flash('A room with that name already exists.', 'error')
return redirect(url_for('chat.create_room_form'))
try:
# Create the room
room = ChatRoom(
name=room_name,
description=description,
room_type=room_type,
is_private=is_private,
is_active=True,
created_by_id=current_user.id,
related_post_id=related_post_id
)
db.session.add(room)
db.session.flush() # Get the room ID
# Add creator as participant with admin role
participant = ChatParticipant(
room_id=room.id,
user_id=current_user.id,
role='admin',
joined_at=datetime.utcnow()
)
db.session.add(participant)
# Add welcome message
if related_post_id:
welcome_content = f"Welcome to the discussion for '{related_post.title}'! This room was created by {current_user.nickname} to discuss this post."
else:
welcome_content = f"Welcome to {room_name}! This room was created by {current_user.nickname}."
welcome_message = ChatMessage(
content=welcome_content,
room_id=room.id,
sender_id=current_user.id,
is_system_message=True
)
db.session.add(welcome_message)
# Update room activity
room.last_activity = datetime.utcnow()
room.message_count = 1
db.session.commit()
if related_post_id:
flash(f'Chat room "{room_name}" created successfully and linked to the post!', 'success')
else:
flash(f'Chat room "{room_name}" created successfully!', 'success')
return redirect(url_for('chat.room', room_id=room.id))
except Exception as e:
db.session.rollback()
flash(f'Error creating room: {str(e)}', 'error')
return redirect(url_for('chat.create_room_form'))
@chat.route('/support')
@login_required
@@ -123,3 +226,51 @@ def embed_post_chat(post_id):
return render_template('chat/embed.html',
post=post,
discussion_room=discussion_room)
@chat.route('/post-discussions')
@login_required
def post_discussions():
"""View all chat rooms related to posts"""
page = request.args.get('page', 1, type=int)
# Get all rooms that are linked to posts
post_rooms = ChatRoom.query.filter(
ChatRoom.related_post_id.isnot(None),
ChatRoom.is_active == True
).join(Post).order_by(ChatRoom.last_activity.desc()).paginate(
page=page, per_page=20, error_out=False
)
# Get statistics
total_post_discussions = ChatRoom.query.filter(
ChatRoom.related_post_id.isnot(None),
ChatRoom.is_active == True
).count()
active_discussions = ChatRoom.query.filter(
ChatRoom.related_post_id.isnot(None),
ChatRoom.is_active == True,
ChatRoom.last_activity >= datetime.utcnow() - timedelta(days=7)
).count()
return render_template('chat/post_discussions.html',
rooms=post_rooms,
total_discussions=total_post_discussions,
active_discussions=active_discussions)
@chat.route('/post/<int:post_id>/discussions')
@login_required
def post_specific_discussions(post_id):
"""View all chat rooms for a specific post"""
post = Post.query.get_or_404(post_id)
# Get all rooms for this specific post
rooms = ChatRoom.query.filter(
ChatRoom.related_post_id == post_id,
ChatRoom.is_active == True
).order_by(ChatRoom.last_activity.desc()).all()
return render_template('chat/post_specific_discussions.html',
post=post, rooms=rooms)