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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user