Merge pull request 'feature-epics-documents' (#6) from feature-epics-documents into main
Reviewed-on: http://localhost:3000/david/flask_react_monorepo_template/pulls/6
This commit is contained in:
commit
ec48ce3225
52 changed files with 3658 additions and 119 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
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -83,4 +83,6 @@ htmlcov/
|
||||||
|
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
|
|
||||||
backend/app/static
|
backend/app/static
|
||||||
|
|
||||||
|
.clinerules
|
||||||
7
Makefile
7
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,31 @@ 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
|
||||||
|
|
||||||
|
|
@ -63,6 +83,31 @@ class TestingConfig(Config):
|
||||||
"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 +120,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,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ from functools import wraps
|
||||||
from flask import abort, g
|
from flask import abort, g
|
||||||
from flask_jwt_extended import get_jwt_identity
|
from flask_jwt_extended import get_jwt_identity
|
||||||
|
|
||||||
from app.models import Board, Card, CheckItem, Checklist, Comment, List
|
from app.models import (Board, Card, CheckItem, Checklist, Comment,
|
||||||
|
FileAttachment, List)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user_id():
|
def get_current_user_id():
|
||||||
|
|
@ -157,3 +158,166 @@ 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.query.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
|
||||||
|
|
||||||
|
|
||||||
|
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 file uploaded by user
|
||||||
|
attachment = FileAttachment.query.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
|
||||||
|
card_attachment = (
|
||||||
|
FileAttachment.query.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
|
||||||
|
comment_attachment = (
|
||||||
|
FileAttachment.query.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 file uploaded by user
|
||||||
|
attachment = FileAttachment.query.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
|
||||||
|
card_attachment = (
|
||||||
|
FileAttachment.query.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
|
||||||
|
comment_attachment = (
|
||||||
|
FileAttachment.query.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
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from app.models.card_label import CardLabel
|
||||||
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.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.order import Order, OrderItem
|
||||||
|
|
@ -23,4 +24,5 @@ __all__ = [
|
||||||
"Checklist",
|
"Checklist",
|
||||||
"CheckItem",
|
"CheckItem",
|
||||||
"Comment",
|
"Comment",
|
||||||
|
"FileAttachment",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,14 @@ class Card(db.Model):
|
||||||
comments = db.relationship(
|
comments = db.relationship(
|
||||||
"Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic"
|
"Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic"
|
||||||
)
|
)
|
||||||
|
attachments = db.relationship(
|
||||||
|
"FileAttachment",
|
||||||
|
foreign_keys="FileAttachment.attachable_id",
|
||||||
|
primaryjoin="""and_(FileAttachment.attachable_id == Card.id,
|
||||||
|
FileAttachment.attachable_type == 'Card')""",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert card to dictionary"""
|
"""Convert card to dictionary"""
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,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 {
|
||||||
|
|
|
||||||
75
backend/app/models/file_attachment.py
Normal file
75
backend/app/models/file_attachment.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Index
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class FileAttachment(db.Model):
|
||||||
|
"""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,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<FileAttachment {self.original_name} ({self.file_type})>"
|
||||||
|
|
@ -6,8 +6,8 @@ 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
|
||||||
|
|
||||||
# fmt: off
|
# fmt: off
|
||||||
from . import (boards, cards, checklists, comments, labels, # noqa: F401 E402
|
from . import (boards, cards, checklists, comments, files, # noqa: F401 E402
|
||||||
lists)
|
labels, lists)
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
|
|
||||||
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -7,6 +7,10 @@ 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.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.product import ProductCreateRequest, ProductResponse
|
||||||
|
|
@ -36,6 +40,13 @@ __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",
|
||||||
|
|
|
||||||
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
|
||||||
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"
|
||||||
315
backend/app/services/storage/file_service.py
Normal file
315
backend/app/services/storage/file_service.py
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
"""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
|
||||||
|
db.session.delete(attachment)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
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())
|
||||||
128
backend/app/services/storage/storage_extension.py
Normal file
128
backend/app/services/storage/storage_extension.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""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"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
try:
|
||||||
|
if not self.client.bucket_exists(bucket_name):
|
||||||
|
self.client.make_bucket(bucket_name)
|
||||||
|
app.logger.info(f"Created MinIO bucket: {bucket_name}")
|
||||||
|
else:
|
||||||
|
app.logger.debug(f"MinIO bucket already exists: {bucket_name}")
|
||||||
|
except S3Error as e:
|
||||||
|
app.logger.error(f"Failed to ensure bucket {bucket_name}: {e}")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to create MinIO bucket '{bucket_name}': {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
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()
|
||||||
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,9 +10,11 @@ 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
|
||||||
pytest-mock==3.12.0
|
pytest-mock==3.12.0
|
||||||
factory-boy==3.3.0
|
factory-boy==3.3.0
|
||||||
faker==20.1.0
|
faker==20.1.0
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
"""Pytest configuration and fixtures"""
|
"""Pytest configuration and fixtures"""
|
||||||
import logging
|
import logging
|
||||||
|
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, Order, OrderItem, Product,
|
||||||
|
User)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
fake = Faker()
|
fake = Faker()
|
||||||
|
|
@ -225,3 +228,91 @@ def order(db_session, regular_user, products):
|
||||||
order.total_amount = total_amount
|
order.total_amount = total_amount
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
# ============ File Upload Fixtures ============
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_image_file():
|
||||||
|
"""Create a test image file as BytesIO"""
|
||||||
|
# Create a simple red PNG image using PIL
|
||||||
|
img = Image.new("RGB", (10, 10), color="red")
|
||||||
|
img_io = BytesIO()
|
||||||
|
img.save(img_io, format="PNG")
|
||||||
|
img_io.seek(0)
|
||||||
|
return img_io
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_large_image_file():
|
||||||
|
"""Create a larger test image (50KB)"""
|
||||||
|
# Create a larger image using PIL
|
||||||
|
img = Image.new("RGB", (200, 200), color="blue")
|
||||||
|
img_io = BytesIO()
|
||||||
|
img.save(img_io, format="PNG")
|
||||||
|
img_io.seek(0)
|
||||||
|
return img_io
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_pdf_file():
|
||||||
|
"""Create a test PDF file as BytesIO"""
|
||||||
|
# Minimal valid PDF
|
||||||
|
pdf_data = b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF"
|
||||||
|
return BytesIO(pdf_data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_board(db_session, regular_user):
|
||||||
|
"""Create a test board"""
|
||||||
|
board = Board(
|
||||||
|
user_id=regular_user.id,
|
||||||
|
name=fake.sentence(nb_words=4)[:-1],
|
||||||
|
description=fake.paragraph(),
|
||||||
|
)
|
||||||
|
db_session.add(board)
|
||||||
|
db_session.commit()
|
||||||
|
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
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
|
|
@ -23,6 +23,7 @@
|
||||||
"@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",
|
||||||
|
|
@ -1614,6 +1615,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",
|
||||||
|
|
@ -7018,6 +7028,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",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
"@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,6 +16,7 @@ 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 { CardDetail } from './pages/CardDetail';
|
import { CardDetail } from './pages/CardDetail';
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
|
|
@ -35,7 +36,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="/"
|
||||||
|
|
@ -84,6 +85,14 @@ const App = () => {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/boards/:id/epics"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BoardEpics />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/boards/:id/cards/:cardId"
|
path="/boards/:id/cards/:cardId"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
40
frontend/src/components/BoardSidebar.tsx
Normal file
40
frontend/src/components/BoardSidebar.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
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: 'history', label: 'History', icon: '📜', path: `/boards/${boardId}/history` },
|
||||||
|
{ id: 'documents', label: 'Documents', icon: '📄', path: `/boards/${boardId}/documents` },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 bg-gray-800 rounded-lg p-4 border border-gray-700 h-fit sticky top-4">
|
||||||
|
<h3 className="text-white font-bold text-lg mb-4">Board Menu</h3>
|
||||||
|
<nav className="space-y-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-3 py-2 rounded-md transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{item.icon}</span>
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
frontend/src/components/CardAttachments.tsx
Normal file
163
frontend/src/components/CardAttachments.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
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 formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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)} • {formatDate(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>;
|
||||||
|
};
|
||||||
46
frontend/src/components/SecureImage.tsx
Normal file
46
frontend/src/components/SecureImage.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useSecureImage } from '../hooks/useSecureImage';
|
||||||
|
|
||||||
|
interface SecureImageProps {
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecureImage({ url, alt, className = '', fallback }: SecureImageProps) {
|
||||||
|
const { imageUrl, loading, error } = useSecureImage(url);
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (loading) {
|
||||||
|
return <div className={`bg-gray-600 animate-pulse ${className}`}></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error or fallback
|
||||||
|
if (error || !imageUrl) {
|
||||||
|
return fallback ? (
|
||||||
|
<>{fallback}</>
|
||||||
|
) : (
|
||||||
|
<div className={`bg-gray-600 flex items-center justify-center ${className}`}>
|
||||||
|
<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"
|
||||||
|
className="text-gray-400"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||||
|
<polyline points="21 15 16 10 5 21"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show image
|
||||||
|
return <img src={imageUrl} alt={alt} className={className} />;
|
||||||
|
}
|
||||||
5
frontend/src/components/WidePageLayout.tsx
Normal file
5
frontend/src/components/WidePageLayout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export const WidePageLayout = ({ children }: { children: ReactNode }) => {
|
||||||
|
return <div className="flex-1 p-8 mx-auto w-full max-w-7xl">{children}</div>;
|
||||||
|
};
|
||||||
19
frontend/src/components/icons/DownloadIcon.tsx
Normal file
19
frontend/src/components/icons/DownloadIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
const DownloadIcon = () => (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DownloadIcon;
|
||||||
18
frontend/src/components/icons/EyeIcon.tsx
Normal file
18
frontend/src/components/icons/EyeIcon.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
const EyeIcon = () => (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default EyeIcon;
|
||||||
18
frontend/src/components/icons/FileIcon.tsx
Normal file
18
frontend/src/components/icons/FileIcon.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
const FileIcon = () => (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||||
|
<polyline points="13 2 13 9 20 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FileIcon;
|
||||||
|
|
@ -267,6 +267,38 @@ export function useApi() {
|
||||||
deleteComment: async (id: number): Promise<void> => {
|
deleteComment: async (id: number): Promise<void> => {
|
||||||
await api.delete(`/comments/${id}`);
|
await api.delete(`/comments/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// File Attachments
|
||||||
|
getCardAttachments: async (cardId: number): Promise<any> => {
|
||||||
|
const response = await api.get(`/cards/${cardId}/attachments`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
uploadCardAttachment: async (
|
||||||
|
cardId: number,
|
||||||
|
file: File,
|
||||||
|
entityType: string = 'Card'
|
||||||
|
): Promise<any> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('entity_type', entityType);
|
||||||
|
formData.append('entity_id', cardId.toString());
|
||||||
|
|
||||||
|
const response = await api.post(`/cards/${cardId}/attachments`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
deleteFile: async (fileUuid: string): Promise<void> => {
|
||||||
|
await api.delete(`/files/${fileUuid}`);
|
||||||
|
},
|
||||||
|
getSecureImage: async (url: string): Promise<Blob> => {
|
||||||
|
const response = await api.get(url, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
129
frontend/src/hooks/useFileAttachments.ts
Normal file
129
frontend/src/hooks/useFileAttachments.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
import { useLoader } from '../context/loaders/useLoader';
|
||||||
|
import { useToast } from '../context/toasts/useToast';
|
||||||
|
import { FileAttachment } from '../types/kanban';
|
||||||
|
|
||||||
|
export function useFileAttachments(cardId?: number) {
|
||||||
|
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { getCardAttachments, uploadCardAttachment, deleteFile } = useApi();
|
||||||
|
const { withLoader } = useLoader();
|
||||||
|
const { addNotification } = useToast();
|
||||||
|
|
||||||
|
const fetchAttachments = useCallback(async () => {
|
||||||
|
if (!cardId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await withLoader(() => getCardAttachments(cardId), 'Loading attachments...');
|
||||||
|
setAttachments(data.files || []);
|
||||||
|
return data.files || [];
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load attachments';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Loading Attachments',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [getCardAttachments, cardId, withLoader, addNotification]);
|
||||||
|
|
||||||
|
const uploadFile = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
if (!cardId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Validate file size (10MB for images, 50MB for documents)
|
||||||
|
const maxSize = file.type.startsWith('image/') ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
const maxSizeMB = file.type.startsWith('image/') ? '10MB' : '50MB';
|
||||||
|
throw new Error(`File size exceeds ${maxSizeMB} limit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await withLoader(
|
||||||
|
() => uploadCardAttachment(cardId, file, 'Card'),
|
||||||
|
'Uploading file...'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add new attachment to state
|
||||||
|
setAttachments((prev) => [...prev, data]);
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'File Uploaded',
|
||||||
|
message: `Successfully uploaded ${file.name}`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to upload file';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Upload Failed',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[uploadCardAttachment, cardId, withLoader, addNotification]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteAttachment = useCallback(
|
||||||
|
async (fileUuid: string) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await withLoader(() => deleteFile(fileUuid), 'Deleting file...');
|
||||||
|
|
||||||
|
// Remove attachment from state
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.uuid !== fileUuid));
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'File Deleted',
|
||||||
|
message: 'File deleted successfully',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to delete file';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Delete Failed',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteFile, withLoader, addNotification]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cardId) {
|
||||||
|
fetchAttachments();
|
||||||
|
}
|
||||||
|
}, [cardId, fetchAttachments]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachments,
|
||||||
|
error, // For debugging, not for UI display
|
||||||
|
loading: false, // Loading is handled by global loader
|
||||||
|
refetch: fetchAttachments,
|
||||||
|
uploadFile,
|
||||||
|
deleteAttachment,
|
||||||
|
};
|
||||||
|
}
|
||||||
51
frontend/src/hooks/useSecureImage.ts
Normal file
51
frontend/src/hooks/useSecureImage.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
|
||||||
|
interface UseSecureImageReturn {
|
||||||
|
imageUrl: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSecureImage(url: string): UseSecureImageReturn {
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { getSecureImage } = useApi();
|
||||||
|
|
||||||
|
const fetchImage = useCallback(async () => {
|
||||||
|
if (!url) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Fetch image with authentication
|
||||||
|
const blob = await getSecureImage(url);
|
||||||
|
|
||||||
|
// Create blob URL
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
setImageUrl(blobUrl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching image:', err);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load image';
|
||||||
|
setError(new Error(errorMessage));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [url, getSecureImage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchImage();
|
||||||
|
}, [fetchImage]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageUrl,
|
||||||
|
loading,
|
||||||
|
error, // For debugging
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useListMutations } from '../hooks/useListMutations';
|
||||||
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
|
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
|
||||||
import { CreateListModal } from '../components/kanban/CreateListModal';
|
import { CreateListModal } from '../components/kanban/CreateListModal';
|
||||||
import { CardPreviewModal } from '../components/CardPreviewModal';
|
import { CardPreviewModal } from '../components/CardPreviewModal';
|
||||||
|
import { BoardSidebar } from '../components/BoardSidebar';
|
||||||
import { useModal } from '../context/modals/useModal';
|
import { useModal } from '../context/modals/useModal';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
|
|
@ -20,6 +21,7 @@ import {
|
||||||
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { Card as CardType, ListWithCards } from '../types/kanban';
|
import { Card as CardType, ListWithCards } from '../types/kanban';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { WidePageLayout } from '@/components/WidePageLayout';
|
||||||
|
|
||||||
export function BoardDetail() {
|
export function BoardDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
@ -65,12 +67,8 @@ export function BoardDetail() {
|
||||||
const handleDragOver = (event: DragOverEvent) => {
|
const handleDragOver = (event: DragOverEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
// console.log('---handleDragOver', event);
|
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
||||||
// const activeId = parseInt(active.id as string);
|
|
||||||
// const overId = parseInt(over.id as string);
|
|
||||||
|
|
||||||
const overIdStr = (over.id as string).split('_')[1];
|
const overIdStr = (over.id as string).split('_')[1];
|
||||||
const overId = parseInt(overIdStr, 10);
|
const overId = parseInt(overIdStr, 10);
|
||||||
const activeIdStr = (active.id as string).split('_')[1];
|
const activeIdStr = (active.id as string).split('_')[1];
|
||||||
|
|
@ -105,8 +103,6 @@ export function BoardDetail() {
|
||||||
|
|
||||||
// Handle column reordering
|
// Handle column reordering
|
||||||
if (activeType === 'COLUMN') {
|
if (activeType === 'COLUMN') {
|
||||||
// todo find over column id,
|
|
||||||
|
|
||||||
let overListIndex = -1;
|
let overListIndex = -1;
|
||||||
const activeList = board.lists.find((l) => l.id === activeId);
|
const activeList = board.lists.find((l) => l.id === activeId);
|
||||||
if (overType === 'CARD') {
|
if (overType === 'CARD') {
|
||||||
|
|
@ -118,12 +114,7 @@ export function BoardDetail() {
|
||||||
overListIndex = board.lists.findIndex((l) => l.id === overId);
|
overListIndex = board.lists.findIndex((l) => l.id === overId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('-------active.id', active.id)
|
|
||||||
// console.log('-------overType.id', overType)
|
|
||||||
// console.log('-------overListIndex', overListIndex)
|
|
||||||
|
|
||||||
const activeListIndex = board.lists.findIndex((l) => l.id === activeId);
|
const activeListIndex = board.lists.findIndex((l) => l.id === activeId);
|
||||||
// overListIndex = board.lists.findIndex((l) => l.id === overId);
|
|
||||||
|
|
||||||
if (activeListIndex === -1 || overListIndex === -1 || !activeList) return;
|
if (activeListIndex === -1 || overListIndex === -1 || !activeList) return;
|
||||||
|
|
||||||
|
|
@ -133,15 +124,6 @@ export function BoardDetail() {
|
||||||
reorderedLists.splice(overListIndex, 0, movedList);
|
reorderedLists.splice(overListIndex, 0, movedList);
|
||||||
|
|
||||||
await updateList(activeList.id, { name: activeList.name, pos: overListIndex });
|
await updateList(activeList.id, { name: activeList.name, pos: overListIndex });
|
||||||
|
|
||||||
// // Update all list positions
|
|
||||||
// for (let i = 0; i < reorderedLists.length; i++) {
|
|
||||||
// const list = reorderedLists[i];
|
|
||||||
// if (list.pos !== i) {
|
|
||||||
// await updateList(list.id, { name: list.name, pos: i });
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,71 +218,81 @@ export function BoardDetail() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-start">
|
<WidePageLayout>
|
||||||
<div>
|
<div className="flex justify-between items-start">
|
||||||
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm">
|
<div>
|
||||||
← Back to Boards
|
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm">
|
||||||
</Link>
|
← Back to Boards
|
||||||
<h1 className="text-3xl font-bold text-white mt-2">{board.name}</h1>
|
</Link>
|
||||||
{board.description && <p className="text-gray-400 mt-1">{board.description}</p>}
|
<h1 className="text-3xl font-bold text-white mt-2">{board.name}</h1>
|
||||||
|
{board.description && <p className="text-gray-400 mt-1">{board.description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/boards/${id}/edit`}
|
||||||
|
className="bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Edit Board
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleAddList}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
+ Add List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
</WidePageLayout>
|
||||||
<Link
|
|
||||||
to={`/boards/${id}/edit`}
|
<div className="px-4 sm:px-6 lg:px-8 ">
|
||||||
className="bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
<div className="flex justify-between items-start gap-6">
|
||||||
>
|
<div className="flex-1">
|
||||||
Edit Board
|
<DndContext
|
||||||
</Link>
|
sensors={sensors}
|
||||||
<button
|
collisionDetection={closestCenter}
|
||||||
onClick={handleAddList}
|
onDragStart={handleDragStart}
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
onDragOver={handleDragOver}
|
||||||
>
|
onDragEnd={handleDragEnd}
|
||||||
+ Add List
|
>
|
||||||
</button>
|
<SortableContext
|
||||||
|
items={board.lists.map((list) => `COLUMN_${list.id}`)}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{board.lists.map((list) => (
|
||||||
|
<SortableKanbanColumn
|
||||||
|
key={list.id}
|
||||||
|
list={list}
|
||||||
|
cards={list.cards}
|
||||||
|
onOpenCardModal={handleOpenCardModal}
|
||||||
|
onCardCreate={handleAddCard(list.id)}
|
||||||
|
onListEdit={(name) => handleEditList(list.id, name)}
|
||||||
|
onListDelete={() => handleDeleteList(list.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeCard ? (
|
||||||
|
<div className="bg-gray-700 rounded-lg p-4 cursor-grabbing border border-gray-600 shadow-lg opacity-80">
|
||||||
|
<h3 className="text-white font-medium">{activeCard.name}</h3>
|
||||||
|
</div>
|
||||||
|
) : activeList ? (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4 cursor-grabbing border border-gray-700 shadow-lg opacity-80 min-w-[300px] max-w-[300px]">
|
||||||
|
<h2 className="text-white font-bold text-lg">{activeList.name}</h2>
|
||||||
|
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block mt-2">
|
||||||
|
{activeList.cards.length} cards
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BoardSidebar boardId={id || ''} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={board.lists.map((list) => `COLUMN_${list.id}`)}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
|
||||||
{board.lists.map((list) => (
|
|
||||||
<SortableKanbanColumn
|
|
||||||
key={list.id}
|
|
||||||
list={list}
|
|
||||||
cards={list.cards}
|
|
||||||
onOpenCardModal={handleOpenCardModal}
|
|
||||||
onCardCreate={handleAddCard(list.id)}
|
|
||||||
onListEdit={(name) => handleEditList(list.id, name)}
|
|
||||||
onListDelete={() => handleDeleteList(list.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
|
|
||||||
<DragOverlay>
|
|
||||||
{activeCard ? (
|
|
||||||
<div className="bg-gray-700 rounded-lg p-4 cursor-grabbing border border-gray-600 shadow-lg opacity-80">
|
|
||||||
<h3 className="text-white font-medium">{activeCard.name}</h3>
|
|
||||||
</div>
|
|
||||||
) : activeList ? (
|
|
||||||
<div className="bg-gray-800 rounded-lg p-4 cursor-grabbing border border-gray-700 shadow-lg opacity-80 min-w-[300px] max-w-[300px]">
|
|
||||||
<h2 className="text-white font-bold text-lg">{activeList.name}</h2>
|
|
||||||
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block mt-2">
|
|
||||||
{activeList.cards.length} cards
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
frontend/src/pages/BoardEpics.tsx
Normal file
35
frontend/src/pages/BoardEpics.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { WidePageLayout } from '../components/WidePageLayout';
|
||||||
|
|
||||||
|
export function BoardEpics() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<WidePageLayout>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to={`/boards/${id}`}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||||
|
>
|
||||||
|
← Back to Board
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-white mt-2">Epics</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Manage and view epics for this board</p>
|
||||||
|
</div>
|
||||||
|
</WidePageLayout>
|
||||||
|
|
||||||
|
<WidePageLayout>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-8 border border-gray-700">
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-6xl mb-4">📋</div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">Epics Coming Soon</h2>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
This page will allow you to create and manage epics for your board.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WidePageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,42 +1,45 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useBoards } from '../hooks/useBoards';
|
import { useBoards } from '../hooks/useBoards';
|
||||||
import { BoardCard } from '../components/kanban/BoardCard';
|
import { BoardCard } from '../components/kanban/BoardCard';
|
||||||
|
import { NarrowPageLayout } from '@/components/NarrowPageLayout';
|
||||||
|
|
||||||
export function Boards() {
|
export function Boards() {
|
||||||
const { boards, deleteBoard } = useBoards();
|
const { boards, deleteBoard } = useBoards();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<NarrowPageLayout>
|
||||||
<div className="flex justify-between items-center">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">My Boards</h1>
|
<div>
|
||||||
<p className="text-gray-400">Manage your Kanban boards</p>
|
<h1 className="text-3xl font-bold text-white mb-2">My Boards</h1>
|
||||||
</div>
|
<p className="text-gray-400">Manage your Kanban boards</p>
|
||||||
<Link
|
</div>
|
||||||
to="/boards/new"
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
+ Create Board
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{boards.length === 0 ? (
|
|
||||||
<div className="text-center py-16">
|
|
||||||
<p className="text-gray-400 text-lg mb-4">No boards yet</p>
|
|
||||||
<Link
|
<Link
|
||||||
to="/boards/new"
|
to="/boards/new"
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors inline-block"
|
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Create your first board
|
+ Create Board
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{boards.length === 0 ? (
|
||||||
{boards.map((board) => (
|
<div className="text-center py-16">
|
||||||
<BoardCard key={board.id} board={board} onDelete={deleteBoard} />
|
<p className="text-gray-400 text-lg mb-4">No boards yet</p>
|
||||||
))}
|
<Link
|
||||||
</div>
|
to="/boards/new"
|
||||||
)}
|
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors inline-block"
|
||||||
</div>
|
>
|
||||||
|
Create your first board
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{boards.map((board) => (
|
||||||
|
<BoardCard key={board.id} board={board} onDelete={deleteBoard} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</NarrowPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ import { CardSidebar } from '../components/CardSidebar';
|
||||||
import { CardComments } from '../components/CardComments';
|
import { CardComments } from '../components/CardComments';
|
||||||
import { CardChecklists } from '../components/CardChecklists';
|
import { CardChecklists } from '../components/CardChecklists';
|
||||||
import { CardLabels } from '../components/CardLabels';
|
import { CardLabels } from '../components/CardLabels';
|
||||||
|
import { CardAttachments } from '../components/CardAttachments';
|
||||||
import { EditCardModal } from '../components/EditCardModal';
|
import { EditCardModal } from '../components/EditCardModal';
|
||||||
import { DeleteCardModal } from '../components/DeleteCardModal';
|
import { DeleteCardModal } from '../components/DeleteCardModal';
|
||||||
import Trash2Icon from '../components/icons/Trash2Icon';
|
import Trash2Icon from '../components/icons/Trash2Icon';
|
||||||
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
|
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
|
||||||
import Edit2Icon from '../components/icons/Edit2Icon';
|
import Edit2Icon from '../components/icons/Edit2Icon';
|
||||||
|
import { NarrowPageLayout } from '@/components/NarrowPageLayout';
|
||||||
|
|
||||||
export function CardDetail() {
|
export function CardDetail() {
|
||||||
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
|
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
|
||||||
|
|
@ -84,7 +86,7 @@ export function CardDetail() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<NarrowPageLayout>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -164,6 +166,8 @@ export function CardDetail() {
|
||||||
removeCheckItem={checklistMutations.removeCheckItem}
|
removeCheckItem={checklistMutations.removeCheckItem}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CardAttachments cardId={cardId ? parseInt(cardId) : undefined} />
|
||||||
|
|
||||||
<CardComments
|
<CardComments
|
||||||
card={card}
|
card={card}
|
||||||
addComment={addComment}
|
addComment={addComment}
|
||||||
|
|
@ -175,6 +179,6 @@ export function CardDetail() {
|
||||||
|
|
||||||
<CardSidebar card={card} />
|
<CardSidebar card={card} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NarrowPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,3 +183,16 @@ export interface User {
|
||||||
first_name?: string;
|
first_name?: string;
|
||||||
last_name?: string;
|
last_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File Attachment types
|
||||||
|
export interface FileAttachment {
|
||||||
|
uuid: string;
|
||||||
|
original_name: string;
|
||||||
|
file_type: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
created_at: string;
|
||||||
|
download_url: string;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
view_url: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue