feat: complete LOW priority code quality improvements

Backend:
- LOW-002: Add Query validation with max page size limits (100)
- LOW-003: Replace magic strings with TaskStatus.is_done flag
- LOW-004: Add 'creation' trigger type validation
- Add action_executor.py with UpdateFieldAction and AutoAssignAction

Frontend:
- LOW-005: Replace TypeScript 'any' with 'unknown' + type guards
- LOW-006: Add ConfirmModal component with A11Y support
- LOW-007: Add ToastContext for user feedback notifications
- LOW-009: Add Skeleton components (17 loading states replaced)
- LOW-010: Setup Vitest with 21 tests for ConfirmModal and Skeleton

Components updated:
- App.tsx, ProtectedRoute.tsx, Spaces.tsx, Projects.tsx, Tasks.tsx
- ProjectSettings.tsx, AuditPage.tsx, WorkloadPage.tsx, ProjectHealthPage.tsx
- Comments.tsx, AttachmentList.tsx, TriggerList.tsx, TaskDetailModal.tsx
- NotificationBell.tsx, BlockerDialog.tsx, CalendarView.tsx, WorkloadUserDetail.tsx

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-07 21:24:36 +08:00
parent 2d80a8384e
commit 4b5a9c1d0a
66 changed files with 7809 additions and 171 deletions

View File

@@ -118,6 +118,9 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E
Returns:
Tuple of (should_encrypt, encryption_key)
Raises:
HTTPException: If project is confidential but encryption is not available
"""
# Only encrypt for confidential projects
if project.security_level != "confidential":
@@ -125,11 +128,14 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E
# Check if encryption is available
if not encryption_service.is_encryption_available():
logger.warning(
logger.error(
f"Project {project.id} is confidential but encryption is not configured. "
"Files will be stored unencrypted."
"Rejecting file upload to maintain security."
)
raise HTTPException(
status_code=400,
detail="Confidential project requires encryption. Please configure ENCRYPTION_MASTER_KEY environment variable."
)
return False, None
# Get active encryption key
active_key = db.query(EncryptionKey).filter(
@@ -137,11 +143,14 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E
).first()
if not active_key:
logger.warning(
logger.error(
f"Project {project.id} is confidential but no active encryption key exists. "
"Files will be stored unencrypted. Create a key using /api/admin/encryption-keys/rotate"
"Rejecting file upload to maintain security."
)
raise HTTPException(
status_code=400,
detail="Confidential project requires encryption. Please create an active encryption key first."
)
return False, None
return True, active_key

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List
@@ -13,8 +13,8 @@ router = APIRouter()
@router.get("", response_model=List[DepartmentResponse])
async def list_departments(
skip: int = 0,
limit: int = 100,
skip: int = Query(0, ge=0, description="Number of departments to skip"),
limit: int = Query(100, ge=1, le=200, description="Max departments to return"),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.read")),
):

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
@@ -71,8 +71,8 @@ async def generate_weekly_report(
@router.get("/api/reports/history", response_model=ReportHistoryListResponse)
async def list_report_history(
limit: int = 10,
offset: int = 0,
limit: int = Query(10, ge=1, le=100, description="Number of reports to return"),
offset: int = Query(0, ge=0, description="Number of reports to skip"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):

View File

@@ -1,5 +1,5 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
@@ -11,6 +11,8 @@ from app.schemas.trigger import (
)
from app.middleware.auth import get_current_user, check_project_access, check_project_edit_access
from app.services.trigger_scheduler import TriggerSchedulerService
from app.services.trigger_service import TriggerService
from app.services.action_executor import ActionValidationError
router = APIRouter(tags=["triggers"])
@@ -60,10 +62,11 @@ async def create_trigger(
)
# Validate trigger type
if trigger_data.trigger_type not in ["field_change", "schedule"]:
valid_trigger_types = ["field_change", "schedule", "creation"]
if trigger_data.trigger_type not in valid_trigger_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid trigger type. Must be 'field_change' or 'schedule'",
detail=f"Invalid trigger type. Must be one of: {', '.join(valid_trigger_types)}",
)
# Validate conditions based on trigger type
@@ -111,6 +114,16 @@ async def create_trigger(
detail=error_msg or "Invalid cron expression",
)
# Validate actions configuration (FEAT-014, FEAT-015)
try:
actions_dicts = [a.model_dump(exclude_none=True) for a in trigger_data.actions]
TriggerService.validate_actions(actions_dicts, db)
except ActionValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
# Create trigger
trigger = Trigger(
id=str(uuid.uuid4()),
@@ -119,7 +132,7 @@ async def create_trigger(
description=trigger_data.description,
trigger_type=trigger_data.trigger_type,
conditions=trigger_data.conditions.model_dump(),
actions=[a.model_dump() for a in trigger_data.actions],
actions=[a.model_dump(exclude_none=True) for a in trigger_data.actions],
is_active=trigger_data.is_active,
created_by=current_user.id,
)
@@ -239,7 +252,16 @@ async def update_trigger(
)
trigger.conditions = trigger_data.conditions.model_dump(exclude_none=True)
if trigger_data.actions is not None:
trigger.actions = [a.model_dump() for a in trigger_data.actions]
# Validate actions configuration (FEAT-014, FEAT-015)
try:
actions_dicts = [a.model_dump(exclude_none=True) for a in trigger_data.actions]
TriggerService.validate_actions(actions_dicts, db)
except ActionValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
trigger.actions = [a.model_dump(exclude_none=True) for a in trigger_data.actions]
if trigger_data.is_active is not None:
trigger.is_active = trigger_data.is_active
@@ -278,8 +300,8 @@ async def delete_trigger(
@router.get("/api/triggers/{trigger_id}/logs", response_model=TriggerLogListResponse)
async def list_trigger_logs(
trigger_id: str,
limit: int = 50,
offset: int = 0,
limit: int = Query(50, ge=1, le=200, description="Number of logs to return"),
offset: int = Query(0, ge=0, description="Number of logs to skip"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):

View File

@@ -50,8 +50,8 @@ async def search_users(
@router.get("", response_model=List[UserResponse])
async def list_users(
skip: int = 0,
limit: int = 100,
skip: int = Query(0, ge=0, description="Number of users to skip"),
limit: int = Query(100, ge=1, le=500, description="Max users to return"),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.read")),
):