Compare commits
16 commits
initial-ka
...
fix-epic-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b95262681 | |||
| 8e138689cf | |||
| f407285e60 | |||
| 543c28984f | |||
| f59670397a | |||
| 2f53b8f4bb | |||
| 65d0a32cb5 | |||
| 67d3e586ec | |||
| 7b32a8a6ec | |||
| ec48ce3225 | |||
| c343033d7e | |||
| 9958443c14 | |||
| 3711b0888d | |||
| 035aa7b3d7 | |||
| d2bc46ae91 | |||
| effb9edc0f |
169 changed files with 14438 additions and 2156 deletions
10
.env.example
10
.env.example
|
|
@ -17,6 +17,16 @@ GRAFANA_PASSWORD=change-this-password-in-production
|
||||||
CELERY_BROKER_URL=redis://redis:6379/0
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
CELERY_RESULT_BACKEND=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
|
# Optional: External Services
|
||||||
# REDIS_URL=redis://localhost:6379/0
|
# REDIS_URL=redis://localhost:6379/0
|
||||||
# SMTP_HOST=smtp.gmail.com
|
# SMTP_HOST=smtp.gmail.com
|
||||||
|
|
|
||||||
29
.github/workflows/backend.yml
vendored
29
.github/workflows/backend.yml
vendored
|
|
@ -15,6 +15,8 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
backend-test:
|
backend-test:
|
||||||
runs-on: [docker]
|
runs-on: [docker]
|
||||||
|
env:
|
||||||
|
UNIQUE_DB: test_db_${{ github.run_id }}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
|
|
@ -22,7 +24,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: test
|
POSTGRES_USER: test
|
||||||
POSTGRES_PASSWORD: test
|
POSTGRES_PASSWORD: test
|
||||||
POSTGRES_DB: test_db
|
POSTGRES_DB: test_db_${{ github.run_id }}_${{ github.run_attempt }}
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
|
|
@ -46,6 +48,27 @@ jobs:
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install --cache-dir /tmp/pip-cache -r requirements/dev.txt
|
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
|
- name: Debug cache
|
||||||
run: |
|
run: |
|
||||||
echo "Listing PIP cache files:"
|
echo "Listing PIP cache files:"
|
||||||
|
|
@ -59,8 +82,8 @@ jobs:
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db
|
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
|
||||||
DATABASE_URL: postgresql://test:test@postgres:5432/test_db
|
DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
|
||||||
SECRET_KEY: test-secret-key
|
SECRET_KEY: test-secret-key
|
||||||
JWT_SECRET_KEY: test-jwt-secret
|
JWT_SECRET_KEY: test-jwt-secret
|
||||||
FLASK_ENV: test
|
FLASK_ENV: test
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -84,3 +84,5 @@ htmlcov/
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
|
|
||||||
backend/app/static
|
backend/app/static
|
||||||
|
|
||||||
|
.clinerules
|
||||||
9
Makefile
9
Makefile
|
|
@ -21,6 +21,10 @@ install: ## Install dependencies
|
||||||
@echo "Installing frontend dependencies..."
|
@echo "Installing frontend dependencies..."
|
||||||
cd frontend && npm install
|
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)
|
dev-services: ## Start development services (postgres & redis only)
|
||||||
@echo "Starting development services (postgres & redis)..."
|
@echo "Starting development services (postgres & redis)..."
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
@ -62,6 +66,9 @@ restart: ## Restart all services
|
||||||
logs: ## Show logs from all services
|
logs: ## Show logs from all services
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
|
|
||||||
|
dev-logs: ## Show logs from all services
|
||||||
|
docker compose -f docker-compose.dev.yml logs
|
||||||
|
|
||||||
test: ## Run all tests
|
test: ## Run all tests
|
||||||
@echo "Running backend tests..."
|
@echo "Running backend tests..."
|
||||||
cd backend && . venv/bin/activate && pytest
|
cd backend && . venv/bin/activate && pytest
|
||||||
|
|
@ -129,7 +136,7 @@ format-frontend: ## Format frontend code only
|
||||||
@echo "Formatting frontend..."
|
@echo "Formatting frontend..."
|
||||||
cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}"
|
cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}"
|
||||||
|
|
||||||
migrate: ## Run database migrations
|
backend-upgrade: ## Run database migrations
|
||||||
cd backend && . venv/bin/activate && flask db upgrade
|
cd backend && . venv/bin/activate && flask db upgrade
|
||||||
|
|
||||||
shell: ## Open Flask shell
|
shell: ## Open Flask shell
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,8 @@ TEST_DATABASE_URL=sqlite:///test.db
|
||||||
# Celery Configuration
|
# Celery Configuration
|
||||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||||
CELERY_RESULT_BACKEND=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
|
||||||
|
|
@ -40,6 +40,13 @@ def create_app(config_name=None):
|
||||||
app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}}
|
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
|
# Initialize Celery
|
||||||
from app.celery import init_celery
|
from app.celery import init_celery
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ class Config:
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
|
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
|
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
|
||||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=12)
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=1)
|
||||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
|
||||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
|
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
|
||||||
|
|
||||||
|
|
@ -39,6 +39,18 @@ class Config:
|
||||||
"pool_pre_ping": True, # Verify connections before using
|
"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):
|
class DevelopmentConfig(Config):
|
||||||
"""Development configuration"""
|
"""Development configuration"""
|
||||||
|
|
@ -46,23 +58,68 @@ class DevelopmentConfig(Config):
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
|
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):
|
class TestingConfig(Config):
|
||||||
"""Testing configuration"""
|
"""Testing configuration"""
|
||||||
|
|
||||||
TESTING = True
|
TESTING = True
|
||||||
|
USE_MOCK_STORAGE = True # Use in-memory mock storage for tests
|
||||||
SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL")
|
SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL")
|
||||||
WTF_CSRF_ENABLED = False
|
WTF_CSRF_ENABLED = False
|
||||||
|
|
||||||
# Conservative connection pool settings for testing
|
# Conservative connection pool settings for testing
|
||||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||||
"pool_size": 1, # Only one connection in the pool
|
"pool_size": 2, # Only one connection in the pool
|
||||||
"max_overflow": 0, # No overflow connections allowed
|
"max_overflow": 2, # No overflow connections allowed
|
||||||
"pool_timeout": 30,
|
"pool_timeout": 30,
|
||||||
"pool_recycle": 3600, # Recycle after 1 hour
|
"pool_recycle": 3600, # Recycle after 1 hour
|
||||||
"pool_pre_ping": True, # Verify connections before using
|
"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):
|
class ProductionConfig(Config):
|
||||||
"""Production configuration"""
|
"""Production configuration"""
|
||||||
|
|
@ -75,6 +132,31 @@ class ProductionConfig(Config):
|
||||||
SESSION_COOKIE_HTTPONLY = True
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
SESSION_COOKIE_SAMESITE = "Lax"
|
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 = {
|
config_by_name = {
|
||||||
"dev": DevelopmentConfig,
|
"dev": DevelopmentConfig,
|
||||||
|
|
|
||||||
21
backend/app/decorators/__init__.py
Normal file
21
backend/app/decorators/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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",
|
||||||
|
]
|
||||||
158
backend/app/decorators/decorators.py
Normal file
158
backend/app/decorators/decorators.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
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
|
||||||
|
|
@ -1,17 +1,10 @@
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import abort, g
|
from flask import abort
|
||||||
from flask_jwt_extended import get_jwt_identity
|
|
||||||
|
|
||||||
from app.models import Board, Card, CheckItem, Checklist, Comment, List
|
from app.decorators.decorators import get_current_user_id
|
||||||
|
from app.models import (Board, Card, CheckItem, Checklist, Comment,
|
||||||
|
FileAttachment, 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):
|
def load_board_owned(f):
|
||||||
|
|
@ -27,7 +20,7 @@ def load_board_owned(f):
|
||||||
board_id = kwargs.get("board_id")
|
board_id = kwargs.get("board_id")
|
||||||
|
|
||||||
# SECURE QUERY: Filter by ID *and* User ID in the DB
|
# SECURE QUERY: Filter by ID *and* User ID in the DB
|
||||||
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
|
board = Board.active().filter_by(id=board_id, user_id=user_id).first()
|
||||||
|
|
||||||
if not board:
|
if not board:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
@ -42,6 +35,7 @@ def load_card_owned(f):
|
||||||
"""
|
"""
|
||||||
Loads a Card and ensures its Parent Board belongs to the current user.
|
Loads a Card and ensures its Parent Board belongs to the current user.
|
||||||
Injects 'card' into the route kwargs.
|
Injects 'card' into the route kwargs.
|
||||||
|
Aborts with 404 if not found, not owned, or soft-deleted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
|
|
@ -49,9 +43,10 @@ def load_card_owned(f):
|
||||||
user_id = get_current_user_id()
|
user_id = get_current_user_id()
|
||||||
card_id = kwargs.get("card_id")
|
card_id = kwargs.get("card_id")
|
||||||
|
|
||||||
# Join Board to check ownership securely in one query
|
# Join Board to check ownership and filter soft-deleted cards
|
||||||
card = (
|
card = (
|
||||||
Card.query.join(Board)
|
Card.active()
|
||||||
|
.join(Board)
|
||||||
.filter(Card.id == card_id, Board.user_id == user_id)
|
.filter(Card.id == card_id, Board.user_id == user_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
@ -74,7 +69,8 @@ def load_list_owned(f):
|
||||||
list_id = kwargs.get("list_id")
|
list_id = kwargs.get("list_id")
|
||||||
|
|
||||||
lst = (
|
lst = (
|
||||||
List.query.join(Board)
|
List.active()
|
||||||
|
.join(Board)
|
||||||
.filter(List.id == list_id, Board.user_id == user_id)
|
.filter(List.id == list_id, Board.user_id == user_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
@ -97,7 +93,8 @@ def load_checklist_owned(f):
|
||||||
checklist_id = kwargs.get("checklist_id")
|
checklist_id = kwargs.get("checklist_id")
|
||||||
|
|
||||||
checklist = (
|
checklist = (
|
||||||
Checklist.query.join(Card)
|
Checklist.active()
|
||||||
|
.join(Card)
|
||||||
.join(Board)
|
.join(Board)
|
||||||
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
|
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
|
||||||
.first()
|
.first()
|
||||||
|
|
@ -121,7 +118,8 @@ def load_check_item_owned(f):
|
||||||
item_id = kwargs.get("item_id")
|
item_id = kwargs.get("item_id")
|
||||||
|
|
||||||
check_item = (
|
check_item = (
|
||||||
CheckItem.query.join(Checklist)
|
CheckItem.active()
|
||||||
|
.join(Checklist)
|
||||||
.join(Card)
|
.join(Card)
|
||||||
.join(Board)
|
.join(Board)
|
||||||
.filter(CheckItem.id == item_id, Board.user_id == user_id)
|
.filter(CheckItem.id == item_id, Board.user_id == user_id)
|
||||||
|
|
@ -148,7 +146,7 @@ def load_comment_owned(f):
|
||||||
user_id = get_current_user_id()
|
user_id = get_current_user_id()
|
||||||
comment_id = kwargs.get("comment_id")
|
comment_id = kwargs.get("comment_id")
|
||||||
|
|
||||||
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
|
comment = Comment.active().filter_by(id=comment_id, user_id=user_id).first()
|
||||||
|
|
||||||
if not comment:
|
if not comment:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
@ -157,3 +155,28 @@ def load_comment_owned(f):
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
return decorated_function
|
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
|
||||||
38
backend/app/decorators/schema_validations.py
Normal file
38
backend/app/decorators/schema_validations.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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
|
||||||
|
|
@ -1,26 +1,34 @@
|
||||||
|
# fmt: off
|
||||||
|
from app.models.base import SoftDeleteMixin
|
||||||
from app.models.board import Board
|
from app.models.board import Board
|
||||||
from app.models.card import Card
|
from app.models.card import Card
|
||||||
from app.models.card_label import CardLabel
|
from app.models.card_label import CardLabel
|
||||||
|
from app.models.card_link import CardLink
|
||||||
from app.models.check_item import CheckItem
|
from app.models.check_item import CheckItem
|
||||||
from app.models.checklist import Checklist
|
from app.models.checklist import Checklist
|
||||||
from app.models.comment import Comment
|
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.label import Label
|
||||||
from app.models.list_model import List
|
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.user import User
|
||||||
|
from app.models.wiki import Wiki, wiki_entity_links
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"SoftDeleteMixin",
|
||||||
"User",
|
"User",
|
||||||
"Product",
|
|
||||||
"Order",
|
|
||||||
"OrderItem",
|
|
||||||
"Board",
|
"Board",
|
||||||
"List",
|
"List",
|
||||||
"Card",
|
"Card",
|
||||||
"Label",
|
"Label",
|
||||||
"CardLabel",
|
"CardLabel",
|
||||||
|
"CardLink",
|
||||||
"Checklist",
|
"Checklist",
|
||||||
"CheckItem",
|
"CheckItem",
|
||||||
"Comment",
|
"Comment",
|
||||||
|
"FileAttachment",
|
||||||
|
"Epic",
|
||||||
|
"Wiki",
|
||||||
|
"wiki_entity_links",
|
||||||
]
|
]
|
||||||
|
# fmt: on
|
||||||
|
|
|
||||||
137
backend/app/models/base.py
Normal file
137
backend/app/models/base.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
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
|
||||||
|
|
@ -3,9 +3,10 @@ from datetime import UTC, datetime
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.models.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class Board(db.Model):
|
class Board(db.Model, SoftDeleteMixin):
|
||||||
"""Board model for Kanban boards"""
|
"""Board model for Kanban boards"""
|
||||||
|
|
||||||
__tablename__ = "boards"
|
__tablename__ = "boards"
|
||||||
|
|
@ -41,15 +42,27 @@ class Board(db.Model):
|
||||||
label_names = db.Column(JSONB) # label color mappings
|
label_names = db.Column(JSONB) # label color mappings
|
||||||
limits = db.Column(JSONB) # various limits
|
limits = db.Column(JSONB) # various limits
|
||||||
|
|
||||||
# Relationships
|
# Relationships - only active records
|
||||||
lists = db.relationship(
|
lists = db.relationship(
|
||||||
"List", backref="board", cascade="all, delete-orphan", lazy="dynamic"
|
"List",
|
||||||
|
backref="board",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Board.id == List.board_id, List.status == 'active')",
|
||||||
)
|
)
|
||||||
cards = db.relationship(
|
cards = db.relationship(
|
||||||
"Card", backref="board", cascade="all, delete-orphan", lazy="dynamic"
|
"Card",
|
||||||
|
backref="board",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Board.id == Card.board_id, Card.status == 'active')",
|
||||||
)
|
)
|
||||||
labels = db.relationship(
|
labels = db.relationship(
|
||||||
"Label", backref="board", cascade="all, delete-orphan", lazy="dynamic"
|
"Label",
|
||||||
|
backref="board",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Board.id == Label.board_id, Label.status == 'active')",
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
|
|
@ -74,6 +87,8 @@ class Board(db.Model):
|
||||||
"prefs": self.prefs,
|
"prefs": self.prefs,
|
||||||
"label_names": self.label_names,
|
"label_names": self.label_names,
|
||||||
"limits": self.limits,
|
"limits": self.limits,
|
||||||
|
"status": self.status,
|
||||||
|
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ from datetime import UTC, datetime
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.models.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class Card(db.Model):
|
class Card(db.Model, SoftDeleteMixin):
|
||||||
"""Card model for Kanban cards"""
|
"""Card model for Kanban cards"""
|
||||||
|
|
||||||
__tablename__ = "cards"
|
__tablename__ = "cards"
|
||||||
|
|
@ -32,6 +33,9 @@ class Card(db.Model):
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
epic_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
date_last_activity = db.Column(db.DateTime)
|
date_last_activity = db.Column(db.DateTime)
|
||||||
|
|
@ -47,20 +51,61 @@ class Card(db.Model):
|
||||||
cover = db.Column(JSONB) # cover settings
|
cover = db.Column(JSONB) # cover settings
|
||||||
desc_data = db.Column(JSONB)
|
desc_data = db.Column(JSONB)
|
||||||
|
|
||||||
# Relationships
|
# Relationships - only active records
|
||||||
checklists = db.relationship(
|
checklists = db.relationship(
|
||||||
"Checklist", backref="card", cascade="all, delete-orphan", lazy="dynamic"
|
"Checklist",
|
||||||
|
backref="card",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Card.id == Checklist.card_id, Checklist.status == 'active')",
|
||||||
)
|
)
|
||||||
labels = db.relationship(
|
labels = db.relationship(
|
||||||
"CardLabel", backref="card", cascade="all, delete-orphan", lazy="dynamic"
|
"CardLabel",
|
||||||
|
backref="card",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Card.id == CardLabel.card_id, CardLabel.status == 'active')",
|
||||||
)
|
)
|
||||||
comments = db.relationship(
|
comments = db.relationship(
|
||||||
"Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic"
|
"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",
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
# 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):
|
||||||
"""Convert card to dictionary"""
|
"""Convert card to dictionary"""
|
||||||
return {
|
result = {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
|
|
@ -71,6 +116,8 @@ class Card(db.Model):
|
||||||
"id_short": self.id_short,
|
"id_short": self.id_short,
|
||||||
"board_id": self.board_id,
|
"board_id": self.board_id,
|
||||||
"list_id": self.list_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()
|
"date_last_activity": self.date_last_activity.isoformat()
|
||||||
if self.date_last_activity
|
if self.date_last_activity
|
||||||
else None,
|
else None,
|
||||||
|
|
@ -79,7 +126,141 @@ class Card(db.Model):
|
||||||
"badges": self.badges,
|
"badges": self.badges,
|
||||||
"cover": self.cover,
|
"cover": self.cover,
|
||||||
"desc_data": self.desc_data,
|
"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):
|
def __repr__(self):
|
||||||
return f"<Card {self.name}>"
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.models.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class CardLabel(db.Model):
|
class CardLabel(db.Model, SoftDeleteMixin):
|
||||||
"""Many-to-many relationship between cards and labels"""
|
"""Many-to-many relationship between cards and labels"""
|
||||||
|
|
||||||
__tablename__ = "card_labels"
|
__tablename__ = "card_labels"
|
||||||
|
|
@ -37,6 +38,8 @@ class CardLabel(db.Model):
|
||||||
"card_id": self.card_id,
|
"card_id": self.card_id,
|
||||||
"label_id": self.label_id,
|
"label_id": self.label_id,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"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):
|
def __repr__(self):
|
||||||
|
|
|
||||||
69
backend/app/models/card_link.py
Normal file
69
backend/app/models/card_link.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.models.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class CheckItem(db.Model):
|
class CheckItem(db.Model, SoftDeleteMixin):
|
||||||
"""CheckItem model for checklist items"""
|
"""CheckItem model for checklist items"""
|
||||||
|
|
||||||
__tablename__ = "check_items"
|
__tablename__ = "check_items"
|
||||||
|
|
@ -45,6 +46,8 @@ class CheckItem(db.Model):
|
||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at 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,
|
"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):
|
def __repr__(self):
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.models.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class Checklist(db.Model):
|
class Checklist(db.Model, SoftDeleteMixin):
|
||||||
"""Checklist model for Kanban checklists"""
|
"""Checklist model for card checklists"""
|
||||||
|
|
||||||
__tablename__ = "checklists"
|
__tablename__ = "checklists"
|
||||||
|
|
||||||
|
|
@ -34,9 +35,14 @@ class Checklist(db.Model):
|
||||||
onupdate=lambda: datetime.now(UTC),
|
onupdate=lambda: datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships - only active check items
|
||||||
check_items = db.relationship(
|
check_items = db.relationship(
|
||||||
"CheckItem", backref="checklist", cascade="all, delete-orphan", lazy="dynamic"
|
"CheckItem",
|
||||||
|
backref="checklist",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="""and_(Checklist.id == CheckItem.checklist_id,
|
||||||
|
CheckItem.status == 'active')""",
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
|
|
@ -45,10 +51,12 @@ class Checklist(db.Model):
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"pos": self.pos,
|
"pos": self.pos,
|
||||||
"board_id": self.board_id,
|
|
||||||
"card_id": self.card_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,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_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):
|
def __repr__(self):
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.models.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class Comment(db.Model):
|
class Comment(db.Model, SoftDeleteMixin):
|
||||||
"""Comment model for card comments"""
|
"""Comment model for card comments"""
|
||||||
|
|
||||||
__tablename__ = "comments"
|
__tablename__ = "comments"
|
||||||
|
|
@ -30,6 +31,17 @@ class Comment(db.Model):
|
||||||
onupdate=lambda: datetime.now(UTC),
|
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):
|
def to_dict(self):
|
||||||
"""Convert comment to dictionary"""
|
"""Convert comment to dictionary"""
|
||||||
return {
|
return {
|
||||||
|
|
@ -39,6 +51,8 @@ class Comment(db.Model):
|
||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at 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,
|
"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):
|
def __repr__(self):
|
||||||
|
|
|
||||||
90
backend/app/models/epic.py
Normal file
90
backend/app/models/epic.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
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}>"
|
||||||
78
backend/app/models/file_attachment.py
Normal file
78
backend/app/models/file_attachment.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
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})>"
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.models.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class Label(db.Model):
|
class Label(db.Model, SoftDeleteMixin):
|
||||||
"""Label model for Kanban labels"""
|
"""Label model for card labels"""
|
||||||
|
|
||||||
__tablename__ = "labels"
|
__tablename__ = "labels"
|
||||||
|
|
||||||
|
|
@ -40,11 +41,12 @@ class Label(db.Model):
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
"uses": self.uses,
|
|
||||||
"board_id": self.board_id,
|
"board_id": self.board_id,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at 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,
|
"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):
|
def __repr__(self):
|
||||||
return f"<Label {self.name} ({self.color})>"
|
return f"<Label {self.name}>"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.models.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class List(db.Model):
|
class List(db.Model, SoftDeleteMixin):
|
||||||
"""List model for Kanban lists (columns)"""
|
"""List model for Kanban lists (columns)"""
|
||||||
|
|
||||||
__tablename__ = "lists"
|
__tablename__ = "lists"
|
||||||
|
|
@ -29,9 +30,13 @@ class List(db.Model):
|
||||||
onupdate=lambda: datetime.now(UTC),
|
onupdate=lambda: datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships - only active cards
|
||||||
cards = db.relationship(
|
cards = db.relationship(
|
||||||
"Card", backref="list", cascade="all, delete-orphan", lazy="dynamic"
|
"Card",
|
||||||
|
backref="list",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(List.id == Card.list_id, Card.status == 'active')",
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
|
|
@ -44,6 +49,8 @@ class List(db.Model):
|
||||||
"board_id": self.board_id,
|
"board_id": self.board_id,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at 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,
|
"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):
|
def __repr__(self):
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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}>"
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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}>"
|
|
||||||
|
|
@ -26,7 +26,6 @@ class User(db.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
|
|
||||||
boards = db.relationship(
|
boards = db.relationship(
|
||||||
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
|
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
114
backend/app/models/wiki.py
Normal file
114
backend/app/models/wiki.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
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}>"
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from flask_jwt_extended import (create_access_token, create_refresh_token,
|
from flask_jwt_extended import (create_access_token, create_refresh_token,
|
||||||
get_jwt_identity, jwt_required)
|
get_jwt_identity, jwt_required)
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.celery import celery
|
from app.models import User
|
||||||
from app.models import Order, OrderItem, Product, User
|
|
||||||
from app.schemas import ProductCreateRequest, ProductResponse
|
|
||||||
|
|
||||||
api_bp = Blueprint("api", __name__)
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
|
@ -79,333 +76,3 @@ def get_current_user():
|
||||||
return jsonify({"error": "User not found"}), 404
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
return jsonify(user.to_dict()), 200
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
|
# fmt: off
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
# Create the kanban blueprint that will be used by all route modules
|
# Create the kanban blueprint that will be used by all route modules
|
||||||
kanban_bp = Blueprint("kanban", __name__)
|
kanban_bp = Blueprint("kanban", __name__)
|
||||||
|
|
||||||
# Import all route modules to register their routes to this blueprint
|
# Import all route modules to register their routes to this blueprint
|
||||||
|
from . import (boards, card_links, cards, checklists, # noqa: F401 E402
|
||||||
# fmt: off
|
comments, epics, files, labels, lists, wikis)
|
||||||
from . import (boards, cards, checklists, comments, labels, # noqa: F401 E402
|
|
||||||
lists)
|
|
||||||
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
__all__ = ["kanban_bp"]
|
__all__ = ["kanban_bp"]
|
||||||
|
# fmt: on
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ from flask_jwt_extended import jwt_required
|
||||||
from flask_pydantic import validate
|
from flask_pydantic import validate
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.decorators import get_current_user_id, load_board_owned
|
from app.decorators import load_board_owned
|
||||||
from app.models import Board, Card, CardLabel, Label, List
|
from app.decorators.decorators import get_current_user_id
|
||||||
|
from app.models import Board, Card, List
|
||||||
from app.schemas import (BoardCreateRequest, BoardResponse,
|
from app.schemas import (BoardCreateRequest, BoardResponse,
|
||||||
BoardWithDetailsResponse)
|
BoardWithDetailsResponse)
|
||||||
|
|
||||||
|
|
@ -18,7 +19,7 @@ from . import kanban_bp
|
||||||
def get_boards():
|
def get_boards():
|
||||||
"""Get all boards for current user"""
|
"""Get all boards for current user"""
|
||||||
user_id = get_current_user_id()
|
user_id = get_current_user_id()
|
||||||
boards = Board.query.filter_by(user_id=user_id).all()
|
boards = Board.active().filter_by(user_id=user_id).all()
|
||||||
return [BoardResponse.model_validate(board).model_dump() for board in boards], 200
|
return [BoardResponse.model_validate(board).model_dump() for board in boards], 200
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -38,13 +39,9 @@ def get_board(board_id, board):
|
||||||
|
|
||||||
# Add labels for this card
|
# Add labels for this card
|
||||||
card_dict["labels"] = [
|
card_dict["labels"] = [
|
||||||
label.to_dict()
|
card_label.label.to_dict()
|
||||||
for label in (
|
for card_label in card.labels
|
||||||
db.session.query(Label)
|
if card_label.label and card_label.status == "active"
|
||||||
.join(CardLabel)
|
|
||||||
.filter(CardLabel.card_id == card.id)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add comments for this card
|
# Add comments for this card
|
||||||
|
|
@ -55,15 +52,25 @@ def get_board(board_id, board):
|
||||||
comment_dict["user"] = user.to_dict() if user else None
|
comment_dict["user"] = user.to_dict() if user else None
|
||||||
card_dict["comments"].append(comment_dict)
|
card_dict["comments"].append(comment_dict)
|
||||||
|
|
||||||
# Add checklists with items for this card
|
# Add checklists with items
|
||||||
|
from app.models import CheckItem, Checklist
|
||||||
|
|
||||||
card_dict["checklists"] = [
|
card_dict["checklists"] = [
|
||||||
{
|
{
|
||||||
**checklist.to_dict(),
|
**checklist.to_dict(),
|
||||||
"items": [item.to_dict() for item in checklist.check_items.all()],
|
"items": [
|
||||||
|
item.to_dict()
|
||||||
|
for item in CheckItem.active()
|
||||||
|
.filter_by(checklist_id=checklist.id)
|
||||||
|
.all()
|
||||||
|
],
|
||||||
}
|
}
|
||||||
for checklist in card.checklists.all()
|
for checklist in Checklist.active().filter_by(card_id=card.id).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Add epic for this card
|
||||||
|
card_dict["epic"] = card.epic.to_dict() if card.epic else None
|
||||||
|
|
||||||
cards_data.append(card_dict)
|
cards_data.append(card_dict)
|
||||||
|
|
||||||
lists_data.append({**lst.to_dict(), "cards": cards_data})
|
lists_data.append({**lst.to_dict(), "cards": cards_data})
|
||||||
|
|
@ -135,7 +142,7 @@ def update_board(board_id, board, body: BoardCreateRequest):
|
||||||
@load_board_owned
|
@load_board_owned
|
||||||
def delete_board(board_id, board):
|
def delete_board(board_id, board):
|
||||||
"""Delete a board"""
|
"""Delete a board"""
|
||||||
db.session.delete(board)
|
board.soft_delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {"message": "Board deleted"}, 200
|
return {"message": "Board deleted"}, 200
|
||||||
|
|
|
||||||
278
backend/app/routes/kanban/card_links.py
Normal file
278
backend/app/routes/kanban/card_links.py
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
"""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,
|
||||||
|
)
|
||||||
|
|
@ -42,7 +42,7 @@ def create_card(list_id, lst, body: CardCreateRequest):
|
||||||
@kanban_bp.route("/cards/<int:card_id>", methods=["GET"])
|
@kanban_bp.route("/cards/<int:card_id>", methods=["GET"])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@load_card_owned
|
@load_card_owned
|
||||||
def get_card(card_id, card):
|
def get_card(card_id, card: Card):
|
||||||
"""Get a single card with full details"""
|
"""Get a single card with full details"""
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
|
|
@ -77,6 +77,12 @@ def get_card(card_id, card):
|
||||||
comment_dict["user"] = user.to_dict() if user else None
|
comment_dict["user"] = user.to_dict() if user else None
|
||||||
card_dict["comments"].append(comment_dict)
|
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)
|
response = CardWithDetailsResponse(**card_dict)
|
||||||
return response.model_dump(), 200
|
return response.model_dump(), 200
|
||||||
|
|
||||||
|
|
@ -140,9 +146,9 @@ def update_card(card_id, card, body: CardCreateRequest):
|
||||||
@kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"])
|
@kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@load_card_owned
|
@load_card_owned
|
||||||
def delete_card(card_id, card):
|
def delete_card(card_id, card: Card):
|
||||||
"""Delete a card"""
|
"""Delete a card"""
|
||||||
db.session.delete(card)
|
card.soft_delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {"message": "Card deleted"}, 200
|
return {"message": "Card deleted"}, 200
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ def update_check_item(item_id, check_item, body: CheckItemCreateRequest):
|
||||||
@load_checklist_owned
|
@load_checklist_owned
|
||||||
def delete_checklist(checklist_id, checklist):
|
def delete_checklist(checklist_id, checklist):
|
||||||
"""Delete a checklist"""
|
"""Delete a checklist"""
|
||||||
db.session.delete(checklist)
|
checklist.soft_delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {"message": "Checklist deleted"}, 200
|
return {"message": "Checklist deleted"}, 200
|
||||||
|
|
@ -81,7 +81,7 @@ def delete_checklist(checklist_id, checklist):
|
||||||
@load_check_item_owned
|
@load_check_item_owned
|
||||||
def delete_check_item(item_id, check_item):
|
def delete_check_item(item_id, check_item):
|
||||||
"""Delete a check item"""
|
"""Delete a check item"""
|
||||||
db.session.delete(check_item)
|
check_item.soft_delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {"message": "Check item deleted"}, 200
|
return {"message": "Check item deleted"}, 200
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ def update_comment(comment_id, comment, body: CommentCreateRequest):
|
||||||
@load_comment_owned
|
@load_comment_owned
|
||||||
def delete_comment(comment_id, comment):
|
def delete_comment(comment_id, comment):
|
||||||
"""Delete a comment"""
|
"""Delete a comment"""
|
||||||
db.session.delete(comment)
|
comment.soft_delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {"message": "Comment deleted"}, 200
|
return {"message": "Comment deleted"}, 200
|
||||||
|
|
|
||||||
170
backend/app/routes/kanban/epics.py
Normal file
170
backend/app/routes/kanban/epics.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""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
|
||||||
299
backend/app/routes/kanban/files.py
Normal file
299
backend/app/routes/kanban/files.py
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
"""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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -73,7 +73,7 @@ def remove_label_from_card(card_id, card, label_id):
|
||||||
if not card_label:
|
if not card_label:
|
||||||
return {"error": "Label not found on card"}, 404
|
return {"error": "Label not found on card"}, 404
|
||||||
|
|
||||||
db.session.delete(card_label)
|
card_label.soft_delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {"message": "Label removed from card"}, 200
|
return {"message": "Label removed from card"}, 200
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ def update_list(list_id, lst, body: ListCreateRequest):
|
||||||
@load_list_owned
|
@load_list_owned
|
||||||
def delete_list(list_id, lst):
|
def delete_list(list_id, lst):
|
||||||
"""Delete a list"""
|
"""Delete a list"""
|
||||||
db.session.delete(lst)
|
lst.soft_delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {"message": "List deleted"}, 200
|
return {"message": "List deleted"}, 200
|
||||||
|
|
|
||||||
232
backend/app/routes/kanban/wikis.py
Normal file
232
backend/app/routes/kanban/wikis.py
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
"""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
|
||||||
|
|
@ -7,14 +7,19 @@ from app.schemas.check_item import CheckItemCreateRequest, CheckItemResponse
|
||||||
from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse
|
from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse
|
||||||
from app.schemas.comment import (CommentCreateRequest, CommentResponse,
|
from app.schemas.comment import (CommentCreateRequest, CommentResponse,
|
||||||
CommentWithUserResponse)
|
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.label import LabelCreateRequest, LabelResponse
|
||||||
from app.schemas.list_model import ListCreateRequest, ListResponse
|
from app.schemas.list_model import ListCreateRequest, ListResponse
|
||||||
from app.schemas.product import ProductCreateRequest, ProductResponse
|
from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink,
|
||||||
|
WikiLinkRequest, WikiResponse, WikiUpdateRequest)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Product schemas
|
# Product schemas
|
||||||
"ProductCreateRequest",
|
|
||||||
"ProductResponse",
|
|
||||||
# Board schemas
|
# Board schemas
|
||||||
"BoardCreateRequest",
|
"BoardCreateRequest",
|
||||||
"BoardResponse",
|
"BoardResponse",
|
||||||
|
|
@ -36,7 +41,25 @@ __all__ = [
|
||||||
# Comment schemas
|
# Comment schemas
|
||||||
"CommentCreateRequest",
|
"CommentCreateRequest",
|
||||||
"CommentResponse",
|
"CommentResponse",
|
||||||
|
# File Attachment schemas
|
||||||
|
"FileAttachmentWithUrlResponse",
|
||||||
|
"FileDeleteResponse",
|
||||||
|
"FileUploadRequest",
|
||||||
|
"MultipleFilesResponse",
|
||||||
|
"ValidationError",
|
||||||
|
# Composite schemas
|
||||||
"BoardWithDetailsResponse",
|
"BoardWithDetailsResponse",
|
||||||
"CardWithDetailsResponse",
|
"CardWithDetailsResponse",
|
||||||
"CommentWithUserResponse",
|
"CommentWithUserResponse",
|
||||||
|
# Epic schemas
|
||||||
|
"EpicCreateRequest",
|
||||||
|
"EpicResponse",
|
||||||
|
"EpicUpdateRequest",
|
||||||
|
"EpicWithCardsResponse",
|
||||||
|
# Wiki schemas
|
||||||
|
"WikiCreateRequest",
|
||||||
|
"WikiResponse",
|
||||||
|
"WikiUpdateRequest",
|
||||||
|
"WikiEntityLink",
|
||||||
|
"WikiLinkRequest",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ class CardResponse(BaseModel):
|
||||||
"pos": 65535.0,
|
"pos": 65535.0,
|
||||||
"list_id": 1,
|
"list_id": 1,
|
||||||
"board_id": 1,
|
"board_id": 1,
|
||||||
|
"epic_id": 1,
|
||||||
"due": "2024-12-31T23:59:59",
|
"due": "2024-12-31T23:59:59",
|
||||||
"due_complete": False,
|
"due_complete": False,
|
||||||
"badges": {"votes": 0},
|
"badges": {"votes": 0},
|
||||||
|
|
@ -64,6 +65,7 @@ class CardResponse(BaseModel):
|
||||||
pos: float
|
pos: float
|
||||||
list_id: int
|
list_id: int
|
||||||
board_id: int
|
board_id: int
|
||||||
|
epic_id: Optional[int]
|
||||||
due: Optional[datetime] = None
|
due: Optional[datetime] = None
|
||||||
due_complete: bool
|
due_complete: bool
|
||||||
badges: Optional[Dict[str, Any]] = None
|
badges: Optional[Dict[str, Any]] = None
|
||||||
|
|
@ -85,8 +87,11 @@ class CardWithDetailsResponse(BaseModel):
|
||||||
"closed": False,
|
"closed": False,
|
||||||
"pos": 65535.0,
|
"pos": 65535.0,
|
||||||
"list_id": 1,
|
"list_id": 1,
|
||||||
|
"list_name": "list 1",
|
||||||
"board_id": 1,
|
"board_id": 1,
|
||||||
"due": "2024-12-31T23:59:59",
|
"due": "2024-12-31T23:59:59",
|
||||||
|
"created_at": "2024-12-31T23:59:59",
|
||||||
|
"updated_at": "2024-12-31T23:59:59",
|
||||||
"due_complete": False,
|
"due_complete": False,
|
||||||
"badges": {"votes": 0},
|
"badges": {"votes": 0},
|
||||||
"cover": "https://example.com/cover.jpg",
|
"cover": "https://example.com/cover.jpg",
|
||||||
|
|
@ -115,3 +120,7 @@ class CardWithDetailsResponse(BaseModel):
|
||||||
labels: List[Dict[str, Any]] = Field(default_factory=list)
|
labels: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
checklists: 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)
|
comments: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
|
epic: Optional[Dict[str, Any]] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
list_name: str
|
||||||
|
|
|
||||||
139
backend/app/schemas/epic.py
Normal file
139
backend/app/schemas/epic.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""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)
|
||||||
72
backend/app/schemas/file_attachment.py
Normal file
72
backend/app/schemas/file_attachment.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""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
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
"""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
|
|
||||||
174
backend/app/schemas/wiki.py
Normal file
174
backend/app/schemas/wiki.py
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
"""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
|
||||||
20
backend/app/services/storage/__init__.py
Normal file
20
backend/app/services/storage/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""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",
|
||||||
|
]
|
||||||
231
backend/app/services/storage/attachment_service.py
Normal file
231
backend/app/services/storage/attachment_service.py
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"""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
|
||||||
113
backend/app/services/storage/base.py
Normal file
113
backend/app/services/storage/base.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""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
|
||||||
264
backend/app/services/storage/file_processor.py
Normal file
264
backend/app/services/storage/file_processor.py
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
"""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"
|
||||||
314
backend/app/services/storage/file_service.py
Normal file
314
backend/app/services/storage/file_service.py
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
"""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,
|
||||||
|
}
|
||||||
222
backend/app/services/storage/minio_client.py
Normal file
222
backend/app/services/storage/minio_client.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""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
|
||||||
217
backend/app/services/storage/mock_adapter.py
Normal file
217
backend/app/services/storage/mock_adapter.py
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
"""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())
|
||||||
123
backend/app/services/storage/storage_extension.py
Normal file
123
backend/app/services/storage/storage_extension.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""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()
|
||||||
56
backend/app/services/storage/storage_factory.py
Normal file
56
backend/app/services/storage/storage_factory.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""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()
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""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 ###
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""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 ###
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""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 ###
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""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 ###
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""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 ###
|
||||||
54
backend/migrations/versions/c5b574480b80_add_file_upload.py
Normal file
54
backend/migrations/versions/c5b574480b80_add_file_upload.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""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 ###
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""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 ###
|
||||||
|
|
@ -10,6 +10,8 @@ Werkzeug==3.0.1
|
||||||
SQLAlchemy==2.0.23
|
SQLAlchemy==2.0.23
|
||||||
celery[redis]==5.3.6
|
celery[redis]==5.3.6
|
||||||
pydantic==2.5.3
|
pydantic==2.5.3
|
||||||
|
minio==7.2.0
|
||||||
|
Pillow==10.1.0
|
||||||
pytest==7.4.3
|
pytest==7.4.3
|
||||||
pytest-flask==1.3.0
|
pytest-flask==1.3.0
|
||||||
pytest-cov==4.1.0
|
pytest-cov==4.1.0
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
"""Pytest configuration and fixtures"""
|
"""Pytest configuration and fixtures"""
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from app import create_app, db
|
from app import create_app, db
|
||||||
from app.models import Order, OrderItem, Product, User
|
from app.models import Board, Card, Comment, List, User
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
fake = Faker()
|
fake = Faker()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="session")
|
||||||
def app():
|
def app():
|
||||||
"""Create application for testing with PostgreSQL database (session scope)"""
|
"""Create application for testing with PostgreSQL database (session scope)"""
|
||||||
app = create_app(config_name="test")
|
app = create_app(config_name="test")
|
||||||
|
|
@ -113,7 +116,7 @@ def regular_user(db_session):
|
||||||
"""Create a regular user for testing"""
|
"""Create a regular user for testing"""
|
||||||
user = User(
|
user = User(
|
||||||
email=fake.email(),
|
email=fake.email(),
|
||||||
username=fake.user_name(),
|
username=f"{fake.user_name()}_{int(time.time() * 1000)}",
|
||||||
first_name=fake.first_name(),
|
first_name=fake.first_name(),
|
||||||
last_name=fake.last_name(),
|
last_name=fake.last_name(),
|
||||||
is_admin=False,
|
is_admin=False,
|
||||||
|
|
@ -142,39 +145,6 @@ def inactive_user(db_session):
|
||||||
return user
|
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
|
@pytest.fixture
|
||||||
def auth_headers(client, regular_user):
|
def auth_headers(client, regular_user):
|
||||||
"""Get authentication headers for a regular user"""
|
"""Get authentication headers for a regular user"""
|
||||||
|
|
@ -200,28 +170,89 @@ def admin_headers(client, admin_user):
|
||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============ File Upload Fixtures ============
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def order(db_session, regular_user, products):
|
def test_image_file():
|
||||||
print("-----order-created------")
|
"""Create a test image file as BytesIO"""
|
||||||
"""Create an order for testing"""
|
# Create a simple red PNG image using PIL
|
||||||
order = Order(
|
img = Image.new("RGB", (10, 10), color="red")
|
||||||
user_id=regular_user.id, total_amount=0.0, shipping_address=fake.address()
|
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(),
|
||||||
)
|
)
|
||||||
db_session.add(order)
|
db_session.add(board)
|
||||||
db_session.flush()
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
total_amount += float(product.price) * quantity
|
|
||||||
db_session.add(order_item)
|
|
||||||
|
|
||||||
order.total_amount = total_amount
|
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return order
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
db_session.add(lst)
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import Board, Card, List
|
from app.models import (Board, Card, CardLabel, CheckItem, Checklist, Comment,
|
||||||
|
Label, List)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
@ -147,7 +148,8 @@ class TestBoardRoutes:
|
||||||
|
|
||||||
# Verify board is deleted
|
# Verify board is deleted
|
||||||
deleted_board = db.session.get(Board, board.id)
|
deleted_board = db.session.get(Board, board.id)
|
||||||
assert deleted_board is None
|
assert deleted_board is not None
|
||||||
|
assert deleted_board.status == "deleted"
|
||||||
|
|
||||||
def test_delete_board_not_found(self, client, db_session, auth_headers):
|
def test_delete_board_not_found(self, client, db_session, auth_headers):
|
||||||
"""Test deleting a non-existent board"""
|
"""Test deleting a non-existent board"""
|
||||||
|
|
@ -164,3 +166,328 @@ class TestBoardRoutes:
|
||||||
response = client.delete(f"/api/boards/{board.id}")
|
response = client.delete(f"/api/boards/{board.id}")
|
||||||
|
|
||||||
assert response.status_code == 401
|
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"
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,8 @@ class TestCardRoutes:
|
||||||
|
|
||||||
# Verify card is deleted
|
# Verify card is deleted
|
||||||
deleted_card = db.session.get(Card, card.id)
|
deleted_card = db.session.get(Card, card.id)
|
||||||
assert deleted_card is None
|
assert deleted_card is not None
|
||||||
|
assert deleted_card.status == "deleted"
|
||||||
|
|
||||||
def test_delete_card_not_found(self, client, db_session, auth_headers):
|
def test_delete_card_not_found(self, client, db_session, auth_headers):
|
||||||
"""Test deleting a non-existent card"""
|
"""Test deleting a non-existent card"""
|
||||||
|
|
@ -168,6 +169,38 @@ class TestCardRoutes:
|
||||||
|
|
||||||
assert response.status_code == 404
|
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(
|
def test_update_card_position_within_same_list(
|
||||||
self, client, db_session, regular_user, auth_headers
|
self, client, db_session, regular_user, auth_headers
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,8 @@ class TestChecklistRoutes:
|
||||||
|
|
||||||
# Verify checklist is deleted
|
# Verify checklist is deleted
|
||||||
deleted_checklist = db.session.get(Checklist, checklist.id)
|
deleted_checklist = db.session.get(Checklist, checklist.id)
|
||||||
assert deleted_checklist is None
|
assert deleted_checklist is not None
|
||||||
|
assert deleted_checklist.status == "deleted"
|
||||||
|
|
||||||
def test_delete_checklist_not_found(self, client, db_session, auth_headers):
|
def test_delete_checklist_not_found(self, client, db_session, auth_headers):
|
||||||
"""Test deleting a non-existent checklist"""
|
"""Test deleting a non-existent checklist"""
|
||||||
|
|
@ -287,7 +288,8 @@ class TestChecklistRoutes:
|
||||||
|
|
||||||
# Verify check item is deleted
|
# Verify check item is deleted
|
||||||
deleted_item = db.session.get(CheckItem, item.id)
|
deleted_item = db.session.get(CheckItem, item.id)
|
||||||
assert deleted_item is None
|
assert deleted_item is not None
|
||||||
|
assert deleted_item.status == "deleted"
|
||||||
|
|
||||||
def test_delete_check_item_not_found(self, client, db_session, auth_headers):
|
def test_delete_check_item_not_found(self, client, db_session, auth_headers):
|
||||||
"""Test deleting a non-existent check item"""
|
"""Test deleting a non-existent check item"""
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,8 @@ class TestCommentRoutes:
|
||||||
|
|
||||||
# Verify comment is deleted
|
# Verify comment is deleted
|
||||||
deleted_comment = db.session.get(Comment, comment.id)
|
deleted_comment = db.session.get(Comment, comment.id)
|
||||||
assert deleted_comment is None
|
assert deleted_comment is not None
|
||||||
|
assert deleted_comment.status == "deleted"
|
||||||
|
|
||||||
def test_delete_comment_not_found(self, client, db_session, auth_headers):
|
def test_delete_comment_not_found(self, client, db_session, auth_headers):
|
||||||
"""Test deleting a non-existent comment"""
|
"""Test deleting a non-existent comment"""
|
||||||
|
|
|
||||||
703
backend/tests/routes/test_epics.py
Normal file
703
backend/tests/routes/test_epics.py
Normal file
|
|
@ -0,0 +1,703 @@
|
||||||
|
"""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
|
||||||
372
backend/tests/routes/test_files.py
Normal file
372
backend/tests/routes/test_files.py
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
"""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
|
||||||
|
|
@ -181,4 +181,5 @@ class TestLabelRoutes:
|
||||||
.filter_by(card_id=card.id, label_id=label.id)
|
.filter_by(card_id=card.id, label_id=label.id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
assert deleted_card_label is None
|
assert deleted_card_label is not None
|
||||||
|
assert deleted_card_label.status == "deleted"
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,8 @@ class TestListRoutes:
|
||||||
|
|
||||||
# Verify list is deleted
|
# Verify list is deleted
|
||||||
deleted_list = db.session.get(List, lst.id)
|
deleted_list = db.session.get(List, lst.id)
|
||||||
assert deleted_list is None
|
assert deleted_list is not None
|
||||||
|
assert deleted_list.status == "deleted"
|
||||||
|
|
||||||
def test_delete_list_not_found(self, client, db_session, auth_headers):
|
def test_delete_list_not_found(self, client, db_session, auth_headers):
|
||||||
"""Test deleting a non-existent list"""
|
"""Test deleting a non-existent list"""
|
||||||
|
|
@ -144,11 +145,13 @@ class TestListRoutes:
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify both list and card are deleted
|
# Verify both list and card are soft-deleted
|
||||||
deleted_list = db.session.get(List, lst.id)
|
deleted_list = db.session.get(List, lst.id)
|
||||||
deleted_card = db.session.get(Card, card.id)
|
deleted_card = db.session.get(Card, card.id)
|
||||||
assert deleted_list is None
|
assert deleted_list is not None
|
||||||
assert deleted_card is None
|
assert deleted_list.status == "deleted"
|
||||||
|
assert deleted_card is not None
|
||||||
|
assert deleted_card.status == "deleted"
|
||||||
|
|
||||||
def test_update_list_position_reorders_others(
|
def test_update_list_position_reorders_others(
|
||||||
self, client, db_session, regular_user, auth_headers
|
self, client, db_session, regular_user, auth_headers
|
||||||
|
|
|
||||||
496
backend/tests/routes/test_soft_delete_integration.py
Normal file
496
backend/tests/routes/test_soft_delete_integration.py
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
"""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
|
||||||
888
backend/tests/routes/test_wikis.py
Normal file
888
backend/tests/routes/test_wikis.py
Normal file
|
|
@ -0,0 +1,888 @@
|
||||||
|
"""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
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
"""Test models"""
|
"""Test models"""
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.models import Order, OrderItem, Product, User
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
class TestUserModel:
|
class TestUserModel:
|
||||||
|
|
@ -69,132 +68,3 @@ class TestUserModel:
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
assert repr(user) == "<User testuser3>"
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -112,234 +112,3 @@ class TestAuthRoutes:
|
||||||
"""Test getting current user without authentication"""
|
"""Test getting current user without authentication"""
|
||||||
response = client.get("/api/users/me")
|
response = client.get("/api/users/me")
|
||||||
assert response.status_code == 401
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -23,8 +23,30 @@ services:
|
||||||
- "6381:6379"
|
- "6381:6379"
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
postgres-dev-data:
|
postgres-dev-data:
|
||||||
|
minio-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
kanban-app-network:
|
kanban-app-network:
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,27 @@ services:
|
||||||
- crafting-shop-network
|
- crafting-shop-network
|
||||||
restart: unless-stopped
|
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:
|
celery_worker:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
|
|
@ -163,6 +184,7 @@ volumes:
|
||||||
grafana-data:
|
grafana-data:
|
||||||
backend-data:
|
backend-data:
|
||||||
celery-beat-data:
|
celery-beat-data:
|
||||||
|
minio-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
crafting-shop-network:
|
crafting-shop-network:
|
||||||
|
|
|
||||||
338
docs/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md
Normal file
338
docs/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
# 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
|
||||||
238
docs/EPIC_WIKI_MODELS_SUMMARY.md
Normal file
238
docs/EPIC_WIKI_MODELS_SUMMARY.md
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
# 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
|
||||||
|
|
@ -372,13 +372,13 @@ useEffect(() => {
|
||||||
```
|
```
|
||||||
|
|
||||||
### Icons
|
### Icons
|
||||||
- **ALWAYS** use inline SVG icons
|
- **ALWAYS** create icon components in `frontend/src/components/icons/`
|
||||||
- **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome
|
- **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome
|
||||||
- Create reusable SVG icon components when needed
|
- Each icon should be a separate file with a default export
|
||||||
- SVGs should be defined as functional components
|
- Import icons from the icons folder
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
// ✅ CORRECT - Inline SVG as a component
|
// ✅ CORRECT - Icon component in frontend/src/components/icons/TrashIcon.tsx
|
||||||
const TrashIcon = () => (
|
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">
|
<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>
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
|
@ -386,6 +386,11 @@ const TrashIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export default TrashIcon;
|
||||||
|
|
||||||
|
// ✅ CORRECT - Using icon component
|
||||||
|
import TrashIcon from "../components/icons/TrashIcon";
|
||||||
|
|
||||||
function DeleteButton() {
|
function DeleteButton() {
|
||||||
return (
|
return (
|
||||||
<button className="flex items-center gap-2">
|
<button className="flex items-center gap-2">
|
||||||
|
|
@ -418,14 +423,31 @@ function DeleteButton() {
|
||||||
</button>
|
</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 inline SVGs?**
|
**Why use icon components in the icons folder?**
|
||||||
1. **No dependencies**: Reduces bundle size and eliminates external dependencies
|
1. **No dependencies**: Reduces bundle size and eliminates external dependencies
|
||||||
2. **Full control**: You can customize SVG properties directly in JSX
|
2. **Full control**: You can customize SVG properties directly in the icon component
|
||||||
3. **Performance**: No runtime overhead from library wrappers
|
3. **Performance**: No runtime overhead from library wrappers
|
||||||
4. **Consistency**: All icons follow to same pattern and styling
|
4. **Consistency**: All icons follow the same pattern and are organized in one place
|
||||||
5. **TypeScript support**: Full type safety without any issues
|
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
|
||||||
|
|
||||||
## Routing Rules
|
## Routing Rules
|
||||||
|
|
||||||
|
|
@ -802,7 +824,7 @@ export function useNewContext() {
|
||||||
❌ **NEVER** hardcode API URLs
|
❌ **NEVER** hardcode API URLs
|
||||||
❌ **NEVER** skip PropTypes validation
|
❌ **NEVER** skip PropTypes validation
|
||||||
❌ **NEVER** use `<a>` for navigation (use `<Link>`)
|
❌ **NEVER** use `<a>` for navigation (use `<Link>`)
|
||||||
❌ **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome (always use inline SVGs)
|
❌ **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome (always use icon components from frontend/src/components/icons/)
|
||||||
|
|
||||||
## Checklist Before Committing
|
## Checklist Before Committing
|
||||||
|
|
||||||
|
|
|
||||||
125
frontend/package-lock.json
generated
125
frontend/package-lock.json
generated
|
|
@ -17,12 +17,16 @@
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-router-dom": "^6.20.1",
|
||||||
|
"slate": "^0.123.0",
|
||||||
|
"slate-history": "^0.113.1",
|
||||||
|
"slate-react": "^0.123.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@testing-library/react": "^14.1.2",
|
"@testing-library/react": "^14.1.2",
|
||||||
"@testing-library/user-event": "^14.5.1",
|
"@testing-library/user-event": "^14.5.1",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
"@types/react": "^18.3.28",
|
"@types/react": "^18.3.28",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
|
|
@ -1054,6 +1058,11 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
|
@ -1614,6 +1623,15 @@
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
|
|
@ -2766,6 +2784,11 @@
|
||||||
"node": ">= 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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
|
@ -3057,6 +3080,18 @@
|
||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"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": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
|
|
@ -4486,6 +4521,11 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/is-map": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
|
||||||
|
|
@ -4544,6 +4584,14 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/is-potential-custom-element-name": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
|
|
@ -4921,6 +4969,11 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
|
@ -6210,6 +6263,14 @@
|
||||||
"loose-envify": "^1.1.0"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
|
@ -6390,6 +6451,59 @@
|
||||||
"node": ">= 10"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
@ -6734,6 +6848,11 @@
|
||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|
@ -7018,6 +7137,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/universalify": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,16 @@
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-router-dom": "^6.20.1",
|
||||||
|
"slate": "^0.123.0",
|
||||||
|
"slate-history": "^0.113.1",
|
||||||
|
"slate-react": "^0.123.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@testing-library/react": "^14.1.2",
|
"@testing-library/react": "^14.1.2",
|
||||||
"@testing-library/user-event": "^14.5.1",
|
"@testing-library/user-event": "^14.5.1",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
"@types/react": "^18.3.28",
|
"@types/react": "^18.3.28",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,16 @@ import { Boards } from './pages/Boards';
|
||||||
import { BoardCreate } from './pages/BoardCreate';
|
import { BoardCreate } from './pages/BoardCreate';
|
||||||
import { BoardEdit } from './pages/BoardEdit';
|
import { BoardEdit } from './pages/BoardEdit';
|
||||||
import { BoardDetail } from './pages/BoardDetail';
|
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 { CardDetail } from './pages/CardDetail';
|
||||||
|
import { BoardDetailLayout } from './components/BoardDetailLayout';
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const { token } = useApp();
|
const { token } = useApp();
|
||||||
|
|
@ -35,7 +44,7 @@ const App = () => {
|
||||||
<ModalProvider>
|
<ModalProvider>
|
||||||
<div className="min-h-screen bg-gray-900 text-gray-100">
|
<div className="min-h-screen bg-gray-900 text-gray-100">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="flex-1 p-8 mx-auto w-full max-w-7xl">
|
<main>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
|
|
@ -72,7 +81,9 @@ const App = () => {
|
||||||
path="/boards/:id/edit"
|
path="/boards/:id/edit"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<BoardEdit />
|
<BoardDetailLayout>
|
||||||
|
<BoardEdit />
|
||||||
|
</BoardDetailLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -80,7 +91,89 @@ const App = () => {
|
||||||
path="/boards/:id"
|
path="/boards/:id"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<BoardDetail />
|
<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>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -88,7 +181,9 @@ const App = () => {
|
||||||
path="/boards/:id/cards/:cardId"
|
path="/boards/:id/cards/:cardId"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<CardDetail />
|
<BoardDetailLayout>
|
||||||
|
<CardDetail />
|
||||||
|
</BoardDetailLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
18
frontend/src/components/BoardDetailLayout.tsx
Normal file
18
frontend/src/components/BoardDetailLayout.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
42
frontend/src/components/BoardSidebar.tsx
Normal file
42
frontend/src/components/BoardSidebar.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/src/components/CardActionDropdown.tsx
Normal file
113
frontend/src/components/CardActionDropdown.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/components/CardAttachments.tsx
Normal file
157
frontend/src/components/CardAttachments.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import CheckSquareIcon from './icons/CheckSquareIcon';
|
||||||
import Trash2Icon from './icons/Trash2Icon';
|
import Trash2Icon from './icons/Trash2Icon';
|
||||||
import Edit2Icon from './icons/Edit2Icon';
|
import Edit2Icon from './icons/Edit2Icon';
|
||||||
import PlusIcon from './icons/PlusIcon';
|
import PlusIcon from './icons/PlusIcon';
|
||||||
|
import MonitorIcon from './icons/MonitorIcon';
|
||||||
import { useModal } from '../context/modals/useModal';
|
import { useModal } from '../context/modals/useModal';
|
||||||
import { CreateChecklistModal } from './CreateChecklistModal';
|
import { CreateChecklistModal } from './CreateChecklistModal';
|
||||||
import { DeleteChecklistModal } from './DeleteChecklistModal';
|
import { DeleteChecklistModal } from './DeleteChecklistModal';
|
||||||
|
|
@ -27,6 +28,7 @@ interface CardChecklistsProps {
|
||||||
state: 'incomplete' | 'complete'
|
state: 'incomplete' | 'complete'
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
removeCheckItem: (itemId: number) => Promise<boolean>;
|
removeCheckItem: (itemId: number) => Promise<boolean>;
|
||||||
|
onConvertToCard?: (itemName: string, itemId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardChecklists({
|
export function CardChecklists({
|
||||||
|
|
@ -37,6 +39,7 @@ export function CardChecklists({
|
||||||
toggleCheckItem,
|
toggleCheckItem,
|
||||||
editCheckItem,
|
editCheckItem,
|
||||||
removeCheckItem,
|
removeCheckItem,
|
||||||
|
onConvertToCard,
|
||||||
}: CardChecklistsProps) {
|
}: CardChecklistsProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
|
|
@ -152,7 +155,7 @@ export function CardChecklists({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{checklist.items && checklist.items.length > 0 ? (
|
{checklist.items && checklist.items.length > 0 ? (
|
||||||
checklist.items.map((item: any) => (
|
checklist.items.map((item: any, itemIndex: number) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center gap-3 p-2 bg-gray-700 rounded group hover:bg-gray-600 transition-colors"
|
className="flex items-center gap-3 p-2 bg-gray-700 rounded group hover:bg-gray-600 transition-colors"
|
||||||
|
|
@ -167,9 +170,62 @@ export function CardChecklists({
|
||||||
onClick={() => handleToggleCheckItem(item)}
|
onClick={() => handleToggleCheckItem(item)}
|
||||||
className={`flex-1 text-sm cursor-pointer ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
|
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}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<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">
|
||||||
|
{' '}
|
||||||
|
"{item.name}"{' '}
|
||||||
|
</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
|
<button
|
||||||
onClick={() => handleEditCheckItem(item)}
|
onClick={() => handleEditCheckItem(item)}
|
||||||
className="text-gray-400 hover:text-white transition-colors p-1"
|
className="text-gray-400 hover:text-white transition-colors p-1"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { FormEvent, useState } from 'react';
|
import { FormEvent, useState } from 'react';
|
||||||
import MessageSquareIcon from './icons/MessageSquareIcon';
|
import MessageSquareIcon from './icons/MessageSquareIcon';
|
||||||
import PlusIcon from './icons/PlusIcon';
|
import PlusIcon from './icons/PlusIcon';
|
||||||
|
import { formatDateTime } from '../utils/dateFormat';
|
||||||
|
|
||||||
interface CardCommentsProps {
|
interface CardCommentsProps {
|
||||||
card: any;
|
card: any;
|
||||||
|
|
@ -21,16 +22,6 @@ export function CardComments({
|
||||||
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
||||||
const [editedCommentText, setEditedCommentText] = useState('');
|
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) => {
|
const handleAddComment = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newCommentText.trim()) return;
|
if (!newCommentText.trim()) return;
|
||||||
|
|
@ -126,7 +117,7 @@ export function CardComments({
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white font-medium">{comment.user?.username || 'Unknown'}</p>
|
<p className="text-white font-medium">{comment.user?.username || 'Unknown'}</p>
|
||||||
<p className="text-gray-400 text-xs">{formatDate(comment.created_at)}</p>
|
<p className="text-gray-400 text-xs">{formatDateTime(comment.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
159
frontend/src/components/CardEpics.tsx
Normal file
159
frontend/src/components/CardEpics.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
frontend/src/components/CardLinks.tsx
Normal file
129
frontend/src/components/CardLinks.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<Link
|
<Link
|
||||||
to={`/boards/${card.board_id}/cards/${card.id}`}
|
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"
|
className="text-2xl font-bold text-blue-400 hover:text-blue-300 hover:underline transition-colors"
|
||||||
>
|
>
|
||||||
{card.name}
|
{card.name}
|
||||||
|
|
@ -24,6 +25,24 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Labels */}
|
||||||
{card.labels && card.labels.length > 0 && (
|
{card.labels && card.labels.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
|
import { formatDateTime } from '../utils/dateFormat';
|
||||||
|
|
||||||
interface CardSidebarProps {
|
interface CardSidebarProps {
|
||||||
card: any;
|
card: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardSidebar({ card }: CardSidebarProps) {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-gray-800 rounded-lg p-6">
|
<div className="bg-gray-800 rounded-lg p-6">
|
||||||
|
|
@ -20,11 +12,11 @@ export function CardSidebar({ card }: CardSidebarProps) {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-400 text-sm">Due Date</p>
|
<p className="text-gray-400 text-sm">Due Date</p>
|
||||||
<p className="text-white">{card.due ? formatDate(card.due) : 'No due date'}</p>
|
<p className="text-white">{card.due ? formatDateTime(card.due) : 'No due date'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-400 text-sm">Last Activity</p>
|
<p className="text-gray-400 text-sm">Last Activity</p>
|
||||||
<p className="text-white">{formatDate(card.date_last_activity)}</p>
|
<p className="text-white">{formatDateTime(card.date_last_activity)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-400 text-sm">Position</p>
|
<p className="text-gray-400 text-sm">Position</p>
|
||||||
|
|
|
||||||
109
frontend/src/components/CreateLinkedCardModal.tsx
Normal file
109
frontend/src/components/CreateLinkedCardModal.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/components/ELEMENT_TAGS.ts
Normal file
37
frontend/src/components/ELEMENT_TAGS.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
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 };
|
||||||
101
frontend/src/components/LinkExistingCardModal.tsx
Normal file
101
frontend/src/components/LinkExistingCardModal.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/components/NarrowPageLayout.tsx
Normal file
5
frontend/src/components/NarrowPageLayout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
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>;
|
||||||
|
};
|
||||||
|
|
@ -3,41 +3,8 @@ import { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { TaskboardLogo } from './TaskboardLogo';
|
import { TaskboardLogo } from './TaskboardLogo';
|
||||||
|
import MenuIcon from './icons/MenuIcon';
|
||||||
const MenuIcon = () => (
|
import CloseIcon from './icons/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="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() {
|
export function Navbar() {
|
||||||
const { user } = useApp();
|
const { user } = useApp();
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue