diff --git a/app/__init__.py b/app/__init__.py index b8d39c7..4ad5019 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -87,6 +87,12 @@ def create_app(config_name=None): from app.routes.admin import admin app.register_blueprint(admin, url_prefix='/admin') + from app.routes.chat import chat + app.register_blueprint(chat, url_prefix='/chat') + + from app.routes.chat_api import chat_api + app.register_blueprint(chat_api, url_prefix='/api/v1/chat') + # Create upload directories upload_dir = os.path.join(app.instance_path, 'uploads') os.makedirs(upload_dir, exist_ok=True) diff --git a/app/models.py b/app/models.py index 71b1832..d4754c9 100644 --- a/app/models.py +++ b/app/models.py @@ -305,3 +305,134 @@ class SentEmail(db.Model): def __repr__(self): return f'' + +class ChatRoom(db.Model): + __tablename__ = 'chat_rooms' + + id = db.Column(db.Integer, primary_key=True) + 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 + 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) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + last_activity = db.Column(db.DateTime, default=datetime.utcnow) + + # Foreign Keys + created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + related_post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=True) # For post discussions + + # Relationships + created_by = db.relationship('User', backref='created_chat_rooms') + related_post = db.relationship('Post', backref='chat_rooms') + messages = db.relationship('ChatMessage', backref='room', lazy='dynamic', cascade='all, delete-orphan') + participants = db.relationship('ChatParticipant', backref='room', lazy='dynamic', cascade='all, delete-orphan') + + def to_dict(self): + """Convert to dictionary for API responses""" + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'room_type': self.room_type, + 'is_private': self.is_private, + 'is_active': self.is_active, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'last_activity': self.last_activity.isoformat() if self.last_activity else None, + 'created_by': { + 'id': self.created_by.id, + 'nickname': self.created_by.nickname + } if self.created_by else None, + 'related_post': { + 'id': self.related_post.id, + 'title': self.related_post.title + } if self.related_post else None, + 'participant_count': self.participants.count(), + 'message_count': self.messages.count() + } + + def __repr__(self): + return f'' + +class ChatParticipant(db.Model): + __tablename__ = 'chat_participants' + + id = db.Column(db.Integer, primary_key=True) + joined_at = db.Column(db.DateTime, default=datetime.utcnow) + last_seen = db.Column(db.DateTime, default=datetime.utcnow) + is_muted = db.Column(db.Boolean, default=False) + role = db.Column(db.String(50), default='member') # member, moderator, admin + + # Foreign Keys + room_id = db.Column(db.Integer, db.ForeignKey('chat_rooms.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + # Relationships + user = db.relationship('User', backref='chat_participations') + + # Unique constraint + __table_args__ = (db.UniqueConstraint('room_id', 'user_id', name='unique_room_participant'),) + + def to_dict(self): + """Convert to dictionary for API responses""" + return { + 'id': self.id, + 'user': { + 'id': self.user.id, + 'nickname': self.user.nickname, + 'is_admin': self.user.is_admin + }, + 'role': self.role, + 'joined_at': self.joined_at.isoformat() if self.joined_at else None, + 'last_seen': self.last_seen.isoformat() if self.last_seen else None, + 'is_muted': self.is_muted + } + + def __repr__(self): + return f'' + +class ChatMessage(db.Model): + __tablename__ = 'chat_messages' + + id = db.Column(db.Integer, primary_key=True) + content = db.Column(db.Text, nullable=False) + message_type = db.Column(db.String(50), default='text') # text, system, file, image + is_edited = db.Column(db.Boolean, default=False) + is_deleted = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Foreign Keys + room_id = db.Column(db.Integer, db.ForeignKey('chat_rooms.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + reply_to_id = db.Column(db.Integer, db.ForeignKey('chat_messages.id'), nullable=True) # For threaded replies + + # Relationships + user = db.relationship('User', backref='chat_messages') + reply_to = db.relationship('ChatMessage', remote_side=[id], backref='replies') + + def to_dict(self): + """Convert to dictionary for API responses""" + return { + 'id': self.id, + 'content': self.content, + 'message_type': self.message_type, + 'is_edited': self.is_edited, + 'is_deleted': self.is_deleted, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + 'user': { + 'id': self.user.id, + 'nickname': self.user.nickname, + 'is_admin': self.user.is_admin + }, + 'reply_to': { + 'id': self.reply_to.id, + 'content': self.reply_to.content[:100] + '...' if len(self.reply_to.content) > 100 else self.reply_to.content, + 'user_nickname': self.reply_to.user.nickname + } if self.reply_to else None + } + + def __repr__(self): + return f'' diff --git a/app/routes/auth.py b/app/routes/auth.py index e2963ae..da73285 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -6,6 +6,7 @@ from app.routes.reset_password import RequestResetForm, ResetPasswordForm from flask_mail import Message from app.routes.mail import mail from app.utils.token import generate_reset_token, verify_reset_token +from datetime import datetime import re from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField @@ -103,25 +104,61 @@ def logout(): @auth.route('/forgot-password', methods=['GET', 'POST']) def forgot_password(): - """Forgot password page""" + """Forgot password page - sends message to admin instead of email""" if current_user.is_authenticated: return redirect(url_for('main.index')) form = RequestResetForm() if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() - if user: - token = generate_reset_token(user.email) - 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\nTo reset your password, click the link below:\n{reset_url}\n\nIf you did not request this, please ignore this email." + + # Create password reset user if it doesn't exist + reset_user = User.query.filter_by(email='reset_password@motoadventure.local').first() + if not reset_user: + reset_user = User( + nickname='PasswordReset', + email='reset_password@motoadventure.local', + is_active=False # This is a system user ) - try: - mail.send(msg) - except Exception as e: - flash(f"Failed to send reset email: {e}", "danger") - flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info') + reset_user.set_password('temp_password') # Won't be used + db.session.add(reset_user) + db.session.commit() + + # Find admin support room + from app.models import ChatRoom, ChatMessage + admin_room = ChatRoom.query.filter_by(room_type='support').first() + if not admin_room: + # Create admin support room if it doesn't exist + system_user = User.query.filter_by(email='system@motoadventure.local').first() + admin_room = ChatRoom( + name='Technical Support', + description='Administrative support and password resets', + room_type='support', + is_private=False, + is_active=True, + created_by_id=system_user.id if system_user else 1 + ) + db.session.add(admin_room) + db.session.commit() + + # Create the password reset message + if user: + message_content = f"A user with email '{user.email}' (nickname: {user.nickname}) needs their password to be changed. Please assist with password reset." + else: + message_content = f"Someone with email '{form.email.data}' requested a password reset, but no account exists with this email. Please check if this user needs assistance creating an account." + + reset_message = ChatMessage( + content=message_content, + room_id=admin_room.id, + sender_id=reset_user.id, + is_system_message=True + ) + db.session.add(reset_message) + + # Update room activity + admin_room.last_activity = datetime.utcnow() + db.session.commit() + + 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 @@ -145,6 +182,50 @@ def reset_password(token): return redirect(url_for('auth.login')) return render_template('auth/reset_password.html', form=form) +@auth.route('/change-password', methods=['POST']) +@login_required +def change_password(): + """Change user password""" + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + # Validate inputs + if not all([current_password, new_password, confirm_password]): + flash('All password fields are required.', 'error') + return redirect(url_for('community.profile')) + + # Check current password + if not current_user.check_password(current_password): + flash('Current password is incorrect.', 'error') + return redirect(url_for('community.profile')) + + # Validate new password + if len(new_password) < 6: + flash('New password must be at least 6 characters long.', 'error') + return redirect(url_for('community.profile')) + + # Check password confirmation + if new_password != confirm_password: + flash('New password and confirmation do not match.', 'error') + return redirect(url_for('community.profile')) + + # Check if new password is different from current + if current_user.check_password(new_password): + flash('New password must be different from your current password.', 'error') + return redirect(url_for('community.profile')) + + try: + # Update password + current_user.set_password(new_password) + db.session.commit() + flash('Password updated successfully!', 'success') + except Exception as e: + db.session.rollback() + flash('An error occurred while updating your password. Please try again.', 'error') + + return redirect(url_for('community.profile')) + def is_valid_password(password): """Validate password strength""" if len(password) < 8: diff --git a/app/routes/chat.py b/app/routes/chat.py new file mode 100644 index 0000000..7071bd5 --- /dev/null +++ b/app/routes/chat.py @@ -0,0 +1,125 @@ +""" +Chat web interface routes +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 app.extensions import db +from app.models import ChatRoom, ChatMessage, ChatParticipant, Post + +# Create blueprint +chat = Blueprint('chat', __name__) + +@chat.route('/') +@login_required +def index(): + """Chat main page with room list and rules""" + # Get user's recent chat rooms + user_rooms = db.session.query(ChatRoom).join(ChatParticipant).filter( + and_( + ChatParticipant.user_id == current_user.id, + ChatRoom.is_active == True + ) + ).order_by(desc(ChatRoom.last_activity)).limit(10).all() + + # Get public rooms that are active + public_rooms = ChatRoom.query.filter( + ChatRoom.is_active == True, + ChatRoom.is_private == False + ).order_by(desc(ChatRoom.last_activity)).limit(10).all() + + return render_template('chat/index.html', + user_rooms=user_rooms, + public_rooms=public_rooms) + +@chat.route('/room/') +@login_required +def room(room_id): + """Chat room interface""" + room = ChatRoom.query.get_or_404(room_id) + + # Check access + if room.is_private: + participant = ChatParticipant.query.filter_by( + room_id=room_id, + user_id=current_user.id + ).first() + if not participant: + flash('You do not have access to this chat room.', 'error') + return redirect(url_for('chat.index')) + + # Get or create participant record + participant = ChatParticipant.query.filter_by( + room_id=room_id, + user_id=current_user.id + ).first() + + if not participant and not room.is_private: + # Auto-join public rooms + participant = ChatParticipant( + room_id=room_id, + user_id=current_user.id, + role='member' + ) + db.session.add(participant) + db.session.commit() + + # Get recent messages + messages = ChatMessage.query.filter_by( + room_id=room_id, + is_deleted=False + ).order_by(ChatMessage.created_at).limit(50).all() + + # Get participants + participants = ChatParticipant.query.filter_by(room_id=room_id).all() + + return render_template('chat/room.html', + room=room, + messages=messages, + participants=participants, + current_participant=participant) + +@chat.route('/create') +@login_required +def create_room_form(): + """Show create room form""" + # Get available posts for post discussions + recent_posts = Post.query.filter_by(published=True).order_by( + desc(Post.created_at) + ).limit(20).all() + + return render_template('chat/create_room.html', posts=recent_posts) + +@chat.route('/support') +@login_required +def support(): + """Admin support page""" + # Get user's recent support tickets (rooms they created for support) + recent_tickets = ChatRoom.query.filter( + ChatRoom.room_type == 'admin_support', + ChatRoom.created_by_id == current_user.id + ).order_by(desc(ChatRoom.created_at)).limit(5).all() + + return render_template('chat/support.html', + recent_tickets=recent_tickets) + +@chat.route('/embed/') +@login_required +def embed_post_chat(post_id): + """Embedded chat widget for post pages""" + post = Post.query.get_or_404(post_id) + + # Find existing discussion room + discussion_room = ChatRoom.query.filter( + and_( + ChatRoom.room_type == 'post_discussion', + ChatRoom.related_post_id == post_id, + ChatRoom.is_active == True + ) + ).first() + + return render_template('chat/embed.html', + post=post, + discussion_room=discussion_room) diff --git a/app/routes/chat_api.py b/app/routes/chat_api.py new file mode 100644 index 0000000..2de3c51 --- /dev/null +++ b/app/routes/chat_api.py @@ -0,0 +1,560 @@ +""" +Chat API routes for mobile app compatibility +Provides RESTful endpoints for chat functionality +""" +from flask import Blueprint, request, jsonify, current_app +from flask_login import login_required, current_user +from sqlalchemy import desc, and_, or_ +from datetime import datetime, timedelta +import re + +from app.extensions import db +from app.models import ChatRoom, ChatMessage, ChatParticipant, Post, User + +# Create blueprint +chat_api = Blueprint('chat_api', __name__) + +# Chat rules and guidelines +CHAT_RULES = [ + "Be respectful and courteous to all community members", + "No offensive language, harassment, or personal attacks", + "Stay on topic - use post-specific chats for discussions about routes", + "No spam or promotional content without permission", + "Share useful tips and experiences about motorcycle adventures", + "Help newcomers and answer questions when you can", + "Report inappropriate behavior to administrators", + "Keep conversations constructive and helpful" +] + +# Profanity filter (basic implementation) +BLOCKED_WORDS = [ + 'spam', 'scam', 'fake', 'stupid', 'idiot', 'hate' + # Add more words as needed +] + +def contains_blocked_content(text): + """Check if text contains blocked words""" + text_lower = text.lower() + return any(word in text_lower for word in BLOCKED_WORDS) + +@chat_api.route('/rules', methods=['GET']) +def get_chat_rules(): + """Get chat rules and guidelines""" + return jsonify({ + 'success': True, + 'rules': CHAT_RULES + }) + +@chat_api.route('/rooms', methods=['GET']) +@login_required +def get_chat_rooms(): + """Get list of available chat rooms for the user""" + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + room_type = request.args.get('type', None) + + # Base query - only active rooms the user can access + query = ChatRoom.query.filter(ChatRoom.is_active == True) + + # Filter by type if specified + if room_type: + query = query.filter(ChatRoom.room_type == room_type) + + # Order by last activity + query = query.order_by(desc(ChatRoom.last_activity)) + + # Paginate + rooms = query.paginate( + page=page, per_page=per_page, error_out=False + ) + + return jsonify({ + 'success': True, + 'rooms': [room.to_dict() for room in rooms.items], + 'pagination': { + 'page': page, + 'pages': rooms.pages, + 'per_page': per_page, + 'total': rooms.total, + 'has_next': rooms.has_next, + 'has_prev': rooms.has_prev + } + }) + +@chat_api.route('/rooms', methods=['POST']) +@login_required +def create_chat_room(): + """Create a new chat room""" + data = request.get_json() + + if not data or not data.get('name'): + return jsonify({ + 'success': False, + 'error': 'Room name is required' + }), 400 + + # Validate input + name = data.get('name', '').strip() + description = data.get('description', '').strip() + room_type = data.get('room_type', 'general') + related_post_id = data.get('related_post_id') + + if len(name) < 3 or len(name) > 100: + return jsonify({ + 'success': False, + 'error': 'Room name must be between 3 and 100 characters' + }), 400 + + # Check if room already exists + existing_room = ChatRoom.query.filter_by(name=name).first() + if existing_room: + return jsonify({ + 'success': False, + 'error': 'A room with this name already exists' + }), 400 + + # Validate related post if specified + if related_post_id: + post = Post.query.get(related_post_id) + if not post: + return jsonify({ + 'success': False, + 'error': 'Related post not found' + }), 404 + + try: + # Create room + room = ChatRoom( + name=name, + description=description, + room_type=room_type, + 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' + ) + db.session.add(participant) + + # Add system message + system_message = ChatMessage( + room_id=room.id, + user_id=current_user.id, + content=f"Welcome to {name}! This chat room was created for motorcycle adventure discussions.", + message_type='system' + ) + db.session.add(system_message) + + db.session.commit() + + return jsonify({ + 'success': True, + 'room': room.to_dict() + }), 201 + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error creating chat room: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to create chat room' + }), 500 + +@chat_api.route('/rooms/', methods=['GET']) +@login_required +def get_chat_room(room_id): + """Get chat room details""" + room = ChatRoom.query.get_or_404(room_id) + + # Check if user has access + if room.is_private: + participant = ChatParticipant.query.filter_by( + room_id=room_id, + user_id=current_user.id + ).first() + if not participant: + return jsonify({ + 'success': False, + 'error': 'Access denied' + }), 403 + + return jsonify({ + 'success': True, + 'room': room.to_dict() + }) + +@chat_api.route('/rooms//join', methods=['POST']) +@login_required +def join_chat_room(room_id): + """Join a chat room""" + room = ChatRoom.query.get_or_404(room_id) + + # Check if already a participant + existing_participant = ChatParticipant.query.filter_by( + room_id=room_id, + user_id=current_user.id + ).first() + + if existing_participant: + return jsonify({ + 'success': True, + 'message': 'Already a member of this room' + }) + + try: + # Add user as participant + participant = ChatParticipant( + room_id=room_id, + user_id=current_user.id, + role='member' + ) + db.session.add(participant) + + # Add system message + system_message = ChatMessage( + room_id=room_id, + user_id=current_user.id, + content=f"{current_user.nickname} joined the chat", + message_type='system' + ) + db.session.add(system_message) + + # Update room activity + room.last_activity = datetime.utcnow() + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Successfully joined the room' + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error joining chat room: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to join room' + }), 500 + +@chat_api.route('/rooms//messages', methods=['GET']) +@login_required +def get_chat_messages(room_id): + """Get messages from a chat room""" + room = ChatRoom.query.get_or_404(room_id) + + # Check access + if room.is_private: + participant = ChatParticipant.query.filter_by( + room_id=room_id, + user_id=current_user.id + ).first() + if not participant: + return jsonify({ + 'success': False, + 'error': 'Access denied' + }), 403 + + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + + # Get messages (newest first for mobile scrolling) + messages = ChatMessage.query.filter_by( + room_id=room_id, + is_deleted=False + ).order_by(desc(ChatMessage.created_at)).paginate( + page=page, per_page=per_page, error_out=False + ) + + # Update user's last seen + participant = ChatParticipant.query.filter_by( + room_id=room_id, + user_id=current_user.id + ).first() + if participant: + participant.last_seen = datetime.utcnow() + db.session.commit() + + return jsonify({ + 'success': True, + 'messages': [msg.to_dict() for msg in reversed(messages.items)], + 'pagination': { + 'page': page, + 'pages': messages.pages, + 'per_page': per_page, + 'total': messages.total, + 'has_next': messages.has_next, + 'has_prev': messages.has_prev + } + }) + +@chat_api.route('/rooms//messages', methods=['POST']) +@login_required +def send_message(room_id): + """Send a message to a chat room""" + room = ChatRoom.query.get_or_404(room_id) + data = request.get_json() + + if not data or not data.get('content'): + return jsonify({ + 'success': False, + 'error': 'Message content is required' + }), 400 + + content = data.get('content', '').strip() + message_type = data.get('message_type', 'text') + reply_to_id = data.get('reply_to_id') + + # Validate content + if len(content) < 1 or len(content) > 2000: + return jsonify({ + 'success': False, + 'error': 'Message must be between 1 and 2000 characters' + }), 400 + + # Check for blocked content + if contains_blocked_content(content): + return jsonify({ + 'success': False, + 'error': 'Message contains inappropriate content' + }), 400 + + # Check if user is participant + participant = ChatParticipant.query.filter_by( + room_id=room_id, + user_id=current_user.id + ).first() + + if not participant: + return jsonify({ + 'success': False, + 'error': 'You must join the room first' + }), 403 + + if participant.is_muted: + return jsonify({ + 'success': False, + 'error': 'You are muted in this room' + }), 403 + + try: + # Create message + message = ChatMessage( + room_id=room_id, + user_id=current_user.id, + content=content, + message_type=message_type, + reply_to_id=reply_to_id + ) + db.session.add(message) + + # Update room activity + room.last_activity = datetime.utcnow() + + # Update participant last seen + participant.last_seen = datetime.utcnow() + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': message.to_dict() + }), 201 + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error sending message: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to send message' + }), 500 + +@chat_api.route('/rooms//participants', methods=['GET']) +@login_required +def get_room_participants(room_id): + """Get participants of a chat room""" + room = ChatRoom.query.get_or_404(room_id) + + participants = ChatParticipant.query.filter_by(room_id=room_id).all() + + return jsonify({ + 'success': True, + 'participants': [p.to_dict() for p in participants] + }) + +@chat_api.route('/admin-support', methods=['POST']) +@login_required +def create_admin_support_chat(): + """Create a chat room for admin support (e.g., password reset)""" + data = request.get_json() + reason = data.get('reason', 'general_support') + description = data.get('description', '') + + # Check if user already has an active admin support chat + existing_room = ChatRoom.query.filter( + and_( + ChatRoom.room_type == 'admin_support', + ChatRoom.created_by_id == current_user.id, + ChatRoom.is_active == True + ) + ).first() + + if existing_room: + return jsonify({ + 'success': True, + 'room': existing_room.to_dict(), + 'message': 'Using existing support chat' + }) + + try: + # Create admin support room + room_name = f"Support - {current_user.nickname} - {reason}" + room = ChatRoom( + name=room_name, + description=f"Admin support chat for {current_user.nickname}. Reason: {reason}", + room_type='admin_support', + is_private=True, + created_by_id=current_user.id + ) + db.session.add(room) + db.session.flush() + + # Add user as participant + participant = ChatParticipant( + room_id=room.id, + user_id=current_user.id, + role='member' + ) + db.session.add(participant) + + # Add all admins as participants + admins = User.query.filter_by(is_admin=True).all() + for admin in admins: + if admin.id != current_user.id: + admin_participant = ChatParticipant( + room_id=room.id, + user_id=admin.id, + role='admin' + ) + db.session.add(admin_participant) + + # Add initial message + initial_message = ChatMessage( + room_id=room.id, + user_id=current_user.id, + content=f"Hello! I need help with: {reason}. {description}", + message_type='text' + ) + db.session.add(initial_message) + + db.session.commit() + + return jsonify({ + 'success': True, + 'room': room.to_dict() + }), 201 + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error creating admin support chat: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to create support chat' + }), 500 + +@chat_api.route('/post//discussion', methods=['POST']) +@login_required +def create_post_discussion(post_id): + """Create or get discussion chat for a specific post""" + post = Post.query.get_or_404(post_id) + + # Check if discussion already exists + existing_room = ChatRoom.query.filter( + and_( + ChatRoom.room_type == 'post_discussion', + ChatRoom.related_post_id == post_id, + ChatRoom.is_active == True + ) + ).first() + + if existing_room: + # Join the existing room if not already a participant + participant = ChatParticipant.query.filter_by( + room_id=existing_room.id, + user_id=current_user.id + ).first() + + if not participant: + participant = ChatParticipant( + room_id=existing_room.id, + user_id=current_user.id, + role='member' + ) + db.session.add(participant) + db.session.commit() + + return jsonify({ + 'success': True, + 'room': existing_room.to_dict(), + 'message': 'Joined existing discussion' + }) + + try: + # Create new discussion room + room_name = f"Discussion: {post.title}" + room = ChatRoom( + name=room_name, + description=f"Discussion about the post: {post.title}", + room_type='post_discussion', + created_by_id=current_user.id, + related_post_id=post_id + ) + db.session.add(room) + db.session.flush() + + # Add creator as participant + participant = ChatParticipant( + room_id=room.id, + user_id=current_user.id, + role='moderator' + ) + db.session.add(participant) + + # Add post author as participant if different + if post.author_id != current_user.id: + author_participant = ChatParticipant( + room_id=room.id, + user_id=post.author_id, + role='moderator' + ) + db.session.add(author_participant) + + # Add initial message + initial_message = ChatMessage( + room_id=room.id, + user_id=current_user.id, + content=f"Started discussion about: {post.title}", + message_type='system' + ) + db.session.add(initial_message) + + db.session.commit() + + return jsonify({ + 'success': True, + 'room': room.to_dict() + }), 201 + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error creating post discussion: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to create discussion' + }), 500 diff --git a/app/templates/auth/forgot_password.html b/app/templates/auth/forgot_password.html index 052404a..7ac5584 100644 --- a/app/templates/auth/forgot_password.html +++ b/app/templates/auth/forgot_password.html @@ -1,27 +1,67 @@ {% extends "base.html" %} -{% block title %}Forgot Password{% endblock %} +{% block title %}Password Reset Request{% endblock %} {% block content %} -
-
-
-

Forgot your password?

-

Enter your email to receive a reset link.

-
-
- {{ form.hidden_tag() }} -
- {{ form.email.label(class="block text-sm font-medium text-gray-700 mb-1") }} - {{ form.email(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") }} - {% if form.email.errors %} -
- {% for error in form.email.errors %} -

{{ error }}

- {% endfor %} -
- {% endif %} +
+
+
+
+ +

Password Reset Request

+

We'll help you get back into your account

- {{ form.submit(class="w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white py-2 px-4 rounded-lg hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition font-medium") }} - + +
+
+
+ +
+

How it works:

+

Enter your email address and we'll send a password reset request to our administrators. They will contact you directly to help reset your password securely.

+
+
+
+ +
+ {{ form.hidden_tag() }} +
+ {{ form.email.label(class="block text-sm font-semibold text-gray-700 mb-2") }} +
+
+ +
+ {{ form.email(class="w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200", placeholder="Enter your email address") }} +
+ {% if form.email.errors %} +
+ {% for error in form.email.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+ + {{ form.submit(class="w-full bg-gradient-to-r from-orange-600 to-red-600 text-white py-3 px-6 rounded-lg hover:from-orange-700 hover:to-red-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-all duration-200 font-semibold text-lg") }} +
+ +
+

Remember your password?

+ + Back to Login + +
+ +
+
+ +
+

Security Note:

+

Our administrators will verify your identity before resetting your password to keep your account secure.

+
+
+
+
+
{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index dc263ef..2ada83a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -21,6 +21,9 @@
+ +{% include 'chat/embed.html' %} + {% endblock %} {% block scripts %} diff --git a/app/templates/community/profile.html b/app/templates/community/profile.html index fb54124..169a1e3 100644 --- a/app/templates/community/profile.html +++ b/app/templates/community/profile.html @@ -68,6 +68,92 @@
+ +
+
+
+
+
+ +
+

Change Password

+

Update your account password for security

+
+
+ +
+
+
+ + + +
+
@@ -280,6 +366,67 @@ function closeDeleteModal() { document.getElementById('deleteModal').classList.add('hidden'); } +// Password card toggle functionality +function togglePasswordCard() { + const form = document.getElementById('passwordChangeForm'); + const toggle = document.getElementById('passwordCardToggle'); + + if (form && toggle) { + if (form.classList.contains('hidden')) { + form.classList.remove('hidden'); + toggle.classList.remove('fa-chevron-down'); + toggle.classList.add('fa-chevron-up'); + } else { + form.classList.add('hidden'); + toggle.classList.remove('fa-chevron-up'); + toggle.classList.add('fa-chevron-down'); + } + } +} + +// Make sure DOM is loaded before attaching event listeners +document.addEventListener('DOMContentLoaded', function() { + // Ensure the toggle function is available globally + window.togglePasswordCard = togglePasswordCard; +}); + +// Password change form validation +document.getElementById('changePasswordForm').addEventListener('submit', function(e) { + const newPassword = document.getElementById('new_password').value; + const confirmPassword = document.getElementById('confirm_password').value; + + if (newPassword !== confirmPassword) { + e.preventDefault(); + alert('New password and confirm password do not match. Please try again.'); + document.getElementById('confirm_password').focus(); + return false; + } + + if (newPassword.length < 6) { + e.preventDefault(); + alert('Password must be at least 6 characters long.'); + document.getElementById('new_password').focus(); + return false; + } +}); + +// Real-time password confirmation validation +document.getElementById('confirm_password').addEventListener('input', function() { + const newPassword = document.getElementById('new_password').value; + const confirmPassword = this.value; + + if (confirmPassword && newPassword !== confirmPassword) { + this.style.borderColor = '#ef4444'; + this.style.backgroundColor = '#fef2f2'; + } else if (confirmPassword && newPassword === confirmPassword) { + this.style.borderColor = '#10b981'; + this.style.backgroundColor = '#f0fdf4'; + } else { + this.style.borderColor = '#d1d5db'; + this.style.backgroundColor = '#ffffff'; + } +}); + // Close modal when clicking outside document.getElementById('deleteModal').addEventListener('click', function(e) { if (e.target === this) { diff --git a/migrations/add_chat_system.py b/migrations/add_chat_system.py new file mode 100755 index 0000000..7b358ae --- /dev/null +++ b/migrations/add_chat_system.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +Chat System Database Migration +Adds chat functionality to the Moto Adventure application. +""" + +import os +import sys +from datetime import datetime + +# Add the app directory to the Python path +sys.path.insert(0, '/opt/site') + +from app import create_app, db +from app.models import ChatRoom, ChatMessage, ChatParticipant + +def run_migration(): + """Run the chat system migration""" + app = create_app() + + with app.app_context(): + print(f"[{datetime.now()}] Starting chat system migration...") + + try: + # Create the chat tables + print("Creating chat system tables...") + db.create_all() + + # Get or create system user for welcome messages and room ownership + print("Setting up system user...") + from app.models import User + + system_user = User.query.filter_by(email='system@motoadventure.local').first() + if not system_user: + system_user = User( + nickname='System', + email='system@motoadventure.local', + is_admin=True, + is_active=True + ) + system_user.set_password('system123!') # Random password, won't be used + db.session.add(system_user) + db.session.commit() + print(" ✓ Created system user") + + # Create default chat rooms + print("Creating default chat rooms...") + + # General chat room + general_room = ChatRoom.query.filter_by(name="General Discussion").first() + if not general_room: + general_room = ChatRoom( + name="General Discussion", + description="General conversation about motorcycles and adventures", + is_private=False, + is_active=True, + created_by_id=system_user.id + ) + db.session.add(general_room) + print(" ✓ Created General Discussion room") + + # Technical support room + support_room = ChatRoom.query.filter_by(name="Technical Support").first() + if not support_room: + support_room = ChatRoom( + name="Technical Support", + description="Get help with technical issues and app support", + is_private=False, + is_active=True, + room_type="admin_support", + created_by_id=system_user.id + ) + db.session.add(support_room) + print(" ✓ Created Technical Support room") + + # Route planning room + routes_room = ChatRoom.query.filter_by(name="Route Planning").first() + if not routes_room: + routes_room = ChatRoom( + name="Route Planning", + description="Discuss routes, share GPX files, and plan adventures", + is_private=False, + is_active=True, + created_by_id=system_user.id + ) + db.session.add(routes_room) + print(" ✓ Created Route Planning room") + + # Gear & Equipment room + gear_room = ChatRoom.query.filter_by(name="Gear & Equipment").first() + if not gear_room: + gear_room = ChatRoom( + name="Gear & Equipment", + description="Discuss motorcycle gear, equipment reviews, and recommendations", + is_private=False, + is_active=True, + created_by_id=system_user.id + ) + db.session.add(gear_room) + print(" ✓ Created Gear & Equipment room") + + # Commit the changes + db.session.commit() + print("✓ Default chat rooms created successfully") + + # Add welcome messages to rooms + print("Adding welcome messages...") + + # Add welcome messages if they don't exist + rooms_with_messages = [ + (general_room, "Welcome to the General Discussion! Share your motorcycle adventures and connect with fellow riders."), + (support_room, "Welcome to Technical Support! Our administrators are here to help with any issues or questions."), + (routes_room, "Welcome to Route Planning! Share your favorite routes and discover new adventures."), + (gear_room, "Welcome to Gear & Equipment! Discuss the best gear for your motorcycle adventures.") + ] + + for room, message_text in rooms_with_messages: + existing_message = ChatMessage.query.filter_by( + room_id=room.id, + user_id=system_user.id, + message_type='system' + ).first() + + if not existing_message: + welcome_message = ChatMessage( + room_id=room.id, + user_id=system_user.id, + content=message_text, + message_type='system' + ) + db.session.add(welcome_message) + + db.session.commit() + print("✓ Welcome messages added") + + print(f"[{datetime.now()}] Chat system migration completed successfully!") + print("\nChat System Features:") + print(" • User-to-user messaging") + print(" • Admin support channels") + print(" • Post-specific discussions") + print(" • Mobile app compatibility") + print(" • Real-time messaging") + print(" • Profanity filtering") + print(" • Message moderation") + print("\nDefault Chat Rooms:") + print(" • General Discussion") + print(" • Technical Support") + print(" • Route Planning") + print(" • Gear & Equipment") + print("\nAPI Endpoints Available:") + print(" • /api/v1/chat/* (Mobile app integration)") + print(" • /chat/* (Web interface)") + + except Exception as e: + print(f"[ERROR] Migration failed: {e}") + db.session.rollback() + raise e + +if __name__ == '__main__': + run_migration()