Migrate TickTick OAuth and action tasks
This commit is contained in:
@@ -18,7 +18,7 @@ from app.services.auth import (
|
||||
revoke_session,
|
||||
validate_csrf_token,
|
||||
)
|
||||
from app.services.config_page import build_config_sections
|
||||
from app.services.config_page import build_config_sections, is_ticktick_oauth_ready
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
@@ -225,6 +225,9 @@ def _render_config_page(
|
||||
"config_error": None,
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
|
||||
"ticktick_oauth_notice": None,
|
||||
"ticktick_oauth_error": None,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,8 @@ from fastapi.responses import PlainTextResponse, Response
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.dependencies import get_db, get_ticktick_client
|
||||
from app.integrations.ticktick import TickTickClient, TickTickConfigError, TickTickRequestError
|
||||
from app.schemas.homeassistant import HomeAssistantPublishEnvelope
|
||||
from app.services.homeassistant_inbound import (
|
||||
UnsupportedHomeAssistantMessage,
|
||||
@@ -21,13 +22,15 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
|
||||
|
||||
@router.post("/homeassistant/publish")
|
||||
async def publish_from_homeassistant(
|
||||
request: Request, db: Session = Depends(get_db)
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
||||
) -> Response:
|
||||
try:
|
||||
raw_payload = await request.body()
|
||||
data = json.loads(raw_payload)
|
||||
envelope = HomeAssistantPublishEnvelope.model_validate(data)
|
||||
handle_homeassistant_message(db, envelope)
|
||||
handle_homeassistant_message(db, envelope, ticktick_client)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Rejected Home Assistant publish request due to invalid JSON: %s", exc)
|
||||
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -42,6 +45,12 @@ async def publish_from_homeassistant(
|
||||
INTERNAL_SERVER_ERROR_MESSAGE,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
except (TickTickConfigError, TickTickRequestError, RuntimeError) as exc:
|
||||
logger.warning("Home Assistant publish request failed during TickTick handling: %s", exc)
|
||||
return PlainTextResponse(
|
||||
INTERNAL_SERVER_ERROR_MESSAGE,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning("Rejected Home Assistant publish request due to invalid content: %s", exc)
|
||||
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
+32
-2
@@ -8,7 +8,12 @@ from fastapi.templating import Jinja2Templates
|
||||
from app.config import Settings, get_settings
|
||||
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
|
||||
from app.services.config_page import (
|
||||
ConfigSaveError,
|
||||
build_config_sections,
|
||||
is_ticktick_oauth_ready,
|
||||
save_config_updates,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
@@ -16,6 +21,18 @@ router = APIRouter(tags=["pages"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]:
|
||||
if status_value == "success":
|
||||
return "TickTick authorization completed successfully.", None
|
||||
if status_value == "invalid-state":
|
||||
return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again."
|
||||
if status_value == "invalid-callback":
|
||||
return None, "TickTick authorization callback was missing required parameters."
|
||||
if status_value == "failed":
|
||||
return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI."
|
||||
return None, None
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def home(
|
||||
request: Request,
|
||||
@@ -46,6 +63,10 @@ def config_page(
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice(
|
||||
request.query_params.get("ticktick_oauth")
|
||||
)
|
||||
|
||||
context = {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
@@ -56,6 +77,9 @@ def config_page(
|
||||
"config_error": None,
|
||||
"config_saved": request.query_params.get("saved") == "1",
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
|
||||
"ticktick_oauth_notice": ticktick_oauth_notice,
|
||||
"ticktick_oauth_error": ticktick_oauth_error,
|
||||
}
|
||||
return templates.TemplateResponse(request, "config.html", context)
|
||||
|
||||
@@ -84,6 +108,9 @@ async def config_submit(
|
||||
"config_error": "invalid config update request",
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
|
||||
"ticktick_oauth_notice": None,
|
||||
"ticktick_oauth_error": None,
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -96,7 +123,7 @@ async def config_submit(
|
||||
save_config_updates(auth_db_session, dict(form), settings)
|
||||
except ConfigSaveError:
|
||||
logger.warning("Rejected config update due to invalid submitted values")
|
||||
refreshed_settings = build_runtime_settings(auth_db_session, get_settings())
|
||||
refreshed_settings = get_settings()
|
||||
context = {
|
||||
"app_name": refreshed_settings.app_name,
|
||||
"app_env": refreshed_settings.app_env,
|
||||
@@ -107,6 +134,9 @@ async def config_submit(
|
||||
"config_error": "invalid config submission",
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, refreshed_settings),
|
||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings),
|
||||
"ticktick_oauth_notice": None,
|
||||
"ticktick_oauth_error": None,
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import PlainTextResponse, RedirectResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.dependencies import (
|
||||
get_app_settings,
|
||||
get_auth_db,
|
||||
get_current_auth_session,
|
||||
get_ticktick_client,
|
||||
)
|
||||
from app.integrations.ticktick import TickTickAuthError, TickTickClient, TickTickConfigError, TickTickRequestError
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.config_page import save_config_value
|
||||
|
||||
router = APIRouter(tags=["ticktick"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/ticktick/auth/start")
|
||||
def start_ticktick_auth(
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
||||
) -> Response:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
try:
|
||||
authorization_url = ticktick_client.build_authorization_url()
|
||||
except TickTickConfigError as exc:
|
||||
logger.warning("Rejected TickTick OAuth start due to incomplete configuration: %s", exc)
|
||||
return PlainTextResponse("TickTick integration is not configured", status_code=400)
|
||||
|
||||
return RedirectResponse(url=authorization_url, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/ticktick/auth/code")
|
||||
def handle_ticktick_auth_code(
|
||||
request: Request,
|
||||
auth_db_session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
||||
) -> Response:
|
||||
code = request.query_params.get("code", "")
|
||||
state = request.query_params.get("state", "")
|
||||
|
||||
if not code or not state:
|
||||
return RedirectResponse(
|
||||
url="/config?ticktick_oauth=invalid-callback",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
try:
|
||||
token = ticktick_client.exchange_authorization_code(code=code, state=state)
|
||||
save_config_value(
|
||||
auth_db_session,
|
||||
env_name="TICKTICK_TOKEN",
|
||||
value=token,
|
||||
bootstrap_settings=settings,
|
||||
)
|
||||
except TickTickAuthError as exc:
|
||||
logger.warning("Rejected TickTick OAuth callback due to invalid state: %s", exc)
|
||||
return RedirectResponse(
|
||||
url="/config?ticktick_oauth=invalid-state",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
except (TickTickConfigError, TickTickRequestError, ValueError) as exc:
|
||||
logger.warning("TickTick OAuth callback failed: %s", exc)
|
||||
return RedirectResponse(
|
||||
url="/config?ticktick_oauth=failed",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
url="/config?ticktick_oauth=success",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
@@ -7,6 +7,7 @@ from app.auth_db import get_auth_db_session
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db_session
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.integrations.ticktick import TickTickClient
|
||||
from app.poo_db import get_poo_db_session
|
||||
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
||||
from app.services.config_page import build_runtime_settings
|
||||
@@ -32,6 +33,10 @@ def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) ->
|
||||
return HomeAssistantClient(settings)
|
||||
|
||||
|
||||
def get_ticktick_client(settings: Settings = Depends(get_app_settings)) -> TickTickClient:
|
||||
return TickTickClient(settings)
|
||||
|
||||
|
||||
def get_current_auth_session(
|
||||
request: Request,
|
||||
session: Session = Depends(get_auth_db),
|
||||
|
||||
@@ -1,12 +1,301 @@
|
||||
from dataclasses import dataclass
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import base64
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TICKTICK_AUTH_URL = "https://ticktick.com/oauth/authorize"
|
||||
TICKTICK_TOKEN_URL = "https://ticktick.com/oauth/token"
|
||||
TICKTICK_OPEN_API_BASE_URL = "https://api.ticktick.com/open/v1"
|
||||
TICKTICK_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
||||
AUTH_SCOPE = "tasks:read tasks:write"
|
||||
|
||||
|
||||
class TickTickConfigError(RuntimeError):
|
||||
"""Raised when TickTick is missing required runtime configuration."""
|
||||
|
||||
|
||||
class TickTickAuthError(RuntimeError):
|
||||
"""Raised when TickTick OAuth state validation fails."""
|
||||
|
||||
|
||||
class TickTickRequestError(RuntimeError):
|
||||
"""Raised when a TickTick API request fails."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickProject:
|
||||
id: str
|
||||
name: str
|
||||
color: str | None = None
|
||||
sortOrder: int | None = None
|
||||
closed: bool | None = None
|
||||
groupId: str | None = None
|
||||
viewMode: str | None = None
|
||||
permission: str | None = None
|
||||
kind: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickTask:
|
||||
projectId: str
|
||||
title: str
|
||||
id: str | None = None
|
||||
isAllDay: bool | None = None
|
||||
completedTime: str | None = None
|
||||
content: str | None = None
|
||||
desc: str | None = None
|
||||
dueDate: str | None = None
|
||||
items: list[Any] | None = None
|
||||
priority: int | None = None
|
||||
reminders: list[str] | None = None
|
||||
repeatFlag: str | None = None
|
||||
sortOrder: int | None = None
|
||||
startDate: str | None = None
|
||||
status: int | None = None
|
||||
timeZone: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickAuthStateStore:
|
||||
pending_state: str | None = None
|
||||
|
||||
def issue_state(self) -> str:
|
||||
self.pending_state = secrets.token_hex(6)
|
||||
return self.pending_state
|
||||
|
||||
def matches_state(self, state: str) -> bool:
|
||||
return bool(self.pending_state and state == self.pending_state)
|
||||
|
||||
def consume_state(self, state: str) -> bool:
|
||||
if not self.pending_state or state != self.pending_state:
|
||||
return False
|
||||
self.pending_state = None
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
self.pending_state = None
|
||||
|
||||
|
||||
default_auth_state_store = TickTickAuthStateStore()
|
||||
|
||||
|
||||
def _coerce_dataclass_payload(model_type: type, payload: dict[str, Any]) -> Any:
|
||||
allowed_field_names = {item.name for item in fields(model_type)}
|
||||
filtered_payload = {
|
||||
key: value for key, value in payload.items() if key in allowed_field_names
|
||||
}
|
||||
return model_type(**filtered_payload)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickClient:
|
||||
settings: Settings
|
||||
auth_state_store: TickTickAuthStateStore = field(default_factory=lambda: default_auth_state_store)
|
||||
timeout_seconds: float = 10.0
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.settings.ticktick_client_id and self.settings.ticktick_client_secret)
|
||||
return bool(self._client_id() and self._client_secret())
|
||||
|
||||
def has_token(self) -> bool:
|
||||
return bool(self.settings.ticktick_token)
|
||||
|
||||
def build_authorization_url(self) -> str:
|
||||
self._require_auth_config()
|
||||
state = self.auth_state_store.issue_state()
|
||||
params = parse.urlencode(
|
||||
{
|
||||
"client_id": self._client_id(),
|
||||
"response_type": "code",
|
||||
"redirect_uri": self._redirect_uri(),
|
||||
"state": state,
|
||||
"scope": AUTH_SCOPE,
|
||||
}
|
||||
)
|
||||
return f"{TICKTICK_AUTH_URL}?{params}"
|
||||
|
||||
def exchange_authorization_code(self, *, code: str, state: str) -> str:
|
||||
self._require_auth_config()
|
||||
if not code:
|
||||
raise ValueError("code must not be empty")
|
||||
if not state:
|
||||
raise ValueError("state must not be empty")
|
||||
if not self.auth_state_store.matches_state(state):
|
||||
raise TickTickAuthError("Invalid state")
|
||||
|
||||
body = parse.urlencode(
|
||||
{
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"scope": AUTH_SCOPE,
|
||||
"redirect_uri": self._redirect_uri(),
|
||||
}
|
||||
).encode("utf-8")
|
||||
req = request.Request(TICKTICK_TOKEN_URL, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.add_header("Authorization", self._basic_auth_header())
|
||||
payload = self._send_json_request(req, operation="exchange_authorization_code")
|
||||
self.auth_state_store.clear()
|
||||
token = payload.get("access_token")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise TickTickRequestError("TickTick token response did not include access_token")
|
||||
return token
|
||||
|
||||
def get_projects(self) -> list[TickTickProject]:
|
||||
self._require_token()
|
||||
payload = self._authorized_json_request(
|
||||
method="GET",
|
||||
path="/project/",
|
||||
operation="get_projects",
|
||||
)
|
||||
if not isinstance(payload, list):
|
||||
raise TickTickRequestError("TickTick get_projects returned an unexpected payload")
|
||||
return [_coerce_dataclass_payload(TickTickProject, project) for project in payload]
|
||||
|
||||
def get_tasks(self, project_id: str) -> list[TickTickTask]:
|
||||
self._require_token()
|
||||
if not project_id:
|
||||
raise ValueError("project_id must not be empty")
|
||||
payload = self._authorized_json_request(
|
||||
method="GET",
|
||||
path=f"/project/{parse.quote(project_id, safe='')}/data",
|
||||
operation="get_tasks",
|
||||
accepted_status_codes={200, 404},
|
||||
)
|
||||
if payload is None:
|
||||
return []
|
||||
if not isinstance(payload, dict):
|
||||
raise TickTickRequestError("TickTick get_tasks returned an unexpected payload")
|
||||
tasks = payload.get("tasks", [])
|
||||
if not isinstance(tasks, list):
|
||||
raise TickTickRequestError("TickTick get_tasks returned an invalid tasks payload")
|
||||
return [_coerce_dataclass_payload(TickTickTask, task) for task in tasks]
|
||||
|
||||
def has_duplicate_task(self, *, project_id: str, task_title: str) -> bool:
|
||||
if not task_title:
|
||||
raise ValueError("task_title must not be empty")
|
||||
return any(task.title == task_title for task in self.get_tasks(project_id))
|
||||
|
||||
def create_task(self, task: TickTickTask) -> None:
|
||||
self._require_token()
|
||||
if not task.projectId:
|
||||
raise ValueError("task.projectId must not be empty")
|
||||
if not task.title:
|
||||
raise ValueError("task.title must not be empty")
|
||||
if self.has_duplicate_task(project_id=task.projectId, task_title=task.title):
|
||||
return
|
||||
|
||||
payload = {key: value for key, value in asdict(task).items() if value is not None}
|
||||
self._authorized_json_request(
|
||||
method="POST",
|
||||
path="/task",
|
||||
operation="create_task",
|
||||
body=payload,
|
||||
accepted_status_codes={200},
|
||||
)
|
||||
|
||||
def _authorized_json_request(
|
||||
self,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
operation: str,
|
||||
body: Any | None = None,
|
||||
accepted_status_codes: set[int] | None = None,
|
||||
) -> Any:
|
||||
url = f"{TICKTICK_OPEN_API_BASE_URL}{path}"
|
||||
encoded_body = None if body is None else json.dumps(body).encode("utf-8")
|
||||
req = request.Request(url, data=encoded_body, method=method)
|
||||
req.add_header("Authorization", f"Bearer {self.settings.ticktick_token}")
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
return self._send_json_request(
|
||||
req,
|
||||
operation=operation,
|
||||
accepted_status_codes=accepted_status_codes,
|
||||
)
|
||||
|
||||
def _send_json_request(
|
||||
self,
|
||||
req: request.Request,
|
||||
*,
|
||||
operation: str,
|
||||
accepted_status_codes: set[int] | None = None,
|
||||
) -> Any:
|
||||
accepted_codes = accepted_status_codes or {200}
|
||||
try:
|
||||
with request.urlopen(req, timeout=self.timeout_seconds) as response:
|
||||
status_code = response.getcode()
|
||||
if status_code not in accepted_codes:
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} returned unexpected status {status_code}"
|
||||
)
|
||||
raw_body = response.read()
|
||||
except error.HTTPError as exc:
|
||||
if exc.code in accepted_codes:
|
||||
raw_body = exc.read()
|
||||
else:
|
||||
logger.warning(
|
||||
"TickTick %s failed with HTTP %s for %s",
|
||||
operation,
|
||||
exc.code,
|
||||
req.full_url,
|
||||
)
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} failed with HTTP {exc.code}"
|
||||
) from exc
|
||||
except error.URLError as exc:
|
||||
logger.warning("TickTick %s failed for %s: %s", operation, req.full_url, exc)
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} failed to reach TickTick API"
|
||||
) from exc
|
||||
|
||||
if not raw_body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw_body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} returned invalid JSON"
|
||||
) from exc
|
||||
|
||||
def _basic_auth_header(self) -> str:
|
||||
raw_credentials = f"{self._client_id()}:{self._client_secret()}"
|
||||
token = base64.b64encode(raw_credentials.encode("utf-8")).decode("ascii")
|
||||
return f"Basic {token}"
|
||||
|
||||
def _client_id(self) -> str:
|
||||
return self.settings.ticktick_client_id.strip()
|
||||
|
||||
def _client_secret(self) -> str:
|
||||
return self.settings.ticktick_client_secret.strip()
|
||||
|
||||
def _redirect_uri(self) -> str:
|
||||
return self.settings.ticktick_redirect_uri.strip()
|
||||
|
||||
def _require_auth_config(self) -> None:
|
||||
if not self.is_configured():
|
||||
raise TickTickConfigError(
|
||||
"TickTick integration is not configured. Set TICKTICK_CLIENT_ID and "
|
||||
"TICKTICK_CLIENT_SECRET."
|
||||
)
|
||||
if not self._redirect_uri():
|
||||
raise TickTickConfigError(
|
||||
"TickTick integration is missing TICKTICK_REDIRECT_URI."
|
||||
)
|
||||
|
||||
def _require_token(self) -> None:
|
||||
self._require_auth_config()
|
||||
if self.has_token():
|
||||
return
|
||||
raise TickTickConfigError(
|
||||
"TickTick integration is missing TICKTICK_TOKEN. Complete the OAuth flow first."
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import app.auth_db as auth_db
|
||||
from app.api.routes.homeassistant import router as homeassistant_router
|
||||
from app.api.routes.location import router as location_router
|
||||
from app.api.routes.poo import router as poo_router
|
||||
from app.api.routes.ticktick import router as ticktick_router
|
||||
from app.config import get_settings
|
||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
||||
from app.services.config_page import seed_missing_config_from_bootstrap
|
||||
@@ -95,6 +96,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(homeassistant_router)
|
||||
app.include_router(location_router)
|
||||
app.include_router(poo_router)
|
||||
app.include_router(ticktick_router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class TickTickActionTaskRequest(BaseModel):
|
||||
title: str | None = None
|
||||
action: str
|
||||
due_hour: int = Field(alias="due_hour")
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
@@ -173,6 +173,29 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s
|
||||
reset_auth_db_caches()
|
||||
|
||||
|
||||
def save_config_value(
|
||||
session: Session,
|
||||
*,
|
||||
env_name: str,
|
||||
value: str,
|
||||
bootstrap_settings: Settings,
|
||||
) -> None:
|
||||
current_values = _read_config_values(session)
|
||||
current_values[env_name] = value
|
||||
_validate_config_values(current_values, bootstrap_settings)
|
||||
_persist_config_values(session, current_values)
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
|
||||
|
||||
def is_ticktick_oauth_ready(settings: Settings) -> bool:
|
||||
return bool(
|
||||
settings.ticktick_client_id
|
||||
and settings.ticktick_client_secret
|
||||
and settings.ticktick_redirect_uri
|
||||
)
|
||||
|
||||
|
||||
def _read_config_values(session: Session) -> dict[str, str]:
|
||||
rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all()
|
||||
return {row.key: row.value for row in rows}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, time, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.integrations.ticktick import TICKTICK_DATETIME_FORMAT, TickTickClient, TickTickTask
|
||||
from app.schemas.homeassistant import HomeAssistantPublishEnvelope
|
||||
from app.schemas.location import LocationRecordRequest
|
||||
from app.schemas.ticktick import TickTickActionTaskRequest
|
||||
from app.services.location import record_location
|
||||
|
||||
|
||||
@@ -13,12 +16,18 @@ class UnsupportedHomeAssistantMessage(RuntimeError):
|
||||
|
||||
|
||||
def handle_homeassistant_message(
|
||||
session: Session, envelope: HomeAssistantPublishEnvelope
|
||||
session: Session,
|
||||
envelope: HomeAssistantPublishEnvelope,
|
||||
ticktick_client: TickTickClient | None = None,
|
||||
) -> None:
|
||||
if envelope.target == "location_recorder":
|
||||
_handle_location_message(session, envelope)
|
||||
return
|
||||
|
||||
if envelope.target == "ticktick":
|
||||
_handle_ticktick_message(envelope, ticktick_client)
|
||||
return
|
||||
|
||||
raise UnsupportedHomeAssistantMessage(
|
||||
f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}"
|
||||
)
|
||||
@@ -33,3 +42,38 @@ def _handle_location_message(session: Session, envelope: HomeAssistantPublishEnv
|
||||
content = json.loads(envelope.content.replace("'", '"'))
|
||||
payload = LocationRecordRequest.model_validate(content)
|
||||
record_location(session, payload)
|
||||
|
||||
|
||||
def _handle_ticktick_message(
|
||||
envelope: HomeAssistantPublishEnvelope,
|
||||
ticktick_client: TickTickClient | None,
|
||||
) -> None:
|
||||
if envelope.action != "create_action_task":
|
||||
raise UnsupportedHomeAssistantMessage(
|
||||
f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}"
|
||||
)
|
||||
if ticktick_client is None:
|
||||
raise UnsupportedHomeAssistantMessage("TickTick client is unavailable")
|
||||
|
||||
content = json.loads(envelope.content.replace("'", '"'))
|
||||
payload = TickTickActionTaskRequest.model_validate(content)
|
||||
project_id = ticktick_client.settings.home_assistant_action_task_project_id
|
||||
if not project_id:
|
||||
raise RuntimeError(
|
||||
"TickTick action task integration is missing HOME_ASSISTANT_ACTION_TASK_PROJECT_ID"
|
||||
)
|
||||
|
||||
ticktick_client.create_task(
|
||||
TickTickTask(
|
||||
projectId=project_id,
|
||||
title=payload.action,
|
||||
dueDate=build_action_task_due_date(datetime.now().astimezone(), payload.due_hour),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_action_task_due_date(now: datetime, due_hour: int) -> str:
|
||||
local_now = now.astimezone()
|
||||
due = local_now + timedelta(hours=due_hour)
|
||||
next_midnight = datetime.combine(due.date(), time.min, tzinfo=local_now.tzinfo) + timedelta(days=1)
|
||||
return next_midnight.astimezone(UTC).strftime(TICKTICK_DATETIME_FORMAT)
|
||||
|
||||
@@ -182,6 +182,53 @@ button:hover {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.integration-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(31, 41, 51, 0.08);
|
||||
}
|
||||
|
||||
.integration-action-title {
|
||||
margin: 0 0 6px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.integration-action-copy {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.button-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
min-width: 120px;
|
||||
padding: 12px 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-link:hover {
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.button-link.disabled {
|
||||
background: rgba(91, 104, 117, 0.28);
|
||||
color: rgba(31, 41, 51, 0.72);
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
margin: 24px auto;
|
||||
@@ -190,4 +237,9 @@ button:hover {
|
||||
.panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.integration-action-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
<div class="notice">config saved to .env. Some changes may require an app restart.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ticktick_oauth_error %}
|
||||
<div class="alert">{{ ticktick_oauth_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ticktick_oauth_notice %}
|
||||
<div class="notice">{{ ticktick_oauth_notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="meta single-column">
|
||||
<div>
|
||||
<dt>当前用户</dt>
|
||||
@@ -75,6 +83,24 @@
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
|
||||
{% if section.name == "TickTick" %}
|
||||
<div class="integration-action-row">
|
||||
<div>
|
||||
<p class="integration-action-title">TickTick OAuth</p>
|
||||
{% if ticktick_oauth_ready %}
|
||||
<p class="integration-action-copy">Use the saved TickTick client settings to start the authorization flow.</p>
|
||||
{% else %}
|
||||
<p class="integration-action-copy">Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if ticktick_oauth_ready %}
|
||||
<a class="button-link" href="/ticktick/auth/start">Authorize TickTick</a>
|
||||
{% else %}
|
||||
<span class="button-link disabled" aria-disabled="true">Authorize TickTick</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
@@ -30,18 +30,27 @@
|
||||
|
||||
## 当前已支持的 Target / Action
|
||||
|
||||
当前只接回最小可用路径:
|
||||
当前已接回的路径:
|
||||
|
||||
- `location_recorder / record`
|
||||
- `ticktick / create_action_task`
|
||||
|
||||
它会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑。
|
||||
其中:
|
||||
|
||||
- `location_recorder / record` 会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑
|
||||
- `ticktick / create_action_task` 会沿用 legacy 行为,把 `content` 解析为:
|
||||
- `action: string`
|
||||
- `due_hour: int`
|
||||
- 可选 `title` 字段会被忽略
|
||||
- TickTick task title 仍使用 `action`
|
||||
- due date 仍按 legacy 语义计算:先取 `now + due_hour`,再落到该日期的“次日零点”,最后转成 UTC 后写给 TickTick
|
||||
- 具体 project 仍由 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` 提供
|
||||
|
||||
## 当前尚未接回
|
||||
|
||||
以下 legacy 路径在当前阶段还没有迁入:
|
||||
|
||||
- `poo_recorder / get_latest`
|
||||
- `ticktick / create_action_task`
|
||||
- 其他未定义 target/action
|
||||
|
||||
这些请求当前会返回:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# TickTick Integration
|
||||
|
||||
当前 Python 项目里的 TickTick 迁移先恢复 legacy 的最核心能力,不额外扩成更大的集成层。
|
||||
|
||||
## 当前已支持
|
||||
|
||||
- 运行时从 config 表读取 TickTick 配置,缺失时仍可 fallback `.env`
|
||||
- `GET /ticktick/auth/start`
|
||||
- 需要已登录 session
|
||||
- 生成 OAuth `state`
|
||||
- 直接重定向到 TickTick 授权页
|
||||
- `GET /ticktick/auth/code`
|
||||
- 校验进程内保存的 `state`
|
||||
- 用 authorization code 换取 access token
|
||||
- 将 `TICKTICK_TOKEN` 持久化到 `app_config` 表
|
||||
- TickTick Open API 基础调用:
|
||||
- 列 project
|
||||
- 列 project 下 task
|
||||
- 创建 task
|
||||
- 按 title 精确匹配做重复创建保护
|
||||
- Home Assistant inbound 已重新接回 `ticktick / create_action_task`
|
||||
|
||||
## 当前配置项
|
||||
|
||||
- `TICKTICK_CLIENT_ID`
|
||||
- `TICKTICK_CLIENT_SECRET`
|
||||
- `TICKTICK_REDIRECT_URI`
|
||||
- `TICKTICK_TOKEN`
|
||||
- `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID`
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
- 仍保留 legacy 的 OAuth authorization code flow
|
||||
- `state` 仍是进程内临时状态;如果服务在 start 和 callback 之间重启,本轮实现下授权需要重新开始
|
||||
- 不再把 token 写回 `.env` 或其他配置文件,统一写入 config 表
|
||||
- 当前没有引入 legacy 的第三方 TickTick 库,先用标准库完成兼容行为
|
||||
|
||||
## 后续适合单独拆分的工作
|
||||
|
||||
- 给 config 页面增加明确的 TickTick 授权入口
|
||||
- 增加 project 探测或选择能力,减少手工填写 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID`
|
||||
- 如果后续发现 OAuth/token 生命周期需要更强健,再补 refresh token 或持久化 auth state
|
||||
@@ -4,7 +4,9 @@ from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.auth_db import reset_auth_db_caches
|
||||
from app.config import get_settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def _extract_csrf_token(html: str) -> str:
|
||||
@@ -56,6 +58,8 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC
|
||||
assert "New Password" in config_response.text
|
||||
assert "Save Config" in config_response.text
|
||||
assert "当前用户" in config_response.text
|
||||
assert "Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth." in config_response.text
|
||||
assert 'aria-disabled="true">Authorize TickTick<' in config_response.text
|
||||
|
||||
|
||||
def test_login_failure_returns_generic_error(client: TestClient) -> None:
|
||||
@@ -187,3 +191,77 @@ def test_config_page_update_persists_to_database(
|
||||
assert rows["APP_NAME"] == "Updated Home Automation"
|
||||
assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token"
|
||||
assert "AUTH_BOOTSTRAP_USERNAME" not in rows
|
||||
|
||||
|
||||
def test_config_page_shows_ticktick_oauth_link_when_ticktick_is_configured(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
config_response = client.get("/config")
|
||||
|
||||
assert config_response.status_code == 200
|
||||
assert "Use the saved TickTick client settings to start the authorization flow." in config_response.text
|
||||
assert 'href="/ticktick/auth/start">Authorize TickTick<' in config_response.text
|
||||
|
||||
|
||||
def test_config_page_shows_ticktick_oauth_success_notice(client: TestClient) -> None:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
response = client.get("/config?ticktick_oauth=success")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "TickTick authorization completed successfully." in response.text
|
||||
|
||||
|
||||
def test_config_page_shows_ticktick_oauth_failure_notice(client: TestClient) -> None:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
response = client.get("/config?ticktick_oauth=failed")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." in response.text
|
||||
|
||||
@@ -109,7 +109,23 @@ def test_homeassistant_publish_rejects_missing_content(location_client) -> None:
|
||||
assert "content" not in response.text
|
||||
|
||||
|
||||
def test_homeassistant_publish_returns_not_implemented_for_unknown_target(location_client) -> None:
|
||||
def test_homeassistant_publish_returns_internal_error_for_unconfigured_ticktick(location_client) -> None:
|
||||
client, _ = location_client
|
||||
|
||||
response = client.post(
|
||||
"/homeassistant/publish",
|
||||
json={
|
||||
"target": "ticktick",
|
||||
"action": "create_action_task",
|
||||
"content": "{'action': 'take out trash', 'due_hour': 6}",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.text == "internal server error"
|
||||
|
||||
|
||||
def test_homeassistant_publish_rejects_invalid_ticktick_content(location_client) -> None:
|
||||
client, _ = location_client
|
||||
|
||||
response = client.post(
|
||||
@@ -121,8 +137,8 @@ def test_homeassistant_publish_returns_not_implemented_for_unknown_target(locati
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.text == "internal server error"
|
||||
assert response.status_code == 400
|
||||
assert response.text == "bad request"
|
||||
|
||||
|
||||
def test_homeassistant_publish_returns_not_implemented_for_unknown_location_action(
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import json
|
||||
import sqlite3
|
||||
from urllib import error
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.auth_db import reset_auth_db_caches
|
||||
from app.config import Settings, get_settings
|
||||
from app.integrations.ticktick import (
|
||||
AUTH_SCOPE,
|
||||
TICKTICK_AUTH_URL,
|
||||
TickTickClient,
|
||||
TickTickTask,
|
||||
default_auth_state_store,
|
||||
)
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class _FakeJsonResponse:
|
||||
def __init__(self, status_code: int, payload):
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
|
||||
def getcode(self) -> int:
|
||||
return self.status_code
|
||||
|
||||
def read(self) -> bytes:
|
||||
return json.dumps(self.payload).encode("utf-8")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _configured_settings(**overrides) -> Settings:
|
||||
payload = {
|
||||
"ticktick_client_id": "ticktick-client-id",
|
||||
"ticktick_client_secret": "ticktick-client-secret",
|
||||
"ticktick_redirect_uri": "http://localhost:8000/ticktick/auth/code",
|
||||
"ticktick_token": "ticktick-access-token",
|
||||
"home_assistant_action_task_project_id": "project-123",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return Settings(_env_file=None, **payload)
|
||||
|
||||
|
||||
def _extract_csrf_token(html: str) -> str:
|
||||
import re
|
||||
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
|
||||
assert match is not None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_build_authorization_url_contains_expected_query(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = TickTickClient(settings=_configured_settings())
|
||||
monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-123")
|
||||
|
||||
authorization_url = client.build_authorization_url()
|
||||
parsed = urlparse(authorization_url)
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == TICKTICK_AUTH_URL
|
||||
assert query["client_id"] == ["ticktick-client-id"]
|
||||
assert query["response_type"] == ["code"]
|
||||
assert query["redirect_uri"] == ["http://localhost:8000/ticktick/auth/code"]
|
||||
assert query["state"] == ["state-123"]
|
||||
assert query["scope"] == [AUTH_SCOPE]
|
||||
|
||||
|
||||
def test_exchange_authorization_code_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured = {}
|
||||
client = TickTickClient(settings=_configured_settings())
|
||||
default_auth_state_store.pending_state = "expected-state"
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
captured["url"] = req.full_url
|
||||
captured["timeout"] = timeout
|
||||
captured["authorization"] = req.headers["Authorization"]
|
||||
captured["content_type"] = req.headers["Content-type"]
|
||||
captured["body"] = req.data.decode("utf-8")
|
||||
return _FakeJsonResponse(200, {"access_token": "new-token"})
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
token = client.exchange_authorization_code(code="oauth-code", state="expected-state")
|
||||
|
||||
assert token == "new-token"
|
||||
assert captured["url"] == "https://ticktick.com/oauth/token"
|
||||
assert captured["timeout"] == pytest.approx(10.0)
|
||||
assert captured["content_type"] == "application/x-www-form-urlencoded"
|
||||
assert captured["authorization"].startswith("Basic ")
|
||||
assert "code=oauth-code" in captured["body"]
|
||||
assert "grant_type=authorization_code" in captured["body"]
|
||||
assert "scope=tasks%3Aread+tasks%3Awrite" in captured["body"]
|
||||
assert "client_id=" not in captured["body"]
|
||||
assert "client_secret=" not in captured["body"]
|
||||
|
||||
|
||||
def test_exchange_authorization_code_trims_ticktick_config_values(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured = {}
|
||||
client = TickTickClient(
|
||||
settings=_configured_settings(
|
||||
ticktick_client_id=" ticktick-client-id ",
|
||||
ticktick_client_secret=" ticktick-client-secret ",
|
||||
ticktick_redirect_uri=" http://localhost:8000/ticktick/auth/code ",
|
||||
)
|
||||
)
|
||||
default_auth_state_store.pending_state = "trimmed-state"
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
captured["authorization"] = req.headers["Authorization"]
|
||||
captured["body"] = req.data.decode("utf-8")
|
||||
return _FakeJsonResponse(200, {"access_token": "trimmed-token"})
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
token = client.exchange_authorization_code(code="oauth-code", state="trimmed-state")
|
||||
|
||||
assert token == "trimmed-token"
|
||||
assert captured["authorization"].startswith("Basic ")
|
||||
assert "redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fticktick%2Fauth%2Fcode" in captured["body"]
|
||||
|
||||
|
||||
def test_create_task_skips_duplicate_titles(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = TickTickClient(settings=_configured_settings())
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
assert req.full_url.endswith("/project/project-123/data")
|
||||
return _FakeJsonResponse(
|
||||
200,
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"projectId": "project-123",
|
||||
"title": "wash dishes",
|
||||
"columnId": "column-7",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
client.create_task(TickTickTask(projectId="project-123", title="wash dishes"))
|
||||
|
||||
|
||||
def test_get_projects_ignores_unknown_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = TickTickClient(settings=_configured_settings())
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
assert req.full_url.endswith("/project/")
|
||||
return _FakeJsonResponse(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"id": "project-123",
|
||||
"name": "Inbox",
|
||||
"etag": "project-etag",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
projects = client.get_projects()
|
||||
|
||||
assert len(projects) == 1
|
||||
assert projects[0].id == "project-123"
|
||||
assert projects[0].name == "Inbox"
|
||||
|
||||
|
||||
def test_create_task_posts_expected_payload(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured = {"calls": []}
|
||||
client = TickTickClient(settings=_configured_settings())
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
captured["calls"].append(req.full_url)
|
||||
if req.full_url.endswith("/project/project-123/data"):
|
||||
return _FakeJsonResponse(200, {"tasks": []})
|
||||
captured["authorization"] = req.headers["Authorization"]
|
||||
captured["content_type"] = req.headers["Content-type"]
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
return _FakeJsonResponse(200, {"id": "task-99"})
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
client.create_task(
|
||||
TickTickTask(projectId="project-123", title="wash dishes", dueDate="2026-04-21T00:00:00+0000")
|
||||
)
|
||||
|
||||
assert captured["calls"] == [
|
||||
"https://api.ticktick.com/open/v1/project/project-123/data",
|
||||
"https://api.ticktick.com/open/v1/task",
|
||||
]
|
||||
assert captured["authorization"] == "Bearer ticktick-access-token"
|
||||
assert captured["content_type"] == "application/json"
|
||||
assert captured["body"] == {
|
||||
"projectId": "project-123",
|
||||
"title": "wash dishes",
|
||||
"dueDate": "2026-04-21T00:00:00+0000",
|
||||
}
|
||||
|
||||
|
||||
def test_homeassistant_publish_creates_ticktick_action_task(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
monkeypatch.setenv("TICKTICK_TOKEN", "ticktick-access-token")
|
||||
monkeypatch.setenv("HOME_ASSISTANT_ACTION_TASK_PROJECT_ID", "project-123")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
|
||||
captured = {"calls": []}
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
captured["calls"].append(req.full_url)
|
||||
if req.full_url.endswith("/project/project-123/data"):
|
||||
return _FakeJsonResponse(200, {"tasks": []})
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
return _FakeJsonResponse(200, {"id": "task-1"})
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.post(
|
||||
"/homeassistant/publish",
|
||||
json={
|
||||
"target": "ticktick",
|
||||
"action": "create_action_task",
|
||||
"content": "{'title': 'ignored', 'action': 'take out trash', 'due_hour': 6}",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["calls"] == [
|
||||
"https://api.ticktick.com/open/v1/project/project-123/data",
|
||||
"https://api.ticktick.com/open/v1/task",
|
||||
]
|
||||
assert captured["body"]["projectId"] == "project-123"
|
||||
assert captured["body"]["title"] == "take out trash"
|
||||
assert captured["body"]["dueDate"].endswith("+0000")
|
||||
|
||||
|
||||
def test_ticktick_auth_start_redirects_authenticated_user(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-redirect")
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
response = client.get("/ticktick/auth/start", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
parsed = urlparse(response.headers["location"])
|
||||
query = parse_qs(parsed.query)
|
||||
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == TICKTICK_AUTH_URL
|
||||
assert query["state"] == ["state-redirect"]
|
||||
|
||||
|
||||
def test_ticktick_auth_callback_persists_token(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
default_auth_state_store.pending_state = "callback-state"
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
return _FakeJsonResponse(200, {"access_token": "persisted-token"})
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/ticktick/auth/code?state=callback-state&code=oauth-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/config?ticktick_oauth=success"
|
||||
|
||||
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_config WHERE key = ?",
|
||||
("TICKTICK_TOKEN",),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert row is not None
|
||||
assert row[0] == "persisted-token"
|
||||
|
||||
|
||||
def test_ticktick_auth_callback_redirects_on_invalid_state(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
default_auth_state_store.pending_state = "expected-state"
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/ticktick/auth/code?state=wrong-state&code=oauth-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/config?ticktick_oauth=invalid-state"
|
||||
|
||||
|
||||
def test_ticktick_auth_callback_redirects_when_token_exchange_fails(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
default_auth_state_store.pending_state = "callback-state"
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
raise error.HTTPError(req.full_url, 401, "Unauthorized", hdrs=None, fp=None)
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/ticktick/auth/code?state=callback-state&code=oauth-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/config?ticktick_oauth=failed"
|
||||
Reference in New Issue
Block a user