From 85298e1d473a5a337fb7a137d2c381525770200d Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Thu, 13 Nov 2025 07:06:43 +0100 Subject: [PATCH] feat: Add per-project Kanban columns support Implement per-project Kanban column workflows, allowing different projects to have their own custom kanban board columns and task states. Changes: - Add project_id field to KanbanColumn model (nullable, NULL = global columns) - Create Alembic migration 043 to add project_id column with foreign key - Update unique constraint from (key) to (key, project_id) to allow same keys across different projects - Update all KanbanColumn model methods to filter by project_id: - get_active_columns(project_id=None) - get_all_columns(project_id=None) - get_column_by_key(key, project_id=None) - get_valid_status_keys(project_id=None) - initialize_default_columns(project_id=None) - reorder_columns(column_ids, project_id=None) - Update kanban routes to support project filtering: - /kanban/columns accepts project_id query parameter - /kanban/columns/create supports project selection - All CRUD operations redirect to project-filtered view when applicable - API endpoints support project_id parameter - Update project view route to use project-specific columns - Update task routes to validate status against project-specific columns - Add fallback logic: projects without custom columns use global columns - Update UI templates: - Add project filter dropdown in column management page - Add project selection in create column form - Show project info in edit column page - Update reorder API calls to include project_id Database Migration: - Migration 043 adds project_id column (nullable) - Existing columns remain global (project_id = NULL) - New unique constraint on (key, project_id) - Foreign key constraint with CASCADE delete Backward Compatibility: - Existing global columns continue to work - Projects without custom columns fall back to global columns - Task status validation uses project-specific columns when available Impact: High - Enables multi-project teams to have different workflows per project while maintaining backward compatibility with existing global column setup. --- app/models/kanban_column.py | 90 ++++++++--- app/routes/kanban.py | 153 +++++++++++++----- app/routes/projects.py | 15 +- app/routes/tasks.py | 26 +-- app/templates/kanban/columns.html | 21 ++- app/templates/kanban/create_column.html | 18 ++- app/templates/kanban/edit_column.html | 7 +- .../043_add_project_id_to_kanban_columns.py | 81 ++++++++++ setup.py | 2 +- 9 files changed, 331 insertions(+), 82 deletions(-) create mode 100644 migrations/versions/043_add_project_id_to_kanban_columns.py diff --git a/app/models/kanban_column.py b/app/models/kanban_column.py index d868446..c2606c5 100644 --- a/app/models/kanban_column.py +++ b/app/models/kanban_column.py @@ -7,7 +7,8 @@ class KanbanColumn(db.Model): __tablename__ = 'kanban_columns' id = db.Column(db.Integer, primary_key=True) - key = db.Column(db.String(50), unique=True, nullable=False, index=True) # Internal identifier (e.g. 'in_progress') + project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'), nullable=True, index=True) # NULL = global columns + key = db.Column(db.String(50), nullable=False, index=True) # Internal identifier (e.g. 'in_progress') label = db.Column(db.String(100), nullable=False) # Display name (e.g. 'In Progress') icon = db.Column(db.String(100), default='fas fa-circle') # Font Awesome icon class color = db.Column(db.String(50), default='secondary') # Bootstrap color class or hex @@ -18,17 +19,22 @@ class KanbanColumn(db.Model): 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) + # Unique constraint: key must be unique per project (or globally if project_id is NULL) + __table_args__ = (db.UniqueConstraint('key', 'project_id', name='uq_kanban_column_key_project'),) + def __init__(self, **kwargs): """Initialize a new KanbanColumn""" super(KanbanColumn, self).__init__(**kwargs) def __repr__(self): - return f'' + project_info = f" project_id={self.project_id}" if self.project_id else " global" + return f'' def to_dict(self): """Convert column to dictionary for API responses""" return { 'id': self.id, + 'project_id': self.project_id, 'key': self.key, 'label': self.label, 'icon': self.icon, @@ -42,53 +48,78 @@ class KanbanColumn(db.Model): } @classmethod - def get_active_columns(cls): - """Get all active columns ordered by position""" + def get_active_columns(cls, project_id=None): + """Get active columns ordered by position. If project_id is None, returns global columns.""" try: # Force a fresh query by using db.session directly and avoiding cache from app import db - # This ensures we always get fresh data from the database - return db.session.query(cls).filter_by(is_active=True).order_by(cls.position.asc()).all() + query = db.session.query(cls).filter_by(is_active=True) + if project_id is None: + # Return global columns (project_id is NULL) - use IS NULL for PostgreSQL + query = query.filter(cls.project_id.is_(None)) + else: + # Return project-specific columns + query = query.filter_by(project_id=project_id) + return query.order_by(cls.position.asc()).all() except Exception as e: # Table might not exist yet during migration print(f"Warning: Could not load kanban columns: {e}") return [] @classmethod - def get_all_columns(cls): - """Get all columns (including inactive) ordered by position""" + def get_all_columns(cls, project_id=None): + """Get all columns (including inactive) ordered by position. If project_id is None, returns global columns.""" try: # Force a fresh query by using db.session directly and avoiding cache from app import db - return db.session.query(cls).order_by(cls.position.asc()).all() + query = db.session.query(cls) + if project_id is None: + # Return global columns (project_id is NULL) - use IS NULL for PostgreSQL + query = query.filter(cls.project_id.is_(None)) + else: + # Return project-specific columns + query = query.filter_by(project_id=project_id) + return query.order_by(cls.position.asc()).all() except Exception as e: # Table might not exist yet during migration print(f"Warning: Could not load all kanban columns: {e}") return [] @classmethod - def get_column_by_key(cls, key): - """Get column by its key""" + def get_column_by_key(cls, key, project_id=None): + """Get column by its key and project_id. If project_id is None, searches global columns.""" try: - return cls.query.filter_by(key=key).first() + query = cls.query.filter_by(key=key) + if project_id is None: + # Use IS NULL for PostgreSQL + query = query.filter(cls.project_id.is_(None)) + else: + query = query.filter_by(project_id=project_id) + return query.first() except Exception as e: # Table might not exist yet print(f"Warning: Could not find kanban column by key: {e}") return None @classmethod - def get_valid_status_keys(cls): - """Get list of all valid status keys (for validation)""" - columns = cls.get_active_columns() + def get_valid_status_keys(cls, project_id=None): + """Get list of all valid status keys (for validation). If project_id is None, returns global column keys.""" + columns = cls.get_active_columns(project_id=project_id) if not columns: # Fallback to default statuses if table doesn't exist return ['todo', 'in_progress', 'review', 'done', 'cancelled'] return [col.key for col in columns] @classmethod - def initialize_default_columns(cls): - """Initialize default kanban columns if none exist""" - if cls.query.count() > 0: + def initialize_default_columns(cls, project_id=None): + """Initialize default kanban columns if none exist for the given project (or globally if project_id is None)""" + query = cls.query + if project_id is None: + query = query.filter(cls.project_id.is_(None)) + else: + query = query.filter_by(project_id=project_id) + + if query.count() > 0: return False # Columns already exist default_columns = [ @@ -99,7 +130,8 @@ class KanbanColumn(db.Model): 'color': 'secondary', 'position': 0, 'is_system': True, - 'is_complete_state': False + 'is_complete_state': False, + 'project_id': project_id }, { 'key': 'in_progress', @@ -108,7 +140,8 @@ class KanbanColumn(db.Model): 'color': 'warning', 'position': 1, 'is_system': True, - 'is_complete_state': False + 'is_complete_state': False, + 'project_id': project_id }, { 'key': 'review', @@ -117,7 +150,8 @@ class KanbanColumn(db.Model): 'color': 'info', 'position': 2, 'is_system': False, - 'is_complete_state': False + 'is_complete_state': False, + 'project_id': project_id }, { 'key': 'done', @@ -126,7 +160,8 @@ class KanbanColumn(db.Model): 'color': 'success', 'position': 3, 'is_system': True, - 'is_complete_state': True + 'is_complete_state': True, + 'project_id': project_id } ] @@ -138,16 +173,19 @@ class KanbanColumn(db.Model): return True @classmethod - def reorder_columns(cls, column_ids): + def reorder_columns(cls, column_ids, project_id=None): """ - Reorder columns based on list of IDs + Reorder columns based on list of IDs for a specific project (or globally if project_id is None) column_ids: list of column IDs in the desired order + project_id: project ID to reorder columns for (None for global columns) """ for position, col_id in enumerate(column_ids): column = cls.query.get(col_id) if column: - column.position = position - column.updated_at = now_in_app_timezone() + # Verify the column belongs to the correct project + if (project_id is None and column.project_id is None) or (column.project_id == project_id): + column.position = position + column.updated_at = now_in_app_timezone() db.session.commit() # Expire all cached data to force fresh reads diff --git a/app/routes/kanban.py b/app/routes/kanban.py index ec72a1b..5ea3291 100644 --- a/app/routes/kanban.py +++ b/app/routes/kanban.py @@ -17,9 +17,20 @@ def board(): query = query.filter_by(project_id=project_id) # Order tasks for stable rendering tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() - # Fresh columns + # Fresh columns - use project-specific columns if project_id is provided db.session.expire_all() - columns = KanbanColumn.get_active_columns() + if KanbanColumn: + # Try to get project-specific columns first + columns = KanbanColumn.get_active_columns(project_id=project_id) + # If no project-specific columns exist, fall back to global columns + if not columns: + columns = KanbanColumn.get_active_columns(project_id=None) + # If still no global columns exist, initialize default global columns + if not columns: + KanbanColumn.initialize_default_columns(project_id=None) + columns = KanbanColumn.get_active_columns(project_id=None) + else: + columns = [] # Provide projects for filter dropdown from app.models import Project projects = Project.query.filter_by(status='active').order_by(Project.name).all() @@ -35,13 +46,18 @@ def board(): @login_required @admin_required def list_columns(): - """List all kanban columns for management""" + """List kanban columns for management, optionally filtered by project""" + project_id = request.args.get('project_id', type=int) # Force fresh data from database - clear all caches db.session.expire_all() - columns = KanbanColumn.get_all_columns() + columns = KanbanColumn.get_all_columns(project_id=project_id) + + # Get projects for filter dropdown + from app.models import Project + projects = Project.query.filter_by(status='active').order_by(Project.name).all() # Prevent browser caching - response = render_template('kanban/columns.html', columns=columns) + response = render_template('kanban/columns.html', columns=columns, projects=projects, project_id=project_id) resp = make_response(response) resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' resp.headers['Pragma'] = 'no-cache' @@ -53,26 +69,39 @@ def list_columns(): @admin_required def create_column(): """Create a new kanban column""" + project_id = request.args.get('project_id', type=int) or request.form.get('project_id', type=int) + if request.method == 'POST': key = request.form.get('key', '').strip().lower().replace(' ', '_') label = request.form.get('label', '').strip() icon = request.form.get('icon', 'fas fa-circle').strip() color = request.form.get('color', 'secondary').strip() is_complete_state = request.form.get('is_complete_state') == 'on' + project_id = request.form.get('project_id', type=int) or None # Validate required fields if not key or not label: flash('Key and label are required', 'error') - return render_template('kanban/create_column.html') + from app.models import Project + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + return render_template('kanban/create_column.html', projects=projects, project_id=project_id) - # Check if key already exists - existing = KanbanColumn.get_column_by_key(key) + # Check if key already exists for this project (or globally) + existing = KanbanColumn.get_column_by_key(key, project_id=project_id) if existing: - flash(f'A column with key "{key}" already exists', 'error') - return render_template('kanban/create_column.html') + project_text = f" for this project" if project_id else " globally" + flash(f'A column with key "{key}" already exists{project_text}', 'error') + from app.models import Project + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + return render_template('kanban/create_column.html', projects=projects, project_id=project_id) - # Get max position and add 1 - max_position = db.session.query(db.func.max(KanbanColumn.position)).scalar() or -1 + # Get max position for this project (or globally) and add 1 + query = db.session.query(db.func.max(KanbanColumn.position)) + if project_id is None: + query = query.filter(KanbanColumn.project_id.is_(None)) + else: + query = query.filter_by(project_id=project_id) + max_position = query.scalar() or -1 # Create column column = KanbanColumn( @@ -83,7 +112,8 @@ def create_column(): position=max_position + 1, is_complete_state=is_complete_state, is_system=False, - is_active=True + is_active=True, + project_id=project_id ) db.session.add(column) @@ -95,12 +125,16 @@ def create_column(): db.session.rollback() flash(f'Could not create column: {str(e)}', 'error') print(f"[KANBAN] Flush failed: {e}") - return render_template('kanban/create_column.html') + from app.models import Project + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + return render_template('kanban/create_column.html', projects=projects, project_id=project_id) # Now commit the transaction - if not safe_commit('create_kanban_column', {'key': key}): + if not safe_commit('create_kanban_column', {'key': key, 'project_id': project_id}): flash('Could not create column due to a database error. Please check server logs.', 'error') - return render_template('kanban/create_column.html') + from app.models import Project + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + return render_template('kanban/create_column.html', projects=projects, project_id=project_id) print(f"[KANBAN] Column '{key}' committed to database successfully") @@ -110,13 +144,19 @@ def create_column(): # Notify all connected clients to refresh kanban boards try: print(f"[KANBAN] Emitting kanban_columns_updated event: created column '{key}'") - socketio.emit('kanban_columns_updated', {'action': 'created', 'column_key': key}, broadcast=True) + socketio.emit('kanban_columns_updated', {'action': 'created', 'column_key': key, 'project_id': project_id}, broadcast=True) print(f"[KANBAN] Event emitted successfully") except Exception as e: print(f"[KANBAN] Failed to emit event: {e}") - return redirect(url_for('kanban.list_columns')) + + redirect_url = url_for('kanban.list_columns') + if project_id: + redirect_url = url_for('kanban.list_columns', project_id=project_id) + return redirect(redirect_url) - return render_template('kanban/create_column.html') + from app.models import Project + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + return render_template('kanban/create_column.html', projects=projects, project_id=project_id) @kanban_bp.route('/kanban/columns//edit', methods=['GET', 'POST']) @login_required @@ -166,11 +206,15 @@ def edit_column(column_id): # Notify all connected clients to refresh kanban boards try: print(f"[KANBAN] Emitting kanban_columns_updated event: updated column ID {column_id}") - socketio.emit('kanban_columns_updated', {'action': 'updated', 'column_id': column_id}, broadcast=True) + socketio.emit('kanban_columns_updated', {'action': 'updated', 'column_id': column_id, 'project_id': column.project_id}, broadcast=True) print(f"[KANBAN] Event emitted successfully") except Exception as e: print(f"[KANBAN] Failed to emit event: {e}") - return redirect(url_for('kanban.list_columns')) + + redirect_url = url_for('kanban.list_columns') + if column.project_id: + redirect_url = url_for('kanban.list_columns', project_id=column.project_id) + return redirect(redirect_url) return render_template('kanban/edit_column.html', column=column) @@ -184,15 +228,25 @@ def delete_column(column_id): # Check if system column if column.is_system: flash('System columns cannot be deleted', 'error') - return redirect(url_for('kanban.list_columns')) + redirect_url = url_for('kanban.list_columns') + if column.project_id: + redirect_url = url_for('kanban.list_columns', project_id=column.project_id) + return redirect(redirect_url) - # Check if column has tasks - task_count = Task.query.filter_by(status=column.key).count() + # Check if column has tasks (filter by project if column is project-specific) + task_query = Task.query.filter_by(status=column.key) + if column.project_id: + task_query = task_query.filter_by(project_id=column.project_id) + task_count = task_query.count() if task_count > 0: flash(f'Cannot delete column with {task_count} task(s). Move or delete tasks first.', 'error') - return redirect(url_for('kanban.list_columns')) + redirect_url = url_for('kanban.list_columns') + if column.project_id: + redirect_url = url_for('kanban.list_columns', project_id=column.project_id) + return redirect(redirect_url) column_name = column.label + project_id = column.project_id db.session.delete(column) # Explicitly flush to execute delete immediately @@ -202,12 +256,18 @@ def delete_column(column_id): db.session.rollback() flash(f'Could not delete column: {str(e)}', 'error') print(f"[KANBAN] Flush failed: {e}") - return redirect(url_for('kanban.list_columns')) + redirect_url = url_for('kanban.list_columns') + if project_id: + redirect_url = url_for('kanban.list_columns', project_id=project_id) + return redirect(redirect_url) # Now commit the transaction if not safe_commit('delete_kanban_column', {'column_id': column_id}): flash('Could not delete column due to a database error. Please check server logs.', 'error') - return redirect(url_for('kanban.list_columns')) + redirect_url = url_for('kanban.list_columns') + if project_id: + redirect_url = url_for('kanban.list_columns', project_id=project_id) + return redirect(redirect_url) print(f"[KANBAN] Column {column_id} deleted and committed to database successfully") @@ -217,11 +277,15 @@ def delete_column(column_id): # Notify all connected clients to refresh kanban boards try: print(f"[KANBAN] Emitting kanban_columns_updated event: deleted column ID {column_id}") - socketio.emit('kanban_columns_updated', {'action': 'deleted', 'column_id': column_id}, broadcast=True) + socketio.emit('kanban_columns_updated', {'action': 'deleted', 'column_id': column_id, 'project_id': project_id}, broadcast=True) print(f"[KANBAN] Event emitted successfully") except Exception as e: print(f"[KANBAN] Failed to emit event: {e}") - return redirect(url_for('kanban.list_columns')) + + redirect_url = url_for('kanban.list_columns') + if project_id: + redirect_url = url_for('kanban.list_columns', project_id=project_id) + return redirect(redirect_url) @kanban_bp.route('/kanban/columns//toggle', methods=['POST']) @login_required @@ -255,11 +319,15 @@ def toggle_column(column_id): # Notify all connected clients to refresh kanban boards try: print(f"[KANBAN] Emitting kanban_columns_updated event: toggled column ID {column_id}") - socketio.emit('kanban_columns_updated', {'action': 'toggled', 'column_id': column_id}, broadcast=True) + socketio.emit('kanban_columns_updated', {'action': 'toggled', 'column_id': column_id, 'project_id': column.project_id}, broadcast=True) print(f"[KANBAN] Event emitted successfully") except Exception as e: print(f"[KANBAN] Failed to emit event: {e}") - return redirect(url_for('kanban.list_columns')) + + redirect_url = url_for('kanban.list_columns') + if column.project_id: + redirect_url = url_for('kanban.list_columns', project_id=column.project_id) + return redirect(redirect_url) @kanban_bp.route('/api/kanban/columns/reorder', methods=['POST']) @login_required @@ -268,13 +336,14 @@ def reorder_columns(): """Reorder kanban columns via API""" data = request.get_json() column_ids = data.get('column_ids', []) + project_id = data.get('project_id', None) if not column_ids: return jsonify({'error': 'No column IDs provided'}), 400 try: - # Reorder columns - KanbanColumn.reorder_columns(column_ids) + # Reorder columns for the specified project (or globally if project_id is None) + KanbanColumn.reorder_columns(column_ids, project_id=project_id) # Explicitly flush to write changes immediately db.session.flush() @@ -290,7 +359,7 @@ def reorder_columns(): # Notify all connected clients to refresh kanban boards try: print(f"[KANBAN] Emitting kanban_columns_updated event: reordered columns") - socketio.emit('kanban_columns_updated', {'action': 'reordered'}, broadcast=True) + socketio.emit('kanban_columns_updated', {'action': 'reordered', 'project_id': project_id}, broadcast=True) print(f"[KANBAN] Event emitted successfully") except Exception as e: print(f"[KANBAN] Failed to emit event: {e}") @@ -303,10 +372,22 @@ def reorder_columns(): @kanban_bp.route('/api/kanban/columns') @login_required def api_list_columns(): - """API endpoint to get all active kanban columns""" + """API endpoint to get active kanban columns, optionally filtered by project""" + project_id = request.args.get('project_id', type=int) # Force fresh data - no caching db.session.expire_all() - columns = KanbanColumn.get_active_columns() + if KanbanColumn: + # Try to get project-specific columns first + columns = KanbanColumn.get_active_columns(project_id=project_id) + # If no project-specific columns exist, fall back to global columns + if not columns: + columns = KanbanColumn.get_active_columns(project_id=None) + # If still no global columns exist, initialize default global columns + if not columns: + KanbanColumn.initialize_default_columns(project_id=None) + columns = KanbanColumn.get_active_columns(project_id=None) + else: + columns = [] response = jsonify({'columns': [col.to_dict() for col in columns]}) # Add no-cache headers to avoid SW/browser caching try: diff --git a/app/routes/projects.py b/app/routes/projects.py index 3df3076..993e484 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -405,9 +405,20 @@ def view_project(project_id): # Get total cost count total_costs_count = ProjectCost.query.filter_by(project_id=project_id).count() - # Get kanban columns - force fresh data + # Get kanban columns for this project - force fresh data db.session.expire_all() - kanban_columns = KanbanColumn.get_active_columns() if KanbanColumn else [] + if KanbanColumn: + # Try to get project-specific columns first + kanban_columns = KanbanColumn.get_active_columns(project_id=project_id) + # If no project-specific columns exist, fall back to global columns + if not kanban_columns: + kanban_columns = KanbanColumn.get_active_columns(project_id=None) + # If still no global columns exist, initialize default global columns + if not kanban_columns: + KanbanColumn.initialize_default_columns(project_id=None) + kanban_columns = KanbanColumn.get_active_columns(project_id=None) + else: + kanban_columns = [] # Prevent browser caching of kanban board response = render_template('projects/view.html', diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 3a212f1..bd18fe4 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -299,7 +299,7 @@ def edit_task(task_id): task.assigned_to = assigned_to # Handle status update (including reopening from done) selected_status = request.form.get('status', '').strip() - valid_statuses = KanbanColumn.get_valid_status_keys() + valid_statuses = KanbanColumn.get_valid_status_keys(project_id=task.project_id) if selected_status and selected_status in valid_statuses and selected_status != task.status: try: previous_status = task.status @@ -393,11 +393,11 @@ def update_task_status(task_id): flash('You do not have permission to update this task', 'error') return redirect(url_for('tasks.view_task', task_id=task.id)) - # Validate status against configured kanban columns - valid_statuses = KanbanColumn.get_valid_status_keys() - if new_status not in valid_statuses: - flash('Invalid status', 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) + # Validate status against configured kanban columns for this task's project + valid_statuses = KanbanColumn.get_valid_status_keys(project_id=task.project_id) + if new_status not in valid_statuses: + flash('Invalid status', 'error') + return redirect(url_for('tasks.view_task', task_id=task.id)) # Update status try: @@ -633,9 +633,7 @@ def bulk_update_status(): flash('No tasks selected', 'warning') return redirect(url_for('tasks.list_tasks')) - # Validate against configured kanban columns - valid_statuses = set(KanbanColumn.get_valid_status_keys()) if KanbanColumn else set(['todo','in_progress','review','done','cancelled']) - if not new_status or new_status not in valid_statuses: + if not new_status: flash('Invalid status value', 'error') return redirect(url_for('tasks.list_tasks')) @@ -647,6 +645,12 @@ def bulk_update_status(): task_id = int(task_id_str) task = Task.query.get(task_id) + # Validate status against configured kanban columns for this task's project + valid_statuses = set(KanbanColumn.get_valid_status_keys(project_id=task.project_id) if KanbanColumn else ['todo','in_progress','review','done','cancelled']) + if new_status not in valid_statuses: + skipped_count += 1 + continue + if not task: continue @@ -1083,8 +1087,8 @@ def api_update_status(task_id): if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id: return jsonify({'error': 'Access denied'}), 403 - # Validate status against configured kanban columns - valid_statuses = KanbanColumn.get_valid_status_keys() + # Validate status against configured kanban columns for this task's project + valid_statuses = KanbanColumn.get_valid_status_keys(project_id=task.project_id) if new_status not in valid_statuses: return jsonify({'error': 'Invalid status'}), 400 diff --git a/app/templates/kanban/columns.html b/app/templates/kanban/columns.html index 1ef27cf..0e2f503 100644 --- a/app/templates/kanban/columns.html +++ b/app/templates/kanban/columns.html @@ -19,9 +19,22 @@

