Files
claudetools/api/routers/ticktick.py
Mike Swanson b26e185a80 Add TickTick integration, MCP server, and dev project tracking
New integration with TickTick API for project/task management:
- OAuth 2.0 auth flow (mcp-servers/ticktick/ticktick_auth.py)
- MCP server with 9 tools for Claude Code (ticktick_mcp.py)
- FastAPI service with SOPS vault credentials (api/services/ticktick_service.py)
- JWT-protected REST router at /api/ticktick/ (api/routers/ticktick.py)
- Credentials stored in SOPS vault (services/ticktick.sops.yaml)

Dev project tracking (hybrid TickTick + DB):
- New dev_projects table migration (14 columns, status index)
- TickTick "Dev Projects" list for mobile visibility
- First project seeded: TickTick Integration (linked both sides)

Security: .tokens.json gitignored, token file permissions restricted,
HTML-escaped OAuth callback, SOPS vault (not env vars) for secrets.

Also: Installed Tailscale on ACG-5070 for office network access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:08:53 -07:00

334 lines
10 KiB
Python

"""
TickTick API router for ClaudeTools.
This module defines REST API endpoints for managing TickTick projects and tasks,
proxying requests through the TickTickService with automatic token management.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from api.middleware.auth import get_current_user
from api.services.ticktick_service import TickTickResult, get_ticktick_service
logger = logging.getLogger(__name__)
router = APIRouter()
# --------------------------------------------------------------------------
# Pydantic request/response schemas
# --------------------------------------------------------------------------
class ProjectCreate(BaseModel):
"""Schema for creating a new TickTick project."""
name: str = Field(..., min_length=1, max_length=200, description="Project name")
color: Optional[str] = Field(
None, description="Hex color string (e.g., '#FF6347')"
)
view_mode: Optional[str] = Field(
None, description="View mode: 'list', 'kanban', or 'timeline'"
)
kind: Optional[str] = Field(
None, description="Project kind: 'TASK' or 'NOTE'"
)
class ProjectUpdate(BaseModel):
"""Schema for updating an existing TickTick project."""
name: Optional[str] = Field(
None, min_length=1, max_length=200, description="New project name"
)
color: Optional[str] = Field(None, description="New hex color string")
view_mode: Optional[str] = Field(None, description="New view mode")
class TaskCreate(BaseModel):
"""Schema for creating a new task in a TickTick project."""
title: str = Field(..., min_length=1, max_length=500, description="Task title")
content: Optional[str] = Field(None, description="Task description/content")
priority: Optional[int] = Field(
None,
ge=0,
le=5,
description="Priority: 0=none, 1=low, 3=medium, 5=high",
)
due_date: Optional[str] = Field(
None, description="Due date in ISO 8601 format"
)
tags: Optional[list[str]] = Field(None, description="List of tag strings")
class TaskUpdate(BaseModel):
"""Schema for updating an existing task."""
title: Optional[str] = Field(
None, min_length=1, max_length=500, description="New task title"
)
content: Optional[str] = Field(None, description="New task content")
priority: Optional[int] = Field(
None, ge=0, le=5, description="New priority level"
)
due_date: Optional[str] = Field(None, description="New due date in ISO 8601 format")
tags: Optional[list[str]] = Field(None, description="New list of tags")
class TickTickResponse(BaseModel):
"""Standard response wrapper for all TickTick endpoints."""
success: bool
data: Optional[dict] = None
error: Optional[str] = None
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
def _to_response(result: TickTickResult, status_code_on_error: int = 500) -> dict:
"""
Convert a TickTickResult to a JSON-serializable response dict.
Raises an HTTPException when the result indicates failure.
Args:
result: The service result to convert.
status_code_on_error: HTTP status code for error responses.
Returns:
Dict matching the TickTickResponse schema.
"""
if not result.success:
raise HTTPException(
status_code=status_code_on_error,
detail={
"success": False,
"data": None,
"error": result.error or "Unknown error",
},
)
return {"success": True, "data": result.data, "error": None}
# --------------------------------------------------------------------------
# Project endpoints
# --------------------------------------------------------------------------
@router.get(
"",
response_model=TickTickResponse,
summary="List all TickTick projects",
status_code=status.HTTP_200_OK,
)
async def list_projects(current_user: dict = Depends(get_current_user)):
"""
Retrieve all projects (lists) from the authenticated TickTick account.
**Example Request:**
```
GET /api/ticktick
```
"""
service = get_ticktick_service()
result = await service.list_projects()
return _to_response(result)
@router.get(
"/{project_id}",
response_model=TickTickResponse,
summary="Get a TickTick project with tasks",
status_code=status.HTTP_200_OK,
)
async def get_project(project_id: str, current_user: dict = Depends(get_current_user)):
"""
Retrieve a single project and its associated task data.
**Path Parameters:**
- **project_id**: The TickTick project ID.
"""
service = get_ticktick_service()
result = await service.get_project(project_id)
return _to_response(result, status_code_on_error=404)
@router.post(
"",
response_model=TickTickResponse,
summary="Create a new TickTick project",
status_code=status.HTTP_201_CREATED,
)
async def create_project(body: ProjectCreate, current_user: dict = Depends(get_current_user)):
"""
Create a new project (list) in TickTick.
**Request Body:**
- **name** (required): Project name.
- **color**: Hex color string.
- **view_mode**: View mode ('list', 'kanban', 'timeline').
- **kind**: Project kind ('TASK' or 'NOTE').
"""
service = get_ticktick_service()
result = await service.create_project(
name=body.name,
color=body.color,
view_mode=body.view_mode,
kind=body.kind,
)
return _to_response(result, status_code_on_error=400)
@router.put(
"/{project_id}",
response_model=TickTickResponse,
summary="Update a TickTick project",
status_code=status.HTTP_200_OK,
)
async def update_project(project_id: str, body: ProjectUpdate, current_user: dict = Depends(get_current_user)):
"""
Update an existing project's properties.
**Path Parameters:**
- **project_id**: The TickTick project ID to update.
**Request Body:**
At least one field must be provided.
"""
service = get_ticktick_service()
result = await service.update_project(
project_id=project_id,
name=body.name,
color=body.color,
view_mode=body.view_mode,
)
return _to_response(result, status_code_on_error=400)
@router.delete(
"/{project_id}",
response_model=TickTickResponse,
summary="Delete a TickTick project",
status_code=status.HTTP_200_OK,
)
async def delete_project(project_id: str, current_user: dict = Depends(get_current_user)):
"""
Delete a project from TickTick.
**Path Parameters:**
- **project_id**: The TickTick project ID to delete.
"""
service = get_ticktick_service()
result = await service.delete_project(project_id)
return _to_response(result, status_code_on_error=404)
# --------------------------------------------------------------------------
# Task endpoints
# --------------------------------------------------------------------------
@router.post(
"/{project_id}/tasks",
response_model=TickTickResponse,
summary="Create a task in a TickTick project",
status_code=status.HTTP_201_CREATED,
)
async def create_task(project_id: str, body: TaskCreate, current_user: dict = Depends(get_current_user)):
"""
Create a new task within the specified project.
**Path Parameters:**
- **project_id**: The TickTick project ID.
**Request Body:**
- **title** (required): Task title.
- **content**: Task description.
- **priority**: 0=none, 1=low, 3=medium, 5=high.
- **due_date**: ISO 8601 date string.
- **tags**: List of tag strings.
"""
service = get_ticktick_service()
result = await service.create_task(
title=body.title,
project_id=project_id,
content=body.content,
priority=body.priority,
due_date=body.due_date,
tags=body.tags,
)
return _to_response(result, status_code_on_error=400)
@router.put(
"/{project_id}/tasks/{task_id}",
response_model=TickTickResponse,
summary="Update a task in a TickTick project",
status_code=status.HTTP_200_OK,
)
async def update_task(project_id: str, task_id: str, body: TaskUpdate, current_user: dict = Depends(get_current_user)):
"""
Update an existing task's properties.
**Path Parameters:**
- **project_id**: The TickTick project ID.
- **task_id**: The task ID to update.
"""
service = get_ticktick_service()
result = await service.update_task(
task_id=task_id,
project_id=project_id,
title=body.title,
content=body.content,
priority=body.priority,
due_date=body.due_date,
tags=body.tags,
)
return _to_response(result, status_code_on_error=400)
@router.post(
"/{project_id}/tasks/{task_id}/complete",
response_model=TickTickResponse,
summary="Complete a task",
status_code=status.HTTP_200_OK,
)
async def complete_task(project_id: str, task_id: str, current_user: dict = Depends(get_current_user)):
"""
Mark a task as complete in TickTick.
**Path Parameters:**
- **project_id**: The TickTick project ID.
- **task_id**: The task ID to mark complete.
"""
service = get_ticktick_service()
result = await service.complete_task(task_id=task_id, project_id=project_id)
return _to_response(result, status_code_on_error=400)
@router.delete(
"/{project_id}/tasks/{task_id}",
response_model=TickTickResponse,
summary="Delete a task",
status_code=status.HTTP_200_OK,
)
async def delete_task(project_id: str, task_id: str, current_user: dict = Depends(get_current_user)):
"""
Delete a task from a TickTick project.
**Path Parameters:**
- **project_id**: The TickTick project ID.
- **task_id**: The task ID to delete.
"""
service = get_ticktick_service()
result = await service.delete_task(task_id=task_id, project_id=project_id)
return _to_response(result, status_code_on_error=404)