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:
@@ -10,13 +10,18 @@ from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.rate_limiter import limiter
|
||||
from app.core.config import settings
|
||||
from app.models import User, Task, TaskDependency, AuditAction
|
||||
from app.schemas.task_dependency import (
|
||||
TaskDependencyCreate,
|
||||
TaskDependencyUpdate,
|
||||
TaskDependencyResponse,
|
||||
TaskDependencyListResponse,
|
||||
TaskInfo
|
||||
TaskInfo,
|
||||
BulkDependencyCreate,
|
||||
BulkDependencyValidationResult,
|
||||
BulkDependencyCreateResponse,
|
||||
)
|
||||
from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access
|
||||
from app.middleware.audit import get_audit_metadata
|
||||
@@ -429,3 +434,184 @@ async def list_project_dependencies(
|
||||
dependencies=[dependency_to_response(d) for d in dependencies],
|
||||
total=len(dependencies)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/projects/{project_id}/dependencies/validate",
|
||||
response_model=BulkDependencyValidationResult
|
||||
)
|
||||
async def validate_bulk_dependencies(
|
||||
project_id: str,
|
||||
bulk_data: BulkDependencyCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Validate a batch of dependencies without creating them.
|
||||
|
||||
This endpoint checks for:
|
||||
- Self-references
|
||||
- Cross-project dependencies
|
||||
- Duplicate dependencies
|
||||
- Circular dependencies (including cycles that would be created by the batch)
|
||||
|
||||
Returns validation results without modifying the database.
|
||||
"""
|
||||
from app.models import Project
|
||||
from app.middleware.auth import check_project_access
|
||||
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
if not check_project_access(current_user, project):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# Convert to tuple format for validation
|
||||
dependencies = [
|
||||
(dep.predecessor_id, dep.successor_id)
|
||||
for dep in bulk_data.dependencies
|
||||
]
|
||||
|
||||
errors = DependencyService.validate_bulk_dependencies(db, dependencies, project_id)
|
||||
|
||||
return BulkDependencyValidationResult(
|
||||
valid=len(errors) == 0,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/projects/{project_id}/dependencies/bulk",
|
||||
response_model=BulkDependencyCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@limiter.limit(settings.RATE_LIMIT_HEAVY)
|
||||
async def create_bulk_dependencies(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
bulk_data: BulkDependencyCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create multiple dependencies at once.
|
||||
|
||||
This endpoint:
|
||||
1. Validates all dependencies together for cycle detection
|
||||
2. Creates valid dependencies
|
||||
3. Returns both created dependencies and any failures
|
||||
|
||||
Cycle detection considers all dependencies in the batch together,
|
||||
so cycles that would only appear when all dependencies are added
|
||||
will be caught.
|
||||
|
||||
Rate limited: 5 requests per minute (heavy tier).
|
||||
"""
|
||||
from app.models import Project
|
||||
from app.middleware.auth import check_project_edit_access
|
||||
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
if not check_project_edit_access(current_user, project):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Permission denied"
|
||||
)
|
||||
|
||||
# Convert to tuple format for validation
|
||||
dependencies_to_validate = [
|
||||
(dep.predecessor_id, dep.successor_id)
|
||||
for dep in bulk_data.dependencies
|
||||
]
|
||||
|
||||
# Validate all dependencies together
|
||||
errors = DependencyService.validate_bulk_dependencies(
|
||||
db, dependencies_to_validate, project_id
|
||||
)
|
||||
|
||||
# Build a set of failed dependency pairs for quick lookup
|
||||
failed_pairs = set()
|
||||
for error in errors:
|
||||
pair = (error.get("predecessor_id"), error.get("successor_id"))
|
||||
failed_pairs.add(pair)
|
||||
|
||||
created_dependencies = []
|
||||
failed_items = errors # Include validation errors
|
||||
|
||||
# Create dependencies that passed validation
|
||||
for dep_data in bulk_data.dependencies:
|
||||
pair = (dep_data.predecessor_id, dep_data.successor_id)
|
||||
if pair in failed_pairs:
|
||||
continue
|
||||
|
||||
# Additional check: verify dependency limit for successor
|
||||
current_count = db.query(TaskDependency).filter(
|
||||
TaskDependency.successor_id == dep_data.successor_id
|
||||
).count()
|
||||
|
||||
if current_count >= DependencyService.MAX_DIRECT_DEPENDENCIES:
|
||||
failed_items.append({
|
||||
"error_type": "limit_exceeded",
|
||||
"predecessor_id": dep_data.predecessor_id,
|
||||
"successor_id": dep_data.successor_id,
|
||||
"message": f"Successor task already has {DependencyService.MAX_DIRECT_DEPENDENCIES} dependencies"
|
||||
})
|
||||
continue
|
||||
|
||||
# Create the dependency
|
||||
dependency = TaskDependency(
|
||||
id=str(uuid.uuid4()),
|
||||
predecessor_id=dep_data.predecessor_id,
|
||||
successor_id=dep_data.successor_id,
|
||||
dependency_type=dep_data.dependency_type.value,
|
||||
lag_days=dep_data.lag_days
|
||||
)
|
||||
|
||||
db.add(dependency)
|
||||
created_dependencies.append(dependency)
|
||||
|
||||
# Audit log for each created dependency
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="task.dependency.create",
|
||||
resource_type="task_dependency",
|
||||
action=AuditAction.CREATE,
|
||||
user_id=current_user.id,
|
||||
resource_id=dependency.id,
|
||||
changes=[{
|
||||
"field": "dependency",
|
||||
"old_value": None,
|
||||
"new_value": {
|
||||
"predecessor_id": dependency.predecessor_id,
|
||||
"successor_id": dependency.successor_id,
|
||||
"dependency_type": dependency.dependency_type,
|
||||
"lag_days": dependency.lag_days
|
||||
}
|
||||
}],
|
||||
request_metadata=get_audit_metadata(request)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Refresh created dependencies to get relationships
|
||||
for dep in created_dependencies:
|
||||
db.refresh(dep)
|
||||
|
||||
return BulkDependencyCreateResponse(
|
||||
created=[dependency_to_response(d) for d in created_dependencies],
|
||||
failed=failed_items,
|
||||
total_created=len(created_dependencies),
|
||||
total_failed=len(failed_items)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user