feat: implement 8 OpenSpec proposals for security, reliability, and UX improvements

## Security Enhancements (P0)
- Add input validation with max_length and numeric range constraints
- Implement WebSocket token authentication via first message
- Add path traversal prevention in file storage service

## Permission Enhancements (P0)
- Add project member management for cross-department access
- Implement is_department_manager flag for workload visibility

## Cycle Detection (P0)
- Add DFS-based cycle detection for task dependencies
- Add formula field circular reference detection
- Display user-friendly cycle path visualization

## Concurrency & Reliability (P1)
- Implement optimistic locking with version field (409 Conflict on mismatch)
- Add trigger retry mechanism with exponential backoff (1s, 2s, 4s)
- Implement cascade restore for soft-deleted tasks

## Rate Limiting (P1)
- Add tiered rate limits: standard (60/min), sensitive (20/min), heavy (5/min)
- Apply rate limits to tasks, reports, attachments, and comments

## Frontend Improvements (P1)
- Add responsive sidebar with hamburger menu for mobile
- Improve touch-friendly UI with proper tap target sizes
- Complete i18n translations for all components

## Backend Reliability (P2)
- Configure database connection pool (size=10, overflow=20)
- Add Redis fallback mechanism with message queue
- Add blocker check before task deletion

## API Enhancements (P3)
- Add standardized response wrapper utility
- Add /health/ready and /health/live endpoints
- Implement project templates with status/field copying

## Tests Added
- test_input_validation.py - Schema and path traversal tests
- test_concurrency_reliability.py - Optimistic locking and retry tests
- test_backend_reliability.py - Connection pool and Redis tests
- test_api_enhancements.py - Health check and template tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-10 22:13:43 +08:00
parent 96210c7ad4
commit 3bdc6ff1c9
106 changed files with 9704 additions and 429 deletions

View File

