refactor: replace DefaultLimitOffsetPage with LimitOffsetPage in multiple files and update timezone handling to use UTC

This commit is contained in:
Abhimanyu Saharan
2026-02-09 20:40:17 +05:30
parent 1f105c19ab
commit 020d02fa22
51 changed files with 302 additions and 192 deletions

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import json
from collections import deque
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from uuid import UUID
@@ -36,6 +36,7 @@ from app.services.organizations import (
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
router = APIRouter(prefix="/activity", tags=["activity"])
@@ -63,7 +64,7 @@ def _parse_since(value: str | None) -> datetime | None:
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed.astimezone(UTC).replace(tzinfo=None)
return parsed
@@ -145,7 +146,7 @@ async def _fetch_task_comment_events(
async def list_activity(
session: AsyncSession = SESSION_DEP,
actor: ActorContext = ACTOR_DEP,
) -> DefaultLimitOffsetPage[ActivityEventRead]:
) -> LimitOffsetPage[ActivityEventRead]:
"""List activity events visible to the calling actor."""
statement = select(ActivityEvent)
if actor.actor_type == "agent" and actor.agent:
@@ -174,7 +175,7 @@ async def list_task_comment_feed(
board_id: UUID | None = BOARD_ID_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead]:
) -> LimitOffsetPage[ActivityTaskCommentFeedItemRead]:
"""List task-comment feed items for accessible boards."""
statement = (
select(ActivityEvent, Task, Board, Agent)

View File

@@ -76,6 +76,7 @@ if TYPE_CHECKING:
from collections.abc import Sequence
from uuid import UUID
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.activity_events import ActivityEvent
@@ -222,7 +223,7 @@ async def _require_gateway_board(
async def list_boards(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> DefaultLimitOffsetPage[BoardRead]:
) -> LimitOffsetPage[BoardRead]:
"""List boards visible to the authenticated agent."""
statement = select(Board)
if agent_ctx.agent.board_id:
@@ -246,7 +247,7 @@ async def list_agents(
board_id: UUID | None = BOARD_ID_QUERY,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> DefaultLimitOffsetPage[AgentRead]:
) -> LimitOffsetPage[AgentRead]:
"""List agents, optionally filtered to a board."""
statement = select(Agent)
if agent_ctx.agent.board_id:
@@ -277,7 +278,7 @@ async def list_tasks(
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> DefaultLimitOffsetPage[TaskRead]:
) -> LimitOffsetPage[TaskRead]:
"""List tasks on a board with optional status and assignment filters."""
_guard_board_access(agent_ctx, board)
return await tasks_api.list_tasks(
@@ -414,7 +415,7 @@ async def list_task_comments(
task: Task = TASK_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> DefaultLimitOffsetPage[TaskCommentRead]:
) -> LimitOffsetPage[TaskCommentRead]:
"""List comments for a task visible to the authenticated agent."""
if (
agent_ctx.agent.board_id
@@ -460,7 +461,7 @@ async def list_board_memory(
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> DefaultLimitOffsetPage[BoardMemoryRead]:
) -> LimitOffsetPage[BoardMemoryRead]:
"""List board memory entries with optional chat filtering."""
_guard_board_access(agent_ctx, board)
return await board_memory_api.list_board_memory(
@@ -497,7 +498,7 @@ async def list_approvals(
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> DefaultLimitOffsetPage[ApprovalRead]:
) -> LimitOffsetPage[ApprovalRead]:
"""List approvals for a board."""
_guard_board_access(agent_ctx, board)
return await approvals_api.list_approvals(
@@ -960,12 +961,12 @@ async def broadcast_gateway_lead_message(
sent = 0
failed = 0
async def _send_to_board(board: Board) -> GatewayLeadBroadcastBoardResult:
async def _send_to_board(target_board: Board) -> GatewayLeadBroadcastBoardResult:
try:
lead, _lead_created = await ensure_board_lead_agent(
session,
request=LeadAgentRequest(
board=board,
board=target_board,
gateway=gateway,
config=config,
user=None,
@@ -975,14 +976,14 @@ async def broadcast_gateway_lead_message(
lead_session_key = _require_lead_session_key(lead)
message = (
f"{header}\n"
f"Board: {board.name}\n"
f"Board ID: {board.id}\n"
f"Board: {target_board.name}\n"
f"Board ID: {target_board.id}\n"
f"From agent: {agent_ctx.agent.name}\n"
f"{correlation_line}\n"
f"{payload.content.strip()}\n\n"
"Reply to the gateway main by writing a NON-chat memory item "
"on this board:\n"
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
f"POST {base_url}/api/v1/agent/boards/{target_board.id}/memory\n"
f'Body: {{"content":"...","tags":{tags_json},'
f'"source":"{reply_source}"}}\n'
"Do NOT reply in OpenClaw chat."
@@ -990,14 +991,14 @@ async def broadcast_gateway_lead_message(
await ensure_session(lead_session_key, config=config, label=lead.name)
await send_message(message, session_key=lead_session_key, config=config)
return GatewayLeadBroadcastBoardResult(
board_id=board.id,
board_id=target_board.id,
lead_agent_id=lead.id,
lead_agent_name=lead.name,
ok=True,
)
except (HTTPException, OpenClawGatewayError, ValueError) as exc:
return GatewayLeadBroadcastBoardResult(
board_id=board.id,
board_id=target_board.id,
ok=False,
error=str(exc),
)

View File

@@ -6,7 +6,7 @@ import asyncio
import json
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4
@@ -65,6 +65,7 @@ from app.services.organizations import (
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlalchemy.sql.elements import ColumnElement
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import SelectOfScalar
@@ -115,7 +116,7 @@ def _parse_since(value: str | None) -> datetime | None:
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed.astimezone(UTC).replace(tzinfo=None)
return parsed
@@ -564,7 +565,7 @@ async def _validate_agent_update_inputs(
updates: dict[str, Any],
make_main: bool | None,
) -> None:
if make_main is True and not is_org_admin(ctx.member):
if make_main and not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if "status" in updates:
raise HTTPException(
@@ -597,7 +598,7 @@ async def _apply_agent_update_mutations(
)
gateway_for_main: Gateway | None = None
if make_main is True:
if make_main:
board_source = updates.get("board_id") or agent.board_id
board_for_main = await _require_board(session, board_source)
gateway_for_main, _ = await _require_gateway(session, board_for_main)
@@ -605,10 +606,10 @@ async def _apply_agent_update_mutations(
agent.is_board_lead = False
agent.openclaw_session_id = gateway_for_main.main_session_key
main_gateway = gateway_for_main
elif make_main is False:
elif make_main is not None:
agent.openclaw_session_id = None
if make_main is not True and "board_id" in updates:
if not make_main and "board_id" in updates:
await _require_board(session, updates["board_id"])
for key, value in updates.items():
setattr(agent, key, value)
@@ -633,7 +634,7 @@ async def _resolve_agent_update_target(
main_gateway: Gateway | None,
gateway_for_main: Gateway | None,
) -> _AgentUpdateProvisionTarget:
if make_main is True:
if make_main:
if gateway_for_main is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -955,7 +956,7 @@ async def list_agents(
gateway_id: UUID | None = GATEWAY_ID_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> DefaultLimitOffsetPage[AgentRead]:
) -> LimitOffsetPage[AgentRead]:
"""List agents visible to the active organization admin."""
main_session_keys = await _get_gateway_main_session_keys(session)
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from uuid import UUID
@@ -35,6 +35,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.boards import Board
@@ -79,7 +80,7 @@ def _parse_since(value: str | None) -> datetime | None:
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed.astimezone(UTC).replace(tzinfo=None)
return parsed
@@ -118,7 +119,7 @@ async def list_approvals(
board: Board = BOARD_READ_DEP,
session: AsyncSession = SESSION_DEP,
_actor: ActorContext = ACTOR_DEP,
) -> DefaultLimitOffsetPage[ApprovalRead]:
) -> LimitOffsetPage[ApprovalRead]:
"""List approvals for a board, optionally filtering by status."""
statement = Approval.objects.filter_by(board_id=board.id)
if status_filter:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from uuid import UUID
@@ -53,6 +53,7 @@ from app.services.organizations import (
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.services.organizations import OrganizationContext
@@ -90,7 +91,7 @@ def _parse_since(value: str | None) -> datetime | None:
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed.astimezone(UTC).replace(tzinfo=None)
return parsed
@@ -343,7 +344,7 @@ async def list_board_group_memory(
is_chat: bool | None = IS_CHAT_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
) -> LimitOffsetPage[BoardGroupMemoryRead]:
"""List board-group memory entries for a specific group."""
await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
statement = (
@@ -439,7 +440,7 @@ async def list_board_group_memory_for_board(
is_chat: bool | None = IS_CHAT_QUERY,
board: Board = BOARD_READ_DEP,
session: AsyncSession = SESSION_DEP,
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
) -> LimitOffsetPage[BoardGroupMemoryRead]:
"""List memory entries for the board's linked group."""
group_id = board.board_group_id
if group_id is None:

View File

@@ -50,6 +50,7 @@ from app.services.organizations import (
)
if TYPE_CHECKING:
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.organization_members import OrganizationMember
@@ -103,7 +104,7 @@ async def _require_group_access(
async def list_board_groups(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DefaultLimitOffsetPage[BoardGroupRead]:
) -> LimitOffsetPage[BoardGroupRead]:
"""List board groups in the active organization."""
if member_all_boards_read(ctx.member):
statement = select(BoardGroup).where(

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from uuid import UUID
@@ -39,6 +39,7 @@ from app.services.mentions import extract_mentions, matches_agent_mention
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.boards import Board
@@ -67,7 +68,7 @@ def _parse_since(value: str | None) -> datetime | None:
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed.astimezone(UTC).replace(tzinfo=None)
return parsed
@@ -250,7 +251,7 @@ async def list_board_memory(
board: Board = BOARD_READ_DEP,
session: AsyncSession = SESSION_DEP,
_actor: ActorContext = ACTOR_DEP,
) -> DefaultLimitOffsetPage[BoardMemoryRead]:
) -> LimitOffsetPage[BoardMemoryRead]:
"""List board memory entries, optionally filtering chat entries."""
statement = (
BoardMemory.objects.filter_by(board_id=board.id)

View File

@@ -50,6 +50,7 @@ from app.services.board_snapshot import build_board_snapshot
from app.services.organizations import OrganizationContext, board_access_filter
if TYPE_CHECKING:
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
router = APIRouter(prefix="/boards", tags=["boards"])
@@ -246,7 +247,7 @@ async def list_boards(
board_group_id: UUID | None = BOARD_GROUP_ID_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DefaultLimitOffsetPage[BoardRead]:
) -> LimitOffsetPage[BoardRead]:
"""List boards visible to the current organization member."""
statement = select(Board).where(board_access_filter(ctx.member, write=False))
if gateway_id is not None:

View File

@@ -46,6 +46,7 @@ from app.services.template_sync import (
)
if TYPE_CHECKING:
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.services.organizations import OrganizationContext
@@ -224,7 +225,7 @@ async def _ensure_main_agent(
async def list_gateways(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> DefaultLimitOffsetPage[GatewayRead]:
) -> LimitOffsetPage[GatewayRead]:
"""List gateways for the caller's organization."""
statement = (
Gateway.objects.filter_by(organization_id=ctx.organization.id)

View File

@@ -8,7 +8,8 @@ from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import DateTime, case, cast, func
from sqlalchemy import DateTime, case, func
from sqlalchemy import cast as sql_cast
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -152,7 +153,7 @@ async def _query_cycle_time(
board_ids: list[UUID],
) -> DashboardRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
in_progress = cast(Task.in_progress_at, DateTime)
in_progress = sql_cast(Task.in_progress_at, DateTime)
duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0
statement = (
select(bucket_col, func.avg(duration_hours))
@@ -249,7 +250,7 @@ async def _median_cycle_time_7d(
) -> float | None:
now = utcnow()
start = now - timedelta(days=7)
in_progress = cast(Task.in_progress_at, DateTime)
in_progress = sql_cast(Task.in_progress_at, DateTime)
duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0
statement = (
select(func.percentile_cont(0.5).within_group(duration_hours))

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import secrets
from typing import TYPE_CHECKING, Any, Sequence
from typing import TYPE_CHECKING, Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
@@ -65,6 +65,9 @@ from app.services.organizations import (
)
if TYPE_CHECKING:
from collections.abc import Sequence
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.auth import AuthContext
@@ -369,7 +372,7 @@ async def get_my_membership(
async def list_org_members(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DefaultLimitOffsetPage[OrganizationMemberRead]:
) -> LimitOffsetPage[OrganizationMemberRead]:
"""List members for the active organization."""
statement = (
select(OrganizationMember, User)
@@ -542,7 +545,7 @@ async def remove_org_member(
async def list_org_invites(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> DefaultLimitOffsetPage[OrganizationInviteRead]:
) -> LimitOffsetPage[OrganizationInviteRead]:
"""List pending invites for the active organization."""
statement = (
OrganizationInvite.objects.filter_by(organization_id=ctx.organization.id)

View File

@@ -3,13 +3,15 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Generic, TypeVar
from typing import TYPE_CHECKING, Any, Generic, TypeVar
from fastapi import HTTPException, status
from app.db.queryset import QuerySet, qs
if TYPE_CHECKING:
from sqlalchemy.orm import Mapped
from sqlalchemy.sql.elements import ColumnElement
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import SelectOfScalar
@@ -27,11 +29,17 @@ class APIQuerySet(Generic[ModelT]):
"""Expose the underlying SQL statement for advanced composition."""
return self.queryset.statement
def filter(self, *criteria: object) -> APIQuerySet[ModelT]:
def filter(
self,
*criteria: ColumnElement[bool] | bool,
) -> APIQuerySet[ModelT]:
"""Return a new queryset with additional SQL criteria applied."""
return APIQuerySet(self.queryset.filter(*criteria))
def order_by(self, *ordering: object) -> APIQuerySet[ModelT]:
def order_by(
self,
*ordering: Mapped[Any] | ColumnElement[Any] | str,
) -> APIQuerySet[ModelT]:
"""Return a new queryset with ordering clauses applied."""
return APIQuerySet(self.queryset.order_by(*ordering))

View File

@@ -7,8 +7,8 @@ import json
from collections import deque
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING, cast
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -67,8 +67,9 @@ from app.services.task_dependencies import (
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import Select, SelectOfScalar
from sqlmodel.sql.expression import SelectOfScalar
from app.core.auth import AuthContext
from app.models.users import User
@@ -85,6 +86,7 @@ TASK_EVENT_TYPES = {
SSE_SEEN_MAX = 2000
TASK_SNIPPET_MAX_LEN = 500
TASK_SNIPPET_TRUNCATED_LEN = 497
TASK_EVENT_ROW_LEN = 2
BOARD_READ_DEP = Depends(get_board_for_actor_read)
ACTOR_DEP = Depends(require_admin_or_agent)
SINCE_QUERY = Query(default=None)
@@ -154,7 +156,7 @@ def _parse_since(value: str | None) -> datetime | None:
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed.astimezone(UTC).replace(tzinfo=None)
return parsed
@@ -168,6 +170,24 @@ def _coerce_task_items(items: Sequence[object]) -> list[Task]:
return tasks
def _coerce_task_event_rows(
items: Sequence[object],
) -> list[tuple[ActivityEvent, Task | None]]:
rows: list[tuple[ActivityEvent, Task | None]] = []
for item in items:
if (
isinstance(item, tuple)
and len(item) == TASK_EVENT_ROW_LEN
and isinstance(item[0], ActivityEvent)
and (isinstance(item[1], Task) or item[1] is None)
):
rows.append((item[0], item[1]))
continue
msg = "Expected (ActivityEvent, Task | None) rows"
raise TypeError(msg)
return rows
async def _lead_was_mentioned(
session: AsyncSession,
task: Task,
@@ -276,16 +296,16 @@ async def _fetch_task_events(
)
if not task_ids:
return []
statement = cast(
"Select[tuple[ActivityEvent, Task | None]]",
statement = (
select(ActivityEvent, Task)
.outerjoin(Task, col(ActivityEvent.task_id) == col(Task.id))
.where(col(ActivityEvent.task_id).in_(task_ids))
.where(col(ActivityEvent.event_type).in_(TASK_EVENT_TYPES))
.where(col(ActivityEvent.created_at) >= since)
.order_by(asc(col(ActivityEvent.created_at))),
.order_by(asc(col(ActivityEvent.created_at)))
)
return list(await session.exec(statement))
result = await session.execute(statement)
return _coerce_task_event_rows(list(result.tuples().all()))
def _serialize_comment(event: ActivityEvent) -> dict[str, object]:
@@ -718,7 +738,7 @@ async def list_tasks(
board: Board = BOARD_READ_DEP,
session: AsyncSession = SESSION_DEP,
_actor: ActorContext = ACTOR_DEP,
) -> DefaultLimitOffsetPage[TaskRead]:
) -> LimitOffsetPage[TaskRead]:
"""List board tasks with optional status and assignment filters."""
statement = _task_list_statement(
board_id=board.id,
@@ -914,7 +934,7 @@ async def delete_task(
async def list_task_comments(
task: Task = TASK_DEP,
session: AsyncSession = SESSION_DEP,
) -> DefaultLimitOffsetPage[TaskCommentRead]:
) -> LimitOffsetPage[TaskCommentRead]:
"""List comments for a task in chronological order."""
statement = (
select(ActivityEvent)