diff --git a/app.py b/app.py index 2afc408..f4b3572 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file -from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate from data_formatting import ( format_duration, prepare_export_data, prepare_team_hours_export_data, format_table_data, format_graph_data, format_team_data, format_burndown_data @@ -3763,8 +3763,12 @@ def create_task(): if data.get('due_date'): due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date() + # Generate task number + task_number = Task.generate_task_number(g.user.company_id) + # Create task task = Task( + task_number=task_number, name=name, description=data.get('description', ''), status=TaskStatus[data.get('status', 'NOT_STARTED')], @@ -3924,6 +3928,7 @@ def get_unified_tasks(): task_data = { 'id': task.id, + 'task_number': getattr(task, 'task_number', f'TSK-{task.id:03d}'), # Fallback for existing tasks 'name': task.name, 'description': task.description, 'status': task.status.name, @@ -4006,6 +4011,194 @@ def update_task_status(task_id): return jsonify({'success': False, 'message': str(e)}) +# Task Dependencies APIs +@app.route('/api/tasks//dependencies') +@role_required(Role.TEAM_MEMBER) +@company_required +def get_task_dependencies(task_id): + """Get dependencies for a specific task""" + try: + # Get the task and verify ownership + task = Task.query.filter_by(id=task_id, company_id=g.user.company_id).first() + if not task: + return jsonify({'success': False, 'message': 'Task not found'}) + + # Get blocked by dependencies (tasks that block this one) + blocked_by_query = db.session.query(Task).join( + TaskDependency, Task.id == TaskDependency.blocking_task_id + ).filter(TaskDependency.blocked_task_id == task_id) + + # Get blocks dependencies (tasks that this one blocks) + blocks_query = db.session.query(Task).join( + TaskDependency, Task.id == TaskDependency.blocked_task_id + ).filter(TaskDependency.blocking_task_id == task_id) + + blocked_by_tasks = blocked_by_query.all() + blocks_tasks = blocks_query.all() + + def task_to_dict(t): + return { + 'id': t.id, + 'name': t.name, + 'task_number': t.task_number + } + + return jsonify({ + 'success': True, + 'dependencies': { + 'blocked_by': [task_to_dict(t) for t in blocked_by_tasks], + 'blocks': [task_to_dict(t) for t in blocks_tasks] + } + }) + + except Exception as e: + logger.error(f"Error getting task dependencies: {str(e)}") + return jsonify({'success': False, 'message': str(e)}) + + +@app.route('/api/tasks//dependencies', methods=['POST']) +@role_required(Role.TEAM_MEMBER) +@company_required +def add_task_dependency(task_id): + """Add a dependency for a task""" + try: + data = request.get_json() + task_number = data.get('task_number') + dependency_type = data.get('type') # 'blocked_by' or 'blocks' + + if not task_number or not dependency_type: + return jsonify({'success': False, 'message': 'Task number and type are required'}) + + # Get the main task + task = Task.query.filter_by(id=task_id, company_id=g.user.company_id).first() + if not task: + return jsonify({'success': False, 'message': 'Task not found'}) + + # Find the dependency task by task number + dependency_task = Task.query.filter_by( + task_number=task_number, + company_id=g.user.company_id + ).first() + + if not dependency_task: + return jsonify({'success': False, 'message': f'Task {task_number} not found'}) + + # Prevent self-dependency + if dependency_task.id == task_id: + return jsonify({'success': False, 'message': 'A task cannot depend on itself'}) + + # Create the dependency based on type + if dependency_type == 'blocked_by': + # Current task is blocked by the dependency task + blocked_task_id = task_id + blocking_task_id = dependency_task.id + elif dependency_type == 'blocks': + # Current task blocks the dependency task + blocked_task_id = dependency_task.id + blocking_task_id = task_id + else: + return jsonify({'success': False, 'message': 'Invalid dependency type'}) + + # Check if dependency already exists + existing_dep = TaskDependency.query.filter_by( + blocked_task_id=blocked_task_id, + blocking_task_id=blocking_task_id + ).first() + + if existing_dep: + return jsonify({'success': False, 'message': 'This dependency already exists'}) + + # Check for circular dependencies + def would_create_cycle(blocked_id, blocking_id): + # Use a simple DFS to check if adding this dependency would create a cycle + visited = set() + + def dfs(current_blocked_id): + if current_blocked_id in visited: + return False + visited.add(current_blocked_id) + + # If we reach the original blocking task, we have a cycle + if current_blocked_id == blocking_id: + return True + + # Check all tasks that block the current task + dependencies = TaskDependency.query.filter_by(blocked_task_id=current_blocked_id).all() + for dep in dependencies: + if dfs(dep.blocking_task_id): + return True + + return False + + return dfs(blocked_id) + + if would_create_cycle(blocked_task_id, blocking_task_id): + return jsonify({'success': False, 'message': 'This dependency would create a circular dependency'}) + + # Create the new dependency + new_dependency = TaskDependency( + blocked_task_id=blocked_task_id, + blocking_task_id=blocking_task_id + ) + + db.session.add(new_dependency) + db.session.commit() + + return jsonify({'success': True, 'message': 'Dependency added successfully'}) + + except Exception as e: + db.session.rollback() + logger.error(f"Error adding task dependency: {str(e)}") + return jsonify({'success': False, 'message': str(e)}) + + +@app.route('/api/tasks//dependencies/', methods=['DELETE']) +@role_required(Role.TEAM_MEMBER) +@company_required +def remove_task_dependency(task_id, dependency_task_id): + """Remove a dependency for a task""" + try: + data = request.get_json() + dependency_type = data.get('type') # 'blocked_by' or 'blocks' + + if not dependency_type: + return jsonify({'success': False, 'message': 'Dependency type is required'}) + + # Get the main task + task = Task.query.filter_by(id=task_id, company_id=g.user.company_id).first() + if not task: + return jsonify({'success': False, 'message': 'Task not found'}) + + # Determine which dependency to remove based on type + if dependency_type == 'blocked_by': + # Remove dependency where current task is blocked by dependency_task_id + dependency = TaskDependency.query.filter_by( + blocked_task_id=task_id, + blocking_task_id=dependency_task_id + ).first() + elif dependency_type == 'blocks': + # Remove dependency where current task blocks dependency_task_id + dependency = TaskDependency.query.filter_by( + blocked_task_id=dependency_task_id, + blocking_task_id=task_id + ).first() + else: + return jsonify({'success': False, 'message': 'Invalid dependency type'}) + + if not dependency: + return jsonify({'success': False, 'message': 'Dependency not found'}) + + db.session.delete(dependency) + db.session.commit() + + return jsonify({'success': True, 'message': 'Dependency removed successfully'}) + + except Exception as e: + db.session.rollback() + logger.error(f"Error removing task dependency: {str(e)}") + return jsonify({'success': False, 'message': str(e)}) + + # Sprint Management APIs @app.route('/api/sprints') @role_required(Role.TEAM_MEMBER) @@ -5046,6 +5239,118 @@ def get_current_timer_status(): logger.error(f"Error getting timer status: {e}") return jsonify({'success': False, 'error': str(e)}) +# Smart Search API Endpoints +@app.route('/api/search/users') +@role_required(Role.TEAM_MEMBER) +@company_required +def search_users(): + """Search for users for smart search auto-completion""" + try: + query = request.args.get('q', '').strip() + + if not query: + return jsonify({'success': True, 'users': []}) + + # Search users in the same company + users = User.query.filter( + User.company_id == g.user.company_id, + User.username.ilike(f'%{query}%') + ).limit(10).all() + + user_list = [ + { + 'id': user.id, + 'username': user.username, + 'full_name': f"{user.first_name} {user.last_name}" if user.first_name and user.last_name else user.username + } + for user in users + ] + + return jsonify({'success': True, 'users': user_list}) + + except Exception as e: + logger.error(f"Error in search_users: {str(e)}") + return jsonify({'success': False, 'message': str(e)}) + +@app.route('/api/search/projects') +@role_required(Role.TEAM_MEMBER) +@company_required +def search_projects(): + """Search for projects for smart search auto-completion""" + try: + query = request.args.get('q', '').strip() + + if not query: + return jsonify({'success': True, 'projects': []}) + + # Search projects the user has access to + projects = Project.query.filter( + Project.company_id == g.user.company_id, + db.or_( + Project.code.ilike(f'%{query}%'), + Project.name.ilike(f'%{query}%') + ) + ).limit(10).all() + + # Filter projects user has access to + accessible_projects = [ + project for project in projects + if project.is_user_allowed(g.user) + ] + + project_list = [ + { + 'id': project.id, + 'code': project.code, + 'name': project.name + } + for project in accessible_projects + ] + + return jsonify({'success': True, 'projects': project_list}) + + except Exception as e: + logger.error(f"Error in search_projects: {str(e)}") + return jsonify({'success': False, 'message': str(e)}) + +@app.route('/api/search/sprints') +@role_required(Role.TEAM_MEMBER) +@company_required +def search_sprints(): + """Search for sprints for smart search auto-completion""" + try: + query = request.args.get('q', '').strip() + + if not query: + return jsonify({'success': True, 'sprints': []}) + + # Search sprints in the same company + sprints = Sprint.query.filter( + Sprint.company_id == g.user.company_id, + Sprint.name.ilike(f'%{query}%') + ).limit(10).all() + + # Filter sprints user has access to + accessible_sprints = [ + sprint for sprint in sprints + if sprint.can_user_access(g.user) + ] + + sprint_list = [ + { + 'id': sprint.id, + 'name': sprint.name, + 'status': sprint.status.value + } + for sprint in accessible_sprints + ] + + return jsonify({'success': True, 'sprints': sprint_list}) + + except Exception as e: + logger.error(f"Error in search_sprints: {str(e)}") + return jsonify({'success': False, 'message': str(e)}) + if __name__ == '__main__': port = int(os.environ.get('PORT', 5000)) app.run(debug=True, host='0.0.0.0', port=port) \ No newline at end of file diff --git a/models.py b/models.py index b191a71..237a8a1 100644 --- a/models.py +++ b/models.py @@ -433,6 +433,7 @@ class TaskPriority(enum.Enum): # Task model for project breakdown class Task(db.Model): id = db.Column(db.Integer, primary_key=True) + task_number = db.Column(db.String(20), nullable=False, unique=True) # e.g., "TSK-001", "TSK-002" name = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) @@ -488,6 +489,68 @@ class Task(db.Model): """Check if a user can access this task""" return self.project.is_user_allowed(user) + @classmethod + def generate_task_number(cls, company_id): + """Generate next task number for the company""" + # Get the highest task number for this company + last_task = cls.query.join(Project).filter( + Project.company_id == company_id, + cls.task_number.like('TSK-%') + ).order_by(cls.task_number.desc()).first() + + if last_task and last_task.task_number: + try: + # Extract number from TSK-XXX format + last_num = int(last_task.task_number.split('-')[1]) + return f"TSK-{last_num + 1:03d}" + except (IndexError, ValueError): + pass + + return "TSK-001" + + @property + def blocked_by_tasks(self): + """Get tasks that are blocking this task""" + return [dep.blocking_task for dep in self.blocked_by_dependencies] + + @property + def blocking_tasks(self): + """Get tasks that this task is blocking""" + return [dep.blocked_task for dep in self.blocking_dependencies] + +# Task Dependencies model for tracking blocking relationships +class TaskDependency(db.Model): + id = db.Column(db.Integer, primary_key=True) + + # The task that is blocked (cannot start until blocking task is done) + blocked_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False) + + # The task that is blocking (must be completed before blocked task can start) + blocking_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False) + + # Dependency type (for future extension) + dependency_type = db.Column(db.String(50), default='blocks', nullable=False) # 'blocks', 'subtask', etc. + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.now) + created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + # Relationships + blocked_task = db.relationship('Task', foreign_keys=[blocked_task_id], + backref=db.backref('blocked_by_dependencies', cascade='all, delete-orphan')) + blocking_task = db.relationship('Task', foreign_keys=[blocking_task_id], + backref=db.backref('blocking_dependencies', cascade='all, delete-orphan')) + created_by = db.relationship('User', foreign_keys=[created_by_id]) + + # Ensure a task doesn't block itself and prevent duplicate dependencies + __table_args__ = ( + db.CheckConstraint('blocked_task_id != blocking_task_id', name='no_self_blocking'), + db.UniqueConstraint('blocked_task_id', 'blocking_task_id', name='unique_dependency'), + ) + + def __repr__(self): + return f'' + # SubTask model for task breakdown class SubTask(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/templates/task_modal.html b/templates/task_modal.html new file mode 100644 index 0000000..1343eac --- /dev/null +++ b/templates/task_modal.html @@ -0,0 +1,467 @@ + + + + + + + + \ No newline at end of file diff --git a/templates/unified_task_management.html b/templates/unified_task_management.html index d45f163..4689076 100644 --- a/templates/unified_task_management.html +++ b/templates/unified_task_management.html @@ -6,71 +6,16 @@

📋 Task Management

- -
- - - - {% if g.user.team_id %} - - {% endif %} -
- -
- - - - - - - -
- -
- - - -
- - -
- - - -
- - + +
+ + - - -
@@ -155,219 +100,155 @@
- - + +{% include 'task_modal.html' %}