mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-24 07:10:21 -05:00
db1b8823e4
- Update app bootstrap and route modules (admin, api, api_v1, audit_logs, clients, expenses, projects, settings, team_chat, timer) - Add error_handling utility; update backup, client_lock, context_processors, data_import
598 lines
20 KiB
Python
598 lines
20 KiB
Python
"""
|
|
Team Chat routes
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
|
from flask_babel import gettext as _
|
|
from flask_login import current_user, login_required
|
|
from sqlalchemy import and_, or_
|
|
|
|
from app import db
|
|
from app.models import Project, User
|
|
from app.models.team_chat import ChatChannel, ChatChannelMember, ChatMessage, ChatReadReceipt
|
|
from app.utils.module_helpers import module_enabled
|
|
|
|
team_chat_bp = Blueprint("team_chat", __name__)
|
|
|
|
|
|
@team_chat_bp.route("/chat")
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def chat_index():
|
|
"""Main chat interface"""
|
|
# Get all channels user is member of
|
|
channels = (
|
|
ChatChannel.query.join(ChatChannelMember)
|
|
.filter(ChatChannelMember.user_id == current_user.id, ChatChannel.is_archived == False)
|
|
.order_by(ChatChannel.updated_at.desc())
|
|
.all()
|
|
)
|
|
|
|
# Get direct messages (channels with type='direct' and 2 members)
|
|
direct_channels = (
|
|
ChatChannel.query.join(ChatChannelMember)
|
|
.filter(
|
|
ChatChannelMember.user_id == current_user.id,
|
|
ChatChannel.channel_type == "direct",
|
|
ChatChannel.is_archived == False,
|
|
)
|
|
.all()
|
|
)
|
|
|
|
return render_template("chat/index.html", channels=channels, direct_channels=direct_channels)
|
|
|
|
|
|
@team_chat_bp.route("/chat/channels/<int:channel_id>")
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def chat_channel(channel_id):
|
|
"""View a specific chat channel"""
|
|
channel = ChatChannel.query.get_or_404(channel_id)
|
|
|
|
# Check membership
|
|
membership = ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=current_user.id).first()
|
|
|
|
if not membership and not current_user.is_admin:
|
|
flash(_("You don't have access to this channel"), "error")
|
|
return redirect(url_for("team_chat.chat_index"))
|
|
|
|
# Get messages
|
|
messages = (
|
|
ChatMessage.query.filter_by(channel_id=channel_id, is_deleted=False)
|
|
.order_by(ChatMessage.created_at.asc())
|
|
.limit(100)
|
|
.all()
|
|
)
|
|
|
|
# Get channel members
|
|
members = ChatChannelMember.query.filter_by(channel_id=channel_id).all()
|
|
|
|
# Mark messages as read (batch load receipts to avoid N+1)
|
|
message_ids = [m.id for m in messages]
|
|
existing_receipts = (
|
|
ChatReadReceipt.query.filter(
|
|
ChatReadReceipt.message_id.in_(message_ids),
|
|
ChatReadReceipt.user_id == current_user.id,
|
|
).all()
|
|
if message_ids
|
|
else []
|
|
)
|
|
receipt_by_message = {r.message_id: r for r in existing_receipts}
|
|
for message in messages:
|
|
if message.id not in receipt_by_message:
|
|
db.session.add(ChatReadReceipt(message_id=message.id, user_id=current_user.id))
|
|
|
|
db.session.commit()
|
|
|
|
return render_template("chat/channel.html", channel=channel, messages=messages, members=members)
|
|
|
|
|
|
@team_chat_bp.route("/chat/channels/<int:channel_id>/send-message", methods=["POST"])
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def send_message(channel_id):
|
|
"""Send a message via form submission (supports attachments)"""
|
|
import json
|
|
import os
|
|
|
|
channel = ChatChannel.query.get_or_404(channel_id)
|
|
|
|
# Check membership
|
|
membership = ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=current_user.id).first()
|
|
|
|
if not membership and not current_user.is_admin:
|
|
flash(_("You don't have access to this channel"), "error")
|
|
return redirect(url_for("team_chat.chat_channel", channel_id=channel_id))
|
|
|
|
content = request.form.get("content", "").strip()
|
|
attachment_data = request.form.get("attachment_data")
|
|
|
|
if not content and not attachment_data:
|
|
flash(_("Message cannot be empty"), "error")
|
|
return redirect(url_for("team_chat.chat_channel", channel_id=channel_id))
|
|
|
|
# Parse attachment data if provided
|
|
attachment_url = None
|
|
attachment_filename = None
|
|
attachment_size = None
|
|
message_type = "text"
|
|
|
|
if attachment_data:
|
|
try:
|
|
attachment_info = json.loads(attachment_data)
|
|
attachment_url = attachment_info.get("url")
|
|
attachment_filename = attachment_info.get("filename")
|
|
attachment_size = attachment_info.get("size")
|
|
message_type = "file"
|
|
except (json.JSONDecodeError, TypeError, ValueError, AttributeError) as e:
|
|
from flask import current_app
|
|
|
|
current_app.logger.warning("Could not parse attachment data: %s", e)
|
|
flash(_("Attachment data was invalid; message sent without attachment."), "warning")
|
|
|
|
# Create message
|
|
message = ChatMessage(
|
|
channel_id=channel_id,
|
|
user_id=current_user.id,
|
|
message=content or attachment_filename or "",
|
|
message_type=message_type,
|
|
attachment_url=attachment_url,
|
|
attachment_filename=attachment_filename,
|
|
attachment_size=attachment_size,
|
|
)
|
|
|
|
# Parse mentions
|
|
mentions = message.parse_mentions()
|
|
if mentions:
|
|
message.mentions = mentions
|
|
|
|
db.session.add(message)
|
|
|
|
# Update channel updated_at
|
|
channel.updated_at = datetime.utcnow()
|
|
|
|
db.session.commit()
|
|
|
|
# Notify mentioned users
|
|
if mentions:
|
|
from app.utils.notification_service import NotificationService
|
|
|
|
service = NotificationService()
|
|
for user_id in mentions:
|
|
service.send_notification(
|
|
user_id=user_id,
|
|
title="You were mentioned",
|
|
message=f"{current_user.display_name} mentioned you in {channel.name}",
|
|
type="info",
|
|
priority="high",
|
|
)
|
|
|
|
if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
return jsonify({"success": True, "message": message.to_dict()})
|
|
|
|
return redirect(url_for("team_chat.chat_channel", channel_id=channel_id))
|
|
|
|
|
|
@team_chat_bp.route("/api/chat/channels", methods=["GET", "POST"])
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def api_channels():
|
|
"""Get or create channels"""
|
|
if request.method == "POST":
|
|
# Create new channel
|
|
data = request.get_json()
|
|
|
|
channel = ChatChannel(
|
|
name=data.get("name"),
|
|
description=data.get("description"),
|
|
channel_type=data.get("channel_type", "public"),
|
|
created_by=current_user.id,
|
|
project_id=data.get("project_id"),
|
|
)
|
|
db.session.add(channel)
|
|
db.session.flush()
|
|
|
|
# Add creator as member
|
|
member = ChatChannelMember(channel_id=channel.id, user_id=current_user.id, is_admin=True)
|
|
db.session.add(member)
|
|
|
|
# Add other members if specified
|
|
if data.get("member_ids"):
|
|
for user_id in data.get("member_ids", []):
|
|
if user_id != current_user.id:
|
|
member = ChatChannelMember(channel_id=channel.id, user_id=user_id)
|
|
db.session.add(member)
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({"success": True, "channel": channel.to_dict()})
|
|
|
|
# GET - List channels
|
|
channels = (
|
|
ChatChannel.query.join(ChatChannelMember)
|
|
.filter(ChatChannelMember.user_id == current_user.id, ChatChannel.is_archived == False)
|
|
.order_by(ChatChannel.updated_at.desc())
|
|
.all()
|
|
)
|
|
|
|
return jsonify({"channels": [c.to_dict() for c in channels]})
|
|
|
|
|
|
@team_chat_bp.route("/api/chat/channels/<int:channel_id>/messages", methods=["GET", "POST"])
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def api_messages(channel_id):
|
|
"""Get or create messages"""
|
|
channel = ChatChannel.query.get_or_404(channel_id)
|
|
|
|
# Check membership
|
|
membership = ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=current_user.id).first()
|
|
|
|
if not membership and not current_user.is_admin:
|
|
return jsonify({"error": "Access denied"}), 403
|
|
|
|
if request.method == "POST":
|
|
# Create new message
|
|
data = request.get_json()
|
|
if data is None:
|
|
return jsonify({"error": "Invalid JSON", "error_code": "validation_error"}), 400
|
|
|
|
# Validate attachment fields if present (API may send attachment_url, attachment_filename, attachment_size)
|
|
attachment_url = data.get("attachment_url")
|
|
attachment_filename = data.get("attachment_filename")
|
|
attachment_size = data.get("attachment_size")
|
|
if attachment_url is not None or attachment_filename is not None or attachment_size is not None:
|
|
errors = {}
|
|
if attachment_url is not None and not isinstance(attachment_url, str):
|
|
errors.setdefault("attachment_url", []).append("Must be a string.")
|
|
if attachment_filename is not None and not isinstance(attachment_filename, str):
|
|
errors.setdefault("attachment_filename", []).append("Must be a string.")
|
|
if attachment_size is not None:
|
|
try:
|
|
attachment_size = int(attachment_size)
|
|
if attachment_size < 0:
|
|
errors.setdefault("attachment_size", []).append("Must be non-negative.")
|
|
except (TypeError, ValueError):
|
|
errors.setdefault("attachment_size", []).append("Invalid value.")
|
|
if errors:
|
|
from app.utils.api_responses import validation_error_response
|
|
return validation_error_response(errors, message="Invalid attachment data.")
|
|
|
|
message = ChatMessage(
|
|
channel_id=channel_id,
|
|
user_id=current_user.id,
|
|
message=data.get("message", ""),
|
|
message_type=data.get("message_type", "text"),
|
|
reply_to_id=data.get("reply_to_id"),
|
|
attachment_url=attachment_url,
|
|
attachment_filename=attachment_filename,
|
|
attachment_size=attachment_size,
|
|
)
|
|
|
|
# Parse mentions
|
|
mentions = message.parse_mentions()
|
|
if mentions:
|
|
message.mentions = mentions
|
|
|
|
db.session.add(message)
|
|
db.session.commit()
|
|
|
|
# Update channel updated_at
|
|
channel.updated_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
# Notify mentioned users
|
|
if mentions:
|
|
from app.utils.notification_service import NotificationService
|
|
|
|
service = NotificationService()
|
|
for user_id in mentions:
|
|
service.send_notification(
|
|
user_id=user_id,
|
|
title="You were mentioned",
|
|
message=f"{current_user.display_name} mentioned you in {channel.name}",
|
|
type="info",
|
|
priority="high",
|
|
)
|
|
|
|
return jsonify({"success": True, "message": message.to_dict()})
|
|
|
|
# GET - List messages
|
|
before_id = request.args.get("before_id", type=int)
|
|
limit = request.args.get("limit", 50, type=int)
|
|
|
|
query = ChatMessage.query.filter_by(channel_id=channel_id, is_deleted=False)
|
|
|
|
if before_id:
|
|
query = query.filter(ChatMessage.id < before_id)
|
|
|
|
messages = query.order_by(ChatMessage.created_at.desc()).limit(limit).all()
|
|
messages.reverse() # Return in chronological order
|
|
|
|
# Mark as read (batch load receipts to avoid N+1)
|
|
message_ids = [m.id for m in messages]
|
|
existing_receipts = (
|
|
ChatReadReceipt.query.filter(
|
|
ChatReadReceipt.message_id.in_(message_ids),
|
|
ChatReadReceipt.user_id == current_user.id,
|
|
).all()
|
|
if message_ids
|
|
else []
|
|
)
|
|
receipt_by_message = {r.message_id: r for r in existing_receipts}
|
|
for message in messages:
|
|
if message.id not in receipt_by_message:
|
|
db.session.add(ChatReadReceipt(message_id=message.id, user_id=current_user.id))
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({"messages": [m.to_dict() for m in messages]})
|
|
|
|
|
|
@team_chat_bp.route("/api/chat/messages/<int:message_id>", methods=["PUT", "DELETE"])
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def api_message(message_id):
|
|
"""Update or delete message"""
|
|
message = ChatMessage.query.get_or_404(message_id)
|
|
|
|
if message.user_id != current_user.id and not current_user.is_admin:
|
|
return jsonify({"error": "Access denied"}), 403
|
|
|
|
if request.method == "PUT":
|
|
# Update message
|
|
data = request.get_json()
|
|
message.message = data.get("message", message.message)
|
|
message.is_edited = True
|
|
message.edited_at = datetime.utcnow()
|
|
|
|
# Re-parse mentions
|
|
message.parse_mentions()
|
|
|
|
db.session.commit()
|
|
return jsonify({"success": True, "message": message.to_dict()})
|
|
|
|
elif request.method == "DELETE":
|
|
# Soft delete
|
|
message.is_deleted = True
|
|
db.session.commit()
|
|
return jsonify({"success": True})
|
|
|
|
|
|
@team_chat_bp.route("/api/chat/messages/<int:message_id>/react", methods=["POST"])
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def api_react(message_id):
|
|
"""Add or remove reaction to message"""
|
|
message = ChatMessage.query.get_or_404(message_id)
|
|
data = request.get_json()
|
|
|
|
emoji = data.get("emoji")
|
|
if not emoji:
|
|
return jsonify({"error": "Emoji required"}), 400
|
|
|
|
reactions = message.reactions or {}
|
|
if emoji not in reactions:
|
|
reactions[emoji] = []
|
|
|
|
if current_user.id in reactions[emoji]:
|
|
reactions[emoji].remove(current_user.id)
|
|
if not reactions[emoji]:
|
|
del reactions[emoji]
|
|
else:
|
|
reactions[emoji].append(current_user.id)
|
|
|
|
message.reactions = reactions if reactions else None
|
|
db.session.commit()
|
|
|
|
return jsonify({"success": True, "reactions": reactions})
|
|
|
|
|
|
@team_chat_bp.route("/chat/channels/<int:channel_id>/messages/<int:message_id>/attachments/download")
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def download_attachment(channel_id, message_id):
|
|
"""Download an attachment from a chat message"""
|
|
import os
|
|
|
|
from flask import current_app, send_file
|
|
|
|
message = ChatMessage.query.get_or_404(message_id)
|
|
|
|
# Verify message belongs to channel
|
|
if message.channel_id != channel_id:
|
|
flash(_("Invalid message"), "error")
|
|
return redirect(url_for("team_chat.chat_channel", channel_id=channel_id))
|
|
|
|
# Check membership
|
|
membership = ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=current_user.id).first()
|
|
|
|
if not membership and not current_user.is_admin:
|
|
flash(_("You don't have access to this channel"), "error")
|
|
return redirect(url_for("team_chat.chat_index"))
|
|
|
|
if not message.attachment_url:
|
|
flash(_("No attachment found"), "error")
|
|
return redirect(url_for("team_chat.chat_channel", channel_id=channel_id))
|
|
|
|
# Build file path
|
|
file_path = os.path.join(current_app.root_path, "..", message.attachment_url)
|
|
|
|
if not os.path.exists(file_path):
|
|
flash(_("File not found"), "error")
|
|
return redirect(url_for("team_chat.chat_channel", channel_id=channel_id))
|
|
|
|
return send_file(
|
|
file_path,
|
|
as_attachment=True,
|
|
download_name=message.attachment_filename,
|
|
)
|
|
|
|
|
|
@team_chat_bp.route("/chat/channels/<int:channel_id>/upload-attachment", methods=["POST"])
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def upload_attachment(channel_id):
|
|
"""Upload an attachment for a chat message"""
|
|
import os
|
|
from datetime import datetime
|
|
|
|
from flask import current_app, jsonify
|
|
from werkzeug.utils import secure_filename
|
|
|
|
channel = ChatChannel.query.get_or_404(channel_id)
|
|
|
|
# Check membership
|
|
membership = ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=current_user.id).first()
|
|
|
|
if not membership and not current_user.is_admin:
|
|
return jsonify({"error": _("You don't have access to this channel")}), 403
|
|
|
|
# File upload configuration
|
|
ALLOWED_EXTENSIONS = {
|
|
"png",
|
|
"jpg",
|
|
"jpeg",
|
|
"gif",
|
|
"pdf",
|
|
"doc",
|
|
"docx",
|
|
"txt",
|
|
"xls",
|
|
"xlsx",
|
|
"zip",
|
|
"rar",
|
|
"csv",
|
|
"json",
|
|
}
|
|
UPLOAD_FOLDER = "uploads/chat_attachments"
|
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
|
|
def allowed_file(filename):
|
|
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
|
|
|
if "file" not in request.files:
|
|
return jsonify({"error": _("No file provided")}), 400
|
|
|
|
file = request.files["file"]
|
|
if file.filename == "":
|
|
return jsonify({"error": _("No file selected")}), 400
|
|
|
|
# Use the file upload utility for proper validation
|
|
from app.utils.file_upload import validate_file_upload
|
|
|
|
# Normalize allowed extensions to include leading dots for validation
|
|
normalized_allowed = {ext if ext.startswith(".") else "." + ext for ext in ALLOWED_EXTENSIONS}
|
|
|
|
is_valid, error_msg = validate_file_upload(file, allowed_extensions=normalized_allowed, max_size=MAX_FILE_SIZE)
|
|
if not is_valid:
|
|
return jsonify({"error": _(error_msg)}), 400
|
|
|
|
# Save file - secure_filename after validation
|
|
original_filename = secure_filename(file.filename)
|
|
if not original_filename:
|
|
return jsonify({"error": _("Invalid filename")}), 400
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"{channel_id}_{timestamp}_{original_filename}"
|
|
|
|
# Ensure upload directory exists
|
|
upload_dir = os.path.join(current_app.root_path, "..", UPLOAD_FOLDER)
|
|
try:
|
|
os.makedirs(upload_dir, exist_ok=True)
|
|
except (OSError, IOError) as e:
|
|
current_app.logger.error(f"Failed to create upload directory {upload_dir}: {e}")
|
|
return jsonify({"error": _("Server error: Could not create upload directory")}), 500
|
|
|
|
file_path = os.path.join(upload_dir, filename)
|
|
try:
|
|
file.save(file_path)
|
|
file_size = os.path.getsize(file_path)
|
|
except (OSError, IOError) as e:
|
|
current_app.logger.error(f"Failed to save file {filename}: {e}")
|
|
return jsonify({"error": _("Server error: Could not save file")}), 500
|
|
|
|
# Return file info for message creation
|
|
return jsonify(
|
|
{
|
|
"success": True,
|
|
"attachment": {
|
|
"url": os.path.join(UPLOAD_FOLDER, filename),
|
|
"filename": original_filename,
|
|
"size": file_size,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@team_chat_bp.route("/api/chat/users", methods=["GET"])
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def api_chat_users():
|
|
"""Get list of users for chat selection"""
|
|
# Get all active users except current user
|
|
# Order by full_name if available, otherwise by username
|
|
users = (
|
|
User.query.filter(User.id != current_user.id, User.is_active == True)
|
|
.order_by(User.full_name, User.username)
|
|
.all()
|
|
)
|
|
|
|
return jsonify({"users": [user.to_dict() for user in users]})
|
|
|
|
|
|
@team_chat_bp.route("/api/chat/direct-message/<int:user_id>", methods=["POST"])
|
|
@login_required
|
|
@module_enabled("team_chat")
|
|
def api_create_direct_message(user_id):
|
|
"""Create or find existing direct message channel with a user"""
|
|
# CSRF token is validated automatically for form submissions
|
|
# Get target user
|
|
target_user = User.query.get_or_404(user_id)
|
|
|
|
if target_user.id == current_user.id:
|
|
return jsonify({"error": _("Cannot create direct message with yourself")}), 400
|
|
|
|
if not target_user.is_active:
|
|
return jsonify({"error": _("User is not active")}), 400
|
|
|
|
# Check if direct message channel already exists
|
|
# Direct messages have type='direct' and exactly 2 members
|
|
existing_channels = (
|
|
ChatChannel.query.join(ChatChannelMember)
|
|
.filter(
|
|
ChatChannel.channel_type == "direct",
|
|
ChatChannel.is_archived == False,
|
|
ChatChannelMember.user_id == current_user.id,
|
|
)
|
|
.all()
|
|
)
|
|
|
|
# Check each channel to see if it's a direct message with target_user
|
|
for channel in existing_channels:
|
|
members = [m.user_id for m in channel.members.all()]
|
|
if len(members) == 2 and target_user.id in members:
|
|
# Found existing direct message channel
|
|
return jsonify({"success": True, "channel_id": channel.id, "channel": channel.to_dict()})
|
|
|
|
# Create new direct message channel
|
|
channel = ChatChannel(
|
|
name=f"{current_user.display_name} & {target_user.display_name}",
|
|
channel_type="direct",
|
|
created_by=current_user.id,
|
|
)
|
|
db.session.add(channel)
|
|
db.session.flush()
|
|
|
|
# Add both users as members
|
|
member1 = ChatChannelMember(channel_id=channel.id, user_id=current_user.id)
|
|
member2 = ChatChannelMember(channel_id=channel.id, user_id=target_user.id)
|
|
db.session.add(member1)
|
|
db.session.add(member2)
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({"success": True, "channel_id": channel.id, "channel": channel.to_dict()})
|