add initial kanban models and apis

This commit is contained in:
david 2026-02-25 19:48:18 +03:00
parent 861160566c
commit 3abd029ab5
27 changed files with 3315 additions and 50 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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",
]

View 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}>"

View 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}>"

View 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}>"

View 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})>"

View 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}>"

View 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}>"

View 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})>"

View 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}>"

View file

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

View 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

View file

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

View file

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

View file

@ -0,0 +1 @@
# Routes test package

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -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
View 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"}'

View 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.