refactor: 重构 Agent 模块为 AgentScope,删除旧版 CrewAI/LiteLLM 实现

This commit is contained in:
qzl
2026-03-11 20:51:56 +08:00
parent 177ed616bf
commit 145e3dc615
149 changed files with 5120 additions and 11356 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ from core.agentscope.runtime.tasks import (
run_command_task_bulk,
run_command_task_critical,
)
from core.agent.infrastructure.storage.tool_result_storage import (
from core.agentscope.tools.tool_result_storage import (
create_tool_result_storage,
)
from core.config.settings import config
+25 -3
View File
@@ -15,7 +15,8 @@ from fastapi import HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from core.agentscope.events import to_sse_event
from core.agent.domain.agui_input import (
from core.agentscope.schemas.agui_input import (
extract_latest_tool_result,
parse_run_input,
validate_run_request_messages_contract,
)
@@ -29,6 +30,7 @@ from v1.users.dependencies import get_current_user
router = APIRouter(prefix="/agent", tags=["agent"])
_LAST_EVENT_ID_RE = re.compile(r"^\d+-\d+$")
_RUNS_PER_MINUTE = 30
_TRANSCRIBES_PER_MINUTE = 20
_MAX_SSE_CONNECTIONS_PER_USER = 3
_SSE_SLOT_TTL_SECONDS = 15 * 60
_MAX_TRANSCRIBE_AUDIO_BYTES = 10 * 1024 * 1024
@@ -61,6 +63,19 @@ async def _allow_run_request(*, user_id: str) -> bool:
return False
async def _allow_transcribe_request(*, user_id: str) -> bool:
try:
redis = await get_or_init_redis_client()
minute_bucket = int(time.time() // 60)
key = f"agent:transcribe-rate:{user_id}:{minute_bucket}"
count = await redis.incr(key)
if count == 1:
await redis.expire(key, 70)
return int(count) <= _TRANSCRIBES_PER_MINUTE
except Exception: # noqa: BLE001
return False
async def _acquire_sse_slot(*, user_id: str) -> bool:
try:
redis = await get_or_init_redis_client()
@@ -130,9 +145,13 @@ async def enqueue_resume(
if request.thread_id != thread_id:
raise HTTPException(status_code=422, detail="thread_id path/body mismatch")
try:
parse_run_input(request.model_dump(mode="json", by_alias=True))
normalized = parse_run_input(request.model_dump(mode="json", by_alias=True))
extract_latest_tool_result(normalized)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
allowed = await _allow_run_request(user_id=str(current_user.id))
if not allowed:
raise HTTPException(status_code=429, detail="Too many run requests")
task = await service.enqueue_resume(
thread_id=thread_id,
run_input=request,
@@ -240,9 +259,12 @@ async def transcribe(
request: Request,
current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> Union[AsrTranscribeResponse, JSONResponse]:
del current_user
temp_path: str | None = None
try:
allowed = await _allow_transcribe_request(user_id=str(current_user.id))
if not allowed:
raise HTTPException(status_code=429, detail="Too many transcribe requests")
if audio.content_type not in _ALLOWED_AUDIO_CONTENT_TYPES:
raise ValueError("Unsupported audio format")
+38 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy import select, update
from sqlalchemy.exc import SQLAlchemyError
from core.logging import get_logger
from models.inbox_messages import InboxMessage
from models.inbox_messages import InboxMessage, InboxMessageType, InboxMessageStatus
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,6 +26,12 @@ class InboxMessageRepository(Protocol):
async def mark_as_read(
self, message_id: UUID, recipient_id: UUID
) -> InboxMessage | None: ...
async def get_pending_calendar_invite(
self, schedule_item_id: UUID, recipient_id: UUID
) -> InboxMessage | None: ...
async def get_calendar_invite(
self, schedule_item_id: UUID, recipient_id: UUID
) -> InboxMessage | None: ...
class SQLAlchemyInboxMessageRepository:
@@ -105,3 +111,34 @@ class SQLAlchemyInboxMessageRepository:
recipient_id=str(recipient_id),
)
raise
async def get_pending_calendar_invite(
self, schedule_item_id: UUID, recipient_id: UUID
) -> InboxMessage | None:
try:
stmt = select(InboxMessage).where(
InboxMessage.schedule_item_id == schedule_item_id,
InboxMessage.recipient_id == recipient_id,
InboxMessage.message_type == InboxMessageType.CALENDAR,
InboxMessage.status == InboxMessageStatus.PENDING,
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
except SQLAlchemyError:
logger.exception("Failed to get pending calendar invite")
raise
async def get_calendar_invite(
self, schedule_item_id: UUID, recipient_id: UUID
) -> InboxMessage | None:
try:
stmt = select(InboxMessage).where(
InboxMessage.schedule_item_id == schedule_item_id,
InboxMessage.recipient_id == recipient_id,
InboxMessage.message_type == InboxMessageType.CALENDAR,
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
except SQLAlchemyError:
logger.exception("Failed to get calendar invite")
raise
+2
View File
@@ -7,6 +7,7 @@ from v1.auth.router import router as auth_router
from v1.friendships.router import router as friendships_router
from v1.inbox_messages.router import router as inbox_messages_router
from v1.schedule_items.router import router as schedule_items_router
from v1.todo.router import router as todo_router
from v1.users.router import router as users_router
@@ -17,3 +18,4 @@ router.include_router(friendships_router)
router.include_router(users_router)
router.include_router(schedule_items_router)
router.include_router(inbox_messages_router)
router.include_router(todo_router)
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.models import CurrentUser
from core.db import get_db
from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
from v1.schedule_items.service import ScheduleItemService
from v1.users.dependencies import get_current_user
@@ -17,8 +18,10 @@ def get_schedule_item_service(
user: Annotated[CurrentUser, Depends(get_current_user)],
) -> ScheduleItemService:
repository = SQLAlchemyScheduleItemRepository(session)
inbox_repository = SQLAlchemyInboxMessageRepository(session)
return ScheduleItemService(
repository=repository,
session=session,
current_user=user,
inbox_repository=inbox_repository,
)
+120 -3
View File
@@ -1,16 +1,16 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Protocol
from typing import TYPE_CHECKING, Protocol, Sequence
from uuid import UUID
from sqlalchemy import func, select, update
from sqlalchemy import func, select, update, delete
from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository
from core.logging import get_logger
from models.schedule_items import ScheduleItem
from models.schedule_subscriptions import ScheduleSubscription
from models.schedule_subscriptions import ScheduleSubscription, SubscriptionStatus
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@@ -41,11 +41,31 @@ class ScheduleItemRepository(Protocol):
page_size: int,
) -> tuple[list[ScheduleItem], int]: ...
async def create_subscription(self, data: dict) -> ScheduleSubscription: ...
async def get_subscriptions_by_item_id(
self, item_id: UUID
) -> list[ScheduleSubscription]: ...
async def get_subscription(
self, item_id: UUID, subscriber_id: UUID
) -> ScheduleSubscription | None: ...
async def update_subscription_status(
self, item_id: UUID, subscriber_id: UUID, status: SubscriptionStatus
): ...
async def delete_subscriptions_by_item_id(self, item_id: UUID): ...
async def get_user_subscriptions(
self, subscriber_id: UUID
) -> list[ScheduleSubscription]: ...
async def list_subscribed_items_by_date_range(
self,
subscriber_id: UUID,
start_at: datetime,
end_at: datetime,
) -> Sequence[tuple[ScheduleItem, ScheduleSubscription]]: ...
class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
def __init__(self, session: AsyncSession) -> None:
super().__init__(session, ScheduleItem)
self._session = session
async def get_by_item_id(
self, item_id: UUID, owner_id: UUID
@@ -181,3 +201,100 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
self._session.add(sub)
await self._session.flush()
return sub
async def get_subscriptions_by_item_id(
self, item_id: UUID
) -> list[ScheduleSubscription]:
try:
stmt = select(ScheduleSubscription).where(
ScheduleSubscription.item_id == item_id,
ScheduleSubscription.status == SubscriptionStatus.ACTIVE,
)
result = await self._session.execute(stmt)
return list(result.scalars().all())
except SQLAlchemyError:
logger.exception("Failed to get subscriptions", item_id=str(item_id))
raise
async def get_subscription(
self, item_id: UUID, subscriber_id: UUID
) -> ScheduleSubscription | None:
try:
stmt = select(ScheduleSubscription).where(
ScheduleSubscription.item_id == item_id,
ScheduleSubscription.subscriber_id == subscriber_id,
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
except SQLAlchemyError:
logger.exception("Failed to get subscription")
raise
async def update_subscription_status(
self, item_id: UUID, subscriber_id: UUID, status: SubscriptionStatus
):
try:
stmt = (
update(ScheduleSubscription)
.where(
ScheduleSubscription.item_id == item_id,
ScheduleSubscription.subscriber_id == subscriber_id,
)
.values(status=status)
)
await self._session.execute(stmt)
await self._session.flush()
except SQLAlchemyError:
logger.exception("Failed to update subscription status")
raise
async def delete_subscriptions_by_item_id(self, item_id: UUID):
try:
stmt = delete(ScheduleSubscription).where(
ScheduleSubscription.item_id == item_id
)
await self._session.execute(stmt)
await self._session.flush()
except SQLAlchemyError:
logger.exception("Failed to delete subscriptions")
raise
async def get_user_subscriptions(
self, subscriber_id: UUID
) -> list[ScheduleSubscription]:
try:
stmt = select(ScheduleSubscription).where(
ScheduleSubscription.subscriber_id == subscriber_id,
ScheduleSubscription.status == SubscriptionStatus.ACTIVE,
)
result = await self._session.execute(stmt)
return list(result.scalars().all())
except SQLAlchemyError:
logger.exception("Failed to get user subscriptions")
raise
async def list_subscribed_items_by_date_range(
self,
subscriber_id: UUID,
start_at: datetime,
end_at: datetime,
) -> Sequence[tuple[ScheduleItem, ScheduleSubscription]]:
try:
stmt = (
select(ScheduleItem, ScheduleSubscription)
.join(
ScheduleSubscription,
ScheduleSubscription.item_id == ScheduleItem.id,
)
.where(ScheduleSubscription.subscriber_id == subscriber_id)
.where(ScheduleSubscription.status == SubscriptionStatus.ACTIVE)
.where(ScheduleItem.deleted_at.is_(None))
.where(ScheduleItem.start_at >= start_at)
.where(ScheduleItem.start_at <= end_at)
.order_by(ScheduleItem.start_at.asc())
)
result = await self._session.execute(stmt)
return [tuple(row) for row in result.all()]
except SQLAlchemyError:
logger.exception("Failed to list subscribed items")
raise
+16
View File
@@ -71,3 +71,19 @@ async def share_schedule_item(
service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)],
) -> ScheduleItemShareResponse:
return await service.share(item_id, request)
@router.post("/{item_id}/accept", response_model=dict)
async def accept_subscription(
item_id: UUID,
service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)],
) -> dict:
return await service.accept_subscription(item_id)
@router.post("/{item_id}/reject", response_model=dict)
async def reject_subscription(
item_id: UUID,
service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)],
) -> dict:
return await service.reject_subscription(item_id)
+52 -2
View File
@@ -1,9 +1,9 @@
from __future__ import annotations
import json
from datetime import datetime
from enum import Enum
from typing import Literal
from typing import ClassVar
from typing import Literal, ClassVar, Union
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field
@@ -76,6 +76,7 @@ class ScheduleItemResponse(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True)
id: UUID
owner_id: UUID
title: str
description: str | None = None
start_at: datetime
@@ -86,6 +87,8 @@ class ScheduleItemResponse(BaseModel):
source_type: ScheduleItemSourceType
created_at: datetime
updated_at: datetime
permission: int = 1
is_owner: bool = False
class ScheduleItemListItem(BaseModel):
@@ -131,3 +134,50 @@ class ScheduleItemShareRequest(BaseModel):
class ScheduleItemShareResponse(BaseModel):
message: str
class CalendarInviteContent(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
type: Literal["invite"]
permission: int = Field(..., description="权限: 1=view, 4=edit, 8=invite")
action: Literal["pending"] = "pending"
class CalendarUpdateContent(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
type: Literal["update"]
title: str = Field(..., description="事件标题")
action: Literal["updated"] = "updated"
class CalendarDeleteContent(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
type: Literal["delete"]
title: str = Field(..., description="事件标题")
action: Literal["deleted"] = "deleted"
CalendarContent = Union[
CalendarInviteContent, CalendarUpdateContent, CalendarDeleteContent
]
def parse_calendar_content(content: str | None) -> CalendarContent | None:
if not content:
return None
try:
data = json.loads(content)
content_type = data.get("type")
if content_type == "invite":
return CalendarInviteContent(**data)
elif content_type == "update":
return CalendarUpdateContent(**data)
elif content_type == "delete":
return CalendarDeleteContent(**data)
else:
raise ValueError(f"Unknown calendar content type: {content_type}")
except Exception:
return None
+240 -18
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Protocol
from typing import TYPE_CHECKING, Protocol, Literal
from uuid import UUID
from fastapi import HTTPException
@@ -10,9 +10,11 @@ from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser
from core.db.base_service import BaseService
from core.logging import get_logger
from models.inbox_messages import InboxMessage, InboxMessageType
from models.inbox_messages import InboxMessage, InboxMessageType, InboxMessageStatus
from models.schedule_items import ScheduleItem
from models.schedule_subscriptions import SubscriptionPermission, SubscriptionStatus
from v1.auth.gateway import SupabaseAuthGateway
from v1.inbox_messages.repository import InboxMessageRepository
from v1.schedule_items.repository import ScheduleItemRepository
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
@@ -42,6 +44,7 @@ class ScheduleItemService(BaseService):
_repository: ScheduleItemRepository
_session: AsyncSession
_auth_gateway: AuthByEmailGateway
_inbox_repository: InboxMessageRepository
def __init__(
self,
@@ -49,11 +52,15 @@ class ScheduleItemService(BaseService):
session: AsyncSession,
current_user: CurrentUser | None,
auth_gateway: AuthByEmailGateway | None = None,
inbox_repository: InboxMessageRepository | None = None,
) -> None:
super().__init__(current_user=current_user)
self._repository = repository
self._session = session
self._auth_gateway = auth_gateway or SupabaseAuthGateway()
if inbox_repository is None:
raise ValueError("inbox_repository is required")
self._inbox_repository = inbox_repository
async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse:
return await self._create_with_source(
@@ -95,6 +102,15 @@ class ScheduleItemService(BaseService):
try:
item = await self._repository.create(data)
await self._repository.create_subscription(
{
"item_id": item.id,
"subscriber_id": user_id,
"permission": SubscriptionPermission.OWNER,
"status": SubscriptionStatus.ACTIVE,
"created_by": user_id,
}
)
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
@@ -109,7 +125,7 @@ class ScheduleItemService(BaseService):
user_id = self.require_user_id()
try:
item = await self._repository.get_by_item_id(item_id, user_id)
item = await self._repository.get_by_id(item_id)
except SQLAlchemyError:
logger.exception("Failed to get schedule item", item_id=str(item_id))
raise HTTPException(
@@ -119,7 +135,14 @@ class ScheduleItemService(BaseService):
if item is None:
raise HTTPException(status_code=404, detail="Schedule item not found")
return self._to_response(item)
is_owner = item.owner_id == user_id
permission = 1
if not is_owner:
subscription = await self._repository.get_subscription(item_id, user_id)
if subscription:
permission = subscription.permission
return self._to_response(item, is_owner=is_owner, permission=permission)
async def update(
self, item_id: UUID, request: ScheduleItemUpdateRequest
@@ -157,6 +180,7 @@ class ScheduleItemService(BaseService):
item = await self._repository.update_by_item_id(
item_id, user_id, update_data
)
await self._notify_subscribers(item_id, existing.title, "updated")
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
@@ -178,6 +202,9 @@ class ScheduleItemService(BaseService):
if existing is None:
raise HTTPException(status_code=404, detail="Schedule item not found")
title = existing.title
await self._repository.delete_subscriptions_by_item_id(item_id)
await self._notify_subscribers(item_id, title, "deleted")
await self._repository.delete_by_item_id(item_id, user_id)
await self._session.commit()
except SQLAlchemyError:
@@ -196,17 +223,30 @@ class ScheduleItemService(BaseService):
raise HTTPException(status_code=400, detail="end_at must be after start_at")
try:
items = await self._repository.list_by_date_range(
user_id, request.start_at, request.end_at
subscribed_items = (
await self._repository.list_subscribed_items_by_date_range(
user_id, request.start_at, request.end_at
)
)
results = []
for item, subscription in subscribed_items:
is_owner = item.owner_id == user_id
results.append(
self._to_response(
item, is_owner=is_owner, permission=subscription.permission
)
)
results.sort(key=lambda x: x.start_at)
return results
except SQLAlchemyError:
logger.exception("Failed to list schedule items")
raise HTTPException(
status_code=503, detail="Schedule item store unavailable"
)
return [self._to_response(item) for item in items]
async def list_paginated(
self,
*,
@@ -244,23 +284,91 @@ class ScheduleItemService(BaseService):
item = await self._repository.get_by_id(item_id)
if item is None:
raise HTTPException(status_code=404, detail="Schedule item not found")
inviter_permission = SubscriptionPermission.OWNER
if item.owner_id != user_id:
inviter_sub = await self._repository.get_subscription(item_id, user_id)
if inviter_sub is None:
raise HTTPException(
status_code=403,
detail="You don't have permission to share this calendar",
)
inviter_permission = SubscriptionPermission(inviter_sub.permission)
request_permission = request._permission_value()
if request_permission > inviter_permission:
raise HTTPException(
status_code=403,
detail="Only owner can share this schedule item",
detail=f"You can only share with permissions up to {inviter_permission}",
)
target_user = await self._auth_gateway.get_user_by_email(request.email)
recipient_id = UUID(target_user.id)
message = InboxMessage(
recipient_id=recipient_id,
sender_id=user_id,
message_type=InboxMessageType.CALENDAR,
schedule_item_id=item.id,
content=json.dumps({"permission": request._permission_value()}),
created_by=user_id,
existing = await self._repository.get_subscription(item_id, recipient_id)
if existing:
if existing.status == SubscriptionStatus.PENDING:
pass
elif existing.status == SubscriptionStatus.UNSUBSCRIBED:
await self._repository.update_subscription_status(
item_id, recipient_id, SubscriptionStatus.PENDING
)
else:
raise HTTPException(
status_code=400,
detail="User already has an active subscription to this calendar",
)
else:
await self._repository.create_subscription(
{
"item_id": item.id,
"subscriber_id": recipient_id,
"permission": request_permission,
"status": SubscriptionStatus.PENDING,
"created_by": user_id,
}
)
existing_msg = await self._inbox_repository.get_calendar_invite(
item.id, recipient_id
)
self._session.add(message)
if existing_msg:
if existing_msg.status == InboxMessageStatus.ACCEPTED:
raise HTTPException(
status_code=400,
detail="User already subscribed to this calendar",
)
elif existing_msg.status == InboxMessageStatus.PENDING:
raise HTTPException(
status_code=400,
detail="User already has a pending invitation to this calendar",
)
elif existing_msg.status == InboxMessageStatus.REJECTED:
existing_msg.status = InboxMessageStatus.PENDING
existing_msg.content = json.dumps(
{
"type": "invite",
"permission": request_permission,
"action": "pending",
}
)
else:
message = InboxMessage(
recipient_id=recipient_id,
sender_id=user_id,
message_type=InboxMessageType.CALENDAR,
schedule_item_id=item.id,
content=json.dumps(
{
"type": "invite",
"permission": request_permission,
"action": "pending",
}
),
created_by=user_id,
)
self._session.add(message)
await self._session.commit()
except HTTPException:
raise
@@ -279,7 +387,12 @@ class ScheduleItemService(BaseService):
return ScheduleItemShareResponse(message="Calendar invitation sent")
def _to_response(self, item: ScheduleItem) -> ScheduleItemResponse:
def _to_response(
self,
item: ScheduleItem,
is_owner: bool = False,
permission: int = 1,
) -> ScheduleItemResponse:
status_value = (
item.status.value if hasattr(item.status, "value") else item.status
)
@@ -290,6 +403,7 @@ class ScheduleItemService(BaseService):
)
return ScheduleItemResponse(
id=item.id,
owner_id=item.owner_id,
title=item.title,
description=item.description,
start_at=item.start_at,
@@ -302,4 +416,112 @@ class ScheduleItemService(BaseService):
source_type=ScheduleItemSourceType(str(source_type_value)),
created_at=item.created_at,
updated_at=item.updated_at,
permission=permission if not is_owner else 7,
is_owner=is_owner,
)
async def accept_subscription(self, item_id: UUID) -> dict:
user_id = self.require_user_id()
try:
inbox = await self._inbox_repository.get_pending_calendar_invite(
item_id, user_id
)
if inbox is None:
raise HTTPException(
status_code=404, detail="No pending invitation found"
)
content = json.loads(inbox.content or "{}")
permission = content.get("permission", 1)
existing = await self._repository.get_subscription(item_id, user_id)
if existing:
await self._repository.update_subscription_status(
item_id, user_id, SubscriptionStatus.ACTIVE
)
else:
await self._repository.create_subscription(
{
"item_id": item_id,
"subscriber_id": user_id,
"permission": permission,
"status": SubscriptionStatus.ACTIVE,
"created_by": inbox.sender_id,
}
)
inbox.status = InboxMessageStatus.ACCEPTED
await self._session.commit()
return {"message": "Subscription accepted"}
except HTTPException:
raise
except Exception:
await self._session.rollback()
logger.exception("Failed to accept subscription")
raise HTTPException(status_code=503, detail="Failed to accept subscription")
async def reject_subscription(self, item_id: UUID) -> dict:
user_id = self.require_user_id()
try:
inbox = await self._inbox_repository.get_pending_calendar_invite(
item_id, user_id
)
if inbox is None:
raise HTTPException(
status_code=404, detail="No pending invitation found"
)
existing = await self._repository.get_subscription(item_id, user_id)
if existing:
await self._repository.update_subscription_status(
item_id, user_id, SubscriptionStatus.UNSUBSCRIBED
)
inbox.status = InboxMessageStatus.REJECTED
await self._session.commit()
return {"message": "Subscription rejected"}
except HTTPException:
raise
except Exception:
await self._session.rollback()
logger.exception("Failed to reject subscription")
raise HTTPException(status_code=503, detail="Failed to reject subscription")
async def _notify_subscribers(
self,
item_id: UUID,
title: str,
action_type: Literal["updated", "deleted"],
):
user_id = self.require_user_id()
subscriptions = await self._repository.get_subscriptions_by_item_id(item_id)
for sub in subscriptions:
if sub.subscriber_id == user_id:
continue
content = json.dumps(
{
"type": action_type,
"title": title,
"action": action_type,
}
)
message = InboxMessage(
recipient_id=sub.subscriber_id,
sender_id=user_id,
message_type=InboxMessageType.CALENDAR,
schedule_item_id=item_id,
content=content,
created_by=user_id,
)
self._session.add(message)
if subscriptions:
await self._session.commit()
View File
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.models import CurrentUser
from core.db import get_db
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
from v1.todo.repository import SQLAlchemyTodoRepository
from v1.todo.service import TodoService
from v1.users.dependencies import get_current_user
async def get_todo_repository(
session: Annotated[AsyncSession, Depends(get_db)],
) -> SQLAlchemyTodoRepository:
return SQLAlchemyTodoRepository(session)
async def get_schedule_item_repository(
session: Annotated[AsyncSession, Depends(get_db)],
) -> SQLAlchemyScheduleItemRepository:
return SQLAlchemyScheduleItemRepository(session)
async def get_todo_service(
session: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> TodoService:
repository = SQLAlchemyTodoRepository(session)
schedule_item_repository = SQLAlchemyScheduleItemRepository(session)
return TodoService(
repository=repository,
schedule_item_repository=schedule_item_repository,
session=session,
current_user=current_user,
)
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository
from core.logging import get_logger
from models.todo_sources import TodoSource
from models.todos import Todo, TodoPriority, TodoStatus
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("v1.todo.repository")
class TodoRepository(Protocol):
"""Protocol defining the todo repository interface."""
async def create(
self,
owner_id: UUID,
title: str,
description: str | None = None,
due_at: datetime | None = None,
priority: int = TodoPriority.IMPORTANT_URGENT,
created_by: UUID | None = None,
) -> Todo:
"""Create a new todo."""
...
async def get_by_id(self, entity_id: UUID) -> Todo | None:
"""Get todo by ID."""
...
async def update(
self,
todo: Todo,
title: str | None = None,
description: str | None = None,
due_at: datetime | None = None,
priority: int | None = None,
status: TodoStatus | None = None,
completed_at: datetime | None = None,
) -> Todo:
"""Update a todo."""
...
async def list_by_owner(
self,
owner_id: UUID,
status: TodoStatus | None = None,
priority: int | None = None,
) -> list[Todo]:
"""List todos by owner with optional filters."""
...
async def set_schedule_items(
self, todo_id: UUID, schedule_item_ids: list[UUID]
) -> None:
"""Set schedule items for a todo."""
...
async def get_schedule_items(self, todo_id: UUID) -> list[UUID]:
"""Get schedule items for a todo."""
...
class SQLAlchemyTodoRepository(BaseRepository[Todo]):
"""SQLAlchemy implementation of TodoRepository.
Note: This repository only performs CRUD operations.
- No commit (only flush) - service layer handles transactions
- No auth logic - service layer handles authorization
- No HTTP exceptions - returns None or raises SQLAlchemyError
"""
def __init__(self, session: AsyncSession) -> None:
super().__init__(session, Todo)
self._session = session
async def create(
self,
owner_id: UUID,
title: str,
description: str | None = None,
due_at: datetime | None = None,
priority: int = TodoPriority.IMPORTANT_URGENT,
created_by: UUID | None = None,
) -> Todo:
try:
todo = Todo(
owner_id=owner_id,
title=title,
description=description,
due_at=due_at,
priority=priority,
status=TodoStatus.PENDING,
created_by=created_by,
)
self._session.add(todo)
await self._session.flush()
return todo
except SQLAlchemyError:
logger.exception(
"Failed to create todo",
owner_id=str(owner_id),
title=title,
)
raise
async def get_by_id(self, entity_id: UUID) -> Todo | None:
try:
return await super().get_by_id(entity_id)
except SQLAlchemyError:
logger.exception(
"Failed to get todo by id",
todo_id=str(entity_id),
)
raise
async def update(
self,
todo: Todo,
title: str | None = None,
description: str | None = None,
due_at: datetime | None = None,
priority: int | None = None,
status: TodoStatus | None = None,
completed_at: datetime | None = None,
) -> Todo:
try:
if title is not None:
todo.title = title
if description is not None:
todo.description = description
if due_at is not None:
todo.due_at = due_at
if priority is not None:
todo.priority = priority
if status is not None:
todo.status = status
if completed_at is not None:
todo.completed_at = completed_at
await self._session.flush()
return todo
except SQLAlchemyError:
logger.exception(
"Failed to update todo",
todo_id=str(todo.id),
)
raise
async def list_by_owner(
self,
owner_id: UUID,
status: TodoStatus | None = None,
priority: int | None = None,
) -> list[Todo]:
try:
stmt = (
select(Todo)
.where(Todo.owner_id == owner_id)
.where(Todo.deleted_at.is_(None))
.order_by(Todo.priority.asc(), Todo.due_at.asc().nullslast())
)
if status is not None:
stmt = stmt.where(Todo.status == status)
if priority is not None:
stmt = stmt.where(Todo.priority == priority)
result = await self._session.execute(stmt)
return list(result.scalars().all())
except SQLAlchemyError:
logger.exception(
"Failed to list todos by owner",
owner_id=str(owner_id),
)
raise
async def set_schedule_items(
self, todo_id: UUID, schedule_item_ids: list[UUID]
) -> None:
try:
stmt = select(TodoSource).where(TodoSource.todo_id == todo_id)
result = await self._session.execute(stmt)
existing = list(result.scalars().all())
for source in existing:
await self._session.delete(source)
for schedule_item_id in schedule_item_ids:
source = TodoSource(
todo_id=todo_id,
schedule_item_id=schedule_item_id,
)
self._session.add(source)
await self._session.flush()
except SQLAlchemyError:
logger.exception(
"Failed to set schedule items",
todo_id=str(todo_id),
)
raise
async def get_schedule_items(self, todo_id: UUID) -> list[UUID]:
try:
stmt = select(TodoSource).where(TodoSource.todo_id == todo_id)
result = await self._session.execute(stmt)
return [source.schedule_item_id for source in result.scalars().all()]
except SQLAlchemyError:
logger.exception(
"Failed to get schedule items",
todo_id=str(todo_id),
)
raise
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Annotated, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from v1.todo.dependencies import get_todo_service
from v1.todo.schemas import TodoComplete, TodoCreate, TodoResponse, TodoUpdate
from v1.todo.service import TodoService
router = APIRouter(prefix="/todos", tags=["todos"])
@router.post(
"",
response_model=TodoResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_todo(
payload: TodoCreate,
service: Annotated[TodoService, Depends(get_todo_service)],
) -> TodoResponse:
return await service.create(payload)
@router.get("", response_model=list[TodoResponse])
async def list_todos(
service: Annotated[TodoService, Depends(get_todo_service)],
status: Literal["pending", "done", "canceled"] | None = Query(None),
priority: int | None = Query(None, ge=1, le=4),
) -> list[TodoResponse]:
return await service.list_todos(status=status, priority=priority)
@router.get("/{todo_id}", response_model=TodoResponse)
async def get_todo(
todo_id: UUID,
service: Annotated[TodoService, Depends(get_todo_service)],
) -> TodoResponse:
return await service.get_by_id(todo_id)
@router.patch("/{todo_id}", response_model=TodoResponse)
async def update_todo(
todo_id: UUID,
payload: TodoUpdate,
service: Annotated[TodoService, Depends(get_todo_service)],
) -> TodoResponse:
return await service.update(todo_id, payload)
@router.post(
"/{todo_id}/complete",
response_model=TodoResponse,
)
async def complete_todo(
todo_id: UUID,
service: Annotated[TodoService, Depends(get_todo_service)],
body: TodoComplete,
) -> TodoResponse:
return await service.complete(todo_id)
@router.delete(
"/{todo_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_todo(
todo_id: UUID,
service: Annotated[TodoService, Depends(get_todo_service)],
) -> None:
await service.delete(todo_id)
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import datetime
from typing import ClassVar, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class TodoCreate(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
title: str = Field(..., min_length=1, max_length=255)
description: str | None = Field(None, max_length=1000)
due_at: datetime | None = None
priority: int = Field(1, ge=1, le=4)
schedule_item_ids: list[UUID] = Field(default_factory=list)
class TodoUpdate(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
title: str | None = Field(None, min_length=1, max_length=255)
description: str | None = Field(None, max_length=1000)
due_at: datetime | None = None
priority: int | None = Field(None, ge=1, le=4)
status: Literal["pending", "done", "canceled"] | None = None
schedule_item_ids: list[UUID] | None = None
class ScheduleItemBasic(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True)
id: UUID
title: str
start_at: datetime
end_at: datetime | None
class TodoResponse(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True)
id: UUID
owner_id: UUID
title: str
description: str | None
due_at: datetime | None
priority: int
status: str
completed_at: datetime | None
created_at: datetime
updated_at: datetime
schedule_items: list[ScheduleItemBasic] = Field(default_factory=list)
class TodoComplete(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
pass
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser
from core.db.base_service import BaseService
from core.logging import get_logger
from models.todos import Todo, TodoStatus
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
from v1.todo.repository import TodoRepository
from v1.todo.schemas import ScheduleItemBasic, TodoCreate, TodoResponse, TodoUpdate
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("v1.todo.service")
class TodoService(BaseService):
"""Todo service handling todo CRUD operations.
Responsibilities:
- Authorization checks
- Validation (ownership, status transitions)
- Transaction boundary (commit/rollback)
- Converting ORM models to response schemas
"""
_repository: TodoRepository
_schedule_item_repository: SQLAlchemyScheduleItemRepository
_session: AsyncSession
def __init__(
self,
repository: TodoRepository,
schedule_item_repository: SQLAlchemyScheduleItemRepository,
session: AsyncSession,
current_user: CurrentUser | None,
) -> None:
super().__init__(current_user=current_user)
self._repository = repository
self._schedule_item_repository = schedule_item_repository
self._session = session
async def create(self, request: TodoCreate) -> TodoResponse:
user_id = self.require_user_id()
try:
todo = await self._repository.create(
owner_id=user_id,
title=request.title,
description=request.description,
due_at=request.due_at,
priority=request.priority,
created_by=user_id,
)
if request.schedule_item_ids:
await self._repository.set_schedule_items(
todo.id, request.schedule_item_ids
)
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(status_code=503, detail="Todo service unavailable")
logger.info(
"todo_created",
extra={
"user_id": str(user_id),
"todo_id": str(todo.id),
},
)
return await self._to_response(todo)
async def get_by_id(self, todo_id: UUID) -> TodoResponse:
user_id = self.require_user_id()
try:
todo = await self._repository.get_by_id(todo_id)
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="Todo service unavailable")
if todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
if todo.owner_id != user_id:
logger.warning(
"todo_access_unauthorized",
extra={
"actor_id": str(user_id),
"todo_id": str(todo_id),
},
)
raise HTTPException(
status_code=403, detail="Not authorized to access this todo"
)
return await self._to_response(todo)
async def update(self, todo_id: UUID, request: TodoUpdate) -> TodoResponse:
user_id = self.require_user_id()
try:
todo = await self._repository.get_by_id(todo_id)
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="Todo service unavailable")
if todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
if todo.owner_id != user_id:
logger.warning(
"todo_update_unauthorized",
extra={
"actor_id": str(user_id),
"todo_id": str(todo_id),
},
)
raise HTTPException(
status_code=403, detail="Not authorized to update this todo"
)
completed_at = None
if request.status == TodoStatus.DONE and todo.status != TodoStatus.DONE:
completed_at = datetime.now(timezone.utc)
elif request.status != TodoStatus.DONE and todo.status == TodoStatus.DONE:
completed_at = None
status_enum: TodoStatus | None = None
if request.status is not None:
status_enum = TodoStatus(request.status)
try:
todo = await self._repository.update(
todo,
title=request.title,
description=request.description,
due_at=request.due_at,
priority=request.priority,
status=status_enum,
completed_at=completed_at,
)
if request.schedule_item_ids is not None:
await self._repository.set_schedule_items(
todo.id, request.schedule_item_ids
)
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(status_code=503, detail="Todo service unavailable")
logger.info(
"todo_updated",
extra={
"user_id": str(user_id),
"todo_id": str(todo_id),
},
)
return await self._to_response(todo)
async def complete(self, todo_id: UUID) -> TodoResponse:
user_id = self.require_user_id()
try:
todo = await self._repository.get_by_id(todo_id)
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="Todo service unavailable")
if todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
if todo.owner_id != user_id:
logger.warning(
"todo_complete_unauthorized",
extra={
"actor_id": str(user_id),
"todo_id": str(todo_id),
},
)
raise HTTPException(
status_code=403, detail="Not authorized to complete this todo"
)
try:
todo = await self._repository.update(
todo,
status=TodoStatus.DONE,
completed_at=datetime.now(timezone.utc),
)
await self._session.commit()
await self._session.refresh(todo)
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(status_code=503, detail="Todo service unavailable")
logger.info(
"todo_completed",
extra={
"user_id": str(user_id),
"todo_id": str(todo_id),
},
)
return await self._to_response(todo)
async def delete(self, todo_id: UUID) -> None:
user_id = self.require_user_id()
try:
todo = await self._repository.get_by_id(todo_id)
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="Todo service unavailable")
if todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
if todo.owner_id != user_id:
logger.warning(
"todo_delete_unauthorized",
extra={
"actor_id": str(user_id),
"todo_id": str(todo_id),
},
)
raise HTTPException(
status_code=403, detail="Not authorized to delete this todo"
)
try:
todo.deleted_at = datetime.now(timezone.utc)
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(status_code=503, detail="Todo service unavailable")
logger.info(
"todo_deleted",
extra={
"user_id": str(user_id),
"todo_id": str(todo_id),
},
)
async def list_todos(
self,
status: str | None = None,
priority: int | None = None,
) -> list[TodoResponse]:
user_id = self.require_user_id()
status_enum = None
if status is not None:
try:
status_enum = TodoStatus(status)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid status value")
if priority is not None and (priority < 1 or priority > 4):
raise HTTPException(status_code=400, detail="Invalid priority value")
try:
todos = await self._repository.list_by_owner(
owner_id=user_id,
status=status_enum,
priority=priority,
)
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="Todo service unavailable")
return [await self._to_response(todo) for todo in todos]
async def _to_response(self, todo: Todo) -> TodoResponse:
status_value = (
todo.status.value if hasattr(todo.status, "value") else str(todo.status)
)
schedule_item_ids = await self._repository.get_schedule_items(todo.id)
schedule_items = []
for item_id in schedule_item_ids:
item = await self._schedule_item_repository.get_by_id(item_id)
if item:
schedule_items.append(
ScheduleItemBasic(
id=item.id,
title=item.title,
start_at=item.start_at,
end_at=item.end_at,
)
)
return TodoResponse(
id=todo.id,
owner_id=todo.owner_id,
title=todo.title,
description=todo.description,
due_at=todo.due_at,
priority=todo.priority,
status=status_value,
completed_at=todo.completed_at,
created_at=todo.created_at,
updated_at=todo.updated_at,
schedule_items=schedule_items,
)
+1 -1
View File
@@ -8,7 +8,7 @@ from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser
from core.agent.infrastructure.persistence.user_context_cache import (
from core.agentscope.persistence.user_context_cache import (
create_user_context_cache,
)
from core.db.base_service import BaseService