Phase 1: Add role, mfa_enforce_pending, must_change_password to users table. Create system_config (singleton) and audit_log tables. Migration 026. Phase 2: Add user_id FK to all 8 data tables (todos, reminders, projects, calendars, people, locations, event_templates, ntfy_sent) with 4-step nullable→backfill→FK→NOT NULL pattern. Migrations 027-034. Phase 3: Harden auth schemas (extra="forbid" on RegisterRequest), add MFA enforcement token serializer with distinct salt, rewrite auth router with require_role() factory and registration endpoint. Phase 4: Scope all 12 routers by user_id, fix dependency type bugs, bound weather cache (SEC-15), multi-user ntfy dispatch. Phase 5: Create admin router (14 endpoints), admin schemas, audit service, rate limiting in nginx. SEC-08 CSRF via X-Requested-With. Phase 6: Update frontend types, useAuth hook (role/isAdmin/register), App.tsx (AdminRoute guard), Sidebar (admin link), api.ts (XHR header). Security findings addressed: SEC-01, SEC-02, SEC-03, SEC-04, SEC-05, SEC-06, SEC-07, SEC-08, SEC-12, SEC-13, SEC-15. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
455 lines
14 KiB
Python
455 lines
14 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())
|
|
.where(Project.user_id == current_user.id)
|
|
.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.user_id == current_user.id,
|
|
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(), user_id=current_user.id)
|
|
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, Project.user_id == current_user.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.user_id == current_user.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.user_id == current_user.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)."""
|
|
# Verify project ownership first
|
|
result = await db.execute(
|
|
select(Project).where(Project.id == project_id, Project.user_id == current_user.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."""
|
|
# Verify project ownership first
|
|
result = await db.execute(
|
|
select(Project).where(Project.id == project_id, Project.user_id == current_user.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."""
|
|
# Verify project ownership first
|
|
result = await db.execute(
|
|
select(Project).where(Project.id == project_id, Project.user_id == current_user.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."""
|
|
# Verify project ownership first, then fetch task scoped to that project
|
|
project_result = await db.execute(
|
|
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
|
)
|
|
if not project_result.scalar_one_or_none():
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
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)."""
|
|
# Verify project ownership first, then fetch task scoped to that project
|
|
project_result = await db.execute(
|
|
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
|
)
|
|
if not project_result.scalar_one_or_none():
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
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."""
|
|
# Verify project ownership first, then fetch task scoped to that project
|
|
project_result = await db.execute(
|
|
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
|
)
|
|
if not project_result.scalar_one_or_none():
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
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."""
|
|
# Verify project ownership first, then fetch comment scoped through task
|
|
project_result = await db.execute(
|
|
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
|
)
|
|
if not project_result.scalar_one_or_none():
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
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
|