@@ -1,16 +1,20 @@
import logging
import uuid
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.redis_pubsub import publish_task_event
from app.core.rate_limiter import limiter
from app.core.config import settings
from app.models import User, Project, Task, TaskStatus, AuditAction, Blocker
from app.schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskWithDetails, TaskListResponse,
TaskStatusUpdate, TaskAssignUpdate, CustomValueResponse
TaskStatusUpdate, TaskAssignUpdate, CustomValueResponse,
TaskRestoreRequest, TaskRestoreResponse,
TaskDeleteWarningResponse, TaskDeleteResponse
)
from app.middleware.auth import (
get_current_user, check_project_access, check_task_access, check_task_edit_access
@@ -72,6 +76,7 @@ def task_to_response(task: Task, db: Session = None, include_custom_values: bool
created_by=task.created_by,
created_at=task.created_at,
updated_at=task.updated_at,
version=task.version,
assignee_name=task.assignee.name if task.assignee else None,
status_name=task.status.name if task.status else None,
status_color=task.status.color if task.status else None,
@@ -161,15 +166,18 @@ async def list_tasks(
@router.post("/api/projects/{project_id}/tasks", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit(settings.RATE_LIMIT_STANDARD)
async def create_task(
request: Request,
project_id: str,
task_data: TaskCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Create a new task in a project.
Rate limited: 60 requests per minute (standard tier).
"""
project = db.query(Project).filter(Project.id == project_id).first()
@@ -367,15 +375,18 @@ async def get_task(
@router.patch("/api/tasks/{task_id}", response_model=TaskResponse)
@limiter.limit(settings.RATE_LIMIT_STANDARD)
async def update_task(
request: Request,
task_id: str,
task_data: TaskUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update a task.
Rate limited: 60 requests per minute (standard tier).
"""
task = db.query(Task).filter(Task.id == task_id).first()
@@ -391,6 +402,18 @@ async def update_task(
detail="Permission denied",
)
# Optimistic locking: validate version if provided
if task_data.version is not None:
if task_data.version != task.version:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Task has been modified by another user",
"current_version": task.version,
"provided_version": task_data.version,
},
)
# Capture old values for audit and triggers
old_values = {
"title": task.title,
@@ -402,9 +425,10 @@ async def update_task(
"time_spent": task.time_spent,
}
# Update fields (exclude custom_values, handle separately)
# Update fields (exclude custom_values and version, handle separately)
update_data = task_data.model_dump(exclude_unset=True)
custom_values_data = update_data.pop("custom_values", None)
update_data.pop("version", None) # version is handled separately for optimistic locking
# Track old assignee for workload cache invalidation
old_assignee_id = task.assignee_id
@@ -501,6 +525,9 @@ async def update_task(
detail=str(e),
)
# Increment version for optimistic locking
task.version += 1
db.commit()
db.refresh(task)
@@ -551,15 +578,20 @@ async def update_task(
return task
@router.delete("/api/tasks/{task_id}", response_model=TaskResponse)
@router.delete("/api/tasks/{task_id}")
async def delete_task(
task_id: str,
request: Request,
force_delete: bool = Query(False, description="Force delete even if task has unresolved blockers"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Soft delete a task (cascades to subtasks).
If the task has unresolved blockers and force_delete is False,
returns a warning response with status 200 and blocker count.
Use force_delete=true to delete anyway (auto-resolves blockers).
"""
task = db.query(Task).filter(Task.id == task_id).first()
@@ -581,9 +613,35 @@ async def delete_task(
detail="Permission denied",
)
# Check for unresolved blockers
unresolved_blockers = db.query(Blocker).filter(
Blocker.task_id == task.id,
Blocker.resolved_at == None,
).all()
blocker_count = len(unresolved_blockers)
# If there are unresolved blockers and force_delete is False, return warning
if blocker_count > 0 and not force_delete:
return TaskDeleteWarningResponse(
warning="Task has unresolved blockers",
blocker_count=blocker_count,
message=f"Task has {blocker_count} unresolved blocker(s). Use force_delete=true to delete anyway.",
)
# Use naive datetime for consistency with database storage
now = datetime.now(timezone.utc).replace(tzinfo=None)
# Auto-resolve blockers if force deleting
blockers_resolved = 0
if force_delete and blocker_count > 0:
for blocker in unresolved_blockers:
blocker.resolved_at = now
blocker.resolved_by = current_user.id
blocker.resolution_note = "Auto-resolved due to task deletion"
blockers_resolved += 1
logger.info(f"Auto-resolved {blockers_resolved} blocker(s) for task {task_id} during force delete")
# Soft delete the task
task.is_deleted = True
task.deleted_at = now
@@ -608,7 +666,11 @@ async def delete_task(
action=AuditAction.DELETE,
user_id=current_user.id,
resource_id=task.id,
changes=[{"field": "is_deleted", "old_value": False, "new_value": True}],
changes=[
{"field": "is_deleted", "old_value": False, "new_value": True},
{"field": "force_delete", "old_value": None, "new_value": force_delete},
{"field": "blockers_resolved", "old_value": None, "new_value": blockers_resolved},
],
request_metadata=get_audit_metadata(request),
)
@@ -635,18 +697,33 @@ async def delete_task(
except Exception as e:
logger.warning(f"Failed to publish task_deleted event: {e}")
return task
return TaskDeleteResponse(
task=task,
blockers_resolved=blockers_resolved,
force_deleted=force_delete and blocker_count > 0,
)
@router.post("/api/tasks/{task_id}/restore", response_model=TaskResponse)
@router.post("/api/tasks/{task_id}/restore", response_model=TaskRestoreResponse)
async def restore_task(
task_id: str,
request: Request,
restore_data: TaskRestoreRequest = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Restore a soft-deleted task (admin only).
Supports cascade restore: when enabled (default), also restores child tasks
that were deleted at the same time as the parent task.
Args:
task_id: ID of the task to restore
restore_data: Optional restore options (cascade=True by default)
Returns:
TaskRestoreResponse with restored task and list of restored children
"""
if not current_user.is_system_admin:
raise HTTPException(
@@ -654,6 +731,10 @@ async def restore_task(
detail="Only system administrators can restore deleted tasks",
)
# Handle default for optional body
if restore_data is None:
restore_data = TaskRestoreRequest()
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
@@ -668,12 +749,16 @@ async def restore_task(
detail="Task is not deleted",
)
# Restore the task
# Store the parent's deleted_at timestamp for cascade restore
parent_deleted_at = task.deleted_at
restored_children_ids = []
# Restore the parent task
task.is_deleted = False
task.deleted_at = None
task.deleted_by = None
# Audit log
# Audit log for parent task
AuditService.log_event(
db=db,
event_type="task.restore",
@@ -681,18 +766,119 @@ async def restore_task(
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=task.id,
changes=[{"field": "is_deleted", "old_value": True, "new_value": False}],
changes=[
{"field": "is_deleted", "old_value": True, "new_value": False},
{"field": "cascade", "old_value": None, "new_value": restore_data.cascade},
],
request_metadata=get_audit_metadata(request),
)
# Cascade restore child tasks if requested
if restore_data.cascade and parent_deleted_at:
restored_children_ids = _cascade_restore_children(
db=db,
parent_task=task,
parent_deleted_at=parent_deleted_at,
current_user=current_user,
request=request,
)
db.commit()
db.refresh(task)
# Invalidate workload cache for assignee
# Invalidate workload cache for parent task assignee
if task.assignee_id:
invalidate_user_workload_cache(task.assignee_id)
return task
# Invalidate workload cache for all restored children's assignees
for child_id in restored_children_ids:
child_task = db.query(Task).filter(Task.id == child_id).first()
if child_task and child_task.assignee_id:
invalidate_user_workload_cache(child_task.assignee_id)
return TaskRestoreResponse(
restored_task=task,
restored_children_count=len(restored_children_ids),
restored_children_ids=restored_children_ids,
)
def _cascade_restore_children(
db: Session,
parent_task: Task,
parent_deleted_at: datetime,
current_user: User,
request: Request,
tolerance_seconds: int = 5,
) -> List[str]:
"""
Recursively restore child tasks that were deleted at the same time as the parent.
Args:
db: Database session
parent_task: The parent task being restored
parent_deleted_at: Timestamp when the parent was deleted
current_user: Current user performing the restore
request: HTTP request for audit metadata
tolerance_seconds: Time tolerance for matching deleted_at timestamps
Returns:
List of restored child task IDs
"""
restored_ids = []
# Find all deleted child tasks with matching deleted_at timestamp
# Use a small tolerance window to account for slight timing differences
time_window_start = parent_deleted_at - timedelta(seconds=tolerance_seconds)
time_window_end = parent_deleted_at + timedelta(seconds=tolerance_seconds)
deleted_children = db.query(Task).filter(
Task.parent_task_id == parent_task.id,
Task.is_deleted == True,
Task.deleted_at >= time_window_start,
Task.deleted_at <= time_window_end,
).all()
for child in deleted_children:
# Store child's deleted_at before restoring
child_deleted_at = child.deleted_at
# Restore the child
child.is_deleted = False
child.deleted_at = None
child.deleted_by = None
restored_ids.append(child.id)
# Audit log for child task
AuditService.log_event(
db=db,
event_type="task.restore",
resource_type="task",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=child.id,
changes=[
{"field": "is_deleted", "old_value": True, "new_value": False},
{"field": "restored_via_cascade", "old_value": None, "new_value": parent_task.id},
],
request_metadata=get_audit_metadata(request),
)
logger.info(f"Cascade restored child task {child.id} (parent: {parent_task.id})")
# Recursively restore grandchildren
if child_deleted_at:
grandchildren_ids = _cascade_restore_children(
db=db,
parent_task=child,
parent_deleted_at=child_deleted_at,
current_user=current_user,
request=request,
tolerance_seconds=tolerance_seconds,
)
restored_ids.extend(grandchildren_ids)
return restored_ids
@router.patch("/api/tasks/{task_id}/status", response_model=TaskResponse)