From 30bd4c62adc3b7faab93a06937c8e73ce11e3a03 Mon Sep 17 00:00:00 2001 From: ske087 Date: Sun, 10 Aug 2025 00:22:33 +0300 Subject: [PATCH] Major Feature Update: Modern Chat System & Admin Management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 173 ++++- app/models.py | 67 ++ app/routes/admin.py | 391 +++++++++++- app/routes/auth.py | 78 ++- app/routes/chat.py | 153 ++++- app/templates/admin/dashboard.html | 82 ++- app/templates/admin/manage_chats.html | 603 ++++++++++++++++++ .../admin/password_reset_email_template.html | 269 ++++++++ .../admin/password_reset_request_detail.html | 242 +++++++ .../admin/password_reset_requests.html | 230 +++++++ .../admin/password_reset_tokens.html | 324 ++++++++++ .../auth/reset_password_with_token.html | 182 ++++++ app/templates/chat/create_room.html | 243 +++++++ app/templates/chat/index.html | 53 +- app/templates/chat/post_discussions.html | 205 ++++++ .../chat/post_specific_discussions.html | 172 +++++ app/templates/chat/room.html | 426 +++++-------- migrations/add_category_to_chat_rooms.py | 39 ++ migrations/add_password_reset_system.py | 66 ++ migrations/add_password_reset_tables.py | 0 20 files changed, 3649 insertions(+), 349 deletions(-) create mode 100644 app/templates/admin/manage_chats.html create mode 100644 app/templates/admin/password_reset_email_template.html create mode 100644 app/templates/admin/password_reset_request_detail.html create mode 100644 app/templates/admin/password_reset_requests.html create mode 100644 app/templates/admin/password_reset_tokens.html create mode 100644 app/templates/auth/reset_password_with_token.html create mode 100644 app/templates/chat/create_room.html create mode 100644 app/templates/chat/post_discussions.html create mode 100644 app/templates/chat/post_specific_discussions.html create mode 100644 migrations/add_category_to_chat_rooms.py create mode 100644 migrations/add_password_reset_system.py create mode 100644 migrations/add_password_reset_tables.py diff --git a/README.md b/README.md index 493a8b7..16ce7e6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent - **Adventure Posts**: Rich content creation with titles, subtitles, and detailed stories - **Comment System**: Community discussions on adventure posts - **Like System**: Engagement tracking with real-time updates +- **Real-time Chat System**: Modern chat interface with room management +- **Post-linked Discussions**: Chat rooms connected to specific adventure posts +- **Chat Categories**: Organized rooms for different topics (general, technical, routes, etc.) +- **Mobile API Integration**: RESTful API for mobile app connectivity - **User Profiles**: Personal dashboards with adventure statistics - **Difficulty Ratings**: 5-star system for adventure difficulty assessment - **Publication Workflow**: Admin approval system for content moderation @@ -40,13 +44,25 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent - **Registration System**: Email-based user registration ### đŸ› ī¸ Admin Panel & Analytics -- **Comprehensive Dashboard**: User and post management interface +- **Comprehensive Dashboard**: User and post management interface with statistics - **Content Moderation**: Review and approve community posts -- **User Analytics**: User engagement and activity metrics +- **User Analytics**: User engagement and activity metrics with page view tracking - **Post Management**: Bulk operations and detailed post information +- **Chat Management**: Full chat room administration with merge capabilities +- **Password Reset System**: Admin-controlled password reset with secure tokens +- **Mail System Configuration**: SMTP settings and email template management - **System Configuration**: Admin-only settings and controls -### 📱 Mobile-Optimized Experience +### īŋŊ Real-time Chat System +- **Modern Chat Interface**: App-style design with gradient backgrounds and card layouts +- **Room Management**: Create, join, and manage chat rooms with categories +- **Post Integration**: Link chat rooms to specific adventure posts for focused discussions +- **Admin Controls**: Comprehensive chat administration with room merging and moderation +- **Mobile API**: RESTful API endpoints for mobile app integration +- **Real-time Updates**: JavaScript polling for live message updates +- **Message Features**: Text messages with editing, deletion, and system notifications + +### īŋŊ📱 Mobile-Optimized Experience - **Touch-Friendly Interface**: Optimized buttons and interactions for mobile devices - **Responsive Grids**: Adaptive layouts that work perfectly on phones and tablets - **Progressive Enhancement**: Graceful degradation for older browsers @@ -62,6 +78,8 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent - **Frontend**: Tailwind CSS 3.x with custom components - **Maps**: Leaflet.js with OpenStreetMap integration - **File Handling**: Secure media uploads with thumbnail generation +- **Chat System**: Real-time messaging with WebSocket-ready architecture +- **API**: RESTful endpoints for mobile app integration - **Deployment**: Docker with Gunicorn WSGI server ## 📁 Project Structure @@ -112,7 +130,106 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent └── docker-compose.yml # Docker Compose setup ``` -## 🚀 Quick Start +## īŋŊ API Documentation + +The platform provides a comprehensive RESTful API for mobile app integration and third-party services. + +### Base URL +``` +https://your-domain.com/api/v1 +``` + +### Authentication +All API endpoints use session-based authentication. Mobile apps can authenticate using: + +```http +POST /auth/login +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "password" +} +``` + +### Chat API Endpoints + +#### Get Chat Rooms +```http +GET /api/v1/chat/rooms +``` +Response: +```json +{ + "rooms": [ + { + "id": 1, + "name": "General Discussion", + "category": "general", + "post_id": null, + "created_at": "2024-01-01T10:00:00Z" + } + ] +} +``` + +#### Join Chat Room +```http +POST /api/v1/chat/rooms/{room_id}/join +``` + +#### Send Message +```http +POST /api/v1/chat/rooms/{room_id}/messages +Content-Type: application/json + +{ + "content": "Hello, world!" +} +``` + +#### Get Messages +```http +GET /api/v1/chat/rooms/{room_id}/messages?page=1&per_page=50 +``` + +### Posts API Endpoints + +#### Get Posts +```http +GET /api/v1/posts?page=1&per_page=20 +``` + +#### Create Post +```http +POST /api/v1/posts +Content-Type: multipart/form-data + +title: "Adventure Title" +content: "Post content" +images: [file uploads] +gpx_file: [GPX file upload] +``` + +### User API Endpoints + +#### Get User Profile +```http +GET /api/v1/users/{user_id} +``` + +#### Update Profile +```http +PUT /api/v1/users/profile +Content-Type: application/json + +{ + "bio": "Updated bio", + "location": "New location" +} +``` + +## īŋŊ🚀 Quick Start ### Local Development @@ -172,8 +289,54 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent ``` 2. **Access the application** - - Web application: http://localhost:5000 + - Web application: http://localhost:8100 - PostgreSQL database: localhost:5432 + - API endpoints: http://localhost:8100/api/v1 + +3. **Production deployment** + ```bash + # Set production environment variables + export FLASK_ENV=production + export SECRET_KEY="your-secure-production-key" + export DATABASE_URL="postgresql://user:password@localhost:5432/moto_adventure" + export MAIL_SERVER="your-smtp-server.com" + export MAIL_USERNAME="your-email@domain.com" + export MAIL_PASSWORD="your-email-password" + + # Run in production mode + docker-compose -f docker-compose.prod.yml up -d + ``` + +### 🔧 Configuration + +#### Environment Variables +- `SECRET_KEY`: Flask secret key for session management +- `DATABASE_URL`: Database connection string +- `MAIL_SERVER`: SMTP server for email notifications +- `MAIL_PORT`: SMTP port (default: 587) +- `MAIL_USE_TLS`: Enable TLS for email (default: True) +- `MAIL_USERNAME`: Email account username +- `MAIL_PASSWORD`: Email account password +- `UPLOAD_PATH`: Custom upload directory path +- `MAX_CONTENT_LENGTH`: Maximum file upload size + +#### Admin Configuration +To create an admin user: +```bash +# Access the container +docker exec -it moto-adventure-app bash + +# Run Python shell +python +>>> from app import create_app, db +>>> from app.models import User +>>> app = create_app() +>>> with app.app_context(): +... admin = User(email='admin@example.com', is_admin=True) +... admin.set_password('secure_password') +... db.session.add(admin) +... db.session.commit() +``` ### 📱 Testing Features diff --git a/app/models.py b/app/models.py index d4754c9..8425341 100644 --- a/app/models.py +++ b/app/models.py @@ -313,6 +313,7 @@ class ChatRoom(db.Model): name = db.Column(db.String(200), nullable=False) description = db.Column(db.Text) room_type = db.Column(db.String(50), default='general') # general, post_discussion, admin_support, password_reset + category = db.Column(db.String(50), default='general') # general, technical, maintenance, routes, events, safety, gear, social is_private = db.Column(db.Boolean, default=False) is_active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) @@ -436,3 +437,69 @@ class ChatMessage(db.Model): def __repr__(self): return f'' + + +class PasswordResetRequest(db.Model): + """Model for tracking password reset requests from chat system""" + __tablename__ = 'password_reset_requests' + + id = db.Column(db.Integer, primary_key=True) + user_email = db.Column(db.String(120), nullable=False) + requester_message = db.Column(db.Text) # Original request message + status = db.Column(db.String(20), default='pending') # pending, token_generated, completed, expired + admin_notes = db.Column(db.Text) # Admin can add notes + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Foreign Keys + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # Nullable in case user not found + chat_message_id = db.Column(db.Integer, db.ForeignKey('chat_messages.id'), nullable=True) + + # Relationships + user = db.relationship('User', backref='password_reset_requests') + chat_message = db.relationship('ChatMessage', backref='password_reset_request') + tokens = db.relationship('PasswordResetToken', backref='request', cascade='all, delete-orphan') + + def __repr__(self): + return f'' + + +class PasswordResetToken(db.Model): + """Model for one-time password reset tokens generated by admin""" + __tablename__ = 'password_reset_tokens' + + id = db.Column(db.Integer, primary_key=True) + token = db.Column(db.String(255), unique=True, nullable=False) + is_used = db.Column(db.Boolean, default=False) + used_at = db.Column(db.DateTime, nullable=True) + expires_at = db.Column(db.DateTime, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + user_ip = db.Column(db.String(45), nullable=True) # IP when token was used + user_agent = db.Column(db.Text, nullable=True) # User agent when token was used + + # Foreign Keys + request_id = db.Column(db.Integer, db.ForeignKey('password_reset_requests.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_by_admin_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + # Relationships + user = db.relationship('User', foreign_keys=[user_id], backref='reset_tokens') + created_by_admin = db.relationship('User', foreign_keys=[created_by_admin_id]) + + @property + def is_expired(self): + return datetime.utcnow() > self.expires_at + + @property + def is_valid(self): + return not self.is_used and not self.is_expired + + def mark_as_used(self, ip_address=None, user_agent=None): + self.is_used = True + self.used_at = datetime.utcnow() + self.user_ip = ip_address + self.user_agent = user_agent + db.session.commit() + + def __repr__(self): + return f'' diff --git a/app/routes/admin.py b/app/routes/admin.py index ba67d50..2eb2aa6 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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/') +@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) diff --git a/app/routes/auth.py b/app/routes/auth.py index da73285..a52aa86 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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/', 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/', 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) diff --git a/app/routes/chat.py b/app/routes/chat.py index 7071bd5..64260a7 100644 --- a/app/routes/chat.py +++ b/app/routes/chat.py @@ -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//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) diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index 8bfec77..b8ab2bc 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -121,10 +121,88 @@ + +
+
+
+
+
+
+
Password Reset Requests
+ +
Pending requests need attention
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+
Active Reset Tokens
+
{{ active_reset_tokens or 0 }}
+
Unused tokens (24h expiry)
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+
+ Chat Management +
+
+
+
+
{{ total_chat_rooms or 0 }}
+ Total Chat Rooms +
+
+
Active Rooms: {{ active_chat_rooms or 0 }}
+
Linked to Posts: {{ linked_chat_rooms or 0 }}
+
Recent Messages: {{ recent_chat_messages or 0 }}
+
+ +
+
+
+ -
+
Recent Posts
@@ -161,7 +239,7 @@
-
+
Most Viewed Posts
diff --git a/app/templates/admin/manage_chats.html b/app/templates/admin/manage_chats.html new file mode 100644 index 0000000..8eea3cb --- /dev/null +++ b/app/templates/admin/manage_chats.html @@ -0,0 +1,603 @@ +{% extends "admin/base.html" %} + +{% block title %}Manage Chats - Admin{% endblock %} + +{% block admin_content %} +
+

