Files
PROJECT-CONTORL/backend/app/api/attachments/router.py
beabigegg 9b220523ff feat: complete issue fixes and implement remaining features
## Critical Issues (CRIT-001~003) - All Fixed
- JWT secret key validation with pydantic field_validator
- Login audit logging for success/failure attempts
- Frontend API path prefix removal

## High Priority Issues (HIGH-001~008) - All Fixed
- Project soft delete using is_active flag
- Redis session token bytes handling
- Rate limiting with slowapi (5 req/min for login)
- Attachment API permission checks
- Kanban view with drag-and-drop
- Workload heatmap UI (WorkloadPage, WorkloadHeatmap)
- TaskDetailModal integrating Comments/Attachments
- UserSelect component for task assignment

## Medium Priority Issues (MED-001~012) - All Fixed
- MED-001~005: DB commits, N+1 queries, datetime, error format, blocker flag
- MED-006: Project health dashboard (HealthService, ProjectHealthPage)
- MED-007: Capacity update API (PUT /api/users/{id}/capacity)
- MED-008: Schedule triggers (cron parsing, deadline reminders)
- MED-009: Watermark feature (image/PDF watermarking)
- MED-010~012: useEffect deps, DOM operations, PDF export

## New Files
- backend/app/api/health/ - Project health API
- backend/app/services/health_service.py
- backend/app/services/trigger_scheduler.py
- backend/app/services/watermark_service.py
- backend/app/core/rate_limiter.py
- frontend/src/pages/ProjectHealthPage.tsx
- frontend/src/components/ProjectHealthCard.tsx
- frontend/src/components/KanbanBoard.tsx
- frontend/src/components/WorkloadHeatmap.tsx

## Tests
- 113 new tests passing (health: 32, users: 14, triggers: 35, watermark: 32)

## OpenSpec Archives
- add-project-health-dashboard
- add-capacity-update-api
- add-schedule-triggers
- add-watermark-feature
- add-rate-limiting
- enhance-frontend-ux
- add-resource-management-ui

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:49:52 +08:00

475 lines
17 KiB
Python

import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access
from app.models import User, Task, Project, 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
from app.services.watermark_service import watermark_service
router = APIRouter(prefix="/api", tags=["attachments"])
def get_task_with_access_check(db: Session, task_id: str, current_user: User, require_edit: bool = False) -> Task:
"""Get task and verify access permissions."""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Get project for access check
project = db.query(Project).filter(Project.id == task.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Check access permission
if not check_task_access(current_user, task, project):
raise HTTPException(status_code=403, detail="Access denied to this task")
# Check edit permission if required
if require_edit and not check_task_edit_access(current_user, task, project):
raise HTTPException(status_code=403, detail="Edit access denied to this task")
return task
def get_attachment_with_access_check(
db: Session, attachment_id: str, current_user: User, require_edit: bool = False
) -> Attachment:
"""Get attachment and verify access permissions."""
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")
# Get task and project for access check
task = db.query(Task).filter(Task.id == attachment.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
project = db.query(Project).filter(Project.id == task.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Check access permission
if not check_task_access(current_user, task, project):
raise HTTPException(status_code=403, detail="Access denied to this attachment")
# Check edit permission if required
if require_edit and not check_task_edit_access(current_user, task, project):
raise HTTPException(status_code=403, detail="Edit access denied to this attachment")
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_with_access_check(db, task_id, current_user, require_edit=True)
# 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
# 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()
db.refresh(existing)
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)
# 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()
db.refresh(attachment)
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_with_access_check(db, task_id, current_user, require_edit=False)
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_with_access_check(db, attachment_id, current_user, require_edit=False)
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 with dynamic watermark."""
attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=False)
# 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
download_time = datetime.now()
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()
# Check if watermark should be applied
mime_type = attachment.mime_type or ""
if watermark_service.supports_watermark(mime_type):
try:
# Read the original file
with open(file_path, "rb") as f:
file_bytes = f.read()
# Apply watermark based on file type
if watermark_service.is_supported_image(mime_type):
watermarked_bytes, output_format = watermark_service.add_image_watermark(
image_bytes=file_bytes,
user_name=current_user.name,
employee_id=current_user.employee_id,
download_time=download_time
)
# Update mime type based on output format
output_mime_type = f"image/{output_format}"
# Update filename extension if format changed
original_filename = attachment.original_filename
if output_format == "png" and not original_filename.lower().endswith(".png"):
original_filename = original_filename.rsplit(".", 1)[0] + ".png"
return Response(
content=watermarked_bytes,
media_type=output_mime_type,
headers={
"Content-Disposition": f'attachment; filename="{original_filename}"'
}
)
elif watermark_service.is_supported_pdf(mime_type):
watermarked_bytes = watermark_service.add_pdf_watermark(
pdf_bytes=file_bytes,
user_name=current_user.name,
employee_id=current_user.employee_id,
download_time=download_time
)
return Response(
content=watermarked_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{attachment.original_filename}"'
}
)
except Exception as e:
# If watermarking fails, log the error but still return the original file
# This ensures users can still download files even if watermarking has issues
import logging
logging.getLogger(__name__).warning(
f"Watermarking failed for attachment {attachment_id}: {str(e)}. "
"Returning original file."
)
# Return original file without watermark for unsupported types or on error
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_with_access_check(db, attachment_id, current_user, require_edit=True)
# Soft delete
attachment.is_deleted = True
# 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 {"detail": "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_with_access_check(db, attachment_id, current_user, require_edit=False)
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_with_access_check(db, attachment_id, current_user, require_edit=True)
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
# 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 {"detail": f"Restored to version {version}", "current_version": version}