Compare commits

..

1 commit

Author SHA1 Message Date
555a2ccf6d style frontend components 2026-03-15 21:40:52 +03:00
169 changed files with 2156 additions and 14438 deletions

View file

@ -17,16 +17,6 @@ GRAFANA_PASSWORD=change-this-password-in-production
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
# MinIO Configuration (Object Storage)
# MinIO server stays hidden - Flask proxies all requests
# MINIO_ENDPOINT: Internal Docker network address (for server-to-server communication)
MINIO_ENDPOINT=minio:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_USE_SSL=false
# Note: Thumbnails are served via /api/files/:id/proxy-thumbnail (no auth)
# Full files require JWT authentication via /api/files/:id/download
# Optional: External Services
# REDIS_URL=redis://localhost:6379/0
# SMTP_HOST=smtp.gmail.com

View file

@ -15,8 +15,6 @@ on:
jobs:
backend-test:
runs-on: [docker]
env:
UNIQUE_DB: test_db_${{ github.run_id }}
services:
postgres:
@ -24,7 +22,7 @@ jobs:
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db_${{ github.run_id }}_${{ github.run_attempt }}
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
@ -48,27 +46,6 @@ jobs:
python -m pip install --upgrade pip
pip install --cache-dir /tmp/pip-cache -r requirements/dev.txt
- name: Create Unique Test Database
env:
DATABASE_URL: postgresql://test:test@postgres:5432/postgres
run: |
# Install postgresql-client if not present in the alpine image to run psql
# Or use python to create the db
cd backend
python -c "
import os
from sqlalchemy import create_engine, text
db_url = os.environ['DATABASE_URL']
engine = create_engine(db_url)
new_db = os.environ['UNIQUE_DB']
# Connect to default 'postgres' db to create the new one
with engine.connect() as conn:
conn.execute(text('COMMIT')) # Close any open transactions
conn.execute(text(f'DROP DATABASE IF EXISTS {new_db}'))
conn.execute(text(f'CREATE DATABASE {new_db}'))
print(f'Created database: {new_db}')
"
- name: Debug cache
run: |
echo "Listing PIP cache files:"
@ -82,8 +59,8 @@ jobs:
- name: Run tests
env:
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db
DATABASE_URL: postgresql://test:test@postgres:5432/test_db
SECRET_KEY: test-secret-key
JWT_SECRET_KEY: test-jwt-secret
FLASK_ENV: test

2
.gitignore vendored
View file

@ -84,5 +84,3 @@ htmlcov/
celerybeat-schedule
backend/app/static
.clinerules

View file

@ -21,10 +21,6 @@ install: ## Install dependencies
@echo "Installing frontend dependencies..."
cd frontend && npm install
dev-backend-install:
@echo "Installing backend dependencies..."
. backend/venv/bin/activate && pip install -r backend/requirements/dev.txt
dev-services: ## Start development services (postgres & redis only)
@echo "Starting development services (postgres & redis)..."
docker compose -f docker-compose.dev.yml up -d
@ -66,9 +62,6 @@ restart: ## Restart all services
logs: ## Show logs from all services
docker compose logs -f
dev-logs: ## Show logs from all services
docker compose -f docker-compose.dev.yml logs
test: ## Run all tests
@echo "Running backend tests..."
cd backend && . venv/bin/activate && pytest
@ -136,7 +129,7 @@ format-frontend: ## Format frontend code only
@echo "Formatting frontend..."
cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}"
backend-upgrade: ## Run database migrations
migrate: ## Run database migrations
cd backend && . venv/bin/activate && flask db upgrade
shell: ## Open Flask shell

View file

@ -9,8 +9,3 @@ TEST_DATABASE_URL=sqlite:///test.db
# Celery Configuration
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
# Minio configuration
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin

View file

@ -40,13 +40,6 @@ def create_app(config_name=None):
app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}}
)
# Import storage extension
from app.services.storage.storage_extension import storage
if not (app.config.get("TESTING") or app.config.get("USE_MOCK_STORAGE")):
# Initialize storage extension (MinIO)
storage.init_app(app)
# Initialize Celery
from app.celery import init_celery

View file

@ -8,7 +8,7 @@ class Config:
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=1)
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=12)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
@ -39,18 +39,6 @@ class Config:
"pool_pre_ping": True, # Verify connections before using
}
# MinIO Configuration
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio:9000")
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
MINIO_REGION = os.environ.get("MINIO_REGION", "garage")
MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "true").lower() == "true"
# MinIO Buckets
MINIO_IMAGES_BUCKET = "kanban-images"
MINIO_DOCUMENTS_BUCKET = "kanban-documents"
MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails"
class DevelopmentConfig(Config):
"""Development configuration"""
@ -58,68 +46,23 @@ class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
# MinIO Configuration for Development
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio:9000")
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "false").lower() == "true"
MINIO_IMAGES_BUCKET = "kanban-images"
MINIO_DOCUMENTS_BUCKET = "kanban-documents"
MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails"
MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024
MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024
ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
]
ALLOWED_DOCUMENT_TYPES = ["application/pdf"]
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
USE_MOCK_STORAGE = True # Use in-memory mock storage for tests
SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL")
WTF_CSRF_ENABLED = False
# Conservative connection pool settings for testing
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 2, # Only one connection in the pool
"max_overflow": 2, # No overflow connections allowed
"pool_size": 1, # Only one connection in the pool
"max_overflow": 0, # No overflow connections allowed
"pool_timeout": 30,
"pool_recycle": 3600, # Recycle after 1 hour
"pool_pre_ping": True, # Verify connections before using
}
# MinIO configuration (not used in tests due to USE_MOCK_STORAGE=True)
MINIO_ENDPOINT = "minio:9000"
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "false").lower() == "true"
# Bucket names (required for FileService even with mock storage)
MINIO_IMAGES_BUCKET = "kanban-images"
MINIO_DOCUMENTS_BUCKET = "kanban-documents"
MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails"
# File upload limits
MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024 # 10MB
MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024 # 50MB
# Allowed file types
ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
]
ALLOWED_DOCUMENT_TYPES = ["application/pdf"]
class ProductionConfig(Config):
"""Production configuration"""
@ -132,31 +75,6 @@ class ProductionConfig(Config):
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
# MinIO Configuration
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio:9000")
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "true").lower() == "true"
# MinIO Buckets
MINIO_IMAGES_BUCKET = "kanban-images"
MINIO_DOCUMENTS_BUCKET = "kanban-documents"
MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails"
# File Upload Limits (in bytes)
MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024 # 10MB
MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024 # 50MB
# Allowed File Types
ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
]
ALLOWED_DOCUMENT_TYPES = ["application/pdf"]
config_by_name = {
"dev": DevelopmentConfig,

View file

@ -1,10 +1,17 @@
from functools import wraps
from flask import abort
from flask import abort, g
from flask_jwt_extended import get_jwt_identity
from app.decorators.decorators import get_current_user_id
from app.models import (Board, Card, CheckItem, Checklist, Comment,
FileAttachment, List)
from app.models import Board, Card, CheckItem, Checklist, Comment, List
def get_current_user_id():
"""Helper to consistently get user ID"""
# Cache in g if you want to avoid decoding JWT multiple times per request
if not hasattr(g, "jwt_identity"):
g.jwt_identity = int(get_jwt_identity())
return g.jwt_identity
def load_board_owned(f):
@ -20,7 +27,7 @@ def load_board_owned(f):
board_id = kwargs.get("board_id")
# SECURE QUERY: Filter by ID *and* User ID in the DB
board = Board.active().filter_by(id=board_id, user_id=user_id).first()
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
if not board:
abort(404)
@ -35,7 +42,6 @@ def load_card_owned(f):
"""
Loads a Card and ensures its Parent Board belongs to the current user.
Injects 'card' into the route kwargs.
Aborts with 404 if not found, not owned, or soft-deleted.
"""
@wraps(f)
@ -43,10 +49,9 @@ def load_card_owned(f):
user_id = get_current_user_id()
card_id = kwargs.get("card_id")
# Join Board to check ownership and filter soft-deleted cards
# Join Board to check ownership securely in one query
card = (
Card.active()
.join(Board)
Card.query.join(Board)
.filter(Card.id == card_id, Board.user_id == user_id)
.first()
)
@ -69,8 +74,7 @@ def load_list_owned(f):
list_id = kwargs.get("list_id")
lst = (
List.active()
.join(Board)
List.query.join(Board)
.filter(List.id == list_id, Board.user_id == user_id)
.first()
)
@ -93,8 +97,7 @@ def load_checklist_owned(f):
checklist_id = kwargs.get("checklist_id")
checklist = (
Checklist.active()
.join(Card)
Checklist.query.join(Card)
.join(Board)
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
.first()
@ -118,8 +121,7 @@ def load_check_item_owned(f):
item_id = kwargs.get("item_id")
check_item = (
CheckItem.active()
.join(Checklist)
CheckItem.query.join(Checklist)
.join(Card)
.join(Board)
.filter(CheckItem.id == item_id, Board.user_id == user_id)
@ -146,7 +148,7 @@ def load_comment_owned(f):
user_id = get_current_user_id()
comment_id = kwargs.get("comment_id")
comment = Comment.active().filter_by(id=comment_id, user_id=user_id).first()
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
if not comment:
abort(404)
@ -155,28 +157,3 @@ def load_comment_owned(f):
return f(*args, **kwargs)
return decorated_function
def load_file_owned(f):
"""
Loads a FileAttachment ensuring it belongs to the user.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Filter by ID and user ID
attachment = (
FileAttachment.active().filter_by(id=file_id, uploaded_by=user_id).first()
)
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function

View file

@ -1,21 +0,0 @@
from app.decorators.owned import (load_board_owned, load_card_owned,
load_check_item_owned, load_checklist_owned,
load_comment_owned, load_file_owned,
load_list_owned)
from .decorators import get_current_user_id, load_file_accessible_by_uuid
from .schema_validations import serialize_list_response, serialize_response
__all__ = [
"load_board_owned",
"load_card_owned",
"load_list_owned",
"load_checklist_owned",
"load_check_item_owned",
"load_comment_owned",
"load_file_owned",
"get_current_user_id",
"load_file_accessible_by_uuid",
"serialize_response",
"serialize_list_response",
]

View file

@ -1,158 +0,0 @@
from functools import wraps
from flask import abort, g
from flask_jwt_extended import get_jwt_identity
from app.models import Board, Card, Comment, FileAttachment
def get_current_user_id():
"""Helper to consistently get user ID"""
# Cache in g if you want to avoid decoding JWT multiple times per request
if not hasattr(g, "jwt_identity"):
g.jwt_identity = int(get_jwt_identity())
return g.jwt_identity
def load_file_accessible(f):
"""
Loads a FileAttachment ensuring the user can access it.
User can access if:
1. They uploaded the file, OR
2. The file is attached to an entity they own (via Board ownership)
Aborts with 404 if not found or not accessible.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Try to find active file uploaded by user
attachment = (
FileAttachment.active().filter_by(id=file_id, uploaded_by=user_id).first()
)
# If not found, check if attached to a Card that belongs to user's board
if not attachment:
# For Card attachments (only active files)
card_attachment = (
FileAttachment.active()
.join(
Card,
(FileAttachment.attachable_type == "Card")
& (FileAttachment.attachable_id == Card.id),
)
.join(Board)
.filter(
FileAttachment.id == file_id,
Board.user_id == user_id,
)
.first()
)
if card_attachment:
attachment = card_attachment
# If still not found, check if attached
# to a Comment that belongs to user's board
if not attachment:
# For Comment attachments (only active files)
comment_attachment = (
FileAttachment.active()
.join(
Comment,
(FileAttachment.attachable_type == "Comment")
& (FileAttachment.attachable_id == Comment.id),
)
.join(Card)
.join(Board)
.filter(
FileAttachment.id == file_id,
Board.user_id == user_id,
)
.first()
)
if comment_attachment:
attachment = comment_attachment
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function
def load_file_accessible_by_uuid(f):
"""
Loads a FileAttachment by UUID ensuring the user can access it.
User can access if:
1. They uploaded the file, OR
2. The file is attached to an entity they own (via Board ownership)
Aborts with 404 if not found or not accessible.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_uuid = kwargs.get("file_uuid")
# Try to find active file uploaded by user
attachment = (
FileAttachment.active()
.filter_by(uuid=file_uuid, uploaded_by=user_id)
.first()
)
# If not found, check if attached to a Card that belongs to user's board
if not attachment:
# For Card attachments (only active files)
card_attachment = (
FileAttachment.active()
.join(
Card,
(FileAttachment.attachable_type == "Card")
& (FileAttachment.attachable_id == Card.id),
)
.join(Board)
.filter(
FileAttachment.uuid == file_uuid,
Board.user_id == user_id,
)
.first()
)
if card_attachment:
attachment = card_attachment
# If still not found, check if attached to a
# Comment that belongs to user's board
if not attachment:
# For Comment attachments (only active files)
comment_attachment = (
FileAttachment.active()
.join(
Comment,
(FileAttachment.attachable_type == "Comment")
& (FileAttachment.attachable_id == Comment.id),
)
.join(Card)
.join(Board)
.filter(
FileAttachment.uuid == file_uuid,
Board.user_id == user_id,
)
.first()
)
if comment_attachment:
attachment = comment_attachment
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function

View file

@ -1,38 +0,0 @@
from functools import wraps
from flask import jsonify
def serialize_response(model):
"""Decorator to serialize single entity response"""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
result, status = f(*args, **kwargs)
if status not in [200, 201]:
return result, status
return jsonify(model.model_validate(result).model_dump()), status
return wrapper
return decorator
def serialize_list_response(model):
"""Decorator to serialize list response"""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
results, status = f(*args, **kwargs)
if status not in [200, 201]:
return results, status
return (
jsonify([model.model_validate(item).model_dump() for item in results]),
status,
)
return wrapper
return decorator

View file

@ -1,34 +1,26 @@
# fmt: off
from app.models.base import SoftDeleteMixin
from app.models.board import Board
from app.models.card import Card
from app.models.card_label import CardLabel
from app.models.card_link import CardLink
from app.models.check_item import CheckItem
from app.models.checklist import Checklist
from app.models.comment import Comment
from app.models.epic import Epic
from app.models.file_attachment import FileAttachment
from app.models.label import Label
from app.models.list_model import List
from app.models.order import Order, OrderItem
from app.models.product import Product
from app.models.user import User
from app.models.wiki import Wiki, wiki_entity_links
__all__ = [
"SoftDeleteMixin",
"User",
"Product",
"Order",
"OrderItem",
"Board",
"List",
"Card",
"Label",
"CardLabel",
"CardLink",
"Checklist",
"CheckItem",
"Comment",
"FileAttachment",
"Epic",
"Wiki",
"wiki_entity_links",
]
# fmt: on

View file

@ -1,137 +0,0 @@
from datetime import UTC, datetime
from app import db
class SoftDeleteMixin:
"""Mixin that provides soft-delete functionality.
Instead of removing rows from the database, records are marked with
status='deleted' and a deleted_at timestamp. This preserves data
integrity and supports audit/recovery use-cases.
Usage:
class MyModel(db.Model, SoftDeleteMixin):
...
# Queries
MyModel.active().filter_by(name="foo").all()
# Soft-delete
instance = db.session.get(MyModel, 1)
instance.soft_delete()
db.session.commit()
# Restore
instance.restore()
db.session.commit()
For relationship filtering:
class Parent(db.Model, SoftDeleteMixin):
children = db.relationship(
"Child",
primaryjoin="and_(Parent.id == Child.parent_id, "
"Child.status == 'active')",
...
)
"""
STATUS_ACTIVE = "active"
STATUS_DELETED = "deleted"
status = db.Column(
db.String(20),
default=STATUS_ACTIVE,
nullable=False,
index=True,
)
deleted_at = db.Column(db.DateTime, nullable=True)
@classmethod
def active(cls):
"""Return a query scoped to non-deleted records only.
Usage:
Board.active().filter_by(user_id=1).all()
Card.active().order_by(Card.pos).all()
"""
return cls.query.filter(cls.status == cls.STATUS_ACTIVE)
@property
def is_active(self):
"""Check if this record is active (not soft-deleted)."""
return self.status == self.STATUS_ACTIVE
def soft_delete(self):
"""Mark this record as deleted and cascade to child relationships.
Every relationship defined with cascade="all, delete-orphan" or
cascade="all" is considered a *soft-deletable child* and will also
be soft-deleted recursively.
"""
self._do_soft_delete()
def _do_soft_delete(self):
"""Internal recursive soft-delete implementation."""
now = datetime.now(UTC)
# Soft-delete children from configured cascade relationships
for prop in self.__mapper__.relationships:
cascade = prop.cascade
if cascade is None:
continue
# Only cascade to relationships that had delete/delete-orphan
if not (cascade.delete or cascade.delete_orphan):
continue
# Get the related objects
children = getattr(self, prop.key, None)
if children is None:
continue
# Handle dynamic/lazy relationships (query-like)
if hasattr(children, "all"):
children = children.all()
# Handle single (many-to-one / scalar) relationships
if not isinstance(children, list):
children = [children] if children else []
for child in children:
if isinstance(child, SoftDeleteMixin):
child._do_soft_delete()
self.status = self.STATUS_DELETED
self.deleted_at = now
def restore(self):
"""Restore a soft-deleted record and cascade to children."""
self._do_restore()
def _do_restore(self):
"""Internal recursive restore implementation."""
for prop in self.__mapper__.relationships:
cascade = prop.cascade
if cascade is None:
continue
if not (cascade.delete or cascade.delete_orphan):
continue
children = getattr(self, prop.key, None)
if children is None:
continue
if hasattr(children, "all"):
children = children.all()
if not isinstance(children, list):
children = [children] if children else []
for child in children:
if isinstance(child, SoftDeleteMixin):
child._do_restore()
self.status = self.STATUS_ACTIVE
self.deleted_at = None

View file

@ -3,10 +3,9 @@ from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB
from app import db
from app.models.base import SoftDeleteMixin
class Board(db.Model, SoftDeleteMixin):
class Board(db.Model):
"""Board model for Kanban boards"""
__tablename__ = "boards"
@ -42,27 +41,15 @@ class Board(db.Model, SoftDeleteMixin):
label_names = db.Column(JSONB) # label color mappings
limits = db.Column(JSONB) # various limits
# Relationships - only active records
# Relationships
lists = db.relationship(
"List",
backref="board",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Board.id == List.board_id, List.status == 'active')",
"List", backref="board", cascade="all, delete-orphan", lazy="dynamic"
)
cards = db.relationship(
"Card",
backref="board",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Board.id == Card.board_id, Card.status == 'active')",
"Card", backref="board", cascade="all, delete-orphan", lazy="dynamic"
)
labels = db.relationship(
"Label",
backref="board",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Board.id == Label.board_id, Label.status == 'active')",
"Label", backref="board", cascade="all, delete-orphan", lazy="dynamic"
)
def to_dict(self):
@ -87,8 +74,6 @@ class Board(db.Model, SoftDeleteMixin):
"prefs": self.prefs,
"label_names": self.label_names,
"limits": self.limits,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
def __repr__(self):

View file

@ -3,10 +3,9 @@ from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB
from app import db
from app.models.base import SoftDeleteMixin
class Card(db.Model, SoftDeleteMixin):
class Card(db.Model):
"""Card model for Kanban cards"""
__tablename__ = "cards"
@ -33,9 +32,6 @@ class Card(db.Model, SoftDeleteMixin):
nullable=False,
index=True,
)
epic_id = db.Column(
db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL"), index=True
)
# Timestamps
date_last_activity = db.Column(db.DateTime)
@ -51,61 +47,20 @@ class Card(db.Model, SoftDeleteMixin):
cover = db.Column(JSONB) # cover settings
desc_data = db.Column(JSONB)
# Relationships - only active records
# Relationships
checklists = db.relationship(
"Checklist",
backref="card",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Card.id == Checklist.card_id, Checklist.status == 'active')",
"Checklist", backref="card", cascade="all, delete-orphan", lazy="dynamic"
)
labels = db.relationship(
"CardLabel",
backref="card",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Card.id == CardLabel.card_id, CardLabel.status == 'active')",
"CardLabel", backref="card", cascade="all, delete-orphan", lazy="dynamic"
)
comments = db.relationship(
"Comment",
backref="card",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Card.id == Comment.card_id, Comment.status == 'active')",
)
attachments = db.relationship(
"FileAttachment",
foreign_keys="FileAttachment.attachable_id",
primaryjoin="""and_(FileAttachment.attachable_id == Card.id,
FileAttachment.attachable_type == 'Card',
FileAttachment.status == 'active')""",
cascade="all, delete-orphan",
lazy="dynamic",
"Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic"
)
# Card link relationships (self-referential many-to-many) - only active links
child_links = db.relationship(
"CardLink",
foreign_keys="CardLink.parent_card_id",
back_populates="parent_card",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="""and_(Card.id == CardLink.parent_card_id,
CardLink.status == 'active')""",
)
parent_links = db.relationship(
"CardLink",
foreign_keys="CardLink.child_card_id",
back_populates="child_card",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="""and_(Card.id == CardLink.child_card_id,
CardLink.status == 'active')""",
)
def to_dict(self, include_linked=False):
def to_dict(self):
"""Convert card to dictionary"""
result = {
return {
"id": self.id,
"name": self.name,
"description": self.description,
@ -116,8 +71,6 @@ class Card(db.Model, SoftDeleteMixin):
"id_short": self.id_short,
"board_id": self.board_id,
"list_id": self.list_id,
"list_name": self.list.name if self.list else None,
"epic_id": self.epic_id,
"date_last_activity": self.date_last_activity.isoformat()
if self.date_last_activity
else None,
@ -126,141 +79,7 @@ class Card(db.Model, SoftDeleteMixin):
"badges": self.badges,
"cover": self.cover,
"desc_data": self.desc_data,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
"parent_card_name": (
pl.parent_card.name
if (pl := self.parent_links.first()) and pl.parent_card
else None
),
}
if include_linked:
result["parent_cards"] = [
link.child_card.to_dict()
for link in self.parent_links
if link.child_card
]
result["child_cards"] = [
link.child_card.to_dict()
for link in self.child_links
if link.child_card
]
return result
def __repr__(self):
return f"<Card {self.name}>"
# SQLAlchemy event listeners to update Epic metrics
def update_epic_metrics_on_card_change(mapper, connection, target):
"""Update epic card_count when card epic_id changes"""
if target.epic_id:
from sqlalchemy import select, update
from app.models import Epic
card_count_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id
)
card_count = connection.execute(card_count_stmt).scalar()
completed_list_id_stmt = select(Epic.completed_list_id).where(
Epic.id == target.epic_id
)
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
completed_cards_count = 0
if completed_list_id:
completed_cards_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id, Card.list_id == completed_list_id
)
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
connection.execute(
update(Epic)
.where(Epic.id == target.epic_id)
.values(
metrics={
"card_count": card_count,
"completed_cards_count": completed_cards_count,
}
)
)
def update_epic_metrics_on_card_insert(mapper, connection, target):
"""Update epic card_count when a card is added to an epic"""
if target.epic_id:
from sqlalchemy import select, update
from app.models import Epic
card_count_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id
)
card_count = connection.execute(card_count_stmt).scalar()
completed_list_id_stmt = select(Epic.completed_list_id).where(
Epic.id == target.epic_id
)
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
completed_cards_count = 0
if completed_list_id:
completed_cards_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id, Card.list_id == completed_list_id
)
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
connection.execute(
update(Epic)
.where(Epic.id == target.epic_id)
.values(
metrics={
"card_count": card_count,
"completed_cards_count": completed_cards_count,
}
)
)
def update_epic_metrics_on_card_delete(mapper, connection, target):
"""Update epic card_count when a card is removed from an epic"""
if target.epic_id:
from sqlalchemy import select, update
from app.models import Epic
card_count_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id
)
card_count = connection.execute(card_count_stmt).scalar()
completed_list_id_stmt = select(Epic.completed_list_id).where(
Epic.id == target.epic_id
)
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
completed_cards_count = 0
if completed_list_id:
completed_cards_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id, Card.list_id == completed_list_id
)
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
connection.execute(
update(Epic)
.where(Epic.id == target.epic_id)
.values(
metrics={
"card_count": card_count,
"completed_cards_count": completed_cards_count,
}
)
)
# Register event listeners
db.event.listen(Card, "after_update", update_epic_metrics_on_card_change)
db.event.listen(Card, "after_insert", update_epic_metrics_on_card_insert)
db.event.listen(Card, "after_delete", update_epic_metrics_on_card_delete)

View file

@ -1,10 +1,9 @@
from datetime import UTC, datetime
from app import db
from app.models.base import SoftDeleteMixin
class CardLabel(db.Model, SoftDeleteMixin):
class CardLabel(db.Model):
"""Many-to-many relationship between cards and labels"""
__tablename__ = "card_labels"
@ -38,8 +37,6 @@ class CardLabel(db.Model, SoftDeleteMixin):
"card_id": self.card_id,
"label_id": self.label_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
def __repr__(self):

View file

@ -1,69 +0,0 @@
from datetime import UTC, datetime
from app import db
from app.models.base import SoftDeleteMixin
class CardLink(db.Model, SoftDeleteMixin):
"""CardLink model for bidirectional card-to-card relationships"""
__tablename__ = "card_links"
id = db.Column(db.Integer, primary_key=True)
parent_card_id = db.Column(
db.Integer,
db.ForeignKey("cards.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
child_card_id = db.Column(
db.Integer,
db.ForeignKey("cards.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_by = db.Column(
db.Integer, db.ForeignKey("users.id"), nullable=True, index=True
)
# Timestamps
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
# Relationships
parent_card = db.relationship(
"Card",
foreign_keys=[parent_card_id],
back_populates="child_links",
)
child_card = db.relationship(
"Card",
foreign_keys=[child_card_id],
back_populates="parent_links",
)
def to_dict(self, include_cards=False):
"""Convert card link to dictionary"""
result = {
"id": self.id,
"parent_card_id": self.parent_card_id,
"child_card_id": self.child_card_id,
"created_by": self.created_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
if include_cards:
result["parent_card"] = (
self.parent_card.to_dict() if self.parent_card else None
)
result["child_card"] = (
self.child_card.to_dict() if self.child_card else None
)
return result
def __repr__(self):
return f"<CardLink {self.parent_card_id} -> {self.child_card_id}>"
__table_args__ = (
db.UniqueConstraint("parent_card_id", "child_card_id", name="unique_card_link"),
)

View file

@ -1,10 +1,9 @@
from datetime import UTC, datetime
from app import db
from app.models.base import SoftDeleteMixin
class CheckItem(db.Model, SoftDeleteMixin):
class CheckItem(db.Model):
"""CheckItem model for checklist items"""
__tablename__ = "check_items"
@ -46,8 +45,6 @@ class CheckItem(db.Model, SoftDeleteMixin):
"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,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
def __repr__(self):

View file

@ -1,11 +1,10 @@
from datetime import UTC, datetime
from app import db
from app.models.base import SoftDeleteMixin
class Checklist(db.Model, SoftDeleteMixin):
"""Checklist model for card checklists"""
class Checklist(db.Model):
"""Checklist model for Kanban checklists"""
__tablename__ = "checklists"
@ -35,14 +34,9 @@ class Checklist(db.Model, SoftDeleteMixin):
onupdate=lambda: datetime.now(UTC),
)
# Relationships - only active check items
# Relationships
check_items = db.relationship(
"CheckItem",
backref="checklist",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="""and_(Checklist.id == CheckItem.checklist_id,
CheckItem.status == 'active')""",
"CheckItem", backref="checklist", cascade="all, delete-orphan", lazy="dynamic"
)
def to_dict(self):
@ -51,12 +45,10 @@ class Checklist(db.Model, SoftDeleteMixin):
"id": self.id,
"name": self.name,
"pos": self.pos,
"board_id": self.board_id,
"card_id": self.card_id,
"board_id": self.card.board_id if self.card 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,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
def __repr__(self):

View file

@ -1,10 +1,9 @@
from datetime import UTC, datetime
from app import db
from app.models.base import SoftDeleteMixin
class Comment(db.Model, SoftDeleteMixin):
class Comment(db.Model):
"""Comment model for card comments"""
__tablename__ = "comments"
@ -31,17 +30,6 @@ class Comment(db.Model, SoftDeleteMixin):
onupdate=lambda: datetime.now(UTC),
)
# Relationships
attachments = db.relationship(
"FileAttachment",
foreign_keys="FileAttachment.attachable_id",
primaryjoin="""and_(FileAttachment.attachable_id == Comment.id,
FileAttachment.attachable_type == 'Comment')""",
cascade="all, delete-orphan",
lazy="dynamic",
overlaps="attachments",
)
def to_dict(self):
"""Convert comment to dictionary"""
return {
@ -51,8 +39,6 @@ class Comment(db.Model, SoftDeleteMixin):
"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,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
def __repr__(self):

View file

@ -1,90 +0,0 @@
from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB
from app import db
from app.models.base import SoftDeleteMixin
class Epic(db.Model, SoftDeleteMixin):
"""Epic model for tracking large features across multiple cards"""
__tablename__ = "epics"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
description = db.Column(db.Text)
content = db.Column(JSONB) # Rich text content (Slate.js JSON)
color = db.Column(db.String(7)) # Hex color for epic badge
closed = db.Column(db.Boolean, default=False, index=True)
pos = db.Column(db.Float) # Position for sorting in epic list
depth_limit = db.Column(db.Integer, default=5) # Max nesting depth
# Foreign keys
board_id = db.Column(
db.Integer,
db.ForeignKey("boards.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
parent_epic_id = db.Column(
db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL")
)
completed_list_id = db.Column(
db.Integer,
db.ForeignKey("lists.id", ondelete="SET NULL"),
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 for metrics
metrics = db.Column(JSONB) # {"card_count": 10}
# Relationships
board = db.relationship("Board", backref="epics")
parent_epic = db.relationship("Epic", remote_side=[id], backref="child_epics")
cards = db.relationship("Card", backref="epic", cascade="all, delete-orphan")
attachments = db.relationship(
"FileAttachment",
foreign_keys="FileAttachment.attachable_id",
primaryjoin="""and_(FileAttachment.attachable_id == Epic.id,
FileAttachment.attachable_type == 'Epic')""",
cascade="all, delete-orphan",
lazy="dynamic",
overlaps="attachments",
)
def to_dict(self):
"""Convert epic to dictionary"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"content": self.content,
"color": self.color,
"closed": self.closed,
"pos": self.pos,
"depth_limit": self.depth_limit,
"board_id": self.board_id,
"parent_epic_id": self.parent_epic_id,
"completed_list_id": self.completed_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,
"metrics": self.metrics or {"card_count": 0, "completed_cards_count": 0},
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
def __repr__(self):
return f"<Epic {self.name}>"

View file

@ -1,78 +0,0 @@
import uuid
from datetime import UTC, datetime
from sqlalchemy import Index
from app import db
from app.models.base import SoftDeleteMixin
class FileAttachment(db.Model, SoftDeleteMixin):
"""Polymorphic file attachment model for Cards, Comments, and other entities"""
__tablename__ = "file_attachments"
id = db.Column(db.Integer, primary_key=True)
uuid = db.Column(
db.String(36), nullable=False, unique=True, default=lambda: str(uuid.uuid4())
)
filename = db.Column(db.String(255), nullable=False)
original_name = db.Column(db.String(255), nullable=False)
file_type = db.Column(db.String(50), nullable=False) # 'image', 'pdf', 'document'
mime_type = db.Column(db.String(100), nullable=False)
file_size = db.Column(db.Integer, nullable=False)
# MinIO storage information
minio_bucket = db.Column(db.String(100), nullable=False)
minio_object_name = db.Column(db.String(255), nullable=False, unique=True)
# Thumbnail information (optional, for images)
thumbnail_minio_object_name = db.Column(db.String(255))
thumbnail_minio_bucket = db.Column(db.String(100))
# Polymorphic relationship - can attach to different entity types
attachable_type = db.Column(
db.String(50), nullable=False
) # 'Card', 'Comment', 'Epic'
attachable_id = db.Column(db.Integer, nullable=False) # ID of the attached entity
# Upload metadata
uploaded_by = db.Column(
db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
# Relationships
uploader = db.relationship("User", backref="uploaded_files")
# Indexes for efficient queries
__table_args__ = (
Index("ix_file_attachments_attachable", "attachable_type", "attachable_id"),
Index("ix_file_attachments_user", "uploaded_by"),
Index("ix_file_attachments_uuid", "uuid"),
)
def to_dict(self):
"""Convert file attachment to dictionary"""
return {
"id": self.id,
"uuid": self.uuid,
"filename": self.filename,
"original_name": self.original_name,
"file_type": self.file_type,
"mime_type": self.mime_type,
"file_size": self.file_size,
"minio_bucket": self.minio_bucket,
"minio_object_name": self.minio_object_name,
"thumbnail_minio_object_name": self.thumbnail_minio_object_name,
"thumbnail_minio_bucket": self.thumbnail_minio_bucket,
"attachable_type": self.attachable_type,
"attachable_id": self.attachable_id,
"uploaded_by": self.uploaded_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
def __repr__(self):
return f"<FileAttachment {self.original_name} ({self.file_type})>"

View file

@ -1,11 +1,10 @@
from datetime import UTC, datetime
from app import db
from app.models.base import SoftDeleteMixin
class Label(db.Model, SoftDeleteMixin):
"""Label model for card labels"""
class Label(db.Model):
"""Label model for Kanban labels"""
__tablename__ = "labels"
@ -41,12 +40,11 @@ class Label(db.Model, SoftDeleteMixin):
"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,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
def __repr__(self):
return f"<Label {self.name}>"
return f"<Label {self.name} ({self.color})>"

View file

@ -1,10 +1,9 @@
from datetime import UTC, datetime
from app import db
from app.models.base import SoftDeleteMixin
class List(db.Model, SoftDeleteMixin):
class List(db.Model):
"""List model for Kanban lists (columns)"""
__tablename__ = "lists"
@ -30,13 +29,9 @@ class List(db.Model, SoftDeleteMixin):
onupdate=lambda: datetime.now(UTC),
)
# Relationships - only active cards
# Relationships
cards = db.relationship(
"Card",
backref="list",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(List.id == Card.list_id, Card.status == 'active')",
"Card", backref="list", cascade="all, delete-orphan", lazy="dynamic"
)
def to_dict(self):
@ -49,8 +44,6 @@ class List(db.Model, SoftDeleteMixin):
"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,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
def __repr__(self):

View file

@ -0,0 +1,75 @@
from datetime import UTC, datetime
from app import db
class Order(db.Model):
"""Order model"""
__tablename__ = "orders"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
status = db.Column(db.String(20), default="pending", index=True)
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
shipping_address = db.Column(db.Text)
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
user = db.relationship("User", back_populates="orders")
items = db.relationship(
"OrderItem",
back_populates="order",
lazy="dynamic",
cascade="all, delete-orphan",
)
def to_dict(self):
"""Convert order to dictionary"""
return {
"id": self.id,
"user_id": self.user_id,
"status": self.status,
"total_amount": float(self.total_amount) if self.total_amount else None,
"shipping_address": self.shipping_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"items": [item.to_dict() for item in self.items],
}
def __repr__(self):
return f"<Order {self.id}>"
class OrderItem(db.Model):
"""Order Item model"""
__tablename__ = "order_items"
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False)
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False)
quantity = db.Column(db.Integer, nullable=False)
price = db.Column(db.Numeric(10, 2), nullable=False)
# Relationships
order = db.relationship("Order", back_populates="items")
product = db.relationship("Product", back_populates="order_items")
def to_dict(self):
"""Convert order item to dictionary"""
return {
"id": self.id,
"order_id": self.order_id,
"product_id": self.product_id,
"quantity": self.quantity,
"price": float(self.price) if self.price else None,
}
def __repr__(self):
return f"<OrderItem {self.id}>"

View file

@ -0,0 +1,43 @@
from datetime import UTC, datetime
from app import db
class Product(db.Model):
"""Product model"""
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
description = db.Column(db.Text)
price = db.Column(db.Numeric(10, 2), nullable=False)
stock = db.Column(db.Integer, default=0)
image_url = db.Column(db.String(500))
is_active = db.Column(db.Boolean, default=True)
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
order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic")
def to_dict(self):
"""Convert product to dictionary"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"price": float(self.price) if self.price else None,
"stock": self.stock,
"image_url": self.image_url,
"is_active": self.is_active,
"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"<Product {self.name}>"

View file

@ -26,6 +26,7 @@ class User(db.Model):
)
# Relationships
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
boards = db.relationship(
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
)

View file

@ -1,114 +0,0 @@
from datetime import UTC, datetime
from sqlalchemy import and_
from sqlalchemy.dialects.postgresql import JSONB
from app import db
from app.models.base import SoftDeleteMixin
from app.models.card import Card
from app.models.epic import Epic
wiki_entity_links = db.Table(
"wiki_entity_links",
db.Column(
"wiki_id",
db.Integer,
db.ForeignKey("wikis.id", ondelete="CASCADE"),
primary_key=True,
),
db.Column("entity_type", db.String(50), nullable=False), # 'Card', 'Epic'
db.Column("entity_id", db.Integer, nullable=False), # ID of the linked entity
db.Column("created_at", db.DateTime, default=lambda: datetime.now(UTC)),
db.Column("linked_by", db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")),
)
class Wiki(db.Model, SoftDeleteMixin):
"""Wiki model for reusable rich text content within a board"""
__tablename__ = "wikis"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
slug = db.Column(db.String(255), index=True) # URL-friendly identifier
content = db.Column(JSONB, nullable=False) # Rich text content (Slate.js JSON)
summary = db.Column(db.Text) # Brief description/abstract
category = db.Column(db.String(100)) # Optional categorization
# Foreign keys
board_id = db.Column(
db.Integer,
db.ForeignKey("boards.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_by = db.Column(
db.Integer,
db.ForeignKey("users.id", ondelete="SET NULL"),
)
updated_by = db.Column(
db.Integer,
db.ForeignKey("users.id", ondelete="SET NULL"),
)
# 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),
)
# JSON fields
tags = db.Column(JSONB) # List of tags for organization: ["security", "api"]
# Relationships
board = db.relationship("Board", backref="wikis")
creator = db.relationship(
"User", foreign_keys=[created_by], backref="created_wikis"
)
updater = db.relationship(
"User", foreign_keys=[updated_by], backref="updated_wikis"
)
# Linked entities relationships
linked_cards = db.relationship(
"Card",
secondary=wiki_entity_links,
primaryjoin=and_(
wiki_entity_links.c.wiki_id == id, wiki_entity_links.c.entity_type == "card"
),
secondaryjoin=wiki_entity_links.c.entity_id == Card.id,
viewonly=True,
)
linked_epics = db.relationship(
"Epic",
secondary=wiki_entity_links,
primaryjoin=and_(
wiki_entity_links.c.wiki_id == id, wiki_entity_links.c.entity_type == "epic"
),
secondaryjoin=wiki_entity_links.c.entity_id == Epic.id,
viewonly=True,
)
def to_dict(self):
"""Convert wiki to dictionary"""
return {
"id": self.id,
"name": self.name,
"slug": self.slug,
"content": self.content,
"summary": self.summary,
"category": self.category,
"board_id": self.board_id,
"tags": self.tags or [],
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
def __repr__(self):
return f"<Wiki {self.name}>"

View file

@ -1,9 +1,12 @@
from flask import Blueprint, jsonify, request
from flask_jwt_extended import (create_access_token, create_refresh_token,
get_jwt_identity, jwt_required)
from pydantic import ValidationError
from app import db
from app.models import User
from app.celery import celery
from app.models import Order, OrderItem, Product, User
from app.schemas import ProductCreateRequest, ProductResponse
api_bp = Blueprint("api", __name__)
@ -76,3 +79,333 @@ def get_current_user():
return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict()), 200
# Product Routes
@api_bp.route("/products", methods=["GET"])
def get_products():
"""Get all products"""
# time.sleep(5) # This adds a 5 second delay
products = Product.query.filter_by(is_active=True).all()
return jsonify([product.to_dict() for product in products]), 200
@api_bp.route("/products/<int:product_id>", methods=["GET"])
def get_product(product_id):
"""Get a single product"""
product = db.session.get(Product, product_id)
if not product:
return jsonify({"error": "Product not found"}), 404
return jsonify(product.to_dict()), 200
@api_bp.route("/products", methods=["POST"])
@jwt_required()
def create_product():
"""Create a new product (admin only)"""
user_id = int(get_jwt_identity())
user = db.session.get(User, user_id)
if not user or not user.is_admin:
return jsonify({"error": "Admin access required"}), 403
try:
# Validate request data using Pydantic schema
product_data = ProductCreateRequest(**request.get_json())
product = Product(
name=product_data.name,
description=product_data.description,
price=product_data.price,
stock=product_data.stock,
image_url=product_data.image_url,
)
db.session.add(product)
db.session.commit()
# Use Pydantic schema for response
response = ProductResponse.model_validate(product)
return jsonify(response.model_dump()), 201
except ValidationError as e:
print(f"Pydantic Validation Error: {e.errors()}")
return jsonify({"error": "Validation error", "details": e.errors()}), 400
@api_bp.route("/products/<int:product_id>", methods=["PUT"])
@jwt_required()
def update_product(product_id):
"""Update a product (admin only)"""
user_id = int(get_jwt_identity())
user = db.session.get(User, user_id)
if not user or not user.is_admin:
return jsonify({"error": "Admin access required"}), 403
product = db.session.get(Product, product_id)
if not product:
return jsonify({"error": "Product not found"}), 404
data = request.get_json()
product.name = data.get("name", product.name)
product.description = data.get("description", product.description)
product.price = data.get("price", product.price)
product.stock = data.get("stock", product.stock)
product.image_url = data.get("image_url", product.image_url)
product.is_active = data.get("is_active", product.is_active)
db.session.commit()
return jsonify(product.to_dict()), 200
@api_bp.route("/products/<int:product_id>", methods=["DELETE"])
@jwt_required()
def delete_product(product_id):
"""Delete a product (admin only)"""
user_id = int(get_jwt_identity())
user = db.session.get(User, user_id)
if not user or not user.is_admin:
return jsonify({"error": "Admin access required"}), 403
product = db.session.get(Product, product_id)
if not product:
return jsonify({"error": "Product not found"}), 404
db.session.delete(product)
db.session.commit()
return jsonify({"message": "Product deleted"}), 200
# Order Routes
@api_bp.route("/orders", methods=["GET"])
@jwt_required()
def get_orders():
"""Get all orders for current user"""
user_id = int(get_jwt_identity())
orders = Order.query.filter_by(user_id=user_id).all()
return jsonify([order.to_dict() for order in orders]), 200
@api_bp.route("/orders", methods=["POST"])
@jwt_required()
def create_order():
"""Create a new order"""
user_id = int(get_jwt_identity())
data = request.get_json()
if not data or not data.get("items"):
return jsonify({"error": "Order items are required"}), 400
total_amount = 0
order_items = []
for item_data in data["items"]:
product = db.session.get(Product, item_data["product_id"])
if not product:
return (
jsonify({"error": f'Product {item_data["product_id"]} not found'}),
404,
)
if product.stock < item_data["quantity"]:
return jsonify({"error": f"Insufficient stock for {product.name}"}), 400
item_total = product.price * item_data["quantity"]
total_amount += item_total
order_items.append(
{
"product": product,
"quantity": item_data["quantity"],
"price": product.price,
}
)
order = Order(
user_id=user_id,
total_amount=total_amount,
shipping_address=data.get("shipping_address"),
)
db.session.add(order)
db.session.flush()
for item_data in order_items:
order_item = OrderItem(
order_id=order.id,
product_id=item_data["product"].id,
quantity=item_data["quantity"],
price=item_data["price"],
)
item_data["product"].stock -= item_data["quantity"]
db.session.add(order_item)
db.session.commit()
return jsonify(order.to_dict()), 201
@api_bp.route("/orders/<int:order_id>", methods=["GET"])
@jwt_required()
def get_order(order_id):
"""Get a single order"""
user_id = int(get_jwt_identity())
order = db.session.get(Order, order_id)
if not order:
return jsonify({"error": "Order not found"}), 404
if order.user_id != user_id:
user = db.session.get(User, user_id)
if not user or not user.is_admin:
return jsonify({"error": "Access denied"}), 403
return jsonify(order.to_dict()), 200
# Celery Task Routes
@api_bp.route("/tasks/hello", methods=["POST"])
@jwt_required()
def trigger_hello_task():
"""Trigger the hello task"""
data = request.get_json() or {}
name = data.get("name", "World")
task = celery.send_task("tasks.print_hello", args=[name])
return (
jsonify(
{"message": "Hello task triggered", "task_id": task.id, "status": "pending"}
),
202,
)
@api_bp.route("/tasks/divide", methods=["POST"])
@jwt_required()
def trigger_divide_task():
"""Trigger the divide numbers task"""
data = request.get_json() or {}
x = data.get("x", 10)
y = data.get("y", 2)
task = celery.send_task("tasks.divide_numbers", args=[x, y])
return (
jsonify(
{
"message": "Divide task triggered",
"task_id": task.id,
"operation": f"{x} / {y}",
"status": "pending",
}
),
202,
)
@api_bp.route("/tasks/report", methods=["POST"])
@jwt_required()
def trigger_report_task():
"""Trigger the daily report task"""
task = celery.send_task("tasks.send_daily_report")
return (
jsonify(
{
"message": "Daily report task triggered",
"task_id": task.id,
"status": "pending",
}
),
202,
)
@api_bp.route("/tasks/stats", methods=["POST"])
@jwt_required()
def trigger_stats_task():
"""Trigger product statistics update task"""
data = request.get_json() or {}
product_id = data.get("product_id")
if product_id:
task = celery.send_task("tasks.update_product_statistics", args=[product_id])
message = f"Product statistics update triggered for product {product_id}"
else:
task = celery.send_task("tasks.update_product_statistics", args=[None])
message = "Product statistics update triggered for all products"
return jsonify({"message": message, "task_id": task.id, "status": "pending"}), 202
@api_bp.route("/tasks/long-running", methods=["POST"])
@jwt_required()
def trigger_long_running_task():
"""Trigger a long-running task"""
data = request.get_json() or {}
iterations = data.get("iterations", 10)
task = celery.send_task("tasks.long_running_task", args=[iterations])
return (
jsonify(
{
"message": f"Long-running task triggered with {iterations} iterations",
"task_id": task.id,
"status": "pending",
}
),
202,
)
@api_bp.route("/tasks/<task_id>", methods=["GET"])
@jwt_required()
def get_task_status(task_id):
"""Get the status of a Celery task"""
task_result = celery.AsyncResult(task_id)
response = {
"task_id": task_id,
"status": task_result.status,
"ready": task_result.ready(),
}
if task_result.ready():
if task_result.successful():
response["result"] = task_result.result
else:
response["error"] = str(task_result.result)
response["traceback"] = task_result.traceback
return jsonify(response), 200
@api_bp.route("/tasks/health", methods=["GET"])
def celery_health():
"""Check Celery health"""
try:
# Try to ping the worker
inspector = celery.control.inspect()
stats = inspector.stats()
if stats:
return (
jsonify(
{"status": "healthy", "workers": len(stats), "workers_info": stats}
),
200,
)
else:
return (
jsonify({"status": "unhealthy", "message": "No workers available"}),
503,
)
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500

View file

@ -1,12 +1,14 @@
# fmt: off
from flask import Blueprint
# Create the kanban blueprint that will be used by all route modules
kanban_bp = Blueprint("kanban", __name__)
# Import all route modules to register their routes to this blueprint
from . import (boards, card_links, cards, checklists, # noqa: F401 E402
comments, epics, files, labels, lists, wikis)
# fmt: off
from . import (boards, cards, checklists, comments, labels, # noqa: F401 E402
lists)
# fmt: on
__all__ = ["kanban_bp"]
# fmt: on

View file

@ -5,9 +5,8 @@ from flask_jwt_extended import jwt_required
from flask_pydantic import validate
from app import db
from app.decorators import load_board_owned
from app.decorators.decorators import get_current_user_id
from app.models import Board, Card, List
from app.decorators import get_current_user_id, load_board_owned
from app.models import Board, Card, CardLabel, Label, List
from app.schemas import (BoardCreateRequest, BoardResponse,
BoardWithDetailsResponse)
@ -19,7 +18,7 @@ from . import kanban_bp
def get_boards():
"""Get all boards for current user"""
user_id = get_current_user_id()
boards = Board.active().filter_by(user_id=user_id).all()
boards = Board.query.filter_by(user_id=user_id).all()
return [BoardResponse.model_validate(board).model_dump() for board in boards], 200
@ -39,9 +38,13 @@ def get_board(board_id, board):
# Add labels for this card
card_dict["labels"] = [
card_label.label.to_dict()
for card_label in card.labels
if card_label.label and card_label.status == "active"
label.to_dict()
for label in (
db.session.query(Label)
.join(CardLabel)
.filter(CardLabel.card_id == card.id)
.all()
)
]
# Add comments for this card
@ -52,25 +55,15 @@ def get_board(board_id, board):
comment_dict["user"] = user.to_dict() if user else None
card_dict["comments"].append(comment_dict)
# Add checklists with items
from app.models import CheckItem, Checklist
# Add checklists with items for this card
card_dict["checklists"] = [
{
**checklist.to_dict(),
"items": [
item.to_dict()
for item in CheckItem.active()
.filter_by(checklist_id=checklist.id)
.all()
],
"items": [item.to_dict() for item in checklist.check_items.all()],
}
for checklist in Checklist.active().filter_by(card_id=card.id).all()
for checklist in card.checklists.all()
]
# Add epic for this card
card_dict["epic"] = card.epic.to_dict() if card.epic else None
cards_data.append(card_dict)
lists_data.append({**lst.to_dict(), "cards": cards_data})
@ -142,7 +135,7 @@ def update_board(board_id, board, body: BoardCreateRequest):
@load_board_owned
def delete_board(board_id, board):
"""Delete a board"""
board.soft_delete()
db.session.delete(board)
db.session.commit()
return {"message": "Board deleted"}, 200

View file

@ -1,278 +0,0 @@
"""Routes for Card-to-Card linking operations."""
from flask import jsonify, request
from flask_jwt_extended import get_jwt_identity, jwt_required
from app import db
from app.models import Card
from app.models.card_label import CardLabel
from app.models.card_link import CardLink
from . import kanban_bp
@kanban_bp.route("/cards/<int:card_id>/links", methods=["GET"])
@jwt_required()
def get_card_links(card_id):
"""Get all cards linked to a card (both parent and child)."""
card = db.session.get(Card, card_id)
if not card:
return jsonify({"error": "Card not found"}), 404
# Cards where this card is the parent (this card -> child cards)
child_links = card.child_links.all()
# Cards where this card is the child (parent cards -> this card)
parent_links = card.parent_links.all()
return (
jsonify(
{
"child_cards": [
{
"id": link.id,
"card": link.child_card.to_dict(),
"created_at": (
link.created_at.isoformat() if link.created_at else None
),
}
for link in child_links
if link.child_card
],
"parent_cards": [
{
"id": link.id,
"card": link.parent_card.to_dict(),
"created_at": (
link.created_at.isoformat() if link.created_at else None
),
}
for link in parent_links
if link.parent_card
],
}
),
200,
)
@kanban_bp.route("/cards/<int:card_id>/links", methods=["POST"])
@jwt_required()
def link_existing_card(card_id):
"""Link an existing card to this card."""
card = db.session.get(Card, card_id)
if not card:
return jsonify({"error": "Card not found"}), 404
data = request.get_json()
target_card_id = data.get("child_card_id")
if not target_card_id:
return jsonify({"error": "child_card_id is required"}), 400
target_card = db.session.get(Card, target_card_id)
if not target_card:
return jsonify({"error": "Target card not found"}), 404
if card.id == target_card.id:
return jsonify({"error": "Cannot link a card to itself"}), 400
# Check if link already exists (in either direction)
existing = CardLink.query.filter(
db.or_(
db.and_(
CardLink.parent_card_id == card.id,
CardLink.child_card_id == target_card.id,
),
db.and_(
CardLink.parent_card_id == target_card.id,
CardLink.child_card_id == card.id,
),
)
).first()
if existing:
return jsonify({"error": "Cards are already linked"}), 409
user_id = get_jwt_identity()
# Create link: card is parent, target is child
link = CardLink(
parent_card_id=card.id,
child_card_id=target_card.id,
created_by=user_id,
)
db.session.add(link)
db.session.commit()
return jsonify(link.to_dict(include_cards=True)), 201
@kanban_bp.route("/cards/<int:card_id>/links/<int:link_id>", methods=["DELETE"])
@jwt_required()
def unlink_card(card_id, link_id):
"""Remove a link between cards."""
card = db.session.get(Card, card_id)
if not card:
return jsonify({"error": "Card not found"}), 404
link = db.session.get(CardLink, link_id)
if not link:
return jsonify({"error": "Link not found"}), 404
# Verify the link involves this card
if link.parent_card_id != card.id and link.child_card_id != card.id:
return jsonify({"error": "Link does not belong to this card"}), 403
link.soft_delete()
db.session.commit()
return jsonify({"message": "Cards unlinked successfully"}), 200
@kanban_bp.route("/cards/<int:card_id>/linked-cards", methods=["POST"])
@jwt_required()
def create_linked_card(card_id):
"""Create a new card linked to this card. Copies labels and epics."""
card = db.session.get(Card, card_id)
if not card:
return jsonify({"error": "Card not found"}), 404
data = request.get_json()
name = data.get("name")
list_id = data.get("list_id")
description = data.get("description", "")
copy_labels = data.get("copy_labels", True)
copy_epics = data.get("copy_epics", True)
if not name:
return jsonify({"error": "name is required"}), 400
if not list_id:
return jsonify({"error": "list_id is required"}), 400
user_id = get_jwt_identity()
# Get the highest position in the target list
max_pos = (
db.session.query(db.func.max(Card.pos)).filter(Card.list_id == list_id).scalar()
)
pos = (max_pos or 0) + 65536
# Create the new card
new_card = Card(
name=name,
description=description,
board_id=card.board_id,
list_id=list_id,
pos=pos,
epic_id=card.epic_id if copy_epics else None,
)
db.session.add(new_card)
db.session.flush()
# Copy labels if requested
if copy_labels:
parent_labels = CardLabel.query.filter_by(card_id=card.id).all()
for pl in parent_labels:
new_label = CardLabel(card_id=new_card.id, label_id=pl.label_id)
db.session.add(new_label)
# Create link: parent card -> new child card
link = CardLink(
parent_card_id=card.id,
child_card_id=new_card.id,
created_by=user_id,
)
db.session.add(link)
db.session.commit()
return (
jsonify(
{
"card": new_card.to_dict(),
"link": link.to_dict(),
}
),
201,
)
@kanban_bp.route("/card-links/<int:link_id>", methods=["DELETE"])
@jwt_required()
def delete_card_link(link_id):
"""Remove a link by link ID."""
link = db.session.get(CardLink, link_id)
if not link:
return jsonify({"error": "Link not found"}), 404
link.soft_delete()
db.session.commit()
return jsonify({"message": "Cards unlinked successfully"}), 200
@kanban_bp.route(
"/cards/<int:card_id>/checklist-items/<int:check_item_id>/convert-to-card",
methods=["POST"],
)
@jwt_required()
def convert_check_item_to_card(card_id, check_item_id):
"""Convert a checklist item to a new linked card."""
from app.models.check_item import CheckItem
card = db.session.get(Card, card_id)
if not card:
return jsonify({"error": "Card not found"}), 404
check_item = db.session.get(CheckItem, check_item_id)
if not check_item:
return jsonify({"error": "Check item not found"}), 404
data = request.get_json() or {}
list_id = data.get("list_id", card.list_id)
user_id = get_jwt_identity()
# Get the highest position in the target list
max_pos = (
db.session.query(db.func.max(Card.pos)).filter(Card.list_id == list_id).scalar()
)
pos = (max_pos or 0) + 65536
# Create the new card from checklist item name
new_card = Card(
name=check_item.name,
board_id=card.board_id,
list_id=list_id,
pos=pos,
epic_id=card.epic_id,
)
db.session.add(new_card)
db.session.flush()
# Copy labels from parent card
parent_labels = CardLabel.query.filter_by(card_id=card.id).all()
for pl in parent_labels:
new_label = CardLabel(card_id=new_card.id, label_id=pl.label_id)
db.session.add(new_label)
# Create link: parent card -> new child card
link = CardLink(
parent_card_id=card.id,
child_card_id=new_card.id,
created_by=user_id,
)
db.session.add(link)
# Remove the checklist item
check_item.soft_delete()
db.session.commit()
return (
jsonify(
{
"card": new_card.to_dict(),
"link": link.to_dict(),
}
),
201,
)

View file

@ -42,7 +42,7 @@ def create_card(list_id, lst, body: CardCreateRequest):
@kanban_bp.route("/cards/<int:card_id>", methods=["GET"])
@jwt_required()
@load_card_owned
def get_card(card_id, card: Card):
def get_card(card_id, card):
"""Get a single card with full details"""
from app.models import User
@ -77,12 +77,6 @@ def get_card(card_id, card: Card):
comment_dict["user"] = user.to_dict() if user else None
card_dict["comments"].append(comment_dict)
# Add epic
if card.epic:
card_dict["epic"] = card.epic.to_dict()
else:
card_dict["epic"] = None
response = CardWithDetailsResponse(**card_dict)
return response.model_dump(), 200
@ -146,9 +140,9 @@ def update_card(card_id, card, body: CardCreateRequest):
@kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"])
@jwt_required()
@load_card_owned
def delete_card(card_id, card: Card):
def delete_card(card_id, card):
"""Delete a card"""
card.soft_delete()
db.session.delete(card)
db.session.commit()
return {"message": "Card deleted"}, 200

View file

@ -70,7 +70,7 @@ def update_check_item(item_id, check_item, body: CheckItemCreateRequest):
@load_checklist_owned
def delete_checklist(checklist_id, checklist):
"""Delete a checklist"""
checklist.soft_delete()
db.session.delete(checklist)
db.session.commit()
return {"message": "Checklist deleted"}, 200
@ -81,7 +81,7 @@ def delete_checklist(checklist_id, checklist):
@load_check_item_owned
def delete_check_item(item_id, check_item):
"""Delete a check item"""
check_item.soft_delete()
db.session.delete(check_item)
db.session.commit()
return {"message": "Check item deleted"}, 200

View file

@ -66,7 +66,7 @@ def update_comment(comment_id, comment, body: CommentCreateRequest):
@load_comment_owned
def delete_comment(comment_id, comment):
"""Delete a comment"""
comment.soft_delete()
db.session.delete(comment)
db.session.commit()
return {"message": "Comment deleted"}, 200

View file

@ -1,170 +0,0 @@
"""Routes for Epic CRUD operations."""
from flask import request
from flask_jwt_extended import jwt_required
from flask_pydantic import validate
from app import db
from app.decorators import serialize_list_response, serialize_response
from app.models import Board, Card, Epic
from app.schemas import (CardResponse, EpicCreateRequest, EpicResponse,
EpicUpdateRequest)
from . import kanban_bp
@kanban_bp.route("/boards/<int:board_id>/epics", methods=["GET"])
@jwt_required()
@serialize_list_response(EpicResponse)
def get_board_epics(board_id):
"""Get all epics for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
epics = Epic.active().filter_by(board_id=board_id).all()
return epics, 200
@kanban_bp.route("/boards/<int:board_id>/epics", methods=["POST"])
@jwt_required()
@validate(body=EpicCreateRequest)
@serialize_response(EpicResponse)
def create_epic(board_id, body: EpicCreateRequest):
"""Create a new epic for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
epic = Epic(
board_id=board_id,
name=body.name,
description=body.description,
content=body.content,
color=body.color,
closed=False,
pos=body.pos,
depth_limit=body.depth_limit,
parent_epic_id=body.parent_epic_id,
completed_list_id=body.completed_list_id,
metrics={"card_count": 0, "completed_cards_count": 0},
)
db.session.add(epic)
db.session.commit()
return epic, 201
@kanban_bp.route("/epics/<int:epic_id>", methods=["GET"])
@jwt_required()
def get_epic(epic_id):
"""Get a specific epic with details."""
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
# Get cards for this epic
cards = Card.active().filter_by(epic_id=epic_id).all()
epic_dict = EpicResponse.model_validate(epic).model_dump()
epic_dict["cards"] = [card.to_dict() for card in cards]
return epic_dict, 200
def _update_epic_fields(epic: Epic, body: EpicUpdateRequest) -> None:
"""Update epic fields from request body."""
if body.name is not None:
epic.name = body.name
if body.description is not None:
epic.description = body.description
if body.content is not None:
epic.content = body.content
if body.color is not None:
epic.color = body.color
if body.closed is not None:
epic.closed = body.closed
if body.pos is not None:
epic.pos = body.pos
if body.depth_limit is not None:
epic.depth_limit = body.depth_limit
if body.parent_epic_id is not None:
epic.parent_epic_id = body.parent_epic_id
if body.completed_list_id is not None:
epic.completed_list_id = body.completed_list_id
@kanban_bp.route("/epics/<int:epic_id>", methods=["PUT"])
@jwt_required()
@validate(body=EpicUpdateRequest)
@serialize_response(EpicResponse)
def update_epic(epic_id, body: EpicUpdateRequest):
"""Update an epic."""
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
_update_epic_fields(epic, body)
db.session.commit()
return epic, 200
@kanban_bp.route("/epics/<int:epic_id>", methods=["DELETE"])
@jwt_required()
def delete_epic(epic_id):
"""Delete an epic."""
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
# Unlink all cards from this epic
Card.active().filter_by(epic_id=epic_id).update({"epic_id": None})
# Delete epic
epic.soft_delete()
db.session.commit()
return {"message": "Epic deleted successfully"}, 200
@kanban_bp.route("/cards/<int:card_id>/epics", methods=["POST"])
@jwt_required()
@serialize_response(CardResponse)
def add_epic_to_card(card_id):
"""Link an epic to a card."""
card = db.session.get(Card, card_id)
if not card:
return {"error": "Card not found"}, 404
data = request.get_json()
epic_id = data.get("epic_id")
if not epic_id:
return {"error": "epic_id is required"}, 400
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
card.epic_id = epic_id
db.session.commit()
return card, 200
@kanban_bp.route("/cards/<int:card_id>/epics/<int:epic_id>", methods=["DELETE"])
@jwt_required()
def remove_epic_from_card(card_id, epic_id):
"""Unlink an epic from a card."""
card = db.session.get(Card, card_id)
if not card:
return {"error": "Card not found"}, 404
if card.epic_id != epic_id:
return {"error": "Card is not linked to this epic"}, 400
card.epic_id = None
db.session.commit()
return {"message": "Epic unlinked from card successfully"}, 200

View file

@ -1,299 +0,0 @@
"""Routes for file upload and management"""
from io import BytesIO
from flask import Response, request
from flask_jwt_extended import jwt_required
from werkzeug.datastructures import FileStorage
from app.decorators import (get_current_user_id, load_card_owned,
load_comment_owned, load_file_accessible_by_uuid)
from app.schemas import (FileAttachmentWithUrlResponse, FileDeleteResponse,
MultipleFilesResponse)
from app.services.storage.attachment_service import AttachmentService
from app.services.storage.file_service import FileService
from . import kanban_bp
@kanban_bp.route("/files/<string:file_uuid>", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def get_file(file_uuid, file):
"""Get file information with UUID-based URLs"""
attachment_service = AttachmentService()
file_info = attachment_service.get_attachment_with_url(file.id)
return file_info, 200
@kanban_bp.route("/files/<string:file_uuid>", methods=["DELETE"])
@jwt_required()
@load_file_accessible_by_uuid
def delete_file(file_uuid, file):
"""Delete a file"""
attachment_service = AttachmentService()
success = attachment_service.delete_attachment(file.id)
if not success:
return {"error": "Failed to delete file"}, 500
return FileDeleteResponse(message="File deleted", file_id=file.id).model_dump(), 200
@kanban_bp.route("/cards/<int:card_id>/attachments", methods=["GET"])
@jwt_required()
@load_card_owned
def get_card_attachments(card_id, card):
"""Get all attachments for a card"""
attachment_service = AttachmentService()
attachments = attachment_service.get_card_attachments(card_id)
# Get URLs for each attachment
files_with_urls = []
for attachment in attachments:
file_info = attachment_service.get_attachment_with_url(attachment.id)
files_with_urls.append(file_info)
return (
MultipleFilesResponse(
files=files_with_urls,
count=len(attachments),
).model_dump(),
200,
)
@kanban_bp.route("/cards/<int:card_id>/attachments", methods=["POST"])
@jwt_required()
@load_card_owned
def upload_card_attachment(card_id, card):
"""Upload a file to a specific card"""
return _upload_file(entity_type="Card", entity_id=card_id)
@kanban_bp.route("/comments/<int:comment_id>/attachments", methods=["GET"])
@jwt_required()
@load_comment_owned
def get_comment_attachments(comment_id, comment):
"""Get all attachments for a comment"""
attachment_service = AttachmentService()
attachments = attachment_service.get_comment_attachments(comment_id)
# Get URLs for each attachment
files_with_urls = []
for attachment in attachments:
file_info = attachment_service.get_attachment_with_url(attachment.id)
files_with_urls.append(file_info)
return (
MultipleFilesResponse(
files=files_with_urls,
count=len(attachments),
).model_dump(),
200,
)
@kanban_bp.route("/comments/<int:comment_id>/attachments", methods=["POST"])
@jwt_required()
@load_comment_owned
def upload_comment_attachment(comment_id, comment):
"""Upload a file to a specific comment"""
return _upload_file(entity_type="Comment", entity_id=comment_id)
@kanban_bp.route("/files/upload", methods=["POST"])
@jwt_required()
def upload_file():
"""Upload a file to an entity (Card, Comment, etc.)"""
# Validate request
entity_type = request.form.get("entity_type")
entity_id = request.form.get("entity_id")
if not entity_type or not entity_id:
return {"error": "entity_type and entity_id are required"}, 400
# Validate entity_type
valid_types = ["Card", "Comment", "Epic", "Board"]
if entity_type not in valid_types:
return {
"error": f"Invalid entity_type: {entity_type}. Must be one of {valid_types}"
}, 400
return _upload_file(entity_type=entity_type, entity_id=int(entity_id))
def _upload_file(entity_type: str, entity_id: int):
"""
Helper function to handle file upload
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
Returns:
tuple: (response_dict, status_code)
"""
# Get file from request
if "file" not in request.files:
return {"error": "No file provided"}, 400
file: FileStorage = request.files["file"]
if file.filename == "":
return {"error": "No file selected"}, 400
# Get user ID
user_id = get_current_user_id()
# Create BytesIO from file
file_data = BytesIO()
file.save(file_data)
file_size = len(file_data.getvalue())
# Get MIME type
mime_type = file.mimetype or "application/octet-stream"
# Upload file
attachment_service = AttachmentService()
attachment = attachment_service.attach_file_to_entity(
entity_type=entity_type,
entity_id=entity_id,
file_data=file_data,
filename=file.filename,
content_type=mime_type,
file_size=file_size,
uploaded_by=user_id,
)
# Get attachment with URLs for response
file_info = attachment_service.get_attachment_with_url(attachment.id)
return FileAttachmentWithUrlResponse(**file_info).model_dump(), 201
@kanban_bp.route("/files/<string:file_uuid>/download", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def download_file(file_uuid, file):
"""
Download a file through secure Flask proxy using UUID
This route serves as a proxy to MinIO, hiding the MinIO server
from the frontend and ensuring authentication/authorization.
Args:
file_uuid: UUID of the file attachment
file: FileAttachment (injected by @load_file_accessible_by_uuid decorator)
Returns:
Response: File data with proper headers
"""
file_service = FileService()
# Download file from MinIO
file_data = file_service.storage.download_file(
file.minio_bucket, file.minio_object_name
)
# Get file info for proper headers
file_info = file_service.storage.get_file_info(
file.minio_bucket, file.minio_object_name
)
# Create Flask Response with file data
return Response(
file_data.getvalue(),
mimetype=file.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{file.original_name}"',
"Content-Length": str(file_info["size"]),
"Cache-Control": "private, max-age=3600", # Cache for 1 hour
},
)
@kanban_bp.route("/files/<string:file_uuid>/thumbnail", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def download_thumbnail(file_uuid, file):
"""
Download a file thumbnail through secure Flask proxy using UUID
This route serves as a proxy to MinIO for thumbnails,
ensuring authentication with JWT.
Args:
file_uuid: UUID of the file attachment
file: FileAttachment (injected by @load_file_accessible_by_uuid decorator)
Returns:
Response: Thumbnail image data with proper headers
"""
# Check if thumbnail exists
if not file.thumbnail_minio_bucket or not file.thumbnail_minio_object_name:
return {"error": "Thumbnail not available"}, 404
file_service = FileService()
# Download thumbnail from MinIO
thumbnail_data = file_service.storage.download_file(
file.thumbnail_minio_bucket,
file.thumbnail_minio_object_name,
)
# Get thumbnail info for proper headers
thumb_info = file_service.storage.get_file_info(
file.thumbnail_minio_bucket,
file.thumbnail_minio_object_name,
)
# Create Flask Response with thumbnail data
return Response(
thumbnail_data.getvalue(),
mimetype="image/jpeg",
headers={
"Content-Length": str(thumb_info["size"]),
"Cache-Control": "private, max-age=3600",
},
)
@kanban_bp.route("/files/<string:file_uuid>/view", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def view_file(file_uuid, file):
"""
View a file inline through secure Flask proxy using UUID
Similar to download, but uses inline disposition so browsers
display the file instead of downloading it.
Args:
file_uuid: UUID of the file attachment
file: FileAttachment (injected by @load_file_accessible_by_uuid decorator)
Returns:
Response: File data with proper headers
"""
file_service = FileService()
# Download file from MinIO
file_data = file_service.storage.download_file(
file.minio_bucket, file.minio_object_name
)
# Get file info for proper headers
file_info = file_service.storage.get_file_info(
file.minio_bucket, file.minio_object_name
)
# Create Flask Response with file data (inline disposition)
return Response(
file_data.getvalue(),
mimetype=file.mime_type,
headers={
"Content-Disposition": f'inline; filename="{file.original_name}"',
"Content-Length": str(file_info["size"]),
"Cache-Control": "private, max-age=3600", # Cache for 1 hour
},
)

View file

@ -73,7 +73,7 @@ def remove_label_from_card(card_id, card, label_id):
if not card_label:
return {"error": "Label not found on card"}, 404
card_label.soft_delete()
db.session.delete(card_label)
db.session.commit()
return {"message": "Label removed from card"}, 200

View file

@ -64,7 +64,7 @@ def update_list(list_id, lst, body: ListCreateRequest):
@load_list_owned
def delete_list(list_id, lst):
"""Delete a list"""
lst.soft_delete()
db.session.delete(lst)
db.session.commit()
return {"message": "List deleted"}, 200

View file

@ -1,232 +0,0 @@
"""Routes for Wiki CRUD operations."""
import re
from flask_jwt_extended import get_jwt_identity, jwt_required
from flask_pydantic import validate
from sqlalchemy import and_
from sqlalchemy import delete as sql_delete
from sqlalchemy import select
from app import db
from app.decorators import serialize_list_response, serialize_response
from app.models import Board, Wiki, wiki_entity_links
from app.models.card import Card
from app.models.epic import Epic
from app.schemas import (CardResponse, EpicResponse, WikiCreateRequest,
WikiLinkRequest, WikiResponse, WikiUpdateRequest)
from . import kanban_bp
def generate_slug(name):
"""Generate URL-friendly slug from name."""
slug = name.lower()
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
slug = re.sub(r"\s+", "-", slug)
slug = slug.strip("-")
return slug
@kanban_bp.route("/boards/<int:board_id>/wikis", methods=["GET"])
@jwt_required()
@serialize_list_response(WikiResponse)
def get_board_wikis(board_id):
"""Get all wikis for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
wikis = Wiki.active().filter_by(board_id=board_id).all()
return wikis, 200
@kanban_bp.route("/boards/<int:board_id>/wikis", methods=["POST"])
@jwt_required()
@validate(body=WikiCreateRequest)
@serialize_response(WikiResponse)
def create_wiki(board_id, body: WikiCreateRequest):
"""Create a new wiki for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
# Generate slug if not provided
slug = body.slug
if not slug:
slug = generate_slug(body.name)
# Ensure uniqueness
counter = 1
original_slug = slug
while Wiki.query.filter_by(slug=slug).first():
slug = f"{original_slug}-{counter}"
counter += 1
wiki = Wiki(
board_id=board_id,
name=body.name,
slug=slug,
content=body.content,
summary=body.summary,
category=body.category,
tags=body.tags,
created_by=get_jwt_identity(),
updated_by=get_jwt_identity(),
)
db.session.add(wiki)
db.session.commit()
return wiki, 201
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["GET"])
@jwt_required()
def get_wiki(wiki_id):
"""Get a specific wiki with details including linked entities."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Convert wiki to dict and add linked entities
wiki_dict = wiki.to_dict()
# Add linked cards
linked_cards = [
CardResponse.model_validate(card).model_dump() for card in wiki.linked_cards
]
# Add linked epics
linked_epics = [
EpicResponse.model_validate(epic).model_dump() for epic in wiki.linked_epics
]
wiki_dict["linked_cards"] = linked_cards
wiki_dict["linked_epics"] = linked_epics
return wiki_dict, 200
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["PUT"])
@jwt_required()
@validate(body=WikiUpdateRequest)
@serialize_response(WikiResponse)
def update_wiki(wiki_id, body: WikiUpdateRequest):
"""Update a wiki."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Update fields
if body.name is not None:
wiki.name = body.name
if body.slug is not None:
wiki.slug = body.slug
if body.content is not None:
wiki.content = body.content
if body.summary is not None:
wiki.summary = body.summary
if body.category is not None:
wiki.category = body.category
if body.tags is not None:
wiki.tags = body.tags
wiki.updated_by = get_jwt_identity()
db.session.commit()
return wiki, 200
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["DELETE"])
@jwt_required()
def delete_wiki(wiki_id):
"""Delete a wiki."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Delete wiki (cascades to wiki_entity_links)
wiki.soft_delete()
db.session.commit()
return {"message": "Wiki deleted successfully"}, 200
@kanban_bp.route("/wikis/<int:wiki_id>/links", methods=["POST"])
@jwt_required()
@validate(body=WikiLinkRequest)
def create_wiki_link(wiki_id, body: WikiLinkRequest):
"""Link a wiki to another entity (card, epic, list, or board)."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Check if entity exists
entity = None
if body.entity_type == "card":
entity = db.session.get(Card, body.entity_id)
elif body.entity_type == "epic":
entity = db.session.get(Epic, body.entity_id)
elif body.entity_type == "list":
from app.models.list_model import List
entity = db.session.get(List, body.entity_id)
elif body.entity_type == "board":
entity = db.session.get(Board, body.entity_id)
if not entity:
return {"error": f"{body.entity_type.capitalize()} not found"}, 404
# Check if link already exists
existing_link = db.session.scalars(
select(wiki_entity_links).where(
and_(
wiki_entity_links.c.wiki_id == wiki_id,
wiki_entity_links.c.entity_type == body.entity_type,
wiki_entity_links.c.entity_id == body.entity_id,
)
)
).first()
if existing_link:
return {"error": "Link already exists"}, 400
# Create link using core insert
stmt = wiki_entity_links.insert().values(
wiki_id=wiki_id,
entity_type=body.entity_type,
entity_id=body.entity_id,
linked_by=get_jwt_identity(),
)
db.session.execute(stmt)
db.session.commit()
return {"message": "Link created successfully"}, 201
@kanban_bp.route("/wikis/<int:wiki_id>/links", methods=["DELETE"])
@jwt_required()
@validate(body=WikiLinkRequest)
def delete_wiki_link(wiki_id, body: WikiLinkRequest):
"""Delete a wiki entity link."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Delete link using core delete
stmt = sql_delete(wiki_entity_links).where(
and_(
wiki_entity_links.c.wiki_id == wiki_id,
wiki_entity_links.c.entity_type == body.entity_type,
wiki_entity_links.c.entity_id == body.entity_id,
)
)
result = db.session.execute(stmt)
if result.rowcount == 0:
return {"error": "Link not found"}, 404
db.session.commit()
return {"message": "Link deleted successfully"}, 200

View file

@ -7,19 +7,14 @@ from app.schemas.check_item import CheckItemCreateRequest, CheckItemResponse
from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse
from app.schemas.comment import (CommentCreateRequest, CommentResponse,
CommentWithUserResponse)
from app.schemas.epic import (EpicCreateRequest, EpicResponse,
EpicUpdateRequest, EpicWithCardsResponse)
from app.schemas.file_attachment import (FileAttachmentWithUrlResponse,
FileDeleteResponse, FileUploadRequest,
MultipleFilesResponse,
ValidationError)
from app.schemas.label import LabelCreateRequest, LabelResponse
from app.schemas.list_model import ListCreateRequest, ListResponse
from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink,
WikiLinkRequest, WikiResponse, WikiUpdateRequest)
from app.schemas.product import ProductCreateRequest, ProductResponse
__all__ = [
# Product schemas
"ProductCreateRequest",
"ProductResponse",
# Board schemas
"BoardCreateRequest",
"BoardResponse",
@ -41,25 +36,7 @@ __all__ = [
# Comment schemas
"CommentCreateRequest",
"CommentResponse",
# File Attachment schemas
"FileAttachmentWithUrlResponse",
"FileDeleteResponse",
"FileUploadRequest",
"MultipleFilesResponse",
"ValidationError",
# Composite schemas
"BoardWithDetailsResponse",
"CardWithDetailsResponse",
"CommentWithUserResponse",
# Epic schemas
"EpicCreateRequest",
"EpicResponse",
"EpicUpdateRequest",
"EpicWithCardsResponse",
# Wiki schemas
"WikiCreateRequest",
"WikiResponse",
"WikiUpdateRequest",
"WikiEntityLink",
"WikiLinkRequest",
]

View file

@ -47,7 +47,6 @@ class CardResponse(BaseModel):
"pos": 65535.0,
"list_id": 1,
"board_id": 1,
"epic_id": 1,
"due": "2024-12-31T23:59:59",
"due_complete": False,
"badges": {"votes": 0},
@ -65,7 +64,6 @@ class CardResponse(BaseModel):
pos: float
list_id: int
board_id: int
epic_id: Optional[int]
due: Optional[datetime] = None
due_complete: bool
badges: Optional[Dict[str, Any]] = None
@ -87,11 +85,8 @@ class CardWithDetailsResponse(BaseModel):
"closed": False,
"pos": 65535.0,
"list_id": 1,
"list_name": "list 1",
"board_id": 1,
"due": "2024-12-31T23:59:59",
"created_at": "2024-12-31T23:59:59",
"updated_at": "2024-12-31T23:59:59",
"due_complete": False,
"badges": {"votes": 0},
"cover": "https://example.com/cover.jpg",
@ -120,7 +115,3 @@ class CardWithDetailsResponse(BaseModel):
labels: List[Dict[str, Any]] = Field(default_factory=list)
checklists: List[Dict[str, Any]] = Field(default_factory=list)
comments: List[Dict[str, Any]] = Field(default_factory=list)
epic: Optional[Dict[str, Any]] = None
created_at: datetime
updated_at: datetime
list_name: str

View file

@ -1,139 +0,0 @@
"""Pydantic schemas for Epic model"""
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
class EpicCreateRequest(BaseModel):
"""Schema for creating a new epic"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "User Authentication",
"description": "Implement login and registration",
"content": [
{"type": "paragraph", "children": [{"text": "Details..."}]}
],
"color": "#3b82f6",
"pos": 1.0,
"depth_limit": 5,
"parent_epic_id": None,
"completed_list_id": 5,
}
}
)
name: str = Field(..., min_length=1, max_length=200, description="Epic name")
description: Optional[str] = Field(None, description="Epic description")
content: Optional[Any] = Field(None, description="Rich text content")
color: Optional[str] = Field(None, max_length=7, description="Hex color code")
pos: Optional[float] = Field(None, description="Position for ordering")
depth_limit: Optional[int] = Field(
None, ge=1, le=10, description="Maximum depth for hierarchy"
)
parent_epic_id: Optional[int] = Field(
None, description="Parent epic ID for nesting"
)
completed_list_id: Optional[int] = Field(
None, description="List ID that marks cards as completed"
)
class EpicResponse(BaseModel):
"""Schema for epic response"""
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"name": "User Authentication",
"description": "Implement login and registration",
"content": [
{"type": "paragraph", "children": [{"text": "Details..."}]}
],
"color": "#3b82f6",
"closed": False,
"pos": 1.0,
"depth_limit": 5,
"board_id": 1,
"parent_epic_id": None,
"metrics": {"card_count": 5, "progress": 60},
"date_last_activity": "2024-01-15T10:30:00",
"created_at": "2024-01-01T10:00:00",
"updated_at": "2024-01-15T10:30:00",
}
},
)
id: int
name: str
description: Optional[str] = None
content: Optional[Any] = None
color: Optional[str] = None
closed: bool
pos: Optional[float] = None
depth_limit: Optional[int] = None
board_id: int
parent_epic_id: Optional[int] = None
completed_list_id: Optional[int] = None
metrics: Optional[Dict[str, Any]] = None
date_last_activity: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class EpicUpdateRequest(BaseModel):
"""Schema for updating an epic"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Updated Epic Name",
"description": "Updated description",
"content": [
{"type": "paragraph", "children": [{"text": "Updated..."}]}
],
"color": "#ef4444",
"closed": True,
"pos": 2.0,
"depth_limit": 3,
"parent_epic_id": 2,
"completed_list_id": 5,
}
}
)
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
content: Optional[Any] = None
color: Optional[str] = Field(None, max_length=7)
closed: Optional[bool] = None
pos: Optional[float] = None
depth_limit: Optional[int] = Field(None, ge=1, le=10)
parent_epic_id: Optional[int] = None
completed_list_id: Optional[int] = None
class EpicWithCardsResponse(BaseModel):
"""Schema for epic response with cards"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
description: Optional[str] = None
content: Optional[Any] = None
color: Optional[str] = None
closed: bool
pos: Optional[float] = None
depth_limit: Optional[int] = None
board_id: int
parent_epic_id: Optional[int] = None
metrics: Optional[Dict[str, Any]] = None
date_last_activity: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
cards: List[Dict[str, Any]] = Field(default_factory=list)

View file

@ -1,72 +0,0 @@
"""Pydantic schemas for file attachments"""
from datetime import datetime
from typing import Optional
from pydantic import (BaseModel, ConfigDict, Field, field_serializer,
field_validator)
class FileUploadRequest(BaseModel):
"""Schema for file upload request"""
entity_type: str = Field(..., description="Type of entity (Card, Comment, etc.)")
entity_id: int = Field(..., description="ID of entity")
@field_validator("entity_type")
@classmethod
def validate_entity_type(cls, v: str) -> str:
"""Validate entity type is allowed"""
valid_types = ["Card", "Comment", "Epic", "Board"]
if v not in valid_types:
raise ValueError(f"Invalid entity_type: {v}. Must be one of {valid_types}")
return v
model_config = ConfigDict(from_attributes=True)
class FileAttachmentWithUrlResponse(BaseModel):
"""
Schema for file attachment with secure URLs
Security: Only exposes public-facing information.
Internal details (MinIO paths, IDs, etc.) are hidden.
"""
uuid: str
original_name: str
file_type: str
file_size: int
created_at: datetime
download_url: str
thumbnail_url: Optional[str] = None
view_url: str
mime_type: str
@field_serializer("created_at")
def serialize_created_at(self, value: datetime) -> str:
"""Serialize datetime to ISO format string"""
return value.isoformat() if value else None
model_config = ConfigDict(from_attributes=True)
class FileDeleteResponse(BaseModel):
"""Schema for file delete response"""
message: str = Field(..., description="Deletion status message")
file_id: int = Field(..., description="ID of deleted file")
class MultipleFilesResponse(BaseModel):
"""Schema for multiple files response"""
files: list[FileAttachmentWithUrlResponse]
count: int = Field(..., description="Number of files")
class ValidationError(BaseModel):
"""Schema for validation errors"""
error: str = Field(..., description="Error message")
details: Optional[dict] = None

View file

@ -0,0 +1,71 @@
"""Pydantic schemas for Product model"""
from datetime import datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ProductCreateRequest(BaseModel):
"""Schema for creating a new product"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Handcrafted Wooden Bowl",
"description": "A beautiful handcrafted bowl made from oak",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/bowl.jpg",
}
}
)
name: str = Field(..., min_length=1, max_length=200, description="Product name")
description: Optional[str] = Field(None, description="Product description")
price: Decimal = Field(
..., gt=0, description="Product price (must be greater than 0)"
)
stock: int = Field(default=0, ge=0, description="Product stock quantity")
image_url: Optional[str] = Field(
None, max_length=500, description="Product image URL"
)
@field_validator("price")
@classmethod
def validate_price(cls, v: Decimal) -> Decimal:
"""Validate that price has at most 2 decimal places"""
if v.as_tuple().exponent < -2:
raise ValueError("Price must have at most 2 decimal places")
return v
class ProductResponse(BaseModel):
"""Schema for product response"""
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"name": "Handcrafted Wooden Bowl",
"description": "A beautiful handcrafted bowl made from oak",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/bowl.jpg",
"is_active": True,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-01-15T10:30:00",
}
},
)
id: int
name: str
description: Optional[str] = None
price: float
stock: int
image_url: Optional[str] = None
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View file

@ -1,174 +0,0 @@
"""Pydantic schemas for Wiki model"""
from datetime import datetime
from typing import Any, List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.card import CardResponse
from app.schemas.epic import EpicResponse
class WikiCreateRequest(BaseModel):
"""Schema for creating a new wiki"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Security Guidelines",
"slug": "security-guidelines",
"content": [
{"type": "paragraph", "children": [{"text": "Security rules..."}]}
],
"summary": "Brief security overview",
"category": "Documentation",
"tags": ["security", "guidelines"],
}
}
)
name: str = Field(..., min_length=1, max_length=200, description="Wiki name")
slug: Optional[str] = Field(None, max_length=200, description="URL-friendly slug")
content: Any = Field(..., description="Rich text content")
summary: Optional[str] = Field(None, description="Brief summary")
category: Optional[str] = Field(None, max_length=100, description="Wiki category")
tags: Optional[List[str]] = Field(None, description="Wiki tags")
@field_validator("slug")
@classmethod
def validate_slug(cls, v):
"""Validate slug is URL-friendly."""
if v and not v.islower():
raise ValueError("Slug must be lowercase")
if v and not all(c.isalnum() or c == "-" for c in v):
raise ValueError(
"Slug must contain only lowercase letters, numbers, and hyphens"
)
return v
class WikiResponse(BaseModel):
"""Schema for wiki response"""
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"name": "Security Guidelines",
"slug": "security-guidelines",
"content": [
{"type": "paragraph", "children": [{"text": "Security rules..."}]}
],
"summary": "Brief security overview",
"category": "Documentation",
"tags": ["security", "guidelines"],
"board_id": 1,
"created_by": 1,
"updated_by": 1,
"created_at": "2024-01-01T10:00:00",
"updated_at": "2024-01-15T10:30:00",
"linked_cards": [],
"linked_epics": [],
}
},
)
id: int
name: str
slug: Optional[str] = None
content: Any
summary: Optional[str] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
board_id: int
created_by: Optional[int] = None
updated_by: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
linked_cards: Optional[List[CardResponse]] = None
linked_epics: Optional[List[EpicResponse]] = None
class WikiUpdateRequest(BaseModel):
"""Schema for updating a wiki"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Updated Security Guidelines",
"slug": "updated-security-guidelines",
"content": [
{"type": "paragraph", "children": [{"text": "Updated..."}]}
],
"summary": "Updated summary",
"category": "Updated Category",
"tags": ["security", "updated"],
}
}
)
name: Optional[str] = Field(None, min_length=1, max_length=200)
slug: Optional[str] = Field(None, max_length=200)
content: Optional[Any] = None
summary: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
tags: Optional[List[str]] = None
@field_validator("slug")
@classmethod
def validate_slug(cls, v):
"""Validate slug is URL-friendly."""
if v and not v.islower():
raise ValueError("Slug must be lowercase")
if v and not all(c.isalnum() or c == "-" for c in v):
raise ValueError(
"Slug must contain only lowercase letters, numbers, and hyphens"
)
return v
class WikiEntityLink(BaseModel):
"""Schema for wiki entity link response"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"wiki_id": 1,
"entity_type": "card",
"entity_id": 5,
"created_at": "2024-01-15T10:30:00",
"linked_by": 1,
}
}
)
wiki_id: int
entity_type: str
entity_id: int
created_at: Optional[datetime] = None
linked_by: Optional[int] = None
class WikiLinkRequest(BaseModel):
"""Schema for creating a wiki entity link"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"entity_type": "card",
"entity_id": 5,
}
}
)
entity_type: str = Field(..., description="Entity type (card, epic, list, board)")
entity_id: int = Field(..., description="Entity ID")
@field_validator("entity_type")
@classmethod
def validate_entity_type(cls, v):
"""Validate entity type is supported."""
valid_types = ["card", "epic", "list", "board"]
if v not in valid_types:
raise ValueError(f"entity_type must be one of: {', '.join(valid_types)}")
return v

View file

@ -1,20 +0,0 @@
"""Storage service module for file upload operations"""
from app.services.storage.attachment_service import AttachmentService
from app.services.storage.base import StorageAdapter
from app.services.storage.file_processor import (FileProcessor, ImageProcessor,
PDFProcessor)
from app.services.storage.file_service import FileService
from app.services.storage.minio_client import MinIOStorageAdapter
from app.services.storage.storage_extension import storage
__all__ = [
"StorageAdapter",
"MinIOStorageAdapter",
"FileProcessor",
"ImageProcessor",
"PDFProcessor",
"FileService",
"AttachmentService",
"storage",
]

View file

@ -1,231 +0,0 @@
"""Service for managing file attachments to entities"""
from io import BytesIO
from typing import List, Optional
from flask import current_app
from app.models import Card, Comment, FileAttachment
from app.services.storage.file_service import FileService
class AttachmentService:
"""Service for managing file attachments to Cards, Comments, etc."""
def __init__(self, file_service: Optional[FileService] = None):
"""
Initialize AttachmentService with dependency injection
Args:
file_service: FileService instance
(defaults to new instance with dependency injection)
"""
self.file_service = file_service or FileService()
def attach_file_to_card(
self,
card_id: int,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
) -> FileAttachment:
"""
Attach a file to a card
Args:
card_id: ID of the card
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
Returns:
FileAttachment: Created attachment record
"""
# Verify card exists
card = Card.query.get(card_id)
if not card:
raise ValueError(f"Card with ID {card_id} not found")
# Upload file
return self.file_service.upload_file(
file_data=file_data,
filename=filename,
content_type=content_type,
file_size=file_size,
uploaded_by=uploaded_by,
attachable_type="Card",
attachable_id=card_id,
)
def attach_file_to_comment(
self,
comment_id: int,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
) -> FileAttachment:
"""
Attach a file to a comment
Args:
comment_id: ID of the comment
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
Returns:
FileAttachment: Created attachment record
"""
# Verify comment exists
comment = Comment.query.get(comment_id)
if not comment:
raise ValueError(f"Comment with ID {comment_id} not found")
# Upload file
return self.file_service.upload_file(
file_data=file_data,
filename=filename,
content_type=content_type,
file_size=file_size,
uploaded_by=uploaded_by,
attachable_type="Comment",
attachable_id=comment_id,
)
def attach_file_to_entity(
self,
entity_type: str,
entity_id: int,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
) -> FileAttachment:
"""
Attach a file to any entity type
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
Returns:
FileAttachment: Created attachment record
"""
# Validate entity type
valid_types = ["Card", "Comment", "Epic", "Board"]
if entity_type not in valid_types:
raise ValueError(
f"Invalid entity type: {entity_type}. Must be one of {valid_types}"
)
# Upload file
return self.file_service.upload_file(
file_data=file_data,
filename=filename,
content_type=content_type,
file_size=file_size,
uploaded_by=uploaded_by,
attachable_type=entity_type,
attachable_id=entity_id,
)
def get_card_attachments(self, card_id: int) -> List[FileAttachment]:
"""
Get all attachments for a card
Args:
card_id: ID of the card
Returns:
list: List of FileAttachment objects
"""
return self.file_service.get_files_for_entity("Card", card_id)
def get_comment_attachments(self, comment_id: int) -> List[FileAttachment]:
"""
Get all attachments for a comment
Args:
comment_id: ID of the comment
Returns:
list: List of FileAttachment objects
"""
return self.file_service.get_files_for_entity("Comment", comment_id)
def get_entity_attachments(
self, entity_type: str, entity_id: int
) -> List[FileAttachment]:
"""
Get all attachments for any entity type
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
Returns:
list: List of FileAttachment objects
"""
return self.file_service.get_files_for_entity(entity_type, entity_id)
def get_attachment_with_url(self, attachment_id: int) -> dict:
"""
Get attachment with download URL
Args:
attachment_id: ID of the attachment
Returns:
dict: Attachment information with URLs
"""
return self.file_service.get_file_info(attachment_id)
def delete_attachment(self, attachment_id: int) -> bool:
"""
Delete an attachment
Args:
attachment_id: ID of the attachment
Returns:
bool: True if successful
"""
return self.file_service.delete_file(attachment_id)
def delete_entity_attachments(self, entity_type: str, entity_id: int) -> int:
"""
Delete all attachments for an entity (cascade delete)
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
Returns:
int: Number of attachments deleted
"""
attachments = self.get_entity_attachments(entity_type, entity_id)
deleted_count = 0
for attachment in attachments:
if self.delete_attachment(attachment.id):
deleted_count += 1
current_app.logger.info(
f"Deleted {deleted_count} attachments for {entity_type} {entity_id}"
)
return deleted_count

View file

@ -1,113 +0,0 @@
"""Abstract base class for storage adapters"""
from abc import ABC, abstractmethod
from io import BytesIO
class StorageAdapter(ABC):
"""Abstract base class for storage adapters"""
@abstractmethod
def upload_file(
self,
file_data: BytesIO,
bucket_name: str,
object_name: str,
content_type: str,
) -> dict:
"""
Upload a file to storage
Args:
file_data: File data as BytesIO
bucket_name: Name of the bucket
object_name: Name of the object in storage
content_type: MIME type of the file
Returns:
dict: Upload result with metadata
"""
pass
@abstractmethod
def download_file(self, bucket_name: str, object_name: str) -> BytesIO:
"""
Download a file from storage
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
BytesIO: File data
"""
pass
@abstractmethod
def delete_file(self, bucket_name: str, object_name: str) -> bool:
"""
Delete a file from storage
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
bool: True if successful
"""
pass
@abstractmethod
def generate_presigned_url(
self, bucket_name: str, object_name: str, expires_in: int = 3600
) -> str:
"""
Generate a presigned URL for file access
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL
"""
pass
@abstractmethod
def file_exists(self, bucket_name: str, object_name: str) -> bool:
"""
Check if a file exists in storage
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
bool: True if file exists
"""
pass
@abstractmethod
def get_file_info(self, bucket_name: str, object_name: str) -> dict:
"""
Get file metadata
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
dict: File metadata
"""
pass
@abstractmethod
def ensure_bucket_exists(self, bucket_name: str) -> None:
"""
Ensure a bucket exists, create if it doesn't
Args:
bucket_name: Name of the bucket
"""
pass

View file

@ -1,264 +0,0 @@
"""File processor classes for different file types"""
from abc import ABC, abstractmethod
from io import BytesIO
from typing import Optional, Tuple
from flask import current_app
from PIL import Image
class FileProcessor(ABC):
"""Abstract base class for file processors"""
@abstractmethod
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""
Validate file
Args:
file_data: File data as BytesIO
file_size: Size of the file in bytes
mime_type: MIME type of the file
Returns:
bool: True if valid
"""
pass
@abstractmethod
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""
Process file and return metadata
Args:
file_data: File data as BytesIO
mime_type: MIME type of the file
Returns:
dict: Processing results and metadata
"""
pass
@abstractmethod
def get_file_type(self) -> str:
"""Return the file type (image, pdf, document, etc.)"""
pass
class ImageProcessor(FileProcessor):
"""Processor for image files"""
def __init__(self):
self.supported_formats = {
"image/jpeg": "jpeg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/svg+xml": "svg+xml",
}
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""Validate image file"""
# Check file size
if file_size > current_app.config["MAX_UPLOAD_SIZE_IMAGE"]:
current_app.logger.error(
f"Image size {file_size} exceeds "
+ "maximum {current_app.config['MAX_UPLOAD_SIZE_IMAGE']}"
)
return False
# Check MIME type
if mime_type not in current_app.config["ALLOWED_IMAGE_TYPES"]:
current_app.logger.error(f"Unsupported image MIME type: {mime_type}")
return False
# Verify it's actually an image
try:
file_data.seek(0)
img = Image.open(file_data)
img.verify()
file_data.seek(0)
return True
except Exception as e:
current_app.logger.error(f"Image validation failed: {e}")
return False
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""Process image and extract metadata"""
file_data.seek(0)
img = Image.open(file_data)
metadata = {
"width": img.width,
"height": img.height,
"format": img.format,
"mode": img.mode,
"file_type": "image",
}
# Generate thumbnail
thumbnail_data = self._generate_thumbnail(img)
file_data.seek(0)
return {
"metadata": metadata,
"thumbnail_data": thumbnail_data,
}
def _generate_thumbnail(
self, img: Image.Image, size: Tuple[int, int] = (200, 200)
) -> Optional[BytesIO]:
"""
Generate thumbnail for image
Args:
img: PIL Image object
size: Thumbnail size (width, height)
Returns:
BytesIO: Thumbnail data or None
"""
try:
# Create thumbnail
img_copy = img.copy()
img_copy.thumbnail(size, Image.Resampling.LANCZOS)
# Convert RGBA to RGB if necessary (JPEG doesn't support transparency)
if img_copy.mode in ("RGBA", "LA", "P"):
# Create white background
background = Image.new("RGB", img_copy.size, (255, 255, 255))
if img_copy.mode == "P":
img_copy = img_copy.convert("RGBA")
if img_copy.mode in ("RGBA", "LA"):
background.paste(img_copy, mask=img_copy.split()[-1])
else:
background.paste(img_copy)
img_copy = background
elif img_copy.mode != "RGB":
img_copy = img_copy.convert("RGB")
# Save to BytesIO
thumb_data = BytesIO()
img_copy.save(thumb_data, format="JPEG", quality=85)
thumb_data.seek(0)
return thumb_data
except Exception as e:
current_app.logger.error(f"Thumbnail generation failed: {e}")
return None
def get_file_type(self) -> str:
return "image"
class PDFProcessor(FileProcessor):
"""Processor for PDF files"""
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""Validate PDF file"""
# Check file size
if file_size > current_app.config["MAX_UPLOAD_SIZE_DOCUMENT"]:
current_app.logger.error(
f"PDF size {file_size} exceeds "
f"maximum {current_app.config['MAX_UPLOAD_SIZE_DOCUMENT']}"
)
return False
# Check MIME type
if mime_type not in current_app.config["ALLOWED_DOCUMENT_TYPES"]:
current_app.logger.error(f"Unsupported document MIME type: {mime_type}")
return False
# Verify it's actually a PDF
try:
file_data.seek(0)
header = file_data.read(4)
file_data.seek(0)
if header != b"%PDF":
current_app.logger.error("Invalid PDF header")
return False
return True
except Exception as e:
current_app.logger.error(f"PDF validation failed: {e}")
return False
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""Process PDF and extract metadata"""
file_data.seek(0)
# Basic PDF metadata extraction
# Note: For more detailed extraction, you might want to use PyPDF2 or similar
metadata = {
"file_type": "pdf",
}
return {
"metadata": metadata,
"thumbnail_data": None, # PDFs don't have thumbnails by default
}
def get_file_type(self) -> str:
return "pdf"
class DocumentProcessor(FileProcessor):
"""Processor for other document types (placeholder for future expansion)"""
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""Validate document file"""
# Basic validation - can be extended for specific document types
if file_size > current_app.config["MAX_UPLOAD_SIZE_DOCUMENT"]:
current_app.logger.error("Document size exceeds maximum")
return False
return True
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""Process document"""
return {
"metadata": {"file_type": "document"},
"thumbnail_data": None,
}
def get_file_type(self) -> str:
return "document"
class ProcessorFactory:
"""Factory for creating appropriate file processors"""
@staticmethod
def get_processor(mime_type: str) -> FileProcessor:
"""
Get appropriate processor for MIME type
Args:
mime_type: MIME type of the file
Returns:
FileProcessor: Appropriate processor instance
"""
if mime_type.startswith("image/"):
return ImageProcessor()
elif mime_type == "application/pdf":
return PDFProcessor()
else:
return DocumentProcessor()
@staticmethod
def get_file_type_from_mime(mime_type: str) -> str:
"""
Get file type category from MIME type
Args:
mime_type: MIME type of the file
Returns:
str: File type category
"""
if mime_type.startswith("image/"):
return "image"
elif mime_type == "application/pdf":
return "pdf"
else:
return "document"

View file

@ -1,314 +0,0 @@
"""Main file service for handling file uploads"""
from io import BytesIO
from typing import Any, Dict, Optional
from flask import current_app
from app import db
from app.models import FileAttachment
from app.services.storage.base import StorageAdapter
from app.services.storage.file_processor import ProcessorFactory
class FileService:
"""Service for handling file upload and storage operations"""
def __init__(self, storage_adapter: Optional[StorageAdapter] = None):
"""
Initialize FileService with dependency injection
Args:
storage_adapter: Storage adapter
(optional, defaults to factory-provided adapter)
"""
if storage_adapter:
self.storage = storage_adapter
else:
from app.services.storage.storage_factory import StorageFactory
self.storage = StorageFactory.get_storage()
def upload_file(
self,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
attachable_type: str,
attachable_id: int,
) -> FileAttachment:
"""
Upload a file and create FileAttachment record
Args:
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
attachable_type: Type of entity (Card, Comment, etc.)
attachable_id: ID of the entity
Returns:
FileAttachment: Created attachment record
"""
# Get appropriate processor
processor = ProcessorFactory.get_processor(content_type)
# Validate file
if not processor.validate(file_data, file_size, content_type):
raise ValueError("File validation failed")
# Process file and get metadata (this also generates thumbnail)
file_data.seek(0) # Ensure file is at start
process_result = processor.process(file_data, content_type)
metadata = process_result["metadata"]
thumbnail_data = process_result["thumbnail_data"]
# Seek back to start for main file upload
file_data.seek(0)
# Determine bucket
bucket_name = self._get_bucket_for_type(content_type)
# Generate unique object name
object_name = self.storage.generate_unique_object_name(filename)
# Upload main file
self.storage.upload_file(
file_data, bucket_name, object_name, content_type, file_size
)
# Upload thumbnail if available
thumbnail_info = {}
if thumbnail_data:
try:
thumb_bucket = current_app.config["MINIO_THUMBNAILS_BUCKET"]
thumb_object_name = f"thumb_{object_name}"
# Get thumbnail length
thumbnail_data.seek(0, 2)
thumb_length = thumbnail_data.tell()
thumbnail_data.seek(0)
# Upload thumbnail
self.storage.upload_file(
thumbnail_data,
thumb_bucket,
thumb_object_name,
"image/jpeg",
length=thumb_length,
)
thumbnail_info = {
"thumbnail_minio_object_name": thumb_object_name,
"thumbnail_minio_bucket": thumb_bucket,
}
current_app.logger.info(
f"Thumbnail uploaded: {thumb_bucket}/{thumb_object_name}"
)
except Exception as e:
current_app.logger.error(f"Failed to upload thumbnail: {e}")
# Continue without thumbnail
# Create database record
attachment = FileAttachment(
filename=object_name,
original_name=filename,
file_type=metadata.get("file_type", "document"),
mime_type=content_type,
file_size=file_size,
minio_bucket=bucket_name,
minio_object_name=object_name,
uploaded_by=uploaded_by,
attachable_type=attachable_type,
attachable_id=attachable_id,
**thumbnail_info,
)
db.session.add(attachment)
db.session.commit()
current_app.logger.info(
f"File uploaded: {filename} -> {bucket_name}/{object_name}"
)
return attachment
def delete_file(self, attachment_id: int) -> bool:
"""
Delete a file and its attachment record
Args:
attachment_id: ID of the FileAttachment
Returns:
bool: True if successful
"""
attachment = db.session.get(FileAttachment, attachment_id)
if not attachment:
return False
# Delete from MinIO
try:
self.storage.delete_file(
attachment.minio_bucket, attachment.minio_object_name
)
# Delete thumbnail if exists
if (
attachment.thumbnail_minio_bucket
and attachment.thumbnail_minio_object_name
):
self.storage.delete_file(
attachment.thumbnail_minio_bucket,
attachment.thumbnail_minio_object_name,
)
except Exception as e:
current_app.logger.error(f"Error deleting file from MinIO: {e}")
# Delete from database
attachment.soft_delete()
current_app.logger.info(f"File deleted: {attachment.original_name}")
return True
def get_file_url(self, attachment_id: int, expires_in: int = 3600) -> str:
"""
Generate a presigned URL for file access
Args:
attachment_id: ID of the FileAttachment
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL
"""
attachment = db.session.get(FileAttachment, attachment_id)
if not attachment:
raise ValueError("Attachment not found")
return self.storage.generate_presigned_url(
attachment.minio_bucket,
attachment.minio_object_name,
expires_in,
)
def get_thumbnail_url(
self, attachment_id: int, expires_in: int = 3600
) -> Optional[str]:
"""
Generate a presigned URL for thumbnail access
Args:
attachment_id: ID of the FileAttachment
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL or None if no thumbnail
"""
attachment = db.session.get(FileAttachment, attachment_id)
if not attachment or not attachment.thumbnail_minio_bucket:
return None
return self.storage.generate_presigned_url(
attachment.thumbnail_minio_bucket,
attachment.thumbnail_minio_object_name,
expires_in,
)
def _get_bucket_for_type(self, mime_type: str) -> str:
"""
Get appropriate bucket for MIME type
Args:
mime_type: MIME type of the file
Returns:
str: Bucket name
"""
if mime_type.startswith("image/"):
return current_app.config["MINIO_IMAGES_BUCKET"]
elif mime_type == "application/pdf":
return current_app.config["MINIO_DOCUMENTS_BUCKET"]
else:
return current_app.config["MINIO_DOCUMENTS_BUCKET"]
def get_file_by_id(self, attachment_id: int) -> Optional[FileAttachment]:
"""
Get file attachment by ID
Args:
attachment_id: ID of the FileAttachment
Returns:
FileAttachment or None
"""
return db.session.get(FileAttachment, attachment_id)
def get_files_for_entity(
self, attachable_type: str, attachable_id: int
) -> list[FileAttachment]:
"""
Get all files attached to an entity
Args:
attachable_type: Type of entity (Card, Comment, etc.)
attachable_id: ID of the entity
Returns:
list: List of FileAttachment objects
"""
return FileAttachment.query.filter_by(
attachable_type=attachable_type, attachable_id=attachable_id
).all()
def get_file_info(self, attachment_id: int) -> Optional[Dict[str, Any]]:
"""
Get file information including UUID-based secure URLs
Security: Only exposes public-facing information.
Internal details (MinIO paths, IDs, entity references) are hidden.
Uses UUID for secure file access:
- UUIDs are non-sequential and hard to guess
- All endpoints require JWT authentication
- MinIO server stays hidden from public
- Frontend uses blob URLs for secure image display
Args:
attachment_id: ID of the FileAttachment
Returns:
dict: File information with UUID-based URLs
"""
attachment = self.get_file_by_id(attachment_id)
if not attachment:
return None
# Use UUID-based secure URLs (all require JWT authentication)
download_url = f"/files/{attachment.uuid}/download"
thumbnail_url = (
f"/files/{attachment.uuid}/thumbnail"
if attachment.thumbnail_minio_object_name
else None
)
view_url = f"/files/{attachment.uuid}/view"
# Only return public-facing fields (security: hide internal implementation)
return {
"uuid": attachment.uuid,
"original_name": attachment.original_name,
"file_type": attachment.file_type,
"file_size": attachment.file_size,
"mime_type": attachment.mime_type,
"created_at": attachment.created_at.isoformat()
if attachment.created_at
else None,
"download_url": download_url,
"thumbnail_url": thumbnail_url,
"view_url": view_url,
}

View file

@ -1,222 +0,0 @@
"""MinIO storage adapter implementation"""
import uuid
from io import BytesIO
from typing import Optional
from flask import current_app
from minio import Minio
from minio.error import S3Error
from app.services.storage.base import StorageAdapter
class MinIOStorageAdapter(StorageAdapter):
"""MinIO implementation of StorageAdapter"""
def __init__(self, minio_client: Optional[Minio] = None):
"""
Initialize MinIO adapter with client dependency
Args:
minio_client: MinIO client
instance (optional, defaults to extension's client)
"""
if minio_client is not None:
self.client = minio_client
else:
# Get client from storage extension (dependency injection)
from app.services.storage.storage_extension import storage
self.client = storage.get_client()
def upload_file(
self,
file_data: BytesIO,
bucket_name: str,
object_name: str,
content_type: str,
length: Optional[int] = None,
) -> dict:
"""
Upload a file to MinIO
Args:
file_data: File data as BytesIO
bucket_name: Name of bucket
object_name: Name of the object in MinIO
content_type: MIME type of the file
length: File size in bytes (optional, will be determined if not provided)
Returns:
dict: Upload result with metadata
"""
try:
# Get file length if not provided
if length is None:
file_data.seek(0, 2) # Seek to end
length = file_data.tell()
file_data.seek(0) # Seek back to start
# Ensure bucket exists
self.ensure_bucket_exists(bucket_name)
# Upload file
result = self.client.put_object(
bucket_name,
object_name,
file_data,
length=length,
content_type=content_type,
)
return {
"success": True,
"bucket": bucket_name,
"object_name": object_name,
"size": length,
"etag": result.etag,
"version_id": result.version_id
if hasattr(result, "version_id")
else None,
}
except S3Error as e:
current_app.logger.error(f"MinIO upload error: {e}")
raise
def download_file(self, bucket_name: str, object_name: str) -> BytesIO:
"""
Download a file from MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
BytesIO: File data
"""
try:
response = self.client.get_object(bucket_name, object_name)
return BytesIO(response.read())
except S3Error as e:
current_app.logger.error(f"MinIO download error: {e}")
raise
def delete_file(self, bucket_name: str, object_name: str) -> bool:
"""
Delete a file from MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
bool: True if successful
"""
try:
self.client.remove_object(bucket_name, object_name)
return True
except S3Error as e:
current_app.logger.error(f"MinIO delete error: {e}")
return False
def generate_presigned_url(
self, bucket_name: str, object_name: str, expires_in: int = 3600
) -> str:
"""
Generate a presigned URL for file access
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL
"""
try:
url = self.client.presigned_get_object(
bucket_name, object_name, expires=expires_in
)
return url
except S3Error as e:
current_app.logger.error(f"MinIO presigned URL error: {e}")
raise
def file_exists(self, bucket_name: str, object_name: str) -> bool:
"""
Check if a file exists in MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
bool: True if file exists
"""
try:
self.client.stat_object(bucket_name, object_name)
return True
except S3Error as e:
if e.code == "NoSuchKey":
return False
current_app.logger.error(f"MinIO check exists error: {e}")
return False
def get_file_info(self, bucket_name: str, object_name: str) -> dict:
"""
Get file metadata from MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
dict: File metadata
"""
try:
stat = self.client.stat_object(bucket_name, object_name)
return {
"size": stat.size,
"last_modified": stat.last_modified,
"etag": stat.etag,
"content_type": stat.content_type,
}
except S3Error as e:
current_app.logger.error(f"MinIO get file info error: {e}")
raise
def ensure_bucket_exists(self, bucket_name: str) -> None:
"""
Ensure a bucket exists, create if it doesn't
Args:
bucket_name: Name of the bucket
"""
try:
if not self.client.bucket_exists(bucket_name):
self.client.make_bucket(bucket_name)
current_app.logger.info(f"Created bucket: {bucket_name}")
except S3Error as e:
current_app.logger.error(f"MinIO ensure bucket error: {e}")
raise
def generate_unique_object_name(self, original_filename: str) -> str:
"""
Generate a unique object name for a file
Args:
original_filename: Original filename
Returns:
str: Unique object name
"""
# Extract file extension
ext = (
original_filename.rsplit(".", 1)[1].lower()
if "." in original_filename
else ""
)
# Generate unique filename with UUID
unique_name = f"{uuid.uuid4().hex}"
return f"{unique_name}.{ext}" if ext else unique_name

View file

@ -1,217 +0,0 @@
"""Mock storage adapter for testing (in-memory storage)"""
import uuid
from datetime import datetime, timezone
from io import BytesIO
from app.services.storage.base import StorageAdapter
class MockStorageAdapter(StorageAdapter):
"""In-memory storage adapter for testing purposes"""
# Class-level storage to persist across instances
_files = {} # {(bucket, object_name): BytesIO}
_file_info = {} # {(bucket, object_name): metadata}
_buckets = set() # Track created buckets
def __init__(self):
"""Initialize mock storage (storage is shared at class level)"""
pass
def upload_file(
self,
file_data: BytesIO,
bucket_name: str,
object_name: str,
content_type: str,
length: int = None,
) -> dict:
"""
Upload a file to in-memory storage
Args:
file_data: File data as BytesIO
bucket_name: Name of bucket
object_name: Name of object in storage
content_type: MIME type of file
length: File size in bytes (optional)
Returns:
dict: Upload result with metadata
"""
# Ensure bucket exists
self.ensure_bucket_exists(bucket_name)
# Get file length if not provided
if length is None:
file_data.seek(0, 2) # Seek to end
length = file_data.tell()
file_data.seek(0) # Seek back to start
# Store file data (create a copy to avoid reference issues)
file_data.seek(0)
file_copy = BytesIO(file_data.read())
# Store in memory
key = (bucket_name, object_name)
self._files[key] = file_copy
# Store metadata
metadata = {
"size": length,
"content_type": content_type,
"last_modified": datetime.now(timezone.utc),
"etag": uuid.uuid4().hex,
}
self._file_info[key] = metadata
return {
"success": True,
"bucket": bucket_name,
"object_name": object_name,
"size": length,
"etag": metadata["etag"],
}
def download_file(self, bucket_name: str, object_name: str) -> BytesIO:
"""
Download a file from in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
BytesIO: File data
Raises:
ValueError: If file doesn't exist
"""
key = (bucket_name, object_name)
if key not in self._files:
raise ValueError(f"File {bucket_name}/{object_name} not found")
# Create a copy to avoid modifying the original
self._files[key].seek(0)
return BytesIO(self._files[key].read())
def delete_file(self, bucket_name: str, object_name: str) -> bool:
"""
Delete a file from in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
bool: True if successful, False if file doesn't exist
"""
key = (bucket_name, object_name)
if key not in self._files:
return False
# Remove from storage
del self._files[key]
del self._file_info[key]
return True
def generate_presigned_url(
self, bucket_name: str, object_name: str, expires_in: int = 3600
) -> str:
"""
Generate a mock presigned URL
In tests, this returns a fake URL that looks like a real MinIO URL
but doesn't actually need to work since we use Flask proxy routes.
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
expires_in: URL expiration time in seconds (ignored in mock)
Returns:
str: Mock presigned URL
"""
return f"http://minio:9000/{bucket_name}/{object_name}?X-Amz-Token=mock"
def file_exists(self, bucket_name: str, object_name: str) -> bool:
"""
Check if a file exists in in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
bool: True if file exists
"""
return (bucket_name, object_name) in self._files
def get_file_info(self, bucket_name: str, object_name: str) -> dict:
"""
Get file metadata from in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
dict: File metadata
Raises:
ValueError: If file doesn't exist
"""
key = (bucket_name, object_name)
if key not in self._file_info:
raise ValueError(f"File {bucket_name}/{object_name} not found")
return self._file_info[key].copy()
def ensure_bucket_exists(self, bucket_name: str) -> None:
"""
Ensure a bucket exists, create if it doesn't
Args:
bucket_name: Name of bucket
"""
if bucket_name not in self._buckets:
self._buckets.add(bucket_name)
def generate_unique_object_name(self, original_filename: str) -> str:
"""
Generate a unique object name for a file
Args:
original_filename: Original filename
Returns:
str: Unique object name
"""
# Extract file extension
ext = (
original_filename.rsplit(".", 1)[1].lower()
if "." in original_filename
else ""
)
# Generate unique filename with UUID
unique_name = f"{uuid.uuid4().hex}"
return f"{unique_name}.{ext}" if ext else unique_name
@classmethod
def clear_all(cls) -> None:
"""Clear all files and buckets (useful for test cleanup)"""
cls._files.clear()
cls._file_info.clear()
cls._buckets.clear()
@classmethod
def get_all_files(cls) -> list:
"""
Get all stored files (useful for test assertions)
Returns:
list: List of (bucket, object_name) tuples
"""
return list(cls._files.keys())

View file

@ -1,123 +0,0 @@
"""Flask extension for MinIO storage client initialization"""
import logging
from flask import Flask
from minio import Minio
from minio.error import S3Error
class StorageExtension:
"""Flask extension for managing MinIO storage client"""
def __init__(self, app: Flask = None):
"""
Initialize StorageExtension
Args:
app: Flask application instance (optional)
"""
self.app = app
self.client = None
self.logger = logging.getLogger(__name__)
if app is not None:
self.init_app(app)
def init_app(self, app: Flask) -> None:
"""
Initialize the extension with Flask app
Args:
app: Flask application instance
"""
# Store extension on app
app.extensions = getattr(app, "extensions", {})
app.extensions["storage"] = self
# Store app reference for later use
self.app = app
# Initialize and validate MinIO client immediately at app startup
self._initialize_and_validate(app)
def _initialize_and_validate(self, app: Flask) -> None:
"""
Initialize MinIO client and validate connection
Args:
app: Flask application instance
"""
try:
# Create MinIO client
self.client = Minio(
app.config["MINIO_ENDPOINT"],
access_key=app.config["MINIO_ACCESS_KEY"],
secret_key=app.config["MINIO_SECRET_KEY"],
secure=app.config["MINIO_USE_SSL"],
region=app.config["MINIO_REGION"],
)
# Validate connection by listing buckets
self.client.list_buckets()
app.logger.info("MinIO client initialized and validated successfully")
# Ensure all required buckets exist
self._ensure_all_buckets_exist(app)
except S3Error as e:
app.logger.error(f"Failed to initialize MinIO client: {e}")
raise RuntimeError(
f"Failed to connect to MinIO at {app.config['MINIO_ENDPOINT']}. "
"Please check your MINIO_ENDPOINT, MINIO_ACCESS_KEY, "
"and MINIO_SECRET_KEY configuration."
) from e
except KeyError as e:
app.logger.error(f"Missing MinIO configuration: {e}")
raise RuntimeError(
f"Missing required MinIO configuration: {e}. "
"Please ensure MINIO_ENDPOINT, MINIO_ACCESS_KEY, "
"MINIO_SECRET_KEY are set."
) from e
def _ensure_all_buckets_exist(self, app: Flask) -> None:
"""
Ensure all required buckets exist
Creates buckets if they don't exist
Args:
app: Flask application instance
"""
buckets = [
app.config["MINIO_IMAGES_BUCKET"],
app.config["MINIO_DOCUMENTS_BUCKET"],
app.config["MINIO_THUMBNAILS_BUCKET"],
]
for bucket_name in buckets:
if self.client.bucket_exists(bucket_name):
app.logger.debug(f"MinIO bucket exists: {bucket_name}")
else:
app.logger.error(f"MinIO bucket does not exists: {bucket_name}")
raise RuntimeError(f"Failed to create MinIO bucket '{bucket_name}'")
def get_client(self) -> Minio:
"""
Get the MinIO client instance
Returns:
Minio: Initialized MinIO client
Raises:
RuntimeError: If client has not been initialized
"""
if self.client is None:
raise RuntimeError(
"MinIO client has not been initialized. "
"Ensure the Flask app context is active "
"and before_first_request has run."
)
return self.client
# Create a singleton instance
storage = StorageExtension()

View file

@ -1,56 +0,0 @@
"""Storage factory for creating storage adapters"""
from flask import current_app
class StorageFactory:
"""Factory for creating storage adapter instances"""
@staticmethod
def get_storage():
"""
Get the appropriate storage adapter based on configuration
Returns:
StorageAdapter: Configured storage adapter instance
Factory Pattern Benefits:
- Centralized adapter selection logic
- Easy to add new storage types (S3, GCS, etc.)
- Single Responsibility Principle - FileService only handles file operations
- Easy to test by injecting mock adapters
"""
# Check if we should use mock storage
if current_app.config.get("USE_MOCK_STORAGE", False):
from app.services.storage.mock_adapter import MockStorageAdapter
return MockStorageAdapter()
# Default to MinIO for production/development
from app.services.storage.minio_client import MinIOStorageAdapter
return MinIOStorageAdapter()
@staticmethod
def get_storage_for_testing():
"""
Get mock storage adapter explicitly for testing
Returns:
StorageAdapter: MockStorageAdapter instance
"""
from app.services.storage.mock_adapter import MockStorageAdapter
return MockStorageAdapter()
@staticmethod
def get_minio_storage():
"""
Get MinIO storage adapter explicitly
Returns:
StorageAdapter: MinIOStorageAdapter instance
"""
from app.services.storage.minio_client import MinIOStorageAdapter
return MinIOStorageAdapter()

View file