+ Manage Chat Rooms +

+
+ + +
+
+ + +
+
+
+
+
+
+
Total Rooms
+
{{ total_rooms }}
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Linked to Posts
+
{{ linked_rooms }}
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Active Today
+
{{ active_today }}
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Total Messages
+
{{ total_messages }}
+
+
+ +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+ + +
+
+
Chat Rooms
+
+
+ {% if chat_rooms %} +
+ + + + + + + + + + + + + + {% for room in chat_rooms %} + + + + + + + + + + {% endfor %} + +
Room NameCategoryCreated ByLinked PostMessagesLast ActivityActions
+
+
+ + {{ room.name }} + + {% if room.description %} +
{{ room.description[:100] }}{% if room.description|length > 100 %}...{% endif %}
+ {% endif %} +
+
+
+ + {{ room.category.title() if room.category else 'Uncategorized' }} + + + + {{ room.created_by.nickname }} + + + {% if room.related_post %} + + {{ room.related_post.title[:30] }}{% if room.related_post.title|length > 30 %}...{% endif %} + + {% else %} + Not linked + {% endif %} + + {{ room.message_count or 0 }} + + {% if room.last_activity %} + {{ room.last_activity.strftime('%Y-%m-%d %H:%M') }} + {% else %} + Never + {% endif %} + + +
+
+ + + {% if pagination %} + + {% endif %} + + {% else %} +
+ +
No chat rooms found
+

