feat: implement task management module

Backend (FastAPI):
- Database migration for spaces, projects, task_statuses, tasks tables
- SQLAlchemy models with relationships
- Pydantic schemas for CRUD operations
- Spaces API: CRUD with soft delete
- Projects API: CRUD with auto-created default statuses
- Tasks API: CRUD, status change, assign, subtask support
- Permission middleware with Security Level filtering
- Subtask depth limit (max 2 levels)

Frontend (React + Vite):
- Layout component with navigation
- Spaces list page
- Projects list page
- Tasks list page with status management

Fixes:
- auth_client.py: use 'username' field for external API
- config.py: extend JWT expiry to 7 days
- auth/router.py: sync Redis session with JWT expiry

Tests: 36 passed (unit + integration)
E2E: All APIs verified with real authentication

OpenSpec: add-task-management archived

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-12-29 00:31:34 +08:00
parent 1fda7da2c2
commit daca7798e3
41 changed files with 3616 additions and 13 deletions

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.core.security import create_access_token, create_token_payload
from app.core.redis import get_redis
@@ -74,10 +75,10 @@ async def login(
# Create access token
access_token = create_access_token(token_data)
# Store session in Redis
# Store session in Redis (sync with JWT expiry)
redis_client.setex(
f"session:{user.id}",
900, # 15 minutes
settings.JWT_EXPIRE_MINUTES * 60, # Convert to seconds
access_token,
)

View File

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

View File

@@ -0,0 +1,273 @@
import uuid
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import User, Space, Project, TaskStatus
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.middleware.auth import (
get_current_user, check_space_access, check_space_edit_access,
check_project_access, check_project_edit_access
)
router = APIRouter(tags=["projects"])
def create_default_statuses(db: Session, project_id: str):
"""Create default task statuses for a new project."""
for status_data in DEFAULT_STATUSES:
status = TaskStatus(
id=str(uuid.uuid4()),
project_id=project_id,
**status_data
)
db.add(status)
@router.get("/api/spaces/{space_id}/projects", response_model=List[ProjectWithDetails])
async def list_projects_in_space(
space_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List all projects in a space that the user can access.
"""
space = db.query(Space).filter(Space.id == 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",
)
projects = db.query(Project).filter(Project.space_id == space_id).all()
# Filter by project access
accessible_projects = [p for p in projects if check_project_access(current_user, p)]
result = []
for project in accessible_projects:
task_count = len(project.tasks) if project.tasks else 0
result.append(ProjectWithDetails(
id=project.id,
space_id=project.space_id,
title=project.title,
description=project.description,
owner_id=project.owner_id,
budget=project.budget,
start_date=project.start_date,
end_date=project.end_date,
security_level=project.security_level,
status=project.status,
department_id=project.department_id,
created_at=project.created_at,
updated_at=project.updated_at,
owner_name=project.owner.name if project.owner else None,
space_name=project.space.name if project.space else None,
department_name=project.department.name if project.department else None,
task_count=task_count,
))
return result
@router.post("/api/spaces/{space_id}/projects", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(
space_id: str,
project_data: ProjectCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Create a new project in a space.
"""
space = db.query(Space).filter(Space.id == 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",
)
project = Project(
id=str(uuid.uuid4()),
space_id=space_id,
title=project_data.title,
description=project_data.description,
owner_id=current_user.id,
budget=project_data.budget,
start_date=project_data.start_date,
end_date=project_data.end_date,
security_level=project_data.security_level.value if project_data.security_level else "department",
department_id=project_data.department_id or current_user.department_id,
)
db.add(project)
db.flush() # Get the project ID
# Create default task statuses
create_default_statuses(db, project.id)
db.commit()
db.refresh(project)
return project
@router.get("/api/projects/{project_id}", response_model=ProjectWithDetails)
async def get_project(
project_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Get a project by ID.
"""
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",
)
task_count = len(project.tasks) if project.tasks else 0
return ProjectWithDetails(
id=project.id,
space_id=project.space_id,
title=project.title,
description=project.description,
owner_id=project.owner_id,
budget=project.budget,
start_date=project.start_date,
end_date=project.end_date,
security_level=project.security_level,
status=project.status,
department_id=project.department_id,
created_at=project.created_at,
updated_at=project.updated_at,
owner_name=project.owner.name if project.owner else None,
space_name=project.space.name if project.space else None,
department_name=project.department.name if project.department else None,
task_count=task_count,
)
@router.patch("/api/projects/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: str,
project_data: ProjectUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update a project.
"""
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="Only project owner can update",
)
# Update fields
update_data = project_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "security_level" and value:
setattr(project, field, value.value)
else:
setattr(project, field, value)
db.commit()
db.refresh(project)
return project
@router.delete("/api/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Delete a project (hard delete, cascades to tasks).
"""
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="Only project owner can delete",
)
db.delete(project)
db.commit()
return None
@router.get("/api/projects/{project_id}/statuses", response_model=List[TaskStatusResponse])
async def list_project_statuses(
project_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List all task statuses for a project.
"""
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",
)
statuses = db.query(TaskStatus).filter(
TaskStatus.project_id == project_id
).order_by(TaskStatus.position).all()
return statuses

View File

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

View File

@@ -0,0 +1,164 @@
import uuid
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import User, Space
from app.schemas.space import SpaceCreate, SpaceUpdate, SpaceResponse, SpaceWithOwner
from app.middleware.auth import get_current_user, check_space_access, check_space_edit_access
router = APIRouter(prefix="/api/spaces", tags=["spaces"])
@router.get("", response_model=List[SpaceWithOwner])
async def list_spaces(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List all active spaces the user can access.
"""
spaces = db.query(Space).filter(Space.is_active == True).all()
# Filter by access (currently all users can see all spaces)
accessible_spaces = [s for s in spaces if check_space_access(current_user, s)]
result = []
for space in accessible_spaces:
result.append(SpaceWithOwner(
id=space.id,
name=space.name,
description=space.description,
owner_id=space.owner_id,
is_active=space.is_active,
created_at=space.created_at,
updated_at=space.updated_at,
owner_name=space.owner.name if space.owner else None,
))
return result
@router.post("", response_model=SpaceResponse, status_code=status.HTTP_201_CREATED)
async def create_space(
space_data: SpaceCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Create a new space.
"""
space = Space(
id=str(uuid.uuid4()),
name=space_data.name,
description=space_data.description,
owner_id=current_user.id,
is_active=True,
)
db.add(space)
db.commit()
db.refresh(space)
return space
@router.get("/{space_id}", response_model=SpaceWithOwner)
async def get_space(
space_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Get a space by ID.
"""
space = db.query(Space).filter(Space.id == space_id).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",
)
return SpaceWithOwner(
id=space.id,
name=space.name,
description=space.description,
owner_id=space.owner_id,
is_active=space.is_active,
created_at=space.created_at,
updated_at=space.updated_at,
owner_name=space.owner.name if space.owner else None,
)
@router.patch("/{space_id}", response_model=SpaceResponse)
async def update_space(
space_id: str,
space_data: SpaceUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update a space.
"""
space = db.query(Space).filter(Space.id == space_id).first()
if not space:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Space not found",
)
if not check_space_edit_access(current_user, space):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only space owner can update",
)
# Update fields
update_data = space_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(space, field, value)
db.commit()
db.refresh(space)
return space
@router.delete("/{space_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_space(
space_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Soft delete a space (set is_active = False).
"""
space = db.query(Space).filter(Space.id == space_id).first()
if not space:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Space not found",
)
if not check_space_edit_access(current_user, space):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only space owner can delete",
)
# Soft delete
space.is_active = False
db.commit()
return None

View File

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

View File

@@ -0,0 +1,420 @@
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import User, Project, Task, TaskStatus
from app.schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskWithDetails, TaskListResponse,
TaskStatusUpdate, TaskAssignUpdate
)
from app.middleware.auth import (
get_current_user, check_project_access, check_task_access, check_task_edit_access
)
router = APIRouter(tags=["tasks"])
# Maximum subtask depth
MAX_SUBTASK_DEPTH = 2
def get_task_depth(db: Session, task: Task) -> int:
"""Calculate the depth of a task in the hierarchy."""
depth = 1
current = task
while current.parent_task_id:
depth += 1
current = db.query(Task).filter(Task.id == current.parent_task_id).first()
if not current:
break
return depth
def task_to_response(task: Task) -> TaskWithDetails:
"""Convert a Task model to TaskWithDetails response."""
return TaskWithDetails(
id=task.id,
project_id=task.project_id,
parent_task_id=task.parent_task_id,
title=task.title,
description=task.description,
priority=task.priority,
original_estimate=task.original_estimate,
time_spent=task.time_spent,
due_date=task.due_date,
assignee_id=task.assignee_id,
status_id=task.status_id,
blocker_flag=task.blocker_flag,
position=task.position,
created_by=task.created_by,
created_at=task.created_at,
updated_at=task.updated_at,
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,
creator_name=task.creator.name if task.creator else None,
subtask_count=len(task.subtasks) if task.subtasks else 0,
)
@router.get("/api/projects/{project_id}/tasks", response_model=TaskListResponse)
async def list_tasks(
project_id: str,
parent_task_id: Optional[str] = Query(None, description="Filter by parent task"),
status_id: Optional[str] = Query(None, description="Filter by status"),
assignee_id: Optional[str] = Query(None, description="Filter by assignee"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List all tasks in a project.
"""
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",
)
query = db.query(Task).filter(Task.project_id == project_id)
# Apply filters
if parent_task_id is not None:
if parent_task_id == "":
# Root tasks only
query = query.filter(Task.parent_task_id == None)
else:
query = query.filter(Task.parent_task_id == parent_task_id)
else:
# By default, show only root tasks
query = query.filter(Task.parent_task_id == None)
if status_id:
query = query.filter(Task.status_id == status_id)
if assignee_id:
query = query.filter(Task.assignee_id == assignee_id)
tasks = query.order_by(Task.position, Task.created_at).all()
return TaskListResponse(
tasks=[task_to_response(t) for t in tasks],
total=len(tasks),
)
@router.post("/api/projects/{project_id}/tasks", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(
project_id: str,
task_data: TaskCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Create a new task in a project.
"""
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",
)
# Validate parent task and check depth
if task_data.parent_task_id:
parent_task = db.query(Task).filter(
Task.id == task_data.parent_task_id,
Task.project_id == project_id
).first()
if not parent_task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Parent task not found in this project",
)
# Check depth limit
parent_depth = get_task_depth(db, parent_task)
if parent_depth >= MAX_SUBTASK_DEPTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Maximum subtask depth ({MAX_SUBTASK_DEPTH}) exceeded",
)
# Validate status_id belongs to this project
if task_data.status_id:
status_obj = db.query(TaskStatus).filter(
TaskStatus.id == task_data.status_id,
TaskStatus.project_id == project_id
).first()
if not status_obj:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Status not found in this project",
)
else:
# Use first status (To Do) as default
default_status = db.query(TaskStatus).filter(
TaskStatus.project_id == project_id
).order_by(TaskStatus.position).first()
task_data.status_id = default_status.id if default_status else None
# Get max position
max_pos_result = db.query(Task).filter(
Task.project_id == project_id,
Task.parent_task_id == task_data.parent_task_id
).order_by(Task.position.desc()).first()
next_position = (max_pos_result.position + 1) if max_pos_result else 0
task = Task(
id=str(uuid.uuid4()),
project_id=project_id,
parent_task_id=task_data.parent_task_id,
title=task_data.title,
description=task_data.description,
priority=task_data.priority.value if task_data.priority else "medium",
original_estimate=task_data.original_estimate,
due_date=task_data.due_date,
assignee_id=task_data.assignee_id,
status_id=task_data.status_id,
position=next_position,
created_by=current_user.id,
)
db.add(task)
db.commit()
db.refresh(task)
return task
@router.get("/api/tasks/{task_id}", response_model=TaskWithDetails)
async def get_task(
task_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Get a task by ID.
"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
if not check_task_access(current_user, task, task.project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
return task_to_response(task)
@router.patch("/api/tasks/{task_id}", response_model=TaskResponse)
async def update_task(
task_id: str,
task_data: TaskUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update a task.
"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
if not check_task_edit_access(current_user, task, task.project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
# Update fields
update_data = task_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "priority" and value:
setattr(task, field, value.value)
else:
setattr(task, field, value)
db.commit()
db.refresh(task)
return task
@router.delete("/api/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(
task_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Delete a task (cascades to subtasks).
"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
if not check_task_edit_access(current_user, task, task.project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
db.delete(task)
db.commit()
return None
@router.patch("/api/tasks/{task_id}/status", response_model=TaskResponse)
async def update_task_status(
task_id: str,
status_data: TaskStatusUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update task status.
"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
if not check_task_edit_access(current_user, task, task.project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
# Validate new status belongs to same project
new_status = db.query(TaskStatus).filter(
TaskStatus.id == status_data.status_id,
TaskStatus.project_id == task.project_id
).first()
if not new_status:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Status not found in this project",
)
task.status_id = status_data.status_id
# Auto-set blocker_flag based on status name
if new_status.name.lower() == "blocked":
task.blocker_flag = True
else:
task.blocker_flag = False
db.commit()
db.refresh(task)
return task
@router.patch("/api/tasks/{task_id}/assign", response_model=TaskResponse)
async def assign_task(
task_id: str,
assign_data: TaskAssignUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Assign or unassign a task.
"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
if not check_task_edit_access(current_user, task, task.project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
# Validate assignee exists if provided
if assign_data.assignee_id:
assignee = db.query(User).filter(User.id == assign_data.assignee_id).first()
if not assignee:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Assignee not found",
)
task.assignee_id = assign_data.assignee_id
db.commit()
db.refresh(task)
return task
@router.get("/api/tasks/{task_id}/subtasks", response_model=TaskListResponse)
async def list_subtasks(
task_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List subtasks of a task.
"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
if not check_task_access(current_user, task, task.project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
subtasks = db.query(Task).filter(
Task.parent_task_id == task_id
).order_by(Task.position, Task.created_at).all()
return TaskListResponse(
tasks=[task_to_response(t) for t in subtasks],
total=len(subtasks),
)

View File

@@ -27,7 +27,7 @@ class Settings(BaseSettings):
# JWT
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 15
JWT_EXPIRE_MINUTES: int = 10080 # 7 days
# External Auth API
AUTH_API_URL: str = "https://pj-auth-api.vercel.app"

View File

@@ -4,6 +4,9 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.auth import router as auth_router
from app.api.users import router as users_router
from app.api.departments import router as departments_router
from app.api.spaces import router as spaces_router
from app.api.projects import router as projects_router
from app.api.tasks import router as tasks_router
from app.core.config import settings
app = FastAPI(
@@ -25,6 +28,9 @@ app.add_middleware(
app.include_router(auth_router.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(users_router.router, prefix="/api/users", tags=["Users"])
app.include_router(departments_router.router, prefix="/api/departments", tags=["Departments"])
app.include_router(spaces_router)
app.include_router(projects_router)
app.include_router(tasks_router)
@app.get("/health")

View File

@@ -167,3 +167,107 @@ def check_department_access(
return True
return False
def check_space_access(user: User, space) -> bool:
"""
Check if user has access to a space.
Currently all active users can see all spaces.
Owner has edit/delete permissions.
"""
# System admin has full access
if user.is_system_admin:
return True
# All active users can view spaces
return True
def check_space_edit_access(user: User, space) -> bool:
"""
Check if user can edit/delete a space.
"""
# System admin has full access
if user.is_system_admin:
return True
# Only owner can edit
return space.owner_id == user.id
def check_project_access(user: User, project) -> bool:
"""
Check if user has access to a project based on security level.
Security Levels:
- public: All logged-in users
- department: Same department users + project owner
- confidential: Only project owner (+ system admin)
"""
# System admin bypasses all restrictions
if user.is_system_admin:
return True
# Project owner always has access
if project.owner_id == user.id:
return True
# Check by security level
security_level = project.security_level
if security_level == "public":
return True
elif security_level == "department":
# Same department has access
if project.department_id and user.department_id == project.department_id:
return True
return False
else: # confidential
# Only owner has access (already checked above)
return False
def check_project_edit_access(user: User, project) -> bool:
"""
Check if user can edit/delete a project.
"""
# System admin has full access
if user.is_system_admin:
return True
# Only owner can edit
return project.owner_id == user.id
def check_task_access(user: User, task, project) -> bool:
"""
Check if user has access to a task.
Task access is based on project access.
"""
return check_project_access(user, project)
def check_task_edit_access(user: User, task, project) -> bool:
"""
Check if user can edit a task.
"""
# System admin has full access
if user.is_system_admin:
return True
# Project owner can edit all tasks
if project.owner_id == user.id:
return True
# Task creator can edit their own tasks
if task.created_by == user.id:
return True
# Assignee can edit their assigned tasks
if task.assignee_id == user.id:
return True
return False

View File

@@ -1,5 +1,9 @@
from app.models.user import User
from app.models.role import Role
from app.models.department import Department
from app.models.space import Space
from app.models.project import Project
from app.models.task_status import TaskStatus
from app.models.task import Task
__all__ = ["User", "Role", "Department"]
__all__ = ["User", "Role", "Department", "Space", "Project", "TaskStatus", "Task"]

View File

@@ -15,3 +15,6 @@ class Department(Base):
# Self-referential relationship
parent = relationship("Department", remote_side=[id], backref="children")
# Project relationship
projects = relationship("Project", back_populates="department")

View File

@@ -0,0 +1,40 @@
from sqlalchemy import Column, String, Text, Boolean, DateTime, Date, Numeric, Enum, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
import enum
class SecurityLevel(str, enum.Enum):
PUBLIC = "public"
DEPARTMENT = "department"
CONFIDENTIAL = "confidential"
class Project(Base):
__tablename__ = "pjctrl_projects"
id = Column(String(36), primary_key=True)
space_id = Column(String(36), ForeignKey("pjctrl_spaces.id", ondelete="CASCADE"), nullable=False)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(String(36), ForeignKey("pjctrl_users.id"), nullable=False)
budget = Column(Numeric(15, 2), nullable=True)
start_date = Column(Date, nullable=True)
end_date = Column(Date, nullable=True)
security_level = Column(
Enum("public", "department", "confidential", name="security_level_enum"),
default="department",
nullable=False
)
status = Column(String(50), default="active", nullable=False)
department_id = Column(String(36), ForeignKey("pjctrl_departments.id"), nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
space = relationship("Space", back_populates="projects")
owner = relationship("User", foreign_keys=[owner_id], back_populates="owned_projects")
department = relationship("Department", back_populates="projects")
task_statuses = relationship("TaskStatus", back_populates="project", cascade="all, delete-orphan")
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")

View File

@@ -0,0 +1,20 @@
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class Space(Base):
__tablename__ = "pjctrl_spaces"
id = Column(String(36), primary_key=True)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(String(36), ForeignKey("pjctrl_users.id"), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
owner = relationship("User", back_populates="owned_spaces")
projects = relationship("Project", back_populates="space", cascade="all, delete-orphan")

View File

@@ -0,0 +1,45 @@
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, Numeric, Enum, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
import enum
class Priority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class Task(Base):
__tablename__ = "pjctrl_tasks"
id = Column(String(36), primary_key=True)
project_id = Column(String(36), ForeignKey("pjctrl_projects.id", ondelete="CASCADE"), nullable=False)
parent_task_id = Column(String(36), ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"), nullable=True)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
assignee_id = Column(String(36), ForeignKey("pjctrl_users.id"), nullable=True)
status_id = Column(String(36), ForeignKey("pjctrl_task_statuses.id"), nullable=True)
priority = Column(
Enum("low", "medium", "high", "urgent", name="priority_enum"),
default="medium",
nullable=False
)
original_estimate = Column(Numeric(8, 2), nullable=True)
time_spent = Column(Numeric(8, 2), default=0, nullable=False)
blocker_flag = Column(Boolean, default=False, nullable=False)
due_date = Column(DateTime, nullable=True)
position = Column(Integer, default=0, nullable=False)
created_by = Column(String(36), ForeignKey("pjctrl_users.id"), nullable=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
project = relationship("Project", back_populates="tasks")
parent_task = relationship("Task", remote_side=[id], back_populates="subtasks")
subtasks = relationship("Task", back_populates="parent_task", cascade="all, delete-orphan")
assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_tasks")
creator = relationship("User", foreign_keys=[created_by], back_populates="created_tasks")
status = relationship("TaskStatus", back_populates="tasks")

View File

@@ -0,0 +1,33 @@
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class TaskStatus(Base):
__tablename__ = "pjctrl_task_statuses"
id = Column(String(36), primary_key=True)
project_id = Column(String(36), ForeignKey("pjctrl_projects.id", ondelete="CASCADE"), nullable=False)
name = Column(String(50), nullable=False)
color = Column(String(7), default="#808080", nullable=False)
position = Column(Integer, default=0, nullable=False)
is_done = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
__table_args__ = (
UniqueConstraint('project_id', 'name', name='uk_status_name'),
)
# Relationships
project = relationship("Project", back_populates="task_statuses")
tasks = relationship("Task", back_populates="status")
# Default statuses to create for new projects
DEFAULT_STATUSES = [
{"name": "To Do", "color": "#808080", "position": 0, "is_done": False},
{"name": "In Progress", "color": "#0066cc", "position": 1, "is_done": False},
{"name": "Blocked", "color": "#cc0000", "position": 2, "is_done": False},
{"name": "Done", "color": "#00cc66", "position": 3, "is_done": True},
]

View File

@@ -23,3 +23,9 @@ class User(Base):
# Relationships
department = relationship("Department", backref="users")
role = relationship("Role", backref="users")
# Task management relationships
owned_spaces = relationship("Space", back_populates="owner")
owned_projects = relationship("Project", foreign_keys="Project.owner_id", back_populates="owner")
assigned_tasks = relationship("Task", foreign_keys="Task.assignee_id", back_populates="assignee")
created_tasks = relationship("Task", foreign_keys="Task.created_by", back_populates="creator")

View File

@@ -2,6 +2,15 @@ from app.schemas.auth import LoginRequest, LoginResponse, TokenPayload
from app.schemas.user import UserCreate, UserUpdate, UserResponse, UserInDB
from app.schemas.department import DepartmentCreate, DepartmentUpdate, DepartmentResponse
from app.schemas.role import RoleResponse
from app.schemas.space import SpaceCreate, SpaceUpdate, SpaceResponse, SpaceWithOwner
from app.schemas.project import (
ProjectCreate, ProjectUpdate, ProjectResponse, ProjectWithDetails, SecurityLevel
)
from app.schemas.task_status import TaskStatusCreate, TaskStatusUpdate, TaskStatusResponse
from app.schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskWithDetails, TaskListResponse,
TaskStatusUpdate as TaskStatusChangeUpdate, TaskAssignUpdate, Priority
)
__all__ = [
"LoginRequest",
@@ -15,4 +24,24 @@ __all__ = [
"DepartmentUpdate",
"DepartmentResponse",
"RoleResponse",
"SpaceCreate",
"SpaceUpdate",
"SpaceResponse",
"SpaceWithOwner",
"ProjectCreate",
"ProjectUpdate",
"ProjectResponse",
"ProjectWithDetails",
"SecurityLevel",
"TaskStatusCreate",
"TaskStatusUpdate",
"TaskStatusResponse",
"TaskCreate",
"TaskUpdate",
"TaskResponse",
"TaskWithDetails",
"TaskListResponse",
"TaskStatusChangeUpdate",
"TaskAssignUpdate",
"Priority",
]

View File

@@ -0,0 +1,55 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime, date
from decimal import Decimal
from enum import Enum
class SecurityLevel(str, Enum):
PUBLIC = "public"
DEPARTMENT = "department"
CONFIDENTIAL = "confidential"
class ProjectBase(BaseModel):
title: str
description: Optional[str] = None
budget: Optional[Decimal] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
security_level: SecurityLevel = SecurityLevel.DEPARTMENT
class ProjectCreate(ProjectBase):
department_id: Optional[str] = None
class ProjectUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
budget: Optional[Decimal] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
security_level: Optional[SecurityLevel] = None
status: Optional[str] = None
department_id: Optional[str] = None
class ProjectResponse(ProjectBase):
id: str
space_id: str
owner_id: str
status: str
department_id: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ProjectWithDetails(ProjectResponse):
owner_name: Optional[str] = None
space_name: Optional[str] = None
department_name: Optional[str] = None
task_count: int = 0

View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class SpaceBase(BaseModel):
name: str
description: Optional[str] = None
class SpaceCreate(SpaceBase):
pass
class SpaceUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class SpaceResponse(SpaceBase):
id: str
owner_id: str
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class SpaceWithOwner(SpaceResponse):
owner_name: Optional[str] = None

View File

@@ -0,0 +1,74 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
from enum import Enum
class Priority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class TaskBase(BaseModel):
title: str
description: Optional[str] = None
priority: Priority = Priority.MEDIUM
original_estimate: Optional[Decimal] = None
due_date: Optional[datetime] = None
class TaskCreate(TaskBase):
parent_task_id: Optional[str] = None
assignee_id: Optional[str] = None
status_id: Optional[str] = None
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
priority: Optional[Priority] = None
original_estimate: Optional[Decimal] = None
time_spent: Optional[Decimal] = None
due_date: Optional[datetime] = None
position: Optional[int] = None
class TaskStatusUpdate(BaseModel):
status_id: str
class TaskAssignUpdate(BaseModel):
assignee_id: Optional[str] = None
class TaskResponse(TaskBase):
id: str
project_id: str
parent_task_id: Optional[str] = None
assignee_id: Optional[str] = None
status_id: Optional[str] = None
time_spent: Decimal
blocker_flag: bool
position: int
created_by: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TaskWithDetails(TaskResponse):
assignee_name: Optional[str] = None
status_name: Optional[str] = None
status_color: Optional[str] = None
creator_name: Optional[str] = None
subtask_count: int = 0
class TaskListResponse(BaseModel):
tasks: List[TaskWithDetails]
total: int

View File

@@ -0,0 +1,30 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class TaskStatusBase(BaseModel):
name: str
color: str = "#808080"
position: int = 0
is_done: bool = False
class TaskStatusCreate(TaskStatusBase):
pass
class TaskStatusUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
position: Optional[int] = None
is_done: Optional[bool] = None
class TaskStatusResponse(TaskStatusBase):
id: str
project_id: str
created_at: datetime
class Config:
from_attributes = True

View File

@@ -35,7 +35,7 @@ async def verify_credentials(email: str, password: str) -> dict:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.AUTH_API_URL}/api/auth/login",
json={"email": email, "password": password},
json={"username": email, "password": password},
)
if response.status_code == 200: