Files
TimeTracker/app/models/issue.py
Dries Peeters 7791e6ada0 feat: Add comprehensive issue/bug tracking system
Implement a complete issue management system with client portal integration
and internal admin interface for tracking and resolving client-reported issues.

Features:
- New Issue model with full lifecycle management (open, in_progress, resolved, closed, cancelled)
- Priority levels (low, medium, high, urgent) with visual indicators
- Issue linking to projects and tasks
- Create tasks directly from issues
- Client portal integration for issue reporting and viewing
- Internal admin routes for issue management, filtering, and assignment
- Comprehensive templates for both client and admin views
- Status filtering and search functionality
- Issue assignment to internal users
- Automatic timestamp tracking (created, updated, resolved, closed)

Client Portal:
- Clients can report new issues with project association
- View all issues with status filtering
- View individual issue details
- Submit issues with optional submitter name/email

Admin Interface:
- List all issues with advanced filtering (status, priority, client, project, assignee, search)
- View, edit, and delete issues
- Link issues to existing tasks
- Create tasks from issues
- Update issue status, priority, and assignment
- Issue statistics dashboard

Technical:
- Added Issue model with relationships to Client, Project, Task, and User
- New issues blueprint for internal management
- Extended client_portal routes with issue endpoints
- Updated model imports and relationships
- Added navigation links in base templates
- Version bump to 4.6.0
- Code cleanup in docker scripts and schema verification
2025-12-14 07:25:42 +01:00

297 lines
11 KiB
Python