Create a new room or adjust your filters.

+
+ {% endif %} +
+
+ + + + + + + + + + + +{% endblock %} diff --git a/app/templates/admin/password_reset_email_template.html b/app/templates/admin/password_reset_email_template.html new file mode 100644 index 0000000..a64f7a1 --- /dev/null +++ b/app/templates/admin/password_reset_email_template.html @@ -0,0 +1,269 @@ +{% extends "admin/base.html" %} + +{% block title %}Password Reset Email Template - Admin{% endblock %} + +{% block admin_content %} +
+

Password Reset Email Template

+ +
+ + +
+
+ Token Information +
+

+ Token Status: + {% if token.is_used %} + Used - This token has already been used + {% elif token.is_expired %} + Expired - This token has expired + {% else %} + Active - This token is ready to use + {% endif %} +

+

+ Expires: {{ token.expires_at.strftime('%B %d, %Y at %I:%M %p') }} +

+

+ For User: {{ token.user.nickname }} ({{ token.user.email }}) +

+
+ + +
+
+
Email Template - Copy and Send to User
+ +
+
+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ Use this if you prefer to compose your own email message. +
+
+
+ + +
+
+
Instructions for Admin
+
+
+
+
+
How to Send:
+
    +
  1. Copy the subject and email body above
  2. +
  3. Open your email client (Gmail, Outlook, etc.)
  4. +
  5. Create a new email to: {{ token.user.email }}
  6. +
  7. Paste the subject and body
  8. +
  9. Send the email
  10. +
  11. Return here to monitor if the link was used
  12. +
