Added messaging functions

This commit is contained in:
2025-05-26 15:11:26 +02:00
parent a497da8274
commit 9b8836183a
32 changed files with 1744 additions and 31 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

13
app.py
View File

@@ -1,10 +1,9 @@
from flask import Flask, send_from_directory
from flask_migrate import Migrate
from flask_login import LoginManager
from dotenv import load_dotenv
import os
from models import db, User
from flask_wtf.csrf import CSRFProtect, generate_csrf
from models import User
from flask_wtf.csrf import generate_csrf
from routes.room_files import room_files_bp
from routes.user import user_bp
from routes.room_members import room_members_bp
@@ -12,6 +11,7 @@ from routes.trash import trash_bp
from tasks import cleanup_trash
import click
from utils import timeago
from extensions import db, login_manager, csrf, socketio
# Load environment variables
load_dotenv()
@@ -28,9 +28,10 @@ def create_app():
# Initialize extensions
db.init_app(app)
migrate = Migrate(app, db)
login_manager = LoginManager(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
csrf = CSRFProtect(app)
csrf.init_app(app)
socketio.init_app(app)
@app.context_processor
def inject_csrf_token():
@@ -67,4 +68,4 @@ def profile_pic(filename):
return send_from_directory(os.path.join(os.getcwd(), 'uploads', 'profile_pics'), filename)
if __name__ == '__main__':
app.run(debug=True)
socketio.run(app, debug=True)

View File

@@ -4,36 +4,25 @@ services:
web:
build: .
ports:
- "10334:5000"
- "5000:5000"
volumes:
- ./uploads:/app/uploads
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/flask_app
- FLASK_APP=app.py
- FLASK_ENV=development
- UPLOAD_FOLDER=/app/uploads
depends_on:
- db
volumes:
- .:/app
- room_data:/data
networks:
- app-network
db:
image: postgres:15
image: postgres:13
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=flask_app
ports:
- "5432:5432"
- POSTGRES_DB=docupulse
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-network
volumes:
postgres_data:
room_data:
networks:
app-network:
driver: bridge
uploads:

10
extensions.py Normal file
View File

@@ -0,0 +1,10 @@
from flask_socketio import SocketIO
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
# Initialize extensions
db = SQLAlchemy()
login_manager = LoginManager()
csrf = CSRFProtect()
socketio = SocketIO(cors_allowed_origins="*")

View File

@@ -1,5 +1,5 @@
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField, SelectMultipleField
from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError
from models import User
from flask_login import current_user
@@ -47,4 +47,14 @@ class UserForm(FlaskForm):
class RoomForm(FlaskForm):
name = StringField('Room Name', validators=[DataRequired(), Length(min=3, max=100)])
description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
submit = SubmitField('Create Room')
submit = SubmitField('Create Room')
class ConversationForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(min=1, max=100)])
description = TextAreaField('Description')
members = SelectMultipleField('Members', coerce=int)
submit = SubmitField('Create Conversation')
def __init__(self, *args, **kwargs):
super(ConversationForm, self).__init__(*args, **kwargs)
self.members.choices = [(u.id, f"{u.username} {u.last_name}") for u in User.query.filter_by(is_active=True).all()]

View File

