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>
This commit is contained in:
333
api/routers/ticktick.py
Normal file
333
api/routers/ticktick.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user