diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..b5f51e6c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,120 @@ +# Changelog + +All notable changes to TimeTracker will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Additional features and improvements in development + +## [4.6.0] - 2025-12-14 + +### Added +- **Comprehensive Issue/Bug Tracking System** - Complete issue and bug tracking functionality with full lifecycle management + +## [4.5.1] - 2025-12-13 + +### Changed +- **Performance Optimization** - Optimized task listing queries and improved version management +- **Version Management** - Enhanced version management system + +## [4.5.0] - 2025-12-12 + +### Added +- **Advanced Report Builder** - Iterative report generation with email distribution capabilities +- **Quick Task Creation** - Create tasks directly from the Start Timer modal for faster workflow +- **Kanban Board Enhancements** - Added user filter and flexible column layout options +- **PWA Install UI** - Improved Progressive Web App installation user interface + +### Fixed +- **Permission and Role Management** - Fixed bugs in permission and role management system + +### Changed +- **Error Handling** - Improved error handling throughout the application +- **Performance Logging** - Enhanced performance logging and monitoring + +## [4.4.1] - 2025-12-08 + +### Added +- **Custom Reports Enhancement** - Enhanced custom reports and scheduled reports functionality + +### Fixed +- **Dashboard Cache Invalidation** - Fixed dashboard cache invalidation when editing timer entries (#342) +- **Custom Field Definitions** - Fixed graceful handling of missing custom_field_definitions table (#344) + +## [4.4.0] - 2025-12-03 + +### Added +- **Project Custom Fields** - Add custom fields to projects for enhanced project tracking +- **File Attachments** - File attachment support for projects and clients +- **Salesman-Based Report Splitting** - Report splitting and email distribution based on salesperson assignments + +### Changed +- **Performance Optimization** - Optimized task queries and fixed N+1 performance issues +- **Version Update** - Updated setup.py version to 4.4.0 + +## [4.3.2] - 2025-12-02 + +### Added +- **Custom Field Filtering** - Custom field filtering and display for clients, projects, and time entries +- **Client Count Tracking** - Client count tracking and cleanup for custom field definitions +- **Unpaid Hours Report** - New unpaid hours report with Ajax filtering and Excel export +- **Time Entries Overview** - New time entries overview page with AJAX filters and bulk mark as paid +- **Configurable Duplicate Detection** - Configurable duplicate detection fields for CSV client import +- **Enhanced Audit Logging** - Improved error handling and diagnostic tools for audit logging + +### Changed +- **Offline Sync** - Enhanced offline sync functionality and performance improvements +- **Error Handling** - Improved error handling throughout the application +- **Docker Healthchecks** - Enhanced Docker healthcheck functionality + +## [4.3.1] - 2025-12-01 + +### Changed +- **Offline Sync** - Enhanced offline sync functionality and performance improvements + +## [4.3.0] - 2025-12-01 + +### Added +- **Custom Field Filtering** - Custom field filtering and display for clients, projects, and time entries +- **Client Count Tracking** - Client count tracking and cleanup for custom field definitions +- **Unpaid Hours Report** - New unpaid hours report with Ajax filtering and Excel export +- **Time Entries Overview** - New time entries overview page with AJAX filters and bulk mark as paid +- **Configurable Duplicate Detection** - Configurable duplicate detection fields for CSV client import +- **Enhanced Audit Logging** - Improved error handling and diagnostic tools for audit logging + +### Changed +- **Error Handling** - Improved error handling throughout the application +- **Docker Healthchecks** - Enhanced Docker healthcheck functionality +- **Offline Sync** - Enhanced offline sync functionality + +## [4.2.1] - 2025-12-01 + +### Fixed +- **AUTH_METHOD=none** - Fixed authentication method when set to none +- **Schema Verification** - Added comprehensive schema verification + +## [4.2.0] - 2025-11-30 + +### Added +- **CSV Import/Export** - CSV import/export for clients with custom fields and contacts +- **Global Custom Field Definitions** - Global custom field definitions with link template support +- **Paid Status Tracking** - Paid status tracking for time entries with invoice reference +- **OAuth Credentials Dropdown** - Converted OAuth credentials section to dropdown in System Settings + +--- + +## Release Notes Format + +Each release includes: +- **Added** - New features +- **Changed** - Changes in existing functionality +- **Deprecated** - Soon-to-be removed features +- **Removed** - Removed features +- **Fixed** - Bug fixes +- **Security** - Security improvements + +For detailed information about each release, see the [GitHub Releases](https://github.com/drytrix/TimeTracker/releases) page. diff --git a/IMPLEMENTATION_COMPLETE.md b/IMPLEMENTATION_COMPLETE.md deleted file mode 100644 index 83e416eb..00000000 --- a/IMPLEMENTATION_COMPLETE.md +++ /dev/null @@ -1,149 +0,0 @@ -# โœ… Feature Implementation Complete - -**Date:** 2025-01-27 -**Total Features:** 24 -**Completed:** 17 (71%) -**Status:** ๐ŸŽ‰ **MAJOR MILESTONE ACHIEVED** - ---- - -## โœ… COMPLETED FEATURES (17/24) - -### ๐ŸŽฏ Core Infrastructure (3) -1. โœ… **Offline Mode with Sync** - Complete IndexedDB implementation -2. โœ… **Automation Workflow Engine** - Full rule-based automation -3. โœ… **Activity Feed UI** - Real-time activity feed - -### ๐Ÿ”Œ Integrations (4) -4. โœ… **Google Calendar** - Two-way sync -5. โœ… **Asana** - Project/task sync -6. โœ… **Trello** - Board/card sync -7. โœ… **QuickBooks** - Invoice/expense sync - -### ๐Ÿ“‹ Workflows & Approvals (3) -8. โœ… **Time Approval Workflow** - Manager approval system -9. โœ… **Client Approval Workflow** - Client-side approvals -10. โœ… **Recurring Tasks** - Automated task creation - -### ๐Ÿ’ฌ Team Collaboration (2) -11. โœ… **Team Chat** - Real-time messaging system -12. โœ… **@Mentions UI** - Autocomplete mentions component - -### ๐ŸŽจ Customization (1) -13. โœ… **Client Portal Customization** - Branding & theme options - -### ๐Ÿ“Š Reporting (3) -14. โœ… **PowerPoint Export** - Presentation generation -15. โœ… **Currency Auto-Conversion** - Real-time rate fetching -16. โœ… **Currency Historical Rates** - Rate history tracking - -### ๐Ÿ”„ Automation (1) -17. โœ… **Recurring Tasks** - Task templates with auto-creation - ---- - -## ๐Ÿ“‹ REMAINING FEATURES (7/24) - -### High Priority (1) -1. โณ **Custom Report Builder** - Drag-and-drop UI component - -### Medium/Low Priority (6) -2. โณ **Pomodoro Enhancements** - Better timer integration -3. โณ **Expense OCR Enhancement** - Improve receipt scanning -4. โณ **Expense GPS Tracking** - Mileage tracking with GPS -5. โณ **AI Suggestions** - Smart time entry suggestions -6. โณ **AI Categorization** - Automatic categorization -7. โณ **Gamification** - Badges and leaderboards - ---- - -## ๐Ÿ“ Implementation Summary - -### Files Created (35+) -- **Models:** 8 files (workflows, approvals, chat, customization, recurring tasks) -- **Services:** 6 files (workflow engine, approval services, currency service) -- **Routes:** 8 files (workflows, approvals, chat, customization, activity feed) -- **Integrations:** 4 files (Google Calendar, Asana, Trello, QuickBooks) -- **Frontend:** 3 files (offline sync, activity feed, mentions) -- **Utilities:** 2 files (PowerPoint export, currency service) -- **Migrations:** 4 files -- **Documentation:** 4 files - -### Database Tables Added -1. `workflow_rules` & `workflow_executions` -2. `time_entry_approvals` & `approval_policies` -3. `recurring_tasks` -4. `client_portal_customizations` -5. `chat_channels`, `chat_messages`, `chat_channel_members`, `chat_read_receipts` -6. `client_time_approvals` & `client_approval_policies` - ---- - -## ๐Ÿš€ Next Steps - -### Immediate Actions - -1. **Run Migrations:** - ```bash - flask db upgrade - ``` - -2. **Add Dependencies:** - ```txt - python-pptx==0.6.23 - ``` - -3. **Register Routes:** - Add to `app/__init__.py`: - ```python - from app.routes.workflows import workflows_bp - from app.routes.time_approvals import time_approvals_bp - from app.routes.activity_feed import activity_feed_bp - from app.routes.recurring_tasks import recurring_tasks_bp - from app.routes.team_chat import team_chat_bp - from app.routes.client_portal_customization import client_portal_customization_bp - - app.register_blueprint(workflows_bp) - app.register_blueprint(time_approvals_bp) - app.register_blueprint(activity_feed_bp) - app.register_blueprint(recurring_tasks_bp) - app.register_blueprint(team_chat_bp) - app.register_blueprint(client_portal_customization_bp) - ``` - -4. **Add Scripts to Templates:** - - `offline-sync.js` - Base template - - `activity-feed.js` - Dashboard - - `mentions.js` - Chat/comments - -5. **Update Models:** - - Already updated in `app/models/__init__.py` - ---- - -## ๐Ÿ“Š Statistics - -- **Completion Rate:** 71% (17/24) -- **Lines of Code:** ~8,000+ -- **New Services:** 6 -- **New Integrations:** 4 -- **Database Tables:** 13 new tables -- **API Endpoints:** 70+ new endpoints -- **JavaScript Components:** 3 major components - ---- - -## ๐ŸŽฏ Key Achievements - -โœ… **Complete Integration Framework** - OAuth-ready connectors -โœ… **Full Workflow Automation** - Rule-based system -โœ… **Team Collaboration** - Chat with mentions -โœ… **Approval Systems** - Manager & client approvals -โœ… **Portal Customization** - Full branding support -โœ… **Export Enhancements** - PowerPoint support -โœ… **Currency Features** - Auto-conversion & history - ---- - -**Status:** โœ… **71% COMPLETE** -**Next Focus:** Custom Report Builder UI diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 41ad5ebc..00000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,167 +0,0 @@ -# Feature Implementation Summary - -**Date:** 2025-01-27 -**Status:** Foundation Complete, Ready for Continued Development - -## โœ… Completed Implementations - -### 1. Offline Mode with Sync โœ… -**Status:** Complete -**Files:** -- `app/static/offline-sync.js` - Full offline sync manager - -**Features Implemented:** -- โœ… IndexedDB storage for time entries, tasks, projects -- โœ… Sync queue management -- โœ… Automatic sync when connection restored -- โœ… Conflict resolution framework -- โœ… UI indicators for offline status -- โœ… Background sync via Service Worker -- โœ… Pending sync count tracking - -**Integration Required:** -- Add `` to base template -- Add offline indicator UI element -- Integrate `offlineSyncManager.createTimeEntryOffline()` into time entry forms - -### 2. Automation Workflow Engine โœ… -**Status:** Complete (Backend) -**Files:** -- `app/models/workflow.py` - WorkflowRule and WorkflowExecution models -- `app/services/workflow_engine.py` - Complete workflow engine -- `app/routes/workflows.py` - Full CRUD API routes -- `migrations/versions/069_add_workflow_automation.py` - Database migration - -**Features Implemented:** -- โœ… Rule-based automation system -- โœ… 8 trigger types (task status, time logged, deadlines, etc.) -- โœ… 8 action types (log time, notifications, status updates, etc.) -- โœ… Template variable resolution ({{task.name}}) -- โœ… Execution logging and history -- โœ… Priority-based rule execution -- โœ… REST API endpoints - -**Next Steps:** -1. Run migration: `flask db upgrade` -2. Register workflow routes in `app/__init__.py` -3. Create UI templates for workflow builder -4. Integrate workflow triggers into existing code: - - Call `WorkflowEngine.trigger_event()` when tasks change status - - Call `WorkflowEngine.trigger_event()` when time entries are created - - Add triggers for deadlines and budget thresholds - -**Integration Points:** -```python -# In task status change handler: -from app.services.workflow_engine import WorkflowEngine - -WorkflowEngine.trigger_event('task_status_change', { - 'data': { - 'task_id': task.id, - 'old_status': old_status, - 'new_status': task.status, - 'task': task.to_dict(), - 'user_id': current_user.id - } -}) -``` - -### 3. Google Calendar Integration โœ… -**Status:** Complete -**Files:** -- `app/integrations/google_calendar.py` - Full Google Calendar connector -- Updated `app/integrations/registry.py` - Registered connector - -**Features Implemented:** -- โœ… OAuth 2.0 authentication -- โœ… Two-way calendar sync -- โœ… Time entry to calendar event conversion -- โœ… Calendar event updates -- โœ… Multiple calendar support -- โœ… Configurable sync direction - -**Next Steps:** -1. Configure Google OAuth credentials in settings -2. Update calendar routes to use new connector -3. Add sync scheduling (background jobs) -4. Test OAuth flow - -**Configuration Required:** -```env -GOOGLE_CLIENT_ID=your_client_id -GOOGLE_CLIENT_SECRET=your_client_secret -``` - -## ๐Ÿ“‹ Remaining Features (Prioritized) - -### High Priority -1. **Asana Integration** - Similar to Google Calendar connector -2. **Trello Integration** - Similar pattern -3. **QuickBooks Integration** - More complex, requires QuickBooks API -4. **Time Approval Workflow** - Manager approval system -5. **Client Approval Workflow** - Client-side approval - -### Medium Priority -6. **Custom Report Builder** - Drag-and-drop UI component -7. **PowerPoint Export** - Use python-pptx library -8. **Team Chat** - Real-time messaging system -9. **Activity Feed UI** - Display Activity model data -10. **@Mentions UI** - Enhance existing comments - -### Lower Priority -11. **AI Features** - Requires ML/AI service integration -12. **Gamification** - Badges and leaderboards -13. **Expense OCR Enhancement** - Improve pytesseract usage -14. **GPS Tracking** - Browser geolocation API -15. **Recurring Tasks** - Similar to recurring invoices -16. **Currency Auto-Conversion** - Exchange rate API integration - -## ๐Ÿš€ Quick Start Guide - -### 1. Run Migrations -```bash -flask db upgrade -``` - -### 2. Register Workflow Routes -Add to `app/__init__.py`: -```python -from app.routes.workflows import workflows_bp -app.register_blueprint(workflows_bp) -``` - -### 3. Add Offline Sync to Templates -Add to `app/templates/base.html`: -```html - - -``` - -### 4. Integrate Workflow Triggers -Add workflow triggers to key events: -- Task status changes -- Time entry creation -- Invoice creation/payment -- Budget threshold reached - -## ๐Ÿ“ Notes - -- All implementations follow existing codebase patterns -- Database migrations are ready to run -- Integration framework is extensible -- Service layer pattern is maintained -- Error handling and logging included - -## ๐Ÿ”„ Next Session Priorities - -1. Complete UI templates for workflows -2. Integrate workflow triggers -3. Add Asana/Trello integrations -4. Implement time approval workflow -5. Create custom report builder - ---- - -**Total Features Implemented:** 3/24 -**Foundation Complete:** โœ… -**Ready for UI Development:** โœ… diff --git a/README.md b/README.md index ce3043c9..455310f6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Track time. Manage projects. Generate invoices. All in one place.** -[๐Ÿ†• What's New](#-whats-new) โ€ข [๐Ÿš€ Quick Start](#-quick-start) โ€ข [โœจ Features](#-features) โ€ข [๐Ÿ“ธ Screenshots](#-screenshots) โ€ข [๐Ÿ“– Getting Started](docs/GETTING_STARTED.md) โ€ข [๐Ÿ“š Documentation](docs/) โ€ข [๐Ÿณ Deploy](#-deployment) +[๐Ÿ†• What's New](#-whats-new) โ€ข [๐Ÿš€ Quick Start](#-quick-start) โ€ข [โœจ Features](#-features) โ€ข [๐Ÿ“ธ Screenshots](#-screenshots) โ€ข [๐Ÿ“– Getting Started](docs/GETTING_STARTED.md) โ€ข [๐Ÿ“š Documentation](docs/) โ€ข [๐Ÿ“‹ Changelog](CHANGELOG.md) โ€ข [๐Ÿณ Deploy](#-deployment) --- @@ -28,6 +28,17 @@ TimeTracker is a **self-hosted, web-based time tracking application** designed f TimeTracker has been continuously enhanced with powerful new features! Here's what's been added recently: +> **๐Ÿ“‹ For complete release history, see [CHANGELOG.md](CHANGELOG.md)** + +**Latest Release: v4.6.0** (December 2025) +- โœจ **Comprehensive Issue/Bug Tracking System** โ€” Complete issue and bug tracking functionality with full lifecycle management + +**Recent Releases:** +- **v4.5.1** โ€” Performance optimizations and version management improvements +- **v4.5.0** โ€” Advanced Report Builder, quick task creation, Kanban enhancements, and PWA improvements +- **v4.4.1** โ€” Dashboard cache fixes and custom reports enhancements +- **v4.4.0** โ€” Project custom fields, file attachments, and salesman-based report splitting + ### ๐ŸŽฏ **Major Feature Additions** #### ๐Ÿงพ **Complete Invoicing System** @@ -336,7 +347,7 @@ docker-compose up -d **First login creates the admin account** โ€” just enter your username! -**๐Ÿ“– See the complete setup guide:** [`docs/DOCKER_COMPOSE_SETUP.md`](docs/DOCKER_COMPOSE_SETUP.md) +**๐Ÿ“– See the complete setup guide:** [`docs/admin/configuration/DOCKER_COMPOSE_SETUP.md`](docs/admin/configuration/DOCKER_COMPOSE_SETUP.md) ### Option 2: Docker with Plain HTTP (Development/Testing) @@ -405,15 +416,18 @@ Even if you're not billing anyone, understanding where your time goes is valuabl Comprehensive documentation is available in the [`docs/`](docs/) directory: +### Release Information +- **[๐Ÿ“‹ Changelog](CHANGELOG.md)** โ€” Complete release history with all changes and new features (โญ See what's new!) + ### Getting Started - **[๐Ÿ“– Getting Started Guide](docs/GETTING_STARTED.md)** โ€” Complete beginner's guide (โญ Start here!) -- **[Installation Guide](docs/DOCKER_PUBLIC_SETUP.md)** โ€” Detailed setup instructions +- **[Installation Guide](docs/admin/configuration/DOCKER_PUBLIC_SETUP.md)** โ€” Detailed setup instructions - **[Requirements](docs/REQUIREMENTS.md)** โ€” System requirements and dependencies -- **[Troubleshooting](docs/DOCKER_STARTUP_TROUBLESHOOTING.md)** โ€” Common issues and solutions -- **[CSRF Token Issues](CSRF_TROUBLESHOOTING.md)** โ€” Fix "CSRF token missing or invalid" errors -- **[CSRF IP Access Fix](CSRF_IP_ACCESS_FIX.md)** โ€” ๐Ÿ”ฅ Fix cookies not working when accessing via IP address -- **[HTTPS Auto-Setup](README_HTTPS_AUTO.md)** โ€” ๐Ÿš€ Automatic HTTPS at startup (one command!) -- **[HTTPS Manual Setup (mkcert)](README_HTTPS.md)** โ€” ๐Ÿ”’ Manual HTTPS with no certificate warnings +- **[Troubleshooting](docs/admin/configuration/DOCKER_STARTUP_TROUBLESHOOTING.md)** โ€” Common issues and solutions +- **[CSRF Token Issues](docs/admin/security/CSRF_TROUBLESHOOTING.md)** โ€” Fix "CSRF token missing or invalid" errors +- **[CSRF IP Access Fix](docs/admin/security/CSRF_IP_ACCESS_FIX.md)** โ€” ๐Ÿ”ฅ Fix cookies not working when accessing via IP address +- **[HTTPS Auto-Setup](docs/admin/security/README_HTTPS_AUTO.md)** โ€” ๐Ÿš€ Automatic HTTPS at startup (one command!) +- **[HTTPS Manual Setup (mkcert)](docs/admin/security/README_HTTPS.md)** โ€” ๐Ÿ”’ Manual HTTPS with no certificate warnings ### Features - **[๐Ÿ“‹ Complete Features Overview](docs/FEATURES_COMPLETE.md)** โ€” Comprehensive documentation of all 120+ features (โญ Complete reference!) @@ -430,15 +444,15 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory: - **[Role-Based Permissions](docs/ADVANCED_PERMISSIONS.md)** โ€” Granular access control ### Technical Documentation -- **[Project Structure](docs/PROJECT_STRUCTURE.md)** โ€” Codebase architecture +- **[Project Structure](docs/development/PROJECT_STRUCTURE.md)** โ€” Codebase architecture - **[Database Migrations](migrations/README.md)** โ€” Database schema management -- **[Version Management](docs/VERSION_MANAGEMENT.md)** โ€” Release and versioning -- **[CSRF Configuration](docs/CSRF_CONFIGURATION.md)** โ€” Security and CSRF token setup for Docker +- **[Version Management](docs/admin/deployment/VERSION_MANAGEMENT.md)** โ€” Release and versioning +- **[CSRF Configuration](docs/admin/security/CSRF_CONFIGURATION.md)** โ€” Security and CSRF token setup for Docker - **[CI/CD Documentation](docs/cicd/)** โ€” Continuous integration setup ### Contributing -- **[Contributing Guidelines](docs/CONTRIBUTING.md)** โ€” How to contribute -- **[Code of Conduct](docs/CODE_OF_CONDUCT.md)** โ€” Community standards +- **[Contributing Guidelines](docs/development/CONTRIBUTING.md)** โ€” How to contribute +- **[Code of Conduct](docs/development/CODE_OF_CONDUCT.md)** โ€” Community standards --- @@ -478,7 +492,7 @@ docker-compose up -d docker-compose -f docker-compose.remote.yml up -d ``` -> **โš ๏ธ Security Note:** Always set a unique `SECRET_KEY` in production! See [CSRF Configuration](docs/CSRF_CONFIGURATION.md) for details. +> **โš ๏ธ Security Note:** Always set a unique `SECRET_KEY` in production! See [CSRF Configuration](docs/admin/security/CSRF_CONFIGURATION.md) for details. ### Raspberry Pi Deployment TimeTracker runs perfectly on Raspberry Pi 4 (2GB+ RAM): @@ -511,8 +525,8 @@ docker-compose up -d # Prometheus: http://localhost:9090 ``` -**๐Ÿ“– See [Deployment Guide](docs/DOCKER_PUBLIC_SETUP.md) for detailed instructions** -**๐Ÿ“– See [Docker Compose Setup](docs/DOCKER_COMPOSE_SETUP.md) for configuration options** +**๐Ÿ“– See [Deployment Guide](docs/admin/configuration/DOCKER_PUBLIC_SETUP.md) for detailed instructions** +**๐Ÿ“– See [Docker Compose Setup](docs/admin/configuration/DOCKER_COMPOSE_SETUP.md) for configuration options** --- @@ -520,7 +534,7 @@ docker-compose up -d TimeTracker is highly configurable through environment variables. For a comprehensive list and recommended values, see: -- [`docs/DOCKER_COMPOSE_SETUP.md`](docs/DOCKER_COMPOSE_SETUP.md) +- [`docs/admin/configuration/DOCKER_COMPOSE_SETUP.md`](docs/admin/configuration/DOCKER_COMPOSE_SETUP.md) - [`env.example`](env.example) Common settings: @@ -719,7 +733,7 @@ We welcome contributions! Whether it's: - ๐Ÿ“ **Documentation** โ€” Improve our docs - ๐Ÿ’ป **Code Contributions** โ€” Submit pull requests -**๐Ÿ“– See [Contributing Guidelines](docs/CONTRIBUTING.md) to get started** +**๐Ÿ“– See [Contributing Guidelines](docs/development/CONTRIBUTING.md) to get started** --- diff --git a/app/__init__.py b/app/__init__.py index 9a0da5e3..9ff4647b 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 21acac1c..c10455ca 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 a529c557..9c39fdae 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 00000000..7908eee4 --- /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 5e4772d1..22452dd4 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 21512167..503f29c9 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 ca05df4c..0e3c71c9 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 b20d1352..0e9fd840 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 42d474f6..90fd478c 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 00000000..5c0873b5 --- /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 da441ef9..6aef314b 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 c693f87a..e2665ec6 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 87dda4f6..0d0777d2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -216,7 +216,7 @@