diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc index 734a7cd..c34c536 100644 Binary files a/__pycache__/app.cpython-313.pyc and b/__pycache__/app.cpython-313.pyc differ diff --git a/__pycache__/extensions.cpython-313.pyc b/__pycache__/extensions.cpython-313.pyc new file mode 100644 index 0000000..ac913cc Binary files /dev/null and b/__pycache__/extensions.cpython-313.pyc differ diff --git a/__pycache__/forms.cpython-313.pyc b/__pycache__/forms.cpython-313.pyc index faa7920..62aa08c 100644 Binary files a/__pycache__/forms.cpython-313.pyc and b/__pycache__/forms.cpython-313.pyc differ diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc index e5b1d99..1d1dbb4 100644 Binary files a/__pycache__/models.cpython-313.pyc and b/__pycache__/models.cpython-313.pyc differ diff --git a/app.py b/app.py index 4244b0e..335dbc9 100644 --- a/app.py +++ b/app.py @@ -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) \ No newline at end of file + socketio.run(app, debug=True) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 7d5133a..426cd59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ No newline at end of file + uploads: \ No newline at end of file diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..6396315 --- /dev/null +++ b/extensions.py @@ -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="*") \ No newline at end of file diff --git a/forms.py b/forms.py index f263117..652057a 100644 --- a/forms.py +++ b/forms.py @@ -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') \ No newline at end of file + 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()] \ No newline at end of file diff --git a/migrations/versions/0f48943140fa_add_file_attachment_fields_to_message_.py b/migrations/versions/0f48943140fa_add_file_attachment_fields_to_message_.py new file mode 100644 index 0000000..eccd880 --- /dev/null +++ b/migrations/versions/0f48943140fa_add_file_attachment_fields_to_message_.py @@ -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 ### diff --git a/migrations/versions/__pycache__/0f48943140fa_add_file_attachment_fields_to_message_.cpython-313.pyc b/migrations/versions/__pycache__/0f48943140fa_add_file_attachment_fields_to_message_.cpython-313.pyc new file mode 100644 index 0000000..e1e2056 Binary files /dev/null and b/migrations/versions/__pycache__/0f48943140fa_add_file_attachment_fields_to_message_.cpython-313.pyc differ diff --git a/migrations/versions/__pycache__/add_conversations_tables.cpython-313.pyc b/migrations/versions/__pycache__/add_conversations_tables.cpython-313.pyc new file mode 100644 index 0000000..c046bbc Binary files /dev/null and b/migrations/versions/__pycache__/add_conversations_tables.cpython-313.pyc differ diff --git a/migrations/versions/__pycache__/bd04430cda95_merge_heads.cpython-313.pyc b/migrations/versions/__pycache__/bd04430cda95_merge_heads.cpython-313.pyc new file mode 100644 index 0000000..64b48cc Binary files /dev/null and b/migrations/versions/__pycache__/bd04430cda95_merge_heads.cpython-313.pyc differ diff --git a/migrations/versions/__pycache__/e7e4ff171f7a_add_message_attachments_table_and_.cpython-313.pyc b/migrations/versions/__pycache__/e7e4ff171f7a_add_message_attachments_table_and_.cpython-313.pyc new file mode 100644 index 0000000..c8d43d7 Binary files /dev/null and b/migrations/versions/__pycache__/e7e4ff171f7a_add_message_attachments_table_and_.cpython-313.pyc differ diff --git a/migrations/versions/add_conversations_tables.py b/migrations/versions/add_conversations_tables.py new file mode 100644 index 0000000..eb13bf1 --- /dev/null +++ b/migrations/versions/add_conversations_tables.py @@ -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') \ No newline at end of file diff --git a/migrations/versions/bd04430cda95_merge_heads.py b/migrations/versions/bd04430cda95_merge_heads.py new file mode 100644 index 0000000..1991835 --- /dev/null +++ b/migrations/versions/bd04430cda95_merge_heads.py @@ -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 diff --git a/migrations/versions/e7e4ff171f7a_add_message_attachments_table_and_.py b/migrations/versions/e7e4ff171f7a_add_message_attachments_table_and_.py new file mode 100644 index 0000000..e3c9b6a --- /dev/null +++ b/migrations/versions/e7e4ff171f7a_add_message_attachments_table_and_.py @@ -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 ### diff --git a/models.py b/models.py index c711992..0c9dd28 100644 --- a/models.py +++ b/models.py @@ -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 \ No newline at end of file + 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'' + +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'' + +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'' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 27f7956..277bbd1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +gunicorn==21.2.0 +Flask-SocketIO==5.3.6 \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py index 97d597d..f158c49 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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//trash') @login_required diff --git a/routes/__pycache__/__init__.cpython-313.pyc b/routes/__pycache__/__init__.cpython-313.pyc index 636a1f3..7ab3b58 100644 Binary files a/routes/__pycache__/__init__.cpython-313.pyc and b/routes/__pycache__/__init__.cpython-313.pyc differ diff --git a/routes/__pycache__/conversations.cpython-313.pyc b/routes/__pycache__/conversations.cpython-313.pyc new file mode 100644 index 0000000..1d3995c Binary files /dev/null and b/routes/__pycache__/conversations.cpython-313.pyc differ diff --git a/routes/__pycache__/main.cpython-313.pyc b/routes/__pycache__/main.cpython-313.pyc index e316fef..477f0cb 100644 Binary files a/routes/__pycache__/main.cpython-313.pyc and b/routes/__pycache__/main.cpython-313.pyc differ diff --git a/routes/conversations.py b/routes/conversations.py new file mode 100644 index 0000000..1d482cb --- /dev/null +++ b/routes/conversations.py @@ -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('/') +@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('//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('//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('//members//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('//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('//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('//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//attachment/') +@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('//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}) \ No newline at end of file diff --git a/routes/main.py b/routes/main.py index 4032f9e..240863e 100644 --- a/routes/main.py +++ b/routes/main.py @@ -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(): diff --git a/templates/common/base.html b/templates/common/base.html index a461988..7f1e917 100644 --- a/templates/common/base.html +++ b/templates/common/base.html @@ -25,7 +25,7 @@ style="height: 40px; margin-right: 10px;"> {% endif %} {% if site_settings.company_name %} - DocuPulse for {% if site_settings.company_website %}{{ site_settings.company_name }}{% else %}{{ site_settings.company_name }}{% endif %} + DocuPulse for:{% if site_settings.company_website %}{{ site_settings.company_name }}{% else %}{{ site_settings.company_name }}{% endif %} {% else %} DocuPulse {% endif %} @@ -85,6 +85,11 @@ Rooms +