mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-07 12:10:04 -06:00
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
This commit is contained in:
@@ -956,6 +956,7 @@ def create_app(config=None):
|
||||
from app.routes.api_docs import api_docs_bp, swaggerui_blueprint
|
||||
from app.routes.analytics import analytics_bp
|
||||
from app.routes.tasks import tasks_bp
|
||||
from app.routes.issues import issues_bp
|
||||
from app.routes.invoices import invoices_bp
|
||||
from app.routes.recurring_invoices import recurring_invoices_bp
|
||||
from app.routes.payments import payments_bp
|
||||
@@ -1011,6 +1012,7 @@ def create_app(config=None):
|
||||
app.register_blueprint(swaggerui_blueprint)
|
||||
app.register_blueprint(analytics_bp)
|
||||
app.register_blueprint(tasks_bp)
|
||||
app.register_blueprint(issues_bp)
|
||||
app.register_blueprint(invoices_bp)
|
||||
app.register_blueprint(recurring_invoices_bp)
|
||||
app.register_blueprint(payments_bp)
|
||||
@@ -1273,6 +1275,7 @@ def create_app(config=None):
|
||||
Settings,
|
||||
TaskActivity,
|
||||
Comment,
|
||||
Issue,
|
||||
)
|
||||
|
||||
# Create database tables
|
||||
@@ -1280,6 +1283,9 @@ def create_app(config=None):
|
||||
|
||||
# Check and migrate Task Management tables if needed
|
||||
migrate_task_management_tables()
|
||||
|
||||
# Check and migrate Issues table if needed
|
||||
migrate_issues_table()
|
||||
|
||||
# Create default admin user if it doesn't exist
|
||||
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
|
||||
@@ -1423,6 +1429,32 @@ def migrate_task_management_tables():
|
||||
print(" The application will continue, but Task Management features may not work properly")
|
||||
|
||||
|
||||
def migrate_issues_table():
|
||||
"""Check and migrate Issues table if it doesn't exist"""
|
||||
try:
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# Check if issues table exists
|
||||
inspector = inspect(db.engine)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
if "issues" not in existing_tables:
|
||||
print("Issues: Creating issues table...")
|
||||
# Import Issue model to ensure it's registered
|
||||
from app.models import Issue
|
||||
# Create the issues table
|
||||
Issue.__table__.create(db.engine, checkfirst=True)
|
||||
print("✓ Issues table created successfully")
|
||||
else:
|
||||
print("Issues: Issues table already exists")
|
||||
|
||||
print("Issues migration check completed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠ Warning: Issues migration check failed: {e}")
|
||||
print(" The application will continue, but Issues features may not work properly")
|
||||
|
||||
|
||||
def init_database(app):
|
||||
"""Initialize database tables and create default admin user"""
|
||||
with app.app_context():
|
||||
@@ -1436,6 +1468,7 @@ def init_database(app):
|
||||
Settings,
|
||||
TaskActivity,
|
||||
Comment,
|
||||
Issue,
|
||||
)
|
||||
|
||||
# Create database tables
|
||||
|
||||
@@ -77,6 +77,7 @@ from .expense_gps import MileageTrack
|
||||
from .link_template import LinkTemplate
|
||||
from .custom_field_definition import CustomFieldDefinition
|
||||
from .salesman_email_mapping import SalesmanEmailMapping
|
||||
from .issue import Issue
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -180,4 +181,5 @@ __all__ = [
|
||||
"LinkTemplate",
|
||||
"CustomFieldDefinition",
|
||||
"SalesmanEmailMapping",
|
||||
"Issue",
|
||||
]
|
||||
|
||||
@@ -32,6 +32,7 @@ class Client(db.Model):
|
||||
portal_password_hash = db.Column(db.String(255), nullable=True) # Hashed password for portal access
|
||||
password_setup_token = db.Column(db.String(100), nullable=True, index=True) # Token for password setup/reset
|
||||
password_setup_token_expires = db.Column(db.DateTime, nullable=True) # Token expiration time
|
||||
portal_issues_enabled = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable issue reporting in portal
|
||||
|
||||
# Custom fields for flexible data storage (e.g., debtor_number, ERP IDs, etc.)
|
||||
custom_fields = db.Column(db.JSON, nullable=True)
|
||||
|
||||
296
app/models/issue.py
Normal file
296
app/models/issue.py
Normal file
@@ -0,0 +1,296 @@
|
||||
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()
|
||||
)
|
||||
@@ -53,6 +53,7 @@ class Settings(db.Model):
|
||||
ui_allow_gantt_chart = db.Column(db.Boolean, default=True, nullable=False)
|
||||
ui_allow_kanban_board = db.Column(db.Boolean, default=True, nullable=False)
|
||||
ui_allow_weekly_goals = db.Column(db.Boolean, default=True, nullable=False)
|
||||
ui_allow_issues = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Issues feature
|
||||
|
||||
# CRM section
|
||||
ui_allow_quotes = db.Column(db.Boolean, default=True, nullable=False)
|
||||
@@ -420,6 +421,7 @@ class Settings(db.Model):
|
||||
"ui_allow_gantt_chart": getattr(self, "ui_allow_gantt_chart", True),
|
||||
"ui_allow_kanban_board": getattr(self, "ui_allow_kanban_board", True),
|
||||
"ui_allow_weekly_goals": getattr(self, "ui_allow_weekly_goals", True),
|
||||
"ui_allow_issues": getattr(self, "ui_allow_issues", True),
|
||||
"ui_allow_quotes": getattr(self, "ui_allow_quotes", True),
|
||||
"ui_allow_reports": getattr(self, "ui_allow_reports", True),
|
||||
"ui_allow_report_builder": getattr(self, "ui_allow_report_builder", True),
|
||||
|
||||
@@ -66,6 +66,7 @@ class User(UserMixin, db.Model):
|
||||
ui_show_gantt_chart = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Gantt Chart
|
||||
ui_show_kanban_board = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Kanban Board
|
||||
ui_show_weekly_goals = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Weekly Goals
|
||||
ui_show_issues = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Issues feature
|
||||
|
||||
# CRM section
|
||||
ui_show_quotes = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Quotes
|
||||
|
||||
@@ -518,6 +518,7 @@ def settings():
|
||||
settings_obj.ui_allow_kanban_board = request.form.get("ui_allow_kanban_board") == "on"
|
||||
if hasattr(settings_obj, "ui_allow_weekly_goals"):
|
||||
settings_obj.ui_allow_weekly_goals = request.form.get("ui_allow_weekly_goals") == "on"
|
||||
settings_obj.ui_allow_issues = request.form.get("ui_allow_issues") == "on"
|
||||
|
||||
# CRM
|
||||
if hasattr(settings_obj, "ui_allow_quotes"):
|
||||
|
||||
@@ -7,7 +7,7 @@ invoices, and time entries. Uses separate authentication from regular users.
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort, session
|
||||
from flask_babel import gettext as _
|
||||
from app import db
|
||||
from app.models import Client, Project, Invoice, TimeEntry, User, Quote
|
||||
from app.models import Client, Project, Invoice, TimeEntry, User, Quote, Issue
|
||||
from app.utils.db import safe_commit
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
@@ -451,3 +451,151 @@ def time_entries():
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
|
||||
@client_portal_bp.route("/client-portal/issues")
|
||||
def issues():
|
||||
"""List all issues reported by the client"""
|
||||
result = check_client_portal_access()
|
||||
if not isinstance(result, Client):
|
||||
return result
|
||||
client = result
|
||||
|
||||
# Check if issue reporting is enabled
|
||||
if not client.has_portal_access or not client.portal_issues_enabled:
|
||||
flash(_("Issue reporting is not available."), "error")
|
||||
return redirect(url_for("client_portal.dashboard"))
|
||||
|
||||
# Get all issues for this client
|
||||
issues_list = Issue.get_issues_by_client(client.id)
|
||||
|
||||
# Filter by status if requested
|
||||
status_filter = request.args.get("status", "all")
|
||||
if status_filter != "all":
|
||||
issues_list = [issue for issue in issues_list if issue.status == status_filter]
|
||||
|
||||
# Get projects for filter dropdown
|
||||
portal_data = get_portal_data(client)
|
||||
projects = portal_data["projects"] if portal_data else []
|
||||
|
||||
return render_template(
|
||||
"client_portal/issues.html",
|
||||
client=client,
|
||||
issues=issues_list,
|
||||
status_filter=status_filter,
|
||||
projects=projects,
|
||||
)
|
||||
|
||||
|
||||
@client_portal_bp.route("/client-portal/issues/new", methods=["GET", "POST"])
|
||||
def new_issue():
|
||||
"""Create a new issue report"""
|
||||
result = check_client_portal_access()
|
||||
if not isinstance(result, Client):
|
||||
return result
|
||||
client = result
|
||||
|
||||
# Check if issue reporting is enabled
|
||||
if not client.has_portal_access or not client.portal_issues_enabled:
|
||||
flash(_("Issue reporting is not available."), "error")
|
||||
return redirect(url_for("client_portal.dashboard"))
|
||||
|
||||
# Get projects for dropdown
|
||||
portal_data = get_portal_data(client)
|
||||
projects = portal_data["projects"] if portal_data else []
|
||||
|
||||
if request.method == "POST":
|
||||
title = request.form.get("title", "").strip()
|
||||
description = request.form.get("description", "").strip()
|
||||
project_id = request.form.get("project_id", type=int)
|
||||
priority = request.form.get("priority", "medium")
|
||||
submitter_name = request.form.get("submitter_name", "").strip()
|
||||
submitter_email = request.form.get("submitter_email", "").strip()
|
||||
|
||||
# Validate
|
||||
if not title:
|
||||
flash(_("Title is required."), "error")
|
||||
return render_template(
|
||||
"client_portal/new_issue.html",
|
||||
client=client,
|
||||
projects=projects,
|
||||
title=title,
|
||||
description=description,
|
||||
project_id=project_id,
|
||||
priority=priority,
|
||||
submitter_name=submitter_name,
|
||||
submitter_email=submitter_email,
|
||||
)
|
||||
|
||||
# Validate project belongs to client
|
||||
if project_id:
|
||||
project = Project.query.get(project_id)
|
||||
if not project or project.client_id != client.id:
|
||||
flash(_("Invalid project selected."), "error")
|
||||
return render_template(
|
||||
"client_portal/new_issue.html",
|
||||
client=client,
|
||||
projects=projects,
|
||||
title=title,
|
||||
description=description,
|
||||
project_id=project_id,
|
||||
priority=priority,
|
||||
submitter_name=submitter_name,
|
||||
submitter_email=submitter_email,
|
||||
)
|
||||
|
||||
# Create issue
|
||||
issue = Issue(
|
||||
client_id=client.id,
|
||||
title=title,
|
||||
description=description if description else None,
|
||||
project_id=project_id,
|
||||
priority=priority,
|
||||
status="open",
|
||||
submitted_by_client=True,
|
||||
client_submitter_name=submitter_name if submitter_name else None,
|
||||
client_submitter_email=submitter_email if submitter_email else None,
|
||||
)
|
||||
|
||||
db.session.add(issue)
|
||||
|
||||
if not safe_commit("client_create_issue", {"client_id": client.id, "issue_id": issue.id}):
|
||||
flash(_("Could not create issue due to a database error."), "error")
|
||||
return render_template(
|
||||
"client_portal/new_issue.html",
|
||||
client=client,
|
||||
projects=projects,
|
||||
title=title,
|
||||
description=description,
|
||||
project_id=project_id,
|
||||
priority=priority,
|
||||
submitter_name=submitter_name,
|
||||
submitter_email=submitter_email,
|
||||
)
|
||||
|
||||
flash(_("Issue reported successfully. We will review it shortly."), "success")
|
||||
return redirect(url_for("client_portal.issues"))
|
||||
|
||||
return render_template("client_portal/new_issue.html", client=client, projects=projects)
|
||||
|
||||
|
||||
@client_portal_bp.route("/client-portal/issues/<int:issue_id>")
|
||||
def view_issue(issue_id):
|
||||
"""View a specific issue"""
|
||||
result = check_client_portal_access()
|
||||
if not isinstance(result, Client):
|
||||
return result
|
||||
client = result
|
||||
|
||||
# Check if issue reporting is enabled
|
||||
if not client.has_portal_access or not client.portal_issues_enabled:
|
||||
flash(_("Issue reporting is not available."), "error")
|
||||
return redirect(url_for("client_portal.dashboard"))
|
||||
|
||||
# Verify issue belongs to this client
|
||||
issue = Issue.query.get_or_404(issue_id)
|
||||
if issue.client_id != client.id:
|
||||
flash(_("Issue not found."), "error")
|
||||
abort(404)
|
||||
|
||||
return render_template("client_portal/issue_detail.html", client=client, issue=issue)
|
||||
|
||||
@@ -512,6 +512,7 @@ def edit_client(client_id):
|
||||
|
||||
# Handle portal settings
|
||||
portal_enabled = request.form.get("portal_enabled") == "on"
|
||||
portal_issues_enabled = request.form.get("portal_issues_enabled") == "on"
|
||||
portal_username = request.form.get("portal_username", "").strip()
|
||||
portal_password = request.form.get("portal_password", "").strip()
|
||||
|
||||
@@ -555,6 +556,7 @@ def edit_client(client_id):
|
||||
client.prepaid_hours_monthly = prepaid_hours_monthly
|
||||
client.prepaid_reset_day = prepaid_reset_day
|
||||
client.portal_enabled = portal_enabled
|
||||
client.portal_issues_enabled = portal_issues_enabled if portal_enabled else False
|
||||
client.custom_fields = custom_fields if custom_fields else None
|
||||
|
||||
# Update portal credentials
|
||||
|
||||
325
app/routes/issues.py
Normal file
325
app/routes/issues.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Issue Management Routes
|
||||
|
||||
Provides routes for internal users to manage client-reported issues,
|
||||
link them to tasks, and create tasks from issues.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort, jsonify
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models import Issue, Client, Project, Task, User
|
||||
from app.utils.db import safe_commit
|
||||
from app.utils.pagination import get_pagination_params
|
||||
from sqlalchemy import or_
|
||||
|
||||
issues_bp = Blueprint("issues", __name__)
|
||||
|
||||
|
||||
@issues_bp.route("/issues")
|
||||
@login_required
|
||||
def list_issues():
|
||||
"""List all issues with filtering options"""
|
||||
page, per_page = get_pagination_params()
|
||||
|
||||
# Get filter parameters
|
||||
status = request.args.get("status", "")
|
||||
priority = request.args.get("priority", "")
|
||||
client_id = request.args.get("client_id", type=int)
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
assigned_to = request.args.get("assigned_to", type=int)
|
||||
search = request.args.get("search", "").strip()
|
||||
|
||||
# Build query
|
||||
query = Issue.query
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
query = query.filter_by(status=status)
|
||||
if priority:
|
||||
query = query.filter_by(priority=priority)
|
||||
if client_id:
|
||||
query = query.filter_by(client_id=client_id)
|
||||
if project_id:
|
||||
query = query.filter_by(project_id=project_id)
|
||||
if assigned_to:
|
||||
query = query.filter_by(assigned_to=assigned_to)
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Issue.title.ilike(f"%{search}%"),
|
||||
Issue.description.ilike(f"%{search}%"),
|
||||
)
|
||||
)
|
||||
|
||||
# Check permissions - non-admin users can only see issues for their assigned clients/projects
|
||||
if not current_user.is_admin:
|
||||
# Get user's accessible client IDs (through projects they have access to)
|
||||
# For simplicity, we'll show all issues but filter in template if needed
|
||||
# In a real implementation, you'd want to filter by user permissions here
|
||||
pass
|
||||
|
||||
# Order by priority and creation date
|
||||
query = query.order_by(
|
||||
Issue.priority.desc(),
|
||||
Issue.created_at.desc()
|
||||
)
|
||||
|
||||
# Paginate
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
issues = pagination.items
|
||||
|
||||
# Get filter options
|
||||
clients = Client.query.filter_by(status="active").order_by(Client.name).limit(500).all()
|
||||
projects = Project.query.filter_by(status="active").order_by(Project.name).limit(500).all()
|
||||
users = User.query.filter_by(is_active=True).order_by(User.username).limit(200).all()
|
||||
|
||||
# Calculate statistics
|
||||
total_issues = Issue.query.count()
|
||||
open_issues = Issue.query.filter(Issue.status.in_(["open", "in_progress"])).count()
|
||||
resolved_issues = Issue.query.filter_by(status="resolved").count()
|
||||
closed_issues = Issue.query.filter_by(status="closed").count()
|
||||
|
||||
return render_template(
|
||||
"issues/list.html",
|
||||
issues=issues,
|
||||
pagination=pagination,
|
||||
status=status,
|
||||
priority=priority,
|
||||
client_id=client_id,
|
||||
project_id=project_id,
|
||||
assigned_to=assigned_to,
|
||||
search=search,
|
||||
clients=clients,
|
||||
projects=projects,
|
||||
users=users,
|
||||
total_issues=total_issues,
|
||||
open_issues=open_issues,
|
||||
resolved_issues=resolved_issues,
|
||||
closed_issues=closed_issues,
|
||||
)
|
||||
|
||||
|
||||
@issues_bp.route("/issues/<int:issue_id>")
|
||||
@login_required
|
||||
def view_issue(issue_id):
|
||||
"""View a specific issue"""
|
||||
issue = Issue.query.get_or_404(issue_id)
|
||||
|
||||
# Get related tasks if project is set
|
||||
related_tasks = []
|
||||
if issue.project_id:
|
||||
related_tasks = Task.query.filter_by(project_id=issue.project_id).order_by(Task.created_at.desc()).limit(20).all()
|
||||
|
||||
# Get users for assignment dropdown
|
||||
users = User.query.filter_by(is_active=True).order_by(User.username).limit(200).all()
|
||||
|
||||
# Get projects for create task form
|
||||
projects = []
|
||||
if issue.client_id:
|
||||
projects = Project.query.filter_by(client_id=issue.client_id, status="active").order_by(Project.name).limit(500).all()
|
||||
|
||||
return render_template(
|
||||
"issues/view.html",
|
||||
issue=issue,
|
||||
related_tasks=related_tasks,
|
||||
users=users,
|
||||
projects=projects,
|
||||
)
|
||||
|
||||
|
||||
@issues_bp.route("/issues/<int:issue_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_issue(issue_id):
|
||||
"""Edit an issue"""
|
||||
issue = Issue.query.get_or_404(issue_id)
|
||||
|
||||
if request.method == "POST":
|
||||
title = request.form.get("title", "").strip()
|
||||
description = request.form.get("description", "").strip()
|
||||
status = request.form.get("status", "open")
|
||||
priority = request.form.get("priority", "medium")
|
||||
project_id = request.form.get("project_id", type=int)
|
||||
assigned_to = request.form.get("assigned_to", type=int) or None
|
||||
|
||||
# Validate
|
||||
if not title:
|
||||
flash(_("Title is required."), "error")
|
||||
return redirect(url_for("issues.edit_issue", issue_id=issue_id))
|
||||
|
||||
# Validate project belongs to same client if changed
|
||||
if project_id and project_id != issue.project_id:
|
||||
project = Project.query.get(project_id)
|
||||
if not project or project.client_id != issue.client_id:
|
||||
flash(_("Project must belong to the same client."), "error")
|
||||
return redirect(url_for("issues.edit_issue", issue_id=issue_id))
|
||||
|
||||
# Update issue
|
||||
issue.title = title
|
||||
issue.description = description if description else None
|
||||
issue.status = status
|
||||
issue.priority = priority
|
||||
issue.project_id = project_id
|
||||
issue.assigned_to = assigned_to
|
||||
|
||||
# Update status timestamps
|
||||
if status == "resolved" and not issue.resolved_at:
|
||||
from app.utils.timezone import now_in_app_timezone
|
||||
issue.resolved_at = now_in_app_timezone()
|
||||
elif status == "closed" and not issue.closed_at:
|
||||
from app.utils.timezone import now_in_app_timezone
|
||||
issue.closed_at = now_in_app_timezone()
|
||||
|
||||
if not safe_commit("edit_issue", {"issue_id": issue.id, "user_id": current_user.id}):
|
||||
flash(_("Could not update issue due to a database error."), "error")
|
||||
return redirect(url_for("issues.edit_issue", issue_id=issue_id))
|
||||
|
||||
flash(_("Issue updated successfully."), "success")
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
# GET - show edit form
|
||||
clients = Client.query.filter_by(status="active").order_by(Client.name).limit(500).all()
|
||||
projects = Project.query.filter_by(client_id=issue.client_id, status="active").order_by(Project.name).limit(500).all()
|
||||
users = User.query.filter_by(is_active=True).order_by(User.username).limit(200).all()
|
||||
|
||||
return render_template(
|
||||
"issues/edit.html",
|
||||
issue=issue,
|
||||
clients=clients,
|
||||
projects=projects,
|
||||
users=users,
|
||||
)
|
||||
|
||||
|
||||
@issues_bp.route("/issues/<int:issue_id>/link-task", methods=["POST"])
|
||||
@login_required
|
||||
def link_task(issue_id):
|
||||
"""Link an issue to an existing task"""
|
||||
issue = Issue.query.get_or_404(issue_id)
|
||||
task_id = request.form.get("task_id", type=int)
|
||||
|
||||
if not task_id:
|
||||
flash(_("Please select a task."), "error")
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
try:
|
||||
issue.link_to_task(task_id)
|
||||
flash(_("Issue linked to task successfully."), "success")
|
||||
except ValueError as e:
|
||||
flash(_(str(e)), "error")
|
||||
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
|
||||
@issues_bp.route("/issues/<int:issue_id>/create-task", methods=["POST"])
|
||||
@login_required
|
||||
def create_task_from_issue(issue_id):
|
||||
"""Create a new task from an issue"""
|
||||
issue = Issue.query.get_or_404(issue_id)
|
||||
project_id = request.form.get("project_id", type=int)
|
||||
assigned_to = request.form.get("assigned_to", type=int) or None
|
||||
|
||||
if not project_id:
|
||||
flash(_("Please select a project."), "error")
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
try:
|
||||
task = issue.create_task_from_issue(
|
||||
project_id=project_id,
|
||||
assigned_to=assigned_to,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
flash(_("Task created from issue successfully."), "success")
|
||||
return redirect(url_for("tasks.view_task", task_id=task.id))
|
||||
except ValueError as e:
|
||||
flash(_(str(e)), "error")
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
|
||||
@issues_bp.route("/issues/<int:issue_id>/status", methods=["POST"])
|
||||
@login_required
|
||||
def update_status(issue_id):
|
||||
"""Update issue status"""
|
||||
issue = Issue.query.get_or_404(issue_id)
|
||||
status = request.form.get("status", "")
|
||||
|
||||
if not status:
|
||||
flash(_("Status is required."), "error")
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
try:
|
||||
if status == "in_progress":
|
||||
issue.mark_in_progress()
|
||||
elif status == "resolved":
|
||||
issue.mark_resolved()
|
||||
elif status == "closed":
|
||||
issue.mark_closed()
|
||||
elif status == "cancelled":
|
||||
issue.cancel()
|
||||
else:
|
||||
issue.status = status
|
||||
from app.utils.timezone import now_in_app_timezone
|
||||
issue.updated_at = now_in_app_timezone()
|
||||
db.session.commit()
|
||||
|
||||
flash(_("Issue status updated successfully."), "success")
|
||||
except ValueError as e:
|
||||
flash(_(str(e)), "error")
|
||||
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
|
||||
@issues_bp.route("/issues/<int:issue_id>/assign", methods=["POST"])
|
||||
@login_required
|
||||
def assign_issue(issue_id):
|
||||
"""Assign issue to a user"""
|
||||
issue = Issue.query.get_or_404(issue_id)
|
||||
user_id = request.form.get("user_id", type=int) or None
|
||||
|
||||
try:
|
||||
issue.reassign(user_id)
|
||||
flash(_("Issue assigned successfully."), "success")
|
||||
except Exception as e:
|
||||
flash(_("Could not assign issue."), "error")
|
||||
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
|
||||
@issues_bp.route("/issues/<int:issue_id>/priority", methods=["POST"])
|
||||
@login_required
|
||||
def update_priority(issue_id):
|
||||
"""Update issue priority"""
|
||||
issue = Issue.query.get_or_404(issue_id)
|
||||
priority = request.form.get("priority", "")
|
||||
|
||||
if not priority:
|
||||
flash(_("Priority is required."), "error")
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
try:
|
||||
issue.update_priority(priority)
|
||||
flash(_("Issue priority updated successfully."), "success")
|
||||
except ValueError as e:
|
||||
flash(_(str(e)), "error")
|
||||
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
|
||||
@issues_bp.route("/issues/<int:issue_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_issue(issue_id):
|
||||
"""Delete an issue"""
|
||||
if not current_user.is_admin:
|
||||
flash(_("Only administrators can delete issues."), "error")
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
issue = Issue.query.get_or_404(issue_id)
|
||||
|
||||
db.session.delete(issue)
|
||||
|
||||
if not safe_commit("delete_issue", {"issue_id": issue_id, "user_id": current_user.id}):
|
||||
flash(_("Could not delete issue due to a database error."), "error")
|
||||
return redirect(url_for("issues.view_issue", issue_id=issue_id))
|
||||
|
||||
flash(_("Issue deleted successfully."), "success")
|
||||
return redirect(url_for("issues.list_issues"))
|
||||
@@ -131,6 +131,7 @@ def settings():
|
||||
current_user.ui_show_gantt_chart = "ui_show_gantt_chart" in request.form
|
||||
current_user.ui_show_kanban_board = "ui_show_kanban_board" in request.form
|
||||
current_user.ui_show_weekly_goals = "ui_show_weekly_goals" in request.form
|
||||
current_user.ui_show_issues = "ui_show_issues" in request.form
|
||||
|
||||
# UI feature flags - CRM
|
||||
current_user.ui_show_quotes = "ui_show_quotes" in request.form
|
||||
|
||||
@@ -123,6 +123,12 @@
|
||||
{{ _('Allow Weekly Goals') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="ui_allow_issues" id="ui_allow_issues" {% if settings.ui_allow_issues %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<label for="ui_allow_issues" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
{{ _('Allow Issues') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
</div>
|
||||
<nav class="flex-1">
|
||||
{% set ep = request.endpoint or '' %}
|
||||
{% set work_open = ep.startswith('projects.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('weekly_goals.') or ep.startswith('project_templates.') or ep.startswith('gantt.') %}
|
||||
{% set work_open = ep.startswith('projects.') or ep.startswith('tasks.') or ep.startswith('issues.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('weekly_goals.') or ep.startswith('project_templates.') or ep.startswith('gantt.') %}
|
||||
{% set calendar_open = ep.startswith('calendar.') %}
|
||||
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('recurring_invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('mileage.') or ep.startswith('per_diem.') or ep.startswith('budget_alerts.') or ep.startswith('invoice_approvals.') or ep.startswith('payment_gateways.') or ep.startswith('scheduled_reports.') or ep.startswith('custom_reports.') %}
|
||||
{% set crm_open = ep.startswith('clients.') or ep.startswith('quotes.') %}
|
||||
@@ -280,6 +280,7 @@
|
||||
{% set nav_active_clients = ep.startswith('clients.') %}
|
||||
{% set nav_active_quotes = ep.startswith('quotes.') %}
|
||||
{% set nav_active_tasks = ep.startswith('tasks.') %}
|
||||
{% set nav_active_issues = ep.startswith('issues.') %}
|
||||
{% set nav_active_kanban = ep.startswith('kanban.') %}
|
||||
{% set nav_active_templates = ep.startswith('time_entry_templates.') %}
|
||||
{% set nav_active_goals = ep.startswith('weekly_goals.') %}
|
||||
@@ -318,6 +319,13 @@
|
||||
<i class="fas fa-tasks w-4 mr-2"></i>{{ _('Tasks') }}
|
||||
</a>
|
||||
</li>
|
||||
{% if settings.ui_allow_issues and current_user.ui_show_issues %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_issues %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('issues.list_issues') }}">
|
||||
<i class="fas fa-bug w-4 mr-2"></i>{{ _('Issues') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if settings.ui_allow_kanban_board and current_user.ui_show_kanban_board %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_kanban %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('kanban.board') }}">
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
<a href="{{ url_for('client_portal.time_entries') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
|
||||
<i class="fas fa-clock mr-1"></i>{{ _('Time Entries') }}
|
||||
</a>
|
||||
{% set current_client = get_current_client() %}
|
||||
{% if current_client and current_client.portal_issues_enabled %}
|
||||
<a href="{{ url_for('client_portal.issues') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
|
||||
<i class="fas fa-bug mr-1"></i>{{ _('Issues') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('client_portal.logout') }}" class="text-sm text-text-light dark:text-text-dark hover:text-red-600 transition-colors">
|
||||
<i class="fas fa-sign-out-alt mr-1"></i>{{ _('Logout') }}
|
||||
</a>
|
||||
|
||||
110
app/templates/client_portal/issue_detail.html
Normal file
110
app/templates/client_portal/issue_detail.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends "client_portal/base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block title %}{{ issue.title }} - {{ _('Client Portal') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Client Portal'), 'url': url_for('client_portal.dashboard')},
|
||||
{'text': _('Issues'), 'url': url_for('client_portal.issues')},
|
||||
{'text': issue.title}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-bug',
|
||||
title_text=issue.title,
|
||||
subtitle_text=_('Issue #%(id)s', id=issue.id),
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Description') }}</h2>
|
||||
{% if issue.description %}
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
{{ issue.description | markdown | safe }}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No description provided.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Details') }}</h2>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Status') }}:</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs rounded-full
|
||||
{% if issue.status == 'open' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% elif issue.status == 'in_progress' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
|
||||
{% elif issue.status == 'resolved' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||
{% elif issue.status == 'closed' %}bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200{% endif %}">
|
||||
{{ issue.status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Priority') }}:</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs rounded-full
|
||||
{% if issue.priority == 'urgent' %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
|
||||
{% elif issue.priority == 'high' %}bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200
|
||||
{% elif issue.priority == 'medium' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200{% endif %}">
|
||||
{{ issue.priority_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if issue.project %}
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}:</span>
|
||||
<span class="ml-2">{{ issue.project.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if issue.task %}
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Linked Task') }}:</span>
|
||||
<span class="ml-2">{{ issue.task.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if issue.assigned_user %}
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Assigned To') }}:</span>
|
||||
<span class="ml-2">{{ issue.assigned_user.username }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Created') }}:</span>
|
||||
<span class="ml-2">{{ issue.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
|
||||
{% if issue.updated_at != issue.created_at %}
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Last Updated') }}:</span>
|
||||
<span class="ml-2">{{ issue.updated_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if issue.resolved_at %}
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Resolved') }}:</span>
|
||||
<span class="ml-2">{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ url_for('client_portal.issues') }}" class="text-primary hover:underline">
|
||||
<i class="fas fa-arrow-left mr-2"></i>{{ _('Back to Issues') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
96
app/templates/client_portal/issues.html
Normal file
96
app/templates/client_portal/issues.html
Normal file
@@ -0,0 +1,96 @@
|
||||
{% extends "client_portal/base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block title %}{{ _('Issues') }} - {{ _('Client Portal') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Client Portal'), 'url': url_for('client_portal.dashboard')},
|
||||
{'text': _('Issues')}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-bug',
|
||||
title_text=_('Issue Reports'),
|
||||
subtitle_text=_('Report and track issues for %(client_name)s', client_name=client.name),
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('client_portal.issues', status='all') }}"
|
||||
class="px-3 py-1 rounded {% if status_filter == 'all' %}bg-primary text-white{% else %}bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark{% endif %}">
|
||||
{{ _('All') }}
|
||||
</a>
|
||||
<a href="{{ url_for('client_portal.issues', status='open') }}"
|
||||
class="px-3 py-1 rounded {% if status_filter == 'open' %}bg-primary text-white{% else %}bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark{% endif %}">
|
||||
{{ _('Open') }}
|
||||
</a>
|
||||
<a href="{{ url_for('client_portal.issues', status='in_progress') }}"
|
||||
class="px-3 py-1 rounded {% if status_filter == 'in_progress' %}bg-primary text-white{% else %}bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark{% endif %}">
|
||||
{{ _('In Progress') }}
|
||||
</a>
|
||||
<a href="{{ url_for('client_portal.issues', status='resolved') }}"
|
||||
class="px-3 py-1 rounded {% if status_filter == 'resolved' %}bg-primary text-white{% else %}bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark{% endif %}">
|
||||
{{ _('Resolved') }}
|
||||
</a>
|
||||
<a href="{{ url_for('client_portal.issues', status='closed') }}"
|
||||
class="px-3 py-1 rounded {% if status_filter == 'closed' %}bg-primary text-white{% else %}bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark{% endif %}">
|
||||
{{ _('Closed') }}
|
||||
</a>
|
||||
</div>
|
||||
<a href="{{ url_for('client_portal.new_issue') }}" class="bg-primary text-white px-4 py-2 rounded hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Report New Issue') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
{% if issues %}
|
||||
<div class="space-y-4">
|
||||
{% for issue in issues %}
|
||||
<div class="border border-border-light dark:border-border-dark rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1">
|
||||
<a href="{{ url_for('client_portal.view_issue', issue_id=issue.id) }}" class="text-lg font-semibold text-primary hover:underline">
|
||||
{{ issue.title }}
|
||||
</a>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="px-2 py-1 text-xs rounded-full
|
||||
{% if issue.status == 'open' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% elif issue.status == 'in_progress' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
|
||||
{% elif issue.status == 'resolved' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||
{% elif issue.status == 'closed' %}bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200{% endif %}">
|
||||
{{ issue.status_display }}
|
||||
</span>
|
||||
<span class="px-2 py-1 text-xs rounded-full
|
||||
{% if issue.priority == 'urgent' %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
|
||||
{% elif issue.priority == 'high' %}bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200
|
||||
{% elif issue.priority == 'medium' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200{% endif %}">
|
||||
{{ issue.priority_display }}
|
||||
</span>
|
||||
{% if issue.project %}
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
<i class="fas fa-folder-open mr-1"></i>{{ issue.project.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ issue.created_at.strftime('%Y-%m-%d') }}
|
||||
</div>
|
||||
</div>
|
||||
{% if issue.description %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2">
|
||||
{{ issue.description[:200] }}{% if issue.description|length > 200 %}...{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">{{ _('No issues found.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
90
app/templates/client_portal/new_issue.html
Normal file
90
app/templates/client_portal/new_issue.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends "client_portal/base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block title %}{{ _('Report New Issue') }} - {{ _('Client Portal') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Client Portal'), 'url': url_for('client_portal.dashboard')},
|
||||
{'text': _('Issues'), 'url': url_for('client_portal.issues')},
|
||||
{'text': _('Report New Issue')}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-bug',
|
||||
title_text=_('Report New Issue'),
|
||||
subtitle_text=_('Report a bug or issue you\'ve encountered'),
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" action="{{ url_for('client_portal.new_issue') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium mb-1">{{ _('Title') }} <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="title" name="title" value="{{ title or '' }}" required
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark"
|
||||
placeholder="{{ _('Brief description of the issue') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium mb-1">{{ _('Description') }}</label>
|
||||
<textarea id="description" name="description" rows="6"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark"
|
||||
placeholder="{{ _('Detailed description of the issue, steps to reproduce, etc.') }}">{{ description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium mb-1">{{ _('Project') }}</label>
|
||||
<select id="project_id" name="project_id"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
|
||||
<option value="">{{ _('Select a project (optional)') }}</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="priority" class="block text-sm font-medium mb-1">{{ _('Priority') }}</label>
|
||||
<select id="priority" name="priority"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
|
||||
<option value="low" {% if priority == 'low' %}selected{% endif %}>{{ _('Low') }}</option>
|
||||
<option value="medium" {% if priority == 'medium' %}selected{% endif %}>{{ _('Medium') }}</option>
|
||||
<option value="high" {% if priority == 'high' %}selected{% endif %}>{{ _('High') }}</option>
|
||||
<option value="urgent" {% if priority == 'urgent' %}selected{% endif %}>{{ _('Urgent') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="submitter_name" class="block text-sm font-medium mb-1">{{ _('Your Name') }}</label>
|
||||
<input type="text" id="submitter_name" name="submitter_name" value="{{ submitter_name or '' }}"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark"
|
||||
placeholder="{{ _('Your name (optional)') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="submitter_email" class="block text-sm font-medium mb-1">{{ _('Your Email') }}</label>
|
||||
<input type="email" id="submitter_email" name="submitter_email" value="{{ submitter_email or '' }}"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark"
|
||||
placeholder="{{ _('Your email (optional)') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-paper-plane mr-2"></i>{{ _('Submit Issue') }}
|
||||
</button>
|
||||
<a href="{{ url_for('client_portal.issues') }}" class="bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark px-4 py-2 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark transition-colors">
|
||||
{{ _('Cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -111,6 +111,15 @@
|
||||
<label for="portal_enabled" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">{{ _('Enable Client Portal') }}</label>
|
||||
</div>
|
||||
<div id="portal_fields" style="display: {% if client.portal_enabled %}block{% else %}none{% endif %};">
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="portal_issues_enabled" id="portal_issues_enabled" {% if client.portal_issues_enabled %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<label for="portal_issues_enabled" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">{{ _('Enable Issue Reporting') }}</label>
|
||||
</div>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1 ml-6">
|
||||
{{ _('Allow clients to report bugs and issues through the portal') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="portal_username" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Portal Username') }}</label>
|
||||
|
||||
91
app/templates/issues/edit.html
Normal file
91
app/templates/issues/edit.html
Normal file
@@ -0,0 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Issues', 'url': url_for('issues.list_issues')},
|
||||
{'text': issue.title, 'url': url_for('issues.view_issue', issue_id=issue.id)},
|
||||
{'text': 'Edit'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-edit',
|
||||
title_text=_('Edit Issue'),
|
||||
subtitle_text=issue.title,
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" action="{{ url_for('issues.edit_issue', issue_id=issue.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium mb-1">{{ _('Title') }} <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="title" name="title" value="{{ issue.title }}" required
|
||||
class="form-input w-full">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium mb-1">{{ _('Description') }}</label>
|
||||
<textarea id="description" name="description" rows="6"
|
||||
class="form-input w-full">{{ issue.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium mb-1">{{ _('Status') }}</label>
|
||||
<select id="status" name="status" class="form-input w-full">
|
||||
<option value="open" {% if issue.status == 'open' %}selected{% endif %}>{{ _('Open') }}</option>
|
||||
<option value="in_progress" {% if issue.status == 'in_progress' %}selected{% endif %}>{{ _('In Progress') }}</option>
|
||||
<option value="resolved" {% if issue.status == 'resolved' %}selected{% endif %}>{{ _('Resolved') }}</option>
|
||||
<option value="closed" {% if issue.status == 'closed' %}selected{% endif %}>{{ _('Closed') }}</option>
|
||||
<option value="cancelled" {% if issue.status == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="priority" class="block text-sm font-medium mb-1">{{ _('Priority') }}</label>
|
||||
<select id="priority" name="priority" class="form-input w-full">
|
||||
<option value="low" {% if issue.priority == 'low' %}selected{% endif %}>{{ _('Low') }}</option>
|
||||
<option value="medium" {% if issue.priority == 'medium' %}selected{% endif %}>{{ _('Medium') }}</option>
|
||||
<option value="high" {% if issue.priority == 'high' %}selected{% endif %}>{{ _('High') }}</option>
|
||||
<option value="urgent" {% if issue.priority == 'urgent' %}selected{% endif %}>{{ _('Urgent') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium mb-1">{{ _('Project') }}</label>
|
||||
<select id="project_id" name="project_id" class="form-input w-full">
|
||||
<option value="">{{ _('No project') }}</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if issue.project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="assigned_to" class="block text-sm font-medium mb-1">{{ _('Assigned To') }}</label>
|
||||
<select id="assigned_to" name="assigned_to" class="form-input w-full">
|
||||
<option value="">{{ _('Unassigned') }}</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if issue.assigned_to == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-save mr-2"></i>{{ _('Save Changes') }}
|
||||
</button>
|
||||
<a href="{{ url_for('issues.view_issue', issue_id=issue.id) }}" class="bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark px-4 py-2 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark transition-colors">
|
||||
{{ _('Cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
172
app/templates/issues/list.html
Normal file
172
app/templates/issues/list.html
Normal file
@@ -0,0 +1,172 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, stat_card %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Issues'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-bug',
|
||||
title_text='Issues',
|
||||
subtitle_text='Manage client-reported issues and bugs',
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<!-- Issue Summary Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{{ stat_card('Total Issues', total_issues, 'fas fa-bug', 'slate-500') }}
|
||||
{{ stat_card('Open Issues', open_issues, 'fas fa-exclamation-circle', 'blue-500') }}
|
||||
{{ stat_card('Resolved', resolved_issues, 'fas fa-check-circle', 'green-500') }}
|
||||
{{ stat_card('Closed', closed_issues, 'fas fa-times-circle', 'gray-500') }}
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Filter Issues') }}</h2>
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium mb-1">{{ _('Search') }}</label>
|
||||
<input type="text" name="search" id="search" value="{{ search or '' }}"
|
||||
class="form-input" placeholder="{{ _('Search by title or description') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium mb-1">{{ _('Status') }}</label>
|
||||
<select name="status" id="status" class="form-input">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
<option value="open" {% if status == 'open' %}selected{% endif %}>{{ _('Open') }}</option>
|
||||
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>{{ _('In Progress') }}</option>
|
||||
<option value="resolved" {% if status == 'resolved' %}selected{% endif %}>{{ _('Resolved') }}</option>
|
||||
<option value="closed" {% if status == 'closed' %}selected{% endif %}>{{ _('Closed') }}</option>
|
||||
<option value="cancelled" {% if status == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="priority" class="block text-sm font-medium mb-1">{{ _('Priority') }}</label>
|
||||
<select name="priority" id="priority" class="form-input">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
<option value="low" {% if priority == 'low' %}selected{% endif %}>{{ _('Low') }}</option>
|
||||
<option value="medium" {% if priority == 'medium' %}selected{% endif %}>{{ _('Medium') }}</option>
|
||||
<option value="high" {% if priority == 'high' %}selected{% endif %}>{{ _('High') }}</option>
|
||||
<option value="urgent" {% if priority == 'urgent' %}selected{% endif %}>{{ _('Urgent') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium mb-1">{{ _('Client') }}</label>
|
||||
<select name="client_id" id="client_id" class="form-input">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}" {% if client_id == client.id %}selected{% endif %}>{{ client.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium mb-1">{{ _('Project') }}</label>
|
||||
<select name="project_id" id="project_id" class="form-input">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="assigned_to" class="block text-sm font-medium mb-1">{{ _('Assigned To') }}</label>
|
||||
<select name="assigned_to" id="assigned_to" class="form-input">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if assigned_to == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-filter mr-2"></i>{{ _('Apply Filters') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Issues List -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
{% if issues %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-3">{{ _('Title') }}</th>
|
||||
<th class="p-3">{{ _('Client') }}</th>
|
||||
<th class="p-3">{{ _('Project') }}</th>
|
||||
<th class="p-3">{{ _('Status') }}</th>
|
||||
<th class="p-3">{{ _('Priority') }}</th>
|
||||
<th class="p-3">{{ _('Assigned To') }}</th>
|
||||
<th class="p-3">{{ _('Created') }}</th>
|
||||
<th class="p-3">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in issues %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||||
<td class="p-3">
|
||||
<a href="{{ url_for('issues.view_issue', issue_id=issue.id) }}" class="text-primary hover:underline font-medium">
|
||||
{{ issue.title }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="p-3">{{ issue.client.name }}</td>
|
||||
<td class="p-3">{{ issue.project.name if issue.project else '-' }}</td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-1 text-xs rounded-full
|
||||
{% if issue.status == 'open' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% elif issue.status == 'in_progress' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
|
||||
{% elif issue.status == 'resolved' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||
{% elif issue.status == 'closed' %}bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200{% endif %}">
|
||||
{{ issue.status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-1 text-xs rounded-full
|
||||
{% if issue.priority == 'urgent' %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
|
||||
{% elif issue.priority == 'high' %}bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200
|
||||
{% elif issue.priority == 'medium' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200{% endif %}">
|
||||
{{ issue.priority_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3">{{ issue.assigned_user.display_name if issue.assigned_user else '-' }}</td>
|
||||
<td class="p-3">{{ issue.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="p-3">
|
||||
<a href="{{ url_for('issues.view_issue', issue_id=issue.id) }}" class="text-primary hover:underline text-sm">
|
||||
<i class="fas fa-eye mr-1"></i>{{ _('View') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="mt-4 flex justify-center">
|
||||
<div class="flex gap-2">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('issues.list_issues', page=pagination.prev_num, status=status, priority=priority, client_id=client_id, project_id=project_id, assigned_to=assigned_to, search=search) }}"
|
||||
class="px-3 py-1 bg-card-light dark:bg-card-dark rounded border border-border-light dark:border-border-dark">
|
||||
{{ _('Previous') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<span class="px-3 py-1">{{ _('Page') }} {{ pagination.page }} {{ _('of') }} {{ pagination.pages }}</span>
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('issues.list_issues', page=pagination.next_num, status=status, priority=priority, client_id=client_id, project_id=project_id, assigned_to=assigned_to, search=search) }}"
|
||||
class="px-3 py-1 bg-card-light dark:bg-card-dark rounded border border-border-light dark:border-border-dark">
|
||||
{{ _('Next') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">{{ _('No issues found.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
192
app/templates/issues/view.html
Normal file
192
app/templates/issues/view.html
Normal file
@@ -0,0 +1,192 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import confirm_dialog %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ issue.title }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Issue #%(id)s', id=issue.id) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('issues.edit_issue', issue_id=issue.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">
|
||||
<i class="fas fa-edit mr-2"></i>{{ _('Edit Issue') }}
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<form method="POST" action="{{ url_for('issues.delete_issue', issue_id=issue.id) }}"
|
||||
onsubmit="event.preventDefault(); window.showConfirm('{{ _('Are you sure you want to delete this issue?') }}', { title: '{{ _('Delete Issue') }}', confirmText: '{{ _('Delete') }}' }).then(ok=>{ if(ok){ this.submit(); } });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-lg mt-4 md:mt-0">
|
||||
<i class="fas fa-trash mr-2"></i>{{ _('Delete') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Left Column: Issue Details -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
{% if issue.description %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Description') }}</h2>
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">{{ issue.description | markdown | safe }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Link to Task or Create Task -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Task Management') }}</h2>
|
||||
{% if issue.task %}
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Linked Task') }}:</p>
|
||||
<a href="{{ url_for('tasks.view_task', task_id=issue.task.id) }}" class="text-primary hover:underline font-medium">
|
||||
<i class="fas fa-tasks mr-2"></i>{{ issue.task.name }}
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Link to existing task') }}:</p>
|
||||
<form method="POST" action="{{ url_for('issues.link_task', issue_id=issue.id) }}" class="flex gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select name="task_id" class="form-input flex-1" required>
|
||||
<option value="">{{ _('Select a task') }}</option>
|
||||
{% for task in related_tasks %}
|
||||
<option value="{{ task.id }}">{{ task.name }} ({{ task.status_display }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded hover:bg-primary/90">
|
||||
{{ _('Link') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="border-t border-border-light dark:border-border-dark pt-4">
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Create new task from this issue') }}:</p>
|
||||
<form method="POST" action="{{ url_for('issues.create_task_from_issue', issue_id=issue.id) }}" class="space-y-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select name="project_id" class="form-input w-full" required>
|
||||
<option value="">{{ _('Select a project') }}</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if issue.project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="assigned_to" class="form-input w-full">
|
||||
<option value="">{{ _('Unassigned') }}</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}">{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 w-full">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Create Task') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Issue Info -->
|
||||
<div class="space-y-6">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Details') }}</h2>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Status') }}:</span>
|
||||
<div class="mt-1">
|
||||
<form method="POST" action="{{ url_for('issues.update_status', issue_id=issue.id) }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select name="status" onchange="this.form.submit()" class="form-input text-sm">
|
||||
<option value="open" {% if issue.status == 'open' %}selected{% endif %}>{{ _('Open') }}</option>
|
||||
<option value="in_progress" {% if issue.status == 'in_progress' %}selected{% endif %}>{{ _('In Progress') }}</option>
|
||||
<option value="resolved" {% if issue.status == 'resolved' %}selected{% endif %}>{{ _('Resolved') }}</option>
|
||||
<option value="closed" {% if issue.status == 'closed' %}selected{% endif %}>{{ _('Closed') }}</option>
|
||||
<option value="cancelled" {% if issue.status == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Priority') }}:</span>
|
||||
<div class="mt-1">
|
||||
<form method="POST" action="{{ url_for('issues.update_priority', issue_id=issue.id) }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select name="priority" onchange="this.form.submit()" class="form-input text-sm">
|
||||
<option value="low" {% if issue.priority == 'low' %}selected{% endif %}>{{ _('Low') }}</option>
|
||||
<option value="medium" {% if issue.priority == 'medium' %}selected{% endif %}>{{ _('Medium') }}</option>
|
||||
<option value="high" {% if issue.priority == 'high' %}selected{% endif %}>{{ _('High') }}</option>
|
||||
<option value="urgent" {% if issue.priority == 'urgent' %}selected{% endif %}>{{ _('Urgent') }}</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Client') }}:</span>
|
||||
<p class="font-medium">{{ issue.client.name }}</p>
|
||||
</div>
|
||||
|
||||
{% if issue.project %}
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}:</span>
|
||||
<p class="font-medium">{{ issue.project.name }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Assigned To') }}:</span>
|
||||
<div class="mt-1">
|
||||
<form method="POST" action="{{ url_for('issues.assign_issue', issue_id=issue.id) }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select name="user_id" onchange="this.form.submit()" class="form-input text-sm">
|
||||
<option value="">{{ _('Unassigned') }}</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if issue.assigned_to == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if issue.submitted_by_client %}
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Submitted By') }}:</span>
|
||||
<p class="font-medium">
|
||||
{% if issue.client_submitter_name %}{{ issue.client_submitter_name }}{% else %}{{ _('Client') }}{% endif %}
|
||||
{% if issue.client_submitter_email %}
|
||||
<br><span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ issue.client_submitter_email }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Created') }}:</span>
|
||||
<p class="font-medium">{{ issue.created_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
</div>
|
||||
|
||||
{% if issue.updated_at != issue.created_at %}
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Last Updated') }}:</span>
|
||||
<p class="font-medium">{{ issue.updated_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if issue.resolved_at %}
|
||||
<div>
|
||||
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Resolved') }}:</span>
|
||||
<p class="font-medium">{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<a href="{{ url_for('issues.list_issues') }}" class="text-primary hover:underline">
|
||||
<i class="fas fa-arrow-left mr-2"></i>{{ _('Back to Issues') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -308,6 +308,16 @@
|
||||
{{ _('Weekly Goals') }}
|
||||
</label>
|
||||
</div>
|
||||
{% if settings.ui_allow_issues %}
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="ui_show_issues" name="ui_show_issues"
|
||||
{% if user.ui_show_issues %}checked{% endif %}
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="ui_show_issues" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ _('Issues') }}
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,23 +37,20 @@ def wait_for_database():
|
||||
|
||||
try:
|
||||
if db_url.startswith('postgresql'):
|
||||
# Parse connection string
|
||||
# Parse connection string using urlparse for proper handling
|
||||
# Handle both postgresql:// and postgresql+psycopg2:// schemes
|
||||
if db_url.startswith('postgresql+psycopg2://'):
|
||||
db_url = db_url.replace('postgresql+psycopg2://', '')
|
||||
|
||||
if '@' in db_url:
|
||||
auth_part, rest = db_url.split('@', 1)
|
||||
user, password = auth_part.split(':', 1)
|
||||
if ':' in rest:
|
||||
host_port, database = rest.rsplit('/', 1)
|
||||
if ':' in host_port:
|
||||
host, port = host_port.split(':', 1)
|
||||
else:
|
||||
host, port = host_port, '5432'
|
||||
else:
|
||||
host, port, database = rest, '5432', 'timetracker'
|
||||
parsed_url = urlparse(db_url.replace('postgresql+psycopg2://', 'postgresql://'))
|
||||
else:
|
||||
host, port, database, user, password = 'db', '5432', 'timetracker', 'timetracker', 'timetracker'
|
||||
parsed_url = urlparse(db_url)
|
||||
|
||||
# Extract connection parameters
|
||||
user = parsed_url.username or 'timetracker'
|
||||
password = parsed_url.password or 'timetracker'
|
||||
host = parsed_url.hostname or 'db'
|
||||
port = parsed_url.port or 5432
|
||||
# Remove leading slash from path to get database name
|
||||
database = parsed_url.path.lstrip('/') or 'timetracker'
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=host,
|
||||
|
||||
@@ -57,22 +57,22 @@ def wait_for_database():
|
||||
return False
|
||||
|
||||
# Parse the URL to get connection details (PostgreSQL)
|
||||
if db_url.startswith('postgresql+psycopg2://'):
|
||||
db_url = db_url.replace('postgresql+psycopg2://', '')
|
||||
|
||||
# Extract host, port, database, user, password
|
||||
if '@' in db_url:
|
||||
auth_part, rest = db_url.split('@', 1)
|
||||
user, password = auth_part.split(':', 1)
|
||||
if ':' in rest:
|
||||
host_port, database = rest.rsplit('/', 1)
|
||||
if ':' in host_port:
|
||||
host, port = host_port.split(':', 1)
|
||||
else:
|
||||
host, port = host_port, '5432'
|
||||
# Handle both postgresql:// and postgresql+psycopg2:// schemes
|
||||
if db_url.startswith('postgresql'):
|
||||
if db_url.startswith('postgresql+psycopg2://'):
|
||||
parsed_url = urlparse(db_url.replace('postgresql+psycopg2://', 'postgresql://'))
|
||||
else:
|
||||
host, port, database = rest, '5432', 'timetracker'
|
||||
parsed_url = urlparse(db_url)
|
||||
|
||||
# Extract connection parameters
|
||||
user = parsed_url.username or 'timetracker'
|
||||
password = parsed_url.password or 'timetracker'
|
||||
host = parsed_url.hostname or 'db'
|
||||
port = parsed_url.port or 5432
|
||||
# Remove leading slash from path to get database name
|
||||
database = parsed_url.path.lstrip('/') or 'timetracker'
|
||||
else:
|
||||
# Fallback for other formats
|
||||
host, port, database, user, password = 'db', '5432', 'timetracker', 'timetracker', 'timetracker'
|
||||
|
||||
max_attempts = 30
|
||||
|
||||
@@ -6,6 +6,7 @@ Simple database connection test script
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def test_database_connection():
|
||||
"""Test basic database connection"""
|
||||
@@ -15,22 +16,22 @@ def test_database_connection():
|
||||
db_url = os.getenv('DATABASE_URL', 'postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker')
|
||||
|
||||
# Parse the URL to get connection details
|
||||
if db_url.startswith('postgresql+psycopg2://'):
|
||||
db_url = db_url.replace('postgresql+psycopg2://', '')
|
||||
|
||||
# Extract host, port, database, user, password
|
||||
if '@' in db_url:
|
||||
auth_part, rest = db_url.split('@', 1)
|
||||
user, password = auth_part.split(':', 1)
|
||||
if ':' in rest:
|
||||
host_port, database = rest.rsplit('/', 1)
|
||||
if ':' in host_port:
|
||||
host, port = host_port.split(':', 1)
|
||||
else:
|
||||
host, port = host_port, '5432'
|
||||
# Handle both postgresql:// and postgresql+psycopg2:// schemes
|
||||
if db_url.startswith('postgresql'):
|
||||
if db_url.startswith('postgresql+psycopg2://'):
|
||||
parsed_url = urlparse(db_url.replace('postgresql+psycopg2://', 'postgresql://'))
|
||||
else:
|
||||
host, port, database = rest, '5432', 'timetracker'
|
||||
parsed_url = urlparse(db_url)
|
||||
|
||||
# Extract connection parameters
|
||||
user = parsed_url.username or 'timetracker'
|
||||
password = parsed_url.password or 'timetracker'
|
||||
host = parsed_url.hostname or 'db'
|
||||
port = parsed_url.port or 5432
|
||||
# Remove leading slash from path to get database name
|
||||
database = parsed_url.path.lstrip('/') or 'timetracker'
|
||||
else:
|
||||
# Fallback for other formats
|
||||
host, port, database, user, password = 'db', '5432', 'timetracker', 'timetracker', 'timetracker'
|
||||
|
||||
print(f"Connection details:")
|
||||
|
||||
@@ -96,8 +96,14 @@ def verify_and_fix_table(engine, inspector, model_class, dialect):
|
||||
|
||||
# Check if table exists
|
||||
if table_name not in inspector.get_table_names():
|
||||
print(f"⚠ Table '{table_name}' does not exist (will be created by migrations)")
|
||||
return 0
|
||||
print(f"⚠ Table '{table_name}' does not exist, creating it...")
|
||||
try:
|
||||
# Create the table
|
||||
model_class.__table__.create(engine, checkfirst=True)
|
||||
print(f" ✓ Created table '{table_name}'")
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to create table '{table_name}': {e}")
|
||||
return 0
|
||||
|
||||
# Get expected columns from model
|
||||
expected_columns = {}
|
||||
|
||||
Reference in New Issue
Block a user