Files
beabigegg 3bdc6ff1c9 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>
2026-01-10 22:13:43 +08:00

441 lines
14 KiB
Python

"""Project Templates API endpoints.
Provides CRUD operations for project templates and
the ability to create projects from templates.
"""
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import (
User, Space, Project, TaskStatus, CustomField, ProjectTemplate, AuditAction
)
from app.schemas.project_template import (
ProjectTemplateCreate,
ProjectTemplateUpdate,
ProjectTemplateResponse,
ProjectTemplateWithOwner,
ProjectTemplateListResponse,
CreateProjectFromTemplateRequest,
CreateProjectFromTemplateResponse,
)
from app.middleware.auth import get_current_user, check_space_access
from app.middleware.audit import get_audit_metadata
from app.services.audit_service import AuditService
router = APIRouter(prefix="/api/templates", tags=["Project Templates"])
def can_view_template(user: User, template: ProjectTemplate) -> bool:
"""Check if a user can view a template."""
if template.is_public:
return True
if template.owner_id == user.id:
return True
if user.is_system_admin:
return True
return False
def can_edit_template(user: User, template: ProjectTemplate) -> bool:
"""Check if a user can edit a template."""
if template.owner_id == user.id:
return True
if user.is_system_admin:
return True
return False
@router.get("", response_model=ProjectTemplateListResponse)
async def list_templates(
include_private: bool = Query(False, description="Include user's private templates"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List available project templates.
By default, only returns public templates.
Set include_private=true to also include the user's private templates.
"""
query = db.query(ProjectTemplate).filter(ProjectTemplate.is_active == True)
if include_private:
# Public templates OR user's own templates
query = query.filter(
(ProjectTemplate.is_public == True) |
(ProjectTemplate.owner_id == current_user.id)
)
else:
# Only public templates
query = query.filter(ProjectTemplate.is_public == True)
templates = query.order_by(ProjectTemplate.name).all()
result = []
for template in templates:
result.append(ProjectTemplateWithOwner(
id=template.id,
name=template.name,
description=template.description,
is_public=template.is_public,
task_statuses=template.task_statuses,
custom_fields=template.custom_fields,
default_security_level=template.default_security_level,
owner_id=template.owner_id,
is_active=template.is_active,
created_at=template.created_at,
updated_at=template.updated_at,
owner_name=template.owner.name if template.owner else None,
))
return ProjectTemplateListResponse(
templates=result,
total=len(result),
)
@router.post("", response_model=ProjectTemplateResponse, status_code=status.HTTP_201_CREATED)
async def create_template(
template_data: ProjectTemplateCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Create a new project template.
The template can include predefined task statuses and custom fields
that will be copied when creating a project from this template.
"""
# Convert Pydantic models to dict for JSON storage
task_statuses_json = None
if template_data.task_statuses:
task_statuses_json = [ts.model_dump() for ts in template_data.task_statuses]
custom_fields_json = None
if template_data.custom_fields:
custom_fields_json = [cf.model_dump() for cf in template_data.custom_fields]
template = ProjectTemplate(
id=str(uuid.uuid4()),
name=template_data.name,
description=template_data.description,
owner_id=current_user.id,
is_public=template_data.is_public,
task_statuses=task_statuses_json,
custom_fields=custom_fields_json,
default_security_level=template_data.default_security_level,
)
db.add(template)
# Audit log
AuditService.log_event(
db=db,
event_type="template.create",
resource_type="project_template",
action=AuditAction.CREATE,
user_id=current_user.id,
resource_id=template.id,
changes=[{"field": "name", "old_value": None, "new_value": template.name}],
request_metadata=get_audit_metadata(request),
)
db.commit()
db.refresh(template)
return template
@router.get("/{template_id}", response_model=ProjectTemplateWithOwner)
async def get_template(
template_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Get a project template by ID.
"""
template = db.query(ProjectTemplate).filter(
ProjectTemplate.id == template_id,
ProjectTemplate.is_active == True
).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found",
)
if not can_view_template(current_user, template):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
return ProjectTemplateWithOwner(
id=template.id,
name=template.name,
description=template.description,
is_public=template.is_public,
task_statuses=template.task_statuses,
custom_fields=template.custom_fields,
default_security_level=template.default_security_level,
owner_id=template.owner_id,
is_active=template.is_active,
created_at=template.created_at,
updated_at=template.updated_at,
owner_name=template.owner.name if template.owner else None,
)
@router.patch("/{template_id}", response_model=ProjectTemplateResponse)
async def update_template(
template_id: str,
template_data: ProjectTemplateUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update a project template.
Only the template owner or system admin can update a template.
"""
template = db.query(ProjectTemplate).filter(
ProjectTemplate.id == template_id,
ProjectTemplate.is_active == True
).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found",
)
if not can_edit_template(current_user, template):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only template owner can update",
)
# Capture old values for audit
old_values = {
"name": template.name,
"description": template.description,
"is_public": template.is_public,
}
# Update fields
update_data = template_data.model_dump(exclude_unset=True)
# Convert Pydantic models to dict for JSON storage
if "task_statuses" in update_data and update_data["task_statuses"]:
update_data["task_statuses"] = [ts.model_dump() if hasattr(ts, 'model_dump') else ts for ts in update_data["task_statuses"]]
if "custom_fields" in update_data and update_data["custom_fields"]:
update_data["custom_fields"] = [cf.model_dump() if hasattr(cf, 'model_dump') else cf for cf in update_data["custom_fields"]]
for field, value in update_data.items():
setattr(template, field, value)
# Log changes
new_values = {
"name": template.name,
"description": template.description,
"is_public": template.is_public,
}
changes = AuditService.detect_changes(old_values, new_values)
if changes:
AuditService.log_event(
db=db,
event_type="template.update",
resource_type="project_template",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=template.id,
changes=changes,
request_metadata=get_audit_metadata(request),
)
db.commit()
db.refresh(template)
return template
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Delete a project template (soft delete).
Only the template owner or system admin can delete a template.
"""
template = db.query(ProjectTemplate).filter(
ProjectTemplate.id == template_id,
ProjectTemplate.is_active == True
).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found",
)
if not can_edit_template(current_user, template):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only template owner can delete",
)
# Audit log
AuditService.log_event(
db=db,
event_type="template.delete",
resource_type="project_template",
action=AuditAction.DELETE,
user_id=current_user.id,
resource_id=template.id,
changes=[{"field": "is_active", "old_value": True, "new_value": False}],
request_metadata=get_audit_metadata(request),
)
# Soft delete
template.is_active = False
db.commit()
return None
@router.post("/create-project", response_model=CreateProjectFromTemplateResponse, status_code=status.HTTP_201_CREATED)
async def create_project_from_template(
data: CreateProjectFromTemplateRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Create a new project from a template.
This will:
1. Create a new project with the specified title and description
2. Copy all task statuses from the template
3. Copy all custom field definitions from the template
"""
# Get the template
template = db.query(ProjectTemplate).filter(
ProjectTemplate.id == data.template_id,
ProjectTemplate.is_active == True
).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found",
)
if not can_view_template(current_user, template):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to template",
)
# Check space access
space = db.query(Space).filter(
Space.id == data.space_id,
Space.is_active == True
).first()
if not space:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Space not found",
)
if not check_space_access(current_user, space):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to space",
)
# Create the project
project = Project(
id=str(uuid.uuid4()),
space_id=data.space_id,
title=data.title,
description=data.description,
owner_id=current_user.id,
security_level=template.default_security_level or "department",
department_id=data.department_id or current_user.department_id,
)
db.add(project)
db.flush() # Get project ID
# Copy task statuses from template
task_statuses_created = 0
if template.task_statuses:
for status_data in template.task_statuses:
task_status = TaskStatus(
id=str(uuid.uuid4()),
project_id=project.id,
name=status_data.get("name", "Unnamed"),
color=status_data.get("color", "#808080"),
position=status_data.get("position", 0),
is_done=status_data.get("is_done", False),
)
db.add(task_status)
task_statuses_created += 1
# Copy custom fields from template
custom_fields_created = 0
if template.custom_fields:
for field_data in template.custom_fields:
custom_field = CustomField(
id=str(uuid.uuid4()),
project_id=project.id,
name=field_data.get("name", "Unnamed"),
field_type=field_data.get("field_type", "text"),
options=field_data.get("options"),
formula=field_data.get("formula"),
is_required=field_data.get("is_required", False),
position=field_data.get("position", 0),
)
db.add(custom_field)
custom_fields_created += 1
# Audit log
AuditService.log_event(
db=db,
event_type="project.create_from_template",
resource_type="project",
action=AuditAction.CREATE,
user_id=current_user.id,
resource_id=project.id,
changes=[
{"field": "title", "old_value": None, "new_value": project.title},
{"field": "template_id", "old_value": None, "new_value": template.id},
],
request_metadata=get_audit_metadata(request),
)
db.commit()
return CreateProjectFromTemplateResponse(
id=project.id,
title=project.title,
template_id=template.id,
template_name=template.name,
task_statuses_created=task_statuses_created,
custom_fields_created=custom_fields_created,
)