feat: 增强日历功能并集成 AgentScope 代理服务
This commit is contained in:
@@ -365,7 +365,11 @@ def _list_auth_users(client: Any) -> list[Any]:
|
||||
|
||||
while page <= max_pages:
|
||||
response = client.auth.admin.list_users(page=page, per_page=100)
|
||||
batch = list(getattr(response, "users", []))
|
||||
batch = (
|
||||
list(response)
|
||||
if isinstance(response, list)
|
||||
else list(getattr(response, "users", []))
|
||||
)
|
||||
users.extend(batch)
|
||||
|
||||
if len(batch) < 100:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
@@ -21,11 +22,20 @@ class FriendshipRepository(Protocol):
|
||||
"""Protocol defining the friendship repository interface."""
|
||||
|
||||
async def create_request(
|
||||
self, initiator_id: UUID, recipient_id: UUID
|
||||
self, initiator_id: UUID, recipient_id: UUID, content: str | None = None
|
||||
) -> tuple[Friendship, InboxMessage]:
|
||||
"""Create a friendship request and inbox message."""
|
||||
...
|
||||
|
||||
async def reactivate_request(
|
||||
self,
|
||||
friendship: Friendship,
|
||||
initiator_id: UUID,
|
||||
content: str | None = None,
|
||||
) -> tuple[Friendship, InboxMessage]:
|
||||
"""Reactivate a declined or canceled friendship request."""
|
||||
...
|
||||
|
||||
async def get_friendship_between_users(
|
||||
self, user_id_1: UUID, user_id_2: UUID
|
||||
) -> Friendship | None:
|
||||
@@ -70,18 +80,21 @@ class SQLAlchemyFriendshipRepository(BaseRepository[Friendship]):
|
||||
super().__init__(session, Friendship)
|
||||
|
||||
async def create_request(
|
||||
self, initiator_id: UUID, recipient_id: UUID
|
||||
self, initiator_id: UUID, recipient_id: UUID, content: str | None = None
|
||||
) -> tuple[Friendship, InboxMessage]:
|
||||
try:
|
||||
user_low_id = min(initiator_id, recipient_id)
|
||||
user_high_id = max(initiator_id, recipient_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
friendship = Friendship(
|
||||
user_low_id=user_low_id,
|
||||
user_high_id=user_high_id,
|
||||
initiator_id=initiator_id,
|
||||
status=FriendshipStatus.PENDING,
|
||||
requested_at=UUID(int=0),
|
||||
requested_at=now,
|
||||
created_by=initiator_id,
|
||||
updated_by=initiator_id,
|
||||
)
|
||||
self._session.add(friendship)
|
||||
await self._session.flush()
|
||||
@@ -91,7 +104,9 @@ class SQLAlchemyFriendshipRepository(BaseRepository[Friendship]):
|
||||
sender_id=initiator_id,
|
||||
message_type=InboxMessageType.FRIEND_REQUEST,
|
||||
friendship_id=friendship.id,
|
||||
content=content,
|
||||
status=InboxMessageStatus.PENDING,
|
||||
created_by=initiator_id,
|
||||
)
|
||||
self._session.add(inbox)
|
||||
await self._session.flush()
|
||||
@@ -105,6 +120,44 @@ class SQLAlchemyFriendshipRepository(BaseRepository[Friendship]):
|
||||
)
|
||||
raise
|
||||
|
||||
async def reactivate_request(
|
||||
self,
|
||||
friendship: Friendship,
|
||||
initiator_id: UUID,
|
||||
content: str | None = None,
|
||||
) -> tuple[Friendship, InboxMessage]:
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
friendship.status = FriendshipStatus.PENDING
|
||||
friendship.requested_at = now
|
||||
friendship.initiator_id = initiator_id
|
||||
friendship.updated_by = initiator_id
|
||||
|
||||
inbox = InboxMessage(
|
||||
recipient_id=(
|
||||
friendship.user_low_id
|
||||
if initiator_id == friendship.user_high_id
|
||||
else friendship.user_high_id
|
||||
),
|
||||
sender_id=initiator_id,
|
||||
message_type=InboxMessageType.FRIEND_REQUEST,
|
||||
friendship_id=friendship.id,
|
||||
content=content,
|
||||
status=InboxMessageStatus.PENDING,
|
||||
created_by=initiator_id,
|
||||
)
|
||||
self._session.add(inbox)
|
||||
await self._session.flush()
|
||||
|
||||
return friendship, inbox
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to reactivate friendship request",
|
||||
friendship_id=str(friendship.id),
|
||||
initiator_id=str(initiator_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_friendship_between_users(
|
||||
self, user_id_1: UUID, user_id_2: UUID
|
||||
) -> Friendship | None:
|
||||
|
||||
@@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, status
|
||||
|
||||
from v1.friendships.dependencies import get_friendship_service
|
||||
from v1.friendships.schemas import (
|
||||
FriendRequestAction,
|
||||
FriendRequestCreate,
|
||||
FriendRequestResponse,
|
||||
FriendResponse,
|
||||
@@ -44,13 +43,20 @@ async def get_outgoing_requests(
|
||||
return await service.get_outgoing_requests()
|
||||
|
||||
|
||||
@router.get("/requests/{friendship_id}", response_model=FriendRequestResponse)
|
||||
async def get_friendship_request(
|
||||
friendship_id: UUID,
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> FriendRequestResponse:
|
||||
return await service.get_request_by_id(friendship_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/requests/{friendship_id}/accept",
|
||||
response_model=FriendRequestResponse,
|
||||
)
|
||||
async def accept_friend_request(
|
||||
friendship_id: UUID,
|
||||
_: FriendRequestAction,
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> FriendRequestResponse:
|
||||
return await service.accept_request(friendship_id)
|
||||
@@ -62,7 +68,6 @@ async def accept_friend_request(
|
||||
)
|
||||
async def decline_friend_request(
|
||||
friendship_id: UUID,
|
||||
_: FriendRequestAction,
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> FriendRequestResponse:
|
||||
return await service.decline_request(friendship_id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -10,8 +10,8 @@ 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.friendships import FriendshipStatus
|
||||
from models.inbox_messages import InboxMessageStatus, InboxMessageType
|
||||
from models.friendships import Friendship, FriendshipStatus
|
||||
from models.inbox_messages import InboxMessage, InboxMessageStatus, InboxMessageType
|
||||
from v1.friendships.repository import FriendshipRepository
|
||||
from v1.friendships.schemas import (
|
||||
FriendRequestCreate,
|
||||
@@ -67,18 +67,47 @@ class FriendshipService(BaseService):
|
||||
user_id, target_user_id
|
||||
)
|
||||
if existing:
|
||||
if existing.status == FriendshipStatus.ACCEPTED:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Already friends with this user"
|
||||
)
|
||||
if existing.status == FriendshipStatus.BLOCKED:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Cannot send friend request to blocked user"
|
||||
)
|
||||
match existing.status:
|
||||
case FriendshipStatus.ACCEPTED:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Already friends with this user"
|
||||
)
|
||||
case FriendshipStatus.BLOCKED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot send friend request to blocked user",
|
||||
)
|
||||
case FriendshipStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Friend request already sent"
|
||||
)
|
||||
case _:
|
||||
# DECLINED, CANCELED - 允许重新发送
|
||||
try:
|
||||
friendship, inbox = await self._repository.reactivate_request(
|
||||
existing, user_id, request.content
|
||||
)
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"friend_request_resent",
|
||||
extra={
|
||||
"initiator_id": str(user_id),
|
||||
"target_id": str(target_user_id),
|
||||
},
|
||||
)
|
||||
return await self._build_friend_request_response(
|
||||
friendship, inbox, user_id, target_user_id
|
||||
)
|
||||
|
||||
try:
|
||||
friendship, inbox = await self._repository.create_request(
|
||||
user_id, target_user_id
|
||||
user_id, target_user_id, request.content
|
||||
)
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
@@ -92,16 +121,8 @@ class FriendshipService(BaseService):
|
||||
extra={"initiator_id": str(user_id), "target_id": str(target_user_id)},
|
||||
)
|
||||
|
||||
sender = await self._user_repository.get_by_user_id(user_id)
|
||||
recipient = await self._user_repository.get_by_user_id(target_user_id)
|
||||
|
||||
return FriendRequestResponse(
|
||||
id=friendship.id,
|
||||
sender=self._build_user_basic_info(sender),
|
||||
recipient=self._build_user_basic_info(recipient),
|
||||
content=inbox.content,
|
||||
status="pending",
|
||||
created_at=friendship.created_at,
|
||||
return await self._build_friend_request_response(
|
||||
friendship, inbox, user_id, target_user_id
|
||||
)
|
||||
|
||||
async def accept_request(self, friendship_id: UUID) -> FriendRequestResponse:
|
||||
@@ -374,6 +395,61 @@ class FriendshipService(BaseService):
|
||||
|
||||
return result
|
||||
|
||||
async def get_request_by_id(self, friendship_id: UUID) -> FriendRequestResponse:
|
||||
user_id = self.require_user_id()
|
||||
|
||||
try:
|
||||
friendship = await self._repository.get_friendship_by_id(friendship_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Friendship service unavailable"
|
||||
)
|
||||
|
||||
if friendship is None:
|
||||
raise HTTPException(status_code=404, detail="Friend request not found")
|
||||
|
||||
# Determine sender and recipient based on current user
|
||||
# initiator_id is the sender
|
||||
initiator_id = friendship.initiator_id
|
||||
if initiator_id is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid friendship data")
|
||||
|
||||
if friendship.user_low_id != user_id and friendship.user_high_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to view this request"
|
||||
)
|
||||
|
||||
sender = await self._user_repository.get_by_user_id(initiator_id)
|
||||
recipient_id = (
|
||||
friendship.user_low_id
|
||||
if friendship.user_low_id != initiator_id
|
||||
else friendship.user_high_id
|
||||
)
|
||||
recipient = await self._user_repository.get_by_user_id(recipient_id)
|
||||
|
||||
# Map FriendshipStatus to response status
|
||||
status_value: Literal["pending", "accepted", "rejected", "canceled"]
|
||||
status_map = {
|
||||
FriendshipStatus.PENDING: "pending",
|
||||
FriendshipStatus.ACCEPTED: "accepted",
|
||||
FriendshipStatus.DECLINED: "rejected",
|
||||
FriendshipStatus.CANCELED: "canceled",
|
||||
FriendshipStatus.BLOCKED: "canceled",
|
||||
}
|
||||
status_value = cast(
|
||||
Literal["pending", "accepted", "rejected", "canceled"],
|
||||
status_map.get(friendship.status, "pending"),
|
||||
)
|
||||
|
||||
return FriendRequestResponse(
|
||||
id=friendship.id,
|
||||
sender=self._build_user_basic_info(sender),
|
||||
recipient=self._build_user_basic_info(recipient),
|
||||
content=None,
|
||||
status=status_value,
|
||||
created_at=friendship.created_at,
|
||||
)
|
||||
|
||||
async def get_outgoing_requests(self) -> list[FriendRequestResponse]:
|
||||
user_id = self.require_user_id()
|
||||
|
||||
@@ -386,13 +462,9 @@ class FriendshipService(BaseService):
|
||||
|
||||
result: list[FriendRequestResponse] = []
|
||||
for friendship in outgoing:
|
||||
recipient_id = (
|
||||
friendship.user_low_id
|
||||
if friendship.initiator_id == friendship.user_high_id
|
||||
else friendship.user_high_id
|
||||
)
|
||||
other_user_id = self._get_other_user_id(friendship, user_id)
|
||||
sender = await self._user_repository.get_by_user_id(user_id)
|
||||
recipient = await self._user_repository.get_by_user_id(recipient_id)
|
||||
recipient = await self._user_repository.get_by_user_id(other_user_id)
|
||||
|
||||
result.append(
|
||||
FriendRequestResponse(
|
||||
@@ -419,11 +491,7 @@ class FriendshipService(BaseService):
|
||||
|
||||
result: list[FriendResponse] = []
|
||||
for friendship in friendships:
|
||||
friend_id = (
|
||||
friendship.user_high_id
|
||||
if friendship.user_low_id == user_id
|
||||
else friendship.user_low_id
|
||||
)
|
||||
friend_id = self._get_other_user_id(friendship, user_id)
|
||||
friend = await self._user_repository.get_by_user_id(friend_id)
|
||||
|
||||
result.append(
|
||||
@@ -499,3 +567,31 @@ class FriendshipService(BaseService):
|
||||
username=p.username,
|
||||
avatar_url=p.avatar_url if hasattr(p, "avatar_url") else None,
|
||||
)
|
||||
|
||||
async def _build_friend_request_response(
|
||||
self,
|
||||
friendship: "Friendship",
|
||||
inbox: "InboxMessage",
|
||||
initiator_id: UUID,
|
||||
recipient_id: UUID,
|
||||
) -> "FriendRequestResponse":
|
||||
from v1.friendships.schemas import FriendRequestResponse
|
||||
|
||||
sender = await self._user_repository.get_by_user_id(initiator_id)
|
||||
recipient = await self._user_repository.get_by_user_id(recipient_id)
|
||||
|
||||
return FriendRequestResponse(
|
||||
id=friendship.id,
|
||||
sender=self._build_user_basic_info(sender),
|
||||
recipient=self._build_user_basic_info(recipient),
|
||||
content=inbox.content,
|
||||
status="pending",
|
||||
created_at=friendship.created_at,
|
||||
)
|
||||
|
||||
def _get_other_user_id(self, friendship: Friendship, current_user_id: UUID) -> UUID:
|
||||
return (
|
||||
friendship.user_high_id
|
||||
if friendship.user_low_id == current_user_id
|
||||
else friendship.user_low_id
|
||||
)
|
||||
|
||||
@@ -21,13 +21,10 @@ class InboxMessageRepository(Protocol):
|
||||
self, message_id: UUID, recipient_id: UUID
|
||||
) -> InboxMessage | None: ...
|
||||
async def list_by_recipient(
|
||||
self, recipient_id: UUID, status: str | None = None
|
||||
self, recipient_id: UUID, is_read: bool | None = None
|
||||
) -> list[InboxMessage]: ...
|
||||
async def update_status(
|
||||
self,
|
||||
message_id: UUID,
|
||||
recipient_id: UUID,
|
||||
status: str,
|
||||
async def mark_as_read(
|
||||
self, message_id: UUID, recipient_id: UUID
|
||||
) -> InboxMessage | None: ...
|
||||
|
||||
|
||||
@@ -67,7 +64,7 @@ class SQLAlchemyInboxMessageRepository:
|
||||
raise
|
||||
|
||||
async def list_by_recipient(
|
||||
self, recipient_id: UUID, status: str | None = None
|
||||
self, recipient_id: UUID, is_read: bool | None = None
|
||||
) -> list[InboxMessage]:
|
||||
try:
|
||||
stmt = (
|
||||
@@ -75,30 +72,27 @@ class SQLAlchemyInboxMessageRepository:
|
||||
.where(InboxMessage.recipient_id == recipient_id)
|
||||
.order_by(InboxMessage.created_at.desc())
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(InboxMessage.status == status)
|
||||
if is_read is not None:
|
||||
stmt = stmt.where(InboxMessage.is_read == is_read)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Inbox message list failed",
|
||||
recipient_id=str(recipient_id),
|
||||
status=status,
|
||||
is_read=is_read,
|
||||
)
|
||||
raise
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
message_id: UUID,
|
||||
recipient_id: UUID,
|
||||
status: str,
|
||||
async def mark_as_read(
|
||||
self, message_id: UUID, recipient_id: UUID
|
||||
) -> InboxMessage | None:
|
||||
try:
|
||||
stmt = (
|
||||
update(InboxMessage)
|
||||
.where(InboxMessage.id == message_id)
|
||||
.where(InboxMessage.recipient_id == recipient_id)
|
||||
.values(status=status, is_read=True)
|
||||
.values(is_read=True)
|
||||
.returning(InboxMessage)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
@@ -106,9 +100,8 @@ class SQLAlchemyInboxMessageRepository:
|
||||
return result.scalar_one_or_none()
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Inbox message status update failed",
|
||||
"Inbox message mark as read failed",
|
||||
message_id=str(message_id),
|
||||
recipient_id=str(recipient_id),
|
||||
status=status,
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -6,12 +6,7 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from v1.inbox_messages.dependencies import get_inbox_message_service
|
||||
from v1.inbox_messages.schemas import (
|
||||
InboxMessageAcceptRequest,
|
||||
InboxMessageListRequest,
|
||||
InboxMessageResponse,
|
||||
InboxMessageStatus,
|
||||
)
|
||||
from v1.inbox_messages.schemas import InboxMessageResponse
|
||||
from v1.inbox_messages.service import InboxMessageService
|
||||
|
||||
router = APIRouter(prefix="/inbox/messages", tags=["inbox-messages"])
|
||||
@@ -20,24 +15,14 @@ router = APIRouter(prefix="/inbox/messages", tags=["inbox-messages"])
|
||||
@router.get("", response_model=list[InboxMessageResponse])
|
||||
async def list_inbox_messages(
|
||||
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
|
||||
status: InboxMessageStatus | None = Query(default=None),
|
||||
is_read: bool | None = Query(default=None, description="Filter by read status"),
|
||||
) -> list[InboxMessageResponse]:
|
||||
request = InboxMessageListRequest(status=status)
|
||||
return await service.list_messages(request)
|
||||
return await service.list_messages(is_read=is_read)
|
||||
|
||||
|
||||
@router.post("/{message_id}/accept", response_model=InboxMessageResponse)
|
||||
async def accept_inbox_message(
|
||||
message_id: UUID,
|
||||
request: InboxMessageAcceptRequest,
|
||||
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
|
||||
) -> InboxMessageResponse:
|
||||
return await service.accept_invitation(message_id, request)
|
||||
|
||||
|
||||
@router.post("/{message_id}/dismiss", response_model=InboxMessageResponse)
|
||||
async def dismiss_inbox_message(
|
||||
@router.patch("/{message_id}/read", response_model=InboxMessageResponse)
|
||||
async def mark_as_read(
|
||||
message_id: UUID,
|
||||
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
|
||||
) -> InboxMessageResponse:
|
||||
return await service.dismiss_invitation(message_id)
|
||||
return await service.mark_as_read(message_id)
|
||||
|
||||
@@ -8,31 +8,6 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class PermissionBits:
|
||||
VIEW: int = 1 # 001
|
||||
INVITE: int = 2 # 010
|
||||
EDIT: int = 4 # 100
|
||||
|
||||
@classmethod
|
||||
def encode(cls, view: bool, edit: bool, invite: bool) -> int:
|
||||
value = 0
|
||||
if view:
|
||||
value |= cls.VIEW
|
||||
if edit:
|
||||
value |= cls.EDIT
|
||||
if invite:
|
||||
value |= cls.INVITE
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def decode(cls, permission: int) -> dict[str, bool]:
|
||||
return {
|
||||
"view": bool(permission & cls.VIEW),
|
||||
"edit": bool(permission & cls.EDIT),
|
||||
"invite": bool(permission & cls.INVITE),
|
||||
}
|
||||
|
||||
|
||||
class InboxMessageType(str, Enum):
|
||||
FRIEND_REQUEST = "friend_request"
|
||||
CALENDAR = "calendar"
|
||||
@@ -55,19 +30,8 @@ class InboxMessageResponse(BaseModel):
|
||||
sender_id: UUID | None = None
|
||||
message_type: InboxMessageType
|
||||
schedule_item_id: UUID | None = None
|
||||
friendship_id: UUID | None = None
|
||||
content: str | None = None
|
||||
is_read: bool = False
|
||||
status: InboxMessageStatus = InboxMessageStatus.PENDING
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class InboxMessageListRequest(BaseModel):
|
||||
status: InboxMessageStatus | None = None
|
||||
|
||||
|
||||
class InboxMessageAcceptRequest(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
permission_view: bool = True
|
||||
permission_edit: bool = False
|
||||
permission_invite: bool = False
|
||||
|
||||
@@ -12,18 +12,11 @@ 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
|
||||
from models.schedule_subscriptions import (
|
||||
ScheduleSubscription,
|
||||
SubscriptionStatus,
|
||||
)
|
||||
from v1.inbox_messages.repository import InboxMessageRepository
|
||||
from v1.inbox_messages.schemas import (
|
||||
InboxMessageAcceptRequest,
|
||||
InboxMessageListRequest,
|
||||
InboxMessageResponse,
|
||||
InboxMessageStatus,
|
||||
InboxMessageStatus as SchemaInboxMessageStatus,
|
||||
InboxMessageType,
|
||||
PermissionBits,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -47,13 +40,12 @@ class InboxMessageService(BaseService):
|
||||
self._session = session
|
||||
|
||||
async def list_messages(
|
||||
self, request: InboxMessageListRequest
|
||||
self, is_read: bool | None = None
|
||||
) -> list[InboxMessageResponse]:
|
||||
user_id = self.require_user_id()
|
||||
|
||||
try:
|
||||
status = request.status.value if request.status else None
|
||||
messages = await self._repository.list_by_recipient(user_id, status)
|
||||
messages = await self._repository.list_by_recipient(user_id, is_read)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("Failed to list inbox messages", user_id=str(user_id))
|
||||
raise HTTPException(
|
||||
@@ -62,65 +54,18 @@ class InboxMessageService(BaseService):
|
||||
|
||||
return [self._to_response(message) for message in messages]
|
||||
|
||||
async def accept_invitation(
|
||||
self,
|
||||
message_id: UUID,
|
||||
request: InboxMessageAcceptRequest,
|
||||
) -> InboxMessageResponse:
|
||||
async def mark_as_read(self, message_id: UUID) -> InboxMessageResponse:
|
||||
user_id = self.require_user_id()
|
||||
|
||||
try:
|
||||
message = await self._repository.get_by_id(message_id, user_id)
|
||||
if message is None:
|
||||
raise HTTPException(status_code=404, detail="Inbox message not found")
|
||||
if message.status.value != InboxMessageStatus.PENDING.value:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Inbox message already handled"
|
||||
)
|
||||
if (
|
||||
message.message_type.value != InboxMessageType.CALENDAR.value
|
||||
or message.schedule_item_id is None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Message is not a calendar invitation"
|
||||
)
|
||||
|
||||
invited_permission = self._parse_invited_permission(message.content)
|
||||
requested_permission = PermissionBits.encode(
|
||||
request.permission_view,
|
||||
request.permission_edit,
|
||||
request.permission_invite,
|
||||
)
|
||||
final_permission = requested_permission & invited_permission
|
||||
if final_permission == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No valid permissions requested (must be subset of invited permissions)",
|
||||
)
|
||||
|
||||
subscription = ScheduleSubscription(
|
||||
item_id=message.schedule_item_id,
|
||||
subscriber_id=user_id,
|
||||
permission=final_permission,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
created_by=user_id,
|
||||
)
|
||||
self._session.add(subscription)
|
||||
updated = await self._repository.update_status(
|
||||
message_id,
|
||||
user_id,
|
||||
InboxMessageStatus.ACCEPTED.value,
|
||||
)
|
||||
updated = await self._repository.mark_as_read(message_id, user_id)
|
||||
if updated is None:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=404, detail="Inbox message not found")
|
||||
await self._session.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception(
|
||||
"Failed to accept inbox invitation",
|
||||
"Failed to mark inbox message as read",
|
||||
message_id=str(message_id),
|
||||
user_id=str(user_id),
|
||||
)
|
||||
@@ -130,49 +75,30 @@ class InboxMessageService(BaseService):
|
||||
|
||||
return self._to_response(updated)
|
||||
|
||||
async def dismiss_invitation(self, message_id: UUID) -> InboxMessageResponse:
|
||||
user_id = self.require_user_id()
|
||||
|
||||
try:
|
||||
message = await self._repository.get_by_id(message_id, user_id)
|
||||
if message is None:
|
||||
raise HTTPException(status_code=404, detail="Inbox message not found")
|
||||
if message.status.value != InboxMessageStatus.PENDING.value:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Inbox message already handled"
|
||||
)
|
||||
|
||||
updated = await self._repository.update_status(
|
||||
message_id,
|
||||
user_id,
|
||||
InboxMessageStatus.DISMISSED.value,
|
||||
)
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception(
|
||||
"Failed to dismiss inbox invitation",
|
||||
message_id=str(message_id),
|
||||
user_id=str(user_id),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Inbox message store unavailable"
|
||||
)
|
||||
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Inbox message not found")
|
||||
return self._to_response(updated)
|
||||
|
||||
def _to_response(self, message: InboxMessage) -> InboxMessageResponse:
|
||||
status_value = (
|
||||
message.status.value if hasattr(message.status, "value") else message.status
|
||||
)
|
||||
message_type_value = (
|
||||
message.message_type.value
|
||||
if hasattr(message.message_type, "value")
|
||||
else message.message_type
|
||||
)
|
||||
return InboxMessageResponse(
|
||||
id=message.id,
|
||||
recipient_id=message.recipient_id,
|
||||
sender_id=message.sender_id,
|
||||
message_type=InboxMessageType(message.message_type),
|
||||
message_type=InboxMessageType(message_type_value),
|
||||
schedule_item_id=message.schedule_item_id,
|
||||
friendship_id=(
|
||||
message.friendship_id
|
||||
if isinstance(message.friendship_id, UUID)
|
||||
or message.friendship_id is None
|
||||
else None
|
||||
),
|
||||
content=message.content,
|
||||
is_read=bool(message.is_read),
|
||||
status=InboxMessageStatus(message.status),
|
||||
status=SchemaInboxMessageStatus(status_value),
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from fastapi import APIRouter, Depends, Query
|
||||
from v1.schedule_items.dependencies import get_schedule_item_service
|
||||
from v1.schedule_items.schemas import (
|
||||
ScheduleItemCreateRequest,
|
||||
ScheduleItemListItem,
|
||||
ScheduleItemListRequest,
|
||||
ScheduleItemResponse,
|
||||
ScheduleItemShareRequest,
|
||||
@@ -30,15 +29,14 @@ async def create_schedule_item(
|
||||
return await service.create(request)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScheduleItemListItem])
|
||||
@router.get("", response_model=list[ScheduleItemResponse])
|
||||
async def list_schedule_items(
|
||||
service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)],
|
||||
start_at: datetime = Query(..., description="Start date/time for range query"),
|
||||
end_at: datetime = Query(..., description="End date/time for range query"),
|
||||
) -> list[ScheduleItemListItem]:
|
||||
) -> list[ScheduleItemResponse]:
|
||||
request = ScheduleItemListRequest(start_at=start_at, end_at=end_at)
|
||||
items = await service.list_by_date_range(request)
|
||||
return [ScheduleItemListItem.model_validate(item) for item in items]
|
||||
return await service.list_by_date_range(request)
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=ScheduleItemResponse)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
from typing import ClassVar
|
||||
from uuid import UUID
|
||||
|
||||
@@ -14,6 +15,8 @@ class AttachmentType(str, Enum):
|
||||
|
||||
|
||||
class ScheduleItemMetadataAttachment(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
type: AttachmentType
|
||||
visible_to: list[UUID] = Field(default_factory=list)
|
||||
@@ -23,11 +26,13 @@ class ScheduleItemMetadataAttachment(BaseModel):
|
||||
|
||||
|
||||
class ScheduleItemMetadata(BaseModel):
|
||||
color: str | None = None
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
location: str | None = None
|
||||
notes: str | None = None
|
||||
attachments: list[ScheduleItemMetadataAttachment] = Field(default_factory=list)
|
||||
version: int = 1
|
||||
version: Literal[1] = 1
|
||||
|
||||
|
||||
class ScheduleItemStatus(str, Enum):
|
||||
|
||||
@@ -87,7 +87,7 @@ class ScheduleItemService(BaseService):
|
||||
"start_at": request.start_at,
|
||||
"end_at": request.end_at,
|
||||
"timezone": request.timezone,
|
||||
"metadata": request.metadata.model_dump() if request.metadata else {},
|
||||
"extra_metadata": request.metadata.model_dump() if request.metadata else {},
|
||||
"source_type": source_type,
|
||||
"status": ScheduleItemStatus.ACTIVE,
|
||||
"created_by": user_id,
|
||||
@@ -136,7 +136,13 @@ class ScheduleItemService(BaseService):
|
||||
|
||||
# Handle metadata separately (model_dump returns dict)
|
||||
if "metadata" in update_data and update_data["metadata"] is not None:
|
||||
update_data["metadata"] = update_data["metadata"].model_dump()
|
||||
metadata_value = update_data["metadata"]
|
||||
update_data["extra_metadata"] = (
|
||||
metadata_value.model_dump()
|
||||
if hasattr(metadata_value, "model_dump")
|
||||
else metadata_value
|
||||
)
|
||||
del update_data["metadata"]
|
||||
|
||||
# Validate time range
|
||||
next_start = update_data.get("start_at", existing.start_at)
|
||||
@@ -275,6 +281,14 @@ class ScheduleItemService(BaseService):
|
||||
return ScheduleItemShareResponse(message="Calendar invitation sent")
|
||||
|
||||
def _to_response(self, item: ScheduleItem) -> ScheduleItemResponse:
|
||||
status_value = (
|
||||
item.status.value if hasattr(item.status, "value") else item.status
|
||||
)
|
||||
source_type_value = (
|
||||
item.source_type.value
|
||||
if hasattr(item.source_type, "value")
|
||||
else item.source_type
|
||||
)
|
||||
return ScheduleItemResponse(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
@@ -285,8 +299,8 @@ class ScheduleItemService(BaseService):
|
||||
metadata=ScheduleItemMetadata.model_validate(item.extra_metadata)
|
||||
if item.extra_metadata
|
||||
else None,
|
||||
status=ScheduleItemStatus(item.status.value),
|
||||
source_type=ScheduleItemSourceType(item.source_type.value),
|
||||
status=ScheduleItemStatus(str(status_value)),
|
||||
source_type=ScheduleItemSourceType(str(source_type_value)),
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
|
||||
@@ -69,6 +69,7 @@ def get_current_user(authorization: str | None = Header(default=None)) -> Curren
|
||||
logger.warning(
|
||||
"JWT validation failed",
|
||||
error_type=type(exc).__name__,
|
||||
reason=str(exc),
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Unauthorized") from exc
|
||||
|
||||
|
||||
Reference in New Issue
Block a user