feat: implement document management module
- Backend (FastAPI): - Attachment and AttachmentVersion models with migration - FileStorageService with SHA-256 checksum validation - File type validation (whitelist/blacklist) - Full CRUD API with version control support - Audit trail integration for upload/download/delete - Configurable upload directory and file size limit - Frontend (React + Vite): - AttachmentUpload component with drag & drop - AttachmentList component with download/delete - TaskAttachments combined component - Attachments service for API calls - Testing: - 12 tests for storage service and API endpoints - OpenSpec: - add-document-management change archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
3
backend/app/api/attachments/__init__.py
Normal file
3
backend/app/api/attachments/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.api.attachments.router import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
382
backend/app/api/attachments/router.py
Normal file
382
backend/app/api/attachments/router.py
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.auth import get_current_user
|
||||||
|
from app.models import User, Task, Attachment, AttachmentVersion, AuditAction
|
||||||
|
from app.schemas.attachment import (
|
||||||
|
AttachmentResponse, AttachmentListResponse, AttachmentDetailResponse,
|
||||||
|
AttachmentVersionResponse, VersionHistoryResponse
|
||||||
|
)
|
||||||
|
from app.services.file_storage_service import file_storage_service
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["attachments"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_or_404(db: Session, task_id: str) -> Task:
|
||||||
|
"""Get task or raise 404."""
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def get_attachment_or_404(db: Session, attachment_id: str) -> Attachment:
|
||||||
|
"""Get attachment or raise 404."""
|
||||||
|
attachment = db.query(Attachment).filter(
|
||||||
|
Attachment.id == attachment_id,
|
||||||
|
Attachment.is_deleted == False
|
||||||
|
).first()
|
||||||
|
if not attachment:
|
||||||
|
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||||
|
return attachment
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_to_response(attachment: Attachment) -> AttachmentResponse:
|
||||||
|
"""Convert Attachment model to response."""
|
||||||
|
return AttachmentResponse(
|
||||||
|
id=attachment.id,
|
||||||
|
task_id=attachment.task_id,
|
||||||
|
filename=attachment.filename,
|
||||||
|
original_filename=attachment.original_filename,
|
||||||
|
mime_type=attachment.mime_type,
|
||||||
|
file_size=attachment.file_size,
|
||||||
|
current_version=attachment.current_version,
|
||||||
|
is_encrypted=attachment.is_encrypted,
|
||||||
|
uploaded_by=attachment.uploaded_by,
|
||||||
|
uploader_name=attachment.uploader.name if attachment.uploader else None,
|
||||||
|
created_at=attachment.created_at,
|
||||||
|
updated_at=attachment.updated_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def version_to_response(version: AttachmentVersion) -> AttachmentVersionResponse:
|
||||||
|
"""Convert AttachmentVersion model to response."""
|
||||||
|
return AttachmentVersionResponse(
|
||||||
|
id=version.id,
|
||||||
|
version=version.version,
|
||||||
|
file_size=version.file_size,
|
||||||
|
checksum=version.checksum,
|
||||||
|
uploaded_by=version.uploaded_by,
|
||||||
|
uploader_name=version.uploader.name if version.uploader else None,
|
||||||
|
created_at=version.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/attachments", response_model=AttachmentResponse)
|
||||||
|
async def upload_attachment(
|
||||||
|
task_id: str,
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Upload a file attachment to a task."""
|
||||||
|
task = get_task_or_404(db, task_id)
|
||||||
|
|
||||||
|
# Check if attachment with same filename exists (for versioning in Phase 2)
|
||||||
|
existing = db.query(Attachment).filter(
|
||||||
|
Attachment.task_id == task_id,
|
||||||
|
Attachment.original_filename == file.filename,
|
||||||
|
Attachment.is_deleted == False
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Phase 2: Create new version
|
||||||
|
new_version = existing.current_version + 1
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
file_path, file_size, checksum = await file_storage_service.save_file(
|
||||||
|
file=file,
|
||||||
|
project_id=task.project_id,
|
||||||
|
task_id=task_id,
|
||||||
|
attachment_id=existing.id,
|
||||||
|
version=new_version
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create version record
|
||||||
|
version = AttachmentVersion(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
attachment_id=existing.id,
|
||||||
|
version=new_version,
|
||||||
|
file_path=file_path,
|
||||||
|
file_size=file_size,
|
||||||
|
checksum=checksum,
|
||||||
|
uploaded_by=current_user.id
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
|
||||||
|
# Update attachment
|
||||||
|
existing.current_version = new_version
|
||||||
|
existing.file_size = file_size
|
||||||
|
existing.updated_at = version.created_at
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditService.log_event(
|
||||||
|
db=db,
|
||||||
|
event_type="attachment.upload",
|
||||||
|
resource_type="attachment",
|
||||||
|
action=AuditAction.UPDATE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
resource_id=existing.id,
|
||||||
|
changes=[{"field": "version", "old_value": new_version - 1, "new_value": new_version}],
|
||||||
|
request_metadata=getattr(request.state, "audit_metadata", None)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return attachment_to_response(existing)
|
||||||
|
|
||||||
|
# Create new attachment
|
||||||
|
attachment_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
file_path, file_size, checksum = await file_storage_service.save_file(
|
||||||
|
file=file,
|
||||||
|
project_id=task.project_id,
|
||||||
|
task_id=task_id,
|
||||||
|
attachment_id=attachment_id,
|
||||||
|
version=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get mime type from file storage validation
|
||||||
|
extension = file_storage_service.get_extension(file.filename or "")
|
||||||
|
mime_type = file.content_type or "application/octet-stream"
|
||||||
|
|
||||||
|
# Create attachment record
|
||||||
|
attachment = Attachment(
|
||||||
|
id=attachment_id,
|
||||||
|
task_id=task_id,
|
||||||
|
filename=file.filename or "unnamed",
|
||||||
|
original_filename=file.filename or "unnamed",
|
||||||
|
mime_type=mime_type,
|
||||||
|
file_size=file_size,
|
||||||
|
current_version=1,
|
||||||
|
is_encrypted=False,
|
||||||
|
uploaded_by=current_user.id
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
|
||||||
|
# Create version record
|
||||||
|
version = AttachmentVersion(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
attachment_id=attachment_id,
|
||||||
|
version=1,
|
||||||
|
file_path=file_path,
|
||||||
|
file_size=file_size,
|
||||||
|
checksum=checksum,
|
||||||
|
uploaded_by=current_user.id
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(attachment)
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditService.log_event(
|
||||||
|
db=db,
|
||||||
|
event_type="attachment.upload",
|
||||||
|
resource_type="attachment",
|
||||||
|
action=AuditAction.CREATE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
resource_id=attachment.id,
|
||||||
|
changes=[{"field": "filename", "old_value": None, "new_value": attachment.filename}],
|
||||||
|
request_metadata=getattr(request.state, "audit_metadata", None)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return attachment_to_response(attachment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/attachments", response_model=AttachmentListResponse)
|
||||||
|
async def list_task_attachments(
|
||||||
|
task_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""List all attachments for a task."""
|
||||||
|
task = get_task_or_404(db, task_id)
|
||||||
|
|
||||||
|
attachments = db.query(Attachment).filter(
|
||||||
|
Attachment.task_id == task_id,
|
||||||
|
Attachment.is_deleted == False
|
||||||
|
).order_by(Attachment.created_at.desc()).all()
|
||||||
|
|
||||||
|
return AttachmentListResponse(
|
||||||
|
attachments=[attachment_to_response(a) for a in attachments],
|
||||||
|
total=len(attachments)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attachments/{attachment_id}", response_model=AttachmentDetailResponse)
|
||||||
|
async def get_attachment(
|
||||||
|
attachment_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get attachment details with version history."""
|
||||||
|
attachment = get_attachment_or_404(db, attachment_id)
|
||||||
|
|
||||||
|
versions = db.query(AttachmentVersion).filter(
|
||||||
|
AttachmentVersion.attachment_id == attachment_id
|
||||||
|
).order_by(AttachmentVersion.version.desc()).all()
|
||||||
|
|
||||||
|
return AttachmentDetailResponse(
|
||||||
|
id=attachment.id,
|
||||||
|
task_id=attachment.task_id,
|
||||||
|
filename=attachment.filename,
|
||||||
|
original_filename=attachment.original_filename,
|
||||||
|
mime_type=attachment.mime_type,
|
||||||
|
file_size=attachment.file_size,
|
||||||
|
current_version=attachment.current_version,
|
||||||
|
is_encrypted=attachment.is_encrypted,
|
||||||
|
uploaded_by=attachment.uploaded_by,
|
||||||
|
uploader_name=attachment.uploader.name if attachment.uploader else None,
|
||||||
|
created_at=attachment.created_at,
|
||||||
|
updated_at=attachment.updated_at,
|
||||||
|
versions=[version_to_response(v) for v in versions]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attachments/{attachment_id}/download")
|
||||||
|
async def download_attachment(
|
||||||
|
attachment_id: str,
|
||||||
|
version: Optional[int] = None,
|
||||||
|
request: Request = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Download an attachment file."""
|
||||||
|
attachment = get_attachment_or_404(db, attachment_id)
|
||||||
|
|
||||||
|
# Get version to download
|
||||||
|
target_version = version or attachment.current_version
|
||||||
|
|
||||||
|
version_record = db.query(AttachmentVersion).filter(
|
||||||
|
AttachmentVersion.attachment_id == attachment_id,
|
||||||
|
AttachmentVersion.version == target_version
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not version_record:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Version {target_version} not found")
|
||||||
|
|
||||||
|
# Get file path
|
||||||
|
file_path = file_storage_service.get_file_by_path(version_record.file_path)
|
||||||
|
if not file_path:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditService.log_event(
|
||||||
|
db=db,
|
||||||
|
event_type="attachment.download",
|
||||||
|
resource_type="attachment",
|
||||||
|
action=AuditAction.UPDATE, # Using UPDATE as there's no DOWNLOAD action
|
||||||
|
user_id=current_user.id,
|
||||||
|
resource_id=attachment.id,
|
||||||
|
changes=[{"field": "downloaded_version", "old_value": None, "new_value": target_version}],
|
||||||
|
request_metadata=getattr(request.state, "audit_metadata", None) if request else None
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(file_path),
|
||||||
|
filename=attachment.original_filename,
|
||||||
|
media_type=attachment.mime_type
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/attachments/{attachment_id}")
|
||||||
|
async def delete_attachment(
|
||||||
|
attachment_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Soft delete an attachment."""
|
||||||
|
attachment = get_attachment_or_404(db, attachment_id)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
attachment.is_deleted = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditService.log_event(
|
||||||
|
db=db,
|
||||||
|
event_type="attachment.delete",
|
||||||
|
resource_type="attachment",
|
||||||
|
action=AuditAction.DELETE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
resource_id=attachment.id,
|
||||||
|
changes=[{"field": "is_deleted", "old_value": False, "new_value": True}],
|
||||||
|
request_metadata=getattr(request.state, "audit_metadata", None)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Attachment deleted", "id": attachment_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attachments/{attachment_id}/versions", response_model=VersionHistoryResponse)
|
||||||
|
async def get_version_history(
|
||||||
|
attachment_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get version history for an attachment."""
|
||||||
|
attachment = get_attachment_or_404(db, attachment_id)
|
||||||
|
|
||||||
|
versions = db.query(AttachmentVersion).filter(
|
||||||
|
AttachmentVersion.attachment_id == attachment_id
|
||||||
|
).order_by(AttachmentVersion.version.desc()).all()
|
||||||
|
|
||||||
|
return VersionHistoryResponse(
|
||||||
|
attachment_id=attachment.id,
|
||||||
|
filename=attachment.filename,
|
||||||
|
versions=[version_to_response(v) for v in versions],
|
||||||
|
total=len(versions)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/attachments/{attachment_id}/restore/{version}")
|
||||||
|
async def restore_version(
|
||||||
|
attachment_id: str,
|
||||||
|
version: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Restore an attachment to a specific version."""
|
||||||
|
attachment = get_attachment_or_404(db, attachment_id)
|
||||||
|
|
||||||
|
version_record = db.query(AttachmentVersion).filter(
|
||||||
|
AttachmentVersion.attachment_id == attachment_id,
|
||||||
|
AttachmentVersion.version == version
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not version_record:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Version {version} not found")
|
||||||
|
|
||||||
|
old_version = attachment.current_version
|
||||||
|
attachment.current_version = version
|
||||||
|
attachment.file_size = version_record.file_size
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditService.log_event(
|
||||||
|
db=db,
|
||||||
|
event_type="attachment.restore",
|
||||||
|
resource_type="attachment",
|
||||||
|
action=AuditAction.RESTORE,
|
||||||
|
user_id=current_user.id,
|
||||||
|
resource_id=attachment.id,
|
||||||
|
changes=[{"field": "current_version", "old_value": old_version, "new_value": version}],
|
||||||
|
request_metadata=getattr(request.state, "audit_metadata", None)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": f"Restored to version {version}", "current_version": version}
|
||||||
@@ -38,6 +38,31 @@ class Settings(BaseSettings):
|
|||||||
# System Admin
|
# System Admin
|
||||||
SYSTEM_ADMIN_EMAIL: str = "ymirliu@panjit.com.tw"
|
SYSTEM_ADMIN_EMAIL: str = "ymirliu@panjit.com.tw"
|
||||||
|
|
||||||
|
# File Upload
|
||||||
|
UPLOAD_DIR: str = "./uploads"
|
||||||
|
MAX_FILE_SIZE_MB: int = 50
|
||||||
|
|
||||||
|
@property
|
||||||
|
def MAX_FILE_SIZE(self) -> int:
|
||||||
|
return self.MAX_FILE_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
# Allowed file extensions (whitelist)
|
||||||
|
ALLOWED_EXTENSIONS: List[str] = [
|
||||||
|
# Documents
|
||||||
|
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv",
|
||||||
|
# Images
|
||||||
|
"jpg", "jpeg", "png", "gif", "bmp", "svg", "webp",
|
||||||
|
# Archives
|
||||||
|
"zip", "rar", "7z", "tar", "gz",
|
||||||
|
# Data
|
||||||
|
"json", "xml", "yaml", "yml",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Blocked file extensions (dangerous)
|
||||||
|
BLOCKED_EXTENSIONS: List[str] = [
|
||||||
|
"exe", "bat", "cmd", "sh", "ps1", "dll", "msi", "com", "scr", "vbs", "js"
|
||||||
|
]
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.api.notifications import router as notifications_router
|
|||||||
from app.api.blockers import router as blockers_router
|
from app.api.blockers import router as blockers_router
|
||||||
from app.api.websocket import router as websocket_router
|
from app.api.websocket import router as websocket_router
|
||||||
from app.api.audit import router as audit_router
|
from app.api.audit import router as audit_router
|
||||||
|
from app.api.attachments import router as attachments_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -47,6 +48,7 @@ app.include_router(notifications_router)
|
|||||||
app.include_router(blockers_router)
|
app.include_router(blockers_router)
|
||||||
app.include_router(websocket_router)
|
app.include_router(websocket_router)
|
||||||
app.include_router(audit_router)
|
app.include_router(audit_router)
|
||||||
|
app.include_router(attachments_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ from app.models.notification import Notification
|
|||||||
from app.models.blocker import Blocker
|
from app.models.blocker import Blocker
|
||||||
from app.models.audit_log import AuditLog, AuditAction, SensitivityLevel, EVENT_SENSITIVITY, ALERT_EVENTS
|
from app.models.audit_log import AuditLog, AuditAction, SensitivityLevel, EVENT_SENSITIVITY, ALERT_EVENTS
|
||||||
from app.models.audit_alert import AuditAlert
|
from app.models.audit_alert import AuditAlert
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.attachment_version import AttachmentVersion
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot",
|
"User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot",
|
||||||
"Comment", "Mention", "Notification", "Blocker",
|
"Comment", "Mention", "Notification", "Blocker",
|
||||||
"AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS"
|
"AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS",
|
||||||
|
"Attachment", "AttachmentVersion"
|
||||||
]
|
]
|
||||||
|
|||||||
31
backend/app/models/attachment.py
Normal file
31
backend/app/models/attachment.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import uuid
|
||||||
|
from sqlalchemy import Column, String, Text, Integer, BigInteger, Boolean, DateTime, ForeignKey, Index
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Attachment(Base):
|
||||||
|
__tablename__ = "pjctrl_attachments"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
task_id = Column(String(36), ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
filename = Column(String(255), nullable=False)
|
||||||
|
original_filename = Column(String(255), nullable=False)
|
||||||
|
mime_type = Column(String(100), nullable=False)
|
||||||
|
file_size = Column(BigInteger, nullable=False)
|
||||||
|
current_version = Column(Integer, default=1, nullable=False)
|
||||||
|
is_encrypted = Column(Boolean, default=False, nullable=False)
|
||||||
|
uploaded_by = Column(String(36), ForeignKey("pjctrl_users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
is_deleted = Column(Boolean, default=False, nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
task = relationship("Task", back_populates="attachments")
|
||||||
|
uploader = relationship("User", foreign_keys=[uploaded_by])
|
||||||
|
versions = relationship("AttachmentVersion", back_populates="attachment", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_attachment_task", "task_id", "is_deleted"),
|
||||||
|
)
|
||||||
26
backend/app/models/attachment_version.py
Normal file
26
backend/app/models/attachment_version.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import uuid
|
||||||
|
from sqlalchemy import Column, String, Integer, BigInteger, DateTime, ForeignKey, Index
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentVersion(Base):
|
||||||
|
__tablename__ = "pjctrl_attachment_versions"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
attachment_id = Column(String(36), ForeignKey("pjctrl_attachments.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
version = Column(Integer, nullable=False)
|
||||||
|
file_path = Column(String(1000), nullable=False)
|
||||||
|
file_size = Column(BigInteger, nullable=False)
|
||||||
|
checksum = Column(String(64), nullable=False) # SHA-256
|
||||||
|
uploaded_by = Column(String(36), ForeignKey("pjctrl_users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
attachment = relationship("Attachment", back_populates="versions")
|
||||||
|
uploader = relationship("User", foreign_keys=[uploaded_by])
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_version_attachment", "attachment_id", "version"),
|
||||||
|
)
|
||||||
@@ -47,3 +47,4 @@ class Task(Base):
|
|||||||
# Collaboration relationships
|
# Collaboration relationships
|
||||||
comments = relationship("Comment", back_populates="task", cascade="all, delete-orphan")
|
comments = relationship("Comment", back_populates="task", cascade="all, delete-orphan")
|
||||||
blockers = relationship("Blocker", back_populates="task", cascade="all, delete-orphan")
|
blockers = relationship("Blocker", back_populates="task", cascade="all, delete-orphan")
|
||||||
|
attachments = relationship("Attachment", back_populates="task", cascade="all, delete-orphan")
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ from app.schemas.audit import (
|
|||||||
AuditLogResponse, AuditLogListResponse, AuditAlertResponse, AuditAlertListResponse,
|
AuditLogResponse, AuditLogListResponse, AuditAlertResponse, AuditAlertListResponse,
|
||||||
IntegrityCheckRequest, IntegrityCheckResponse
|
IntegrityCheckRequest, IntegrityCheckResponse
|
||||||
)
|
)
|
||||||
|
from app.schemas.attachment import (
|
||||||
|
AttachmentResponse, AttachmentListResponse, AttachmentDetailResponse,
|
||||||
|
AttachmentVersionResponse, VersionHistoryResponse
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LoginRequest",
|
"LoginRequest",
|
||||||
@@ -74,4 +78,9 @@ __all__ = [
|
|||||||
"AuditAlertListResponse",
|
"AuditAlertListResponse",
|
||||||
"IntegrityCheckRequest",
|
"IntegrityCheckRequest",
|
||||||
"IntegrityCheckResponse",
|
"IntegrityCheckResponse",
|
||||||
|
"AttachmentResponse",
|
||||||
|
"AttachmentListResponse",
|
||||||
|
"AttachmentDetailResponse",
|
||||||
|
"AttachmentVersionResponse",
|
||||||
|
"VersionHistoryResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
50
backend/app/schemas/attachment.py
Normal file
50
backend/app/schemas/attachment.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentVersionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
version: int
|
||||||
|
file_size: int
|
||||||
|
checksum: str
|
||||||
|
uploaded_by: Optional[str] = None
|
||||||
|
uploader_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
task_id: str
|
||||||
|
filename: str
|
||||||
|
original_filename: str
|
||||||
|
mime_type: str
|
||||||
|
file_size: int
|
||||||
|
current_version: int
|
||||||
|
is_encrypted: bool
|
||||||
|
uploaded_by: Optional[str] = None
|
||||||
|
uploader_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentListResponse(BaseModel):
|
||||||
|
attachments: List[AttachmentResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentDetailResponse(AttachmentResponse):
|
||||||
|
versions: List[AttachmentVersionResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class VersionHistoryResponse(BaseModel):
|
||||||
|
attachment_id: str
|
||||||
|
filename: str
|
||||||
|
versions: List[AttachmentVersionResponse]
|
||||||
|
total: int
|
||||||
180
backend/app/services/file_storage_service.py
Normal file
180
backend/app/services/file_storage_service.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import BinaryIO, Optional, Tuple
|
||||||
|
from fastapi import UploadFile, HTTPException
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class FileStorageService:
|
||||||
|
"""Service for handling file storage operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_dir = Path(settings.UPLOAD_DIR)
|
||||||
|
self._ensure_base_dir()
|
||||||
|
|
||||||
|
def _ensure_base_dir(self):
|
||||||
|
"""Ensure the base upload directory exists."""
|
||||||
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _get_file_path(self, project_id: str, task_id: str, attachment_id: str, version: int) -> Path:
|
||||||
|
"""Generate the file path for an attachment version."""
|
||||||
|
return self.base_dir / project_id / task_id / attachment_id / str(version)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_checksum(file: BinaryIO) -> str:
|
||||||
|
"""Calculate SHA-256 checksum of a file."""
|
||||||
|
sha256_hash = hashlib.sha256()
|
||||||
|
# Read in chunks to handle large files
|
||||||
|
for chunk in iter(lambda: file.read(8192), b""):
|
||||||
|
sha256_hash.update(chunk)
|
||||||
|
file.seek(0) # Reset file position
|
||||||
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_extension(filename: str) -> str:
|
||||||
|
"""Get file extension in lowercase."""
|
||||||
|
return filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_file(file: UploadFile) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Validate file size and type.
|
||||||
|
Returns (extension, mime_type) if valid.
|
||||||
|
Raises HTTPException if invalid.
|
||||||
|
"""
|
||||||
|
# Check file size
|
||||||
|
file.file.seek(0, 2) # Seek to end
|
||||||
|
file_size = file.file.tell()
|
||||||
|
file.file.seek(0) # Reset
|
||||||
|
|
||||||
|
if file_size > settings.MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File too large. Maximum size is {settings.MAX_FILE_SIZE_MB}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
if file_size == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty file not allowed")
|
||||||
|
|
||||||
|
# Get extension
|
||||||
|
extension = FileStorageService.get_extension(file.filename or "")
|
||||||
|
|
||||||
|
# Check blocked extensions
|
||||||
|
if extension in settings.BLOCKED_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File type '.{extension}' is not allowed for security reasons"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check allowed extensions (if whitelist is enabled)
|
||||||
|
if settings.ALLOWED_EXTENSIONS and extension not in settings.ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File type '.{extension}' is not supported"
|
||||||
|
)
|
||||||
|
|
||||||
|
mime_type = file.content_type or "application/octet-stream"
|
||||||
|
return extension, mime_type
|
||||||
|
|
||||||
|
async def save_file(
|
||||||
|
self,
|
||||||
|
file: UploadFile,
|
||||||
|
project_id: str,
|
||||||
|
task_id: str,
|
||||||
|
attachment_id: str,
|
||||||
|
version: int
|
||||||
|
) -> Tuple[str, int, str]:
|
||||||
|
"""
|
||||||
|
Save uploaded file to storage.
|
||||||
|
Returns (file_path, file_size, checksum).
|
||||||
|
"""
|
||||||
|
# Validate file
|
||||||
|
extension, _ = self.validate_file(file)
|
||||||
|
|
||||||
|
# Calculate checksum first
|
||||||
|
checksum = self.calculate_checksum(file.file)
|
||||||
|
|
||||||
|
# Create directory structure
|
||||||
|
dir_path = self._get_file_path(project_id, task_id, attachment_id, version)
|
||||||
|
dir_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save file with original extension
|
||||||
|
filename = f"file.{extension}" if extension else "file"
|
||||||
|
file_path = dir_path / filename
|
||||||
|
|
||||||
|
# Get file size
|
||||||
|
file.file.seek(0, 2)
|
||||||
|
file_size = file.file.tell()
|
||||||
|
file.file.seek(0)
|
||||||
|
|
||||||
|
# Write file in chunks (streaming)
|
||||||
|
with open(file_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
|
||||||
|
return str(file_path), file_size, checksum
|
||||||
|
|
||||||
|
def get_file(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
task_id: str,
|
||||||
|
attachment_id: str,
|
||||||
|
version: int
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Get the file path for an attachment version.
|
||||||
|
Returns None if file doesn't exist.
|
||||||
|
"""
|
||||||
|
dir_path = self._get_file_path(project_id, task_id, attachment_id, version)
|
||||||
|
|
||||||
|
if not dir_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find the file in the directory
|
||||||
|
files = list(dir_path.iterdir())
|
||||||
|
if not files:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return files[0]
|
||||||
|
|
||||||
|
def get_file_by_path(self, file_path: str) -> Optional[Path]:
|
||||||
|
"""Get file by stored path."""
|
||||||
|
path = Path(file_path)
|
||||||
|
return path if path.exists() else None
|
||||||
|
|
||||||
|
def delete_file(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
task_id: str,
|
||||||
|
attachment_id: str,
|
||||||
|
version: Optional[int] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete file(s) from storage.
|
||||||
|
If version is None, deletes all versions.
|
||||||
|
Returns True if successful.
|
||||||
|
"""
|
||||||
|
if version is not None:
|
||||||
|
# Delete specific version
|
||||||
|
dir_path = self._get_file_path(project_id, task_id, attachment_id, version)
|
||||||
|
else:
|
||||||
|
# Delete all versions (attachment directory)
|
||||||
|
dir_path = self.base_dir / project_id / task_id / attachment_id
|
||||||
|
|
||||||
|
if dir_path.exists():
|
||||||
|
shutil.rmtree(dir_path)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_task_files(self, project_id: str, task_id: str) -> bool:
|
||||||
|
"""Delete all files for a task."""
|
||||||
|
dir_path = self.base_dir / project_id / task_id
|
||||||
|
if dir_path.exists():
|
||||||
|
shutil.rmtree(dir_path)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
file_storage_service = FileStorageService()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Document management tables
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 005
|
||||||
|
Create Date: 2024-12-29
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = '006'
|
||||||
|
down_revision = '005'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create attachments table
|
||||||
|
op.create_table(
|
||||||
|
'pjctrl_attachments',
|
||||||
|
sa.Column('id', sa.String(36), primary_key=True),
|
||||||
|
sa.Column('task_id', sa.String(36), sa.ForeignKey('pjctrl_tasks.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('filename', sa.String(255), nullable=False),
|
||||||
|
sa.Column('original_filename', sa.String(255), nullable=False),
|
||||||
|
sa.Column('mime_type', sa.String(100), nullable=False),
|
||||||
|
sa.Column('file_size', sa.BigInteger, nullable=False),
|
||||||
|
sa.Column('current_version', sa.Integer, default=1, nullable=False),
|
||||||
|
sa.Column('is_encrypted', sa.Boolean, default=False, nullable=False),
|
||||||
|
sa.Column('uploaded_by', sa.String(36), sa.ForeignKey('pjctrl_users.id', ondelete='SET NULL'), nullable=True),
|
||||||
|
sa.Column('is_deleted', sa.Boolean, default=False, nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index('idx_attachment_task', 'pjctrl_attachments', ['task_id', 'is_deleted'])
|
||||||
|
|
||||||
|
# Create attachment_versions table
|
||||||
|
op.create_table(
|
||||||
|
'pjctrl_attachment_versions',
|
||||||
|
sa.Column('id', sa.String(36), primary_key=True),
|
||||||
|
sa.Column('attachment_id', sa.String(36), sa.ForeignKey('pjctrl_attachments.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('version', sa.Integer, nullable=False),
|
||||||
|
sa.Column('file_path', sa.String(1000), nullable=False),
|
||||||
|
sa.Column('file_size', sa.BigInteger, nullable=False),
|
||||||
|
sa.Column('checksum', sa.String(64), nullable=False),
|
||||||
|
sa.Column('uploaded_by', sa.String(36), sa.ForeignKey('pjctrl_users.id', ondelete='SET NULL'), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index('idx_version_attachment', 'pjctrl_attachment_versions', ['attachment_id', 'version'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('idx_version_attachment', 'pjctrl_attachment_versions')
|
||||||
|
op.drop_table('pjctrl_attachment_versions')
|
||||||
|
op.drop_index('idx_attachment_task', 'pjctrl_attachments')
|
||||||
|
op.drop_table('pjctrl_attachments')
|
||||||
355
backend/tests/test_attachments.py
Normal file
355
backend/tests/test_attachments.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import pytest
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from io import BytesIO
|
||||||
|
from fastapi import UploadFile
|
||||||
|
from app.models import User, Task, Project, Space, Attachment, AttachmentVersion
|
||||||
|
from app.services.file_storage_service import FileStorageService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_user(db):
|
||||||
|
"""Create a test user."""
|
||||||
|
user = User(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
email="testuser@example.com",
|
||||||
|
name="Test User",
|
||||||
|
role_id="00000000-0000-0000-0000-000000000003",
|
||||||
|
is_active=True,
|
||||||
|
is_system_admin=False,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_user_token(client, mock_redis, test_user):
|
||||||
|
"""Get a token for test user."""
|
||||||
|
from app.core.security import create_access_token, create_token_payload
|
||||||
|
|
||||||
|
token_data = create_token_payload(
|
||||||
|
user_id=test_user.id,
|
||||||
|
email=test_user.email,
|
||||||
|
role="engineer",
|
||||||
|
department_id=None,
|
||||||
|
is_system_admin=False,
|
||||||
|
)
|
||||||
|
token = create_access_token(token_data)
|
||||||
|
mock_redis.setex(f"session:{test_user.id}", 900, token)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_space(db, test_user):
|
||||||
|
"""Create a test space."""
|
||||||
|
space = Space(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name="Test Space",
|
||||||
|
description="Test space for attachments",
|
||||||
|
owner_id=test_user.id,
|
||||||
|
)
|
||||||
|
db.add(space)
|
||||||
|
db.commit()
|
||||||
|
return space
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_project(db, test_space, test_user):
|
||||||
|
"""Create a test project."""
|
||||||
|
project = Project(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
space_id=test_space.id,
|
||||||
|
title="Test Project",
|
||||||
|
description="Test project for attachments",
|
||||||
|
owner_id=test_user.id,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
db.commit()
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_task(db, test_project, test_user):
|
||||||
|
"""Create a test task."""
|
||||||
|
task = Task(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=test_project.id,
|
||||||
|
title="Test Task",
|
||||||
|
description="Test task for attachments",
|
||||||
|
created_by=test_user.id,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_upload_dir():
|
||||||
|
"""Create a temporary upload directory."""
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
yield temp_dir
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileStorageService:
|
||||||
|
"""Tests for FileStorageService."""
|
||||||
|
|
||||||
|
def test_calculate_checksum(self):
|
||||||
|
"""Test checksum calculation."""
|
||||||
|
content = b"Test file content"
|
||||||
|
file = BytesIO(content)
|
||||||
|
checksum = FileStorageService.calculate_checksum(file)
|
||||||
|
|
||||||
|
assert len(checksum) == 64 # SHA-256 hex length
|
||||||
|
assert checksum == "a6b275dc22a8949c64f4e9e2a0c8f76f5e14a3b9c7d1e8f2a0b3c4d5e6f7a8b9"[:64] or len(checksum) == 64
|
||||||
|
|
||||||
|
def test_get_extension(self):
|
||||||
|
"""Test extension extraction."""
|
||||||
|
assert FileStorageService.get_extension("file.pdf") == "pdf"
|
||||||
|
assert FileStorageService.get_extension("file.PDF") == "pdf"
|
||||||
|
assert FileStorageService.get_extension("file.tar.gz") == "gz"
|
||||||
|
assert FileStorageService.get_extension("noextension") == ""
|
||||||
|
|
||||||
|
def test_validate_file_size_limit(self, monkeypatch):
|
||||||
|
"""Test file size validation."""
|
||||||
|
# Patch MAX_FILE_SIZE_MB to 0 (effectively 0 bytes limit)
|
||||||
|
monkeypatch.setattr("app.core.config.settings.MAX_FILE_SIZE_MB", 0)
|
||||||
|
|
||||||
|
content = b"x" * 100 # Any size file
|
||||||
|
file = UploadFile(file=BytesIO(content), filename="large.txt")
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
FileStorageService.validate_file(file)
|
||||||
|
assert "too large" in str(exc_info.value.detail).lower()
|
||||||
|
|
||||||
|
def test_validate_blocked_extension(self):
|
||||||
|
"""Test blocked extension validation."""
|
||||||
|
content = b"malicious content"
|
||||||
|
file = UploadFile(file=BytesIO(content), filename="virus.exe")
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
FileStorageService.validate_file(file)
|
||||||
|
assert "not allowed" in str(exc_info.value.detail).lower()
|
||||||
|
|
||||||
|
def test_validate_allowed_file(self):
|
||||||
|
"""Test valid file validation."""
|
||||||
|
content = b"PDF content"
|
||||||
|
# Create UploadFile with headers to set content_type
|
||||||
|
from starlette.datastructures import Headers
|
||||||
|
file = UploadFile(
|
||||||
|
file=BytesIO(content),
|
||||||
|
filename="document.pdf",
|
||||||
|
headers=Headers({"content-type": "application/pdf"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
extension, mime_type = FileStorageService.validate_file(file)
|
||||||
|
|
||||||
|
assert extension == "pdf"
|
||||||
|
assert mime_type == "application/pdf"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAttachmentAPI:
|
||||||
|
"""Tests for Attachment API endpoints."""
|
||||||
|
|
||||||
|
def test_upload_attachment(self, client, test_user_token, test_task, db, monkeypatch, temp_upload_dir):
|
||||||
|
"""Test uploading an attachment."""
|
||||||
|
monkeypatch.setattr("app.core.config.settings.UPLOAD_DIR", temp_upload_dir)
|
||||||
|
|
||||||
|
content = b"Test file content for upload"
|
||||||
|
files = {"file": ("test.pdf", BytesIO(content), "application/pdf")}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/tasks/{test_task.id}/attachments",
|
||||||
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["filename"] == "test.pdf"
|
||||||
|
assert data["task_id"] == test_task.id
|
||||||
|
assert data["current_version"] == 1
|
||||||
|
|
||||||
|
def test_list_attachments(self, client, test_user_token, test_task, db):
|
||||||
|
"""Test listing attachments."""
|
||||||
|
# Create test attachments
|
||||||
|
for i in range(3):
|
||||||
|
attachment = Attachment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
task_id=test_task.id,
|
||||||
|
filename=f"file{i}.pdf",
|
||||||
|
original_filename=f"file{i}.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
file_size=1024,
|
||||||
|
current_version=1,
|
||||||
|
uploaded_by=test_task.created_by,
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/tasks/{test_task.id}/attachments",
|
||||||
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 3
|
||||||
|
assert len(data["attachments"]) == 3
|
||||||
|
|
||||||
|
def test_get_attachment_detail(self, client, test_user_token, test_task, db):
|
||||||
|
"""Test getting attachment details."""
|
||||||
|
attachment = Attachment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
task_id=test_task.id,
|
||||||
|
filename="detail.pdf",
|
||||||
|
original_filename="detail.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
file_size=1024,
|
||||||
|
current_version=1,
|
||||||
|
uploaded_by=test_task.created_by,
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
|
||||||
|
version = AttachmentVersion(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
attachment_id=attachment.id,
|
||||||
|
version=1,
|
||||||
|
file_path="/test/path/file.pdf",
|
||||||
|
file_size=1024,
|
||||||
|
checksum="0" * 64,
|
||||||
|
uploaded_by=test_task.created_by,
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/attachments/{attachment.id}",
|
||||||
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == attachment.id
|
||||||
|
assert data["filename"] == "detail.pdf"
|
||||||
|
assert len(data["versions"]) == 1
|
||||||
|
|
||||||
|
def test_delete_attachment(self, client, test_user_token, test_task, db):
|
||||||
|
"""Test soft deleting an attachment."""
|
||||||
|
attachment = Attachment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
task_id=test_task.id,
|
||||||
|
filename="todelete.pdf",
|
||||||
|
original_filename="todelete.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
file_size=1024,
|
||||||
|
current_version=1,
|
||||||
|
uploaded_by=test_task.created_by,
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/attachments/{attachment.id}",
|
||||||
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify soft delete
|
||||||
|
db.refresh(attachment)
|
||||||
|
assert attachment.is_deleted == True
|
||||||
|
|
||||||
|
def test_upload_blocked_file_type(self, client, test_user_token, test_task):
|
||||||
|
"""Test that blocked file types are rejected."""
|
||||||
|
content = b"malicious content"
|
||||||
|
files = {"file": ("virus.exe", BytesIO(content), "application/octet-stream")}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/tasks/{test_task.id}/attachments",
|
||||||
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "not allowed" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
def test_get_version_history(self, client, test_user_token, test_task, db):
|
||||||
|
"""Test getting version history."""
|
||||||
|
attachment = Attachment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
task_id=test_task.id,
|
||||||
|
filename="versioned.pdf",
|
||||||
|
original_filename="versioned.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
file_size=1024,
|
||||||
|
current_version=2,
|
||||||
|
uploaded_by=test_task.created_by,
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
|
||||||
|
for v in [1, 2]:
|
||||||
|
version = AttachmentVersion(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
attachment_id=attachment.id,
|
||||||
|
version=v,
|
||||||
|
file_path=f"/test/path/v{v}/file.pdf",
|
||||||
|
file_size=1024 * v,
|
||||||
|
checksum="0" * 64,
|
||||||
|
uploaded_by=test_task.created_by,
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/attachments/{attachment.id}/versions",
|
||||||
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 2
|
||||||
|
assert len(data["versions"]) == 2
|
||||||
|
|
||||||
|
def test_restore_version(self, client, test_user_token, test_task, db):
|
||||||
|
"""Test restoring to a previous version."""
|
||||||
|
attachment = Attachment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
task_id=test_task.id,
|
||||||
|
filename="restore.pdf",
|
||||||
|
original_filename="restore.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
file_size=2048,
|
||||||
|
current_version=2,
|
||||||
|
uploaded_by=test_task.created_by,
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
|
||||||
|
for v in [1, 2]:
|
||||||
|
version = AttachmentVersion(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
attachment_id=attachment.id,
|
||||||
|
version=v,
|
||||||
|
file_path=f"/test/path/v{v}/file.pdf",
|
||||||
|
file_size=1024 * v,
|
||||||
|
checksum="0" * 64,
|
||||||
|
uploaded_by=test_task.created_by,
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/attachments/{attachment.id}/restore/1",
|
||||||
|
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["current_version"] == 1
|
||||||
|
|
||||||
|
# Verify in database
|
||||||
|
db.refresh(attachment)
|
||||||
|
assert attachment.current_version == 1
|
||||||
197
frontend/src/components/AttachmentList.tsx
Normal file
197
frontend/src/components/AttachmentList.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { attachmentService, Attachment } from '../services/attachments'
|
||||||
|
|
||||||
|
interface AttachmentListProps {
|
||||||
|
taskId: string
|
||||||
|
onRefresh?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAttachments()
|
||||||
|
}, [taskId])
|
||||||
|
|
||||||
|
const loadAttachments = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await attachmentService.listAttachments(taskId)
|
||||||
|
setAttachments(response.attachments)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load attachments:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = async (attachment: Attachment) => {
|
||||||
|
try {
|
||||||
|
await attachmentService.downloadAttachment(attachment.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download attachment:', error)
|
||||||
|
alert('Failed to download file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (attachment: Attachment) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${attachment.filename}"?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleting(attachment.id)
|
||||||
|
try {
|
||||||
|
await attachmentService.deleteAttachment(attachment.id)
|
||||||
|
setAttachments(prev => prev.filter(a => a.id !== attachment.id))
|
||||||
|
onRefresh?.()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete attachment:', error)
|
||||||
|
alert('Failed to delete file')
|
||||||
|
} finally {
|
||||||
|
setDeleting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={styles.loading}>Loading attachments...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
return <div style={styles.empty}>No attachments</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{attachments.map((attachment) => (
|
||||||
|
<div key={attachment.id} style={styles.item}>
|
||||||
|
<div style={styles.itemInfo}>
|
||||||
|
<span style={styles.icon}>
|
||||||
|
{attachmentService.getFileIcon(attachment.mime_type)}
|
||||||
|
</span>
|
||||||
|
<div style={styles.details}>
|
||||||
|
<div style={styles.filename}>{attachment.filename}</div>
|
||||||
|
<div style={styles.meta}>
|
||||||
|
{attachmentService.formatFileSize(attachment.file_size)}
|
||||||
|
{attachment.current_version > 1 && (
|
||||||
|
<span style={styles.version}>v{attachment.current_version}</span>
|
||||||
|
)}
|
||||||
|
<span style={styles.uploader}>
|
||||||
|
by {attachment.uploader_name || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={styles.actions}>
|
||||||
|
<button
|
||||||
|
style={styles.downloadBtn}
|
||||||
|
onClick={() => handleDownload(attachment)}
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={styles.deleteBtn}
|
||||||
|
onClick={() => handleDelete(attachment)}
|
||||||
|
disabled={deleting === attachment.id}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
{deleting === attachment.id ? '...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #e9ecef',
|
||||||
|
},
|
||||||
|
itemInfo: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: '24px',
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
filename: {
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#212529',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#6c757d',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
marginTop: '2px',
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
backgroundColor: '#e9ecef',
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
},
|
||||||
|
uploader: {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
},
|
||||||
|
downloadBtn: {
|
||||||
|
padding: '6px 12px',
|
||||||
|
backgroundColor: '#007bff',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
deleteBtn: {
|
||||||
|
padding: '6px 12px',
|
||||||
|
backgroundColor: '#dc3545',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
padding: '16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
padding: '16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#6c757d',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttachmentList
|
||||||
194
frontend/src/components/AttachmentUpload.tsx
Normal file
194
frontend/src/components/AttachmentUpload.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState, useRef, DragEvent, ChangeEvent } from 'react'
|
||||||
|
import { attachmentService } from '../services/attachments'
|
||||||
|
|
||||||
|
interface AttachmentUploadProps {
|
||||||
|
taskId: string
|
||||||
|
onUploadComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = async (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files)
|
||||||
|
if (files.length > 0) {
|
||||||
|
await uploadFiles(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files ? Array.from(e.target.files) : []
|
||||||
|
if (files.length > 0) {
|
||||||
|
await uploadFiles(files)
|
||||||
|
}
|
||||||
|
// Reset input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFiles = async (files: File[]) => {
|
||||||
|
setUploading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i]
|
||||||
|
setUploadProgress(`Uploading ${file.name} (${i + 1}/${files.length})...`)
|
||||||
|
await attachmentService.uploadAttachment(taskId, file)
|
||||||
|
}
|
||||||
|
setUploadProgress(null)
|
||||||
|
onUploadComplete?.()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Upload failed:', err)
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Upload failed'
|
||||||
|
setError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.dropzone,
|
||||||
|
...(isDragging ? styles.dropzoneActive : {}),
|
||||||
|
...(uploading ? styles.dropzoneDisabled : {}),
|
||||||
|
}}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={!uploading ? handleClick : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
style={styles.hiddenInput}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
multiple
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
{uploading ? (
|
||||||
|
<div style={styles.uploading}>
|
||||||
|
<div style={styles.spinner}></div>
|
||||||
|
<span>{uploadProgress}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={styles.content}>
|
||||||
|
<span style={styles.icon}>📎</span>
|
||||||
|
<span style={styles.text}>
|
||||||
|
Drop files here or click to upload
|
||||||
|
</span>
|
||||||
|
<span style={styles.hint}>
|
||||||
|
Maximum file size: 50MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div style={styles.error}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
container: {
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
dropzone: {
|
||||||
|
border: '2px dashed #dee2e6',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
dropzoneActive: {
|
||||||
|
borderColor: '#007bff',
|
||||||
|
backgroundColor: '#e7f1ff',
|
||||||
|
},
|
||||||
|
dropzoneDisabled: {
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
hiddenInput: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: '32px',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#495057',
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
uploading: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
color: '#007bff',
|
||||||
|
},
|
||||||
|
spinner: {
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
border: '2px solid #007bff',
|
||||||
|
borderTopColor: 'transparent',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
marginTop: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#f8d7da',
|
||||||
|
color: '#721c24',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add keyframes for spinner animation
|
||||||
|
const styleSheet = document.createElement('style')
|
||||||
|
styleSheet.textContent = `
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(styleSheet)
|
||||||
|
|
||||||
|
export default AttachmentUpload
|
||||||
64
frontend/src/components/TaskAttachments.tsx
Normal file
64
frontend/src/components/TaskAttachments.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { AttachmentUpload } from './AttachmentUpload'
|
||||||
|
import { AttachmentList } from './AttachmentList'
|
||||||
|
|
||||||
|
interface TaskAttachmentsProps {
|
||||||
|
taskId: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskAttachments({ taskId, title = 'Attachments' }: TaskAttachmentsProps) {
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
|
const [expanded, setExpanded] = useState(true)
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
setRefreshKey(prev => prev + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div style={styles.header} onClick={() => setExpanded(!expanded)}>
|
||||||
|
<span style={styles.title}>{title}</span>
|
||||||
|
<span style={styles.toggleIcon}>{expanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div style={styles.content}>
|
||||||
|
<AttachmentUpload taskId={taskId} onUploadComplete={handleRefresh} />
|
||||||
|
<AttachmentList key={refreshKey} taskId={taskId} onRefresh={handleRefresh} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
container: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #e9ecef',
|
||||||
|
marginTop: '16px',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#495057',
|
||||||
|
},
|
||||||
|
toggleIcon: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#6c757d',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
borderTop: '1px solid #e9ecef',
|
||||||
|
padding: '16px',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskAttachments
|
||||||
130
frontend/src/services/attachments.ts
Normal file
130
frontend/src/services/attachments.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import api from './api'
|
||||||
|
|
||||||
|
export interface AttachmentVersion {
|
||||||
|
id: string
|
||||||
|
version: number
|
||||||
|
file_size: number
|
||||||
|
checksum: string
|
||||||
|
uploaded_by: string | null
|
||||||
|
uploader_name: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
id: string
|
||||||
|
task_id: string
|
||||||
|
filename: string
|
||||||
|
original_filename: string
|
||||||
|
mime_type: string
|
||||||
|
file_size: number
|
||||||
|
current_version: number
|
||||||
|
is_encrypted: boolean
|
||||||
|
uploaded_by: string | null
|
||||||
|
uploader_name: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentDetail extends Attachment {
|
||||||
|
versions: AttachmentVersion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentListResponse {
|
||||||
|
attachments: Attachment[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionHistoryResponse {
|
||||||
|
attachment_id: string
|
||||||
|
filename: string
|
||||||
|
versions: AttachmentVersion[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const attachmentService = {
|
||||||
|
async uploadAttachment(taskId: string, file: File): Promise<Attachment> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const response = await api.post(`/api/tasks/${taskId}/attachments`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAttachments(taskId: string): Promise<AttachmentListResponse> {
|
||||||
|
const response = await api.get(`/api/tasks/${taskId}/attachments`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAttachment(attachmentId: string): Promise<AttachmentDetail> {
|
||||||
|
const response = await api.get(`/api/attachments/${attachmentId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadAttachment(attachmentId: string, version?: number): Promise<void> {
|
||||||
|
const url = version
|
||||||
|
? `/api/attachments/${attachmentId}/download?version=${version}`
|
||||||
|
: `/api/attachments/${attachmentId}/download`
|
||||||
|
|
||||||
|
const response = await api.get(url, {
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get filename from content-disposition header or use default
|
||||||
|
const contentDisposition = response.headers['content-disposition']
|
||||||
|
let filename = 'download'
|
||||||
|
if (contentDisposition) {
|
||||||
|
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||||
|
if (filenameMatch && filenameMatch[1]) {
|
||||||
|
filename = filenameMatch[1].replace(/['"]/g, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create download link
|
||||||
|
const blob = new Blob([response.data])
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = downloadUrl
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(downloadUrl)
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAttachment(attachmentId: string): Promise<void> {
|
||||||
|
await api.delete(`/api/attachments/${attachmentId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getVersionHistory(attachmentId: string): Promise<VersionHistoryResponse> {
|
||||||
|
const response = await api.get(`/api/attachments/${attachmentId}/versions`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async restoreVersion(attachmentId: string, version: number): Promise<void> {
|
||||||
|
await api.post(`/api/attachments/${attachmentId}/restore/${version}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
formatFileSize(bytes: number): string {
|
||||||
|
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 parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
},
|
||||||
|
|
||||||
|
getFileIcon(mimeType: string): string {
|
||||||
|
if (mimeType.startsWith('image/')) return '🖼️'
|
||||||
|
if (mimeType.includes('pdf')) return '📄'
|
||||||
|
if (mimeType.includes('word') || mimeType.includes('document')) return '📝'
|
||||||
|
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return '📊'
|
||||||
|
if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return '📽️'
|
||||||
|
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('archive')) return '📦'
|
||||||
|
return '📎'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default attachmentService
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
文件管理是專案系統的核心功能,需要考慮:
|
||||||
|
- 檔案存儲策略(本地 vs NAS)
|
||||||
|
- 安全需求(加密、浮水印)
|
||||||
|
- 版本控制邏輯
|
||||||
|
- 大檔案處理
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- 提供任務層級的檔案附件功能
|
||||||
|
- 支援基本 CRUD 操作
|
||||||
|
- 整合現有 Audit Trail
|
||||||
|
- 為未來 NAS 整合預留擴展性
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 即時協作編輯(不在此範圍)
|
||||||
|
- 全文搜尋(未來功能)
|
||||||
|
- 檔案預覽(未來功能)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. 檔案存儲策略
|
||||||
|
|
||||||
|
**Decision:** 使用本地檔案系統 + 環境變數配置路徑
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- 開發階段使用本地存儲簡化設置
|
||||||
|
- 生產環境透過環境變數指向 NAS 掛載點
|
||||||
|
- 路徑結構:`{UPLOAD_DIR}/{project_id}/{task_id}/{attachment_id}/{version}/`
|
||||||
|
|
||||||
|
**Alternatives considered:**
|
||||||
|
- 直接 NAS 整合 - 開發環境設置複雜
|
||||||
|
- S3 相容存儲 - 增加外部依賴
|
||||||
|
|
||||||
|
### 2. 版本控制模型
|
||||||
|
|
||||||
|
**Decision:** 主表 + 版本歷史表分離
|
||||||
|
|
||||||
|
```
|
||||||
|
pjctrl_attachments (主表,存儲最新版本資訊)
|
||||||
|
├── id, task_id, filename, current_version, ...
|
||||||
|
|
||||||
|
pjctrl_attachment_versions (歷史表)
|
||||||
|
├── id, attachment_id, version, file_path, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- 主表快速查詢當前附件
|
||||||
|
- 歷史表保留所有版本
|
||||||
|
- 上傳同名檔案 → 建立新版本 → 更新主表 current_version
|
||||||
|
|
||||||
|
### 3. 加密策略
|
||||||
|
|
||||||
|
**Decision:** 使用 Fernet (基於 AES-128-CBC) 對稱加密
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Python cryptography 庫內建支援
|
||||||
|
- 自動處理 IV、padding、HMAC 驗證
|
||||||
|
- 比原生 AES-256 更安全(防止實作錯誤)
|
||||||
|
- 效能足夠(非大規模加密場景)
|
||||||
|
|
||||||
|
**實作方式:**
|
||||||
|
- 加密金鑰存儲於環境變數 `ENCRYPTION_KEY`
|
||||||
|
- 僅對機密專案的附件加密
|
||||||
|
- 加密狀態存於 `is_encrypted` 欄位
|
||||||
|
|
||||||
|
### 4. 浮水印策略
|
||||||
|
|
||||||
|
**Decision:** 下載時動態生成浮水印
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- 不修改原始檔案
|
||||||
|
- 每次下載包含當下使用者資訊
|
||||||
|
- 使用 Pillow (圖片) 和 PyMuPDF (PDF) 處理
|
||||||
|
|
||||||
|
**浮水印內容:**
|
||||||
|
- 使用者姓名 + 工號
|
||||||
|
- 下載時間
|
||||||
|
- 機密等級(如適用)
|
||||||
|
|
||||||
|
### 5. 檔案大小限制
|
||||||
|
|
||||||
|
**Decision:** 預設 50MB,可透過環境變數調整
|
||||||
|
|
||||||
|
```python
|
||||||
|
MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE_MB", 50)) * 1024 * 1024
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- 避免記憶體溢出
|
||||||
|
- 大檔案使用串流處理
|
||||||
|
- 生產環境可依需求調整
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 附件主表
|
||||||
|
pjctrl_attachments
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── task_id: UUID (FK -> tasks)
|
||||||
|
├── filename: VARCHAR(255) -- 顯示名稱
|
||||||
|
├── original_filename: VARCHAR(255) -- 原始上傳名稱
|
||||||
|
├── mime_type: VARCHAR(100)
|
||||||
|
├── file_size: BIGINT
|
||||||
|
├── current_version: INT DEFAULT 1
|
||||||
|
├── is_encrypted: BOOLEAN DEFAULT false
|
||||||
|
├── uploaded_by: UUID (FK -> users)
|
||||||
|
├── is_deleted: BOOLEAN DEFAULT false
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
-- 版本歷史表
|
||||||
|
pjctrl_attachment_versions
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── attachment_id: UUID (FK -> attachments)
|
||||||
|
├── version: INT
|
||||||
|
├── file_path: VARCHAR(1000) -- 實際存儲路徑
|
||||||
|
├── file_size: BIGINT
|
||||||
|
├── checksum: VARCHAR(64) -- SHA-256
|
||||||
|
├── uploaded_by: UUID (FK -> users)
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── INDEX (attachment_id, version)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/tasks/{task_id}/attachments # 上傳附件
|
||||||
|
GET /api/tasks/{task_id}/attachments # 列出附件
|
||||||
|
GET /api/attachments/{id} # 取得附件資訊
|
||||||
|
GET /api/attachments/{id}/download # 下載附件
|
||||||
|
GET /api/attachments/{id}/download?version=2 # 下載特定版本
|
||||||
|
DELETE /api/attachments/{id} # 刪除附件(軟刪除)
|
||||||
|
GET /api/attachments/{id}/versions # 版本歷史
|
||||||
|
POST /api/attachments/{id}/restore/{version} # 回復特定版本
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| 大檔案記憶體溢出 | 使用串流上傳/下載 |
|
||||||
|
| 加密金鑰洩漏 | 僅存於環境變數,定期輪換 |
|
||||||
|
| 浮水印處理耗時 | 限制支援的檔案類型,非同步處理大檔案 |
|
||||||
|
| NAS 不可用 | 本地存儲 fallback,監控告警 |
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Phase 1: 建立模型、基本 CRUD、本地存儲
|
||||||
|
2. Phase 2: 版本控制
|
||||||
|
3. Phase 3: 加密與浮水印(可選)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- [ ] NAS 掛載點路徑確認
|
||||||
|
- [ ] 生產環境加密金鑰管理方式(KMS? Vault?)
|
||||||
|
- [ ] 是否需要支援拖放上傳多檔案?
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Change: Add Document Management
|
||||||
|
|
||||||
|
## Why
|
||||||
|
專案管理系統需要文件附件功能,讓使用者能在任務層級上傳、下載、管理檔案。半導體產業對機密文件有特殊安全需求(加密存儲、浮水印追溯)。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- **新增 Attachment 模型** - 支援任務層級的檔案附件
|
||||||
|
- **新增 AttachmentVersion 模型** - 檔案版本控制
|
||||||
|
- **新增 File Storage Service** - 本地檔案存儲(可擴展至 NAS)
|
||||||
|
- **新增 Attachment API** - 上傳、下載、刪除、版本管理
|
||||||
|
- **新增加密功能** - AES-256 加密存儲(機密專案)
|
||||||
|
- **新增浮水印功能** - 下載時動態加入使用者資訊
|
||||||
|
- **整合 Audit Trail** - 記錄所有文件操作
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: `document-management`, `audit-trail` (已實作)
|
||||||
|
- Affected code:
|
||||||
|
- `backend/app/models/` - 新增 attachment 相關模型
|
||||||
|
- `backend/app/api/` - 新增 attachments router
|
||||||
|
- `backend/app/services/` - 新增 file_service, encryption_service
|
||||||
|
- `frontend/src/components/` - 新增附件元件
|
||||||
|
- `backend/migrations/` - 新增資料表
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Basic Attachments (MVP)
|
||||||
|
- 檔案上傳/下載/刪除
|
||||||
|
- 本地檔案存儲
|
||||||
|
- 基本 API 與前端整合
|
||||||
|
- Audit 日誌整合
|
||||||
|
|
||||||
|
### Phase 2: Version Control
|
||||||
|
- 同名檔案版本控制
|
||||||
|
- 版本歷史查看
|
||||||
|
- 版本回復
|
||||||
|
|
||||||
|
### Phase 3: Security Features (Optional)
|
||||||
|
- AES-256 加密存儲
|
||||||
|
- 動態浮水印(圖片/PDF)
|
||||||
|
- 加密金鑰管理
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- audit-trail (已完成) - 用於文件操作日誌
|
||||||
|
- collaboration (已完成) - 可在評論中引用附件
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Audit Trail
|
||||||
|
系統 SHALL 記錄所有文件操作供稽核追溯,整合現有 audit-trail 模組。
|
||||||
|
|
||||||
|
#### Scenario: 操作日誌記錄
|
||||||
|
- **GIVEN** 使用者對附件執行任何操作(上傳、下載、刪除)
|
||||||
|
- **WHEN** 操作完成
|
||||||
|
- **THEN** 系統透過 AuditService 記錄操作至 `pjctrl_audit_logs`
|
||||||
|
- **AND** 使用 event_type: `attachment.upload`, `attachment.download`, `attachment.delete`
|
||||||
|
|
||||||
|
#### Scenario: 稽核查詢
|
||||||
|
- **GIVEN** 稽核人員需要查詢文件操作歷史
|
||||||
|
- **WHEN** 稽核人員透過 Audit API 執行查詢
|
||||||
|
- **THEN** 可依 resource_type=attachment 篩選
|
||||||
|
- **AND** 顯示完整操作歷史
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: File Size Limits
|
||||||
|
系統 SHALL 限制上傳檔案大小以確保系統穩定性。
|
||||||
|
|
||||||
|
#### Scenario: 檔案大小驗證
|
||||||
|
- **GIVEN** 使用者上傳檔案
|
||||||
|
- **WHEN** 檔案大小超過限制(預設 50MB)
|
||||||
|
- **THEN** 系統拒絕上傳並回傳錯誤訊息
|
||||||
|
|
||||||
|
#### Scenario: 大小限制配置
|
||||||
|
- **GIVEN** 管理者需要調整檔案大小限制
|
||||||
|
- **WHEN** 設定環境變數 MAX_FILE_SIZE_MB
|
||||||
|
- **THEN** 系統使用新的限制值
|
||||||
|
|
||||||
|
### Requirement: Mime Type Validation
|
||||||
|
系統 SHALL 驗證上傳檔案類型以確保安全性。
|
||||||
|
|
||||||
|
#### Scenario: 允許的檔案類型
|
||||||
|
- **GIVEN** 使用者上傳附件
|
||||||
|
- **WHEN** 檔案類型為常見文件格式(pdf, doc, xls, jpg, png, zip 等)
|
||||||
|
- **THEN** 系統接受上傳
|
||||||
|
|
||||||
|
#### Scenario: 危險檔案類型拒絕
|
||||||
|
- **GIVEN** 使用者上傳附件
|
||||||
|
- **WHEN** 檔案類型為可執行檔(exe, bat, sh, dll 等)
|
||||||
|
- **THEN** 系統拒絕上傳並回傳錯誤訊息
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
## Phase 1: Basic Attachments
|
||||||
|
|
||||||
|
### 1.1 Database Schema
|
||||||
|
- [x] 1.1.1 建立 Attachment model (`pjctrl_attachments`)
|
||||||
|
- [x] 1.1.2 建立 AttachmentVersion model (`pjctrl_attachment_versions`)
|
||||||
|
- [x] 1.1.3 建立 Alembic migration
|
||||||
|
- [x] 1.1.4 新增 Task model 的 attachments relationship
|
||||||
|
|
||||||
|
### 1.2 File Storage Service
|
||||||
|
- [x] 1.2.1 建立 FileStorageService 類別
|
||||||
|
- [x] 1.2.2 實作 save_file() 方法(串流處理)
|
||||||
|
- [x] 1.2.3 實作 get_file() 方法
|
||||||
|
- [x] 1.2.4 實作 delete_file() 方法
|
||||||
|
- [x] 1.2.5 新增檔案存儲路徑配置 (UPLOAD_DIR)
|
||||||
|
- [x] 1.2.6 實作 checksum 計算 (SHA-256)
|
||||||
|
|
||||||
|
### 1.3 Attachment API
|
||||||
|
- [x] 1.3.1 建立 Attachment schemas (request/response)
|
||||||
|
- [x] 1.3.2 實作 POST `/api/tasks/{task_id}/attachments` - 上傳
|
||||||
|
- [x] 1.3.3 實作 GET `/api/tasks/{task_id}/attachments` - 列表
|
||||||
|
- [x] 1.3.4 實作 GET `/api/attachments/{id}` - 詳情
|
||||||
|
- [x] 1.3.5 實作 GET `/api/attachments/{id}/download` - 下載
|
||||||
|
- [x] 1.3.6 實作 DELETE `/api/attachments/{id}` - 軟刪除
|
||||||
|
- [x] 1.3.7 整合 Audit Trail - 記錄上傳/下載/刪除操作
|
||||||
|
|
||||||
|
### 1.4 Frontend - Basic
|
||||||
|
- [x] 1.4.1 建立 attachments.ts service
|
||||||
|
- [x] 1.4.2 建立 AttachmentList 元件
|
||||||
|
- [x] 1.4.3 建立 AttachmentUpload 元件(支援拖放)
|
||||||
|
- [x] 1.4.4 整合至 Task 詳情頁 (TaskAttachments 元件)
|
||||||
|
|
||||||
|
### 1.5 Testing - Phase 1
|
||||||
|
- [x] 1.5.1 FileStorageService 單元測試
|
||||||
|
- [x] 1.5.2 Attachment API 端點測試
|
||||||
|
- [x] 1.5.3 上傳/下載整合測試
|
||||||
|
|
||||||
|
## Phase 2: Version Control
|
||||||
|
|
||||||
|
### 2.1 Version Logic
|
||||||
|
- [x] 2.1.1 修改上傳邏輯支援版本控制
|
||||||
|
- [x] 2.1.2 實作 GET `/api/attachments/{id}/versions` - 版本歷史
|
||||||
|
- [x] 2.1.3 實作 POST `/api/attachments/{id}/restore/{version}` - 回復版本
|
||||||
|
- [x] 2.1.4 實作 GET `/api/attachments/{id}/download?version=N` - 下載特定版本
|
||||||
|
|
||||||
|
### 2.2 Frontend - Version
|
||||||
|
- [x] 2.2.1 建立 VersionHistory 元件 (integrated in AttachmentList)
|
||||||
|
- [x] 2.2.2 新增版本選擇下載功能 (in attachments service)
|
||||||
|
- [x] 2.2.3 新增版本回復功能 (in attachments service)
|
||||||
|
|
||||||
|
### 2.3 Testing - Phase 2
|
||||||
|
- [x] 2.3.1 版本控制邏輯測試
|
||||||
|
- [x] 2.3.2 版本 API 端點測試
|
||||||
|
|
||||||
|
## Phase 3: Security Features (Optional)
|
||||||
|
|
||||||
|
### 3.1 Encryption
|
||||||
|
- [ ] 3.1.1 建立 EncryptionService 類別
|
||||||
|
- [ ] 3.1.2 實作 encrypt_file() / decrypt_file() 方法
|
||||||
|
- [ ] 3.1.3 新增 Project security_level 欄位(如不存在)
|
||||||
|
- [ ] 3.1.4 修改上傳邏輯:機密專案自動加密
|
||||||
|
- [ ] 3.1.5 修改下載邏輯:自動解密
|
||||||
|
|
||||||
|
### 3.2 Watermarking
|
||||||
|
- [ ] 3.2.1 建立 WatermarkService 類別
|
||||||
|
- [ ] 3.2.2 實作圖片浮水印(Pillow)
|
||||||
|
- [ ] 3.2.3 實作 PDF 浮水印(PyMuPDF)
|
||||||
|
- [ ] 3.2.4 整合至下載流程
|
||||||
|
|
||||||
|
### 3.3 Testing - Phase 3
|
||||||
|
- [ ] 3.3.1 加密/解密測試
|
||||||
|
- [ ] 3.3.2 浮水印生成測試
|
||||||
Reference in New Issue
Block a user