mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -05:00
b5c65b0ce8
- Add upload_comment_attachment route with file validation - Add download_attachment route for file downloads - Add delete_attachment route with permission checks - Enhance Comment.to_dict() to include attachments array - Support file size limits (10 MB) and type validation - Proper error handling and file cleanup on errors Routes follow existing attachment patterns from projects/clients.
248 lines
9.3 KiB
Python
248 lines
9.3 KiB
Python
from datetime import datetime
|
|
from app import db
|
|
from app.utils.timezone import now_in_app_timezone
|
|
|
|
|
|
class Comment(db.Model):
|
|
"""Comment model for project and task discussions"""
|
|
|
|
__tablename__ = "comments"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
content = db.Column(db.Text, nullable=False)
|
|
|
|
# Reference to either project, task, or quote (one will be null)
|
|
project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True)
|
|
task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True, index=True)
|
|
quote_id = db.Column(db.Integer, db.ForeignKey("quotes.id", ondelete="CASCADE"), nullable=True, index=True)
|
|
|
|
# Author of the comment (nullable for client comments)
|
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True)
|
|
client_contact_id = db.Column(db.Integer, db.ForeignKey("contacts.id"), nullable=True, index=True) # For client comments
|
|
|
|
# Visibility: True = internal team comment, False = client-visible comment
|
|
is_internal = db.Column(db.Boolean, default=True, nullable=False)
|
|
is_client_comment = db.Column(db.Boolean, default=False, nullable=False) # True if from client contact
|
|
|
|
# Timestamps
|
|
created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False)
|
|
updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False)
|
|
|
|
# Optional: for threaded comments (replies to other comments)
|
|
parent_id = db.Column(db.Integer, db.ForeignKey("comments.id"), nullable=True, index=True)
|
|
|
|
# Relationships
|
|
author = db.relationship("User", backref="comments")
|
|
client_contact = db.relationship("Contact", backref="comments")
|
|
project = db.relationship("Project", backref="comments")
|
|
task = db.relationship("Task", backref="comments")
|
|
quote = db.relationship("Quote", backref="comments")
|
|
|
|
# Self-referential relationship for replies
|
|
parent = db.relationship("Comment", remote_side=[id], backref="replies")
|
|
|
|
def __init__(
|
|
self, content, user_id=None, client_contact_id=None, project_id=None, task_id=None, quote_id=None, parent_id=None, is_internal=True
|
|
):
|
|
"""Create a comment.
|
|
|
|
Args:
|
|
content: The comment text
|
|
user_id: ID of the user creating the comment (optional for client comments)
|
|
client_contact_id: ID of the client contact (optional, for client comments)
|
|
project_id: ID of the project (if this is a project comment)
|
|
task_id: ID of the task (if this is a task comment)
|
|
parent_id: ID of parent comment (if this is a reply)
|
|
"""
|
|
if not project_id and not task_id and not quote_id:
|
|
raise ValueError("Comment must be associated with either a project, task, or quote")
|
|
|
|
# Ensure only one target is set
|
|
targets = [x for x in [project_id, task_id, quote_id] if x is not None]
|
|
if len(targets) > 1:
|
|
raise ValueError("Comment cannot be associated with multiple targets")
|
|
|
|
# Must have either user_id or client_contact_id
|
|
if not user_id and not client_contact_id:
|
|
raise ValueError("Comment must have either user_id or client_contact_id")
|
|
|
|
self.content = content.strip()
|
|
self.user_id = user_id
|
|
self.client_contact_id = client_contact_id
|
|
self.project_id = project_id
|
|
self.task_id = task_id
|
|
self.quote_id = quote_id
|
|
self.parent_id = parent_id
|
|
self.is_internal = is_internal
|
|
self.is_client_comment = client_contact_id is not None
|
|
|
|
def __repr__(self):
|
|
if self.project_id:
|
|
target = f"Project {self.project_id}"
|
|
elif self.task_id:
|
|
target = f"Task {self.task_id}"
|
|
elif self.quote_id:
|
|
target = f"Quote {self.quote_id}"
|
|
else:
|
|
target = "Unknown"
|
|
|
|
author_name = "Unknown"
|
|
if self.author:
|
|
author_name = self.author.username
|
|
elif self.client_contact:
|
|
author_name = self.client_contact.full_name
|
|
|
|
return f'<Comment by {author_name} on {target}>'
|
|
|
|
@property
|
|
def is_reply(self):
|
|
"""Check if this comment is a reply to another comment"""
|
|
return self.parent_id is not None
|
|
|
|
@property
|
|
def target_type(self):
|
|
"""Get the type of target this comment is attached to"""
|
|
if self.project_id:
|
|
return "project"
|
|
elif self.task_id:
|
|
return "task"
|
|
elif self.quote_id:
|
|
return "quote"
|
|
return "unknown"
|
|
|
|
@property
|
|
def target_name(self):
|
|
"""Get the name of the target this comment is attached to"""
|
|
if self.project_id and self.project:
|
|
return self.project.name
|
|
elif self.task_id and self.task:
|
|
return self.task.name
|
|
elif self.quote_id and self.quote:
|
|
return self.quote.title
|
|
return "Unknown"
|
|
|
|
@property
|
|
def reply_count(self):
|
|
"""Get the number of replies to this comment"""
|
|
return len(self.replies) if self.replies else 0
|
|
|
|
def can_edit(self, user):
|
|
"""Check if a user can edit this comment"""
|
|
return user.id == self.user_id or user.is_admin
|
|
|
|
def can_delete(self, user):
|
|
"""Check if a user can delete this comment"""
|
|
return user.id == self.user_id or user.is_admin
|
|
|
|
def edit_content(self, new_content, user):
|
|
"""Edit the comment content"""
|
|
if not self.can_edit(user):
|
|
raise PermissionError("User does not have permission to edit this comment")
|
|
|
|
self.content = new_content.strip()
|
|
self.updated_at = now_in_app_timezone()
|
|
db.session.commit()
|
|
|
|
def delete_comment(self, user):
|
|
"""Delete the comment (soft delete by clearing content)"""
|
|
if not self.can_delete(user):
|
|
raise PermissionError("User does not have permission to delete this comment")
|
|
|
|
# If the comment has replies, we'll mark it as deleted but keep the structure
|
|
if self.replies:
|
|
self.content = "[Comment deleted]"
|
|
self.updated_at = now_in_app_timezone()
|
|
else:
|
|
# If no replies, we can safely delete it
|
|
db.session.delete(self)
|
|
|
|
db.session.commit()
|
|
|
|
def to_dict(self):
|
|
"""Convert comment to dictionary for API responses"""
|
|
author_name = None
|
|
author_full_name = None
|
|
if self.author:
|
|
author_name = self.author.username
|
|
author_full_name = self.author.full_name if self.author.full_name else None
|
|
elif self.client_contact:
|
|
author_name = self.client_contact.full_name
|
|
author_full_name = self.client_contact.full_name
|
|
|
|
result = {
|
|
"id": self.id,
|
|
"content": self.content,
|
|
"project_id": self.project_id,
|
|
"task_id": self.task_id,
|
|
"quote_id": self.quote_id,
|
|
"user_id": self.user_id,
|
|
"client_contact_id": self.client_contact_id,
|
|
"author": author_name,
|
|
"author_full_name": author_full_name,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
"parent_id": self.parent_id,
|
|
"is_reply": self.is_reply,
|
|
"reply_count": self.reply_count,
|
|
"target_type": self.target_type,
|
|
"target_name": self.target_name,
|
|
"is_internal": self.is_internal,
|
|
"is_client_comment": self.is_client_comment,
|
|
}
|
|
|
|
# Add attachments if relationship exists
|
|
if hasattr(self, 'attachments'):
|
|
result["attachments"] = [att.to_dict() for att in self.attachments.all()]
|
|
else:
|
|
result["attachments"] = []
|
|
|
|
return result
|
|
|
|
@classmethod
|
|
def get_project_comments(cls, project_id, include_replies=True):
|
|
"""Get all comments for a project"""
|
|
query = cls.query.filter_by(project_id=project_id)
|
|
|
|
if not include_replies:
|
|
query = query.filter_by(parent_id=None)
|
|
|
|
return query.order_by(cls.created_at.asc()).all()
|
|
|
|
@classmethod
|
|
def get_task_comments(cls, task_id, include_replies=True):
|
|
"""Get all comments for a task"""
|
|
query = cls.query.filter_by(task_id=task_id)
|
|
|
|
if not include_replies:
|
|
query = query.filter_by(parent_id=None)
|
|
|
|
return query.order_by(cls.created_at.asc()).all()
|
|
|
|
@classmethod
|
|
def get_user_comments(cls, user_id, limit=None):
|
|
"""Get recent comments by a user"""
|
|
query = cls.query.filter_by(user_id=user_id).order_by(cls.created_at.desc())
|
|
|
|
if limit:
|
|
query = query.limit(limit)
|
|
|
|
return query.all()
|
|
|
|
@classmethod
|
|
def get_quote_comments(cls, quote_id, include_replies=True, include_internal=True):
|
|
"""Get all comments for a quote"""
|
|
query = cls.query.filter_by(quote_id=quote_id)
|
|
|
|
if not include_internal:
|
|
query = query.filter_by(is_internal=False)
|
|
|
|
if not include_replies:
|
|
query = query.filter_by(parent_id=None)
|
|
|
|
return query.order_by(cls.created_at.asc()).all()
|
|
|
|
@classmethod
|
|
def get_recent_comments(cls, limit=10):
|
|
"""Get recent comments across all projects, tasks, and quotes"""
|
|
return cls.query.order_by(cls.created_at.desc()).limit(limit).all()
|