feat: 添加日历批量操作与客户端时区感知功能,优化前端 UI 交互体验

This commit is contained in:
zl-q
2026-03-17 00:13:41 +08:00
parent d3783522e6
commit c26cdbbc27
27 changed files with 1532 additions and 412 deletions
+48 -2
View File
@@ -3,8 +3,9 @@ from __future__ import annotations
from datetime import datetime
from typing import ClassVar
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from schemas.inbox.messages import (
CalendarContent,
@@ -49,9 +50,27 @@ class ScheduleItemCreateRequest(BaseModel):
description: str | None = Field(default=None, max_length=2000)
start_at: datetime
end_at: datetime | None = None
timezone: str = Field(default="UTC", max_length=50)
timezone: str = Field(..., min_length=1, max_length=50)
metadata: ScheduleItemMetadata | None = None
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError("timezone must be a valid IANA timezone") from exc
return value
@field_validator("start_at", "end_at")
@classmethod
def validate_datetime_tzinfo(cls, value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
raise ValueError("datetime must include timezone offset")
return value
class ScheduleItemUpdateRequest(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@@ -64,6 +83,26 @@ class ScheduleItemUpdateRequest(BaseModel):
metadata: ScheduleItemMetadata | None = None
status: ScheduleItemStatus | None = None
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str | None) -> str | None:
if value is None:
return None
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError("timezone must be a valid IANA timezone") from exc
return value
@field_validator("start_at", "end_at")
@classmethod
def validate_datetime_tzinfo(cls, value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
raise ValueError("datetime must include timezone offset")
return value
class ScheduleItemResponse(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True)
@@ -99,6 +138,13 @@ class ScheduleItemListRequest(BaseModel):
start_at: datetime
end_at: datetime
@field_validator("start_at", "end_at")
@classmethod
def validate_datetime_tzinfo(cls, value: datetime) -> datetime:
if value.tzinfo is None:
raise ValueError("datetime must include timezone offset")
return value
_PERMISSION_VIEW = 1
_PERMISSION_INVITE = 2
+42 -9
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Protocol, Literal
from uuid import UUID
@@ -83,15 +84,18 @@ class ScheduleItemService(BaseService):
) -> ScheduleItemResponse:
user_id = self.require_user_id()
if request.end_at and request.end_at <= request.start_at:
normalized_start_at = self._to_utc_required(request.start_at)
normalized_end_at = self._to_utc(request.end_at)
if normalized_end_at and normalized_end_at <= normalized_start_at:
raise HTTPException(status_code=400, detail="end_at must be after start_at")
data = {
"owner_id": user_id,
"title": request.title,
"description": request.description,
"start_at": request.start_at,
"end_at": request.end_at,
"start_at": normalized_start_at,
"end_at": normalized_end_at,
"timezone": request.timezone,
"extra_metadata": request.metadata.model_dump() if request.metadata else {},
"source_type": source_type,
@@ -168,10 +172,21 @@ class ScheduleItemService(BaseService):
# Validate time range
next_start = update_data.get("start_at", existing.start_at)
next_end = update_data.get("end_at", existing.end_at)
if next_end is not None and next_end <= next_start:
raise HTTPException(
status_code=400, detail="end_at must be after start_at"
)
if isinstance(next_start, datetime):
next_start = self._to_utc_required(next_start)
update_data["start_at"] = next_start
if isinstance(next_end, datetime):
next_end = self._to_utc(next_end)
update_data["end_at"] = next_end
if next_end is not None:
if not isinstance(next_start, datetime):
raise HTTPException(
status_code=400, detail="start_at must include timezone"
)
if next_end <= next_start:
raise HTTPException(
status_code=400, detail="end_at must be after start_at"
)
if not update_data:
return self._to_response(existing)
@@ -218,13 +233,16 @@ class ScheduleItemService(BaseService):
) -> list[ScheduleItemResponse]:
user_id = self.require_user_id()
if request.end_at <= request.start_at:
normalized_start_at = self._to_utc_required(request.start_at)
normalized_end_at = self._to_utc_required(request.end_at)
if normalized_end_at <= normalized_start_at:
raise HTTPException(status_code=400, detail="end_at must be after start_at")
try:
subscribed_items = (
await self._repository.list_subscribed_items_by_date_range(
user_id, request.start_at, request.end_at
user_id, normalized_start_at, normalized_end_at
)
)
@@ -518,3 +536,18 @@ class ScheduleItemService(BaseService):
if subscriptions:
await self._session.commit()
def _to_utc(self, dt: datetime | None) -> datetime | None:
if dt is None:
return None
if dt.tzinfo is None:
raise HTTPException(
status_code=400, detail="datetime must include timezone"
)
return dt.astimezone(timezone.utc)
def _to_utc_required(self, dt: datetime) -> datetime:
normalized = self._to_utc(dt)
if normalized is None:
raise HTTPException(status_code=400, detail="datetime is required")
return normalized