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:
@@ -8,6 +8,8 @@ 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.middleware.auth import get_current_user, check_task_access, check_task_edit_access
|
||||
from app.models import User, Task, Project, Attachment, AttachmentVersion, EncryptionKey, AuditAction
|
||||
from app.schemas.attachment import (
|
||||
@@ -156,9 +158,10 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/attachments", response_model=AttachmentResponse)
|
||||
@limiter.limit(settings.RATE_LIMIT_SENSITIVE)
|
||||
async def upload_attachment(
|
||||
task_id: str,
|
||||
request: Request,
|
||||
task_id: str,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
@@ -167,6 +170,8 @@ async def upload_attachment(
|
||||
Upload a file attachment to a task.
|
||||
|
||||
For confidential projects, files are automatically encrypted using AES-256-GCM.
|
||||
|
||||
Rate limited: 20 requests per minute (sensitive tier).
|
||||
"""
|
||||
task = get_task_with_access_check(db, task_id, current_user, require_edit=True)
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import uuid
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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, Comment
|
||||
from app.schemas.comment import (
|
||||
CommentCreate, CommentUpdate, CommentResponse, CommentListResponse,
|
||||
@@ -49,13 +51,19 @@ def comment_to_response(comment: Comment) -> CommentResponse:
|
||||
|
||||
|
||||
@router.post("/api/tasks/{task_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit(settings.RATE_LIMIT_STANDARD)
|
||||
async def create_comment(
|
||||
request: Request,
|
||||
task_id: str,
|
||||
comment_data: CommentCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new comment on a task."""
|
||||
"""
|
||||
Create a new comment on a task.
|
||||
|
||||
Rate limited: 60 requests per minute (standard tier).
|
||||
"""
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
|
||||
if not task:
|
||||
|
||||
@@ -91,13 +91,17 @@ async def create_custom_field(
|
||||
detail="Formula is required for formula fields",
|
||||
)
|
||||
|
||||
is_valid, error_msg = FormulaService.validate_formula(
|
||||
is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details(
|
||||
field_data.formula, project_id, db
|
||||
)
|
||||
if not is_valid:
|
||||
detail = {"message": error_msg}
|
||||
if cycle_path:
|
||||
detail["cycle_path"] = cycle_path
|
||||
detail["cycle_description"] = " -> ".join(cycle_path)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_msg,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
# Get next position
|
||||
@@ -229,13 +233,17 @@ async def update_custom_field(
|
||||
|
||||
# Validate formula if updating formula field
|
||||
if field.field_type == "formula" and field_data.formula is not None:
|
||||
is_valid, error_msg = FormulaService.validate_formula(
|
||||
is_valid, error_msg, cycle_path = FormulaService.validate_formula_with_details(
|
||||
field_data.formula, field.project_id, db, field_id
|
||||
)
|
||||
if not is_valid:
|
||||
detail = {"message": error_msg}
|
||||
if cycle_path:
|
||||
detail["cycle_path"] = cycle_path
|
||||
detail["cycle_description"] = " -> ".join(cycle_path)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_msg,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
# Validate options if updating dropdown field
|
||||
|
||||
@@ -4,10 +4,17 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models import User, Space, Project, TaskStatus, AuditAction
|
||||
from app.models import User, Space, Project, TaskStatus, AuditAction, ProjectMember
|
||||
from app.models.task_status import DEFAULT_STATUSES
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, ProjectWithDetails
|
||||
from app.schemas.task_status import TaskStatusResponse
|
||||
from app.schemas.project_member import (
|
||||
ProjectMemberCreate,
|
||||
ProjectMemberUpdate,
|
||||
ProjectMemberResponse,
|
||||
ProjectMemberWithDetails,
|
||||
ProjectMemberListResponse,
|
||||
)
|
||||
from app.middleware.auth import (
|
||||
get_current_user, check_space_access, check_space_edit_access,
|
||||
check_project_access, check_project_edit_access
|
||||
@@ -336,3 +343,271 @@ async def list_project_statuses(
|
||||
).order_by(TaskStatus.position).all()
|
||||
|
||||
return statuses
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Project Members API - Cross-Department Collaboration
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/api/projects/{project_id}/members", response_model=ProjectMemberListResponse)
|
||||
async def list_project_members(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all members of a project.
|
||||
|
||||
Only users with project access can view the member list.
|
||||
"""
|
||||
project = db.query(Project).filter(Project.id == project_id, Project.is_active == True).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",
|
||||
)
|
||||
|
||||
members = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id
|
||||
).all()
|
||||
|
||||
member_list = []
|
||||
for member in members:
|
||||
user = db.query(User).filter(User.id == member.user_id).first()
|
||||
added_by_user = db.query(User).filter(User.id == member.added_by).first()
|
||||
|
||||
member_list.append(ProjectMemberWithDetails(
|
||||
id=member.id,
|
||||
project_id=member.project_id,
|
||||
user_id=member.user_id,
|
||||
role=member.role,
|
||||
added_by=member.added_by,
|
||||
created_at=member.created_at,
|
||||
user_name=user.name if user else None,
|
||||
user_email=user.email if user else None,
|
||||
user_department_id=user.department_id if user else None,
|
||||
user_department_name=user.department.name if user and user.department else None,
|
||||
added_by_name=added_by_user.name if added_by_user else None,
|
||||
))
|
||||
|
||||
return ProjectMemberListResponse(
|
||||
members=member_list,
|
||||
total=len(member_list),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/members", response_model=ProjectMemberResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def add_project_member(
|
||||
project_id: str,
|
||||
member_data: ProjectMemberCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Add a user as a project member for cross-department collaboration.
|
||||
|
||||
Only project owners and members with 'admin' role can add new members.
|
||||
"""
|
||||
project = db.query(Project).filter(Project.id == project_id, Project.is_active == True).first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found",
|
||||
)
|
||||
|
||||
# Check if user has permission to add members (owner or admin member)
|
||||
if not check_project_edit_access(current_user, project):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only project owner or admin members can add new members",
|
||||
)
|
||||
|
||||
# Check if user exists
|
||||
user_to_add = db.query(User).filter(User.id == member_data.user_id, User.is_active == True).first()
|
||||
if not user_to_add:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Check if user is already a member
|
||||
existing_member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == member_data.user_id,
|
||||
).first()
|
||||
|
||||
if existing_member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User is already a member of this project",
|
||||
)
|
||||
|
||||
# Don't add the owner as a member (they already have access)
|
||||
if member_data.user_id == project.owner_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Project owner cannot be added as a member",
|
||||
)
|
||||
|
||||
# Create the membership
|
||||
member = ProjectMember(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
user_id=member_data.user_id,
|
||||
role=member_data.role.value,
|
||||
added_by=current_user.id,
|
||||
)
|
||||
|
||||
db.add(member)
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="project_member.add",
|
||||
resource_type="project_member",
|
||||
action=AuditAction.CREATE,
|
||||
user_id=current_user.id,
|
||||
resource_id=member.id,
|
||||
changes=[
|
||||
{"field": "user_id", "old_value": None, "new_value": member_data.user_id},
|
||||
{"field": "role", "old_value": None, "new_value": member_data.role.value},
|
||||
],
|
||||
request_metadata=get_audit_metadata(request),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(member)
|
||||
|
||||
return member
|
||||
|
||||
|
||||
@router.patch("/api/projects/{project_id}/members/{member_id}", response_model=ProjectMemberResponse)
|
||||
async def update_project_member(
|
||||
project_id: str,
|
||||
member_id: str,
|
||||
member_data: ProjectMemberUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update a project member's role.
|
||||
|
||||
Only project owners and members with 'admin' role can update member roles.
|
||||
"""
|
||||
project = db.query(Project).filter(Project.id == project_id, Project.is_active == True).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="Only project owner or admin members can update member roles",
|
||||
)
|
||||
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.id == member_id,
|
||||
ProjectMember.project_id == project_id,
|
||||
).first()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Member not found",
|
||||
)
|
||||
|
||||
old_role = member.role
|
||||
member.role = member_data.role.value
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="project_member.update",
|
||||
resource_type="project_member",
|
||||
action=AuditAction.UPDATE,
|
||||
user_id=current_user.id,
|
||||
resource_id=member.id,
|
||||
changes=[{"field": "role", "old_value": old_role, "new_value": member_data.role.value}],
|
||||
request_metadata=get_audit_metadata(request),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(member)
|
||||
|
||||
return member
|
||||
|
||||
|
||||
@router.delete("/api/projects/{project_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_project_member(
|
||||
project_id: str,
|
||||
member_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Remove a member from a project.
|
||||
|
||||
Only project owners and members with 'admin' role can remove members.
|
||||
Members can also remove themselves from a project.
|
||||
"""
|
||||
project = db.query(Project).filter(Project.id == project_id, Project.is_active == True).first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found",
|
||||
)
|
||||
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.id == member_id,
|
||||
ProjectMember.project_id == project_id,
|
||||
).first()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Member not found",
|
||||
)
|
||||
|
||||
# Allow self-removal or admin access
|
||||
is_self_removal = member.user_id == current_user.id
|
||||
if not is_self_removal and not check_project_edit_access(current_user, project):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only project owner, admin members, or the member themselves can remove membership",
|
||||
)
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="project_member.remove",
|
||||
resource_type="project_member",
|
||||
action=AuditAction.DELETE,
|
||||
user_id=current_user.id,
|
||||
resource_id=member.id,
|
||||
changes=[
|
||||
{"field": "user_id", "old_value": member.user_id, "new_value": None},
|
||||
{"field": "role", "old_value": member.role, "new_value": None},
|
||||
],
|
||||
request_metadata=get_audit_metadata(request),
|
||||
)
|
||||
|
||||
db.delete(member)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
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, ReportHistory, ScheduledReport
|
||||
from app.schemas.report import (
|
||||
WeeklyReportContent, ReportHistoryListResponse, ReportHistoryItem,
|
||||
@@ -35,12 +37,16 @@ async def preview_weekly_report(
|
||||
|
||||
|
||||
@router.post("/api/reports/weekly/generate", response_model=GenerateReportResponse)
|
||||
@limiter.limit(settings.RATE_LIMIT_HEAVY)
|
||||
async def generate_weekly_report(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Manually trigger weekly report generation for the current user.
|
||||
|
||||
Rate limited: 5 requests per minute (heavy tier).
|
||||
"""
|
||||
# Generate report
|
||||
report_history = ReportService.generate_weekly_report(db, current_user.id)
|
||||
@@ -112,13 +118,17 @@ async def list_report_history(
|
||||
|
||||
|
||||
@router.get("/api/reports/history/{report_id}")
|
||||
@limiter.limit(settings.RATE_LIMIT_SENSITIVE)
|
||||
async def get_report_detail(
|
||||
request: Request,
|
||||
report_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get detailed content of a specific report.
|
||||
|
||||
Rate limited: 20 requests per minute (sensitive tier).
|
||||
"""
|
||||
report = db.query(ReportHistory).filter(ReportHistory.id == report_id).first()
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
3
backend/app/api/templates/__init__.py
Normal file
3
backend/app/api/templates/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.templates.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
440
backend/app/api/templates/router.py
Normal file
440
backend/app/api/templates/router.py
Normal file
@@ -0,0 +1,440 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -19,6 +20,9 @@ router = APIRouter(tags=["websocket"])
|
||||
PING_INTERVAL = 60.0 # Send ping after this many seconds of no messages
|
||||
PONG_TIMEOUT = 30.0 # Disconnect if no pong received within this time after ping
|
||||
|
||||
# Authentication timeout (10 seconds)
|
||||
AUTH_TIMEOUT = 10.0
|
||||
|
||||
|
||||
async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
|
||||
"""Validate token and return user_id and user object."""
|
||||
@@ -47,6 +51,56 @@ async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
|
||||
db.close()
|
||||
|
||||
|
||||
async def authenticate_websocket(
|
||||
websocket: WebSocket,
|
||||
query_token: Optional[str] = None
|
||||
) -> tuple[str | None, User | None]:
|
||||
"""
|
||||
Authenticate WebSocket connection.
|
||||
|
||||
Supports two authentication methods:
|
||||
1. First message authentication (preferred, more secure)
|
||||
- Client sends: {"type": "auth", "token": "<jwt_token>"}
|
||||
2. Query parameter authentication (deprecated, for backward compatibility)
|
||||
- Client connects with: ?token=<jwt_token>
|
||||
|
||||
Returns (user_id, user) if authenticated, (None, None) otherwise.
|
||||
"""
|
||||
# If token provided via query parameter (backward compatibility)
|
||||
if query_token:
|
||||
logger.warning(
|
||||
"WebSocket authentication via query parameter is deprecated. "
|
||||
"Please use first-message authentication for better security."
|
||||
)
|
||||
return await get_user_from_token(query_token)
|
||||
|
||||
# Wait for authentication message with timeout
|
||||
try:
|
||||
data = await asyncio.wait_for(
|
||||
websocket.receive_json(),
|
||||
timeout=AUTH_TIMEOUT
|
||||
)
|
||||
|
||||
msg_type = data.get("type")
|
||||
if msg_type != "auth":
|
||||
logger.warning("Expected 'auth' message type, got: %s", msg_type)
|
||||
return None, None
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
logger.warning("No token provided in auth message")
|
||||
return None, None
|
||||
|
||||
return await get_user_from_token(token)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("WebSocket authentication timeout after %.1f seconds", AUTH_TIMEOUT)
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error("Error during WebSocket authentication: %s", e)
|
||||
return None, None
|
||||
|
||||
|
||||
async def get_unread_notifications(user_id: str) -> list[dict]:
|
||||
"""Query all unread notifications for a user."""
|
||||
db = SessionLocal()
|
||||
@@ -90,14 +144,22 @@ async def get_unread_count(user_id: str) -> int:
|
||||
@router.websocket("/ws/notifications")
|
||||
async def websocket_notifications(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(..., description="JWT token for authentication"),
|
||||
token: Optional[str] = Query(None, description="JWT token (deprecated, use first-message auth)"),
|
||||
):
|
||||
"""
|
||||
WebSocket endpoint for real-time notifications.
|
||||
|
||||
Connect with: ws://host/ws/notifications?token=<jwt_token>
|
||||
Authentication methods (in order of preference):
|
||||
1. First message authentication (recommended):
|
||||
- Connect without token: ws://host/ws/notifications
|
||||
- Send: {"type": "auth", "token": "<jwt_token>"}
|
||||
- Must authenticate within 10 seconds or connection will be closed
|
||||
|
||||
2. Query parameter (deprecated, for backward compatibility):
|
||||
- Connect with: ws://host/ws/notifications?token=<jwt_token>
|
||||
|
||||
Messages sent by server:
|
||||
- {"type": "auth_required"} - Sent when waiting for auth message
|
||||
- {"type": "connected", "data": {"user_id": "...", "message": "..."}} - Connection success
|
||||
- {"type": "unread_sync", "data": {"notifications": [...], "unread_count": N}} - All unread on connect
|
||||
- {"type": "notification", "data": {...}} - New notification
|
||||
@@ -106,9 +168,18 @@ async def websocket_notifications(
|
||||
- {"type": "pong"} - Response to client ping
|
||||
|
||||
Messages accepted from client:
|
||||
- {"type": "auth", "token": "..."} - Authentication (must be first message if no query token)
|
||||
- {"type": "ping"} - Client keepalive ping
|
||||
"""
|
||||
user_id, user = await get_user_from_token(token)
|
||||
# Accept WebSocket connection first
|
||||
await websocket.accept()
|
||||
|
||||
# If no query token, notify client that auth is required
|
||||
if not token:
|
||||
await websocket.send_json({"type": "auth_required"})
|
||||
|
||||
# Authenticate
|
||||
user_id, user = await authenticate_websocket(websocket, token)
|
||||
|
||||
if user_id is None:
|
||||
await websocket.close(code=4001, reason="Invalid or expired token")
|
||||
@@ -263,14 +334,22 @@ async def verify_project_access(user_id: str, project_id: str) -> tuple[bool, Pr
|
||||
async def websocket_project_sync(
|
||||
websocket: WebSocket,
|
||||
project_id: str,
|
||||
token: str = Query(..., description="JWT token for authentication"),
|
||||
token: Optional[str] = Query(None, description="JWT token (deprecated, use first-message auth)"),
|
||||
):
|
||||
"""
|
||||
WebSocket endpoint for project task real-time sync.
|
||||
|
||||
Connect with: ws://host/ws/projects/{project_id}?token=<jwt_token>
|
||||
Authentication methods (in order of preference):
|
||||
1. First message authentication (recommended):
|
||||
- Connect without token: ws://host/ws/projects/{project_id}
|
||||
- Send: {"type": "auth", "token": "<jwt_token>"}
|
||||
- Must authenticate within 10 seconds or connection will be closed
|
||||
|
||||
2. Query parameter (deprecated, for backward compatibility):
|
||||
- Connect with: ws://host/ws/projects/{project_id}?token=<jwt_token>
|
||||
|
||||
Messages sent by server:
|
||||
- {"type": "auth_required"} - Sent when waiting for auth message
|
||||
- {"type": "connected", "data": {"project_id": "...", "user_id": "..."}}
|
||||
- {"type": "task_created", "data": {...}, "triggered_by": "..."}
|
||||
- {"type": "task_updated", "data": {...}, "triggered_by": "..."}
|
||||
@@ -280,10 +359,18 @@ async def websocket_project_sync(
|
||||
- {"type": "ping"} / {"type": "pong"}
|
||||
|
||||
Messages accepted from client:
|
||||
- {"type": "auth", "token": "..."} - Authentication (must be first message if no query token)
|
||||
- {"type": "ping"} - Client keepalive ping
|
||||
"""
|
||||
# Accept WebSocket connection first
|
||||
await websocket.accept()
|
||||
|
||||
# If no query token, notify client that auth is required
|
||||
if not token:
|
||||
await websocket.send_json({"type": "auth_required"})
|
||||
|
||||
# Authenticate user
|
||||
user_id, user = await get_user_from_token(token)
|
||||
user_id, user = await authenticate_websocket(websocket, token)
|
||||
|
||||
if user_id is None:
|
||||
await websocket.close(code=4001, reason="Invalid or expired token")
|
||||
@@ -300,8 +387,7 @@ async def websocket_project_sync(
|
||||
await websocket.close(code=4004, reason="Project not found")
|
||||
return
|
||||
|
||||
# Accept connection and join project room
|
||||
await websocket.accept()
|
||||
# Join project room
|
||||
await manager.join_project(websocket, user_id, project_id)
|
||||
|
||||
# Create Redis subscriber for project task events
|
||||
|
||||
@@ -41,22 +41,34 @@ def check_workload_access(
|
||||
"""
|
||||
Check if current user has access to view workload data.
|
||||
|
||||
Access rules:
|
||||
- System admin: can access all workloads
|
||||
- Department manager: can access workloads of users in their department
|
||||
- Regular user: can only access their own workload
|
||||
|
||||
Raises HTTPException if access is denied.
|
||||
"""
|
||||
# System admin can access all
|
||||
if current_user.is_system_admin:
|
||||
return
|
||||
|
||||
# If querying specific user, must be self
|
||||
# (Phase 1: only self access for non-admin users)
|
||||
# If querying specific user
|
||||
if target_user_id and target_user_id != current_user.id:
|
||||
# Department manager can view subordinates' workload
|
||||
if current_user.is_department_manager:
|
||||
# Manager can view users in their department
|
||||
# target_user_department_id must be provided for this check
|
||||
if target_user_department_id and target_user_department_id == current_user.department_id:
|
||||
return
|
||||
# Access denied for non-manager or user not in same department
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied: Cannot view other users' workload",
|
||||
)
|
||||
|
||||
# If querying by department, must be same department
|
||||
# If querying by department
|
||||
if department_id and department_id != current_user.department_id:
|
||||
# Department manager can only query their own department
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied: Cannot view other departments' workload",
|
||||
@@ -66,15 +78,40 @@ def check_workload_access(
|
||||
def filter_accessible_users(
|
||||
current_user: User,
|
||||
user_ids: Optional[List[str]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[List[str]]:
|
||||
"""
|
||||
Filter user IDs to only those accessible by current user.
|
||||
Returns None if user can access all (system admin).
|
||||
|
||||
Access rules:
|
||||
- System admin: can see all users
|
||||
- Department manager: can see all users in their department
|
||||
- Regular user: can only see themselves
|
||||
"""
|
||||
# System admin can access all
|
||||
if current_user.is_system_admin:
|
||||
return user_ids
|
||||
|
||||
# Department manager can see all users in their department
|
||||
if current_user.is_department_manager and current_user.department_id and db:
|
||||
# Get all users in the same department
|
||||
department_users = db.query(User.id).filter(
|
||||
User.department_id == current_user.department_id,
|
||||
User.is_active == True
|
||||
).all()
|
||||
department_user_ids = {u.id for u in department_users}
|
||||
|
||||
if user_ids:
|
||||
# Filter to only users in manager's department
|
||||
accessible = [uid for uid in user_ids if uid in department_user_ids]
|
||||
if not accessible:
|
||||
return [current_user.id]
|
||||
return accessible
|
||||
else:
|
||||
# No filter specified, return all department users
|
||||
return list(department_user_ids)
|
||||
|
||||
# Regular user can only see themselves
|
||||
if user_ids:
|
||||
# Filter to only accessible users
|
||||
@@ -111,6 +148,11 @@ async def get_heatmap(
|
||||
"""
|
||||
Get workload heatmap for users.
|
||||
|
||||
Access Rules:
|
||||
- System admin: Can view all users' workload
|
||||
- Department manager: Can view workload of all users in their department
|
||||
- Regular user: Can only view their own workload
|
||||
|
||||
Returns workload summaries for users showing:
|
||||
- allocated_hours: Total estimated hours from tasks due this week
|
||||
- capacity_hours: User's weekly capacity
|
||||
@@ -126,8 +168,8 @@ async def get_heatmap(
|
||||
if department_id:
|
||||
check_workload_access(current_user, department_id=department_id)
|
||||
|
||||
# Filter user_ids based on access
|
||||
accessible_user_ids = filter_accessible_users(current_user, parsed_user_ids)
|
||||
# Filter user_ids based on access (pass db for manager department lookup)
|
||||
accessible_user_ids = filter_accessible_users(current_user, parsed_user_ids, db)
|
||||
|
||||
# Normalize week_start
|
||||
if week_start is None:
|
||||
@@ -181,12 +223,25 @@ async def get_user_workload(
|
||||
"""
|
||||
Get detailed workload for a specific user.
|
||||
|
||||
Access rules:
|
||||
- System admin: can view any user's workload
|
||||
- Department manager: can view workload of users in their department
|
||||
- Regular user: can only view their own workload
|
||||
|
||||
Returns:
|
||||
- Workload summary (same as heatmap)
|
||||
- List of tasks contributing to the workload
|
||||
"""
|
||||
# Check access
|
||||
check_workload_access(current_user, target_user_id=user_id)
|
||||
# Get target user's department for manager access check
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
target_user_department_id = target_user.department_id if target_user else None
|
||||
|
||||
# Check access (pass target user's department for manager check)
|
||||
check_workload_access(
|
||||
current_user,
|
||||
target_user_id=user_id,
|
||||
target_user_department_id=target_user_department_id
|
||||
)
|
||||
|
||||
# Calculate workload detail
|
||||
detail = get_user_workload_detail(db, user_id, week_start)
|
||||
|
||||
Reference in New Issue
Block a user