from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file from flask_login import login_required, current_user from models import db, Conversation, User, Message, MessageAttachment, DocuPulseSettings from forms import ConversationForm from routes.auth import require_password_change from utils import log_event, create_notification, get_unread_count import os from werkzeug.utils import secure_filename from datetime import datetime conversations_bp = Blueprint('conversations', __name__, url_prefix='/conversations') # Configure upload settings UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads') ALLOWED_EXTENSIONS = { # Documents 'pdf', 'docx', 'doc', 'txt', 'rtf', 'odt', 'md', 'csv', # Spreadsheets 'xlsx', 'xls', 'ods', 'xlsm', # Presentations 'pptx', 'ppt', 'odp', # Images 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'tiff', # Archives 'zip', 'rar', '7z', 'tar', 'gz', # Code/Text 'py', 'js', 'html', 'css', 'json', 'xml', 'sql', 'sh', 'bat', # Audio 'mp3', 'wav', 'ogg', 'm4a', 'flac', # Video 'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', # CAD/Design 'dwg', 'dxf', 'ai', 'psd', 'eps', 'indd', # Other 'eml', 'msg', 'vcf', 'ics' } MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS def get_file_extension(filename): return filename.rsplit('.', 1)[1].lower() if '.' in filename else '' @conversations_bp.route('/') @login_required @require_password_change def conversations(): search = request.args.get('search', '').strip() if current_user.is_admin: query = Conversation.query else: query = Conversation.query.filter(Conversation.members.any(id=current_user.id)) if search: query = query.filter(Conversation.name.ilike(f'%{search}%')) conversations = query.order_by(Conversation.created_at.desc()).all() unread_count = get_unread_count(current_user.id) usage_stats = DocuPulseSettings.get_usage_stats() return render_template('conversations/conversations.html', conversations=conversations, search=search, unread_notifications=unread_count, usage_stats=usage_stats) @conversations_bp.route('/create', methods=['GET', 'POST']) @login_required @require_password_change def create_conversation(): if not (current_user.is_admin or current_user.is_manager): flash('Only administrators and managers can create conversations.', 'error') return redirect(url_for('conversations.conversations')) form = ConversationForm() if form.validate_on_submit(): conversation = Conversation( name=form.name.data, description=form.description.data, created_by=current_user.id ) # Add creator as a member conversation.members.append(current_user) creator_id = current_user.id # Add selected members, skipping the creator if present for user_id in form.members.data: if int(user_id) != creator_id: user = User.query.get(user_id) if user and user not in conversation.members: conversation.members.append(user) db.session.add(conversation) db.session.commit() # Create notifications for all members except the creator for member in conversation.members: if member.id != current_user.id: create_notification( notif_type='conversation_invite', user_id=member.id, sender_id=current_user.id, details={ 'message': f'You have been added to conversation "{conversation.name}"', 'conversation_id': conversation.id, 'conversation_name': conversation.name, 'invited_by': f"{current_user.username} {current_user.last_name}", 'timestamp': datetime.utcnow().isoformat() } ) # Log conversation creation log_event( event_type='conversation_create', details={ 'conversation_id': conversation.id, 'created_by': current_user.id, 'created_by_name': f"{current_user.username} {current_user.last_name}", 'name': conversation.name, 'description': conversation.description, 'member_count': len(conversation.members), 'member_ids': [member.id for member in conversation.members] } ) db.session.commit() flash('Conversation created successfully!', 'success') return redirect(url_for('conversations.conversations')) unread_count = get_unread_count(current_user.id) return render_template('conversations/create_conversation.html', form=form, unread_notifications=unread_count) @conversations_bp.route('/') @login_required @require_password_change def conversation(conversation_id): conversation = Conversation.query.get_or_404(conversation_id) # Check if user is a member if not current_user.is_admin and current_user not in conversation.members: flash('You do not have access to this conversation.', 'error') return redirect(url_for('conversations.conversations')) # Log conversation open log_event( event_type='conversation_open', details={ 'conversation_id': conversation.id, 'conversation_name': conversation.name, 'user_id': current_user.id, 'user_name': f"{current_user.username} {current_user.last_name}", 'message_count': Message.query.filter_by(conversation_id=conversation_id).count() } ) db.session.commit() # Query messages directly using the Message model messages = Message.query.filter_by(conversation_id=conversation_id).order_by(Message.created_at.asc()).all() # Get all users for member selection (needed for admin and manager) all_users = User.query.all() if (current_user.is_admin or current_user.is_manager) else None unread_count = get_unread_count(current_user.id) return render_template('conversations/conversation.html', conversation=conversation, messages=messages, all_users=all_users, unread_notifications=unread_count) @conversations_bp.route('//members') @login_required @require_password_change def conversation_members(conversation_id): conversation = Conversation.query.get_or_404(conversation_id) if not current_user.is_admin and current_user not in conversation.members: flash('You do not have access to this conversation.', 'error') return redirect(url_for('conversations.conversations')) if not (current_user.is_admin or current_user.is_manager): flash('Only administrators and managers can manage conversation members.', 'error') return redirect(url_for('conversations.conversation', conversation_id=conversation_id)) available_users = User.query.filter(~User.id.in_([m.id for m in conversation.members])).all() unread_count = get_unread_count(current_user.id) return render_template('conversations/conversation_members.html', conversation=conversation, available_users=available_users, unread_notifications=unread_count) @conversations_bp.route('//members/add', methods=['POST']) @login_required @require_password_change def add_member(conversation_id): conversation = Conversation.query.get_or_404(conversation_id) if not current_user.is_admin: flash('Only administrators can manage conversation members.', 'error') return redirect(url_for('conversations.conversation', conversation_id=conversation_id)) user_id = request.form.get('user_id') if not user_id: flash('Please select a user to add.', 'error') return redirect(url_for('conversations.conversation_members', conversation_id=conversation_id)) user = User.query.get_or_404(user_id) if user in conversation.members: flash('User is already a member of this conversation.', 'error') else: conversation.members.append(user) db.session.commit() # Create notification for the invited user create_notification( notif_type='conversation_invite', user_id=user.id, sender_id=current_user.id, details={ 'message': f'You have been invited to join conversation "{conversation.name}"', 'conversation_id': conversation.id, 'conversation_name': conversation.name, 'invited_by': f"{current_user.username} {current_user.last_name}", 'timestamp': datetime.utcnow().isoformat() } ) # Log member addition log_event( event_type='conversation_member_add', details={ 'conversation_id': conversation.id, 'conversation_name': conversation.name, 'added_by': current_user.id, 'added_by_name': f"{current_user.username} {current_user.last_name}", 'added_user_id': user.id, 'added_user_name': f"{user.username} {user.last_name}", 'added_user_email': user.email } ) db.session.commit() flash(f'{user.username} has been added to the conversation.', 'success') return redirect(url_for('conversations.conversation_members', conversation_id=conversation_id)) @conversations_bp.route('//members//remove', methods=['POST']) @login_required @require_password_change def remove_member(conversation_id, user_id): conversation = Conversation.query.get_or_404(conversation_id) if not current_user.is_admin: flash('Only administrators can manage conversation members.', 'error') return redirect(url_for('conversations.conversation', conversation_id=conversation_id)) if user_id == conversation.created_by: flash('Cannot remove the conversation creator.', 'error') else: user = User.query.get_or_404(user_id) if user not in conversation.members: flash('User is not a member of this conversation.', 'error') else: conversation.members.remove(user) db.session.commit() # Create notification for the removed user create_notification( notif_type='conversation_invite_removed', user_id=user.id, sender_id=current_user.id, details={ 'message': f'You have been removed from conversation "{conversation.name}"', 'conversation_id': conversation.id, 'conversation_name': conversation.name, 'removed_by': f"{current_user.username} {current_user.last_name}", 'timestamp': datetime.utcnow().isoformat() } ) # Log member removal log_event( event_type='conversation_member_remove', details={ 'conversation_id': conversation.id, 'conversation_name': conversation.name, 'removed_by': current_user.id, 'removed_by_name': f"{current_user.username} {current_user.last_name}", 'removed_user_id': user.id, 'removed_user_name': f"{user.username} {user.last_name}", 'removed_user_email': user.email } ) db.session.commit() flash('User has been removed from the conversation.', 'success') return redirect(url_for('conversations.conversation_members', conversation_id=conversation_id)) @conversations_bp.route('//edit', methods=['GET', 'POST']) @login_required @require_password_change def edit_conversation(conversation_id): if not current_user.is_admin: flash('Only administrators can edit conversations.', 'error') return redirect(url_for('conversations.conversations')) conversation = Conversation.query.get_or_404(conversation_id) form = ConversationForm(obj=conversation) if request.method == 'POST': # Store old values for comparison old_values = { 'name': conversation.name, 'description': conversation.description, 'member_ids': [member.id for member in conversation.members] } # Update name and description from form data conversation.name = form.name.data conversation.description = form.description.data # Get members from the form data and convert to integers member_ids = [int(id) for id in request.form.getlist('members')] # Update members current_member_ids = {user.id for user in conversation.members} new_member_ids = set(member_ids) # Remove members that are no longer in the list members_to_remove = current_member_ids - new_member_ids for member_id in members_to_remove: if member_id != conversation.created_by: # Don't remove the creator user = User.query.get(member_id) if user: conversation.members.remove(user) # Create notification for removed user create_notification( notif_type='conversation_invite_removed', user_id=user.id, sender_id=current_user.id, details={ 'message': f'You have been removed from conversation "{conversation.name}"', 'conversation_id': conversation.id, 'conversation_name': conversation.name, 'removed_by': f"{current_user.username} {current_user.last_name}", 'timestamp': datetime.utcnow().isoformat() } ) # Add new members members_to_add = new_member_ids - current_member_ids for member_id in members_to_add: user = User.query.get(member_id) if user and user not in conversation.members: conversation.members.append(user) # Create notification for the invited user create_notification( notif_type='conversation_invite', user_id=user.id, sender_id=current_user.id, details={ 'message': f'You have been invited to join conversation "{conversation.name}"', 'conversation_id': conversation.id, 'conversation_name': conversation.name, 'invited_by': f"{current_user.username} {current_user.last_name}", 'timestamp': datetime.utcnow().isoformat() } ) db.session.commit() # Log conversation update log_event( event_type='conversation_update', details={ 'conversation_id': conversation.id, 'updated_by': current_user.id, 'updated_by_name': f"{current_user.username} {current_user.last_name}", 'old_values': old_values, 'new_values': { 'name': conversation.name, 'description': conversation.description, 'member_ids': [member.id for member in conversation.members], 'member_names': [f"{member.username} {member.last_name}" for member in conversation.members] } } ) db.session.commit() flash('Conversation updated successfully!', 'success') # Check if redirect parameter is provided redirect_url = request.args.get('redirect') if redirect_url: return redirect(redirect_url) return redirect(url_for('conversations.conversations')) # Prepopulate form members with current members form.members.data = [str(user.id) for user in conversation.members] unread_count = get_unread_count(current_user.id) return render_template('conversations/create_conversation.html', form=form, edit_mode=True, conversation=conversation, unread_notifications=unread_count) @conversations_bp.route('//delete', methods=['POST']) @login_required @require_password_change def delete_conversation(conversation_id): if not current_user.is_admin: flash('Only administrators can delete conversations.', 'error') return redirect(url_for('conversations.conversations')) conversation = Conversation.query.get_or_404(conversation_id) # Log conversation deletion log_event( event_type='conversation_delete', details={ 'conversation_id': conversation.id, 'conversation_name': conversation.name, 'deleted_by': current_user.id, 'deleted_by_name': f"{current_user.username} {current_user.last_name}", 'member_count': len(conversation.members), 'message_count': Message.query.filter_by(conversation_id=conversation_id).count() } ) db.session.commit() # Get all messages in the conversation messages = Message.query.filter_by(conversation_id=conversation_id).all() # Delete attachments and their files first for message in messages: for attachment in message.attachments: # Delete the physical file if os.path.exists(attachment.path): os.remove(attachment.path) # Delete the attachment record db.session.delete(attachment) # Delete all messages in the conversation for message in messages: db.session.delete(message) # Delete the conversation db.session.delete(conversation) db.session.commit() flash('Conversation has been deleted successfully.', 'success') return redirect(url_for('conversations.conversations')) @conversations_bp.route('//messages', methods=['GET']) @login_required @require_password_change def get_messages(conversation_id): conversation = Conversation.query.get_or_404(conversation_id) # Check if user is a member if not current_user.is_admin and current_user not in conversation.members: return jsonify({'error': 'Unauthorized'}), 403 # Get the last message ID from the request last_message_id = request.args.get('last_message_id', type=int) # Query for new messages query = Message.query.filter_by(conversation_id=conversation_id) if last_message_id: query = query.filter(Message.id > last_message_id) messages = query.order_by(Message.created_at.asc()).all() # Format messages for response message_data = [{ 'id': message.id, 'content': message.content, 'created_at': message.created_at.strftime('%b %d, %Y %H:%M'), 'sender_id': str(message.user_id), 'sender_name': f"{message.user.username} {message.user.last_name}", 'attachments': [{ 'name': attachment.name, 'size': attachment.size, 'url': url_for('conversations.download_attachment', message_id=message.id, attachment_index=index) } for index, attachment in enumerate(message.attachments)] } for message in messages] return jsonify({ 'success': True, 'messages': message_data }) @conversations_bp.route('//send_message', methods=['POST']) @login_required @require_password_change def send_message(conversation_id): conversation = Conversation.query.get_or_404(conversation_id) # Check if user is a member if not current_user.is_admin and current_user not in conversation.members: return jsonify({'error': 'Unauthorized'}), 403 message_content = request.form.get('message', '').strip() file_count = int(request.form.get('file_count', 0)) if not message_content and file_count == 0: return jsonify({'error': 'Message cannot be empty'}), 400 # Create the message message = Message( content=message_content, user_id=current_user.id, conversation_id=conversation_id ) db.session.add(message) db.session.flush() # Get the message ID # Handle file attachments attachments = [] for i in range(file_count): file_key = f'file_{i}' if file_key in request.files: file = request.files[file_key] if file and allowed_file(file.filename): filename = secure_filename(file.filename) file_path = os.path.join(UPLOAD_FOLDER, filename) file.save(file_path) attachment = MessageAttachment( message_id=message.id, name=filename, path=file_path, size=os.path.getsize(file_path) ) db.session.add(attachment) attachments.append(attachment) db.session.commit() # Create notifications for all conversation members except the sender for member in conversation.members: if member.id != current_user.id: create_notification( notif_type='conversation_message', user_id=member.id, sender_id=current_user.id, details={ 'message': f'New message in conversation "{conversation.name}"', 'conversation_id': conversation.id, 'conversation_name': conversation.name, 'sender': f"{current_user.username} {current_user.last_name}", 'message_preview': message_content[:100] + ('...' if len(message_content) > 100 else ''), 'has_attachments': len(attachments) > 0, 'attachment_count': len(attachments), 'timestamp': datetime.utcnow().isoformat() } ) # Log message creation log_event( event_type='message_create', details={ 'message_id': message.id, 'conversation_id': conversation_id, 'conversation_name': conversation.name, 'sender_id': current_user.id, 'sender_name': f"{current_user.username} {current_user.last_name}", 'has_attachments': len(attachments) > 0, 'attachment_count': len(attachments), 'attachment_types': [get_file_extension(att.name) for att in attachments] if attachments else [] } ) db.session.commit() # Prepare message data for response message_data = { 'id': message.id, 'content': message.content, 'created_at': message.created_at.strftime('%b %d, %Y %H:%M'), 'sender_id': str(current_user.id), 'sender_name': f"{current_user.username} {current_user.last_name}", 'sender_avatar': url_for('profile_pic', filename=current_user.profile_picture) if current_user.profile_picture else url_for('static', filename='default-avatar.png'), 'attachments': [{ 'name': attachment.name, 'size': attachment.size, 'url': url_for('conversations.download_attachment', message_id=message.id, attachment_index=index) } for index, attachment in enumerate(attachments)] } return jsonify({ 'success': True, 'message': message_data }) @conversations_bp.route('/messages//attachment/') @login_required @require_password_change def download_attachment(message_id, attachment_index): message = Message.query.get_or_404(message_id) conversation = message.conversation # Check if user is a member if not current_user.is_admin and current_user not in conversation.members: flash('You do not have access to this file.', 'error') return redirect(url_for('conversations.conversation', conversation_id=conversation.id)) try: attachment = message.attachments[attachment_index] return send_file( attachment.path, as_attachment=True, download_name=attachment.name ) except (IndexError, Exception) as e: flash('File not found.', 'error') return redirect(url_for('conversations.conversation', conversation_id=conversation.id))