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.
This commit is contained in:
Dries Peeters
2025-11-13 07:06:43 +01:00
parent d567dcce7e
commit 85298e1d47
9 changed files with 331 additions and 82 deletions

View File

@@ -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'<KanbanColumn {self.key}: {self.label}>'
project_info = f" project_id={self.project_id}" if self.project_id else " global"
return f'<KanbanColumn {self.key}: {self.label}{project_info}>'
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

View File

@@ -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/<int:column_id>/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/<int:column_id>/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:

View File

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

View File

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

View File

@@ -19,9 +19,22 @@
</h2>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Customize your kanban board columns and task states') }}</p>
</div>
<a href="{{ url_for('kanban.create_column') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white hover:opacity-90">
<i class="fas fa-plus"></i> {{ _('Add Column') }}
</a>
<div class="flex items-center gap-3">
{% if projects %}
<form method="GET" action="{{ url_for('kanban.list_columns') }}" class="flex items-center gap-2">
<label for="project_filter" class="text-sm font-medium">{{ _('Project:') }}</label>
<select id="project_filter" name="project_id" onchange="this.form.submit()" class="rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2">
<option value="">{{ _('Global Columns') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</form>
{% endif %}
<a href="{{ url_for('kanban.create_column', project_id=project_id) if project_id else url_for('kanban.create_column') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white hover:opacity-90">
<i class="fas fa-plus"></i> {{ _('Add Column') }}
</a>
</div>
</div>
<!-- Flash messages are globally converted to toasts in base.html -->
@@ -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) {

View File

@@ -16,6 +16,9 @@
<div class="p-6">
<form method="POST" action="{{ url_for('kanban.create_column') }}" role="form" aria-labelledby="formTitle">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if project_id %}
<input type="hidden" name="project_id" value="{{ project_id }}">
{% endif %}
<h3 id="formTitle" class="sr-only">{{ _('Create Kanban Column form') }}</h3>
@@ -31,6 +34,19 @@
<p id="keyHelp" class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Unique identifier (lowercase, no spaces, use underscores). Auto-generated from label if empty.') }}</p>
</div>
{% if projects %}
<div class="mb-4">
<label for="project_id" class="block text-sm font-medium mb-1">{{ _('Project') }}</label>
<select id="project_id" name="project_id" class="w-full rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2">
<option value="">{{ _('Global (for all projects)') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Select a project to create project-specific columns, or leave as Global for all projects') }}</p>
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="icon" class="block text-sm font-medium mb-1">{{ _('Icon Class') }}</label>
@@ -66,7 +82,7 @@
</div>
<div class="mt-6 flex items-center justify-between">
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<a href="{{ url_for('kanban.list_columns', project_id=project_id) if project_id else url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-times"></i> {{ _('Cancel') }}
</a>
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white hover:opacity-90">

View File

@@ -23,6 +23,11 @@
<label for="key" class="block text-sm font-medium mb-1">{{ _('Column Key') }}</label>
<input type="text" id="key" value="{{ column.key }}" disabled class="w-full rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2 opacity-75 cursor-not-allowed">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('The key cannot be changed after creation') }}</p>
{% if column.project_id %}
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400"><i class="fas fa-info-circle"></i> {{ _('This is a project-specific column') }}</p>
{% else %}
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark"><i class="fas fa-globe"></i> {{ _('This is a global column (for all projects)') }}</p>
{% endif %}
</div>
<div class="mb-4">
@@ -79,7 +84,7 @@
</div>
<div class="mt-6 flex items-center justify-between">
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<a href="{{ url_for('kanban.list_columns', project_id=column.project_id) if column.project_id else url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-times"></i> {{ _('Cancel') }}
</a>
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white hover:opacity-90">

View File

@@ -0,0 +1,81 @@
"""add project_id to kanban_columns table
Revision ID: 043
Revises: 042_client_prepaid_hours
Create Date: 2025-01-20
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = '043'
down_revision = '042_client_prepaid_hours'
branch_labels = None
depends_on = None
def upgrade():
"""Add project_id column to kanban_columns for per-project kanban workflows"""
# Drop the old unique constraint on 'key' alone (handle different constraint names)
try:
op.drop_constraint('kanban_columns_key_key', 'kanban_columns', type_='unique')
except Exception:
# Try alternative constraint name that might exist
try:
op.drop_constraint('uq_kanban_columns_key', 'kanban_columns', type_='unique')
except Exception:
# Constraint might not exist or have different name, continue
pass
# Add project_id column (nullable, NULL = global columns)
op.add_column('kanban_columns',
sa.Column('project_id', sa.Integer(), nullable=True)
)
# Add foreign key constraint
op.create_foreign_key(
'fk_kanban_columns_project_id',
'kanban_columns', 'projects',
['project_id'], ['id'],
ondelete='CASCADE'
)
# Create index on project_id for better query performance
op.create_index('idx_kanban_columns_project_id', 'kanban_columns', ['project_id'])
# Explicitly set project_id to NULL for existing columns (they are global columns)
connection = op.get_bind()
connection.execute(text("UPDATE kanban_columns SET project_id = NULL WHERE project_id IS NULL"))
# Create new unique constraint on (key, project_id)
# This allows the same key to exist for different projects, but unique per project
# Note: PostgreSQL allows multiple NULLs in unique constraints, so global columns can share keys
op.create_unique_constraint(
'uq_kanban_column_key_project',
'kanban_columns',
['key', 'project_id']
)
def downgrade():
"""Remove project_id column from kanban_columns"""
# Drop the new unique constraint
op.drop_constraint('uq_kanban_column_key_project', 'kanban_columns', type_='unique')
# Drop index
op.drop_index('idx_kanban_columns_project_id', table_name='kanban_columns')
# Drop foreign key
op.drop_constraint('fk_kanban_columns_project_id', 'kanban_columns', type_='foreignkey')
# Remove project_id column
op.drop_column('kanban_columns', 'project_id')
# Restore the old unique constraint on 'key' alone
op.create_unique_constraint('kanban_columns_key_key', 'kanban_columns', ['key'])

View File

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