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

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