+
+
+
Security Notes:
+
    +
  • Token expires in 24 hours automatically
  • +
  • Token can only be used once
  • +
  • Monitor token usage below
  • +
  • Do not share the reset link publicly
  • +
  • User must enter a new password to complete reset
  • +
+
+
+
+
+ + +
+
+
Token Usage Tracking
+
+
+
+
+
+
+ {{ 'Yes' if token.is_used else 'No' }} +
+ Used +
+
+
+
+
+ {{ 'Yes' if token.is_expired else 'No' }} +
+ Expired +
+
+
+
+
+ {% if token.used_at %} + {{ token.used_at.strftime('%m/%d %H:%M') }} + {% else %} + - + {% endif %} +
+ Used At +
+
+
+
+
+ {% if token.user_ip %} + {{ token.user_ip }} + {% else %} + - + {% endif %} +
+ User IP +
+
+
+ +
+ +
+
+
+ + +{% endblock %} diff --git a/app/templates/admin/password_reset_request_detail.html b/app/templates/admin/password_reset_request_detail.html new file mode 100644 index 0000000..7a898a3 --- /dev/null +++ b/app/templates/admin/password_reset_request_detail.html @@ -0,0 +1,242 @@ +{% extends "admin/base.html" %} + +{% block title %}Password Reset Request #{{ reset_request.id }} - Admin{% endblock %} + +{% block admin_content %} +
+

Password Reset Request #{{ reset_request.id }}

+
+ + Back to List + + {% if reset_request.user and reset_request.status == 'pending' %} +
+ +
+ {% endif %} +
+
+ +
+ +
+
+
+
Request Details
+
+
+
+
Status:
+
+ {% if reset_request.status == 'pending' %} + + Pending + + {% elif reset_request.status == 'token_generated' %} + + Token Generated + + {% elif reset_request.status == 'completed' %} + + Completed + + {% elif reset_request.status == 'expired' %} + + Expired + + {% endif %} +
+
+ +
+
User Email:
+
+ {{ reset_request.user_email }} + {% if reset_request.user %} +
+ User found: {{ reset_request.user.nickname }} + + {% else %} +
+ User not found in system + + {% endif %} +
+
+ +
+
Requested:
+
+ {{ reset_request.created_at.strftime('%B %d, %Y at %I:%M %p') }} +
{{ reset_request.created_at.strftime('%A') }} +
+
+ +
+
Last Updated:
+
+ {{ reset_request.updated_at.strftime('%B %d, %Y at %I:%M %p') }} +
+
+ + {% if reset_request.requester_message %} +
+
Original Message:
+
+
+ {{ reset_request.requester_message }} +
+
+
+ {% endif %} + + {% if reset_request.chat_message %} +
+
Chat Reference:
+ +
+ {% endif %} +
+
+ + +
+
+
Admin Notes
+
+
+
+
+ + +
+ +
+
+
+
+ + +
+ +
+
+
Generated Tokens ({{ reset_request.tokens|length }})
+
+
+ {% if reset_request.tokens %} + {% for token in reset_request.tokens %} +
+
+
+ {% if token.is_used %} + Used + {% elif token.is_expired %} + Expired + {% else %} + Active + {% endif %} +
+ + {{ token.created_at.strftime('%m/%d %H:%M') }} + +
+ +
+ Token: {{ token.token[:12] }}... +
+ +
+ Expires: {{ token.expires_at.strftime('%m/%d/%Y %H:%M') }} +
+ + {% if token.is_used %} +
+ Used: {{ token.used_at.strftime('%m/%d/%Y %H:%M') }} +
+ {% endif %} + +
+ Created by: {{ token.created_by_admin.nickname }} +
+ + {% if token.is_valid %} + + {% endif %} +
+ {% endfor %} + {% else %} +
+ +

No tokens generated yet.

+ {% if reset_request.user and reset_request.status == 'pending' %} +
+ +
+ {% endif %} +
+ {% endif %} +
+
+ + + {% if reset_request.user %} +
+
+
User Information
+
+
+
+ Username: {{ reset_request.user.nickname }} +
+
+ Email: {{ reset_request.user.email }} +
+
+ Account Created: {{ reset_request.user.created_at.strftime('%m/%d/%Y') }} +
+
+ Admin: + {% if reset_request.user.is_admin %} + Yes + {% else %} + No + {% endif %} +
+
+ Active: + {% if reset_request.user.is_active %} + Yes + {% else %} + No + {% endif %} +
+ + View User Profile + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/admin/password_reset_requests.html b/app/templates/admin/password_reset_requests.html new file mode 100644 index 0000000..1ad686b --- /dev/null +++ b/app/templates/admin/password_reset_requests.html @@ -0,0 +1,230 @@ +{% extends "admin/base.html" %} + +{% block title %}Password Reset Requests - Admin{% endblock %} + +{% block admin_content %} +
+

Password Reset Requests

+ +
+ +{% if requests.items %} +
+
+
+ {{ requests.total }} Password Reset {{ 'Request' if requests.total == 1 else 'Requests' }} + {% if status != 'all' %}({{ status.replace('_', ' ').title() }}){% endif %} +
+
+
+
+ + + + + + + + + + + + + {% for request in requests.items %} + + + + + + + + + {% endfor %} + +
Request DateUser EmailUser FoundStatusGenerated TokensActions
+
{{ request.created_at.strftime('%Y-%m-%d') }}
+ {{ request.created_at.strftime('%H:%M:%S') }} +
+
{{ request.user_email }}
+ {% if request.user %} + + {{ request.user.nickname }} + + {% endif %} +
+ {% if request.user %} + + Found + + {% else %} + + Not Found + + {% endif %} + + {% if request.status == 'pending' %} + + Pending + + {% elif request.status == 'token_generated' %} + + Token Generated + + {% elif request.status == 'completed' %} + + Completed + + {% elif request.status == 'expired' %} + + Expired + + {% endif %} + +
{{ request.tokens|length }}
+ {% set active_tokens = request.tokens|selectattr('is_valid')|list %} + {% if active_tokens %} + {{ active_tokens|length }} active + {% else %} + None active + {% endif %} +
+
+ + + + {% if request.user and request.status == 'pending' %} +
+ +
+ {% endif %} +
+
+
+
+
+ + +{% if requests.pages > 1 %} + +{% endif %} + +{% else %} +
+
+ +
No Password Reset Requests
+

+ {% if status == 'all' %} + No password reset requests have been made yet. + {% else %} + No {{ status.replace('_', ' ') }} password reset requests found. + {% endif %} +

+ {% if status != 'all' %} + + View All Requests + + {% endif %} +
+
+{% endif %} + + +
+
+
+
+
How Password Reset Works
+
+
+
+
+
Process Flow:
+
    +
  1. User requests password reset through chat system
  2. +
  3. Request appears here with "Pending" status
  4. +
  5. Admin generates one-time reset token (24h expiry)
  6. +
  7. Admin copies email template and sends to user
  8. +
  9. User clicks link and resets password
  10. +
  11. Token becomes "Used" and request "Completed"
  12. +
+
+
+
Status Meanings:
+
    +
  • Pending - Awaiting admin action
  • +
  • Token Generated - Reset link created
  • +
  • Completed - Password successfully reset
  • +
  • Expired - Token expired unused
  • +
+
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/password_reset_tokens.html b/app/templates/admin/password_reset_tokens.html new file mode 100644 index 0000000..c6eefbe --- /dev/null +++ b/app/templates/admin/password_reset_tokens.html @@ -0,0 +1,324 @@ +{% extends "admin/base.html" %} + +{% block title %}Password Reset Tokens - Admin{% endblock %} + +{% block admin_content %} +
+

Password Reset Tokens

+ +
+ + +
+
+
+
+
+
+
+ Total Tokens +
+
{{ tokens.total }}
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ Active Tokens +
+
{{ active_count }}
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ Used Tokens +
+
{{ used_count }}
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ Expired Tokens +
+
{{ expired_count }}
+
+
+ +
+
+
+
+
+
+ + +
+
+
Filter Tokens
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + Clear + +
+
+
+
+
+ + +
+
+
Password Reset Tokens
+ {{ tokens.total }} total tokens +
+
+ {% if tokens.items %} +
+ + + + + + + + + + + + + + {% for token in tokens.items %} + + + + + + + + + + {% endfor %} + +
UserStatusCreatedExpiresUsedAdminActions
+
+
+
{{ token.user.nickname }}
+
{{ token.user.email }}
+
+
+
+ {% if token.is_used %} + Used + {% elif token.is_expired %} + Expired + {% else %} + Active + {% endif %} + +
{{ token.created_at.strftime('%m/%d/%Y') }}
+ {{ token.created_at.strftime('%I:%M %p') }} +
+
{{ token.expires_at.strftime('%m/%d/%Y') }}
+ {{ token.expires_at.strftime('%I:%M %p') }} +
+ {% if token.used_at %} +
{{ token.used_at.strftime('%m/%d/%Y') }}
+ {{ token.used_at.strftime('%I:%M %p') }} + {% if token.user_ip %} +
IP: {{ token.user_ip }} + {% endif %} + {% else %} + - + {% endif %} +
+
{{ token.created_by.nickname }}
+ {{ token.created_by.email }} +
+
+ {% if not token.is_used and not token.is_expired %} + + + + {% endif %} + + + + {% if not token.is_used and not token.is_expired %} + + {% endif %} +
+
+
+ + + {% if tokens.pages > 1 %} + + {% endif %} + + {% else %} +
+ +
No Tokens Found
+

No password reset tokens match your current filters.

+ + Generate New Token + +
+ {% endif %} +
+
+ + + + + +{% endblock %} diff --git a/app/templates/auth/reset_password_with_token.html b/app/templates/auth/reset_password_with_token.html new file mode 100644 index 0000000..fa30a10 --- /dev/null +++ b/app/templates/auth/reset_password_with_token.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} + +{% block title %}Reset Your Password - Moto Adventure{% endblock %} + +{% block content %} +
+
+
+ +

Reset Your Password

+

Enter your new password below

