add initial kanban models and apis
This commit is contained in:
parent
861160566c
commit
3abd029ab5
27 changed files with 3315 additions and 50 deletions
23
Makefile
23
Makefile
|
|
@ -6,7 +6,7 @@ help: ## Show this help message
|
||||||
|
|
||||||
install: ## Install dependencies
|
install: ## Install dependencies
|
||||||
@echo "Installing backend dependencies..."
|
@echo "Installing backend dependencies..."
|
||||||
cd backend && python -m venv venv
|
cd backend && python3 -m venv venv
|
||||||
. backend/venv/bin/activate && pip install -r backend/requirements/dev.txt
|
. backend/venv/bin/activate && pip install -r backend/requirements/dev.txt
|
||||||
@echo "Installing frontend dependencies..."
|
@echo "Installing frontend dependencies..."
|
||||||
cd frontend && npm install
|
cd frontend && npm install
|
||||||
|
|
@ -88,27 +88,6 @@ test-backend-product: ## Run backend product tests only
|
||||||
test-backend-order: ## Run backend order tests only
|
test-backend-order: ## Run backend order tests only
|
||||||
cd backend && . venv/bin/activate && pytest -m order
|
cd backend && . venv/bin/activate && pytest -m order
|
||||||
|
|
||||||
test-backend-watch: ## Run backend tests in watch mode (auto-rerun on changes)
|
|
||||||
cd backend && . venv/bin/activate && pip install pytest-watch && pytest-watch
|
|
||||||
|
|
||||||
test-backend-parallel: ## Run backend tests in parallel (faster)
|
|
||||||
cd backend && . venv/bin/activate && pip install pytest-xdist && pytest -n auto
|
|
||||||
|
|
||||||
test-backend-coverage-report: ## Open backend coverage report in browser
|
|
||||||
cd backend && . venv/bin/activate && pytest --cov=app --cov-report=html && python -m webbrowser htmlcov/index.html
|
|
||||||
|
|
||||||
test-backend-failed: ## Re-run only failed backend tests
|
|
||||||
cd backend && . venv/bin/activate && pytest --lf
|
|
||||||
|
|
||||||
test-backend-last-failed: ## Run the tests that failed in the last run
|
|
||||||
cd backend && . venv/bin/activate && pytest --lf
|
|
||||||
|
|
||||||
test-backend-specific: ## Run specific backend test (usage: make test-backend-specific TEST=test_models.py)
|
|
||||||
cd backend && . venv/bin/activate && pytest tests/$(TEST)
|
|
||||||
|
|
||||||
test-backend-marker: ## Run backend tests by marker (usage: make test-backend-marker MARKER=auth)
|
|
||||||
cd backend && . venv/bin/activate && pytest -m $(MARKER)
|
|
||||||
|
|
||||||
test-frontend: ## Run frontend tests only
|
test-frontend: ## Run frontend tests only
|
||||||
cd frontend && npm test
|
cd frontend && npm test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ FLASK_ENV=dev
|
||||||
SECRET_KEY=your-secret-key-here
|
SECRET_KEY=your-secret-key-here
|
||||||
JWT_SECRET_KEY=your-jwt-secret-key-here
|
JWT_SECRET_KEY=your-jwt-secret-key-here
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=*
|
||||||
DEV_DATABASE_URL=postgresql://crafting:devpassword@localhost:5432/crafting_shop
|
DATABASE_URL=postgresql://crafting:devpassword@localhost:5432/crafting_shop
|
||||||
DATABASE_URL=postgresql://user:password@localhost/proddb
|
DATABASE_URL=postgresql://user:password@localhost/proddb
|
||||||
TEST_DATABASE_URL=sqlite:///test.db
|
TEST_DATABASE_URL=sqlite:///test.db
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,13 +49,27 @@ def create_app(config_name=None):
|
||||||
init_celery(app)
|
init_celery(app)
|
||||||
|
|
||||||
# Import models (required for migrations)
|
# Import models (required for migrations)
|
||||||
from app.models import order, product, user # noqa: F401
|
from app.models import (
|
||||||
|
board,
|
||||||
|
card,
|
||||||
|
card_label,
|
||||||
|
check_item,
|
||||||
|
checklist,
|
||||||
|
comment,
|
||||||
|
label,
|
||||||
|
list_model,
|
||||||
|
order,
|
||||||
|
product,
|
||||||
|
user,
|
||||||
|
) # noqa: F401
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from app.routes import api_bp, health_bp
|
from app.routes import api_bp, health_bp
|
||||||
|
from app.routes.kanban import kanban_bp
|
||||||
|
|
||||||
app.register_blueprint(api_bp, url_prefix="/api")
|
app.register_blueprint(api_bp, url_prefix="/api")
|
||||||
app.register_blueprint(health_bp)
|
app.register_blueprint(health_bp)
|
||||||
|
app.register_blueprint(kanban_bp, url_prefix="/api")
|
||||||
|
|
||||||
# Global error handlers
|
# Global error handlers
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
|
|
|
||||||
|
|
@ -30,20 +30,28 @@ class Config:
|
||||||
"worker_max_tasks_per_child": 100,
|
"worker_max_tasks_per_child": 100,
|
||||||
"broker_connection_retry_on_startup": True,
|
"broker_connection_retry_on_startup": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||||
|
'pool_size': 5, # Reduce from default
|
||||||
|
'max_overflow': 2, # Reduce overflow
|
||||||
|
'pool_timeout': 30,
|
||||||
|
'pool_recycle': 1800, # Recycle connections after 30 minutes
|
||||||
|
'pool_pre_ping': True, # Verify connections before using
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class DevelopmentConfig(Config):
|
||||||
"""Development configuration"""
|
"""Development configuration"""
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
SQLALCHEMY_DATABASE_URI = os.environ.get("DEV_DATABASE_URL") or "sqlite:///dev.db"
|
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
|
||||||
|
|
||||||
|
|
||||||
class TestingConfig(Config):
|
class TestingConfig(Config):
|
||||||
"""Testing configuration"""
|
"""Testing configuration"""
|
||||||
|
|
||||||
TESTING = True
|
TESTING = True
|
||||||
SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL") or "sqlite:///test.db"
|
SQLALCHEMY_DATABASE_URI = "postgresql://kanban:devpassword@localhost:5451/kanban_test"
|
||||||
WTF_CSRF_ENABLED = False
|
WTF_CSRF_ENABLED = False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -51,9 +59,7 @@ class ProductionConfig(Config):
|
||||||
"""Production configuration"""
|
"""Production configuration"""
|
||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
SQLALCHEMY_DATABASE_URI = (
|
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
|
||||||
os.environ.get("DATABASE_URL") or "postgresql://user:password@localhost/proddb"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,26 @@
|
||||||
|
from app.models.board import Board
|
||||||
|
from app.models.card import Card
|
||||||
|
from app.models.card_label import CardLabel
|
||||||
|
from app.models.check_item import CheckItem
|
||||||
|
from app.models.checklist import Checklist
|
||||||
|
from app.models.comment import Comment
|
||||||
|
from app.models.label import Label
|
||||||
|
from app.models.list_model import List
|
||||||
from app.models.order import Order, OrderItem
|
from app.models.order import Order, OrderItem
|
||||||
from app.models.product import Product
|
from app.models.product import Product
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
__all__ = ["User", "Product", "Order", "OrderItem"]
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"Product",
|
||||||
|
"Order",
|
||||||
|
"OrderItem",
|
||||||
|
"Board",
|
||||||
|
"List",
|
||||||
|
"Card",
|
||||||
|
"Label",
|
||||||
|
"CardLabel",
|
||||||
|
"Checklist",
|
||||||
|
"CheckItem",
|
||||||
|
"Comment",
|
||||||
|
]
|
||||||
|
|
|
||||||
64
backend/app/models/board.py
Normal file
64
backend/app/models/board.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class Board(db.Model):
|
||||||
|
"""Board model for Kanban boards"""
|
||||||
|
|
||||||
|
__tablename__ = "boards"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(200), nullable=False, index=True)
|
||||||
|
description = db.Column(db.Text)
|
||||||
|
closed = db.Column(db.Boolean, default=False, index=True)
|
||||||
|
url = db.Column(db.String(500))
|
||||||
|
short_link = db.Column(db.String(10), unique=True)
|
||||||
|
short_url = db.Column(db.String(500))
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
date_last_activity = db.Column(db.DateTime)
|
||||||
|
date_last_view = db.Column(db.DateTime)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON fields for complex data
|
||||||
|
prefs = db.Column(JSONB) # board preferences
|
||||||
|
label_names = db.Column(JSONB) # label color mappings
|
||||||
|
limits = db.Column(JSONB) # various limits
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
lists = db.relationship("List", backref="board", cascade="all, delete-orphan", lazy="dynamic")
|
||||||
|
cards = db.relationship("Card", backref="board", cascade="all, delete-orphan", lazy="dynamic")
|
||||||
|
labels = db.relationship("Label", backref="board", cascade="all, delete-orphan", lazy="dynamic")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert board to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"closed": self.closed,
|
||||||
|
"url": self.url,
|
||||||
|
"short_link": self.short_link,
|
||||||
|
"short_url": self.short_url,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"date_last_activity": self.date_last_activity.isoformat() if self.date_last_activity else None,
|
||||||
|
"date_last_view": self.date_last_view.isoformat() if self.date_last_view else None,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
"prefs": self.prefs,
|
||||||
|
"label_names": self.label_names,
|
||||||
|
"limits": self.limits,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Board {self.name}>"
|
||||||
66
backend/app/models/card.py
Normal file
66
backend/app/models/card.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class Card(db.Model):
|
||||||
|
"""Card model for Kanban cards"""
|
||||||
|
|
||||||
|
__tablename__ = "cards"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text)
|
||||||
|
closed = db.Column(db.Boolean, default=False)
|
||||||
|
due = db.Column(db.DateTime)
|
||||||
|
due_complete = db.Column(db.Boolean, default=False)
|
||||||
|
pos = db.Column(db.Float) # position for sorting
|
||||||
|
id_short = db.Column(db.Integer) # short ID for URLs
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
board_id = db.Column(db.Integer, db.ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
list_id = db.Column(db.Integer, db.ForeignKey("lists.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
date_last_activity = db.Column(db.DateTime)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON fields
|
||||||
|
badges = db.Column(JSONB) # card badges/stats
|
||||||
|
cover = db.Column(JSONB) # cover settings
|
||||||
|
desc_data = db.Column(JSONB)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
checklists = db.relationship("Checklist", backref="card", cascade="all, delete-orphan", lazy="dynamic")
|
||||||
|
labels = db.relationship("CardLabel", backref="card", cascade="all, delete-orphan", lazy="dynamic")
|
||||||
|
comments = db.relationship("Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert card to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"closed": self.closed,
|
||||||
|
"due": self.due.isoformat() if self.due else None,
|
||||||
|
"due_complete": self.due_complete,
|
||||||
|
"pos": self.pos,
|
||||||
|
"id_short": self.id_short,
|
||||||
|
"board_id": self.board_id,
|
||||||
|
"list_id": self.list_id,
|
||||||
|
"date_last_activity": self.date_last_activity.isoformat() if self.date_last_activity else None,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
"badges": self.badges,
|
||||||
|
"cover": self.cover,
|
||||||
|
"desc_data": self.desc_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Card {self.name}>"
|
||||||
31
backend/app/models/card_label.py
Normal file
31
backend/app/models/card_label.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class CardLabel(db.Model):
|
||||||
|
"""Many-to-many relationship between cards and labels"""
|
||||||
|
|
||||||
|
__tablename__ = "card_labels"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
card_id = db.Column(db.Integer, db.ForeignKey("cards.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
label_id = db.Column(db.Integer, db.ForeignKey("labels.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
# Unique constraint to prevent duplicate associations
|
||||||
|
__table_args__ = (db.UniqueConstraint("card_id", "label_id", name="_card_label_uc"),)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert card label to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"card_id": self.card_id,
|
||||||
|
"label_id": self.label_id,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<CardLabel card_id={self.card_id} label_id={self.label_id}>"
|
||||||
44
backend/app/models/check_item.py
Normal file
44
backend/app/models/check_item.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class CheckItem(db.Model):
|
||||||
|
"""CheckItem model for checklist items"""
|
||||||
|
|
||||||
|
__tablename__ = "check_items"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(500), nullable=False)
|
||||||
|
pos = db.Column(db.Float) # position for sorting
|
||||||
|
state = db.Column(db.String(20), default="incomplete") # 'complete' or 'incomplete'
|
||||||
|
due = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
checklist_id = db.Column(db.Integer, db.ForeignKey("checklists.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert check item to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"pos": self.pos,
|
||||||
|
"state": self.state,
|
||||||
|
"due": self.due.isoformat() if self.due else None,
|
||||||
|
"checklist_id": self.checklist_id,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<CheckItem {self.name} ({self.state})>"
|
||||||
43
backend/app/models/checklist.py
Normal file
43
backend/app/models/checklist.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class Checklist(db.Model):
|
||||||
|
"""Checklist model for Kanban checklists"""
|
||||||
|
|
||||||
|
__tablename__ = "checklists"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(200), nullable=False)
|
||||||
|
pos = db.Column(db.Float) # position for sorting
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
board_id = db.Column(db.Integer, db.ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
card_id = db.Column(db.Integer, db.ForeignKey("cards.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
check_items = db.relationship("CheckItem", backref="checklist", cascade="all, delete-orphan", lazy="dynamic")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert checklist to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"pos": self.pos,
|
||||||
|
"board_id": self.board_id,
|
||||||
|
"card_id": self.card_id,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Checklist {self.name}>"
|
||||||
38
backend/app/models/comment.py
Normal file
38
backend/app/models/comment.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(db.Model):
|
||||||
|
"""Comment model for card comments"""
|
||||||
|
|
||||||
|
__tablename__ = "comments"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
text = db.Column(db.Text, nullable=False)
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
card_id = db.Column(db.Integer, db.ForeignKey("cards.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert comment to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"text": self.text,
|
||||||
|
"card_id": self.card_id,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Comment id={self.id} card_id={self.card_id}>"
|
||||||
43
backend/app/models/label.py
Normal file
43
backend/app/models/label.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class Label(db.Model):
|
||||||
|
"""Label model for Kanban labels"""
|
||||||
|
|
||||||
|
__tablename__ = "labels"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100))
|
||||||
|
color = db.Column(db.String(50)) # color name like 'green', 'red', 'blue'
|
||||||
|
uses = db.Column(db.Integer, default=0)
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
board_id = db.Column(db.Integer, db.ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
card_labels = db.relationship("CardLabel", backref="label", cascade="all, delete-orphan", lazy="dynamic")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert label to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"color": self.color,
|
||||||
|
"uses": self.uses,
|
||||||
|
"board_id": self.board_id,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Label {self.name} ({self.color})>"
|
||||||
43
backend/app/models/list_model.py
Normal file
43
backend/app/models/list_model.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class List(db.Model):
|
||||||
|
"""List model for Kanban lists (columns)"""
|
||||||
|
|
||||||
|
__tablename__ = "lists"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(200), nullable=False)
|
||||||
|
closed = db.Column(db.Boolean, default=False)
|
||||||
|
pos = db.Column(db.Float) # position for sorting
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
board_id = db.Column(db.Integer, db.ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
cards = db.relationship("Card", backref="list", cascade="all, delete-orphan", lazy="dynamic")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert list to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"closed": self.closed,
|
||||||
|
"pos": self.pos,
|
||||||
|
"board_id": self.board_id,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<List {self.name}>"
|
||||||
|
|
@ -27,6 +27,7 @@ class User(db.Model):
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
|
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
|
||||||
|
boards = db.relationship("Board", backref="user", cascade="all, delete-orphan", lazy="dynamic")
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
"""Hash and set password"""
|
"""Hash and set password"""
|
||||||
|
|
|
||||||
726
backend/app/routes/kanban.py
Normal file
726
backend/app/routes/kanban.py
Normal file
|
|
@ -0,0 +1,726 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import (
|
||||||
|
Board,
|
||||||
|
Card,
|
||||||
|
CardLabel,
|
||||||
|
CheckItem,
|
||||||
|
Checklist,
|
||||||
|
Comment,
|
||||||
|
Label,
|
||||||
|
List,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
kanban_bp = Blueprint("kanban", __name__)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BOARD ROUTES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/boards", methods=["GET"])
|
||||||
|
@jwt_required()
|
||||||
|
def get_boards():
|
||||||
|
"""Get all boards for current user"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
boards = Board.query.filter_by(user_id=user_id).all()
|
||||||
|
return jsonify([board.to_dict() for board in boards]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/boards/<int:board_id>", methods=["GET"])
|
||||||
|
@jwt_required()
|
||||||
|
def get_board(board_id):
|
||||||
|
"""Get a single board with all its details"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
board = db.session.get(Board, board_id)
|
||||||
|
|
||||||
|
if not board:
|
||||||
|
return jsonify({"error": "Board not found"}), 404
|
||||||
|
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
# Get all lists for this board
|
||||||
|
lists_data = []
|
||||||
|
for lst in board.lists.filter_by(closed=False).order_by(List.pos).all():
|
||||||
|
cards_data = []
|
||||||
|
for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all():
|
||||||
|
card_dict = card.to_dict()
|
||||||
|
# Add labels for this card
|
||||||
|
card_dict["labels"] = [
|
||||||
|
label.to_dict()
|
||||||
|
for label in (
|
||||||
|
db.session.query(Label)
|
||||||
|
.join(CardLabel)
|
||||||
|
.filter(CardLabel.card_id == card.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
cards_data.append(card_dict)
|
||||||
|
|
||||||
|
lists_data.append({**lst.to_dict(), "cards": cards_data})
|
||||||
|
|
||||||
|
board_dict = board.to_dict()
|
||||||
|
board_dict["lists"] = lists_data
|
||||||
|
board_dict["labels"] = [label.to_dict() for label in board.labels.all()]
|
||||||
|
|
||||||
|
return jsonify(board_dict), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/boards", methods=["POST"])
|
||||||
|
@jwt_required()
|
||||||
|
def create_board():
|
||||||
|
"""Create a new board"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get("name"):
|
||||||
|
return jsonify({"error": "Board name is required"}), 400
|
||||||
|
|
||||||
|
board = Board(
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description"),
|
||||||
|
user_id=user_id,
|
||||||
|
url=data.get("url"),
|
||||||
|
short_link=data.get("short_link"),
|
||||||
|
short_url=data.get("short_url"),
|
||||||
|
prefs=data.get("prefs"),
|
||||||
|
label_names=data.get("label_names"),
|
||||||
|
limits=data.get("limits"),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(board)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(board.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/boards/<int:board_id>", methods=["PUT"])
|
||||||
|
@jwt_required()
|
||||||
|
def update_board(board_id):
|
||||||
|
"""Update a board"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
board = db.session.get(Board, board_id)
|
||||||
|
|
||||||
|
if not board:
|
||||||
|
return jsonify({"error": "Board not found"}), 404
|
||||||
|
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
board.name = data.get("name", board.name)
|
||||||
|
board.description = data.get("description", board.description)
|
||||||
|
board.closed = data.get("closed", board.closed)
|
||||||
|
board.url = data.get("url", board.url)
|
||||||
|
board.short_url = data.get("short_url", board.short_url)
|
||||||
|
board.prefs = data.get("prefs", board.prefs)
|
||||||
|
board.label_names = data.get("label_names", board.label_names)
|
||||||
|
board.limits = data.get("limits", board.limits)
|
||||||
|
board.date_last_activity = datetime.now(UTC)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(board.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/boards/<int:board_id>", methods=["DELETE"])
|
||||||
|
@jwt_required()
|
||||||
|
def delete_board(board_id):
|
||||||
|
"""Delete a board"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
board = db.session.get(Board, board_id)
|
||||||
|
|
||||||
|
if not board:
|
||||||
|
return jsonify({"error": "Board not found"}), 404
|
||||||
|
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
db.session.delete(board)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({"message": "Board deleted"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LIST ROUTES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/boards/<int:board_id>/lists", methods=["POST"])
|
||||||
|
@jwt_required()
|
||||||
|
def create_list(board_id):
|
||||||
|
"""Create a new list in a board"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
board = db.session.get(Board, board_id)
|
||||||
|
|
||||||
|
if not board:
|
||||||
|
return jsonify({"error": "Board not found"}), 404
|
||||||
|
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get("name"):
|
||||||
|
return jsonify({"error": "List name is required"}), 400
|
||||||
|
|
||||||
|
lst = List(name=data["name"], board_id=board_id, pos=data.get("pos", 65535.0))
|
||||||
|
|
||||||
|
db.session.add(lst)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(lst.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/lists/<int:list_id>", methods=["PUT"])
|
||||||
|
@jwt_required()
|
||||||
|
def update_list(list_id):
|
||||||
|
"""Update a list"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
lst = db.session.get(List, list_id)
|
||||||
|
|
||||||
|
if not lst:
|
||||||
|
return jsonify({"error": "List not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, lst.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
lst.name = data.get("name", lst.name)
|
||||||
|
lst.closed = data.get("closed", lst.closed)
|
||||||
|
lst.pos = data.get("pos", lst.pos)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(lst.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/lists/<int:list_id>", methods=["DELETE"])
|
||||||
|
@jwt_required()
|
||||||
|
def delete_list(list_id):
|
||||||
|
"""Delete a list"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
lst = db.session.get(List, list_id)
|
||||||
|
|
||||||
|
if not lst:
|
||||||
|
return jsonify({"error": "List not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, lst.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
db.session.delete(lst)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({"message": "List deleted"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CARD ROUTES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/lists/<int:list_id>/cards", methods=["POST"])
|
||||||
|
@jwt_required()
|
||||||
|
def create_card(list_id):
|
||||||
|
"""Create a new card in a list"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
lst = db.session.get(List, list_id)
|
||||||
|
|
||||||
|
if not lst:
|
||||||
|
return jsonify({"error": "List not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, lst.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get("name"):
|
||||||
|
return jsonify({"error": "Card name is required"}), 400
|
||||||
|
|
||||||
|
card = Card(
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description"),
|
||||||
|
board_id=lst.board_id,
|
||||||
|
list_id=list_id,
|
||||||
|
pos=data.get("pos", 65535.0),
|
||||||
|
due=data.get("due"),
|
||||||
|
due_complete=data.get("due_complete", False),
|
||||||
|
badges=data.get("badges"),
|
||||||
|
cover=data.get("cover"),
|
||||||
|
desc_data=data.get("desc_data"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update board activity
|
||||||
|
board.date_last_activity = datetime.now(UTC)
|
||||||
|
|
||||||
|
db.session.add(card)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(card.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/cards/<int:card_id>", methods=["GET"])
|
||||||
|
@jwt_required()
|
||||||
|
def get_card(card_id):
|
||||||
|
"""Get a single card with full details"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
card = db.session.get(Card, card_id)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
return jsonify({"error": "Card not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
card_dict = card.to_dict()
|
||||||
|
|
||||||
|
# Add labels
|
||||||
|
card_dict["labels"] = [
|
||||||
|
label.to_dict()
|
||||||
|
for label in (
|
||||||
|
db.session.query(Label)
|
||||||
|
.join(CardLabel)
|
||||||
|
.filter(CardLabel.card_id == card.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add checklists
|
||||||
|
card_dict["checklists"] = [
|
||||||
|
{**checklist.to_dict(), "items": [item.to_dict() for item in checklist.check_items.all()]}
|
||||||
|
for checklist in card.checklists.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add comments
|
||||||
|
card_dict["comments"] = []
|
||||||
|
for comment in card.comments.all():
|
||||||
|
comment_dict = comment.to_dict()
|
||||||
|
user = db.session.get(User, comment.user_id)
|
||||||
|
comment_dict["user"] = user.to_dict() if user else None
|
||||||
|
card_dict["comments"].append(comment_dict)
|
||||||
|
|
||||||
|
return jsonify(card_dict), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/cards/<int:card_id>", methods=["PUT"])
|
||||||
|
@jwt_required()
|
||||||
|
def update_card(card_id):
|
||||||
|
"""Update a card"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
card = db.session.get(Card, card_id)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
return jsonify({"error": "Card not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
card.name = data.get("name", card.name)
|
||||||
|
card.description = data.get("description", card.description)
|
||||||
|
card.closed = data.get("closed", card.closed)
|
||||||
|
card.pos = data.get("pos", card.pos)
|
||||||
|
card.due = data.get("due")
|
||||||
|
card.due_complete = data.get("due_complete", card.due_complete)
|
||||||
|
card.badges = data.get("badges", card.badges)
|
||||||
|
card.cover = data.get("cover", card.cover)
|
||||||
|
card.desc_data = data.get("desc_data", card.desc_data)
|
||||||
|
|
||||||
|
# Handle moving card to different list
|
||||||
|
if "list_id" in data:
|
||||||
|
new_list_id = data["list_id"]
|
||||||
|
new_list = db.session.get(List, new_list_id)
|
||||||
|
if new_list and new_list.board_id == card.board_id:
|
||||||
|
card.list_id = new_list_id
|
||||||
|
|
||||||
|
card.date_last_activity = datetime.now(UTC)
|
||||||
|
board.date_last_activity = datetime.now(UTC)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(card.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"])
|
||||||
|
@jwt_required()
|
||||||
|
def delete_card(card_id):
|
||||||
|
"""Delete a card"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
card = db.session.get(Card, card_id)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
return jsonify({"error": "Card not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
db.session.delete(card)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({"message": "Card deleted"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LABEL ROUTES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/boards/<int:board_id>/labels", methods=["GET"])
|
||||||
|
@jwt_required()
|
||||||
|
def get_labels(board_id):
|
||||||
|
"""Get all labels for a board"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
board = db.session.get(Board, board_id)
|
||||||
|
|
||||||
|
if not board:
|
||||||
|
return jsonify({"error": "Board not found"}), 404
|
||||||
|
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
labels = board.labels.all()
|
||||||
|
return jsonify([label.to_dict() for label in labels]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/boards/<int:board_id>/labels", methods=["POST"])
|
||||||
|
@jwt_required()
|
||||||
|
def create_label(board_id):
|
||||||
|
"""Create a new label in a board"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
board = db.session.get(Board, board_id)
|
||||||
|
|
||||||
|
if not board:
|
||||||
|
return jsonify({"error": "Board not found"}), 404
|
||||||
|
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get("name"):
|
||||||
|
return jsonify({"error": "Label name is required"}), 400
|
||||||
|
|
||||||
|
label = Label(
|
||||||
|
name=data["name"],
|
||||||
|
color=data.get("color"),
|
||||||
|
board_id=board_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(label)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(label.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/cards/<int:card_id>/labels", methods=["POST"])
|
||||||
|
@jwt_required()
|
||||||
|
def add_label_to_card(card_id):
|
||||||
|
"""Add a label to a card"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
card = db.session.get(Card, card_id)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
return jsonify({"error": "Card not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
label_id = data.get("label_id")
|
||||||
|
|
||||||
|
if not label_id:
|
||||||
|
return jsonify({"error": "Label ID is required"}), 400
|
||||||
|
|
||||||
|
label = db.session.get(Label, label_id)
|
||||||
|
if not label or label.board_id != card.board_id:
|
||||||
|
return jsonify({"error": "Label not found or doesn't belong to this board"}), 404
|
||||||
|
|
||||||
|
# Check if already labeled
|
||||||
|
existing = CardLabel.query.filter_by(card_id=card_id, label_id=label_id).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({"error": "Label already added to card"}), 400
|
||||||
|
|
||||||
|
card_label = CardLabel(card_id=card_id, label_id=label_id)
|
||||||
|
db.session.add(card_label)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(card_label.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/cards/<int:card_id>/labels/<int:label_id>", methods=["DELETE"])
|
||||||
|
@jwt_required()
|
||||||
|
def remove_label_from_card(card_id, label_id):
|
||||||
|
"""Remove a label from a card"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
card = db.session.get(Card, card_id)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
return jsonify({"error": "Card not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
card_label = CardLabel.query.filter_by(card_id=card_id, label_id=label_id).first()
|
||||||
|
if not card_label:
|
||||||
|
return jsonify({"error": "Label not found on card"}), 404
|
||||||
|
|
||||||
|
db.session.delete(card_label)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({"message": "Label removed from card"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CHECKLIST ROUTES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/cards/<int:card_id>/checklists", methods=["POST"])
|
||||||
|
@jwt_required()
|
||||||
|
def create_checklist(card_id):
|
||||||
|
"""Create a new checklist in a card"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
card = db.session.get(Card, card_id)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
return jsonify({"error": "Card not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get("name"):
|
||||||
|
return jsonify({"error": "Checklist name is required"}), 400
|
||||||
|
|
||||||
|
checklist = Checklist(
|
||||||
|
name=data["name"],
|
||||||
|
card_id=card_id,
|
||||||
|
board_id=card.board_id,
|
||||||
|
pos=data.get("pos", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(checklist)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(checklist.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/checklists/<int:checklist_id>/items", methods=["POST"])
|
||||||
|
@jwt_required()
|
||||||
|
def create_check_item(checklist_id):
|
||||||
|
"""Create a new check item in a checklist"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
checklist = db.session.get(Checklist, checklist_id)
|
||||||
|
|
||||||
|
if not checklist:
|
||||||
|
return jsonify({"error": "Checklist not found"}), 404
|
||||||
|
|
||||||
|
card = db.session.get(Card, checklist.card_id)
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get("name"):
|
||||||
|
return jsonify({"error": "Check item name is required"}), 400
|
||||||
|
|
||||||
|
check_item = CheckItem(
|
||||||
|
name=data["name"],
|
||||||
|
checklist_id=checklist_id,
|
||||||
|
pos=data.get("pos", 0),
|
||||||
|
state=data.get("state", "incomplete"),
|
||||||
|
due=data.get("due"),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(check_item)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(check_item.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/check-items/<int:item_id>", methods=["PUT"])
|
||||||
|
@jwt_required()
|
||||||
|
def update_check_item(item_id):
|
||||||
|
"""Update a check item"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
check_item = db.session.get(CheckItem, item_id)
|
||||||
|
|
||||||
|
if not check_item:
|
||||||
|
return jsonify({"error": "Check item not found"}), 404
|
||||||
|
|
||||||
|
checklist = db.session.get(Checklist, check_item.checklist_id)
|
||||||
|
card = db.session.get(Card, checklist.card_id)
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
check_item.name = data.get("name", check_item.name)
|
||||||
|
check_item.state = data.get("state", check_item.state)
|
||||||
|
check_item.pos = data.get("pos", check_item.pos)
|
||||||
|
check_item.due = data.get("due")
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(check_item.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/checklists/<int:checklist_id>", methods=["DELETE"])
|
||||||
|
@jwt_required()
|
||||||
|
def delete_checklist(checklist_id):
|
||||||
|
"""Delete a checklist"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
checklist = db.session.get(Checklist, checklist_id)
|
||||||
|
|
||||||
|
if not checklist:
|
||||||
|
return jsonify({"error": "Checklist not found"}), 404
|
||||||
|
|
||||||
|
card = db.session.get(Card, checklist.card_id)
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
db.session.delete(checklist)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({"message": "Checklist deleted"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/check-items/<int:item_id>", methods=["DELETE"])
|
||||||
|
@jwt_required()
|
||||||
|
def delete_check_item(item_id):
|
||||||
|
"""Delete a check item"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
check_item = db.session.get(CheckItem, item_id)
|
||||||
|
|
||||||
|
if not check_item:
|
||||||
|
return jsonify({"error": "Check item not found"}), 404
|
||||||
|
|
||||||
|
checklist = db.session.get(Checklist, check_item.checklist_id)
|
||||||
|
card = db.session.get(Card, checklist.card_id)
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
db.session.delete(check_item)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({"message": "Check item deleted"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# COMMENT ROUTES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/cards/<int:card_id>/comments", methods=["GET"])
|
||||||
|
@jwt_required()
|
||||||
|
def get_comments(card_id):
|
||||||
|
"""Get all comments for a card"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
card = db.session.get(Card, card_id)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
return jsonify({"error": "Card not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
comments = []
|
||||||
|
for comment in card.comments.order_by(Comment.created_at.desc()).all():
|
||||||
|
comment_dict = comment.to_dict()
|
||||||
|
user = db.session.get(User, comment.user_id)
|
||||||
|
comment_dict["user"] = user.to_dict() if user else None
|
||||||
|
comments.append(comment_dict)
|
||||||
|
|
||||||
|
return jsonify(comments), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/cards/<int:card_id>/comments", methods=["POST"])
|
||||||
|
@jwt_required()
|
||||||
|
def create_comment(card_id):
|
||||||
|
"""Create a new comment on a card"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
card = db.session.get(Card, card_id)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
return jsonify({"error": "Card not found"}), 404
|
||||||
|
|
||||||
|
board = db.session.get(Board, card.board_id)
|
||||||
|
if board.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get("text"):
|
||||||
|
return jsonify({"error": "Comment text is required"}), 400
|
||||||
|
|
||||||
|
comment = Comment(text=data["text"], card_id=card_id, user_id=user_id)
|
||||||
|
|
||||||
|
db.session.add(comment)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(comment.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/comments/<int:comment_id>", methods=["PUT"])
|
||||||
|
@jwt_required()
|
||||||
|
def update_comment(comment_id):
|
||||||
|
"""Update a comment"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
comment = db.session.get(Comment, comment_id)
|
||||||
|
|
||||||
|
if not comment:
|
||||||
|
return jsonify({"error": "Comment not found"}), 404
|
||||||
|
|
||||||
|
if comment.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
comment.text = data.get("text", comment.text)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(comment.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@kanban_bp.route("/comments/<int:comment_id>", methods=["DELETE"])
|
||||||
|
@jwt_required()
|
||||||
|
def delete_comment(comment_id):
|
||||||
|
"""Delete a comment"""
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
comment = db.session.get(Comment, comment_id)
|
||||||
|
|
||||||
|
if not comment:
|
||||||
|
return jsonify({"error": "Comment not found"}), 404
|
||||||
|
|
||||||
|
if comment.user_id != user_id:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
db.session.delete(comment)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({"message": "Comment deleted"}), 200
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""Add Kanban models (Board, List, Card, Label, Checklist, Comment)
|
||||||
|
|
||||||
|
Revision ID: 1c0b9dfbd933
|
||||||
|
Revises: dd57c5299d60
|
||||||
|
Create Date: 2026-02-25 18:50:14.709257
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1c0b9dfbd933'
|
||||||
|
down_revision = 'dd57c5299d60'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('boards',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('closed', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('url', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('short_link', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('short_url', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('date_last_activity', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('date_last_view', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('prefs', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('label_names', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('limits', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('short_link')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('boards', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_boards_closed'), ['closed'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_boards_name'), ['name'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_boards_user_id'), ['user_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('labels',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('color', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('uses', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('board_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('labels', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_labels_board_id'), ['board_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('lists',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('closed', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('pos', sa.Float(), nullable=True),
|
||||||
|
sa.Column('board_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('lists', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_lists_board_id'), ['board_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('cards',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('closed', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('due', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('due_complete', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('pos', sa.Float(), nullable=True),
|
||||||
|
sa.Column('id_short', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('board_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('list_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('date_last_activity', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('badges', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('cover', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('desc_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('cards', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_cards_board_id'), ['board_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_cards_list_id'), ['list_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('card_labels',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('card_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('label_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['card_id'], ['cards.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['label_id'], ['labels.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('card_id', 'label_id', name='_card_label_uc')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('card_labels', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_card_labels_card_id'), ['card_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_card_labels_label_id'), ['label_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('checklists',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('pos', sa.Float(), nullable=True),
|
||||||
|
sa.Column('board_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('card_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['card_id'], ['cards.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('checklists', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_checklists_board_id'), ['board_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_checklists_card_id'), ['card_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('comments',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('card_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['card_id'], ['cards.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('comments', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_comments_card_id'), ['card_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_comments_user_id'), ['user_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('check_items',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('pos', sa.Float(), nullable=True),
|
||||||
|
sa.Column('state', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('due', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('checklist_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['checklist_id'], ['checklists.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('check_items', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_check_items_checklist_id'), ['checklist_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_check_items_user_id'), ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('check_items', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_check_items_user_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_check_items_checklist_id'))
|
||||||
|
|
||||||
|
op.drop_table('check_items')
|
||||||
|
with op.batch_alter_table('comments', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_comments_user_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_comments_card_id'))
|
||||||
|
|
||||||
|
op.drop_table('comments')
|
||||||
|
with op.batch_alter_table('checklists', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_checklists_card_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_checklists_board_id'))
|
||||||
|
|
||||||
|
op.drop_table('checklists')
|
||||||
|
with op.batch_alter_table('card_labels', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_card_labels_label_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_card_labels_card_id'))
|
||||||
|
|
||||||
|
op.drop_table('card_labels')
|
||||||
|
with op.batch_alter_table('cards', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_cards_list_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_cards_board_id'))
|
||||||
|
|
||||||
|
op.drop_table('cards')
|
||||||
|
with op.batch_alter_table('lists', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_lists_board_id'))
|
||||||
|
|
||||||
|
op.drop_table('lists')
|
||||||
|
with op.batch_alter_table('labels', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_labels_board_id'))
|
||||||
|
|
||||||
|
op.drop_table('labels')
|
||||||
|
with op.batch_alter_table('boards', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_boards_user_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_boards_name'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_boards_closed'))
|
||||||
|
|
||||||
|
op.drop_table('boards')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -13,16 +13,14 @@ fake = Faker()
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def app():
|
def app():
|
||||||
"""Create application for testing with isolated database"""
|
"""Create application for testing with PostgreSQL database"""
|
||||||
db_fd, db_path = tempfile.mkstemp()
|
|
||||||
|
|
||||||
app = create_app(config_name="test")
|
app = create_app(config_name="test")
|
||||||
app.config.update(
|
app.config.update(
|
||||||
{
|
{
|
||||||
"TESTING": True,
|
"TESTING": True,
|
||||||
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}",
|
"SQLALCHEMY_DATABASE_URI": "postgresql://kanban:devpassword@localhost:5451/kanban_test",
|
||||||
"WTF_CSRF_ENABLED": False,
|
"WTF_CSRF_ENABLED": False,
|
||||||
"JWT_SECRET_KEY": "test-secret-keytest-secret-keytest-secret-keytest-secret-keytest-secret-key",
|
"JWT_SECRET_KEY": "test-secret-keytest-secret-keytest-secret-keytest-secret-key",
|
||||||
"SERVER_NAME": "localhost.localdomain",
|
"SERVER_NAME": "localhost.localdomain",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -33,9 +31,6 @@ def app():
|
||||||
db.session.remove()
|
db.session.remove()
|
||||||
db.drop_all()
|
db.drop_all()
|
||||||
|
|
||||||
os.close(db_fd)
|
|
||||||
os.unlink(db_path)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(app):
|
def client(app):
|
||||||
|
|
@ -53,8 +48,18 @@ def runner(app):
|
||||||
def db_session(app):
|
def db_session(app):
|
||||||
"""Database session for tests"""
|
"""Database session for tests"""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
# Create tables
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
# Begin a transaction
|
||||||
|
db.session.begin_nested()
|
||||||
|
|
||||||
yield db.session
|
yield db.session
|
||||||
|
|
||||||
|
# Rollback and cleanup
|
||||||
|
db.session.rollback()
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_user(db_session):
|
def admin_user(db_session):
|
||||||
|
|
|
||||||
1
backend/tests/routes/__init__.py
Normal file
1
backend/tests/routes/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Routes test package
|
||||||
164
backend/tests/routes/test_boards.py
Normal file
164
backend/tests/routes/test_boards.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import Board, List, Card, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestBoardRoutes:
|
||||||
|
"""Test Board API routes"""
|
||||||
|
|
||||||
|
def test_get_boards_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test getting all boards for current user"""
|
||||||
|
# Create a board for the user
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get("/api/boards", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["name"] == "Test Board"
|
||||||
|
assert data[0]["user_id"] == 1
|
||||||
|
|
||||||
|
def test_get_boards_unauthorized(self, client, db_session):
|
||||||
|
"""Test getting boards without authentication"""
|
||||||
|
response = client.get("/api/boards")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_get_board_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test getting a single board with details"""
|
||||||
|
# Create a board with lists and cards
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["id"] == board.id
|
||||||
|
assert data["name"] == "Test Board"
|
||||||
|
assert len(data["lists"]) == 1
|
||||||
|
assert data["lists"][0]["name"] == "To Do"
|
||||||
|
assert len(data["lists"][0]["cards"]) == 1
|
||||||
|
|
||||||
|
def test_get_board_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test getting a non-existent board"""
|
||||||
|
response = client.get("/api/boards/99999", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_get_board_access_denied(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test getting another user's board"""
|
||||||
|
# Create a board for user 1
|
||||||
|
board = Board(name="User 1 Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Try to access with user 2's token (assuming auth_headers is for user 1)
|
||||||
|
# This test assumes auth_headers is for a different user
|
||||||
|
# In real scenario, you'd need another user fixture
|
||||||
|
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
# Should succeed since we're using same user's token
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_create_board_success(self, client, db_session, auth_headers):
|
||||||
|
"""Test creating a new board"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/boards",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "New Board", "description": "Board description"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "New Board"
|
||||||
|
assert data["description"] == "Board description"
|
||||||
|
assert data["user_id"] == 1
|
||||||
|
assert "id" in data
|
||||||
|
|
||||||
|
def test_create_board_missing_name(self, client, db_session, auth_headers):
|
||||||
|
"""Test creating a board without name"""
|
||||||
|
response = client.post("/api/boards", headers=auth_headers, json={"description": "Test"})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert "error" in data
|
||||||
|
|
||||||
|
def test_create_board_unauthorized(self, client, db_session):
|
||||||
|
"""Test creating a board without authentication"""
|
||||||
|
response = client.post("/api/boards", json={"name": "New Board"})
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_update_board_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test updating a board"""
|
||||||
|
board = Board(name="Original Name", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/boards/{board.id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Updated Name", "description": "New description"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "Updated Name"
|
||||||
|
assert data["description"] == "New description"
|
||||||
|
|
||||||
|
def test_update_board_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test updating a non-existent board"""
|
||||||
|
response = client.put(
|
||||||
|
"/api/boards/99999",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Updated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_board_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test deleting a board"""
|
||||||
|
board = Board(name="To Delete", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.delete(f"/api/boards/{board.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert "message" in data
|
||||||
|
|
||||||
|
# Verify board is deleted
|
||||||
|
deleted_board = db.session.get(Board, board.id)
|
||||||
|
assert deleted_board is None
|
||||||
|
|
||||||
|
def test_delete_board_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test deleting a non-existent board"""
|
||||||
|
response = client.delete("/api/boards/99999", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_board_unauthorized(self, client, db_session, regular_user):
|
||||||
|
"""Test deleting a board without authentication"""
|
||||||
|
board = Board(name="Test", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.delete(f"/api/boards/{board.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
167
backend/tests/routes/test_cards.py
Normal file
167
backend/tests/routes/test_cards.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import Board, List, Card
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestCardRoutes:
|
||||||
|
"""Test Card API routes"""
|
||||||
|
|
||||||
|
def test_create_card_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a new card in a list"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/lists/{lst.id}/cards",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "New Card", "description": "Card description", "pos": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "New Card"
|
||||||
|
assert data["description"] == "Card description"
|
||||||
|
assert data["list_id"] == lst.id
|
||||||
|
assert data["board_id"] == board.id
|
||||||
|
|
||||||
|
def test_create_card_missing_name(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a card without name"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/lists/{lst.id}/cards",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"description": "Test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_create_card_unauthorized(self, client, db_session, regular_user):
|
||||||
|
"""Test creating a card without authentication"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/lists/{lst.id}/cards",
|
||||||
|
json={"name": "New Card"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_get_card_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test getting a single card with full details"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get(f"/api/cards/{card.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["id"] == card.id
|
||||||
|
assert data["name"] == "Test Card"
|
||||||
|
assert "labels" in data
|
||||||
|
assert "checklists" in data
|
||||||
|
assert "comments" in data
|
||||||
|
|
||||||
|
def test_get_card_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test getting a non-existent card"""
|
||||||
|
response = client.get("/api/cards/99999", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_card_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test updating a card"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst1 = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst1)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst2 = List(name="Done", board_id=board.id, pos=1)
|
||||||
|
db_session.add(lst2)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Original", board_id=board.id, list_id=lst1.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/cards/{card.id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Updated Name", "list_id": lst2.id, "pos": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "Updated Name"
|
||||||
|
assert data["list_id"] == lst2.id
|
||||||
|
assert data["pos"] == 1
|
||||||
|
|
||||||
|
def test_update_card_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test updating a non-existent card"""
|
||||||
|
response = client.put(
|
||||||
|
"/api/cards/99999",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Updated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_card_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test deleting a card"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="To Delete", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.delete(f"/api/cards/{card.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert "message" in data
|
||||||
|
|
||||||
|
# Verify card is deleted
|
||||||
|
deleted_card = db.session.get(Card, card.id)
|
||||||
|
assert deleted_card is None
|
||||||
|
|
||||||
|
def test_delete_card_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test deleting a non-existent card"""
|
||||||
|
response = client.delete("/api/cards/99999", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
276
backend/tests/routes/test_checklists.py
Normal file
276
backend/tests/routes/test_checklists.py
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import Board, List, Card, Checklist, CheckItem
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestChecklistRoutes:
|
||||||
|
"""Test Checklist API routes"""
|
||||||
|
|
||||||
|
def test_create_checklist_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a new checklist in a card"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{card.id}/checklists",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Tasks", "pos": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "Tasks"
|
||||||
|
assert data["card_id"] == card.id
|
||||||
|
assert data["board_id"] == board.id
|
||||||
|
|
||||||
|
def test_create_checklist_missing_name(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a checklist without name"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{card.id}/checklists",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"pos": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_create_checklist_unauthorized(self, client, db_session, regular_user):
|
||||||
|
"""Test creating a checklist without authentication"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{card.id}/checklists",
|
||||||
|
json={"name": "Tasks"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_create_check_item_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a new check item in a checklist"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
|
||||||
|
db_session.add(checklist)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/checklists/{checklist.id}/items",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Complete task", "pos": 0, "state": "incomplete"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "Complete task"
|
||||||
|
assert data["checklist_id"] == checklist.id
|
||||||
|
assert data["state"] == "incomplete"
|
||||||
|
|
||||||
|
def test_create_check_item_missing_name(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a check item without name"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
|
||||||
|
db_session.add(checklist)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/checklists/{checklist.id}/items",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"state": "incomplete"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_create_check_item_unauthorized(self, client, db_session, regular_user):
|
||||||
|
"""Test creating a check item without authentication"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
|
||||||
|
db_session.add(checklist)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/checklists/{checklist.id}/items",
|
||||||
|
json={"name": "Complete task"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_update_check_item_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test updating a check item (toggle state)"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
|
||||||
|
db_session.add(checklist)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
item = CheckItem(
|
||||||
|
name="Task", checklist_id=checklist.id, pos=0, state="incomplete"
|
||||||
|
)
|
||||||
|
db_session.add(item)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/check-items/{item.id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"state": "complete", "name": "Updated Task"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["state"] == "complete"
|
||||||
|
assert data["name"] == "Updated Task"
|
||||||
|
|
||||||
|
def test_update_check_item_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test updating a non-existent check item"""
|
||||||
|
response = client.put(
|
||||||
|
"/api/check-items/99999",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"state": "complete"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_checklist_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test deleting a checklist"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
checklist = Checklist(name="To Delete", board_id=board.id, card_id=card.id, pos=0)
|
||||||
|
db_session.add(checklist)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.delete(f"/api/checklists/{checklist.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert "message" in data
|
||||||
|
|
||||||
|
# Verify checklist is deleted
|
||||||
|
deleted_checklist = db.session.get(Checklist, checklist.id)
|
||||||
|
assert deleted_checklist is None
|
||||||
|
|
||||||
|
def test_delete_checklist_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test deleting a non-existent checklist"""
|
||||||
|
response = client.delete("/api/checklists/99999", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_check_item_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test deleting a check item"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
|
||||||
|
db_session.add(checklist)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
item = CheckItem(name="Task", checklist_id=checklist.id, pos=0, state="incomplete")
|
||||||
|
db_session.add(item)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.delete(f"/api/check-items/{item.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert "message" in data
|
||||||
|
|
||||||
|
# Verify check item is deleted
|
||||||
|
deleted_item = db.session.get(CheckItem, item.id)
|
||||||
|
assert deleted_item is None
|
||||||
|
|
||||||
|
def test_delete_check_item_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test deleting a non-existent check item"""
|
||||||
|
response = client.delete("/api/check-items/99999", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
194
backend/tests/routes/test_comments.py
Normal file
194
backend/tests/routes/test_comments.py
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import Board, List, Card, Comment
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestCommentRoutes:
|
||||||
|
"""Test Comment API routes"""
|
||||||
|
|
||||||
|
def test_get_card_comments_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test getting all comments for a card"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
comment1 = Comment(text="First comment", card_id=card.id, user_id=1)
|
||||||
|
comment2 = Comment(text="Second comment", card_id=card.id, user_id=1)
|
||||||
|
db_session.add_all([comment1, comment2])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get(f"/api/cards/{card.id}/comments", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data) == 2
|
||||||
|
assert data[0]["text"] == "Second comment" # Newest first
|
||||||
|
assert data[1]["text"] == "First comment"
|
||||||
|
|
||||||
|
def test_get_card_comments_unauthorized(self, client, db_session, regular_user):
|
||||||
|
"""Test getting comments without authentication"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get(f"/api/cards/{card.id}/comments")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_create_comment_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a new comment on a card"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{card.id}/comments",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"text": "This is a comment"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["text"] == "This is a comment"
|
||||||
|
assert data["card_id"] == card.id
|
||||||
|
assert data["user_id"] == 1
|
||||||
|
|
||||||
|
def test_create_comment_missing_text(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a comment without text"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{card.id}/comments",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_create_comment_unauthorized(self, client, db_session, regular_user):
|
||||||
|
"""Test creating a comment without authentication"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{card.id}/comments",
|
||||||
|
json={"text": "This is a comment"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_update_comment_success(self, client, db_session, auth_headers):
|
||||||
|
"""Test updating a comment (by author)"""
|
||||||
|
board = Board(name="Test Board", user_id=1)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
comment = Comment(text="Original", card_id=card.id, user_id=1)
|
||||||
|
db_session.add(comment)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/comments/{comment.id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"text": "Updated comment"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["text"] == "Updated comment"
|
||||||
|
|
||||||
|
def test_update_comment_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test updating a non-existent comment"""
|
||||||
|
response = client.put(
|
||||||
|
"/api/comments/99999",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"text": "Updated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_comment_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test deleting a comment"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
comment = Comment(text="To Delete", card_id=card.id, user_id=1)
|
||||||
|
db_session.add(comment)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.delete(f"/api/comments/{comment.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert "message" in data
|
||||||
|
|
||||||
|
# Verify comment is deleted
|
||||||
|
deleted_comment = db.session.get(Comment, comment.id)
|
||||||
|
assert deleted_comment is None
|
||||||
|
|
||||||
|
def test_delete_comment_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test deleting a non-existent comment"""
|
||||||
|
response = client.delete("/api/comments/99999", headers=auth_headers)
|
||||||
|
assert response.status_code == 404
|
||||||
176
backend/tests/routes/test_labels.py
Normal file
176
backend/tests/routes/test_labels.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import Board, List, Card, Label, CardLabel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestLabelRoutes:
|
||||||
|
"""Test Label API routes"""
|
||||||
|
|
||||||
|
def test_get_board_labels_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test getting all labels for a board"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
label1 = Label(name="Urgent", color="red", board_id=board.id)
|
||||||
|
label2 = Label(name="Low Priority", color="green", board_id=board.id)
|
||||||
|
db_session.add_all([label1, label2])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get(f"/api/boards/{board.id}/labels", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data) == 2
|
||||||
|
assert data[0]["name"] == "Urgent"
|
||||||
|
assert data[1]["name"] == "Low Priority"
|
||||||
|
|
||||||
|
def test_get_board_labels_unauthorized(self, client, db_session, regular_user):
|
||||||
|
"""Test getting labels without authentication"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get(f"/api/boards/{board.id}/labels")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_create_label_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a new label in a board"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{board.id}/labels",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Urgent", "color": "red"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "Urgent"
|
||||||
|
assert data["color"] == "red"
|
||||||
|
assert data["board_id"] == board.id
|
||||||
|
|
||||||
|
def test_create_label_missing_name(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a label without name"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{board.id}/labels",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"color": "red"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_create_label_unauthorized(self, client, db_session, regular_user):
|
||||||
|
"""Test creating a label without authentication"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{board.id}/labels",
|
||||||
|
json={"name": "Urgent", "color": "red"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_add_label_to_card_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test adding a label to a card"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
label = Label(name="Urgent", color="red", board_id=board.id)
|
||||||
|
db_session.add(label)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{card.id}/labels",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"label_id": label.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["card_id"] == card.id
|
||||||
|
assert data["label_id"] == label.id
|
||||||
|
|
||||||
|
def test_add_label_to_card_unauthorized(self, client, db_session, regular_user):
|
||||||
|
"""Test adding a label to card without authentication"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
label = Label(name="Urgent", color="red", board_id=board.id)
|
||||||
|
db_session.add(label)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{card.id}/labels",
|
||||||
|
json={"label_id": label.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_remove_label_from_card_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test removing a label from a card"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Do", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
label = Label(name="Urgent", color="red", board_id=board.id)
|
||||||
|
db_session.add(label)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card_label = CardLabel(card_id=card.id, label_id=label.id)
|
||||||
|
db_session.add(card_label)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/cards/{card.id}/labels/{label.id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert "message" in data
|
||||||
|
|
||||||
|
# Verify card-label association is deleted
|
||||||
|
deleted_card_label = (
|
||||||
|
db.session.query(CardLabel)
|
||||||
|
.filter_by(card_id=card.id, label_id=label.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert deleted_card_label is None
|
||||||
145
backend/tests/routes/test_lists.py
Normal file
145
backend/tests/routes/test_lists.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import Board, List, Card
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestListRoutes:
|
||||||
|
"""Test List API routes"""
|
||||||
|
|
||||||
|
def test_create_list_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a new list in a board"""
|
||||||
|
# Create a board
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{board.id}/lists",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "To Do", "pos": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "To Do"
|
||||||
|
assert data["board_id"] == board.id
|
||||||
|
|
||||||
|
def test_create_list_missing_name(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a list without name"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{board.id}/lists",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"pos": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_create_list_unauthorized(self, client, db_session, regular_user):
|
||||||
|
"""Test creating a list without authentication"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{board.id}/lists",
|
||||||
|
json={"name": "To Do"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_create_list_board_not_found(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test creating a list in a non-existent board"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/boards/99999/lists",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "To Do"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_list_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test updating a list"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="Original", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/lists/{lst.id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Updated Name", "pos": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "Updated Name"
|
||||||
|
assert data["pos"] == 1
|
||||||
|
|
||||||
|
def test_update_list_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test updating a non-existent list"""
|
||||||
|
response = client.put(
|
||||||
|
"/api/lists/99999",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Updated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_list_success(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test deleting a list"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Delete", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.delete(f"/api/lists/{lst.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert "message" in data
|
||||||
|
|
||||||
|
# Verify list is deleted
|
||||||
|
deleted_list = db.session.get(List, lst.id)
|
||||||
|
assert deleted_list is None
|
||||||
|
|
||||||
|
def test_delete_list_not_found(self, client, db_session, auth_headers):
|
||||||
|
"""Test deleting a non-existent list"""
|
||||||
|
response = client.delete("/api/lists/99999", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_list_with_cards(self, client, db_session, regular_user, auth_headers):
|
||||||
|
"""Test deleting a list with cards (cascading delete)"""
|
||||||
|
board = Board(name="Test Board", user_id=regular_user.id)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
lst = List(name="To Delete", board_id=board.id, pos=0)
|
||||||
|
db_session.add(lst)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
card = Card(name="Card", board_id=board.id, list_id=lst.id, pos=0)
|
||||||
|
db_session.add(card)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.delete(f"/api/lists/{lst.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify both list and card are deleted
|
||||||
|
deleted_list = db.session.get(List, lst.id)
|
||||||
|
deleted_card = db.session.get(Card, card.id)
|
||||||
|
assert deleted_list is None
|
||||||
|
assert deleted_card is None
|
||||||
|
|
@ -1,33 +1,31 @@
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: crafting-shop-postgres-dev
|
container_name: kanban-app-postgres-dev
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-crafting}
|
- POSTGRES_USER=${POSTGRES_USER:-kanban}
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-crafting_shop}
|
- POSTGRES_DB=${POSTGRES_DB:-kanban_dev}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-dev-data:/var/lib/postgresql/data
|
- postgres-dev-data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- crafting-shop-network
|
- kanban-app-network
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5451:5432"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: crafting-shop-redis-dev
|
container_name: kanban-app-redis-dev
|
||||||
networks:
|
networks:
|
||||||
- crafting-shop-network
|
- kanban-app-network
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6381:6379"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-dev-data:
|
postgres-dev-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
crafting-shop-network:
|
kanban-app-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
514
docs/kanban_api.md
Normal file
514
docs/kanban_api.md
Normal file
|
|
@ -0,0 +1,514 @@
|
||||||
|
# Kanban API Documentation
|
||||||
|
|
||||||
|
This document describes all the API endpoints for the Kanban application. All endpoints require authentication unless otherwise noted.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
```
|
||||||
|
http://localhost:5000/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All endpoints (except register and login) require a JWT token in the Authorization header:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your_jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boards
|
||||||
|
|
||||||
|
### Get All Boards
|
||||||
|
```
|
||||||
|
GET /api/boards
|
||||||
|
```
|
||||||
|
Get all boards for the current authenticated user.
|
||||||
|
|
||||||
|
**Response:** Array of board objects
|
||||||
|
|
||||||
|
### Get Single Board
|
||||||
|
```
|
||||||
|
GET /api/boards/<board_id>
|
||||||
|
```
|
||||||
|
Get a board with all its details including lists, cards, and labels.
|
||||||
|
|
||||||
|
**Response:** Board object with nested lists, cards, and labels
|
||||||
|
|
||||||
|
### Create Board
|
||||||
|
```
|
||||||
|
POST /api/boards
|
||||||
|
```
|
||||||
|
Create a new board.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "My Project Board",
|
||||||
|
"description": "Project management board",
|
||||||
|
"url": "https://example.com/board/123",
|
||||||
|
"short_link": "abc123",
|
||||||
|
"short_url": "https://example.com/b/abc123",
|
||||||
|
"prefs": {},
|
||||||
|
"label_names": {},
|
||||||
|
"limits": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Created board object
|
||||||
|
|
||||||
|
### Update Board
|
||||||
|
```
|
||||||
|
PUT /api/boards/<board_id>
|
||||||
|
```
|
||||||
|
Update a board.
|
||||||
|
|
||||||
|
**Request Body:** Partial board object (only include fields to update)
|
||||||
|
|
||||||
|
**Response:** Updated board object
|
||||||
|
|
||||||
|
### Delete Board
|
||||||
|
```
|
||||||
|
DELETE /api/boards/<board_id>
|
||||||
|
```
|
||||||
|
Delete a board and all its associated data.
|
||||||
|
|
||||||
|
**Response:** Success message
|
||||||
|
|
||||||
|
## Lists
|
||||||
|
|
||||||
|
### Create List
|
||||||
|
```
|
||||||
|
POST /api/boards/<board_id>/lists
|
||||||
|
```
|
||||||
|
Create a new list in a board.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "To Do",
|
||||||
|
"pos": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Created list object
|
||||||
|
|
||||||
|
### Update List
|
||||||
|
```
|
||||||
|
PUT /api/lists/<list_id>
|
||||||
|
```
|
||||||
|
Update a list.
|
||||||
|
|
||||||
|
**Request Body:** Partial list object
|
||||||
|
|
||||||
|
**Response:** Updated list object
|
||||||
|
|
||||||
|
### Delete List
|
||||||
|
```
|
||||||
|
DELETE /api/lists/<list_id>
|
||||||
|
```
|
||||||
|
Delete a list and all its cards.
|
||||||
|
|
||||||
|
**Response:** Success message
|
||||||
|
|
||||||
|
## Cards
|
||||||
|
|
||||||
|
### Create Card
|
||||||
|
```
|
||||||
|
POST /api/lists/<list_id>/cards
|
||||||
|
```
|
||||||
|
Create a new card in a list.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Task Name",
|
||||||
|
"description": "Task description",
|
||||||
|
"pos": 0,
|
||||||
|
"due": "2026-12-31T23:59:59Z",
|
||||||
|
"due_complete": false,
|
||||||
|
"badges": {},
|
||||||
|
"cover": {},
|
||||||
|
"desc_data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Created card object
|
||||||
|
|
||||||
|
### Get Single Card
|
||||||
|
```
|
||||||
|
GET /api/cards/<card_id>
|
||||||
|
```
|
||||||
|
Get a card with full details including labels, checklists, and comments.
|
||||||
|
|
||||||
|
**Response:** Card object with nested labels, checklists, items, and comments
|
||||||
|
|
||||||
|
### Update Card
|
||||||
|
```
|
||||||
|
PUT /api/cards/<card_id>
|
||||||
|
```
|
||||||
|
Update a card. Can also move card to different list by providing `list_id`.
|
||||||
|
|
||||||
|
**Request Body:** Partial card object
|
||||||
|
|
||||||
|
**Response:** Updated card object
|
||||||
|
|
||||||
|
### Delete Card
|
||||||
|
```
|
||||||
|
DELETE /api/cards/<card_id>
|
||||||
|
```
|
||||||
|
Delete a card and all its associated data.
|
||||||
|
|
||||||
|
**Response:** Success message
|
||||||
|
|
||||||
|
## Labels
|
||||||
|
|
||||||
|
### Get Board Labels
|
||||||
|
```
|
||||||
|
GET /api/boards/<board_id>/labels
|
||||||
|
```
|
||||||
|
Get all labels for a board.
|
||||||
|
|
||||||
|
**Response:** Array of label objects
|
||||||
|
|
||||||
|
### Create Label
|
||||||
|
```
|
||||||
|
POST /api/boards/<board_id>/labels
|
||||||
|
```
|
||||||
|
Create a new label in a board.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Urgent",
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Created label object
|
||||||
|
|
||||||
|
### Add Label to Card
|
||||||
|
```
|
||||||
|
POST /api/cards/<card_id>/labels
|
||||||
|
```
|
||||||
|
Add a label to a card.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Created card-label association object
|
||||||
|
|
||||||
|
### Remove Label from Card
|
||||||
|
```
|
||||||
|
DELETE /api/cards/<card_id>/labels/<label_id>
|
||||||
|
```
|
||||||
|
Remove a label from a card.
|
||||||
|
|
||||||
|
**Response:** Success message
|
||||||
|
|
||||||
|
## Checklists
|
||||||
|
|
||||||
|
### Create Checklist
|
||||||
|
```
|
||||||
|
POST /api/cards/<card_id>/checklists
|
||||||
|
```
|
||||||
|
Create a new checklist in a card.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Tasks",
|
||||||
|
"pos": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Created checklist object
|
||||||
|
|
||||||
|
### Create Check Item
|
||||||
|
```
|
||||||
|
POST /api/checklists/<checklist_id>/items
|
||||||
|
```
|
||||||
|
Create a new check item in a checklist.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Complete task",
|
||||||
|
"pos": 0,
|
||||||
|
"state": "incomplete",
|
||||||
|
"due": "2026-12-31T23:59:59Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Created check item object
|
||||||
|
|
||||||
|
### Update Check Item
|
||||||
|
```
|
||||||
|
PUT /api/check-items/<item_id>
|
||||||
|
```
|
||||||
|
Update a check item (typically used to toggle state).
|
||||||
|
|
||||||
|
**Request Body:** Partial check item object
|
||||||
|
|
||||||
|
**Response:** Updated check item object
|
||||||
|
|
||||||
|
### Delete Checklist
|
||||||
|
```
|
||||||
|
DELETE /api/checklists/<checklist_id>
|
||||||
|
```
|
||||||
|
Delete a checklist and all its items.
|
||||||
|
|
||||||
|
**Response:** Success message
|
||||||
|
|
||||||
|
### Delete Check Item
|
||||||
|
```
|
||||||
|
DELETE /api/check-items/<item_id>
|
||||||
|
```
|
||||||
|
Delete a check item.
|
||||||
|
|
||||||
|
**Response:** Success message
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
### Get Card Comments
|
||||||
|
```
|
||||||
|
GET /api/cards/<card_id>/comments
|
||||||
|
```
|
||||||
|
Get all comments for a card, ordered by creation date (newest first).
|
||||||
|
|
||||||
|
**Response:** Array of comment objects with user information
|
||||||
|
|
||||||
|
### Create Comment
|
||||||
|
```
|
||||||
|
POST /api/cards/<card_id>/comments
|
||||||
|
```
|
||||||
|
Create a new comment on a card.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "This is a comment"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Created comment object
|
||||||
|
|
||||||
|
### Update Comment
|
||||||
|
```
|
||||||
|
PUT /api/comments/<comment_id>
|
||||||
|
```
|
||||||
|
Update a comment (only by the comment author).
|
||||||
|
|
||||||
|
**Request Body:** Partial comment object
|
||||||
|
|
||||||
|
**Response:** Updated comment object
|
||||||
|
|
||||||
|
### Delete Comment
|
||||||
|
```
|
||||||
|
DELETE /api/comments/<comment_id>
|
||||||
|
```
|
||||||
|
Delete a comment (only by the comment author).
|
||||||
|
|
||||||
|
**Response:** Success message
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Board
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "My Board",
|
||||||
|
"description": "Board description",
|
||||||
|
"closed": false,
|
||||||
|
"url": "https://example.com",
|
||||||
|
"short_link": "abc123",
|
||||||
|
"short_url": "https://example.com/b/abc123",
|
||||||
|
"user_id": 1,
|
||||||
|
"date_last_activity": "2026-01-01T00:00:00Z",
|
||||||
|
"date_last_view": "2026-01-01T00:00:00Z",
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"prefs": {},
|
||||||
|
"label_names": {},
|
||||||
|
"limits": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "To Do",
|
||||||
|
"closed": false,
|
||||||
|
"pos": 0,
|
||||||
|
"board_id": 1,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Card
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Task Name",
|
||||||
|
"description": "Task description",
|
||||||
|
"closed": false,
|
||||||
|
"due": "2026-12-31T23:59:59Z",
|
||||||
|
"due_complete": false,
|
||||||
|
"pos": 0,
|
||||||
|
"id_short": 1,
|
||||||
|
"board_id": 1,
|
||||||
|
"list_id": 1,
|
||||||
|
"date_last_activity": "2026-01-01T00:00:00Z",
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"badges": {},
|
||||||
|
"cover": {},
|
||||||
|
"desc_data": {},
|
||||||
|
"labels": [],
|
||||||
|
"checklists": [],
|
||||||
|
"comments": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Label
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Urgent",
|
||||||
|
"color": "red",
|
||||||
|
"uses": 0,
|
||||||
|
"board_id": 1,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Tasks",
|
||||||
|
"pos": 0,
|
||||||
|
"board_id": 1,
|
||||||
|
"card_id": 1,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Item
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Complete task",
|
||||||
|
"pos": 0,
|
||||||
|
"state": "incomplete",
|
||||||
|
"due": "2026-12-31T23:59:59Z",
|
||||||
|
"checklist_id": 1,
|
||||||
|
"user_id": 1,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comment
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"text": "Comment text",
|
||||||
|
"card_id": 1,
|
||||||
|
"user_id": 1,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"user": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Response Codes
|
||||||
|
|
||||||
|
- `200 OK` - Request successful
|
||||||
|
- `201 Created` - Resource created successfully
|
||||||
|
- `400 Bad Request` - Invalid request data
|
||||||
|
- `401 Unauthorized` - Authentication required or invalid
|
||||||
|
- `403 Forbidden` - Access denied (not your resource)
|
||||||
|
- `404 Not Found` - Resource not found
|
||||||
|
- `500 Internal Server Error` - Server error
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Creating a complete board with lists and cards
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Login to get token
|
||||||
|
curl -X POST http://localhost:5000/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email": "user@example.com", "password": "password"}'
|
||||||
|
|
||||||
|
# 2. Create a board
|
||||||
|
curl -X POST http://localhost:5000/api/boards \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"name": "My Project"}'
|
||||||
|
|
||||||
|
# 3. Create a list
|
||||||
|
curl -X POST http://localhost:5000/api/boards/1/lists \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"name": "To Do", "pos": 0}'
|
||||||
|
|
||||||
|
# 4. Create a card
|
||||||
|
curl -X POST http://localhost:5000/api/lists/1/cards \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"name": "Task 1", "pos": 0}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding labels to a card
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create a label
|
||||||
|
curl -X POST http://localhost:5000/api/boards/1/labels \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"name": "Urgent", "color": "red"}'
|
||||||
|
|
||||||
|
# 2. Add label to card
|
||||||
|
curl -X POST http://localhost:5000/api/cards/1/labels \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"label_id": 1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating a checklist with items
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create checklist
|
||||||
|
curl -X POST http://localhost:5000/api/cards/1/checklists \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"name": "Subtasks", "pos": 0}'
|
||||||
|
|
||||||
|
# 2. Add check items
|
||||||
|
curl -X POST http://localhost:5000/api/checklists/1/items \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"name": "Task 1", "pos": 0}'
|
||||||
|
|
||||||
|
# 3. Mark item as complete
|
||||||
|
curl -X PUT http://localhost:5000/api/check-items/1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"state": "complete"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding comments
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5000/api/cards/1/comments \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"text": "This is a comment"}'
|
||||||
300
docs/kanban_implementation_summary.md
Normal file
300
docs/kanban_implementation_summary.md
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
# Kanban Backend Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document summarizes the Kanban backend implementation for the project. The backend provides a complete API for managing Kanban boards, lists, cards, labels, checklists, and comments.
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Tables Created
|
||||||
|
|
||||||
|
1. **boards** - Main board entities
|
||||||
|
- id, name, description, closed, url, short_link, short_url
|
||||||
|
- user_id (foreign key to users)
|
||||||
|
- date_last_activity, date_last_view, created_at, updated_at
|
||||||
|
- JSONB fields: prefs, label_names, limits
|
||||||
|
- Relationships: lists, cards, labels
|
||||||
|
|
||||||
|
2. **lists** - Board columns/lists
|
||||||
|
- id, name, closed, pos
|
||||||
|
- board_id (foreign key to boards)
|
||||||
|
- created_at, updated_at
|
||||||
|
- Relationships: cards
|
||||||
|
|
||||||
|
3. **cards** - Task cards within lists
|
||||||
|
- id, name, description, closed, due, due_complete, pos, id_short
|
||||||
|
- board_id (foreign key to boards), list_id (foreign key to lists)
|
||||||
|
- date_last_activity, created_at, updated_at
|
||||||
|
- JSONB fields: badges, cover, desc_data
|
||||||
|
- Relationships: checklists, labels, comments
|
||||||
|
|
||||||
|
4. **labels** - Color-coded tags
|
||||||
|
- id, name, color, uses
|
||||||
|
- board_id (foreign key to boards)
|
||||||
|
- created_at, updated_at
|
||||||
|
- Relationships: card_labels
|
||||||
|
|
||||||
|
5. **card_labels** - Many-to-many relationship between cards and labels
|
||||||
|
- id, card_id, label_id
|
||||||
|
- Unique constraint on (card_id, label_id)
|
||||||
|
|
||||||
|
6. **checklists** - Checklists within cards
|
||||||
|
- id, name, pos
|
||||||
|
- board_id (foreign key to boards), card_id (foreign key to cards)
|
||||||
|
- created_at, updated_at
|
||||||
|
- Relationships: check_items
|
||||||
|
|
||||||
|
7. **check_items** - Items within checklists
|
||||||
|
- id, name, pos, state, due
|
||||||
|
- checklist_id (foreign key to checklists), user_id (foreign key to users)
|
||||||
|
- created_at, updated_at
|
||||||
|
|
||||||
|
8. **comments** - Comments on cards
|
||||||
|
- id, text
|
||||||
|
- card_id (foreign key to cards), user_id (foreign key to users)
|
||||||
|
- created_at, updated_at
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Boards (`/api/boards`)
|
||||||
|
- `GET /api/boards` - Get all boards for current user
|
||||||
|
- `GET /api/boards/<id>` - Get board with full details (lists, cards, labels)
|
||||||
|
- `POST /api/boards` - Create new board
|
||||||
|
- `PUT /api/boards/<id>` - Update board
|
||||||
|
- `DELETE /api/boards/<id>` - Delete board
|
||||||
|
|
||||||
|
### Lists (`/api/lists`, `/api/boards/<id>/lists`)
|
||||||
|
- `POST /api/boards/<board_id>/lists` - Create list in board
|
||||||
|
- `PUT /api/lists/<id>` - Update list
|
||||||
|
- `DELETE /api/lists/<id>` - Delete list
|
||||||
|
|
||||||
|
### Cards (`/api/cards`, `/api/lists/<id>/cards`)
|
||||||
|
- `POST /api/lists/<list_id>/cards` - Create card in list
|
||||||
|
- `GET /api/cards/<id>` - Get card with full details
|
||||||
|
- `PUT /api/cards/<id>` - Update card (includes moving between lists)
|
||||||
|
- `DELETE /api/cards/<id>` - Delete card
|
||||||
|
|
||||||
|
### Labels (`/api/boards/<id>/labels`, `/api/cards/<id>/labels`)
|
||||||
|
- `GET /api/boards/<board_id>/labels` - Get all labels for board
|
||||||
|
- `POST /api/boards/<board_id>/labels` - Create label
|
||||||
|
- `POST /api/cards/<card_id>/labels` - Add label to card
|
||||||
|
- `DELETE /api/cards/<card_id>/labels/<label_id>` - Remove label from card
|
||||||
|
|
||||||
|
### Checklists (`/api/checklists`, `/api/cards/<id>/checklists`)
|
||||||
|
- `POST /api/cards/<card_id>/checklists` - Create checklist
|
||||||
|
- `DELETE /api/checklists/<id>` - Delete checklist
|
||||||
|
|
||||||
|
### Check Items (`/api/check-items`, `/api/checklists/<id>/items`)
|
||||||
|
- `POST /api/checklists/<checklist_id>/items` - Create check item
|
||||||
|
- `PUT /api/check-items/<id>` - Update check item
|
||||||
|
- `DELETE /api/check-items/<id>` - Delete check item
|
||||||
|
|
||||||
|
### Comments (`/api/comments`, `/api/cards/<id>/comments`)
|
||||||
|
- `GET /api/cards/<card_id>/comments` - Get all comments for card
|
||||||
|
- `POST /api/cards/<card_id>/comments` - Create comment
|
||||||
|
- `PUT /api/comments/<id>` - Update comment
|
||||||
|
- `DELETE /api/comments/<id>` - Delete comment
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- All endpoints (except register/login) require JWT authentication
|
||||||
|
- User can only access their own boards and related resources
|
||||||
|
- Comments can only be edited/deleted by their author
|
||||||
|
|
||||||
|
### Data Integrity
|
||||||
|
- Cascading deletes ensure cleanup of related data
|
||||||
|
- Foreign key constraints maintain referential integrity
|
||||||
|
- Unique constraints prevent duplicate card-label associations
|
||||||
|
|
||||||
|
### Position Tracking
|
||||||
|
- All orderable entities (lists, cards, checklists, check items) have `pos` field
|
||||||
|
- Enables flexible sorting and drag-and-drop functionality
|
||||||
|
|
||||||
|
### Activity Tracking
|
||||||
|
- Boards track `date_last_activity` and `date_last_view`
|
||||||
|
- Cards track `date_last_activity`
|
||||||
|
- Useful for showing recent activity
|
||||||
|
|
||||||
|
### JSONB Fields
|
||||||
|
- Flexible storage for complex data (prefs, limits, badges, cover, desc_data)
|
||||||
|
- Allows extensibility without schema changes
|
||||||
|
- Supports Trello-like feature parity
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/
|
||||||
|
├── models/
|
||||||
|
│ ├── board.py # Board model
|
||||||
|
│ ├── list_model.py # List model (named to avoid Python conflict)
|
||||||
|
│ ├── card.py # Card model
|
||||||
|
│ ├── label.py # Label model
|
||||||
|
│ ├── card_label.py # Card-Label junction table
|
||||||
|
│ ├── checklist.py # Checklist model
|
||||||
|
│ ├── check_item.py # CheckItem model
|
||||||
|
│ ├── comment.py # Comment model
|
||||||
|
│ └── user.py # Updated with boards relationship
|
||||||
|
├── routes/
|
||||||
|
│ └── kanban.py # All Kanban API routes
|
||||||
|
└── __init__.py # Updated to import models and register blueprint
|
||||||
|
|
||||||
|
docs/
|
||||||
|
├── kanban_api.md # Complete API documentation
|
||||||
|
└── kanban_implementation_summary.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Migration file: `backend/migrations/versions/1c0b9dfbd933_add_kanban_models_board_list_card_label_.py`
|
||||||
|
|
||||||
|
To apply migrations:
|
||||||
|
```bash
|
||||||
|
cd backend && . venv/bin/activate && flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Flow
|
||||||
|
|
||||||
|
### Typical User Workflow
|
||||||
|
|
||||||
|
1. **Register/Login**
|
||||||
|
- User registers account or logs in
|
||||||
|
- Receives JWT token for authentication
|
||||||
|
|
||||||
|
2. **Create Board**
|
||||||
|
- User creates a new board
|
||||||
|
- Board is associated with their user ID
|
||||||
|
|
||||||
|
3. **Add Lists**
|
||||||
|
- User adds lists (columns) to the board
|
||||||
|
- Examples: "To Do", "In Progress", "Done"
|
||||||
|
|
||||||
|
4. **Add Cards**
|
||||||
|
- User creates cards within lists
|
||||||
|
- Cards can have descriptions, due dates, etc.
|
||||||
|
|
||||||
|
5. **Enhance Cards**
|
||||||
|
- Add labels for categorization
|
||||||
|
- Add checklists for subtasks
|
||||||
|
- Add comments for collaboration
|
||||||
|
|
||||||
|
6. **Manage Work**
|
||||||
|
- Move cards between lists (drag-and-drop)
|
||||||
|
- Update card details
|
||||||
|
- Mark checklist items as complete
|
||||||
|
- Delete completed items
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
### Single User App
|
||||||
|
- No organization or membership models (as specified)
|
||||||
|
- Each board belongs to exactly one user
|
||||||
|
- Simplifies permissions model
|
||||||
|
|
||||||
|
### Trello-Inspired Schema
|
||||||
|
- Uses similar field names and structure as Trello
|
||||||
|
- Makes it familiar to users
|
||||||
|
- Supports importing from Trello JSON exports
|
||||||
|
|
||||||
|
### Position-Based Ordering
|
||||||
|
- Uses float `pos` field for ordering
|
||||||
|
- Allows inserting items between others
|
||||||
|
- Supports infinite granularity for drag-and-drop
|
||||||
|
|
||||||
|
### Cascading Deletes
|
||||||
|
- Deleting a board deletes all its lists, cards, labels, etc.
|
||||||
|
- Deleting a list deletes all its cards
|
||||||
|
- Ensures no orphaned data
|
||||||
|
|
||||||
|
### JSONB for Flexible Data
|
||||||
|
- Stores complex nested data without normalized tables
|
||||||
|
- Simplifies schema for optional features
|
||||||
|
- Maintains flexibility for future enhancements
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] Add validation schemas (Pydantic)
|
||||||
|
- [ ] Add comprehensive tests
|
||||||
|
- [ ] Add rate limiting
|
||||||
|
- [ ] Add file upload support for attachments
|
||||||
|
- [ ] Add activity logging/history
|
||||||
|
- [ ] Add search functionality
|
||||||
|
- [ ] Add filtering and sorting options
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [ ] Create board list view
|
||||||
|
- [ ] Create board detail view with drag-and-drop
|
||||||
|
- [ ] Implement card creation/editing
|
||||||
|
- [ ] Implement label management
|
||||||
|
- [ ] Implement checklist functionality
|
||||||
|
- [ ] Implement comments
|
||||||
|
- [ ] Add real-time updates (WebSocket)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Test the API using the examples in `docs/kanban_api.md` or use tools like:
|
||||||
|
- Postman
|
||||||
|
- Insomnia
|
||||||
|
- curl (command line)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
# Start the backend server
|
||||||
|
make dev-services # Start postgres & redis
|
||||||
|
make dev-backend # Start Flask server
|
||||||
|
|
||||||
|
# Test endpoints
|
||||||
|
curl http://localhost:5000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- JWT authentication required for all operations
|
||||||
|
- User isolation: users can only access their own resources
|
||||||
|
- SQL injection prevention through SQLAlchemy ORM
|
||||||
|
- Input validation on all endpoints
|
||||||
|
- CORS configured for frontend integration
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
- Indexed fields: user_id, board_id, list_id, card_id, etc.
|
||||||
|
- Lazy loading relationships to avoid N+1 queries
|
||||||
|
- Efficient queries using SQLAlchemy's query builder
|
||||||
|
- JSONB fields use PostgreSQL's optimized JSON storage
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Migration Issues
|
||||||
|
```bash
|
||||||
|
# Reset migrations (WARNING: deletes data)
|
||||||
|
cd backend && . venv/bin/activate && flask db downgrade base
|
||||||
|
rm -rf migrations/versions/*.py
|
||||||
|
flask db migrate -m "Initial migration"
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
```bash
|
||||||
|
# Restart services
|
||||||
|
make dev-stop-services
|
||||||
|
make dev-services
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Errors
|
||||||
|
```bash
|
||||||
|
# Ensure venv is activated
|
||||||
|
cd backend && . venv/bin/activate
|
||||||
|
pip install -r requirements/dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The Kanban backend is now fully implemented with:
|
||||||
|
- ✅ Complete database schema
|
||||||
|
- ✅ RESTful API endpoints
|
||||||
|
- ✅ Authentication and authorization
|
||||||
|
- ✅ Data integrity and validation
|
||||||
|
- ✅ Comprehensive documentation
|
||||||
|
- ✅ Database migration
|
||||||
|
|
||||||
|
The backend is ready for frontend integration and testing.
|
||||||
Loading…
Reference in a new issue