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

@@ -29,7 +29,17 @@ class FormulaError(Exception):
class CircularReferenceError(FormulaError):
"""Exception raised when circular references are detected in formulas."""
pass
def __init__(self, message: str, cycle_path: Optional[List[str]] = None):
super().__init__(message)
self.message = message
self.cycle_path = cycle_path or []
def get_cycle_description(self) -> str:
"""Get a human-readable description of the cycle."""
if not self.cycle_path:
return ""
return " -> ".join(self.cycle_path)
class FormulaService:
@@ -140,24 +150,43 @@ class FormulaService:
field_id: str,
references: Set[str],
visited: Optional[Set[str]] = None,
path: Optional[List[str]] = None,
) -> None:
"""
Check for circular references in formula fields.
Raises CircularReferenceError if a cycle is detected.
Args:
db: Database session
project_id: Project ID to scope the query
field_id: The current field being validated
references: Set of field names referenced in the formula
visited: Set of visited field IDs (for cycle detection)
path: Current path of field names (for error reporting)
"""
if visited is None:
visited = set()
if path is None:
path = []
# Get the current field's name
current_field = db.query(CustomField).filter(
CustomField.id == field_id
).first()
current_field_name = current_field.name if current_field else "unknown"
# Add current field to path if not already there
if current_field_name not in path:
path = path + [current_field_name]
if current_field:
if current_field.name in references:
cycle_path = path + [current_field.name]
raise CircularReferenceError(
f"Circular reference detected: field cannot reference itself"
f"Circular reference detected: field '{current_field.name}' cannot reference itself",
cycle_path=cycle_path
)
# Get all referenced formula fields
@@ -173,22 +202,199 @@ class FormulaService:
for field in formula_fields:
if field.id in visited:
# Found a cycle
cycle_path = path + [field.name]
raise CircularReferenceError(
f"Circular reference detected involving field '{field.name}'"
f"Circular reference detected: {' -> '.join(cycle_path)}",
cycle_path=cycle_path
)
visited.add(field.id)
new_path = path + [field.name]
if field.formula:
nested_refs = FormulaService.extract_field_references(field.formula)
if current_field and current_field.name in nested_refs:
cycle_path = new_path + [current_field.name]
raise CircularReferenceError(
f"Circular reference detected: '{field.name}' references the current field"
f"Circular reference detected: {' -> '.join(cycle_path)}",
cycle_path=cycle_path
)
FormulaService._check_circular_references(
db, project_id, field_id, nested_refs, visited
db, project_id, field_id, nested_refs, visited, new_path
)
@staticmethod
def build_formula_dependency_graph(
db: Session,
project_id: str
) -> Dict[str, Set[str]]:
"""
Build a dependency graph for all formula fields in a project.
Returns a dict where keys are field names and values are sets of
field names that the key field depends on.
Args:
db: Database session
project_id: Project ID to scope the query
Returns:
Dict mapping field names to their dependencies
"""
graph: Dict[str, Set[str]] = {}
formula_fields = db.query(CustomField).filter(
CustomField.project_id == project_id,
CustomField.field_type == "formula",
).all()
for field in formula_fields:
if field.formula:
refs = FormulaService.extract_field_references(field.formula)
# Only include custom field references (not builtin fields)
custom_refs = refs - FormulaService.BUILTIN_FIELDS
graph[field.name] = custom_refs
else:
graph[field.name] = set()
return graph
@staticmethod
def detect_formula_cycles(
db: Session,
project_id: str
) -> List[List[str]]:
"""
Detect all cycles in the formula dependency graph for a project.
This is useful for auditing or cleanup operations.
Args:
db: Database session
project_id: Project ID to check
Returns:
List of cycles, where each cycle is a list of field names
"""
graph = FormulaService.build_formula_dependency_graph(db, project_id)
if not graph:
return []
cycles: List[List[str]] = []
visited: Set[str] = set()
found_cycles: Set[Tuple[str, ...]] = set()
def dfs(node: str, path: List[str], in_path: Set[str]):
"""DFS to find cycles."""
if node in in_path:
# Found a cycle
cycle_start = path.index(node)
cycle = path[cycle_start:] + [node]
# Normalize for deduplication
normalized = tuple(sorted(cycle[:-1]))
if normalized not in found_cycles:
found_cycles.add(normalized)
cycles.append(cycle)
return
if node in visited:
return
if node not in graph:
return
visited.add(node)
in_path.add(node)
path.append(node)
for neighbor in graph.get(node, set()):
dfs(neighbor, path.copy(), in_path.copy())
path.pop()
in_path.discard(node)
for start_node in graph.keys():
if start_node not in visited:
dfs(start_node, [], set())
return cycles
@staticmethod
def validate_formula_with_details(
formula: str,
project_id: str,
db: Session,
current_field_id: Optional[str] = None,
) -> Tuple[bool, Optional[str], Optional[List[str]]]:
"""
Validate a formula expression with detailed error information.
Similar to validate_formula but returns cycle path on circular reference errors.
Args:
formula: The formula expression to validate
project_id: Project ID to scope field lookups
db: Database session
current_field_id: Optional ID of the field being edited (for self-reference check)
Returns:
Tuple of (is_valid, error_message, cycle_path)
"""
if not formula or not formula.strip():
return False, "Formula cannot be empty", None
# Extract field references
references = FormulaService.extract_field_references(formula)
if not references:
return False, "Formula must reference at least one field", None
# Validate syntax by trying to parse
try:
# Replace field references with dummy numbers for syntax check
test_formula = formula
for ref in references:
test_formula = test_formula.replace(f"{{{ref}}}", "1")
# Try to parse and evaluate with safe operations
FormulaService._safe_eval(test_formula)
except Exception as e:
return False, f"Invalid formula syntax: {str(e)}", None
# Separate builtin and custom field references
custom_references = references - FormulaService.BUILTIN_FIELDS
# Validate custom field references exist and are numeric types
if custom_references:
fields = db.query(CustomField).filter(
CustomField.project_id == project_id,
CustomField.name.in_(custom_references),
).all()
found_names = {f.name for f in fields}
missing = custom_references - found_names
if missing:
return False, f"Unknown field references: {', '.join(missing)}", None
# Check field types (must be number or formula)
for field in fields:
if field.field_type not in ("number", "formula"):
return False, f"Field '{field.name}' is not a numeric type", None
# Check for circular references
if current_field_id:
try:
FormulaService._check_circular_references(
db, project_id, current_field_id, references
)
except CircularReferenceError as e:
return False, str(e), e.cycle_path
return True, None, None
@staticmethod
def _safe_eval(expression: str) -> Decimal:
"""