+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + +
+ + Resetting password for: {{ user.nickname }} ({{ user.email }}) +
+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} + {% if form.password.errors %} +
+ {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} +
+ Password must be at least 8 characters long and contain both letters and numbers. +
+
+ +
+ {{ form.password2.label(class="form-label") }} + {{ form.password2(class="form-control") }} + {% if form.password2.errors %} +
+ {% for error in form.password2.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.submit(class="btn btn-primary btn-lg") }} +
+
+
+ + +
+
+ + +
+
+
+
+
+
+ Security Notice +
+

+ This reset link can only be used once and will expire soon. + After resetting your password, you'll be able to log in immediately. +

+
+
+
+
+
+ + +{% endblock %} diff --git a/app/templates/chat/create_room.html b/app/templates/chat/create_room.html new file mode 100644 index 0000000..1bb0106 --- /dev/null +++ b/app/templates/chat/create_room.html @@ -0,0 +1,243 @@ +{% extends "base.html" %} + +{% block title %}Create Chat Room{% endblock %} + +{% block content %} +
+ +
+
+
+
+

+ Create New Chat Room +

+

Start a discussion with the motorcycle community

+
+
+
+ +
+
+
+
+
+ +
+

Room Configuration

+

Set up your chat room details

+
+
+ + Back to Chat + +
+
+ +
+
+ +
+ + +

Choose a clear, descriptive name for your chat room

+
+ + +
+ + +

Optional: Help others understand the room's purpose

+
+ + +
+
+ +

Link to Post (Optional)

+
+ +
+ + +

+ + Link this room to a specific post for focused discussions +

+
+
+ + +
+ + +

Category will auto-update based on post selection

+
+ + +
+
+ +

Privacy Settings

+
+ +
+ +
+ +

+ Private rooms are only visible to invited members. Public rooms can be joined by anyone. +

+
+
+
+ + +
+ + Cancel + + +
+
+
+
+ + + {% if posts %} +
+
+
+ +
+

Recent Community Posts

+

Available for discussion rooms

+
+
+
+
+
+ {% for post in posts[:6] %} +
+

+ {{ post.title }} +

+

+ {{ post.content[:120] }}{% if post.content|length > 120 %}...{% endif %} +

+
+ by {{ post.author.nickname }} + {{ post.created_at.strftime('%m/%d') }} +
+
+ {% endfor %} +
+
+
+ {% endif %} +
+
+ + + + +{% endblock %} diff --git a/app/templates/chat/index.html b/app/templates/chat/index.html index 2e9dcc4..4e7b862 100644 --- a/app/templates/chat/index.html +++ b/app/templates/chat/index.html @@ -48,60 +48,41 @@
-
-
-
-
- -
-

Get Support

-

Need help or password reset?

-
-
-
-
-

Contact administrators for support or password reset

- - Get Support - -
-
- +
-

Create Room

-

Start a new discussion

+

Create Chat Room

+

Start a new discussion

- - +
-
+
- +
-

Password Reset

-

Forgot your password?

+

Post Discussions

+

Chat about community posts

- diff --git a/app/templates/chat/post_discussions.html b/app/templates/chat/post_discussions.html new file mode 100644 index 0000000..317f9eb --- /dev/null +++ b/app/templates/chat/post_discussions.html @@ -0,0 +1,205 @@ +{% extends "base.html" %} + +{% block title %}Post Discussions - Chat Rooms{% endblock %} + +{% block content %} +
+ +
+
+
+
+

+ Post Discussions +

+

Chat rooms linked to community posts

+
+
+
+ +
+ +
+
+ + All Chats + + + Post Discussions + + + Create Room + +
+
+ + +
+
+
+
+ +
+

{{ total_discussions }}

+

Total Post Discussions

+
+
+
+
+ +
+
+
+ +
+

{{ active_discussions }}

+

Active This Week

+
+
+
+
+
+ + {% if rooms.items %} + +
+ {% for room in rooms.items %} +
+
+
+
+ +
+
+

{{ room.name }}

+ {% if room.description %} +

{{ room.description }}

+ {% endif %} +
+ + {{ room.message_count or 0 }} messages + +
+ + + {% if room.related_post %} +
+
+ +
+

Discussing Post:

+ + {{ room.related_post.title }} + +

+ by {{ room.related_post.author.nickname }} â€ĸ + {{ room.related_post.created_at.strftime('%B %d, %Y') }} +

+
+
+
+ {% endif %} + + +
+ + + Created by {{ room.created_by.nickname }} + + + + {{ room.participants.count() }} members + + + + {% if room.last_activity %} + Last activity {{ room.last_activity.strftime('%m/%d/%Y at %I:%M %p') }} + {% else %} + No recent activity + {% endif %} + + {% if room.is_private %} + + Private + + {% endif %} +
+
+ + +
+ + Join Discussion + + {% if room.related_post %} + + View Post + + {% endif %} +
+
+
+
+ {% endfor %} +
+ + + {% if rooms.pages > 1 %} +
+
+
+ {% if rooms.has_prev %} + + + + {% endif %} + + {% for page_num in rooms.iter_pages() %} + {% if page_num %} + {% if page_num != rooms.page %} + + {{ page_num }} + + {% else %} + {{ page_num }} + {% endif %} + {% else %} + ... + {% endif %} + {% endfor %} + + {% if rooms.has_next %} + + + + {% endif %} +
+
+
+ {% endif %} + + {% else %} + +
+
+ +

No post discussions yet

+

