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:
beabigegg
2025-12-29 22:03:05 +08:00
parent 0ef78e13ff
commit 3108fe1dff
21 changed files with 2027 additions and 1 deletions

View File

@@ -0,0 +1,3 @@
from app.api.attachments.router import router
__all__ = ["router"]

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

View File

@@ -38,6 +38,31 @@ class Settings(BaseSettings):
# System Admin
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:
env_file = ".env"
case_sensitive = True

View File

@@ -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.websocket import router as websocket_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
app = FastAPI(
@@ -47,6 +48,7 @@ app.include_router(notifications_router)
app.include_router(blockers_router)
app.include_router(websocket_router)
app.include_router(audit_router)
app.include_router(attachments_router)
@app.get("/health")

View File

@@ -12,9 +12,12 @@ from app.models.notification import Notification
from app.models.blocker import Blocker
from app.models.audit_log import AuditLog, AuditAction, SensitivityLevel, EVENT_SENSITIVITY, ALERT_EVENTS
from app.models.audit_alert import AuditAlert
from app.models.attachment import Attachment
from app.models.attachment_version import AttachmentVersion
__all__ = [
"User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot",
"Comment", "Mention", "Notification", "Blocker",
"AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS"
"AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS",
"Attachment", "AttachmentVersion"
]

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

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

View File

@@ -47,3 +47,4 @@ class Task(Base):
# Collaboration relationships
comments = relationship("Comment", 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")

View File

@@ -24,6 +24,10 @@ from app.schemas.audit import (
AuditLogResponse, AuditLogListResponse, AuditAlertResponse, AuditAlertListResponse,
IntegrityCheckRequest, IntegrityCheckResponse
)
from app.schemas.attachment import (
AttachmentResponse, AttachmentListResponse, AttachmentDetailResponse,
AttachmentVersionResponse, VersionHistoryResponse
)
__all__ = [
"LoginRequest",
@@ -74,4 +78,9 @@ __all__ = [
"AuditAlertListResponse",
"IntegrityCheckRequest",
"IntegrityCheckResponse",
"AttachmentResponse",
"AttachmentListResponse",
"AttachmentDetailResponse",
"AttachmentVersionResponse",
"VersionHistoryResponse",
]

View 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

View 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()