{{ _('Customize your kanban board columns and task states') }}

- - {{ _('Add Column') }} - +
+ {% if projects %} +
+ + +
+ {% endif %} + + {{ _('Add Column') }} + +
@@ -199,7 +212,7 @@ if (tbody) { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': '{{ csrf_token() }}' }, - body: JSON.stringify({ column_ids: columnIds }) + body: JSON.stringify({ column_ids: columnIds, project_id: {% if project_id %}{{ project_id }}{% else %}null{% endif %} }) }); if (!response.ok) { diff --git a/app/templates/kanban/create_column.html b/app/templates/kanban/create_column.html index 51951b2..271dc5d 100644 --- a/app/templates/kanban/create_column.html +++ b/app/templates/kanban/create_column.html @@ -16,6 +16,9 @@
+ {% if project_id %} + + {% endif %}

{{ _('Create Kanban Column form') }}

@@ -31,6 +34,19 @@

{{ _('Unique identifier (lowercase, no spaces, use underscores). Auto-generated from label if empty.') }}

+ {% if projects %} +
+ + +

{{ _('Select a project to create project-specific columns, or leave as Global for all projects') }}

+
+ {% endif %} +
@@ -66,7 +82,7 @@
- + {{ _('Cancel') }}
@@ -79,7 +84,7 @@
- + {{ _('Cancel') }}