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:
Dries Peeters
2025-12-14 07:25:42 +01:00
parent 8324636e2b
commit 7791e6ada0
27 changed files with 1654 additions and 48 deletions

View File

@@ -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

View File

@@ -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",
]

View File

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

View File

@@ -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),

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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>

View File

@@ -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') }}">

View File

@@ -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>

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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>

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -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 = {}

View File

@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='4.5.1',
version='4.6.0',
packages=find_packages(),
include_package_data=True,
install_requires=[