Create the first chat room linked to a community post!

+ + Create Post Discussion + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/chat/post_specific_discussions.html b/app/templates/chat/post_specific_discussions.html new file mode 100644 index 0000000..d6c1d00 --- /dev/null +++ b/app/templates/chat/post_specific_discussions.html @@ -0,0 +1,172 @@ +{% extends "base.html" %} + +{% block title %}{{ post.title }} - Discussions{% endblock %} + +{% block content %} +
+ +
+
+
+
+
+
+

+ Discussions for: +

+

{{ post.title }}

+

+ by {{ post.author.nickname }} â€ĸ {{ post.created_at.strftime('%B %d, %Y') }} +

+
+ + View Post + +
+
+
+
+ +
+ + + + +
+
+
+ +
+

Original Post

+

{{ post.created_at.strftime('%B %d, %Y at %I:%M %p') }}

+
+
+
+
+
+ {{ post.content[:300] }}{% if post.content|length > 300 %}...{% endif %} +
+
+
+ + {{ post.author.nickname }} + + {% if post.likes %} + + {{ post.likes.count() }} likes + + {% endif %} + {% if post.comments %} + + {{ post.comments.count() }} comments + + {% endif %} +
+ + Read Full Post + +
+
+
+ + {% if rooms %} + +
+

+ Discussion Rooms ({{ rooms|length }}) +

+ + {% for room in rooms %} +
+
+
+
+
+
+

{{ room.name }}

+ {% if room.description %} +

{{ room.description }}

+ {% endif %} +
+
+ + {{ room.message_count or 0 }} + + {% if room.is_private %} + + Private + + {% endif %} +
+
+ +
+ + + Created by {{ room.created_by.nickname }} + + + + {{ room.participants.count() }} members + + + + {% if room.last_activity %} + {{ room.last_activity.strftime('%m/%d/%Y at %I:%M %p') }} + {% else %} + No recent activity + {% endif %} + + + + Created {{ room.created_at.strftime('%m/%d/%Y') }} + +
+
+ + +
+
+
+ {% endfor %} +
+ + {% else %} + +
+
+ +

No discussions yet

+

Be the first to start a discussion about this post!

+ + Start Discussion + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/chat/room.html b/app/templates/chat/room.html index 293bd8a..0d35591 100644 --- a/app/templates/chat/room.html +++ b/app/templates/chat/room.html @@ -2,276 +2,131 @@ {% block title %}{{ room.name }} - Chat{% endblock %} -{% block head %} - -{% endblock %} - {% block content %} -
-
-
-
-

{{ room.name }}

- {{ room.description or 'No description' }} - {% if room.related_post %} -
Related to: {{ room.related_post.title }} - {% endif %} -
- -
-
- -
-
-
- {% for message in messages %} -
- {% if message.message_type != 'system' %} -
- {{ message.user.nickname }} - {% if message.user.is_admin %} - ADMIN + +
+
+ +
+
+
+
+
+ +
+
+

{{ room.name }}

+ {% if room.description %} +

{{ room.description }}

