mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-30 07:40:51 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
81
migrations/versions/043_add_project_id_to_kanban_columns.py
Normal file
81
migrations/versions/043_add_project_id_to_kanban_columns.py
Normal 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'])
|
||||
|
||||
Reference in New Issue
Block a user