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

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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)
)

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
from app.api.templates.router import router
__all__ = ["router"]

View 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,
)

View File

@@ -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

View File

@@ -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)