+ {% endif %} +
+
+ +
+ {% if room.category %} + + {{ room.category.title() }} + {% endif %} - {{ message.created_at.strftime('%H:%M') }} + + {% if room.related_post %} + + Linked to Post + + {% endif %} + + + {{ room.participants.count() if room.participants else 0 }} Members + +
+ + {% if room.related_post %} + {% endif %} -
- {{ message.content }} - {% if message.is_edited %} - (edited) - {% endif %} +
+ +
+ + +
+
+
+ + +
+ +
+ {% for message in messages %} +
+
+
+ {% if not message.is_system_message %} +
+ + {{ message.sender.nickname }} + {% if message.sender.is_admin %} + ADMIN + {% endif %} + â€ĸ {{ message.created_at.strftime('%H:%M') }} + +
+ {% endif %} + +
+ {% if message.is_system_message %} + + {% endif %} + {{ message.content }} + {% if message.is_edited %} + (edited) + {% endif %} +
+
{% endfor %}
- -
-
- -
- Press Enter to send â€ĸ Max 2000 characters
- -
-
Participants ({{ participants|length }})
- {% for participant in participants %} -
-
- {{ participant.user.nickname[0].upper() }} -
-
-
{{ participant.user.nickname }}
-
- {{ participant.role.title() }} - {% if participant.user.is_admin %}â€ĸ Admin{% endif %} -
-
-
- {% endfor %} -
@@ -281,13 +136,13 @@ const currentUserId = {{ current_user.id }}; let lastMessageId = {{ messages[-1].id if messages else 0 }}; // Message form handling -document.getElementById('messageForm').addEventListener('submit', function(e) { +document.getElementById('message-form').addEventListener('submit', function(e) { e.preventDefault(); sendMessage(); }); // Enter key handling -document.getElementById('messageInput').addEventListener('keypress', function(e) { +document.getElementById('message-input').addEventListener('keypress', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); @@ -295,7 +150,7 @@ document.getElementById('messageInput').addEventListener('keypress', function(e) }); function sendMessage() { - const input = document.getElementById('messageInput'); + const input = document.getElementById('message-input'); const content = input.value.trim(); if (!content) return; @@ -306,8 +161,7 @@ function sendMessage() { fetch(`/api/v1/chat/rooms/${roomId}/messages`, { method: 'POST', headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': '{{ csrf_token() }}' + 'Content-Type': 'application/json' }, body: JSON.stringify({ content: content, @@ -335,35 +189,44 @@ function sendMessage() { } function addMessageToUI(message) { - const messagesArea = document.getElementById('messagesArea'); + const messagesArea = document.getElementById('messages-container'); const messageDiv = document.createElement('div'); - messageDiv.className = `message ${message.user.id === currentUserId ? 'own-message' : ''} ${message.message_type === 'system' ? 'system-message' : ''}`; + messageDiv.className = `message mb-4 ${message.user.id === currentUserId ? 'ml-12' : 'mr-12'}`; + messageDiv.setAttribute('data-message-id', message.id); - let messageHTML = ''; - if (message.message_type !== 'system') { - messageHTML += ` -
- ${message.user.nickname} - ${message.user.is_admin ? 'ADMIN' : ''} - ${new Date(message.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} + const isOwnMessage = message.user.id === currentUserId; + const isSystemMessage = message.message_type === 'system'; + + messageDiv.innerHTML = ` +
+
+ ${!isSystemMessage ? ` +
+ + ${message.user.nickname} ${message.user.is_admin ? 'ADMIN' : ''} â€ĸ ${new Date(message.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} + +
+ ` : ''} + +
+ ${isSystemMessage ? '' : ''} + ${message.content} + ${message.is_edited ? ' (edited)' : ''} +
- `; - } - messageHTML += ` -
- ${message.content} - ${message.is_edited ? ' (edited)' : ''}
`; - messageDiv.innerHTML = messageHTML; messagesArea.appendChild(messageDiv); - lastMessageId = message.id; } function scrollToBottom() { - const messagesArea = document.getElementById('messagesArea'); + const messagesArea = document.getElementById('messages-container'); messagesArea.scrollTop = messagesArea.scrollHeight; } @@ -390,11 +253,10 @@ scrollToBottom(); setInterval(loadNewMessages, 3000); // Auto-focus on message input -document.getElementById('messageInput').focus(); +document.getElementById('message-input').focus(); // Mobile app integration if (window.ReactNativeWebView) { - // React Native WebView integration window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'chat_room_opened', roomId: roomId, diff --git a/migrations/add_category_to_chat_rooms.py b/migrations/add_category_to_chat_rooms.py new file mode 100644 index 0000000..dbc1f3f --- /dev/null +++ b/migrations/add_category_to_chat_rooms.py @@ -0,0 +1,39 @@ +""" +Database migration to add category field to chat_rooms table +""" + +from app.extensions import db + +def upgrade(): + """Add category field to chat_rooms table""" + try: + # Add the category column + db.engine.execute(""" + ALTER TABLE chat_rooms + ADD COLUMN category VARCHAR(50) DEFAULT 'general' + """) + + # Update existing rooms to have category based on room_type + db.engine.execute(""" + UPDATE chat_rooms + SET category = CASE + WHEN room_type = 'general' THEN 'general' + WHEN room_type = 'post_discussion' THEN 'general' + WHEN room_type = 'admin_support' THEN 'technical' + WHEN room_type = 'password_reset' THEN 'technical' + ELSE 'general' + END + """) + + print("✅ Successfully added category field to chat_rooms table") + + except Exception as e: + print(f"❌ Error adding category field: {e}") + # If column already exists, that's fine + if "duplicate column name" in str(e).lower() or "already exists" in str(e).lower(): + print("â„šī¸ Category column already exists") + else: + raise + +if __name__ == "__main__": + upgrade() diff --git a/migrations/add_password_reset_system.py b/migrations/add_password_reset_system.py new file mode 100644 index 0000000..9103788 --- /dev/null +++ b/migrations/add_password_reset_system.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +""" +Database migration script for Password Reset System +Adds PasswordResetRequest and PasswordResetToken tables +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.extensions import db +from app.models import PasswordResetRequest, PasswordResetToken +from config import Config + +def create_password_reset_tables(): + """Create password reset tables""" + try: + # Create tables + db.create_all() + print("✅ Password reset tables created successfully!") + + # Verify tables exist + from sqlalchemy import inspect + inspector = inspect(db.engine) + tables = inspector.get_table_names() + + if 'password_reset_request' in tables: + print("✅ PasswordResetRequest table exists") + else: + print("❌ PasswordResetRequest table missing") + + if 'password_reset_token' in tables: + print("✅ PasswordResetToken table exists") + else: + print("❌ PasswordResetToken table missing") + + return True + + except Exception as e: + print(f"❌ Error creating tables: {e}") + return False + +def main(): + """Main migration function""" + print("🔄 Starting password reset system migration...") + + # Import app to initialize database + from run import app + + with app.app_context(): + success = create_password_reset_tables() + + if success: + print("✅ Migration completed successfully!") + print("\nNew features available:") + print("- Admin can view password reset requests") + print("- Admin can generate secure reset tokens") + print("- Email templates for manual sending") + print("- Token usage tracking") + print("- Request status management") + else: + print("❌ Migration failed!") + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/migrations/add_password_reset_tables.py b/migrations/add_password_reset_tables.py new file mode 100644 index 0000000..e69de29