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"" @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()