feat: add share calendar API

This commit is contained in:
qzl
2026-02-28 12:15:59 +08:00
parent 709ae5ab73
commit 7a49783156
5 changed files with 204 additions and 1 deletions
@@ -10,6 +10,7 @@ from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository from core.db.base_repository import BaseRepository
from core.logging import get_logger from core.logging import get_logger
from models.schedule_items import ScheduleItem from models.schedule_items import ScheduleItem
from models.schedule_subscriptions import ScheduleSubscription
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +19,7 @@ logger = get_logger("v1.schedule_items.repository")
class ScheduleItemRepository(Protocol): class ScheduleItemRepository(Protocol):
async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None: ...
async def get_by_item_id( async def get_by_item_id(
self, item_id: UUID, owner_id: UUID self, item_id: UUID, owner_id: UUID
) -> ScheduleItem | None: ... ) -> ScheduleItem | None: ...
@@ -31,6 +33,7 @@ class ScheduleItemRepository(Protocol):
async def list_by_date_range( async def list_by_date_range(
self, owner_id: UUID, start_at: datetime, end_at: datetime self, owner_id: UUID, start_at: datetime, end_at: datetime
) -> list[ScheduleItem]: ... ) -> list[ScheduleItem]: ...
async def create_subscription(self, data: dict) -> ScheduleSubscription: ...
class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
@@ -127,3 +130,12 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
except SQLAlchemyError: except SQLAlchemyError:
logger.exception("Schedule item list failed", owner_id=str(owner_id)) logger.exception("Schedule item list failed", owner_id=str(owner_id))
raise raise
async def create_subscription(self, data: dict) -> ScheduleSubscription:
sub = ScheduleSubscription(**data)
self._session.add(sub)
await self._session.flush()
return sub
async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None:
return await super().get_by_id(entity_id)
+11
View File
@@ -12,6 +12,8 @@ from v1.schedule_items.schemas import (
ScheduleItemListItem, ScheduleItemListItem,
ScheduleItemListRequest, ScheduleItemListRequest,
ScheduleItemResponse, ScheduleItemResponse,
ScheduleItemShareRequest,
ScheduleItemShareResponse,
ScheduleItemUpdateRequest, ScheduleItemUpdateRequest,
) )
from v1.schedule_items.service import ScheduleItemService from v1.schedule_items.service import ScheduleItemService
@@ -72,3 +74,12 @@ async def delete_schedule_item(
service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)], service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)],
) -> None: ) -> None:
await service.delete(item_id) await service.delete(item_id)
@router.post("/{item_id}/share", response_model=ScheduleItemShareResponse)
async def share_schedule_item(
item_id: UUID,
request: ScheduleItemShareRequest,
service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)],
) -> ScheduleItemShareResponse:
return await service.share(item_id, request)
+21
View File
@@ -96,3 +96,24 @@ class ScheduleItemListItem(BaseModel):
class ScheduleItemListRequest(BaseModel): class ScheduleItemListRequest(BaseModel):
start_at: datetime start_at: datetime
end_at: datetime end_at: datetime
class ScheduleItemShareRequest(BaseModel):
email: str = Field(..., description="Email of user to share with")
permission_view: bool = Field(True, description="Grant view permission")
permission_edit: bool = Field(False, description="Grant edit permission")
permission_invite: bool = Field(False, description="Grant invite permission")
def _permission_value(self) -> int:
value = 0
if self.permission_view:
value |= 1
if self.permission_edit:
value |= 4
if self.permission_invite:
value |= 2
return value
class ScheduleItemShareResponse(BaseModel):
message: str
+59 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING import json
from typing import TYPE_CHECKING, Protocol
from uuid import UUID from uuid import UUID
from fastapi import HTTPException from fastapi import HTTPException
@@ -9,13 +10,17 @@ from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.db.base_service import BaseService from core.db.base_service import BaseService
from core.logging import get_logger from core.logging import get_logger
from models.inbox_messages import InboxMessage, InboxMessageType
from models.schedule_items import ScheduleItem from models.schedule_items import ScheduleItem
from v1.auth.gateway import SupabaseAuthGateway
from v1.schedule_items.repository import ScheduleItemRepository from v1.schedule_items.repository import ScheduleItemRepository
from v1.schedule_items.schemas import ( from v1.schedule_items.schemas import (
ScheduleItemCreateRequest, ScheduleItemCreateRequest,
ScheduleItemListRequest, ScheduleItemListRequest,
ScheduleItemMetadata, ScheduleItemMetadata,
ScheduleItemResponse, ScheduleItemResponse,
ScheduleItemShareRequest,
ScheduleItemShareResponse,
ScheduleItemUpdateRequest, ScheduleItemUpdateRequest,
ScheduleItemSourceType, ScheduleItemSourceType,
ScheduleItemStatus, ScheduleItemStatus,
@@ -24,22 +29,31 @@ from v1.schedule_items.schemas import (
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from v1.auth.schemas import UserByEmailResponse
logger = get_logger("v1.schedule_items.service") logger = get_logger("v1.schedule_items.service")
class AuthByEmailGateway(Protocol):
async def get_user_by_email(self, email: str) -> "UserByEmailResponse": ...
class ScheduleItemService(BaseService): class ScheduleItemService(BaseService):
_repository: ScheduleItemRepository _repository: ScheduleItemRepository
_session: AsyncSession _session: AsyncSession
_auth_gateway: AuthByEmailGateway
def __init__( def __init__(
self, self,
repository: ScheduleItemRepository, repository: ScheduleItemRepository,
session: AsyncSession, session: AsyncSession,
current_user: CurrentUser | None, current_user: CurrentUser | None,
auth_gateway: AuthByEmailGateway | None = None,
) -> None: ) -> None:
super().__init__(current_user=current_user) super().__init__(current_user=current_user)
self._repository = repository self._repository = repository
self._session = session self._session = session
self._auth_gateway = auth_gateway or SupabaseAuthGateway()
async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse: async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse:
user_id = self.require_user_id() user_id = self.require_user_id()
@@ -179,6 +193,50 @@ class ScheduleItemService(BaseService):
return [self._to_response(item) for item in items] return [self._to_response(item) for item in items]
async def share(
self, item_id: UUID, request: ScheduleItemShareRequest
) -> ScheduleItemShareResponse:
user_id = self.require_user_id()
try:
item = await self._repository.get_by_id(item_id)
if item is None:
raise HTTPException(status_code=404, detail="Schedule item not found")
if item.owner_id != user_id:
raise HTTPException(
status_code=403,
detail="Only owner can share this schedule item",
)
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,
)
self._session.add(message)
await self._session.commit()
except HTTPException:
raise
except SQLAlchemyError:
await self._session.rollback()
logger.exception("Failed to share schedule item", item_id=str(item_id))
raise HTTPException(
status_code=503, detail="Schedule item store unavailable"
)
except ValueError:
await self._session.rollback()
logger.exception(
"Auth lookup returned invalid user id", email=request.email
)
raise HTTPException(status_code=503, detail="Auth lookup unavailable")
return ScheduleItemShareResponse(message="Calendar invitation sent")
def _to_response(self, item: ScheduleItem) -> ScheduleItemResponse: def _to_response(self, item: ScheduleItem) -> ScheduleItemResponse:
return ScheduleItemResponse( return ScheduleItemResponse(
id=item.id, id=item.id,
@@ -0,0 +1,101 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import cast
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
from core.auth.models import CurrentUser
from models.schedule_items import ScheduleItem
from v1.auth.schemas import UserByEmailResponse
from v1.schedule_items.repository import ScheduleItemRepository
from v1.schedule_items.schemas import ScheduleItemShareRequest
from v1.schedule_items.service import ScheduleItemService
def test_share_request_schema() -> None:
request = ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=True,
permission_invite=False,
)
assert request.email == "friend@example.com"
assert request.permission_view is True
def test_permission_bits_calculation() -> None:
request = ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=True,
permission_invite=False,
)
assert request._permission_value() == 5
def _build_item(item_id: UUID, owner_id: UUID) -> ScheduleItem:
item = MagicMock(spec=ScheduleItem)
item.id = item_id
item.owner_id = owner_id
item.title = "test"
item.description = None
item.start_at = datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc)
item.end_at = None
item.timezone = "UTC"
item.extra_metadata = {}
item.created_at = datetime(2026, 2, 28, 10, 0, 0, tzinfo=timezone.utc)
item.updated_at = datetime(2026, 2, 28, 10, 0, 0, tzinfo=timezone.utc)
return item
class ShareRepo:
def __init__(self, item: ScheduleItem | None) -> None:
self._item = item
async def get_by_id(self, item_id: UUID) -> ScheduleItem | None:
if self._item and self._item.id == item_id:
return self._item
return None
class AuthGatewayStub:
async def get_user_by_email(self, email: str) -> UserByEmailResponse:
return UserByEmailResponse(
id="00000000-0000-0000-0000-000000000222",
email=email,
created_at="2026-02-28T10:00:00Z",
email_confirmed_at=None,
)
@pytest.mark.asyncio
async def test_share_forbidden_when_not_owner() -> None:
owner_id = UUID("00000000-0000-0000-0000-000000000001")
requester_id = UUID("00000000-0000-0000-0000-000000000002")
item_id = uuid4()
service = ScheduleItemService(
repository=cast(
ScheduleItemRepository,
ShareRepo(_build_item(item_id=item_id, owner_id=owner_id)),
),
session=AsyncMock(),
current_user=CurrentUser(id=requester_id),
auth_gateway=AuthGatewayStub(),
)
with pytest.raises(HTTPException) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=False,
permission_invite=False,
),
)
assert exc_info.value.status_code == 403