diff --git a/app/__init__.py b/app/__init__.py index 9a0da5e..9ff4647 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 21acac1..c10455c 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", ] diff --git a/app/models/client.py b/app/models/client.py index a529c55..9c39fda 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -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) diff --git a/app/models/issue.py b/app/models/issue.py new file mode 100644 index 0000000..7908eee --- /dev/null +++ b/app/models/issue.py @@ -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"" + + @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() + ) diff --git a/app/models/settings.py b/app/models/settings.py index 5e4772d..22452dd 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -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), diff --git a/app/models/user.py b/app/models/user.py index 2151216..503f29c 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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 diff --git a/app/routes/admin.py b/app/routes/admin.py index ca05df4..0e3c71c 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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"): diff --git a/app/routes/client_portal.py b/app/routes/client_portal.py index b20d135..0e9fd84 100644 --- a/app/routes/client_portal.py +++ b/app/routes/client_portal.py @@ -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/") +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) diff --git a/app/routes/clients.py b/app/routes/clients.py index 42d474f..90fd478 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -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 diff --git a/app/routes/issues.py b/app/routes/issues.py new file mode 100644 index 0000000..5c0873b --- /dev/null +++ b/app/routes/issues.py @@ -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/") +@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//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//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//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//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//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//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//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")) diff --git a/app/routes/user.py b/app/routes/user.py index da441ef..6aef314 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -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 diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index c693f87..e2665ec 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -123,6 +123,12 @@ {{ _('Allow Weekly Goals') }} +
+ + +
diff --git a/app/templates/base.html b/app/templates/base.html index 87dda4f..0d0777d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -216,7 +216,7 @@