Files
TimeTracker/app/models/issue.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

301 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"""
# Verify project belongs to same client
from .project import Project
from .task import Task
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()
)