@ -1,106 +0,0 @@
"""add epic and wiki models
Revision ID: 6fc439155ced
Revises: e9515e29ef8b
Create Date: 2026-03-22 00:24:26.645867
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '6fc439155ced'
down_revision = 'e9515e29ef8b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('epics',
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('content', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('closed', sa.Boolean(), nullable=True),
sa.Column('pos', sa.Float(), nullable=True),
sa.Column('depth_limit', sa.Integer(), nullable=True),
sa.Column('board_id', sa.Integer(), nullable=False),
sa.Column('parent_epic_id', sa.Integer(), nullable=True),
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('metrics', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['parent_epic_id'], ['epics.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_epics_board_id'), ['board_id'], unique=False)
batch_op.create_index(batch_op.f('ix_epics_closed'), ['closed'], unique=False)
batch_op.create_index(batch_op.f('ix_epics_name'), ['name'], unique=False)
op.create_table('wikis',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=True),
sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('summary', sa.Text(), nullable=True),
sa.Column('category', sa.String(length=100), nullable=True),
sa.Column('board_id', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('updated_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('tags', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_wikis_board_id'), ['board_id'], unique=False)
batch_op.create_index(batch_op.f('ix_wikis_name'), ['name'], unique=False)
batch_op.create_index(batch_op.f('ix_wikis_slug'), ['slug'], unique=False)
op.create_table('wiki_entity_links',
sa.Column('wiki_id', sa.Integer(), nullable=False),
sa.Column('entity_type', sa.String(length=50), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('linked_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['linked_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['wiki_id'], ['wikis.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('wiki_id')
)
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.add_column(sa.Column('epic_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_cards_epic_id'), ['epic_id'], unique=False)
batch_op.create_foreign_key(None, 'epics', ['epic_id'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_cards_epic_id'))
batch_op.drop_column('epic_id')
op.drop_table('wiki_entity_links')
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_wikis_slug'))
batch_op.drop_index(batch_op.f('ix_wikis_name'))
batch_op.drop_index(batch_op.f('ix_wikis_board_id'))
op.drop_table('wikis')
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_epics_name'))
batch_op.drop_index(batch_op.f('ix_epics_closed'))
batch_op.drop_index(batch_op.f('ix_epics_board_id'))
op.drop_table('epics')
# ### end Alembic commands ###

View file

@ -1,146 +0,0 @@
"""add soft delete columns
Revision ID: 7a0cfda486e1
Revises: bf430156bcf2
Create Date: 2026-05-01 13:40:24.316892
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7a0cfda486e1'
down_revision = 'bf430156bcf2'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('boards', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_boards_status'), ['status'], unique=False)
with op.batch_alter_table('card_labels', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_card_labels_status'), ['status'], unique=False)
with op.batch_alter_table('card_links', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_card_links_status'), ['status'], unique=False)
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_cards_status'), ['status'], unique=False)
with op.batch_alter_table('check_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_check_items_status'), ['status'], unique=False)
with op.batch_alter_table('checklists', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_checklists_status'), ['status'], unique=False)
with op.batch_alter_table('comments', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_comments_status'), ['status'], unique=False)
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_epics_status'), ['status'], unique=False)
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_file_attachments_status'), ['status'], unique=False)
with op.batch_alter_table('labels', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_labels_status'), ['status'], unique=False)
with op.batch_alter_table('lists', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_lists_status'), ['status'], unique=False)
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_wikis_status'), ['status'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_wikis_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('lists', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_lists_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('labels', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_labels_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_file_attachments_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_epics_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('comments', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_comments_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('checklists', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_checklists_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('check_items', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_check_items_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_cards_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('card_links', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_card_links_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('card_labels', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_card_labels_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('boards', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_boards_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
# ### end Alembic commands ###

View file

@ -1,36 +0,0 @@
"""epic_add completion list id
Revision ID: 84551733ccb0
Revises: 6fc439155ced
Create Date: 2026-03-28 21:54:31.562023
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '84551733ccb0'
down_revision = '6fc439155ced'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.add_column(sa.Column('completed_list_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_epics_completed_list_id'), ['completed_list_id'], unique=False)
batch_op.create_foreign_key(None, 'lists', ['completed_list_id'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_epics_completed_list_id'))
batch_op.drop_column('completed_list_id')
# ### end Alembic commands ###

View file

@ -1,77 +0,0 @@
"""remove products tables
Revision ID: a9709e7ed22d
Revises: 84551733ccb0
Create Date: 2026-04-12 12:55:32.064593
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'a9709e7ed22d'
down_revision = '84551733ccb0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Drop order_items first (has FK to products)
op.drop_table('order_items')
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_products_name'))
op.drop_table('products')
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_orders_status'))
op.drop_table('orders')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('orders',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('status', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('total_amount', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
sa.Column('shipping_address', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('orders_user_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('orders_pkey'))
)
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_orders_status'), ['status'], unique=False)
op.create_table('order_items',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('order_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('product_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('quantity', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['order_id'], ['orders.id'], name=op.f('order_items_order_id_fkey')),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], name=op.f('order_items_product_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('order_items_pkey'))
)
op.create_table('products',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=200), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
sa.Column('stock', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('image_url', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('products_pkey'))
)
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_products_name'), ['name'], unique=False)
# ### end Alembic commands ###

View file

@ -1,49 +0,0 @@
"""add_card_links_table
Revision ID: bf430156bcf2
Revises: a9709e7ed22d
Create Date: 2026-04-30 19:37:25.884514
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bf430156bcf2'
down_revision = 'a9709e7ed22d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('card_links',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('parent_card_id', sa.Integer(), nullable=False),
sa.Column('child_card_id', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['child_card_id'], ['cards.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['parent_card_id'], ['cards.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('parent_card_id', 'child_card_id', name='unique_card_link')
)
with op.batch_alter_table('card_links', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_card_links_child_card_id'), ['child_card_id'], unique=False)
batch_op.create_index(batch_op.f('ix_card_links_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_card_links_parent_card_id'), ['parent_card_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('card_links', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_card_links_parent_card_id'))
batch_op.drop_index(batch_op.f('ix_card_links_created_by'))
batch_op.drop_index(batch_op.f('ix_card_links_child_card_id'))
op.drop_table('card_links')
# ### end Alembic commands ###

View file

@ -1,54 +0,0 @@
"""add file upload
Revision ID: c5b574480b80
Revises: 1c0b9dfbd933
Create Date: 2026-03-20 20:16:04.599906
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c5b574480b80'
down_revision = '1c0b9dfbd933'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('file_attachments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('original_name', sa.String(length=255), nullable=False),
sa.Column('file_type', sa.String(length=50), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('minio_bucket', sa.String(length=100), nullable=False),
sa.Column('minio_object_name', sa.String(length=255), nullable=False),
sa.Column('thumbnail_minio_object_name', sa.String(length=255), nullable=True),
sa.Column('thumbnail_minio_bucket', sa.String(length=100), nullable=True),
sa.Column('attachable_type', sa.String(length=50), nullable=False),
sa.Column('attachable_id', sa.Integer(), nullable=False),
sa.Column('uploaded_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('minio_object_name')
)
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.create_index('ix_file_attachments_attachable', ['attachable_type', 'attachable_id'], unique=False)
batch_op.create_index('ix_file_attachments_user', ['uploaded_by'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.drop_index('ix_file_attachments_user')
batch_op.drop_index('ix_file_attachments_attachable')
op.drop_table('file_attachments')
# ### end Alembic commands ###

View file

@ -1,59 +0,0 @@
"""Add UUID to file_attachments
Revision ID: e9515e29ef8b
Revises: c5b574480b80
Create Date: 2026-03-20 21:58:22.006727
"""
from alembic import op
import sqlalchemy as sa
import uuid
# revision identifiers, used by Alembic.
revision = 'e9515e29ef8b'
down_revision = 'c5b574480b80'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Step 1: Add UUID column as nullable
op.add_column('file_attachments', sa.Column('uuid', sa.String(length=36), nullable=True))
# Step 2: Populate UUID for existing rows
from sqlalchemy.sql import table, column, select, update
file_attachments_table = table('file_attachments',
column('id', sa.Integer),
column('uuid', sa.String(36))
)
connection = op.get_bind()
# Get all existing file attachments
result = connection.execute(select(file_attachments_table.c.id))
for row in result:
new_uuid = str(uuid.uuid4())
connection.execute(
file_attachments_table.update()
.where(file_attachments_table.c.id == row[0])
.values(uuid=new_uuid)
)
# Step 3: Make UUID not null and add constraints
op.alter_column('file_attachments', 'uuid', nullable=False)
op.create_index('ix_file_attachments_uuid', 'file_attachments', ['uuid'], unique=False)
op.create_unique_constraint('uq_file_attachments_uuid', 'file_attachments', ['uuid'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uq_file_attachments_uuid', 'file_attachments', type_='unique')
op.drop_index('ix_file_attachments_uuid', table_name='file_attachments')
op.drop_column('file_attachments', 'uuid')
# ### end Alembic commands ###

View file

@ -10,8 +10,6 @@ Werkzeug==3.0.1
SQLAlchemy==2.0.23
celery[redis]==5.3.6
pydantic==2.5.3
minio==7.2.0
Pillow==10.1.0
pytest==7.4.3
pytest-flask==1.3.0
pytest-cov==4.1.0

View file

@ -1,20 +1,17 @@
"""Pytest configuration and fixtures"""
import logging
import time
from io import BytesIO
import pytest
from faker import Faker
from PIL import Image
from app import create_app, db
from app.models import Board, Card, Comment, List, User
from app.models import Order, OrderItem, Product, User
log = logging.getLogger(__name__)
fake = Faker()
@pytest.fixture(scope="session")
@pytest.fixture(scope="function")
def app():
"""Create application for testing with PostgreSQL database (session scope)"""
app = create_app(config_name="test")
@ -116,7 +113,7 @@ def regular_user(db_session):
"""Create a regular user for testing"""
user = User(
email=fake.email(),
username=f"{fake.user_name()}_{int(time.time() * 1000)}",
username=fake.user_name(),
first_name=fake.first_name(),
last_name=fake.last_name(),
is_admin=False,
@ -145,6 +142,39 @@ def inactive_user(db_session):
return user
@pytest.fixture
def product(db_session):
"""Create a product for testing"""
product = Product(
name=fake.sentence(nb_words=4)[:-1], # Remove period
description=fake.paragraph(),
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
stock=fake.pyint(min_value=0, max_value=100),
image_url=fake.url(),
)
db_session.add(product)
db_session.commit()
return product
@pytest.fixture
def products(db_session):
"""Create multiple products for testing"""
products = []
for _ in range(5):
product = Product(
name=fake.sentence(nb_words=4)[:-1],
description=fake.paragraph(),
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
stock=fake.pyint(min_value=20, max_value=100),
image_url=fake.url(),
)
db_session.add(product)
products.append(product)
db_session.commit()
return products
@pytest.fixture
def auth_headers(client, regular_user):
"""Get authentication headers for a regular user"""
@ -170,89 +200,28 @@ def admin_headers(client, admin_user):
return {"Authorization": f"Bearer {token}"}
# ============ File Upload Fixtures ============
@pytest.fixture
def test_image_file():
"""Create a test image file as BytesIO"""
# Create a simple red PNG image using PIL
img = Image.new("RGB", (10, 10), color="red")
img_io = BytesIO()
img.save(img_io, format="PNG")
img_io.seek(0)
return img_io
@pytest.fixture
def test_large_image_file():
"""Create a larger test image (50KB)"""
# Create a larger image using PIL
img = Image.new("RGB", (200, 200), color="blue")
img_io = BytesIO()
img.save(img_io, format="PNG")
img_io.seek(0)
return img_io
@pytest.fixture
def test_pdf_file():
"""Create a test PDF file as BytesIO"""
# Minimal valid PDF
pdf_data = b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF"
return BytesIO(pdf_data)
@pytest.fixture
def test_board(db_session, regular_user):
"""Create a test board"""
board = Board(
user_id=regular_user.id,
name=fake.sentence(nb_words=4)[:-1],
description=fake.paragraph(),
def order(db_session, regular_user, products):
print("-----order-created------")
"""Create an order for testing"""
order = Order(
user_id=regular_user.id, total_amount=0.0, shipping_address=fake.address()
)
db_session.add(board)
db_session.commit()
return board
db_session.add(order)
db_session.flush()
@pytest.fixture
def test_list(db_session, test_board):
"""Create a test list"""
lst = List(
board_id=test_board.id,
name=fake.sentence(nb_words=3)[:-1],
pos=0,
total_amount = 0
for i, product in enumerate(products[:2]):
quantity = fake.pyint(min_value=1, max_value=5)
order_item = OrderItem(
order_id=order.id,
product_id=product.id,
quantity=quantity,
price=product.price,
)
db_session.add(lst)
total_amount += float(product.price) * quantity
db_session.add(order_item)
order.total_amount = total_amount
db_session.commit()
return lst
@pytest.fixture
def test_card(db_session, test_list):
"""Create a test card"""
card = Card(
list_id=test_list.id,
name=fake.sentence(nb_words=4)[:-1],
description=fake.paragraph(),
pos=0,
due=None,
board_id=test_list.board_id,
)
db_session.add(card)
db_session.commit()
return card
@pytest.fixture
def test_comment(db_session, test_card, regular_user):
"""Create a test comment"""
comment = Comment(
card_id=test_card.id,
user_id=regular_user.id,
text=fake.paragraph(),
)
db_session.add(comment)
db_session.commit()
return comment
return order

View file

@ -1,8 +1,7 @@
import pytest
from app import db
from app.models import (Board, Card, CardLabel, CheckItem, Checklist, Comment,
Label, List)
from app.models import Board, Card, List
@pytest.mark.integration
@ -148,8 +147,7 @@ class TestBoardRoutes:
# Verify board is deleted
deleted_board = db.session.get(Board, board.id)
assert deleted_board is not None
assert deleted_board.status == "deleted"
assert deleted_board is None
def test_delete_board_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent board"""
@ -166,328 +164,3 @@ class TestBoardRoutes:
response = client.delete(f"/api/boards/{board.id}")
assert response.status_code == 401
def test_get_board_filters_deleted_cards(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted cards are filtered out from board response"""
# Create board with list and 2 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()
card1 = Card(name="Active Card", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Deleted Card", board_id=board.id, list_id=lst.id, pos=1)
db_session.add_all([card1, card2])
db_session.commit()
# Soft delete card2
card2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 card (the active one)
assert len(data["lists"][0]["cards"]) == 1
assert data["lists"][0]["cards"][0]["name"] == "Active Card"
def test_get_board_filters_deleted_lists(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted lists are filtered out from board response"""
# Create board with 2 lists
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)
lst2 = List(name="Deleted List", board_id=board.id, pos=1)
db_session.add_all([lst1, lst2])
db_session.commit()
# Soft delete lst2
lst2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 list (the active one)
assert len(data["lists"]) == 1
assert data["lists"][0]["name"] == "To Do"
def test_get_board_filters_deleted_comments(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted comments are filtered out from card response"""
# Create board, list, card and 2 comments
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="Active comment", card_id=card.id, user_id=regular_user.id
)
comment2 = Comment(
text="Deleted comment", card_id=card.id, user_id=regular_user.id
)
db_session.add_all([comment1, comment2])
db_session.commit()
# Soft delete comment2
comment2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 comment (the active one)
assert len(data["lists"][0]["cards"][0]["comments"]) == 1
assert data["lists"][0]["cards"][0]["comments"][0]["text"] == "Active comment"
def test_get_board_filters_deleted_checklists(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted checklists are filtered out from card response"""
# Create board, list, card and 2 checklists
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()
checklist1 = Checklist(
name="Active Checklist", board_id=board.id, card_id=card.id, pos=0
)
checklist2 = Checklist(
name="Deleted Checklist", board_id=board.id, card_id=card.id, pos=1
)
db_session.add_all([checklist1, checklist2])
db_session.commit()
# Soft delete checklist2
checklist2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 checklist (the active one)
assert len(data["lists"][0]["cards"][0]["checklists"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["name"] == "Active Checklist"
)
def test_get_board_filters_deleted_check_items(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted check items are
filtered out from checklist response"""
# Create board, list, card, checklist and 2 check items
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()
item1 = CheckItem(name="Active Task", checklist_id=checklist.id, pos=0)
item2 = CheckItem(name="Deleted Task", checklist_id=checklist.id, pos=1)
db_session.add_all([item1, item2])
db_session.commit()
# Soft delete item2
item2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 check item (the active one)
assert len(data["lists"][0]["cards"][0]["checklists"][0]["items"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["items"][0]["name"]
== "Active Task"
)
def test_get_board_filters_deleted_card_labels(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted card-label associations are filtered out"""
# Create board, list, card, label and 2 card-label associations
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()
label1 = Label(name="Urgent", color="red", board_id=board.id)
label2 = Label(name="Important", color="yellow", board_id=board.id)
db_session.add_all([label1, label2])
db_session.flush()
card_label1 = CardLabel(card_id=card.id, label_id=label1.id)
card_label2 = CardLabel(card_id=card.id, label_id=label2.id)
db_session.add_all([card_label1, card_label2])
db_session.commit()
# Soft delete card_label2
card_label2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 label (the active one)
assert len(data["lists"][0]["cards"][0]["labels"]) == 1
assert data["lists"][0]["cards"][0]["labels"][0]["name"] == "Urgent"
def test_get_board_comprehensive_soft_delete_filtering(
self, client, db_session, regular_user, auth_headers
):
"""Test comprehensive soft delete filtering across all nested resources"""
# Create board with all types of nested resources, some deleted
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
# Active list
lst1 = List(name="Active List", board_id=board.id, pos=0)
db_session.add(lst1)
db_session.flush()
card1 = Card(name="Active Card 1", board_id=board.id, list_id=lst1.id, pos=0)
card2 = Card(name="Deleted Card", board_id=board.id, list_id=lst1.id, pos=1)
db_session.add_all([card1, card2])
db_session.flush()
# Active comment
comment1 = Comment(
text="Active comment", card_id=card1.id, user_id=regular_user.id
)
# Deleted comment
comment2 = Comment(
text="Deleted comment", card_id=card1.id, user_id=regular_user.id
)
db_session.add_all([comment1, comment2])
db_session.flush()
# Active checklist
checklist1 = Checklist(
name="Active Checklist", board_id=board.id, card_id=card1.id, pos=0
)
# Deleted checklist
checklist2 = Checklist(
name="Deleted Checklist", board_id=board.id, card_id=card1.id, pos=1
)
db_session.add_all([checklist1, checklist2])
db_session.flush()
# Active check item
item1 = CheckItem(name="Active Task", checklist_id=checklist1.id, pos=0)
# Deleted check item
item2 = CheckItem(name="Deleted Task", checklist_id=checklist1.id, pos=1)
db_session.add_all([item1, item2])
db_session.flush()
# Labels
label1 = Label(name="Active Label", color="red", board_id=board.id)
label2 = Label(name="Deleted Label", color="yellow", board_id=board.id)
db_session.add_all([label1, label2])
db_session.flush()
# Active card-label
card_label1 = CardLabel(card_id=card1.id, label_id=label1.id)
# Deleted card-label
card_label2 = CardLabel(card_id=card1.id, label_id=label2.id)
db_session.add_all([card_label1, card_label2])
db_session.commit()
# Soft delete some resources
card2.soft_delete()
comment2.soft_delete()
checklist2.soft_delete()
item2.soft_delete()
card_label2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Verify lists: should have 1 list (we didn't delete the list)
assert len(data["lists"]) == 1
assert data["lists"][0]["name"] == "Active List"
# Verify cards: should have 1 card (card2 is deleted)
assert len(data["lists"][0]["cards"]) == 1
assert data["lists"][0]["cards"][0]["name"] == "Active Card 1"
# Verify comments: should have 1 comment (comment2 is deleted)
assert len(data["lists"][0]["cards"][0]["comments"]) == 1
assert data["lists"][0]["cards"][0]["comments"][0]["text"] == "Active comment"
# Verify checklists: should have 1 checklist (checklist2 is deleted)
assert len(data["lists"][0]["cards"][0]["checklists"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["name"] == "Active Checklist"
)
# Verify check items: should have 1 item (item2 is deleted)
assert len(data["lists"][0]["cards"][0]["checklists"][0]["items"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["items"][0]["name"]
== "Active Task"
)
# Verify labels: should have 1 label (card_label2 is deleted)
assert len(data["lists"][0]["cards"][0]["labels"]) == 1
assert data["lists"][0]["cards"][0]["labels"][0]["name"] == "Active Label"

View file

@ -160,8 +160,7 @@ class TestCardRoutes:
# Verify card is deleted
deleted_card = db.session.get(Card, card.id)
assert deleted_card is not None
assert deleted_card.status == "deleted"
assert deleted_card is None
def test_delete_card_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent card"""
@ -169,38 +168,6 @@ class TestCardRoutes:
assert response.status_code == 404
def test_get_soft_deleted_card_returns_404(
self, client, db_session, regular_user, auth_headers
):
"""Test that getting a soft-deleted card returns 404"""
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()
# Soft delete the card
card.soft_delete()
db_session.commit()
# Verify card is deleted in database
deleted_card = db.session.get(Card, card.id)
assert deleted_card is not None
assert deleted_card.status == "deleted"
assert deleted_card.deleted_at is not None
# Try to get the deleted card via API
response = client.get(f"/api/cards/{card.id}", headers=auth_headers)
# Should return 404
assert response.status_code == 404
def test_update_card_position_within_same_list(
self, client, db_session, regular_user, auth_headers
):

View file

@ -245,8 +245,7 @@ class TestChecklistRoutes:
# Verify checklist is deleted
deleted_checklist = db.session.get(Checklist, checklist.id)
assert deleted_checklist is not None
assert deleted_checklist.status == "deleted"
assert deleted_checklist is None
def test_delete_checklist_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent checklist"""
@ -288,8 +287,7 @@ class TestChecklistRoutes:
# Verify check item is deleted
deleted_item = db.session.get(CheckItem, item.id)
assert deleted_item is not None
assert deleted_item.status == "deleted"
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"""

View file

@ -196,8 +196,7 @@ class TestCommentRoutes:
# Verify comment is deleted
deleted_comment = db.session.get(Comment, comment.id)
assert deleted_comment is not None
assert deleted_comment.status == "deleted"
assert deleted_comment is None
def test_delete_comment_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent comment"""

View file

@ -1,703 +0,0 @@
"""Integration tests for Epic API routes"""
import pytest
from app import db
from app.models import Epic
@pytest.mark.integration
class TestEpicRoutes:
"""Test Epic API routes"""
# ========== GET /boards/<board_id>/epics ==========
def test_get_board_epics_success(
self, client, db_session, auth_headers, test_board
):
"""Test getting all epics for a board"""
# Create epics for the board
epic1 = Epic(name="Epic 1", board_id=test_board.id, color="#3b82f6", pos=1.0)
epic2 = Epic(name="Epic 2", board_id=test_board.id, color="#ef4444", pos=2.0)
db_session.add(epic1)
db_session.add(epic2)
db_session.commit()
response = client.get(
f"/api/boards/{test_board.id}/epics", headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert len(data) == 2
assert data[0]["name"] == "Epic 1"
assert data[1]["name"] == "Epic 2"
def test_get_board_epics_empty(self, client, db_session, auth_headers, test_board):
"""Test getting epics when none exist for board"""
response = client.get(
f"/api/boards/{test_board.id}/epics", headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert len(data) == 0
def test_get_board_epics_board_not_found(self, client, db_session, auth_headers):
"""Test getting epics for non-existent board"""
response = client.get("/api/boards/99999/epics", headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_get_board_epics_unauthorized(self, client, db_session, test_board):
"""Test getting epics without authentication"""
response = client.get(f"/api/boards/{test_board.id}/epics")
assert response.status_code == 401
# ========== POST /boards/<board_id>/epics ==========
def test_create_epic_success(
self, client, db_session, regular_user, auth_headers, test_board
):
"""Test creating a new epic successfully"""
epic_data = {
"name": "User Authentication",
"description": "Implement login and registration",
"content": [{"type": "paragraph", "children": [{"text": "Details..."}]}],
"color": "#3b82f6",
"pos": 1.0,
"depth_limit": 5,
}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "User Authentication"
assert data["description"] == "Implement login and registration"
assert data["color"] == "#3b82f6"
assert data["pos"] == 1.0
assert data["depth_limit"] == 5
assert data["closed"] is False
assert "id" in data
assert data["metrics"] == {"card_count": 0, "completed_cards_count": 0}
def test_create_epic_minimal_data(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic with only required fields"""
epic_data = {"name": "Minimal Epic"}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "Minimal Epic"
assert data["description"] is None
assert data["color"] is None
assert data["pos"] is None
assert data["depth_limit"] == 5 # Model default value
def test_create_epic_with_parent(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic with parent epic"""
# Create parent epic
parent_epic = Epic(name="Parent Epic", board_id=test_board.id)
db_session.add(parent_epic)
db_session.commit()
epic_data = {
"name": "Child Epic",
"parent_epic_id": parent_epic.id,
}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["parent_epic_id"] == parent_epic.id
def test_create_epic_with_completed_list(
self, client, db_session, auth_headers, test_board, test_list
):
"""Test creating epic with completed list"""
epic_data = {
"name": "Epic with Completed List",
"completed_list_id": test_list.id,
}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["completed_list_id"] == test_list.id
def test_create_epic_board_not_found(self, client, db_session, auth_headers):
"""Test creating epic for non-existent board"""
epic_data = {"name": "Test Epic"}
response = client.post(
"/api/boards/99999/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_create_epic_unauthorized(self, client, db_session, test_board):
"""Test creating epic without authentication"""
epic_data = {"name": "Test Epic"}
response = client.post(
f"/api/boards/{test_board.id}/epics",
json=epic_data,
)
assert response.status_code == 401
def test_create_epic_missing_name(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic without name"""
epic_data = {"description": "No name provided"}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 400
def test_create_epic_name_too_long(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic with name exceeding max length"""
epic_data = {"name": "A" * 201}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 400
def test_create_epic_invalid_depth_limit(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic with invalid depth limit"""
epic_data = {"name": "Test Epic", "depth_limit": 15}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 400
def test_create_epic_invalid_color(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic with invalid color code"""
epic_data = {"name": "Test Epic", "color": "invalid-color"}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 400
# ========== GET /epics/<epic_id> ==========
def test_get_epic_success(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test getting a specific epic with details"""
# Create epic and link card
epic = Epic(name="Test Epic", board_id=test_board.id, color="#3b82f6")
db_session.add(epic)
db_session.commit()
test_card.epic_id = epic.id
db_session.commit()
response = client.get(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data["id"] == epic.id
assert data["name"] == "Test Epic"
assert "cards" in data
assert len(data["cards"]) == 1
assert data["cards"][0]["id"] == test_card.id
def test_get_epic_with_no_cards(self, client, db_session, auth_headers, test_board):
"""Test getting epic with no cards"""
epic = Epic(name="Empty Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.get(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data["id"] == epic.id
assert "cards" in data
assert len(data["cards"]) == 0
def test_get_epic_not_found(self, client, db_session, auth_headers):
"""Test getting non-existent epic"""
response = client.get("/api/epics/99999", headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_get_epic_unauthorized(self, client, db_session, test_board):
"""Test getting epic without authentication"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.get(f"/api/epics/{epic.id}")
assert response.status_code == 401
# ========== PUT /epics/<epic_id> ==========
def test_update_epic_success(self, client, db_session, auth_headers, test_board):
"""Test updating an epic successfully"""
epic = Epic(
name="Original Name",
board_id=test_board.id,
color="#3b82f6",
closed=False,
)
db_session.add(epic)
db_session.commit()
update_data = {
"name": "Updated Name",
"description": "Updated description",
"color": "#ef4444",
"closed": True,
"pos": 5.0,
"depth_limit": 3,
}
response = client.put(
f"/api/epics/{epic.id}",
headers=auth_headers,
json=update_data,
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Name"
assert data["description"] == "Updated description"
assert data["color"] == "#ef4444"
assert data["closed"] is True
assert data["pos"] == 5.0
assert data["depth_limit"] == 3
def test_update_epic_partial(self, client, db_session, auth_headers, test_board):
"""Test updating epic with partial data"""
epic = Epic(name="Original Name", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
update_data = {"name": "Updated Name"}
response = client.put(
f"/api/epics/{epic.id}",
headers=auth_headers,
json=update_data,
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Name"
# Other fields should remain unchanged
assert data["description"] is None
def test_update_epic_content(self, client, db_session, auth_headers, test_board):
"""Test updating epic content"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
new_content = [{"type": "heading", "children": [{"text": "Updated Content"}]}]
response = client.put(
f"/api/epics/{epic.id}",
headers=auth_headers,
json={"content": new_content},
)
assert response.status_code == 200
data = response.get_json()
assert data["content"] == new_content
def test_update_epic_not_found(self, client, db_session, auth_headers):
"""Test updating non-existent epic"""
response = client.put(
"/api/epics/99999",
headers=auth_headers,
json={"name": "Updated"},
)
assert response.status_code == 404
def test_update_epic_unauthorized(self, client, db_session, test_board):
"""Test updating epic without authentication"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.put(f"/api/epics/{epic.id}", json={"name": "Updated"})
assert response.status_code == 401
def test_update_epic_invalid_name(
self, client, db_session, auth_headers, test_board
):
"""Test updating epic with invalid name"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.put(
f"/api/epics/{epic.id}",
headers=auth_headers,
json={"name": ""},
)
assert response.status_code == 400
# ========== DELETE /epics/<epic_id> ==========
def test_delete_epic_success(self, client, db_session, auth_headers, test_board):
"""Test deleting an epic successfully"""
epic = Epic(name="To Delete", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
epic_id = epic.id
response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "message" in data
# Verify epic is deleted
deleted_epic = db.session.get(Epic, epic_id)
assert deleted_epic is not None
assert deleted_epic.status == "deleted"
def test_delete_epic_with_cards(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test deleting epic unlinks cards"""
epic = Epic(name="Epic with Cards", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
# Link card to epic
test_card.epic_id = epic.id
db_session.commit()
response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
# Verify card is unlinked
db.session.refresh(test_card)
assert test_card.epic_id is None
def test_delete_epic_not_found(self, client, db_session, auth_headers):
"""Test deleting non-existent epic"""
response = client.delete("/api/epics/99999", headers=auth_headers)
assert response.status_code == 404
def test_delete_epic_unauthorized(self, client, db_session, test_board):
"""Test deleting epic without authentication"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.delete(f"/api/epics/{epic.id}")
assert response.status_code == 401
# ========== POST /cards/<card_id>/epics ==========
def test_add_epic_to_card_success(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test linking an epic to a card successfully"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.post(
f"/api/cards/{test_card.id}/epics",
headers=auth_headers,
json={"epic_id": epic.id},
)
assert response.status_code == 200
data = response.get_json()
assert data["epic_id"] == epic.id
# Verify card is linked
db.session.refresh(test_card)
assert test_card.epic_id == epic.id
def test_add_epic_to_card_already_linked(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test linking epic to card that already has an epic"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
test_card.epic_id = epic.id
db_session.commit()
# Try to link to same epic again (should just update)
response = client.post(
f"/api/cards/{test_card.id}/epics",
headers=auth_headers,
json={"epic_id": epic.id},
)
assert response.status_code == 200
def test_add_epic_to_card_replacement(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test replacing epic on card"""
epic1 = Epic(name="Epic 1", board_id=test_board.id)
epic2 = Epic(name="Epic 2", board_id=test_board.id)
db_session.add(epic1)
db_session.add(epic2)
db_session.commit()
test_card.epic_id = epic1.id
db_session.commit()
# Replace with epic2
response = client.post(
f"/api/cards/{test_card.id}/epics",
headers=auth_headers,
json={"epic_id": epic2.id},
)
assert response.status_code == 200
# Verify card is linked to epic2
db.session.refresh(test_card)
assert test_card.epic_id == epic2.id
def test_add_epic_to_card_not_found(self, client, db_session, auth_headers):
"""Test linking epic to non-existent card"""
response = client.post(
"/api/cards/99999/epics",
headers=auth_headers,
json={"epic_id": 1},
)
assert response.status_code == 404
def test_add_epic_to_card_epic_not_found(
self, client, db_session, auth_headers, test_card
):
"""Test linking non-existent epic to card"""
response = client.post(
f"/api/cards/{test_card.id}/epics",
headers=auth_headers,
json={"epic_id": 99999},
)
assert response.status_code == 404
def test_add_epic_to_card_missing_epic_id(
self, client, db_session, auth_headers, test_card
):
"""Test linking epic to card without epic_id"""
response = client.post(
f"/api/cards/{test_card.id}/epics",
headers=auth_headers,
json={},
)
assert response.status_code == 400
data = response.get_json()
assert "required" in data["error"].lower()
def test_add_epic_to_card_unauthorized(self, client, db_session, test_card):
"""Test linking epic to card without authentication"""
response = client.post(
f"/api/cards/{test_card.id}/epics",
json={"epic_id": 1},
)
assert response.status_code == 401
# ========== DELETE /cards/<card_id>/epics/<epic_id> ==========
def test_remove_epic_from_card_success(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test unlinking an epic from a card successfully"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
test_card.epic_id = epic.id
db_session.commit()
response = client.delete(
f"/api/cards/{test_card.id}/epics/{epic.id}",
headers=auth_headers,
)
assert response.status_code == 200
data = response.get_json()
assert "message" in data
# Verify card is unlinked
db.session.refresh(test_card)
assert test_card.epic_id is None
def test_remove_epic_from_card_card_not_found(
self, client, db_session, auth_headers
):
"""Test unlinking epic from non-existent card"""
response = client.delete(
"/api/cards/99999/epics/1",
headers=auth_headers,
)
assert response.status_code == 404
def test_remove_epic_from_card_wrong_epic(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test unlinking wrong epic from card"""
epic1 = Epic(name="Epic 1", board_id=test_board.id)
epic2 = Epic(name="Epic 2", board_id=test_board.id)
db_session.add(epic1)
db_session.add(epic2)
db_session.commit()
test_card.epic_id = epic1.id
db_session.commit()
# Try to unlink epic2 (but card has epic1)
response = client.delete(
f"/api/cards/{test_card.id}/epics/{epic2.id}",
headers=auth_headers,
)
assert response.status_code == 400
data = response.get_json()
assert "not linked" in data["error"].lower()
def test_remove_epic_from_card_no_epic(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test unlinking epic from card that has no epic"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
# Card has no epic linked
assert test_card.epic_id is None
response = client.delete(
f"/api/cards/{test_card.id}/epics/{epic.id}",
headers=auth_headers,
)
assert response.status_code == 400
data = response.get_json()
assert "not linked" in data["error"].lower()
def test_remove_epic_from_card_unauthorized(
self, client, db_session, test_board, test_card
):
"""Test unlinking epic from card without authentication"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
test_card.epic_id = epic.id
db_session.commit()
response = client.delete(f"/api/cards/{test_card.id}/epics/{epic.id}")
assert response.status_code == 401
# ========== Epic Hierarchy Tests ==========
def test_epic_hierarchy(self, client, db_session, auth_headers, test_board):
"""Test creating nested epic hierarchy"""
# Create parent epic
parent_epic = Epic(name="Parent Epic", board_id=test_board.id)
db_session.add(parent_epic)
db_session.commit()
# Create child epic
child_epic = Epic(
name="Child Epic",
board_id=test_board.id,
parent_epic_id=parent_epic.id,
)
db_session.add(child_epic)
db_session.commit()
# Verify hierarchy
response = client.get(
f"/api/epics/{parent_epic.id}",
headers=auth_headers,
)
assert response.status_code == 200
data = response.get_json()
assert data["parent_epic_id"] is None
response = client.get(
f"/api/epics/{child_epic.id}",
headers=auth_headers,
)
assert response.status_code == 200
data = response.get_json()
assert data["parent_epic_id"] == parent_epic.id

View file

@ -1,372 +0,0 @@
"""Integration tests for file upload routes"""
import pytest
@pytest.mark.integration
def test_upload_image_to_card(client, auth_headers, test_card, test_image_file):
"""Test uploading an image to a card"""
data = {
"file": (test_image_file, "test.png", "image/png"),
}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 201
file_data = response.get_json()
# Only check public-facing fields
assert file_data["original_name"] == "test.png"
assert file_data["file_type"] == "image"
assert file_data["file_size"] > 0
assert file_data["mime_type"] == "image/png" # Mime type is now exposed
assert "uuid" in file_data
assert "download_url" in file_data
assert "thumbnail_url" in file_data
assert "view_url" in file_data
assert "created_at" in file_data
# Security: Verify internal details are NOT exposed
assert "id" not in file_data
assert "filename" not in file_data
assert "minio_bucket" not in file_data
assert "minio_object_name" not in file_data
assert "thumbnail_minio_bucket" not in file_data
assert "thumbnail_minio_object_name" not in file_data
assert "attachable_id" not in file_data
assert "attachable_type" not in file_data
assert "uploaded_by" not in file_data
@pytest.mark.integration
def test_upload_pdf_to_card(client, auth_headers, test_card, test_pdf_file):
"""Test uploading a PDF to a card"""
data = {
"file": (test_pdf_file, "test.pdf", "application/pdf"),
}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 201
file_data = response.get_json()
assert file_data["original_name"] == "test.pdf"
assert file_data["file_type"] == "pdf" # PDFProcessor returns "pdf" as file_type
# PDFs don't have thumbnails
assert file_data.get("thumbnail_url") is None
@pytest.mark.integration
def test_upload_file_to_comment(client, auth_headers, test_comment, test_image_file):
"""Test uploading a file to a comment"""
data = {
"file": (test_image_file, "comment_image.png", "image/png"),
}
response = client.post(
f"/api/comments/{test_comment.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 201
file_data = response.get_json()
assert file_data["original_name"] == "comment_image.png"
assert file_data["file_type"] == "image"
@pytest.mark.integration
def test_get_card_attachments(client, auth_headers, test_card, test_image_file):
"""Test getting all attachments for a card"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
# Get attachments
response = client.get(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
)
assert response.status_code == 200
attachments_data = response.get_json()
assert attachments_data["count"] == 1
assert len(attachments_data["files"]) == 1
assert attachments_data["files"][0]["original_name"] == "test.png"
# Verify only public fields are exposed
assert "id" not in attachments_data["files"][0]
assert "attachable_id" not in attachments_data["files"][0]
@pytest.mark.integration
def test_get_comment_attachments(client, auth_headers, test_comment, test_image_file):
"""Test getting all attachments for a comment"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
client.post(
f"/api/comments/{test_comment.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
# Get attachments
response = client.get(
f"/api/comments/{test_comment.id}/attachments",
headers=auth_headers,
)
assert response.status_code == 200
attachments_data = response.get_json()
assert attachments_data["count"] == 1
assert len(attachments_data["files"]) == 1
@pytest.mark.integration
def test_get_file_by_uuid(client, auth_headers, test_card, test_image_file):
"""Test getting file info by UUID"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Get file by UUID
response = client.get(
f"/api/files/{file_uuid}",
headers=auth_headers,
)
assert response.status_code == 200
file_data = response.get_json()
assert file_data["uuid"] == file_uuid
assert file_data["original_name"] == "test.png"
assert "download_url" in file_data
# Verify only public fields
assert "id" not in file_data
assert "attachable_id" not in file_data
@pytest.mark.integration
def test_delete_file(client, auth_headers, test_card, test_image_file):
"""Test deleting a file"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Delete file
response = client.delete(
f"/api/files/{file_uuid}",
headers=auth_headers,
)
assert response.status_code == 200
delete_data = response.get_json()
assert delete_data["message"] == "File deleted"
# Verify file is deleted
get_response = client.get(
f"/api/files/{file_uuid}",
headers=auth_headers,
)
assert get_response.status_code == 404
@pytest.mark.integration
def test_download_file(client, auth_headers, test_card, test_image_file):
"""Test downloading a file through proxy"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Download file
response = client.get(
f"/api/files/{file_uuid}/download",
headers=auth_headers,
)
assert response.status_code == 200
assert response.content_type == "image/png"
assert "Content-Disposition" in response.headers
assert "test.png" in response.headers["Content-Disposition"]
@pytest.mark.integration
def test_view_file(client, auth_headers, test_card, test_image_file):
"""Test viewing a file inline through proxy"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# View file inline
response = client.get(
f"/api/files/{file_uuid}/view",
headers=auth_headers,
)
assert response.status_code == 200
assert response.content_type == "image/png"
assert "inline" in response.headers["Content-Disposition"]
@pytest.mark.integration
def test_download_thumbnail(client, auth_headers, test_card, test_image_file):
"""Test downloading a file thumbnail"""
# Upload an image file
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Download thumbnail
response = client.get(
f"/api/files/{file_uuid}/thumbnail",
headers=auth_headers,
)
assert response.status_code == 200
assert response.content_type == "image/jpeg"
@pytest.mark.integration
def test_thumbnail_not_available_for_pdf(
client, auth_headers, test_card, test_pdf_file
):
"""Test that PDF files don't have thumbnails"""
# Upload a PDF file
data = {"file": (test_pdf_file, "test.pdf", "application/pdf")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Try to download thumbnail
response = client.get(
f"/api/files/{file_uuid}/thumbnail",
headers=auth_headers,
)
assert response.status_code == 404
@pytest.mark.integration
def test_upload_without_authentication(client, test_card, test_image_file):
"""Test uploading a file without authentication"""
data = {"file": (test_image_file, "test.png", "image/png")}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 401
@pytest.mark.integration
def test_get_card_attachments_empty(client, auth_headers, test_card):
"""Test getting attachments when card has none"""
response = client.get(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
)
assert response.status_code == 200
attachments_data = response.get_json()
assert attachments_data["count"] == 0
assert len(attachments_data["files"]) == 0
@pytest.mark.integration
def test_upload_without_file(client, auth_headers, test_card):
"""Test uploading without providing a file"""
data = {}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 400
@pytest.mark.integration
def test_upload_with_invalid_card_id(client, auth_headers, test_image_file):
"""Test uploading to a non-existent card"""
data = {"file": (test_image_file, "test.png", "image/png")}
response = client.post(
"/api/cards/99999/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 404
@pytest.mark.integration
def test_get_file_with_invalid_uuid(client, auth_headers):
"""Test getting a file with an invalid UUID"""
response = client.get(
"/api/files/invalid-uuid-12345",
headers=auth_headers,
)
assert response.status_code == 404
@pytest.mark.integration
def test_delete_file_with_invalid_uuid(client, auth_headers):
"""Test deleting a file with an invalid UUID"""
response = client.delete(
"/api/files/invalid-uuid-12345",
headers=auth_headers,
)
assert response.status_code == 404

View file

@ -181,5 +181,4 @@ class TestLabelRoutes:
.filter_by(card_id=card.id, label_id=label.id)
.first()
)
assert deleted_card_label is not None
assert deleted_card_label.status == "deleted"
assert deleted_card_label is None

View file

@ -116,8 +116,7 @@ class TestListRoutes:
# Verify list is deleted
deleted_list = db.session.get(List, lst.id)
assert deleted_list is not None
assert deleted_list.status == "deleted"
assert deleted_list is None
def test_delete_list_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent list"""
@ -145,13 +144,11 @@ class TestListRoutes:
assert response.status_code == 200
# Verify both list and card are soft-deleted
# 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 not None
assert deleted_list.status == "deleted"
assert deleted_card is not None
assert deleted_card.status == "deleted"
assert deleted_list is None
assert deleted_card is None
def test_update_list_position_reorders_others(
self, client, db_session, regular_user, auth_headers

View file

@ -1,496 +0,0 @@
"""High-level integration tests for soft delete functionality
These tests verify that soft delete works correctly by checking:
1. The delete endpoint returns success
2. The record is marked as deleted in the database (deleted_at is set)
3. The record still exists (soft delete, not hard delete)
"""
import pytest
from app import db
from app.models import (Board, Card, CardLabel, CardLink, CheckItem, Checklist,
Comment, Epic, Label, List, Wiki)
@pytest.mark.integration
class TestSoftDeleteIntegration:
"""High-level integration tests for soft delete across all resources"""
def test_soft_delete_card_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a card marks it as deleted in the database"""
# Create board, list and 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()
# Delete the card
response = client.delete(f"/api/cards/{card.id}", headers=auth_headers)
assert response.status_code == 200
# Verify card is marked as deleted in database
deleted_card = db.session.get(Card, card.id)
assert deleted_card is not None
assert deleted_card.deleted_at is not None
def test_soft_delete_list_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a list marks it as deleted in the database"""
# Create board and 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()
# Delete the list
response = client.delete(f"/api/lists/{lst.id}", headers=auth_headers)
assert response.status_code == 200
# Verify list is marked as deleted in database
deleted_list = db.session.get(List, lst.id)
assert deleted_list is not None
assert deleted_list.deleted_at is not None
def test_soft_delete_comment_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a comment marks it as deleted in the database"""
# Create board, list, card and 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="Test comment", card_id=card.id, user_id=regular_user.id)
db_session.add(comment)
db_session.commit()
# Delete the comment
response = client.delete(f"/api/comments/{comment.id}", headers=auth_headers)
assert response.status_code == 200
# Verify comment is marked as deleted in database
deleted_comment = db.session.get(Comment, comment.id)
assert deleted_comment is not None
assert deleted_comment.deleted_at is not None
def test_soft_delete_checklist_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a checklist marks it as deleted in the database"""
# Create board, list, card and 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()
# Delete the checklist
response = client.delete(
f"/api/checklists/{checklist.id}", headers=auth_headers
)
assert response.status_code == 200
# Verify checklist is marked as deleted in database
deleted_checklist = db.session.get(Checklist, checklist.id)
assert deleted_checklist is not None
assert deleted_checklist.deleted_at is not None
def test_soft_delete_check_item_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a check item marks it as deleted in the database"""
# Create board, list, card, checklist and 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()
# Delete the check item
response = client.delete(f"/api/check-items/{item.id}", headers=auth_headers)
assert response.status_code == 200
# Verify check item is marked as deleted in database
deleted_item = db.session.get(CheckItem, item.id)
assert deleted_item is not None
assert deleted_item.deleted_at is not None
def test_soft_delete_card_label_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that removing a label from a card marks the association as deleted"""
# Create board, list, card and labels
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()
# Remove label from card
response = client.delete(
f"/api/cards/{card.id}/labels/{label.id}", headers=auth_headers
)
assert response.status_code == 200
# Verify card-label association is marked as deleted in database
deleted_card_label = (
db_session.query(CardLabel)
.filter_by(card_id=card.id, label_id=label.id)
.first()
)
assert deleted_card_label is not None
assert deleted_card_label.deleted_at is not None
def test_soft_delete_epic_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting an epic marks it as deleted in the database"""
# Create board and epic
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
epic = Epic(name="Test Epic", board_id=board.id, color="#3b82f6")
db_session.add(epic)
db_session.commit()
# Delete the epic
response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
# Verify epic is marked as deleted in database
deleted_epic = db.session.get(Epic, epic.id)
assert deleted_epic is not None
assert deleted_epic.deleted_at is not None
def test_soft_delete_epic_unlinks_card(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleting an epic unlinks associated cards"""
# Create board, list, card and epic
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()
epic = Epic(name="Test Epic", board_id=board.id, color="#3b82f6")
db_session.add(epic)
db_session.flush()
card.epic_id = epic.id
db_session.commit()
# Delete epic
response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
# Verify card no longer has epic
db.session.refresh(card)
assert card.epic_id is None
def test_soft_delete_wiki_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a wiki marks it as deleted in the database"""
# Create board and wiki
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
wiki = Wiki(
name="Test Wiki",
board_id=board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Delete the wiki
response = client.delete(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
# Verify wiki is marked as deleted in database
deleted_wiki = db.session.get(Wiki, wiki.id)
assert deleted_wiki is not None
assert deleted_wiki.deleted_at is not None
def test_soft_delete_board_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a board marks it as deleted in the database"""
# Create board
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.commit()
# Delete the board
response = client.delete(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
# Verify board is marked as deleted in database
deleted_board = db.session.get(Board, board.id)
assert deleted_board is not None
assert deleted_board.status == "deleted"
def test_soft_delete_card_link_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a card link marks it as deleted in the database"""
# Create board, list 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()
parent_card = Card(name="Parent Card", board_id=board.id, list_id=lst.id, pos=0)
child_card = Card(name="Child Card", board_id=board.id, list_id=lst.id, pos=1)
db_session.add_all([parent_card, child_card])
db_session.commit()
# Create card link
link = CardLink(
parent_card_id=parent_card.id,
child_card_id=child_card.id,
created_by=regular_user.id,
)
db_session.add(link)
db_session.commit()
# Delete the link
response = client.delete(f"/api/card-links/{link.id}", headers=auth_headers)
assert response.status_code == 200
# Verify link is marked as deleted in database
deleted_link = db.session.get(CardLink, link.id)
assert deleted_link is not None
assert deleted_link.deleted_at is not None
def test_soft_delete_cascade_from_list_to_cards(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a list soft-deletes its cards"""
# Create board, list 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()
card1 = Card(name="Card 1", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=lst.id, pos=1)
db_session.add_all([card1, card2])
db_session.commit()
# Delete list
response = client.delete(f"/api/lists/{lst.id}", headers=auth_headers)
assert response.status_code == 200
# Verify list is soft-deleted
deleted_list = db.session.get(List, lst.id)
assert deleted_list is not None
assert deleted_list.deleted_at is not None
# Verify cards are also soft-deleted
deleted_card1 = db.session.get(Card, card1.id)
deleted_card2 = db.session.get(Card, card2.id)
assert deleted_card1 is not None
assert deleted_card1.deleted_at is not None
assert deleted_card2 is not None
assert deleted_card2.deleted_at is not None
def test_soft_delete_multiple_resources_in_sequence(
self, client, db_session, regular_user, auth_headers
):
"""Test deleting multiple resources in sequence and verifying soft delete"""
# Create board with lists, cards, comments, checklists, labels
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="Test comment", card_id=card.id, user_id=regular_user.id)
db_session.add(comment)
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.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()
# Delete multiple resources in sequence
client.delete(f"/api/comments/{comment.id}", headers=auth_headers)
client.delete(f"/api/check-items/{item.id}", headers=auth_headers)
client.delete(f"/api/cards/{card.id}/labels/{label.id}", headers=auth_headers)
# Verify all are marked as deleted in database
deleted_comment = db.session.get(Comment, comment.id)
deleted_item = db.session.get(CheckItem, item.id)
deleted_card_label = (
db_session.query(CardLabel)
.filter_by(card_id=card.id, label_id=label.id)
.first()
)
assert deleted_comment is not None
assert deleted_comment.deleted_at is not None
assert deleted_item is not None
assert deleted_item.deleted_at is not None
assert deleted_card_label is not None
assert deleted_card_label.deleted_at is not None
def test_soft_delete_record_still_exists(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted records still exist in the database"""
# Create board
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.commit()
# Delete the board
response = client.delete(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
# Verify record still exists (not hard deleted)
deleted_board = db.session.get(Board, board.id)
assert deleted_board is not None
assert deleted_board.id == board.id
assert deleted_board.name == "Test Board"
def test_convert_check_item_to_card_deletes_check_item(
self, client, db_session, regular_user, auth_headers
):
"""Test that converting a check item to a card soft-deletes the check item"""
# Create board, list, card, checklist and 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="Parent 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 to convert", checklist_id=checklist.id, pos=0, state="incomplete"
)
db_session.add(item)
db_session.commit()
# Convert check item to card
response = client.post(
f"/api/cards/{card.id}/checklist-items/{item.id}/convert-to-card",
headers=auth_headers,
json={"list_id": lst.id},
)
assert response.status_code == 201
# Verify check item is soft-deleted
deleted_item = db.session.get(CheckItem, item.id)
assert deleted_item is not None
assert deleted_item.deleted_at is not None

View file

@ -1,888 +0,0 @@
"""Integration tests for Wiki API routes"""
import pytest
from app import db
from app.models import Epic, Wiki, wiki_entity_links
@pytest.mark.integration
class TestWikiRoutes:
"""Test Wiki API routes"""
# ========== GET /boards/<board_id>/wikis ==========
def test_get_board_wikis_success(
self, client, db_session, regular_user, auth_headers, test_board
):
"""Test getting all wikis for a board"""
# Create wikis for board
wiki1 = Wiki(
name="Getting Started",
board_id=test_board.id,
slug="getting-started",
content=[
{"type": "paragraph", "children": [{"text": "Getting started content"}]}
],
created_by=regular_user.id,
)
wiki2 = Wiki(
name="API Documentation",
board_id=test_board.id,
slug="api-documentation",
content=[{"type": "paragraph", "children": [{"text": "API docs content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki1)
db_session.add(wiki2)
db_session.commit()
response = client.get(
f"/api/boards/{test_board.id}/wikis", headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert len(data) == 2
assert data[0]["name"] == "Getting Started"
assert data[1]["name"] == "API Documentation"
def test_get_board_wikis_empty(self, client, db_session, auth_headers, test_board):
"""Test getting wikis when none exist for board"""
response = client.get(
f"/api/boards/{test_board.id}/wikis", headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert len(data) == 0
def test_get_board_wikis_board_not_found(self, client, db_session, auth_headers):
"""Test getting wikis for non-existent board"""
response = client.get("/api/boards/99999/wikis", headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_get_board_wikis_unauthorized(self, client, db_session, test_board):
"""Test getting wikis without authentication"""
response = client.get(f"/api/boards/{test_board.id}/wikis")
assert response.status_code == 401
# ========== POST /boards/<board_id>/wikis ==========
def test_create_wiki_success(
self, client, db_session, regular_user, auth_headers, test_board
):
"""Test creating a new wiki successfully"""
wiki_data = {
"name": "User Guide",
"slug": "user-guide",
"content": [{"type": "paragraph", "children": [{"text": "Welcome..."}]}],
"summary": "A comprehensive user guide",
"category": "Documentation",
"tags": ["getting-started", "tutorial"],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "User Guide"
assert data["slug"] == "user-guide"
assert data["summary"] == "A comprehensive user guide"
assert data["category"] == "Documentation"
assert data["tags"] == ["getting-started", "tutorial"]
assert data["created_by"] == regular_user.id
assert data["updated_by"] == regular_user.id
assert "id" in data
def test_create_wiki_minimal_data(
self, client, db_session, auth_headers, test_board
):
"""Test creating wiki with only required fields"""
wiki_data = {
"name": "Simple Wiki",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "Simple Wiki"
assert data["slug"] == "simple-wiki" # Auto-generated
assert data["summary"] is None
assert data["category"] is None
assert data["tags"] is None
def test_create_wiki_auto_generate_slug(
self, client, db_session, auth_headers, test_board
):
"""Test creating wiki with auto-generated slug"""
wiki_data = {
"name": "API Reference Guide",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["slug"] == "api-reference-guide"
def test_create_wiki_slug_collision(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test creating wiki with colliding slug"""
# Create first wiki
wiki1 = Wiki(
name="Test Wiki",
board_id=test_board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki1)
db_session.commit()
# Create second wiki with same name (should auto-increment slug)
wiki_data = {
"name": "Test Wiki",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["slug"] == "test-wiki-1"
def test_create_wiki_board_not_found(self, client, db_session, auth_headers):
"""Test creating wiki for non-existent board"""
wiki_data = {
"name": "Test Wiki",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
"/api/boards/99999/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_create_wiki_unauthorized(self, client, db_session, test_board):
"""Test creating wiki without authentication"""
wiki_data = {
"name": "Test Wiki",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
json=wiki_data,
)
assert response.status_code == 401
def test_create_wiki_missing_name(
self, client, db_session, auth_headers, test_board
):
"""Test creating wiki without name"""
wiki_data = {"summary": "No name provided"}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 400
def test_create_wiki_name_too_long(
self, client, db_session, auth_headers, test_board
):
"""Test creating wiki with name exceeding max length"""
wiki_data = {
"name": "A" * 201,
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 400
def test_create_wiki_invalid_slug(
self, client, db_session, auth_headers, test_board
):
"""Test creating wiki with invalid slug format"""
wiki_data = {
"name": "Test Wiki",
"slug": "invalid slug!",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 400
# ========== GET /wikis/<wiki_id> ==========
def test_get_wiki_success(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test getting a specific wiki with details"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.get(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data["id"] == wiki.id
assert data["name"] == "Test Wiki"
assert data["slug"] == "test-wiki"
assert "linked_cards" in data
assert "linked_epics" in data
def test_get_wiki_with_linked_entities(
self, client, db_session, auth_headers, test_board, regular_user, test_card
):
"""Test getting wiki with linked cards and epics"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Link card to wiki
db.session.execute(
wiki_entity_links.insert().values(
wiki_id=wiki.id, entity_type="card", entity_id=test_card.id
)
)
db_session.commit()
response = client.get(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "linked_cards" in data
assert "linked_epics" in data
assert len(data["linked_cards"]) == 1
assert data["linked_cards"][0]["id"] == test_card.id
def test_get_wiki_not_found(self, client, db_session, auth_headers):
"""Test getting non-existent wiki"""
response = client.get("/api/wikis/99999", headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_get_wiki_unauthorized(self, client, db_session, test_board, regular_user):
"""Test getting wiki without authentication"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.get(f"/api/wikis/{wiki.id}")
assert response.status_code == 401
# ========== PUT /wikis/<wiki_id> ==========
def test_update_wiki_success(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test updating a wiki successfully"""
wiki = Wiki(
name="Original Name",
board_id=test_board.id,
slug="original-slug",
content=[{"type": "paragraph", "children": [{"text": "Original content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
update_data = {
"name": "Updated Name",
"slug": "updated-slug",
"summary": "Updated summary",
"category": "Documentation",
"tags": ["updated", "tag"],
}
response = client.put(
f"/api/wikis/{wiki.id}",
headers=auth_headers,
json=update_data,
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Name"
assert data["slug"] == "updated-slug"
assert data["summary"] == "Updated summary"
assert data["category"] == "Documentation"
assert data["tags"] == ["updated", "tag"]
def test_update_wiki_partial(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test updating wiki with partial data"""
wiki = Wiki(
name="Original Name",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Original content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
update_data = {"name": "Updated Name"}
response = client.put(
f"/api/wikis/{wiki.id}",
headers=auth_headers,
json=update_data,
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Name"
# Other fields should remain unchanged
def test_update_wiki_content(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test updating wiki content"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Original content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
new_content = [{"type": "heading", "children": [{"text": "Updated Content"}]}]
response = client.put(
f"/api/wikis/{wiki.id}",
headers=auth_headers,
json={"content": new_content},
)
assert response.status_code == 200
data = response.get_json()
assert data["content"] == new_content
def test_update_wiki_not_found(self, client, db_session, auth_headers):
"""Test updating non-existent wiki"""
response = client.put(
"/api/wikis/99999",
headers=auth_headers,
json={"name": "Updated"},
)
assert response.status_code == 404
def test_update_wiki_unauthorized(
self, client, db_session, test_board, regular_user
):
"""Test updating wiki without authentication"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.put(f"/api/wikis/{wiki.id}", json={"name": "Updated"})
assert response.status_code == 401
def test_update_wiki_invalid_name(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test updating wiki with invalid name"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.put(
f"/api/wikis/{wiki.id}",
headers=auth_headers,
json={"name": ""},
)
assert response.status_code == 400
def test_update_wiki_invalid_slug(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test updating wiki with invalid slug"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.put(
f"/api/wikis/{wiki.id}",
headers=auth_headers,
json={"slug": "invalid slug!"},
)
assert response.status_code == 400
# ========== DELETE /wikis/<wiki_id> ==========
def test_delete_wiki_success(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test deleting a wiki successfully"""
wiki = Wiki(
name="To Delete",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
wiki_id = wiki.id
response = client.delete(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "message" in data
# Verify wiki is deleted
deleted_wiki = db.session.get(Wiki, wiki_id)
assert deleted_wiki is not None
assert deleted_wiki.status == "deleted"
def test_delete_wiki_with_links(
self, client, db_session, auth_headers, test_board, regular_user, test_card
):
"""Test deleting wiki (soft delete) preserves entity links"""
wiki = Wiki(
name="Wiki with Links",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Link card to wiki
db.session.execute(
wiki_entity_links.insert().values(
wiki_id=wiki.id, entity_type="card", entity_id=test_card.id
)
)
db_session.commit()
response = client.delete(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
# Verify wiki is soft-deleted
deleted_wiki = db.session.get(Wiki, wiki.id)
assert deleted_wiki is not None
assert deleted_wiki.status == "deleted"
# Verify links are preserved (not deleted with soft delete)
from sqlalchemy import select
links = db.session.scalars(
select(wiki_entity_links).where(wiki_entity_links.c.wiki_id == wiki.id)
).all()
assert len(links) == 1 # Links are preserved
def test_delete_wiki_not_found(self, client, db_session, auth_headers):
"""Test deleting non-existent wiki"""
response = client.delete("/api/wikis/99999", headers=auth_headers)
assert response.status_code == 404
def test_delete_wiki_unauthorized(
self, client, db_session, test_board, regular_user
):
"""Test deleting wiki without authentication"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.delete(f"/api/wikis/{wiki.id}")
assert response.status_code == 401
# ========== POST /wikis/<wiki_id>/links ==========
def test_create_wiki_link_card(
self, client, db_session, auth_headers, test_board, regular_user, test_card
):
"""Test linking wiki to card successfully"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": test_card.id},
)
assert response.status_code == 201
data = response.get_json()
assert "message" in data
# Verify link is created
from sqlalchemy import select
link = db.session.scalars(
select(wiki_entity_links).where(
wiki_entity_links.c.wiki_id == wiki.id,
wiki_entity_links.c.entity_type == "card",
wiki_entity_links.c.entity_id == test_card.id,
)
).first()
assert link is not None
def test_create_wiki_link_epic(
self,
client,
db_session,
auth_headers,
test_board,
regular_user,
test_list,
):
"""Test linking wiki to epic successfully"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(wiki)
db_session.add(epic)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "epic", "entity_id": epic.id},
)
assert response.status_code == 201
# Verify link is created
from sqlalchemy import select
link = db.session.scalars(
select(wiki_entity_links).where(
wiki_entity_links.c.wiki_id == wiki.id,
wiki_entity_links.c.entity_type == "epic",
wiki_entity_links.c.entity_id == epic.id,
)
).first()
assert link is not None
def test_create_wiki_link_list(
self,
client,
db_session,
auth_headers,
test_board,
regular_user,
test_list,
):
"""Test linking wiki to list successfully"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "list", "entity_id": test_list.id},
)
assert response.status_code == 201
def test_create_wiki_link_board(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test linking wiki to board successfully"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "board", "entity_id": test_board.id},
)
assert response.status_code == 201
def test_create_wiki_link_already_exists(
self, client, db_session, auth_headers, test_board, regular_user, test_card
):
"""Test creating duplicate link"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Create first link
db.session.execute(
wiki_entity_links.insert().values(
wiki_id=wiki.id, entity_type="card", entity_id=test_card.id
)
)
db_session.commit()
# Try to create duplicate link
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": test_card.id},
)
assert response.status_code == 400
data = response.get_json()
assert "already exists" in data["error"].lower()
def test_create_wiki_link_wiki_not_found(self, client, db_session, auth_headers):
"""Test linking non-existent wiki"""
response = client.post(
"/api/wikis/99999/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": 1},
)
assert response.status_code == 404
def test_create_wiki_link_entity_not_found(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test linking to non-existent entity"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": 99999},
)
assert response.status_code == 404
def test_create_wiki_link_unauthorized(
self, client, db_session, test_board, regular_user
):
"""Test creating link without authentication"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
json={"entity_type": "card", "entity_id": 1},
)
assert response.status_code == 401
# ========== DELETE /wikis/<wiki_id>/links ==========
def test_delete_wiki_link_success(
self, client, db_session, auth_headers, test_board, regular_user, test_card
):
"""Test deleting a wiki link successfully"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Create link
db.session.execute(
wiki_entity_links.insert().values(
wiki_id=wiki.id, entity_type="card", entity_id=test_card.id
)
)
db_session.commit()
response = client.delete(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": test_card.id},
)
assert response.status_code == 200
data = response.get_json()
assert "message" in data
# Verify link is deleted
from sqlalchemy import select
link = db.session.scalars(
select(wiki_entity_links).where(
wiki_entity_links.c.wiki_id == wiki.id,
wiki_entity_links.c.entity_type == "card",
wiki_entity_links.c.entity_id == test_card.id,
)
).first()
assert link is None
def test_delete_wiki_link_wiki_not_found(self, client, db_session, auth_headers):
"""Test deleting link for non-existent wiki"""
response = client.delete(
"/api/wikis/99999/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": 1},
)
assert response.status_code == 404
def test_delete_wiki_link_not_found(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test deleting non-existent link"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.delete(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": 99999},
)
assert response.status_code == 404
def test_delete_wiki_link_unauthorized(
self, client, db_session, test_board, regular_user
):
"""Test deleting link without authentication"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.delete(
f"/api/wikis/{wiki.id}/links",
json={"entity_type": "card", "entity_id": 1},
)
assert response.status_code == 401

View file

@ -1,8 +1,9 @@
"""Test models"""
from decimal import Decimal
import pytest
from app.models import User
from app.models import Order, OrderItem, Product, User
class TestUserModel:
@ -68,3 +69,132 @@ class TestUserModel:
db_session.commit()
assert repr(user) == "<User testuser3>"
class TestProductModel:
"""Test Product model"""
@pytest.mark.unit
def test_product_creation(self, db_session):
"""Test creating a product"""
product = Product(
name="Test Product",
description="A test product",
price=Decimal("99.99"),
stock=10,
image_url="https://example.com/product.jpg",
)
db_session.add(product)
db_session.commit()
assert product.id is not None
assert product.name == "Test Product"
assert product.price == Decimal("99.99")
assert product.stock == 10
assert product.is_active is True
@pytest.mark.unit
def test_product_to_dict(self, db_session):
"""Test product serialization to dictionary"""
product = Product(
name="Test Product",
description="A test product",
price=Decimal("99.99"),
stock=10,
)
db_session.add(product)
db_session.commit()
product_dict = product.to_dict()
assert product_dict["name"] == "Test Product"
assert product_dict["price"] == 99.99
assert isinstance(product_dict["created_at"], str)
assert isinstance(product_dict["updated_at"], str)
@pytest.mark.unit
def test_product_defaults(self, db_session):
"""Test product default values"""
product = Product(name="Test Product", price=Decimal("9.99"))
db_session.add(product)
db_session.commit()
assert product.stock == 0
assert product.is_active is True
assert product.description is None
assert product.image_url is None
@pytest.mark.unit
def test_product_repr(self, db_session):
"""Test product string representation"""
product = Product(name="Test Product", price=Decimal("9.99"))
db_session.add(product)
db_session.commit()
assert repr(product) == "<Product Test Product>"
class TestOrderModel:
"""Test Order model"""
@pytest.mark.unit
def test_order_creation(self, db_session, regular_user):
"""Test creating an order"""
order = Order(
user_id=regular_user.id,
total_amount=Decimal("199.99"),
shipping_address="123 Test St",
)
db_session.add(order)
db_session.commit()
assert order.id is not None
assert order.user_id == regular_user.id
assert order.total_amount == Decimal("199.99")
@pytest.mark.unit
def test_order_to_dict(self, db_session, regular_user):
"""Test order serialization to dictionary"""
order = Order(
user_id=regular_user.id,
total_amount=Decimal("199.99"),
shipping_address="123 Test St",
)
db_session.add(order)
db_session.commit()
order_dict = order.to_dict()
assert order_dict["user_id"] == regular_user.id
assert order_dict["total_amount"] == 199.99
assert isinstance(order_dict["created_at"], str)
class TestOrderItemModel:
"""Test OrderItem model"""
@pytest.mark.unit
def test_order_item_creation(self, db_session, order, product):
"""Test creating an order item"""
order_item = OrderItem(
order_id=order.id, product_id=product.id, quantity=2, price=product.price
)
db_session.add(order_item)
db_session.commit()
assert order_item.id is not None
assert order_item.order_id == order.id
assert order_item.product_id == product.id
assert order_item.quantity == 2
@pytest.mark.unit
def test_order_item_to_dict(self, db_session, order, product):
"""Test order item serialization to dictionary"""
order_item = OrderItem(
order_id=order.id, product_id=product.id, quantity=2, price=product.price
)
db_session.add(order_item)
db_session.commit()
item_dict = order_item.to_dict()
assert item_dict["order_id"] == order.id
assert item_dict["product_id"] == product.id
assert item_dict["quantity"] == 2

View file

@ -112,3 +112,234 @@ class TestAuthRoutes:
"""Test getting current user without authentication"""
response = client.get("/api/users/me")
assert response.status_code == 401
class TestProductRoutes:
"""Test product routes"""
@pytest.mark.product
def test_get_products(self, app, client, products):
"""Test getting all products"""
from app.models import Product
before_count = Product.query.count()
response = client.get("/api/products")
assert response.status_code == 200
data = response.get_json()
assert len(data) == before_count
@pytest.mark.product
def test_get_products_empty(self, client):
"""Test getting products when none exist"""
from app.models import Product
before_count = Product.query.count()
response = client.get("/api/products")
assert response.status_code == 200
data = response.get_json()
assert len(data) == before_count
@pytest.mark.product
def test_get_single_product(self, client, product):
"""Test getting a single product"""
response = client.get(f"/api/products/{product.id}")
assert response.status_code == 200
data = response.get_json()
assert data["id"] == product.id
assert data["name"] == product.name
@pytest.mark.product
def test_get_product_not_found(self, client):
"""Test getting non-existent product"""
response = client.get("/api/products/999")
assert response.status_code == 404
@pytest.mark.product
def test_create_product_admin(self, client, admin_headers):
"""Test creating product as admin"""
response = client.post(
"/api/products",
headers=admin_headers,
json={
"name": "New Product",
"description": "A new product",
"price": 29.99,
"stock": 10,
},
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "New Product"
assert data["price"] == 29.99
@pytest.mark.product
def test_create_product_regular_user(self, client, auth_headers):
"""Test creating product as regular user (should fail)"""
response = client.post(
"/api/products",
headers=auth_headers,
json={"name": "New Product", "price": 29.99},
)
assert response.status_code == 403
data = response.get_json()
assert "admin" in data["error"].lower()
@pytest.mark.product
def test_create_product_unauthorized(self, client):
"""Test creating product without authentication"""
response = client.post(
"/api/products", json={"name": "New Product", "price": 29.99}
)
assert response.status_code == 401
@pytest.mark.product
def test_create_product_validation_error(self, client, admin_headers):
"""Test creating product with invalid data"""
response = client.post(
"/api/products",
headers=admin_headers,
json={"name": "New Product", "price": -10.99},
)
assert response.status_code == 400
data = response.get_json()
assert "Validation error" in data["error"]
@pytest.mark.product
def test_create_product_missing_required_fields(self, client, admin_headers):
"""Test creating product with missing required fields"""
response = client.post(
"/api/products",
headers=admin_headers,
json={"description": "Missing name and price"},
)
assert response.status_code == 400
data = response.get_json()
assert "Validation error" in data["error"]
@pytest.mark.product
def test_create_product_minimal_data(self, client, admin_headers):
"""Test creating product with minimal valid data"""
response = client.post(
"/api/products",
headers=admin_headers,
json={"name": "Minimal Product", "price": 19.99},
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "Minimal Product"
assert data["stock"] == 0 # Default value
@pytest.mark.product
def test_update_product_admin(self, client, admin_headers, product):
"""Test updating product as admin"""
response = client.put(
f"/api/products/{product.id}",
headers=admin_headers,
json={"name": "Updated Product", "price": 39.99},
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Product"
assert data["price"] == 39.99
@pytest.mark.product
def test_delete_product_admin(self, client, admin_headers, product):
"""Test deleting product as admin"""
response = client.delete(f"/api/products/{product.id}", headers=admin_headers)
assert response.status_code == 200
# Verify product is deleted
response = client.get(f"/api/products/{product.id}")
assert response.status_code == 404
class TestOrderRoutes:
"""Test order routes"""
@pytest.mark.order
def test_get_orders(self, client, auth_headers, order):
"""Test getting orders for current user"""
response = client.get("/api/orders", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert len(data) >= 1
@pytest.mark.order
def test_get_orders_unauthorized(self, client):
"""Test getting orders without authentication"""
response = client.get("/api/orders")
assert response.status_code == 401
@pytest.mark.order
def test_create_order(self, client, auth_headers, products):
"""Test creating an order"""
response = client.post(
"/api/orders",
headers=auth_headers,
json={
"items": [
{"product_id": products[0].id, "quantity": 2},
{"product_id": products[1].id, "quantity": 1},
],
"shipping_address": "123 Test St",
},
)
assert response.status_code == 201
data = response.get_json()
assert "id" in data
assert len(data["items"]) == 2
@pytest.mark.order
def test_create_order_insufficient_stock(
self, client, auth_headers, db_session, products
):
"""Test creating order with insufficient stock"""
# Set stock to 0
products[0].stock = 0
db_session.commit()
response = client.post(
"/api/orders",
headers=auth_headers,
json={"items": [{"product_id": products[0].id, "quantity": 2}]},
)
assert response.status_code == 400
data = response.get_json()
assert "insufficient" in data["error"].lower()
@pytest.mark.order
def test_get_single_order(self, client, auth_headers, order):
"""Test getting a single order"""
response = client.get(f"/api/orders/{order.id}", headers=auth_headers)
print("test_get_single_order", response.get_json())
assert response.status_code == 200
data = response.get_json()
assert data["id"] == order.id
@pytest.mark.order
def test_get_other_users_order(self, client, admin_headers, regular_user, products):
"""Test admin accessing another user's order"""
# Create an order for regular_user
client.post(
"/api/auth/login",
json={"email": regular_user.email, "password": "password123"},
)
# Admin should be able to access any order
# This test assumes order exists, adjust as needed
pass

View file

@ -0,0 +1,249 @@
"""Test Pydantic schemas"""
from decimal import Decimal
import pytest
from pydantic import ValidationError
from app.schemas import ProductCreateRequest, ProductResponse
class TestProductCreateRequestSchema:
"""Test ProductCreateRequest schema"""
@pytest.mark.unit
def test_valid_product_request(self):
"""Test valid product creation request"""
data = {
"name": "Handcrafted Wooden Bowl",
"description": "A beautiful handcrafted bowl",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/bowl.jpg",
}
product = ProductCreateRequest(**data)
assert product.name == data["name"]
assert product.description == data["description"]
assert product.price == Decimal("45.99")
assert product.stock == 10
assert product.image_url == data["image_url"]
@pytest.mark.unit
def test_minimal_valid_request(self):
"""Test minimal valid request (only required fields)"""
data = {"name": "Simple Product", "price": 19.99}
product = ProductCreateRequest(**data)
assert product.name == "Simple Product"
assert product.price == Decimal("19.99")
assert product.stock == 0
assert product.description is None
assert product.image_url is None
@pytest.mark.unit
def test_missing_name(self):
"""Test request with missing name"""
data = {"price": 19.99}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("name",) for error in errors)
@pytest.mark.unit
def test_missing_price(self):
"""Test request with missing price"""
data = {"name": "Test Product"}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("price",) for error in errors)
@pytest.mark.unit
def test_invalid_price_negative(self):
"""Test request with negative price"""
data = {"name": "Test Product", "price": -10.99}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than" for error in errors)
@pytest.mark.unit
def test_invalid_price_zero(self):
"""Test request with zero price"""
data = {"name": "Test Product", "price": 0.0}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than" for error in errors)
@pytest.mark.unit
def test_invalid_price_too_many_decimals(self):
"""Test request with too many decimal places"""
data = {"name": "Test Product", "price": 10.999}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any("decimal places" in str(error).lower() for error in errors)
@pytest.mark.unit
def test_invalid_stock_negative(self):
"""Test request with negative stock"""
data = {"name": "Test Product", "price": 19.99, "stock": -5}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than_equal" for error in errors)
@pytest.mark.unit
def test_name_too_long(self):
"""Test request with name exceeding max length"""
data = {"name": "A" * 201, "price": 19.99} # Exceeds 200 character limit
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("name",) for error in errors)
@pytest.mark.unit
def test_image_url_too_long(self):
"""Test request with image_url exceeding max length"""
data = {
"name": "Test Product",
"price": 19.99,
"image_url": "A" * 501, # Exceeds 500 character limit
}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("image_url",) for error in errors)
@pytest.mark.unit
def test_price_string_conversion(self):
"""Test price string to Decimal conversion"""
data = {"name": "Test Product", "price": "29.99"}
product = ProductCreateRequest(**data)
assert product.price == Decimal("29.99")
@pytest.mark.unit
def test_stock_string_conversion(self):
"""Test stock string to int conversion"""
data = {"name": "Test Product", "price": 19.99, "stock": "10"}
product = ProductCreateRequest(**data)
assert product.stock == 10
assert isinstance(product.stock, int)
class TestProductResponseSchema:
"""Test ProductResponse schema"""
@pytest.mark.unit
def test_valid_product_response(self):
"""Test valid product response"""
data = {
"id": 1,
"name": "Test Product",
"description": "A test product",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/product.jpg",
"is_active": True,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-01-15T10:30:00",
}
product = ProductResponse(**data)
assert product.id == 1
assert product.name == "Test Product"
assert product.price == 45.99
assert product.stock == 10
assert product.is_active is True
@pytest.mark.unit
def test_product_response_with_none_fields(self):
"""Test product response with optional None fields"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 0,
"is_active": True,
}
product = ProductResponse(**data)
assert product.description is None
assert product.image_url is None
assert product.created_at is None
assert product.updated_at is None
@pytest.mark.unit
def test_model_validate_from_sqlalchemy(self, db_session):
"""Test validating SQLAlchemy model to Pydantic schema"""
from app.models import Product
db_product = Product(
name="Test Product",
description="A test product",
price=Decimal("45.99"),
stock=10,
)
db_session.add(db_product)
db_session.commit()
# Validate using model_validate (for SQLAlchemy models)
response = ProductResponse.model_validate(db_product)
assert response.name == "Test Product"
assert response.price == 45.99
assert response.stock == 10
@pytest.mark.unit
def test_model_dump(self):
"""Test model_dump method"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 5,
"is_active": True,
}
product = ProductResponse(**data)
dumped = product.model_dump()
assert isinstance(dumped, dict)
assert dumped["id"] == 1
assert dumped["name"] == "Test Product"
assert dumped["price"] == 19.99
@pytest.mark.unit
def test_model_dump_json(self):
"""Test model_dump_json method"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 5,
"is_active": True,
}
product = ProductResponse(**data)
json_str = product.model_dump_json()
assert isinstance(json_str, str)
assert "Test Product" in json_str

View file

@ -23,30 +23,8 @@ services:
- "6381:6379"
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: kanban-app-minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio-data:/data
networks:
- kanban-app-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
volumes:
postgres-dev-data:
minio-data:
networks:
kanban-app-network:

View file

@ -55,27 +55,6 @@ services:
- crafting-shop-network
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: crafting-shop-minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio-data:/data
networks:
- crafting-shop-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
celery_worker:
build:
context: ./backend
@ -184,7 +163,6 @@ volumes:
grafana-data:
backend-data:
celery-beat-data:
minio-data:
networks:
crafting-shop-network:

View file

@ -1,338 +0,0 @@
# Epic & Wiki Feature Implementation Summary
## Overview
This document summarizes the implementation of the Epic and Wiki features for the Kanban application.
## What Was Implemented
### Backend (Flask)
#### 1. Database Models
**Epic Model** (`backend/app/models/epic.py`)
- Rich text content support (JSON for Slate.js)
- Hierarchical structure (parent_epic_id for nesting)
- Color coding
- Position ordering
- Board-level scoping
- Soft delete support (closed field)
- Depth limit for hierarchy control
**Wiki Model** (`backend/app/models/wiki.py`)
- Rich text content (JSON for Slate.js)
- URL-friendly slugs
- Summary field
- Categories for organization
- Tags support
- Created by / Updated by tracking
- Board-level scoping
**Card Model Update** (`backend/app/models/card.py`)
- Added `epic_id` foreign key to link cards to epics
**Association Table** (`wiki_entity_links`)
- Many-to-many relationship between Wikis and entity types
- Supports linking Wikis to Cards, Epics, and future entity types
#### 2. Database Migration
- Created migration file: `6fc439155ced_add_epic_and_wiki_models.py`
- Adds all new tables and relationships
### Frontend (React/TypeScript)
#### 1. TypeScript Types (`frontend/src/types/epic.ts`)
```typescript
- Epic interface
- Wiki interface
- CreateEpicRequest / UpdateEpicRequest
- CreateWikiRequest / UpdateWikiRequest
- WikiEntityLink interface
```
#### 2. Components
**RichTextEditor** (`frontend/src/components/RichTextEditor.tsx`)
- Slate.js-based rich text editor
- Editable with proper styling
- Dark mode support
- Placeholder support
- Read-only mode option
**RichTextContent** (`frontend/src/components/RichTextContent.tsx`)
- Read-only renderer for Slate.js content
- Supports paragraphs, lists, blockquotes
- Text formatting (bold, italic, underline, code)
- Dark mode styling
#### 3. Custom Hook
**useEpics** (`frontend/src/hooks/useEpics.ts`)
- Fetch all epics for a board
- Create new epic
- Update existing epic
- Delete epic
- Integrated with global loader and toast notifications
- Error handling with user-friendly messages
#### 4. API Integration
Updated `useApi` hook with epic methods:
```typescript
- getEpics(boardId)
- createEpic(boardId, epicData)
- getEpic(epicId)
- updateEpic(epicId, epicData)
- deleteEpic(epicId)
- addEpicToCard(cardId, epicId)
- removeEpicFromCard(cardId, epicId)
```
## Key Design Decisions
### 1. Epic Hierarchy
- **Decision**: Self-referential foreign key (`parent_epic_id`)
- **Rationale**: Allows flexible nesting of epics with arbitrary depth
- **Feature**: `depth_limit` field to control maximum nesting depth
### 2. Rich Text Storage
- **Decision**: Store as JSON (compatible with Slate.js)
- **Rationale**:
- Slate.js natively uses JSON format
- No serialization/deserialization overhead
- Easy to query and modify content structure
### 3. Wiki vs Document Naming
- **Decision**: Use "Wiki" instead of "Document"
- **Rationale**: Avoids confusion with file attachments
- **Meaning**: Wiki implies knowledge base / documentation repository
### 4. Entity Linking Strategy
- **Decision**: Polymorphic association table (`wiki_entity_links`)
- **Rationale**:
- Single table handles all entity types
- Easy to add new entity types in future
- Avoids circular imports and complex schema changes
### 5. Epic-Card Relationship
- **Decision**: One-way reference (Card → Epic)
- **Rationale**:
- Simpler than many-to-many (epics contain cards)
- Cards can belong to one epic at a time
- Consistent with Jira's parent/child pattern
## Architecture
### Backend Data Flow
```
Board (1) ───────┬─────── (1) Epic
│ │
│ │─── (0..*) Card (via epic_id)
└───────────────┬─────── (0..*) Wiki
└─── wiki_entity_links ───┬── Card
├── Epic
└── (future entities)
```
### Frontend Component Structure
```
BoardEpics (page)
├── EpicList
│ └── EpicCard
└── CreateEpicModal
├── EpicForm
│ ├── EpicNameInput
│ ├── EpicDescriptionInput
│ └── RichTextEditor (content)
└── ColorPicker
```
## Next Steps
### Immediate Actions Required
1. **Run Database Migration**
```bash
cd backend
flask db upgrade
```
2. **Create Backend Routes** (`backend/app/routes/kanban/epics.py`)
- CRUD operations for Epics
- Epic-Card linking endpoints
- Wiki CRUD operations
- Wiki-Entity linking endpoints
3. **Create Backend Schemas** (`backend/app/schemas/epic.py`, `wiki.py`)
- Marshmallow schemas for serialization
- Input validation
4. **Update BoardEpics Page** (`frontend/src/pages/BoardEpics.tsx`)
- Implement epic list view
- Add create epic modal
- Add epic detail view
- Include RichTextEditor for epic content
5. **Update Card Detail Page** (`frontend/src/pages/CardDetail.tsx`)
- Add epic selector dropdown
- Display linked epic information
- Add wiki links
### Future Enhancements
1. **Wiki Features**
- Wiki page with sidebar navigation
- Markdown export/import
- Wiki search functionality
- Version history
2. **Epic Features**
- Epic progress tracking (cards completed vs total)
- Epic metrics dashboard
- Epic drag-and-drop reordering
- Epic timeline view
3. **UI/UX Improvements**
- Epic color picker with presets
- Epic hierarchy tree view
- Inline card epic assignment
- Epic templates
4. **Analytics**
- Epic completion rate
- Time spent on epic
- Epic size distribution
- Wiki usage statistics
## Database Schema
### Epic Table
```sql
CREATE TABLE epics (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
content JSONB,
color VARCHAR(7),
closed BOOLEAN DEFAULT FALSE,
pos FLOAT,
depth_limit INTEGER,
board_id INTEGER REFERENCES boards(id),
parent_epic_id INTEGER REFERENCES epics(id),
date_last_activity TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Wiki Table
```sql
CREATE TABLE wikis (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE,
content JSONB NOT NULL,
summary TEXT,
category VARCHAR(100),
board_id INTEGER REFERENCES boards(id),
created_by INTEGER REFERENCES users(id),
updated_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Wiki Entity Links Table
```sql
CREATE TABLE wiki_entity_links (
wiki_id INTEGER REFERENCES wikis(id) ON DELETE CASCADE,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
linked_by INTEGER REFERENCES users(id),
PRIMARY KEY (wiki_id, entity_type, entity_id)
);
```
## API Endpoints (To Be Implemented)
### Epic Endpoints
```
GET /api/boards/<board_id>/epics
POST /api/boards/<board_id>/epics
GET /api/epics/<epic_id>
PUT /api/epics/<epic_id>
DELETE /api/epics/<epic_id>
```
### Epic-Card Linking
```
POST /api/cards/<card_id>/epics
DELETE /api/cards/<card_id>/epics/<epic_id>
```
### Wiki Endpoints
```
GET /api/boards/<board_id>/wikis
POST /api/boards/<board_id>/wikis
GET /api/wikis/<wiki_id>
PUT /api/wikis/<wiki_id>
DELETE /api/wikis/<wiki_id>
GET /api/wikis/<wiki_id>/content
```
### Wiki-Entity Linking
```
POST /api/wikis/<wiki_id>/links
DELETE /api/wikis/<wiki_id>/links/<entity_type>/<entity_id>
```
## Testing Considerations
### Unit Tests
- Epic model creation and relationships
- Wiki model creation and relationships
- Epic hierarchy validation
- Wiki slug generation
- Rich text content serialization
### Integration Tests
- Epic CRUD operations
- Wiki CRUD operations
- Epic-Card linking
- Wiki-Entity linking
- Epic hierarchy operations
### Frontend Tests
- RichTextEditor component
- RichTextContent component
- useEpics hook
- Epic list rendering
- Epic creation form
## Notes
- **Slate.js Content**: Content is stored as raw Slate.js JSON format, which includes element types, text nodes, and formatting information
- **Hierarchical Epics**: While supported, UI for nesting epics is not yet implemented
- **Wiki Slugs**: Slugs should be auto-generated from wiki names and checked for uniqueness
- **Color Codes**: Epic colors should be valid hex codes (e.g., "#ef4444")
- **Position**: Epic positions use float values for flexible reordering (similar to Trello)
## Related Files
### Backend
- `backend/app/models/epic.py` - Epic model
- `backend/app/models/wiki.py` - Wiki model
- `backend/app/models/card.py` - Updated Card model
- `backend/app/models/__init__.py` - Model imports
- `backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py` - Migration
### Frontend
- `frontend/src/types/epic.ts` - TypeScript interfaces
- `frontend/src/components/RichTextEditor.tsx` - Editor component
- `frontend/src/components/RichTextContent.tsx` - Content renderer
- `frontend/src/hooks/useEpics.ts` - Epic custom hook
- `frontend/src/hooks/useApi.ts` - Updated API methods

View file

@ -1,238 +0,0 @@
# Epic and Wiki Models - Implementation Summary
## Overview
This document summarizes the implementation of Epic and Wiki models for the Kanban application.
## What Was Implemented
### 1. Epic Model (`backend/app/models/epic.py`)
- Tracks large features across multiple cards
- Hierarchical structure (parent-child epics)
- Rich text content support (Slate.js JSON)
- Color-coded badges for visual identification
- Metrics tracking (card count)
**Key Fields:**
- `id`, `name`, `description`, `content` (JSONB)
- `color` (hex code for epic badge)
- `closed`, `pos`, `depth_limit` (default 5)
- `board_id`, `parent_epic_id`
- `date_last_activity`, `created_at`, `updated_at`
- `metrics` (JSONB - stores card_count)
**Relationships:**
- Board: One-to-many (Board has many Epics)
- Cards: One-to-many (Epic has many Cards)
- Parent Epic: Self-referential (hierarchical)
- File Attachments: Polymorphic (like Cards)
### 2. Wiki Model (`backend/app/models/wiki.py`)
- Reusable rich text content within a board
- Board-scoped (not global across all boards)
- Polymorphic links to entities (Card, Epic, etc.)
- Categorization and tagging support
**Key Fields:**
- `id`, `name`, `slug` (URL-friendly)
- `content` (JSONB - rich text)
- `summary`, `category`, `tags` (JSONB)
- `board_id`, `created_by`, `updated_by`
- `created_at`, `updated_at`
**Relationships:**
- Board: One-to-many (Board has many Wikis)
- Entities: Many-to-many polymorphic (via wiki_entity_links)
### 3. Card Model Updates (`backend/app/models/card.py`)
- Added `epic_id` foreign key (nullable)
- Updated `to_dict()` to include `epic_id`
- One-to-one relationship: Card belongs to one Epic
### 4. Association Table (`wiki_entity_links`)
- Polymorphic many-to-many table
- Links wikis to any entity type
- Fields: `wiki_id`, `entity_type`, `entity_id`, `created_at`, `linked_by`
## Database Schema
### New Tables
1. **epics** - Epic records
2. **wikis** - Wiki content records
3. **wiki_entity_links** - Wiki-to-entity associations
### Modified Tables
1. **cards** - Added `epic_id` foreign key
### Relationships Diagram
```
Board (1) ----< (N) Epic
Epic (1) ----< (N) Card (each card belongs to one epic)
Epic (1) ----< (N) Epic (parent-child hierarchy)
Board (1) ----< (N) Wiki
Wiki (M) ----> (M) Entity (polymorphic: Card, Epic)
```
## Database Migration
**File:** `backend/migrations/versions/add_epic_and_wiki_models.py`
**Creates:**
- `epics` table with indexes on `board_id`, `closed`, `name`
- `wikis` table with indexes on `board_id`, `name`, `slug`
- `wiki_entity_links` table with composite primary key
- `epic_id` column in `cards` table with index and foreign key
**To apply migration:**
```bash
cd backend
flask db upgrade
```
**To rollback:**
```bash
flask db downgrade
```
## Model Exports
Updated `backend/app/models/__init__.py` to include:
- `from app.models.epic import Epic`
- `from app.models.wiki import Wiki`
Added to `__all__` list: `"Epic"`, `"Wiki"`
## Design Decisions
### Why "Wiki" instead of "Document"?
- Avoids confusion with file attachments
- Emphasizes reusable knowledge content
- Better semantic meaning for rich text resources
### One-to-Many Epic-Card Relationship
- Simpler, clearer ownership
- Each card belongs to one epic
- Easier to query and display
### Board-Scoped Wikis
- Wikis belong to a specific board
- Not global across all boards
- Better organization and access control
### Epic Hierarchy Depth
- Default depth limit: 5 levels
- Configurable per epic
- Backend should enforce when creating child epics
### Simplified Metrics
- Currently only tracks `card_count`
- Stored in JSONB field: `{"card_count": 10}`
- Easy to extend with more metrics later
## Next Steps
### Backend Implementation
1. ✅ Create models - **DONE**
2. ✅ Create database migration - **DONE**
3. ⏭️ Create schemas for serialization
4. ⏭️ Create API routes (CRUD operations)
5. ⏭️ Create services for business logic
6. ⏭️ Add validation for epic depth limit
7. ⏭️ Update epic metrics when cards change
8. ⏭️ Write tests for models and routes
### Frontend Implementation
1. ⏭️ Update TypeScript types
2. ⏭️ Create Epic page/component
3. ⏭️ Create Wiki page/component
4. ⏭️ Add epic dropdown to card detail
5. ⏭️ Create epic list on board detail
6. ⏭️ Implement rich text editor (Slate.js)
7. ⏭️ Add wiki linking UI
### API Endpoints (Future)
#### Epic Endpoints
```
GET /api/boards/{board_id}/epics # List all epics for board
POST /api/boards/{board_id}/epics # Create epic
GET /api/epics/{epic_id} # Get epic details with cards
PUT /api/epics/{epic_id} # Update epic
DELETE /api/epics/{epic_id} # Delete epic
POST /api/epics/{epic_id}/cards # Create card directly in epic
GET /api/epics/{epic_id}/tree # Get epic hierarchy tree
```
#### Wiki Endpoints
```
GET /api/boards/{board_id}/wikis # List all wikis for board
POST /api/boards/{board_id}/wikis # Create wiki
GET /api/wikis/{wiki_id} # Get wiki details
PUT /api/wikis/{wiki_id} # Update wiki
DELETE /api/wikis/{wiki_id} # Delete wiki
POST /api/wikis/{wiki_id}/links # Link wiki to entity
DELETE /api/wikis/{wiki_id}/links/{link_id} # Unlink from entity
GET /api/{entity_type}/{entity_id}/wikis # Get wikis for entity
```
#### Card Endpoints (Updated)
```
PUT /api/cards/{card_id}/epic # Link card to epic (or null to unlink)
GET /api/cards/{card_id}/epic # Get card's epic
```
## Files Created/Modified
### Created
- `backend/app/models/epic.py`
- `backend/app/models/wiki.py`
- `backend/migrations/versions/add_epic_and_wiki_models.py`
### Modified
- `backend/app/models/card.py` (added epic_id)
- `backend/app/models/__init__.py` (added Epic, Wiki imports)
## Testing the Implementation
1. **Apply migration:**
```bash
cd backend
source venv/bin/activate
flask db upgrade
```
2. **Verify tables created:**
```bash
flask dbcurrent
# Should show: add_epic_and_wiki_models
```
3. **Test in Python shell:**
```python
from app import create_app, db
from app.models import Epic, Wiki, Card, Board
app = create_app()
with app.app_context():
# Create an epic
epic = Epic(name="My Epic", board_id=1, color="#3b82f6")
db.session.add(epic)
db.session.commit()
# Create a wiki
wiki = Wiki(name="Security Guide", board_id=1, content={})
db.session.add(wiki)
db.session.commit()
print(f"Epic created: {epic.to_dict()}")
print(f"Wiki created: {wiki.to_dict()}")
```
## Notes
- All models follow the existing project patterns
- Uses `db` from `app` module (not `flask_sqlalchemy` directly)
- Proper foreign key constraints with CASCADE/SET NULL
- Timestamps use UTC timezone
- JSONB fields for flexible data storage
- Indexed for optimal query performance

View file

@ -372,13 +372,13 @@ useEffect(() => {
```
### Icons
- **ALWAYS** create icon components in `frontend/src/components/icons/`
- **ALWAYS** use inline SVG icons
- **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome
- Each icon should be a separate file with a default export
- Import icons from the icons folder
- Create reusable SVG icon components when needed
- SVGs should be defined as functional components
```jsx
// ✅ CORRECT - Icon component in frontend/src/components/icons/TrashIcon.tsx
// ✅ CORRECT - Inline SVG as a component
const TrashIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
@ -386,11 +386,6 @@ const TrashIcon = () => (
</svg>
);
export default TrashIcon;
// ✅ CORRECT - Using icon component
import TrashIcon from "../components/icons/TrashIcon";
function DeleteButton() {
return (
<button className="flex items-center gap-2">
@ -423,31 +418,14 @@ function DeleteButton() {
</button>
);
}
// ❌ WRONG - Inline SVG in component file
function DeleteButton() {
return (
<button className="flex items-center gap-2">
<span className="w-4 h-4">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</span>
Delete
</button>
);
}
```
**Why use icon components in the icons folder?**
**Why inline SVGs?**
1. **No dependencies**: Reduces bundle size and eliminates external dependencies
2. **Full control**: You can customize SVG properties directly in the icon component
2. **Full control**: You can customize SVG properties directly in JSX
3. **Performance**: No runtime overhead from library wrappers
4. **Consistency**: All icons follow the same pattern and are organized in one place
5. **Reusability**: Icons can be easily imported and reused across the application
6. **Maintainability**: Icon components are centralized and easy to update
7. **TypeScript support**: Full type safety without any issues
4. **Consistency**: All icons follow to same pattern and styling
5. **TypeScript support**: Full type safety without any issues
## Routing Rules
@ -824,7 +802,7 @@ export function useNewContext() {
**NEVER** hardcode API URLs
**NEVER** skip PropTypes validation
**NEVER** use `<a>` for navigation (use `<Link>`)
**NEVER** use icon libraries like lucide-react, react-icons, or font-awesome (always use icon components from frontend/src/components/icons/)
**NEVER** use icon libraries like lucide-react, react-icons, or font-awesome (always use inline SVGs)
## Checklist Before Committing

View file

@ -17,16 +17,12 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.71.2",
"react-router-dom": "^6.20.1",
"slate": "^0.123.0",
"slate-history": "^0.113.1",
"slate-react": "^0.123.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
"@types/node": "^25.5.0",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.56.1",
@ -1058,11 +1054,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@juggle/resize-observer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1623,15 +1614,6 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -2784,11 +2766,6 @@
"node": ">= 6"
}
},
"node_modules/compute-scroll-into-view": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -3080,18 +3057,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/direction": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
"integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==",
"bin": {
"direction": "cli.js"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@ -4521,11 +4486,6 @@
"node": ">=0.10.0"
}
},
"node_modules/is-hotkey": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@ -4584,14 +4544,6 @@
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@ -4969,11 +4921,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -6263,14 +6210,6 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/scroll-into-view-if-needed": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
"integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
"dependencies": {
"compute-scroll-into-view": "^3.0.2"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -6451,59 +6390,6 @@
"node": ">= 10"
}
},
"node_modules/slate": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate/-/slate-0.123.0.tgz",
"integrity": "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg=="
},
"node_modules/slate-dom": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.123.0.tgz",
"integrity": "sha512-OUinp4tvSrAlt64JL9y20Xin08jgnnj1gJmIuPdGvU5MELKXRNZh17a7EKKNOS6OZPAE8Dk9NI1MAIS/Qz0YBw==",
"peer": true,
"dependencies": {
"@juggle/resize-observer": "^3.4.0",
"direction": "^1.0.4",
"is-hotkey": "^0.2.0",
"is-plain-object": "^5.0.0",
"lodash": "^4.17.21",
"scroll-into-view-if-needed": "^3.1.0",
"tiny-invariant": "1.3.1"
},
"peerDependencies": {
"slate": ">=0.121.0"
}
},
"node_modules/slate-history": {
"version": "0.113.1",
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz",
"integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==",
"dependencies": {
"is-plain-object": "^5.0.0"
},
"peerDependencies": {
"slate": ">=0.65.3"
}
},
"node_modules/slate-react": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.123.0.tgz",
"integrity": "sha512-nQwXL1FEacrY9ZFmatRhoBnsySNUX2x6qB77V3oNHd7wWxBJWuzz4GMrBXcVoRE8Gac7Angf8xaNGzb6zcPlHg==",
"dependencies": {
"@juggle/resize-observer": "^3.4.0",
"direction": "^1.0.4",
"is-hotkey": "^0.2.0",
"lodash": "^4.17.21",
"scroll-into-view-if-needed": "^3.1.0",
"tiny-invariant": "1.3.1"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0",
"slate": ">=0.121.0",
"slate-dom": ">=0.119.1"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -6848,11 +6734,6 @@
"node": ">=0.8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw=="
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -7137,12 +7018,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",

View file

@ -23,16 +23,12 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.71.2",
"react-router-dom": "^6.20.1",
"slate": "^0.123.0",
"slate-history": "^0.113.1",
"slate-react": "^0.123.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
"@types/node": "^25.5.0",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.56.1",

View file

@ -16,16 +16,7 @@ import { Boards } from './pages/Boards';
import { BoardCreate } from './pages/BoardCreate';
import { BoardEdit } from './pages/BoardEdit';
import { BoardDetail } from './pages/BoardDetail';
import { BoardEpics } from './pages/BoardEpics';
import CreateEpic from './pages/CreateEpic';
import { EpicDetail } from './pages/EpicDetail';
import { EditEpic } from './pages/EditEpic';
import { BoardWikis } from './pages/BoardWikis';
import CreateWiki from './pages/CreateWiki';
import { WikiDetail } from './pages/WikiDetail';
import { EditWiki } from './pages/EditWiki';
import { CardDetail } from './pages/CardDetail';
import { BoardDetailLayout } from './components/BoardDetailLayout';
const App = () => {
const { token } = useApp();
@ -44,7 +35,7 @@ const App = () => {
<ModalProvider>
<div className="min-h-screen bg-gray-900 text-gray-100">
<Navbar />
<main>
<main className="flex-1 p-8 mx-auto w-full max-w-7xl">
<Routes>
<Route
path="/"
@ -81,9 +72,7 @@ const App = () => {
path="/boards/:id/edit"
element={
<ProtectedRoute>
<BoardDetailLayout>
<BoardEdit />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
@ -91,89 +80,7 @@ const App = () => {
path="/boards/:id"
element={
<ProtectedRoute>
<BoardDetailLayout>
<BoardDetail />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics"
element={
<ProtectedRoute>
<BoardDetailLayout>
<BoardEpics />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/new"
element={
<ProtectedRoute>
<BoardDetailLayout>
<CreateEpic />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/:epicId"
element={
<ProtectedRoute>
<BoardDetailLayout>
<EpicDetail />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/:epicId/edit"
element={
<ProtectedRoute>
<BoardDetailLayout>
<EditEpic />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis"
element={
<ProtectedRoute>
<BoardDetailLayout>
<BoardWikis />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/new"
element={
<ProtectedRoute>
<BoardDetailLayout>
<CreateWiki />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/:wikiId"
element={
<ProtectedRoute>
<BoardDetailLayout>
<WikiDetail />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/:wikiId/edit"
element={
<ProtectedRoute>
<BoardDetailLayout>
<EditWiki />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
@ -181,9 +88,7 @@ const App = () => {
path="/boards/:id/cards/:cardId"
element={
<ProtectedRoute>
<BoardDetailLayout>
<CardDetail />
</BoardDetailLayout>
</ProtectedRoute>
}
/>

View file

@ -1,18 +0,0 @@
import { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { BoardSidebar } from './BoardSidebar';
export const BoardDetailLayout = ({ children }: { children: ReactNode }) => {
const { id } = useParams<{ id: string }>();
return (
<div className="relative">
<div className="pr-6">{children}</div>
{id && (
<div className="">
<BoardSidebar boardId={id} />
</div>
)}
</div>
);
};

View file

@ -1,42 +0,0 @@
import { Link, useLocation } from 'react-router-dom';
interface BoardSidebarProps {
boardId: string;
}
export function BoardSidebar({ boardId }: BoardSidebarProps) {
const location = useLocation();
const menuItems = [
{ id: 'epics', label: 'Epics', icon: '📋', path: `/boards/${boardId}/epics` },
{ id: 'wikis', label: 'Wikis', icon: '📚', path: `/boards/${boardId}/wikis` },
{ id: 'history', label: 'History', icon: '📜', path: `/boards/${boardId}/history` },
];
return (
<div className="fixed right-0 top-1/2 -translate-y-1/2 z-40">
<div className="flex flex-col gap-2">
{menuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<Link
key={item.id}
to={item.path}
className={`
flex items-center gap-3 px-4 py-3 rounded-l-lg transition-all duration-200
${
isActive
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'
}
`}
>
<span className="text-xl">{item.icon}</span>
<span className="font-medium">{item.label}</span>
</Link>
);
})}
</div>
</div>
);
}

View file

@ -1,113 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import Edit2Icon from './icons/Edit2Icon';
import Trash2Icon from './icons/Trash2Icon';
import PlusIcon from './icons/PlusIcon';
import LinkIcon from './icons/LinkIcon';
import VerticalEllipsisIcon from './icons/VerticalEllipsisIcon';
interface CardActionDropdownProps {
onEdit: () => void;
onDelete: () => void;
onCreateLinkedCard?: () => void;
onLinkExistingCard?: () => void;
}
export function CardActionDropdown({
onEdit,
onDelete,
onCreateLinkedCard,
onLinkExistingCard,
}: CardActionDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
const handleEdit = () => {
setIsOpen(false);
onEdit();
};
const handleDelete = () => {
setIsOpen(false);
onDelete();
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="text-gray-400 hover:text-white hover:bg-gray-700 p-2 rounded-lg transition-colors"
title="Card actions"
aria-label="Card actions"
aria-expanded={isOpen}
>
<VerticalEllipsisIcon />
</button>
{isOpen && (
<div className="absolute top-full right-0 mt-2 w-56 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50">
<div className="py-1">
<button
onClick={handleEdit}
className="w-full flex items-center gap-2 px-4 py-2 text-left text-gray-300 hover:text-white hover:bg-gray-700 transition-colors"
>
<span className="w-4 h-4">
<Edit2Icon />
</span>
Edit Card
</button>
{onCreateLinkedCard && (
<button
onClick={() => {
setIsOpen(false);
onCreateLinkedCard();
}}
className="w-full flex items-center gap-2 px-4 py-2 text-left text-gray-300 hover:text-white hover:bg-gray-700 transition-colors"
>
<span className="w-4 h-4">
<LinkIcon />
</span>
Create Linked Card
</button>
)}
{onLinkExistingCard && (
<button
onClick={() => {
setIsOpen(false);
onLinkExistingCard();
}}
className="w-full flex items-center gap-2 px-4 py-2 text-left text-gray-300 hover:text-white hover:bg-gray-700 transition-colors"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Link Existing Card
</button>
)}
<button
onClick={handleDelete}
className="w-full flex items-center gap-2 px-4 py-2 text-left text-red-400 hover:text-red-300 hover:bg-red-900/20 transition-colors"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
Delete Card
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -1,157 +0,0 @@
import { useFileAttachments } from '../hooks/useFileAttachments';
import Trash2Icon from './icons/Trash2Icon';
import FileIcon from './icons/FileIcon';
import DownloadIcon from './icons/DownloadIcon';
import EyeIcon from './icons/EyeIcon';
import { SecureImage } from './SecureImage';
import { formatDateOnly } from '../utils/dateFormat';
interface CardAttachmentsProps {
cardId?: number;
}
export function CardAttachments({ cardId }: CardAttachmentsProps) {
const { attachments, uploadFile, deleteAttachment } = useFileAttachments(cardId);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await uploadFile(file);
} catch {
// Error already handled in hook with toast
}
// Reset input
e.target.value = '';
};
const handleDelete = async (fileUuid: string, fileName: string) => {
if (!window.confirm(`Are you sure you want to delete "${fileName}"?`)) {
return;
}
await deleteAttachment(fileUuid);
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
const isImage = (mimeType: string) => mimeType.startsWith('image/');
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<span className="w-5 h-5">
<FileIcon />
</span>
Attachments
</h2>
<div className="flex items-center gap-2">
<span className="text-gray-400 text-sm">{attachments.length} files</span>
<label className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm">
Add Attachment
<input
type="file"
className="hidden"
onChange={handleFileUpload}
accept="image/*,.pdf"
/>
</label>
</div>
</div>
{attachments.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p>No attachments yet</p>
<p className="text-sm mt-2">Upload images or PDFs to this card</p>
</div>
) : (
<div className="space-y-3">
{attachments.map((attachment) => (
<div
key={attachment.uuid}
className="bg-gray-700 rounded-lg p-4 hover:bg-gray-650 transition-colors"
>
<div className="flex items-start gap-4">
{/* Thumbnail or file icon */}
<div className="flex-shrink-0">
{isImage(attachment.mime_type) && attachment.thumbnail_url ? (
<SecureImage
url={attachment.thumbnail_url}
alt={attachment.original_name}
className="w-16 h-16 object-cover rounded"
/>
) : (
<div className="w-16 h-16 bg-gray-600 rounded flex items-center justify-center">
<span className="w-8 h-8 text-gray-400">
<FileIcon />
</span>
</div>
)}
</div>
{/* File info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{attachment.original_name}</p>
<p className="text-gray-400 text-sm mt-1">
{formatFileSize(attachment.file_size)} {' '}
{formatDateOnly(attachment.created_at)}
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
{isImage(attachment.mime_type) && attachment.view_url && (
<a
href={attachment.view_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 transition-colors p-2"
title="View"
>
<span className="w-4 h-4">
<EyeIcon />
</span>
</a>
)}
{attachment.download_url && (
<a
href={attachment.download_url}
className="text-blue-400 hover:text-blue-300 transition-colors p-2"
title="Download"
>
<span className="w-4 h-4">
<DownloadIcon />
</span>
</a>
)}
<button
onClick={() => handleDelete(attachment.uuid, attachment.original_name)}
className="text-red-400 hover:text-red-300 transition-colors p-2"
title="Delete"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
</button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -2,7 +2,6 @@ import CheckSquareIcon from './icons/CheckSquareIcon';
import Trash2Icon from './icons/Trash2Icon';
import Edit2Icon from './icons/Edit2Icon';
import PlusIcon from './icons/PlusIcon';
import MonitorIcon from './icons/MonitorIcon';
import { useModal } from '../context/modals/useModal';
import { CreateChecklistModal } from './CreateChecklistModal';
import { DeleteChecklistModal } from './DeleteChecklistModal';
@ -28,7 +27,6 @@ interface CardChecklistsProps {
state: 'incomplete' | 'complete'
) => Promise<boolean>;
removeCheckItem: (itemId: number) => Promise<boolean>;
onConvertToCard?: (itemName: string, itemId: number) => void;
}
export function CardChecklists({
@ -39,7 +37,6 @@ export function CardChecklists({
toggleCheckItem,
editCheckItem,
removeCheckItem,
onConvertToCard,
}: CardChecklistsProps) {
const { openModal } = useModal();
@ -155,7 +152,7 @@ export function CardChecklists({
</div>
<div className="space-y-2">
{checklist.items && checklist.items.length > 0 ? (
checklist.items.map((item: any, itemIndex: number) => (
checklist.items.map((item: any) => (
<div
key={item.id}
className="flex items-center gap-3 p-2 bg-gray-700 rounded group hover:bg-gray-600 transition-colors"
@ -170,62 +167,9 @@ export function CardChecklists({
onClick={() => handleToggleCheckItem(item)}
className={`flex-1 text-sm cursor-pointer ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
>
<span className="text-gray-500 mr-1">{itemIndex + 1}.</span>
{item.name}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onConvertToCard && (
<button
onClick={() => {
openModal((modalProps) => (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<div className="flex items-center gap-3 mb-4">
<span className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center">
<span className="w-5 h-5">
<MonitorIcon />
</span>
</span>
<h3 className="text-xl font-bold text-white">
Convert to Card
</h3>
</div>
<p className="text-gray-300 mb-6">
Convert
<span className="text-white font-semibold">
{' '}
&quot;{item.name}&quot;{' '}
</span>
into a new linked card? The checklist item will be removed and
a new card will be created.
</p>
<div className="flex justify-end gap-3">
<button
onClick={modalProps.onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={() => {
modalProps.onClose();
onConvertToCard(item.name, item.id);
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Convert to Card
</button>
</div>
</div>
));
}}
className="text-gray-400 hover:text-blue-400 transition-colors p-1"
title="Convert to card"
>
<span className="w-3.5 h-3.5">
<MonitorIcon />
</span>
</button>
)}
<button
onClick={() => handleEditCheckItem(item)}
className="text-gray-400 hover:text-white transition-colors p-1"

View file

@ -1,7 +1,6 @@
import { FormEvent, useState } from 'react';
import MessageSquareIcon from './icons/MessageSquareIcon';
import PlusIcon from './icons/PlusIcon';
import { formatDateTime } from '../utils/dateFormat';
interface CardCommentsProps {
card: any;
@ -22,6 +21,16 @@ export function CardComments({
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [editedCommentText, setEditedCommentText] = useState('');
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const handleAddComment = async (e: FormEvent) => {
e.preventDefault();
if (!newCommentText.trim()) return;
@ -117,7 +126,7 @@ export function CardComments({
</div>
<div>
<p className="text-white font-medium">{comment.user?.username || 'Unknown'}</p>
<p className="text-gray-400 text-xs">{formatDateTime(comment.created_at)}</p>
<p className="text-gray-400 text-xs">{formatDate(comment.created_at)}</p>
</div>
</div>
<div className="flex gap-2">

View file

@ -1,159 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useCardEpics } from '../hooks/useCardEpics';
import type { EpicBasic } from '../types/kanban';
import PlusIcon from './icons/PlusIcon';
import UnlinkIcon from './icons/UnlinkIcon';
import ChevronRightIcon from './icons/ChevronRightIcon';
import CheckIcon from './icons/CheckIcon';
interface CardEpicsProps {
cardEpics: EpicBasic[];
boardId: number;
cardId: number;
refetchCard: () => void;
}
export function CardEpics({ cardEpics, boardId, cardId, refetchCard }: CardEpicsProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { epics, addEpic, removeEpic } = useCardEpics(boardId, cardId);
const handleAddEpic = async (epicId: number) => {
const success = await addEpic(epicId);
if (success) {
await refetchCard();
setIsDropdownOpen(false);
}
};
const handleRemoveEpic = async (epicId: number, epicName: string) => {
if (!confirm(`Unlink epic "${epicName}" from this card?`)) {
return;
}
const success = await removeEpic(epicId);
if (success) {
await refetchCard();
}
};
// Filter out epics that are already linked to the card
const availableEpics = epics.filter(
(epic) => !cardEpics.some((cardEpic) => cardEpic.id === epic.id)
);
return (
<div className="bg-gray-800 rounded-lg overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-700 flex justify-between items-center">
<h2 className="text-lg font-semibold text-white">Epics</h2>
<span className="text-sm text-gray-400">{cardEpics.length} linked</span>
</div>
{/* Epics List */}
<div className="divide-y divide-gray-700 max-h-[400px] overflow-y-auto scrollbar-custom">
{cardEpics.length > 0 ? (
cardEpics.map((epic: EpicBasic) => (
<EpicListItem
key={epic.id}
epic={epic}
boardId={boardId}
onUnlink={() => handleRemoveEpic(epic.id, epic.name)}
/>
))
) : (
<div className="p-6 text-center">
<div className="text-4xl mb-2">📋</div>
<p className="text-gray-400 text-sm">No epics linked yet</p>
</div>
)}
</div>
{/* Add Epic Button */}
{availableEpics.length > 0 && (
<div className="px-4 py-3 border-t border-gray-700 relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="w-full flex items-center justify-center gap-2 text-gray-400 hover:text-white hover:bg-gray-700 px-3 py-2 rounded text-sm transition-colors"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
{cardEpics.length > 0 ? 'Link another epic' : 'Link epic'}
</button>
{/* Dropdown */}
{isDropdownOpen && (
<div className="absolute bottom-full left-4 right-4 mb-2 bg-gray-700 rounded-lg shadow-lg border border-gray-600 max-h-[200px] overflow-y-auto scrollbar-custom z-10">
{availableEpics.map((epic: EpicBasic) => (
<button
key={epic.id}
onClick={() => handleAddEpic(epic.id)}
className="w-full text-left px-3 py-2 hover:bg-gray-600 transition-colors flex items-center gap-3 border-b border-gray-600 last:border-b-0"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: epic.color || '#3b82f6' }}
/>
<span className="text-white text-sm truncate flex-1">{epic.name}</span>
<span className="w-4 h-4 text-green-400 flex-shrink-0">
<CheckIcon />
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
interface EpicListItemProps {
epic: EpicBasic;
boardId: number;
onUnlink: () => void;
}
function EpicListItem({ epic, boardId, onUnlink }: EpicListItemProps) {
const defaultColor = '#3b82f6';
const color = epic.color || defaultColor;
return (
<div className="group relative">
<Link
to={`/boards/${boardId}/epics/${epic.id}`}
className="block px-4 py-2.5 hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-3">
{/* Color Indicator */}
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
{/* Epic Name */}
<h3 className="text-white text-sm font-medium truncate flex-1">{epic.name}</h3>
{/* Chevron indicator */}
<span className="w-4 h-4 text-gray-600 group-hover:text-gray-400 transition-colors flex-shrink-0">
<ChevronRightIcon />
</span>
</div>
</Link>
{/* Unlink Button (visible on hover) */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUnlink();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-gray-800 hover:bg-red-900/50 text-gray-400 hover:text-red-400 p-1 rounded border border-gray-700 hover:border-red-800"
title="Unlink epic"
>
<span className="w-3 h-3">
<UnlinkIcon />
</span>
</button>
</div>
);
}

View file

@ -1,129 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { CardLinksResponse } from '../types/kanban';
import LinkIcon from './icons/LinkIcon';
import UnlinkIcon from './icons/UnlinkIcon';
import { UnlinkCardModal } from './UnlinkCardModal';
interface CardLinksProps {
links: CardLinksResponse;
boardId: number;
onUnlink: (linkId: number) => void;
}
export function CardLinks({ links, boardId, onUnlink }: CardLinksProps) {
const { parent_cards: parentCards, child_cards: childCards } = links;
const hasLinks = parentCards.length > 0 || childCards.length > 0;
const [pendingUnlink, setPendingUnlink] = useState<{
id: number;
cardName: string;
} | null>(null);
const handleConfirmUnlink = () => {
if (pendingUnlink) {
onUnlink(pendingUnlink.id);
setPendingUnlink(null);
}
};
if (!hasLinks) {
return null;
}
return (
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white flex items-center gap-2 mb-4">
<span className="w-5 h-5">
<LinkIcon />
</span>
Linked Cards
</h2>
{parentCards.length > 0 && (
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-400 mb-2 uppercase tracking-wide">
Parent Cards
</h3>
<div className="space-y-2">
{parentCards.map((link) => (
<div
key={link.id}
className="flex items-center justify-between bg-gray-750 border border-gray-700 rounded-lg px-3 py-2 hover:bg-gray-700 transition-colors"
>
<Link
to={`/boards/${boardId}/cards/${link.card.id}`}
className="flex-1 text-gray-200 hover:text-blue-400 transition-colors truncate"
>
<span className="text-gray-500 text-xs mr-2">
#{link.card.id_short || link.card.id}
</span>
{link.card.name}
</Link>
<button
onClick={() => onUnlink(link.id)}
className="text-gray-500 hover:text-red-400 transition-colors ml-2 p-1"
title="Unlink card"
>
<span className="w-4 h-4">
<UnlinkIcon />
</span>
</button>
</div>
))}
</div>
</div>
)}
{childCards.length > 0 && (
<div>
<h3 className="text-sm font-medium text-gray-400 mb-2 uppercase tracking-wide">
Child Cards
</h3>
<div className="space-y-2">
{childCards.map((link) => (
<div
key={link.id}
className="flex items-center justify-between bg-gray-750 border border-gray-700 rounded-lg px-3 py-2 hover:bg-gray-700 transition-colors"
>
<Link
to={`/boards/${boardId}/cards/${link.card.id}`}
className="flex-1 text-gray-200 hover:text-blue-400 transition-colors truncate"
>
<span className="text-gray-500 text-xs mr-2">
#{link.card.id_short || link.card.id}
</span>
{link.card.name}
</Link>
<button
onClick={() =>
setPendingUnlink({
id: link.id,
cardName: link.card.name,
})
}
className="text-gray-500 hover:text-red-400 transition-colors ml-2 p-1"
title="Unlink card"
>
<span className="w-4 h-4">
<UnlinkIcon />
</span>
</button>
</div>
))}
</div>
</div>
)}
{pendingUnlink && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<UnlinkCardModal
cardName={pendingUnlink.cardName}
onUnlink={handleConfirmUnlink}
onClose={() => setPendingUnlink(null)}
/>
</div>
)}
</div>
);
}

View file

@ -12,7 +12,6 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
<div className="flex justify-between items-start mb-4">
<Link
to={`/boards/${card.board_id}/cards/${card.id}`}
onClick={onClose}
className="text-2xl font-bold text-blue-400 hover:text-blue-300 hover:underline transition-colors"
>
{card.name}
@ -25,24 +24,6 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
</button>
</div>
{/* Epic */}
{card.epic && (
<div className="mb-4">
<Link
to={`/boards/${card.board_id}/epics/${card.epic.id}`}
onClick={onClose}
className="inline-flex items-center gap-2 px-3 py-1 rounded text-sm font-medium transition-colors hover:opacity-80"
style={{
backgroundColor: card.epic.color ? `${card.epic.color}33` : '#3b82f633',
color: card.epic.color || '#3b82f6',
border: `1px solid ${card.epic.color || '#3b82f6'}66`,
}}
>
<span>{card.epic.name}</span>
</Link>
</div>
)}
{/* Labels */}
{card.labels && card.labels.length > 0 && (
<div className="mb-4">

View file

@ -1,10 +1,18 @@
import { formatDateTime } from '../utils/dateFormat';
interface CardSidebarProps {
card: any;
}
export function CardSidebar({ card }: CardSidebarProps) {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="space-y-6">
<div className="bg-gray-800 rounded-lg p-6">
@ -12,11 +20,11 @@ export function CardSidebar({ card }: CardSidebarProps) {
<div className="space-y-3">
<div>
<p className="text-gray-400 text-sm">Due Date</p>
<p className="text-white">{card.due ? formatDateTime(card.due) : 'No due date'}</p>
<p className="text-white">{card.due ? formatDate(card.due) : 'No due date'}</p>
</div>
<div>
<p className="text-gray-400 text-sm">Last Activity</p>
<p className="text-white">{formatDateTime(card.date_last_activity)}</p>
<p className="text-white">{formatDate(card.date_last_activity)}</p>
</div>
<div>
<p className="text-gray-400 text-sm">Position</p>

View file

@ -1,109 +0,0 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import CloseIcon from './icons/CloseIcon';
const linkedCardSchema = z.object({
name: z
.string()
.min(1, 'Card name is required')
.max(100, 'Card name must be less than 100 characters'),
description: z.string().max(2000, 'Description must be less than 2000 characters').optional(),
});
type LinkedCardFormData = z.infer<typeof linkedCardSchema>;
interface CreateLinkedCardModalProps {
parentCardName: string;
onClose: () => void;
onSubmit: (name: string, description: string) => void;
}
export function CreateLinkedCardModal({
parentCardName,
onClose,
onSubmit,
}: CreateLinkedCardModalProps) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LinkedCardFormData>({
resolver: zodResolver(linkedCardSchema),
});
const onFormSubmit = async (data: LinkedCardFormData) => {
await onSubmit(data.name.trim(), data.description?.trim() ?? '');
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4 border border-gray-700">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white">Create Linked Card</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<span className="w-5 h-5">
<CloseIcon />
</span>
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
This card will be linked as a child of{' '}
<span className="text-white font-medium">{parentCardName}</span>
</p>
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-1">
Card Name <span className="text-red-400">*</span>
</label>
<input
id="name"
type="text"
{...register('name')}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter card name..."
autoFocus
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-1">
Description
</label>
<textarea
id="description"
{...register('description')}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y min-h-[80px]"
placeholder="Add a description..."
/>
{errors.description && (
<p className="mt-1 text-sm text-red-400">{errors.description.message}</p>
)}
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{isSubmitting ? 'Creating...' : 'Create & Link'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View file

@ -1,37 +0,0 @@
const ELEMENT_TAGS: Record<string, (el: HTMLElement) => Partial<any>> = {
A: (el: HTMLElement) => ({
type: 'link',
url: el.getAttribute('href') || '',
children: [],
}),
BLOCKQUOTE: () => ({ type: 'block-quote', children: [] }),
H1: () => ({ type: 'heading-one', children: [] }),
H2: () => ({ type: 'heading-two', children: [] }),
H3: () => ({ type: 'heading-three', children: [] }),
H4: () => ({ type: 'heading-four', children: [] }),
H5: () => ({ type: 'heading-five', children: [] }),
H6: () => ({ type: 'heading-six', children: [] }),
IMG: (el: HTMLElement) => ({
type: 'image',
url: el.getAttribute('src') || '',
children: [],
}),
LI: () => ({ type: 'list-item', children: [] }),
OL: () => ({ type: 'numbered-list', children: [] }),
P: () => ({ type: 'paragraph', children: [] }),
PRE: () => ({ type: 'code-block', children: [] }),
CODE: () => ({ type: 'code-line bg-gray-900', children: [] }),
UL: () => ({ type: 'bulleted-list', children: [] }),
};
const TEXT_TAGS: Record<string, () => Record<string, boolean>> = {
CODE: () => ({ code: true }),
DEL: () => ({ strikethrough: true }),
EM: () => ({ italic: true }),
I: () => ({ italic: true }),
S: () => ({ strikethrough: true }),
STRONG: () => ({ bold: true }),
U: () => ({ underline: true }),
};
export { ELEMENT_TAGS, TEXT_TAGS };

View file

@ -1,101 +0,0 @@
import { useState } from 'react';
import CloseIcon from './icons/CloseIcon';
import { useLinkExistingCard } from '../hooks/useLinkExistingCard';
interface LinkExistingCardModalProps {
boardId: number;
currentCardId: number;
onClose: () => void;
onLinked: () => void;
}
export function LinkExistingCardModal({
boardId,
currentCardId,
onClose,
onLinked,
}: LinkExistingCardModalProps) {
const [search, setSearch] = useState('');
const [selectedCardId, setSelectedCardId] = useState<number | null>(null);
const { cards, isSubmitting, linkCard } = useLinkExistingCard(boardId, currentCardId);
const filteredCards = cards.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
String(c.id_short || c.id).includes(search)
);
const handleLink = async () => {
if (!selectedCardId) return;
const success = await linkCard(selectedCardId);
if (success) {
onLinked();
onClose();
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4 border border-gray-700">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white">Link Existing Card</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<span className="w-5 h-5">
<CloseIcon />
</span>
</button>
</div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 mb-3"
placeholder="Search cards by name or ID..."
autoFocus
/>
<div className="max-h-60 overflow-y-auto space-y-1 scrollbar-custom">
{filteredCards.length === 0 && (
<p className="text-gray-500 text-sm text-center py-4">No cards found</p>
)}
{filteredCards.map((c) => (
<button
key={c.id}
onClick={() => setSelectedCardId(c.id)}
className={`w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-2 ${
selectedCardId === c.id
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700'
}`}
>
<span className="text-gray-500 text-xs">#{c.id_short || c.id}</span>
<span className="truncate">{c.name}</span>
<span className="ml-auto text-xs text-gray-500">{c.list_name}</span>
</button>
))}
</div>
<div className="flex justify-end gap-3 pt-4 mt-2 border-t border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleLink}
disabled={!selectedCardId || isSubmitting}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{isSubmitting ? 'Linking...' : 'Link Card'}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,5 +0,0 @@
import { ReactNode } from 'react';
export const NarrowPageLayout = ({ children }: { children: ReactNode }) => {
return <div className="flex-1 p-8 mx-auto w-full max-w-7xl">{children}</div>;
};

View file

@ -3,8 +3,41 @@ import { useState } from 'react';
import { useApp } from '../context/AppContext';
import { useAuth } from '../hooks/useAuth';
import { TaskboardLogo } from './TaskboardLogo';
import MenuIcon from './icons/MenuIcon';
import CloseIcon from './icons/CloseIcon';
const MenuIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
);
const CloseIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
);
export function Navbar() {
const { user } = useApp();

Some files were not shown because too many files have changed in this diff Show more