from datetime import datetime
from app import db
from app.utils.timezone import now_in_app_timezone
class Issue(db.Model):
"""Issue/Bug Report model for tracking client-reported issues"""
__tablename__ = "issues"
id = db.Column(db.Integer, primary_key=True)
client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True)
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)
title = db.Column(db.String(200), nullable=False, index=True)
description = db.Column(db.Text, nullable=True)
status = db.Column(
db.String(20), default="open", nullable=False, index=True
) # 'open', 'in_progress', 'resolved', 'closed', 'cancelled'
priority = db.Column(db.String(20), default="medium", nullable=False) # 'low', 'medium', 'high', 'urgent'
# Client submission info
submitted_by_client = db.Column(db.Boolean, default=True, nullable=False) # True if submitted via client portal
client_submitter_name = db.Column(db.String(200), nullable=True) # Name of person who submitted (if not a user)
client_submitter_email = db.Column(db.String(200), nullable=True) # Email of submitter
# Internal assignment
assigned_to = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True)
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) # Internal user who created/imported
# 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)
resolved_at = db.Column(db.DateTime, nullable=True)
closed_at = db.Column(db.DateTime, nullable=True)
# Relationships
client = db.relationship("Client", backref="issues", lazy="joined")
project = db.relationship("Project", backref="issues", lazy="joined")
task = db.relationship("Task", backref="issues", lazy="joined")
assigned_user = db.relationship("User", foreign_keys=[assigned_to], backref="assigned_issues", lazy="joined")
creator = db.relationship("User", foreign_keys=[created_by], backref="created_issues", lazy="joined")
def __init__(
self,
client_id,
title,
description=None,
project_id=None,
task_id=None,
priority="medium",
status="open",
submitted_by_client=True,
client_submitter_name=None,
client_submitter_email=None,
assigned_to=None,
created_by=None,
):
self.client_id = client_id
self.title = title.strip()
self.description = description.strip() if description else None
self.project_id = project_id
self.task_id = task_id
self.priority = priority
self.status = status
self.submitted_by_client = submitted_by_client
self.client_submitter_name = client_submitter_name
self.client_submitter_email = client_submitter_email
self.assigned_to = assigned_to
self.created_by = created_by
def __repr__(self):
return f"<Issue {self.title} ({self.status})>"
@property
def is_open(self):
"""Check if issue is open (not resolved or closed)"""
return self.status in ["open", "in_progress"]
@property
def is_resolved(self):
"""Check if issue is resolved"""
return self.status == "resolved"
@property
def is_closed(self):
"""Check if issue is closed"""
return self.status == "closed"
@property
def status_display(self):
"""Get human-readable status"""
status_map = {
"open": "Open",
"in_progress": "In Progress",
"resolved": "Resolved",
"closed": "Closed",
"cancelled": "Cancelled",
}
return status_map.get(self.status, self.status.replace("_", " ").title())
@property
def priority_display(self):
"""Get human-readable priority"""
priority_map = {"low": "Low", "medium": "Medium", "high": "High", "urgent": "Urgent"}
return priority_map.get(self.priority, self.priority)
@property
def priority_class(self):
"""Get CSS class for priority styling"""
priority_classes = {
"low": "priority-low",
"medium": "priority-medium",
"high": "priority-high",
"urgent": "priority-urgent",
}
return priority_classes.get(self.priority, "priority-medium")
def mark_in_progress(self):
"""Mark issue as in progress"""
if self.status in ["closed", "cancelled"]:
raise ValueError("Cannot mark a closed or cancelled issue as in progress")
self.status = "in_progress"
self.updated_at = now_in_app_timezone()
db.session.commit()
def mark_resolved(self):
"""Mark issue as resolved"""
if self.status in ["closed", "cancelled"]:
raise ValueError("Cannot resolve a closed or cancelled issue")
self.status = "resolved"
self.resolved_at = now_in_app_timezone()
self.updated_at = now_in_app_timezone()
db.session.commit()
def mark_closed(self):
"""Mark issue as closed"""
self.status = "closed"
self.closed_at = now_in_app_timezone()
self.updated_at = now_in_app_timezone()
db.session.commit()
def cancel(self):
"""Cancel the issue"""
if self.status == "closed":
raise ValueError("Cannot cancel a closed issue")
self.status = "cancelled"
self.updated_at = now_in_app_timezone()
db.session.commit()
def link_to_task(self, task_id):
"""Link this issue to a task"""
from .task import Task
task = Task.query.get(task_id)
if not task:
raise ValueError("Task not found")
# Verify task belongs to same client (through project)
if task.project.client_id != self.client_id:
raise ValueError("Task must belong to a project from the same client")
self.task_id = task_id
self.updated_at = now_in_app_timezone()
db.session.commit()
def create_task_from_issue(self, project_id, assigned_to=None, created_by=None):
"""Create a new task from this issue"""
from .task import Task
# Verify project belongs to same client
from .project import Project
project = Project.query.get(project_id)
if not project:
raise ValueError("Project not found")
if project.client_id != self.client_id:
raise ValueError("Project must belong to the same client")
# Create task
task = Task(
project_id=project_id,
name=f"Issue: {self.title}",
description=f"Created from issue #{self.id}\n\n{self.description or ''}",
priority=self.priority,
assigned_to=assigned_to,
created_by=created_by or self.created_by,
status="todo",
)
db.session.add(task)
db.session.flush() # Get task ID
# Link issue to task
self.task_id = task.id
self.updated_at = now_in_app_timezone()
db.session.commit()
return task
def reassign(self, user_id):
"""Reassign issue to different user"""
self.assigned_to = user_id
self.updated_at = now_in_app_timezone()
db.session.commit()
def update_priority(self, priority):
"""Update issue priority"""
valid_priorities = ["low", "medium", "high", "urgent"]
if priority not in valid_priorities:
raise ValueError(f"Invalid priority. Must be one of: {', '.join(valid_priorities)}")
self.priority = priority
self.updated_at = now_in_app_timezone()
db.session.commit()
def to_dict(self):
"""Convert issue to dictionary for API responses"""
return {
"id": self.id,
"client_id": self.client_id,
"client_name": self.client.name if self.client else None,
"project_id": self.project_id,
"project_name": self.project.name if self.project else None,
"task_id": self.task_id,
"task_name": self.task.name if self.task else None,
"title": self.title,
"description": self.description,
"status": self.status,
"status_display": self.status_display,
"priority": self.priority,
"priority_display": self.priority_display,
"priority_class": self.priority_class,
"submitted_by_client": self.submitted_by_client,
"client_submitter_name": self.client_submitter_name,
"client_submitter_email": self.client_submitter_email,
"assigned_to": self.assigned_to,
"assigned_user": self.assigned_user.username if self.assigned_user else None,
"created_by": self.created_by,
"creator": self.creator.username if self.creator else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
"closed_at": self.closed_at.isoformat() if self.closed_at else None,
"is_open": self.is_open,
"is_resolved": self.is_resolved,
"is_closed": self.is_closed,
}
@classmethod
def get_issues_by_client(cls, client_id, status=None, priority=None):
"""Get issues for a specific client with optional filters"""
query = cls.query.filter_by(client_id=client_id)
if status:
query = query.filter_by(status=status)
if priority:
query = query.filter_by(priority=priority)
return query.order_by(cls.priority.desc(), cls.created_at.desc()).all()
@classmethod
def get_issues_by_project(cls, project_id, status=None):
"""Get issues for a specific project"""
query = cls.query.filter_by(project_id=project_id)
if status:
query = query.filter_by(status=status)
return query.order_by(cls.priority.desc(), cls.created_at.desc()).all()
@classmethod
def get_issues_by_task(cls, task_id):
"""Get issues linked to a specific task"""
return cls.query.filter_by(task_id=task_id).order_by(cls.created_at.desc()).all()
@classmethod
def get_user_issues(cls, user_id, status=None):
"""Get issues assigned to a specific user"""
query = cls.query.filter_by(assigned_to=user_id)
if status:
query = query.filter_by(status=status)
return query.order_by(cls.priority.desc(), cls.created_at.desc()).all()
@classmethod
def get_open_issues(cls):
"""Get all open issues"""
return (
cls.query.filter(cls.status.in_(["open", "in_progress"]))
.order_by(cls.priority.desc(), cls.created_at.desc())
.all()
)