feat: 添加日历批量操作与客户端时区感知功能,优化前端 UI 交互体验
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user