UMBRA/backend/app/routers/projects.py
Kyle Pope fbc452a004 Implement Stage 6 Track A: PIN → Username/Password auth migration
- New User model (username, argon2id password_hash, totp fields, lockout)
- New UserSession model (DB-backed revocation, replaces in-memory set)
- New services/auth.py: Argon2id hashing, bcrypt→Argon2id upgrade path, URLSafeTimedSerializer session/MFA tokens
- New schemas/auth.py: SetupRequest, LoginRequest, ChangePasswordRequest with OWASP password strength validation
- Full rewrite of routers/auth.py: setup/login/logout/status/change-password with account lockout (10 failures → 30-min, HTTP 423), IP rate limiting retained as outer layer, get_current_user + get_current_settings dependencies replacing get_current_session
- Settings model: drop pin_hash, add user_id FK (nullable for migration)
- Schemas/settings.py: remove SettingsCreate, ChangePinRequest, _validate_pin_length
- Settings router: rewrite to use get_current_user + get_current_settings, preserve ntfy test endpoint
- All 11 consumer routers updated: auth-gate-only routers use get_current_user, routers reading Settings fields use get_current_settings
- config.py: add SESSION_MAX_AGE_DAYS, MFA_TOKEN_MAX_AGE_SECONDS, TOTP_ISSUER
- main.py: import User and UserSession models for Alembic discovery
- requirements.txt: add argon2-cffi>=23.1.0
- Migration 023: create users + user_sessions tables, migrate pin_hash → User row (admin), backfill settings.user_id, drop pin_hash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:12:37 +08:00

404 lines
12 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import date, timedelta
from pydantic import BaseModel
from app.database import get_db
from app.models.project import Project
from app.models.project_task import ProjectTask
from app.models.task_comment import TaskComment
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
from app.routers.auth import get_current_user
from app.models.user import User
router = APIRouter()
class ReorderItem(BaseModel):
id: int
sort_order: int
def _project_load_options():
"""All load options needed for project responses (tasks + subtasks + comments at each level)."""
return [
selectinload(Project.tasks).selectinload(ProjectTask.comments),
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
]
def _task_load_options():
"""All load options needed for task responses."""
return [
selectinload(ProjectTask.comments),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
]
@router.get("/", response_model=List[ProjectResponse])
async def get_projects(
tracked: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all projects with their tasks. Optionally filter by tracked status."""
query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc())
if tracked is not None:
query = query.where(Project.is_tracked == tracked)
result = await db.execute(query)
projects = result.scalars().unique().all()
return projects
# This route MUST be defined before /{project_id} to avoid path parameter shadowing
@router.get("/tracked-tasks", response_model=List[TrackedTaskResponse])
async def get_tracked_tasks(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get tasks and subtasks from tracked projects with due dates within the next N days."""
today = date.today()
cutoff = today + timedelta(days=days)
query = (
select(ProjectTask)
.join(Project, ProjectTask.project_id == Project.id)
.options(
selectinload(ProjectTask.project),
selectinload(ProjectTask.parent_task),
)
.where(
Project.is_tracked == True,
ProjectTask.due_date.isnot(None),
ProjectTask.due_date >= today,
ProjectTask.due_date <= cutoff,
ProjectTask.status != "completed",
)
.order_by(ProjectTask.due_date.asc())
)
result = await db.execute(query)
tasks = result.scalars().unique().all()
return [
TrackedTaskResponse(
id=t.id,
title=t.title,
status=t.status,
priority=t.priority,
due_date=t.due_date,
project_name=t.project.name,
project_id=t.project_id,
parent_task_title=t.parent_task.title if t.parent_task else None,
)
for t in tasks
]
@router.post("/", response_model=ProjectResponse, status_code=201)
async def create_project(
project: ProjectCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new project."""
new_project = Project(**project.model_dump())
db.add(new_project)
await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization
query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id)
result = await db.execute(query)
return result.scalar_one()
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific project by ID with its tasks."""
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
result = await db.execute(query)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.put("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: int,
project_update: ProjectUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
update_data = project_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(project, key, value)
await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
result = await db.execute(query)
return result.scalar_one()
@router.delete("/{project_id}", status_code=204)
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a project and all its tasks."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
await db.delete(project)
await db.commit()
return None
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
async def get_project_tasks(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get top-level tasks for a specific project (subtasks are nested)."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
query = (
select(ProjectTask)
.options(*_task_load_options())
.where(
ProjectTask.project_id == project_id,
ProjectTask.parent_task_id.is_(None),
)
.order_by(ProjectTask.sort_order.asc())
)
result = await db.execute(query)
tasks = result.scalars().unique().all()
return tasks
@router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201)
async def create_project_task(
project_id: int,
task: ProjectTaskCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new task or subtask for a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Validate parent_task_id if creating a subtask
if task.parent_task_id is not None:
parent_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task.parent_task_id,
ProjectTask.project_id == project_id,
ProjectTask.parent_task_id.is_(None), # Parent must be top-level
)
)
parent_task = parent_result.scalar_one_or_none()
if not parent_task:
raise HTTPException(
status_code=400,
detail="Parent task not found or is itself a subtask",
)
task_data = task.model_dump()
task_data["project_id"] = project_id
new_task = ProjectTask(**task_data)
db.add(new_task)
await db.commit()
# Re-fetch with subtasks loaded
query = (
select(ProjectTask)
.options(*_task_load_options())
.where(ProjectTask.id == new_task.id)
)
result = await db.execute(query)
return result.scalar_one()
@router.put("/{project_id}/tasks/reorder", status_code=200)
async def reorder_tasks(
project_id: int,
items: List[ReorderItem],
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Bulk update sort_order for tasks."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
for item in items:
task_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == item.id,
ProjectTask.project_id == project_id
)
)
task = task_result.scalar_one_or_none()
if task:
task.sort_order = item.sort_order
await db.commit()
return {"status": "ok"}
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
async def update_project_task(
project_id: int,
task_id: int,
task_update: ProjectTaskUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a project task."""
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
update_data = task_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(task, key, value)
await db.commit()
# Re-fetch with subtasks loaded
query = (
select(ProjectTask)
.options(*_task_load_options())
.where(ProjectTask.id == task_id)
)
result = await db.execute(query)
return result.scalar_one()
@router.delete("/{project_id}/tasks/{task_id}", status_code=204)
async def delete_project_task(
project_id: int,
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a project task (cascades to subtasks)."""
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await db.delete(task)
await db.commit()
return None
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
async def create_task_comment(
project_id: int,
task_id: int,
comment: TaskCommentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Add a comment to a task."""
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
new_comment = TaskComment(task_id=task_id, content=comment.content)
db.add(new_comment)
await db.commit()
await db.refresh(new_comment)
return new_comment
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
async def delete_task_comment(
project_id: int,
task_id: int,
comment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a task comment."""
result = await db.execute(
select(TaskComment).where(
TaskComment.id == comment_id,
TaskComment.task_id == task_id
)
)
comment = result.scalar_one_or_none()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
await db.delete(comment)
await db.commit()
return None