@@ -0,0 +1,40 @@
"""add file attachment fields to message model
Revision ID: 0f48943140fa
Revises: bd04430cda95
Create Date: 2025-05-26 14:00:05.521776
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0f48943140fa'
down_revision = 'bd04430cda95'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('message', schema=None) as batch_op:
batch_op.add_column(sa.Column('has_attachment', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('attachment_name', sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column('attachment_path', sa.String(length=512), nullable=True))
batch_op.add_column(sa.Column('attachment_type', sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column('attachment_size', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('message', schema=None) as batch_op:
batch_op.drop_column('attachment_size')
batch_op.drop_column('attachment_type')
batch_op.drop_column('attachment_path')
batch_op.drop_column('attachment_name')
batch_op.drop_column('has_attachment')
# ### end Alembic commands ###

View File

@@ -0,0 +1,57 @@
"""Add conversations and messages tables
Revision ID: add_conversations_tables
Revises: 2c5f57dddb78
Create Date: 2024-03-19 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_conversations_tables'
down_revision = '2c5f57dddb78'
branch_labels = None
depends_on = None
def upgrade():
# Create conversation table first
op.create_table('conversation',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Create conversation_members table
op.create_table('conversation_members',
sa.Column('conversation_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('conversation_id', 'user_id')
)
# Create message table
op.create_table('message',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('conversation_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
# Drop tables in reverse order
op.drop_table('message')
op.drop_table('conversation_members')
op.drop_table('conversation')

View File

@@ -0,0 +1,24 @@
"""merge heads
Revision ID: bd04430cda95
Revises: f18735338888, add_conversations_tables
Create Date: 2025-05-26 11:14:05.629795
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bd04430cda95'
down_revision = ('f18735338888', 'add_conversations_tables')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -0,0 +1,52 @@
"""add message attachments table and update message model
Revision ID: e7e4ff171f7a
Revises: 0f48943140fa
Create Date: 2025-05-26 15:00:18.557702
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e7e4ff171f7a'
down_revision = '0f48943140fa'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('message_attachment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('message_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('path', sa.String(length=512), nullable=False),
sa.Column('type', sa.String(length=100), nullable=True),
sa.Column('size', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['message_id'], ['message.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('message', schema=None) as batch_op:
batch_op.drop_column('attachment_path')
batch_op.drop_column('attachment_type')
batch_op.drop_column('has_attachment')
batch_op.drop_column('attachment_size')
batch_op.drop_column('attachment_name')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('message', schema=None) as batch_op:
batch_op.add_column(sa.Column('attachment_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
batch_op.add_column(sa.Column('attachment_size', sa.INTEGER(), autoincrement=False, nullable=True))
batch_op.add_column(sa.Column('has_attachment', sa.BOOLEAN(), autoincrement=False, nullable=True))
batch_op.add_column(sa.Column('attachment_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True))
batch_op.add_column(sa.Column('attachment_path', sa.VARCHAR(length=512), autoincrement=False, nullable=True))
op.drop_table('message_attachment')
# ### end Alembic commands ###

View File

@@ -3,8 +3,7 @@ from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
from sqlalchemy.orm import relationship
db = SQLAlchemy()
from extensions import db
# Association table for room members
room_members = db.Table('room_members',
@@ -12,6 +11,12 @@ room_members = db.Table('room_members',
db.Column('user_id', db.Integer, db.ForeignKey('user.id'), primary_key=True)
)
# Association table for conversation members
conversation_members = db.Table('conversation_members',
db.Column('conversation_id', db.Integer, db.ForeignKey('conversation.id'), primary_key=True),
db.Column('user_id', db.Integer, db.ForeignKey('user.id'), primary_key=True)
)
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
@@ -151,4 +156,49 @@ class SiteSettings(db.Model):
settings = cls()
db.session.add(settings)
db.session.commit()
return settings
return settings
class Conversation(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
creator = db.relationship('User', backref='created_conversations', foreign_keys=[created_by])
members = db.relationship('User', secondary=conversation_members, backref=db.backref('conversations', lazy='dynamic'))
messages = db.relationship('Message', back_populates='conversation', cascade='all, delete-orphan')
def __repr__(self):
return f'<Conversation {self.name}>'
class Message(db.Model):
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
conversation = db.relationship('Conversation', back_populates='messages')
user = db.relationship('User', backref='messages')
attachments = db.relationship('MessageAttachment', back_populates='message', cascade='all, delete-orphan')
def __repr__(self):
return f'<Message {self.id}>'
class MessageAttachment(db.Model):
id = db.Column(db.Integer, primary_key=True)
message_id = db.Column(db.Integer, db.ForeignKey('message.id'), nullable=False)
name = db.Column(db.String(255), nullable=False)
path = db.Column(db.String(512), nullable=False)
type = db.Column(db.String(100))
size = db.Column(db.Integer) # Size in bytes
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
message = db.relationship('Message', back_populates='attachments')
def __repr__(self):
return f'<MessageAttachment {self.name}>'

View File

@@ -8,4 +8,5 @@ Werkzeug==3.0.1
WTForms==3.1.1
python-dotenv==1.0.1
psycopg2-binary==2.9.9
gunicorn==21.2.0
gunicorn==21.2.0
Flask-SocketIO==5.3.6

View File

@@ -13,6 +13,7 @@ def init_app(app: Flask):
from .auth import init_routes as init_auth_routes
from .contacts import contacts_bp as contacts_routes
from .rooms import rooms_bp as rooms_routes
from .conversations import conversations_bp as conversations_routes
# Initialize routes
init_main_routes(main_bp)
@@ -29,6 +30,7 @@ def init_app(app: Flask):
app.register_blueprint(auth_bp)
app.register_blueprint(rooms_routes)
app.register_blueprint(contacts_routes)
app.register_blueprint(conversations_routes)
@app.route('/rooms/<int:room_id>/trash')
@login_required

Binary file not shown.

390
routes/conversations.py Normal file
View File

@@ -0,0 +1,390 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
from flask_login import login_required, current_user
from flask_socketio import emit, join_room, leave_room
from models import db, Conversation, User, Message, MessageAttachment
from forms import ConversationForm
import os
from werkzeug.utils import secure_filename
from datetime import datetime
from extensions import socketio
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
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()
return render_template('conversations/conversations.html', conversations=conversations, search=search)
@conversations_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_conversation():
if not current_user.is_admin:
flash('Only administrators 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()
flash('Conversation created successfully!', 'success')
return redirect(url_for('conversations.conversations'))
return render_template('conversations/create_conversation.html', form=form)
@conversations_bp.route('/<int:conversation_id>')
@login_required
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'))
# 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 (only needed for admin)
all_users = User.query.all() if current_user.is_admin else None
return render_template('conversations/conversation.html',
conversation=conversation,
messages=messages,
all_users=all_users)
@conversations_bp.route('/<int:conversation_id>/members')
@login_required
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:
flash('Only administrators 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()
return render_template('conversations/conversation_members.html',
conversation=conversation,
available_users=available_users)
@conversations_bp.route('/<int:conversation_id>/members/add', methods=['POST'])
@login_required
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()
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('/<int:conversation_id>/members/<int:user_id>/remove', methods=['POST'])
@login_required
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()
flash('User has been removed from the conversation.', 'success')
return redirect(url_for('conversations.conversation_members', conversation_id=conversation_id))
@conversations_bp.route('/<int:conversation_id>/edit', methods=['GET', 'POST'])
@login_required
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':
# Get members from the form data
member_ids = request.form.getlist('members')
# Update members
current_member_ids = {str(user.id) for user in conversation.members}
new_member_ids = set(member_ids)
# Remove members that are no longer in the list
for member_id in current_member_ids - new_member_ids:
if int(member_id) != conversation.created_by: # Don't remove the creator
user = User.query.get(member_id)
if user:
conversation.members.remove(user)
# Add new members
for member_id in new_member_ids - current_member_ids:
user = User.query.get(member_id)
if user and user not in conversation.members:
conversation.members.append(user)
db.session.commit()
flash('Conversation members 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]
return render_template('conversations/create_conversation.html', form=form, edit_mode=True, conversation=conversation)
@conversations_bp.route('/<int:conversation_id>/delete', methods=['POST'])
@login_required
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)
# Delete all messages in the conversation
Message.query.filter_by(conversation_id=conversation_id).delete()
# Delete the conversation
db.session.delete(conversation)
db.session.commit()
flash('Conversation has been deleted successfully.', 'success')
return redirect(url_for('conversations.conversations'))
@socketio.on('join_conversation')
@login_required
def on_join(data):
conversation_id = data.get('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
# Join the room
join_room(f'conversation_{conversation_id}')
@socketio.on('leave_conversation')
@login_required
def on_leave(data):
conversation_id = data.get('conversation_id')
leave_room(f'conversation_{conversation_id}')
@conversations_bp.route('/<int:conversation_id>/send_message', methods=['POST'])
@login_required
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({'success': False, 'error': 'You do not have access to this conversation.'}), 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({'success': False, 'error': 'Message or file is required.'}), 400
# Create new message
message = Message(
content=message_content,
conversation_id=conversation_id,
user_id=current_user.id
)
# Create conversation-specific directory
conversation_dir = os.path.join(UPLOAD_FOLDER, str(conversation_id))
os.makedirs(conversation_dir, exist_ok=True)
# Handle file attachments
attachments = []
for i in range(file_count):
file = request.files.get(f'file_{i}')
if file and file.filename:
if not allowed_file(file.filename):
return jsonify({'success': False, 'error': f'File type not allowed: {file.filename}'}), 400
if file.content_length and file.content_length > MAX_FILE_SIZE:
return jsonify({'success': False, 'error': f'File size exceeds limit: {file.filename}'}), 400
# Generate unique filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = secure_filename(file.filename)
unique_filename = f"{timestamp}_{filename}"
file_path = os.path.join(conversation_dir, unique_filename)
# Save file
file.save(file_path)
# Create attachment record
attachment = MessageAttachment(
name=filename,
path=file_path,
type=get_file_extension(filename),
size=os.path.getsize(file_path)
)
message.attachments.append(attachment)
attachments.append(attachment)
db.session.add(message)
db.session.commit()
# Prepare message data for WebSocket
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)]
}
# Emit the message to all users in the conversation room
socketio.emit('new_message', message_data, room=f'conversation_{conversation_id}')
# Return minimal response since the message will be received through WebSocket
return jsonify({'success': True})
@conversations_bp.route('/messages/<int:message_id>/attachment/<int:attachment_index>')
@login_required
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))
@conversations_bp.route('/<int:conversation_id>/messages')
@login_required
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({'success': False, 'error': 'You do not have access to this conversation.'}), 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
formatted_messages = []
for message in messages:
formatted_messages.append({
'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}",
'sender_avatar': url_for('profile_pic', filename=message.user.profile_picture) if message.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(message.attachments)]
})
return jsonify({'success': True, 'messages': formatted_messages})

View File

@@ -324,6 +324,11 @@ def init_routes(main_bp):
def starred():
return render_template('starred/starred.html')
@main_bp.route('/conversations')
@login_required
def conversations():
return redirect(url_for('conversations.conversations'))
@main_bp.route('/trash')
@login_required
def trash():

View File

@@ -25,7 +25,7 @@
style="height: 40px; margin-right: 10px;">
{% endif %}
{% if site_settings.company_name %}
DocuPulse for {% if site_settings.company_website %}<a href="{{ site_settings.company_website }}" target="_blank" style="text-decoration: none; color: #fff !important; font-weight: inherit; font-size: 1.25rem; font-family: inherit; display: inline; padding: 0; margin: 0;">{{ site_settings.company_name }}</a>{% else %}{{ site_settings.company_name }}{% endif %}
DocuPulse for:{% if site_settings.company_website %}<a href="{{ site_settings.company_website }}" target="_blank" style="text-decoration: none; color: #fff !important; font-weight: inherit; font-size: 1.25rem; font-family: inherit; display: inline; padding: 0; margin: 0;">{{ site_settings.company_name }}</a>{% else %}{{ site_settings.company_name }}{% endif %}
{% else %}
DocuPulse
{% endif %}
@@ -85,6 +85,11 @@
<i class="fas fa-door-open"></i> Rooms
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'conversations.conversations' %}active{% endif %}" href="{{ url_for('conversations.conversations') }}">
<i class="fas fa-comments"></i> Conversations
</a>
</li>
<li class="nav-item">
{% if current_user.is_admin %}
<a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}">

View File

@@ -0,0 +1,685 @@
{% extends "common/base.html" %}
{% from "components/header.html" import header %}
{% block title %}{{ conversation.name }} - {% if site_settings.company_name %}DocuPulse for {{ site_settings.company_name }}{% else %}DocuPulse{% endif %}{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
<style>
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
margin-right: 1rem;
}
.badge-creator {
background-color: #d1f2eb;
color: #117a65;
font-weight: 500;
}
.btn-remove-member {
background: #f8d7da;
color: #c82333;
border: none;
font-weight: 500;
}
.btn-remove-member:hover {
background: #f5c6cb;
color: #a71d2a;
}
/* Chat bubble styles */
.chat-messages {
padding: 1rem;
}
.message {
margin-bottom: 1.5rem;
}
.message-content {
position: relative;
padding: 0.75rem 1rem;
border-radius: 1rem;
max-width: 80%;
word-wrap: break-word;
}
.message.sent {
display: flex;
justify-content: flex-end;
}
.message.received {
display: flex;
justify-content: flex-start;
}
.message.sent .message-content {
background-color: var(--primary-color);
color: white;
border-top-right-radius: 0.25rem;
}
.message.received .message-content {
background-color: #f0f2f5;
color: #1c1e21;
border-top-left-radius: 0.25rem;
}
.message-info {
font-size: 0.75rem;
margin-bottom: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
}
.message.sent .message-info {
text-align: right;
background-color: rgba(0, 0, 0, 0.1);
color: rgba(255, 255, 255, 0.9);
}
.message.received .message-info {
background-color: rgba(0, 0, 0, 0.05);
color: #65676b;
}
.message.sent .message-info .text-muted {
color: rgba(255, 255, 255, 0.8) !important;
}
.message.received .message-info .text-muted {
color: #65676b !important;
}
.attachment {
margin-top: 0.5rem;
}
.message.sent .attachment .btn {
background-color: rgba(255, 255, 255, 0.2);
color: white;
border: none;
}
.message.sent .attachment .btn:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.message.received .attachment .btn {
background-color: #e4e6eb;
color: #1c1e21;
border: none;
}
.message.received .attachment .btn:hover {
background-color: #d8dadf;
}
</style>
{% endblock %}
{% block content %}
{{ header(
title=conversation.name,
description=conversation.description or "No description",
button_text="Back to Conversations",
button_url=url_for('conversations.conversations'),
icon="fa-comments",
button_class="btn-secondary",
button_icon="fa-arrow-left"
) }}
<div class="container-fluid py-4">
<div class="row">
<!-- Main Chat Area -->
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-body">
<div class="chat-messages" style="min-height: 400px; max-height: 600px; overflow-y: auto;" id="chatMessages">
{% if messages %}
{% for message in messages %}
<div class="message {% if message.user.id == current_user.id %}sent{% else %}received{% endif %}" data-message-id="{{ message.id }}">
{% if message.user.id != current_user.id %}
<img src="{{ url_for('profile_pic', filename=message.user.profile_picture) if message.user.profile_picture else url_for('static', filename='default-avatar.png') }}"
alt="{{ message.user.username }}"
class="rounded-circle me-2"
style="width: 40px; height: 40px; object-fit: cover;">
{% endif %}
<div class="message-content">
<div class="message-info">
<span class="fw-medium">{{ message.user.username }} {{ message.user.last_name }}</span>
<span class="text-muted ms-2">{{ message.created_at.strftime('%b %d, %Y %H:%M') }}</span>
</div>
{{ message.content }}
{% if message.attachments %}
<div class="attachments mt-2">
{% for attachment in message.attachments %}
<div class="attachment mb-1">
<a href="{{ url_for('conversations.download_attachment', message_id=message.id, attachment_index=loop.index0) }}" class="btn btn-sm">
<i class="fas fa-paperclip me-1"></i>
{{ attachment.name }}
<small class="ms-1">({{ (attachment.size / 1024)|round|int }} KB)</small>
</a>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if message.user.id == current_user.id %}
<img src="{{ url_for('profile_pic', filename=message.user.profile_picture) if message.user.profile_picture else url_for('static', filename='default-avatar.png') }}"
alt="{{ message.user.username }}"
class="rounded-circle ms-2"
style="width: 40px; height: 40px; object-fit: cover;">
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-comments fa-3x mb-3"></i>
<p>No messages yet. Start the conversation!</p>
</div>
{% endif %}
</div>
<div class="card-footer bg-white border-top">
<form id="messageForm" class="d-flex flex-column gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="d-flex gap-2 align-items-center">
<input type="text" class="form-control" id="messageInput" placeholder="Type your message..." required style="min-width:0; height: 38px;">
<label for="fileInput" class="btn btn-outline-secondary btn-sm mb-0 d-flex align-items-center justify-content-center" style="height: 38px; width: 38px;">
<i class="fas fa-paperclip"></i>
</label>
<input type="file" id="fileInput" class="d-none" multiple accept=".pdf,.docx,.doc,.txt,.rtf,.odt,.md,.csv,.xlsx,.xls,.ods,.xlsm,.pptx,.ppt,.odp,.jpg,.jpeg,.png,.gif,.bmp,.svg,.webp,.tiff,.zip,.rar,.7z,.tar,.gz,.py,.js,.html,.css,.json,.xml,.sql,.sh,.bat,.mp3,.wav,.ogg,.m4a,.flac,.mp4,.avi,.mov,.wmv,.flv,.mkv,.webm,.dwg,.dxf,.ai,.psd,.eps,.indd,.eml,.msg,.vcf,.ics">
<button type="submit" class="btn btn-primary d-flex align-items-center justify-content-center" style="height: 38px; width: 38px;">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<div class="d-flex align-items-center" style="min-height: 1.2em;">
<small id="selectedFiles" class="text-muted"></small>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Members Sidebar -->
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<i class="fas fa-users me-2"></i>Members
</h5>
{% if current_user.is_admin %}
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#manageMembersModal">
<i class="fas fa-user-edit me-1"></i>Manage Members
</button>
{% endif %}
</div>
<div class="card-body">
<div class="list-group list-group-flush">
{% for member in conversation.members %}
<div class="list-group-item d-flex align-items-center">
<img src="{{ url_for('profile_pic', filename=member.profile_picture) if member.profile_picture else url_for('static', filename='default-avatar.png') }}"
alt="{{ member.username }}"
class="rounded-circle me-2"
style="width: 40px; height: 40px; object-fit: cover;">
<div class="flex-grow-1">
<div class="fw-medium">{{ member.username }} {{ member.last_name }}</div>
<div class="text-muted small">{{ member.email }}</div>
</div>
{% if member.id == conversation.created_by %}
<span class="badge bg-primary">
<i class="fas fa-crown me-1"></i>Creator
</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
{% if current_user.is_admin %}
<!-- Manage Members Modal -->
<div class="modal fade" id="manageMembersModal" tabindex="-1" aria-labelledby="manageMembersModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="manageMembersModalLabel">Manage Members</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="POST" action="{{ url_for('conversations.edit_conversation', conversation_id=conversation.id, redirect=url_for('conversations.conversation', conversation_id=conversation.id)) }}" id="membersForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="user_id" class="form-label">Select User</label>
<select class="form-select select2" id="user_id" name="user_id">
<option value="">Search for a user...</option>
{% for user in all_users %}
{% if user.id != current_user.id %}
<option value="{{ user.id }}" data-email="{{ user.email }}" data-avatar="{{ url_for('profile_pic', filename=user.profile_picture) if user.profile_picture else url_for('static', filename='default-avatar.png') }}">{{ user.username }} {{ user.last_name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<button type="button" class="btn btn-primary w-100 mb-3" id="addMemberBtn">
<i class="fas fa-user-plus me-2"></i>Add Member
</button>
<div class="list-group" id="selectedMembersList">
<!-- Render all current members -->
{% for member in conversation.members %}
<div class="list-group-item d-flex align-items-center member-row" data-user-id="{{ member.id }}">
<img class="member-avatar" src="{{ url_for('profile_pic', filename=member.profile_picture) if member.profile_picture else url_for('static', filename='default-avatar.png') }}">
<div class="flex-grow-1">
<div class="fw-bold">{{ member.username }} {{ member.last_name }}</div>
<div class="text-muted small">{{ member.email }}</div>
</div>
{% if member.id == conversation.created_by %}
<span class="badge badge-creator ms-2"><i class="fas fa-user"></i> Creator</span>
{% else %}
<button type="button" class="btn btn-remove-member ms-2">
<i class="fas fa-user-minus me-1"></i>Remove
</button>
{% endif %}
</div>
{% endfor %}
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="membersForm" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</div>
{% endif %}
{% block extra_js %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<script>
// Initialize Select2 for user selection
$(document).ready(function() {
$('.select2').select2({
theme: 'bootstrap-5',
width: '100%'
});
// Handle member removal
$(document).on('click', '.btn-remove-member', function() {
const memberRow = $(this).closest('.member-row');
memberRow.remove();
});
// Handle adding new member
$('#addMemberBtn').on('click', function() {
const select = $('#user_id');
const selectedOption = select.find('option:selected');
const selectedValue = select.val();
if (!selectedValue) {
return;
}
const userId = selectedValue;
const userName = selectedOption.text();
const userEmail = selectedOption.data('email');
const userAvatar = selectedOption.data('avatar');
// Check if user is already in the list
if ($(`.member-row[data-user-id="${userId}"]`).length > 0) {
return;
}
// Create new member row
const memberRow = `
<div class="list-group-item d-flex align-items-center member-row" data-user-id="${userId}">
<img class="member-avatar" src="${userAvatar}">
<div class="flex-grow-1">
<div class="fw-bold">${userName}</div>
<div class="text-muted small">${userEmail}</div>
</div>
<button type="button" class="btn btn-remove-member ms-2">
<i class="fas fa-user-minus me-1"></i>Remove
</button>
</div>
`;
// Add to the list
$('#selectedMembersList').append(memberRow);
// Clear the select and trigger change event
select.val(null).trigger('change');
});
// Handle form submission
$('#membersForm').on('submit', function(e) {
e.preventDefault();
// Collect all member IDs
const memberIds = [];
$('.member-row').each(function() {
memberIds.push($(this).data('user-id'));
});
// Remove any existing hidden inputs
$('input[name="members"]').remove();
// Add hidden input for each member ID
memberIds.forEach(function(id) {
$('#membersForm').append(`<input type="hidden" name="members" value="${id}">`);
});
// Submit the form
this.submit();
});
});
// Global state and socket management
if (typeof window.ChatManager === 'undefined') {
window.ChatManager = (function() {
let instance = null;
let socket = null;
const state = {
addedMessageIds: new Set(),
messageQueue: new Set(),
connectionState: {
hasJoined: false,
isConnected: false,
lastMessageId: null,
connectionAttempts: 0,
socketId: null
}
};
function init() {
if (instance) {
return instance;
}
// Initialize message IDs from existing messages
$('.message').each(function() {
const messageId = $(this).data('message-id');
if (messageId) {
state.addedMessageIds.add(messageId);
}
});
// Create socket instance
socket = io({
transports: ['websocket'],
upgrade: false,
reconnection: false,
debug: true,
forceNew: false,
multiplex: false
});
// Set up socket event handlers
socket.on('connect', function() {
state.connectionState.isConnected = true;
state.connectionState.connectionAttempts++;
state.connectionState.socketId = socket.id;
console.log('Socket connected:', {
attempt: state.connectionState.connectionAttempts,
socketId: socket.id,
existingSocketId: state.connectionState.socketId
});
});
socket.on('disconnect', function(reason) {
console.log('Disconnected from conversation:', {
reason: reason,
socketId: socket.id,
connectionState: state.connectionState
});
state.connectionState.isConnected = false;
state.connectionState.hasJoined = false;
state.connectionState.socketId = null;
});
socket.on('error', function(error) {
console.error('Socket error:', error);
cleanup();
});
instance = {
socket: socket,
state: state,
cleanup: cleanup
};
return instance;
}
function cleanup() {
console.log('Cleaning up socket connection');
if (socket) {
socket.off('new_message');
socket.disconnect();
socket = null;
}
instance = null;
}
return {
getInstance: function() {
if (!instance) {
instance = init();
}
return instance;
}
};
})();
}
// Initialize chat when document is ready
$(document).ready(function() {
const chat = ChatManager.getInstance();
const socket = chat.socket;
const state = chat.state;
const conversationId = "{{ conversation.id }}";
console.log('Initializing chat for conversation:', conversationId);
// Join conversation room
socket.on('connect', function() {
if (!state.connectionState.hasJoined) {
console.log('Joining conversation room:', conversationId);
socket.emit('join_conversation', {
conversation_id: conversationId,
timestamp: new Date().toISOString(),
socketId: socket.id
});
state.connectionState.hasJoined = true;
}
});
// Function to append a new message to the chat
function appendMessage(message) {
const currentUserId = "{{ current_user.id }}";
const isCurrentUser = message.sender_id === currentUserId;
const messageHtml = `
<div class="message ${isCurrentUser ? 'sent' : 'received'}" data-message-id="${message.id}">
${!isCurrentUser ? `
<img src="${message.sender_avatar}"
alt="${message.sender_name}"
class="rounded-circle me-2"
style="width: 40px; height: 40px; object-fit: cover;">
` : ''}
<div class="message-content">
<div class="message-info">
<span class="fw-medium">${message.sender_name}</span>
<span class="text-muted ms-2">${message.created_at}</span>
</div>
${message.content}
${message.attachments && message.attachments.length > 0 ? `
<div class="attachments mt-2">
${message.attachments.map((attachment, index) => `
<div class="attachment mb-1">
<a href="${attachment.url}" class="btn btn-sm">
<i class="fas fa-paperclip me-1"></i>
${attachment.name}
<small class="ms-1">(${Math.round(attachment.size / 1024)} KB)</small>
</a>
</div>
`).join('')}
</div>
` : ''}
</div>
${isCurrentUser ? `
<img src="${message.sender_avatar}"
alt="${message.sender_name}"
class="rounded-circle ms-2"
style="width: 40px; height: 40px; object-fit: cover;">
` : ''}
</div>
`;
// Remove the "no messages" placeholder if it exists
$('.text-center.text-muted').remove();
$('#chatMessages').append(messageHtml);
scrollToBottom();
}
// Scroll to bottom of chat messages
function scrollToBottom() {
const chatMessages = document.getElementById('chatMessages');
chatMessages.scrollTop = chatMessages.scrollHeight;
}
scrollToBottom();
// Message handling with deduplication
socket.on('new_message', function(message) {
const timestamp = new Date().toISOString();
const messageKey = `${message.id}-${socket.id}`;
console.log('Message received:', {
id: message.id,
timestamp: timestamp,
socketId: socket.id,
messageKey: messageKey,
queueSize: state.messageQueue.size
});
if (state.messageQueue.has(messageKey)) {
console.log('Message already in queue:', messageKey);
return;
}
state.messageQueue.add(messageKey);
if (!state.addedMessageIds.has(message.id)) {
console.log('Processing new message:', message.id);
appendMessage(message);
state.connectionState.lastMessageId = message.id;
state.addedMessageIds.add(message.id);
} else {
console.log('Duplicate message detected:', {
messageId: message.id,
lastMessageId: state.connectionState.lastMessageId,
socketId: socket.id
});
}
// Clean up message from queue after processing
state.messageQueue.delete(messageKey);
});
// Handle file selection
$('#fileInput').on('change', function() {
const files = Array.from(this.files);
if (files.length > 0) {
const fileNames = files.map(file => file.name).join(', ');
$('#selectedFiles').text(files.length > 1 ? `${files.length} files selected: ${fileNames}` : fileNames);
} else {
$('#selectedFiles').text('');
}
});
// Handle message form submission
let isSubmitting = false;
$('#messageForm').off('submit').on('submit', function(e) {
e.preventDefault();
e.stopPropagation();
if (isSubmitting) {
console.log('Message submission already in progress');
return false;
}
if (!state.connectionState.isConnected) {
console.error('Socket not connected, cannot send message');
alert('Connection lost. Please refresh the page.');
return false;
}
const messageInput = $('#messageInput');
const submitButton = $('#messageForm button[type="submit"]');
const message = messageInput.val().trim();
const fileInput = $('#fileInput')[0];
const files = Array.from(fileInput.files);
if (!message && files.length === 0) {
console.log('Empty message submission attempted');
return false;
}
console.log('Submitting message:', {
hasText: !!message,
fileCount: files.length,
socketId: socket.id,
connectionState: state.connectionState
});
isSubmitting = true;
messageInput.prop('disabled', true);
submitButton.prop('disabled', true);
const formData = new FormData();
formData.append('message', message);
formData.append('csrf_token', $('input[name="csrf_token"]').val());
formData.append('socket_id', socket.id);
// Append each file to the FormData
files.forEach((file, index) => {
formData.append(`file_${index}`, file);
});
formData.append('file_count', files.length);
$.ajax({
url: "{{ url_for('conversations.send_message', conversation_id=conversation.id) }}",
method: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(response) {
console.log('Message sent successfully:', {
response: response,
socketId: socket.id
});
if (response.success) {
messageInput.val('');
fileInput.value = '';
$('#selectedFiles').text('');
}
},
error: function(xhr, status, error) {
console.error('Failed to send message:', {
status: status,
error: error,
response: xhr.responseText
});
alert('Failed to send message. Please try again.');
},
complete: function() {
messageInput.prop('disabled', false);
submitButton.prop('disabled', false);
isSubmitting = false;
}
});
return false;
});
// Clean up on page unload
$(window).on('beforeunload', function() {
chat.cleanup();
});
});
</script>
{% endblock %}
{% endblock content %}

View File

@@ -0,0 +1,154 @@
{% extends "common/base.html" %}
{% from "components/header.html" import header %}
{% block title %}Conversations - {% if site_settings.company_name %}DocuPulse for {{ site_settings.company_name }}{% else %}DocuPulse{% endif %}{% endblock %}
{% block content %}
{{ header(
title="Conversations",
description="Manage your conversations and messages",
button_text="New Conversation" if current_user.is_admin else "",
button_url=url_for('conversations.create_conversation') if current_user.is_admin else "",
icon="fa-comments",
button_icon="fa-plus"
) }}
<div class="container mt-4">
<!-- Search and Filter Section -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<form method="GET" class="d-flex align-items-center w-100 justify-content-between" id="conversationFilterForm" style="gap: 1rem;">
<input type="text" name="search" placeholder="Search conversations..." value="{{ search }}" class="form-control flex-grow-1" id="conversationSearchInput" autocomplete="off" style="min-width: 0;" />
<button type="button" id="clearConversationsFilter" class="px-4 py-2 rounded-lg text-white font-medium transition-colors duration-200 ms-2 flex-shrink-0"
style="background-color: var(--primary-color); border: 1px solid var(--primary-color);"
onmouseover="this.style.backgroundColor='var(--primary-light)'"
onmouseout="this.style.backgroundColor='var(--primary-color)'">
Clear
</button>
</form>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for conversation in conversations %}
<div class="col">
<div class="card h-100 shadow-sm hover-shadow">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="card-title mb-0">{{ conversation.name }}</h5>
<div class="text-muted small mt-1">Created on {{ conversation.created_at.strftime('%b %d, %Y') }}</div>
</div>
<span class="badge bg-light text-dark">
<i class="fas fa-users"></i> {{ conversation.members|length }}
</span>
</div>
<p class="card-text text-muted">{{ conversation.description or 'No description' }}</p>
<div class="d-flex align-items-center mt-3">
<img src="{{ url_for('profile_pic', filename=conversation.creator.profile_picture) if conversation.creator and conversation.creator.profile_picture else url_for('static', filename='img/default-avatar.png') }}"
alt="Creator"
class="rounded-circle me-2"
style="width: 32px; height: 32px; object-fit: cover;">
<div>
<small class="text-muted">Created by</small>
<div class="fw-medium">{{ conversation.creator.username }} {{ conversation.creator.last_name }}</div>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-top-0">
<div class="d-flex gap-2">
<a href="{{ url_for('conversations.conversation', conversation_id=conversation.id) }}" class="btn btn-primary flex-grow-1">
<i class="fas fa-comments me-2"></i>Open Conversation
</a>
{% if current_user.is_admin %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="conversationActions{{ conversation.id }}" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-ellipsis-v"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="conversationActions{{ conversation.id }}">
<li>
<a class="dropdown-item" href="{{ url_for('conversations.edit_conversation', conversation_id=conversation.id) }}">
<i class="fas fa-edit me-2"></i>Edit Conversation
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<button type="button" class="dropdown-item text-danger" data-bs-toggle="modal" data-bs-target="#deleteConversationModal{{ conversation.id }}">
<i class="fas fa-trash me-2"></i>Delete Conversation
</button>
</li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if current_user.is_admin %}
<!-- Delete Conversation Modal -->
<div class="modal fade" id="deleteConversationModal{{ conversation.id }}" tabindex="-1" aria-labelledby="deleteConversationModalLabel{{ conversation.id }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteConversationModalLabel{{ conversation.id }}">Move to Trash</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex align-items-center gap-3 mb-3">
<i class="fas fa-trash text-danger" style="font-size: 2rem;"></i>
<div>
<h6 class="mb-1">Move to Trash</h6>
<p class="text-muted mb-0" id="deleteConversationName{{ conversation.id }}">{{ conversation.name }}</p>
</div>
</div>
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
This item will be moved to trash. You can restore it from the trash page within 30 days.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form action="{{ url_for('conversations.delete_conversation', conversation_id=conversation.id) }}" method="POST" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Delete Conversation
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% block extra_js %}
<script>
// Debounce function
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('conversationSearchInput');
const form = document.getElementById('conversationFilterForm');
if (searchInput && form) {
searchInput.addEventListener('input', debounce(function() {
form.submit();
}, 300));
}
// Clear button logic
const clearBtn = document.getElementById('clearConversationsFilter');
if (clearBtn && searchInput) {
clearBtn.addEventListener('click', function() {
searchInput.value = '';
form.submit();
});
}
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,238 @@
{% extends "common/base.html" %}
{% from "components/header.html" import header %}
{% block title %}Create Conversation - {% if site_settings.company_name %}DocuPulse for {{ site_settings.company_name }}{% else %}DocuPulse{% endif %}{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
<style>
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
margin-right: 1rem;
}
.badge-creator {
background-color: #d1f2eb;
color: #117a65;
font-weight: 500;
}
.btn-remove-member {
background: #f8d7da;
color: #c82333;
border: none;
font-weight: 500;
}
.btn-remove-member:hover {
background: #f5c6cb;
color: #a71d2a;
}
</style>
{% endblock %}
{% block content %}
{{ header(
title=("Edit Conversation" if edit_mode else "Create New Conversation"),
description=("Update conversation details" if edit_mode else "Start a new conversation with your team"),
button_text="Cancel",
button_url=url_for('conversations.conversations'),
icon="fa-edit" if edit_mode else "fa-comments",
button_class="btn-secondary",
button_icon="fa-times"
) }}
<!-- Alert Modal -->
<div id="alertModal" class="modal fade" tabindex="-1" aria-labelledby="alertModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="alertModalLabel">Notification</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex align-items-center gap-3">
<i class="fas fa-exclamation-circle text-warning" style="font-size: 2rem;"></i>
<div id="alertModalMessage"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<div class="container-fluid py-4">
<div class="row">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" id="conversationForm">
{{ form.csrf_token }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control", placeholder="Enter conversation name") }}
{% if form.name.errors %}
<div class="text-danger">
{% for error in form.name.errors %}
<small>{{ error }}</small>
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.description.id }}" class="form-label">Description (Optional)</label>
{{ form.description(class="form-control", rows="3", placeholder="Enter a description (optional)") }}
{% if form.description.errors %}
<div class="text-danger">
{% for error in form.description.errors %}
<small>{{ error }}</small>
{% endfor %}
</div>
{% endif %}
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary">
{{ "Edit Conversation" if edit_mode else "Create Conversation" }}
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-header bg-white">
<h5 class="card-title mb-0">Add Members</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="user_id" class="form-label">Select User</label>
<select class="form-select select2" id="user_id" name="user_id">
<option value="">Search for a user...</option>
{% for user in form.members.choices %}
{% if user[0] != current_user.id %}
<option value="{{ user[0] }}" data-email="{{ user[2] if user|length > 2 else '' }}" data-avatar="{{ url_for('profile_pic', filename=user[3]) if user|length > 3 and user[3] else url_for('static', filename='default-avatar.png') }}">{{ user[1] }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<button type="button" class="btn btn-primary w-100 mb-3" id="addMemberBtn">
<i class="fas fa-user-plus me-2"></i>Add Member
</button>
<div class="list-group" id="selectedMembersList">
<!-- Render creator row server-side -->
<div class="list-group-item d-flex align-items-center member-row" data-user-id="{{ current_user.id }}">
<img class="member-avatar" src="{{ url_for('profile_pic', filename=current_user.profile_picture) if current_user.profile_picture else url_for('static', filename='default-avatar.png') }}">
<div class="flex-grow-1">
<div class="fw-bold">{{ current_user.username }} {{ current_user.last_name }}</div>
<div class="text-muted small">{{ current_user.email }}</div>
</div>
<span class="badge badge-creator ms-2"><i class="fas fa-user"></i> Creator</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
$('.select2').select2({
theme: 'bootstrap-5',
width: '100%',
placeholder: 'Search for a user...',
allowClear: true
});
// Keep track of added members
var addedMembers = new Set();
var creatorId = '{{ current_user.id }}';
addedMembers.add(creatorId);
// Function to show alert modal
function showAlert(message) {
$('#alertModalMessage').text(message);
var alertModal = new bootstrap.Modal(document.getElementById('alertModal'));
alertModal.show();
}
// Handle Add Member button click
$('#addMemberBtn').click(function() {
var selectedUserId = $('#user_id').val();
var selectedUserName = $('#user_id option:selected').text();
var selectedUserEmail = $('#user_id option:selected').data('email') || '';
var selectedUserAvatar = $('#user_id option:selected').data('avatar') || "{{ url_for('static', filename='default-avatar.png') }}";
if (!selectedUserId) {
showAlert('Please select a user to add.');
return;
}
if (addedMembers.has(selectedUserId)) {
showAlert('This user has already been added.');
return;
}
// Add to the set of added members
addedMembers.add(selectedUserId);
// Disable the option in the dropdown
$('#user_id option[value="' + selectedUserId + '"]').prop('disabled', true);
$('#user_id').val(null).trigger('change');
// Create the member list item
var memberItem = $('<div class="list-group-item d-flex align-items-center member-row" data-user-id="' + selectedUserId + '">')
.append($('<img class="member-avatar">').attr('src', selectedUserAvatar))
.append($('<div class="flex-grow-1">')
.append($('<div class="fw-bold">').text(selectedUserName))
.append($('<div class="text-muted small">').text(selectedUserEmail))
)
.append($('<button type="button" class="btn btn-remove-member ms-2">')
.append($('<i class="fas fa-user-minus me-1"></i>'))
.append('Remove')
.click(function() {
$(this).closest('.list-group-item').remove();
addedMembers.delete(selectedUserId);
// Re-enable the option in the dropdown
$('#user_id option[value="' + selectedUserId + '"]').prop('disabled', false);
$('#user_id').trigger('change');
updateHiddenInputs();
})
);
// Add to the list
$('#selectedMembersList').append(memberItem);
// Update hidden inputs
updateHiddenInputs();
});
function updateHiddenInputs() {
// Remove any existing members inputs
$('#conversationForm input[name="members"]').remove();
// Add new hidden inputs for each member
addedMembers.forEach(function(memberId) {
var input = $('<input>')
.attr('type', 'hidden')
.attr('name', 'members')
.val(memberId);
$('#conversationForm').append(input);
});
}
// Initialize hidden inputs
updateHiddenInputs();
});
</script>
{% endblock %}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB