feat: Improve file display, timezone handling, and LOT management
Changes: - Fix datetime serialization with UTC 'Z' suffix for correct timezone display - Add PDF upload support with extension fallback for MIME detection - Fix LOT add/remove by creating new list for SQLAlchemy JSON change detection - Add file message components (FileMessage, ImageLightbox, UploadPreview) - Add multi-file upload support with progress tracking - Link uploaded files to chat messages via message_id - Include file attachments in AI report generation - Update specs for file-storage, realtime-messaging, and ai-report-generation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
"""Pydantic schemas for WebSocket messages and REST API"""
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_serializer
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
def serialize_datetime_utc(dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class MessageTypeEnum(str, Enum):
|
||||
"""Message type enumeration for validation"""
|
||||
TEXT = "text"
|
||||
@@ -89,6 +96,13 @@ class MessageBroadcast(BaseModel):
|
||||
deleted_at: Optional[datetime] = None
|
||||
sequence_number: int
|
||||
|
||||
@field_serializer("created_at", "edited_at", "deleted_at")
|
||||
def serialize_datetime(self, dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class SystemMessageBroadcast(BaseModel):
|
||||
"""System message broadcast"""
|
||||
@@ -99,6 +113,11 @@ class SystemMessageBroadcast(BaseModel):
|
||||
timestamp: datetime
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_serializer("timestamp")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class TypingBroadcast(BaseModel):
|
||||
"""Typing indicator broadcast"""
|
||||
@@ -115,6 +134,11 @@ class MessageAck(BaseModel):
|
||||
sequence_number: int
|
||||
timestamp: datetime
|
||||
|
||||
@field_serializer("timestamp")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class ErrorMessage(BaseModel):
|
||||
"""Error message"""
|
||||
@@ -145,16 +169,25 @@ class MessageResponse(BaseModel):
|
||||
sender_display_name: Optional[str] = None # Display name from users table
|
||||
content: str
|
||||
message_type: MessageTypeEnum
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, alias="message_metadata")
|
||||
# Use validation_alias to read from ORM's message_metadata, but serialize as "metadata"
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, validation_alias="message_metadata")
|
||||
created_at: datetime
|
||||
edited_at: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
sequence_number: int
|
||||
reaction_counts: Optional[Dict[str, int]] = None # emoji -> count
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
populate_by_name = True # Allow both 'metadata' and 'message_metadata'
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@field_serializer("created_at", "edited_at", "deleted_at")
|
||||
def serialize_datetime(self, dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class MessageListResponse(BaseModel):
|
||||
@@ -179,8 +212,12 @@ class ReactionResponse(BaseModel):
|
||||
emoji: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_serializer("created_at")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class ReactionSummary(BaseModel):
|
||||
@@ -195,12 +232,18 @@ class OnlineUser(BaseModel):
|
||||
user_id: str
|
||||
connected_at: datetime
|
||||
|
||||
@field_serializer("connected_at")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
# File Upload WebSocket Schemas
|
||||
class FileUploadedBroadcast(BaseModel):
|
||||
"""Broadcast when a file is uploaded to a room"""
|
||||
type: str = "file_uploaded"
|
||||
file_id: str
|
||||
message_id: Optional[str] = None # Associated chat message ID
|
||||
room_id: str
|
||||
uploader_id: str
|
||||
filename: str
|
||||
@@ -208,6 +251,7 @@ class FileUploadedBroadcast(BaseModel):
|
||||
file_size: int
|
||||
mime_type: str
|
||||
download_url: Optional[str] = None
|
||||
thumbnail_url: Optional[str] = None # Thumbnail URL for images
|
||||
uploaded_at: datetime
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -215,6 +259,7 @@ class FileUploadedBroadcast(BaseModel):
|
||||
return {
|
||||
"type": self.type,
|
||||
"file_id": self.file_id,
|
||||
"message_id": self.message_id,
|
||||
"room_id": self.room_id,
|
||||
"uploader_id": self.uploader_id,
|
||||
"filename": self.filename,
|
||||
@@ -222,7 +267,8 @@ class FileUploadedBroadcast(BaseModel):
|
||||
"file_size": self.file_size,
|
||||
"mime_type": self.mime_type,
|
||||
"download_url": self.download_url,
|
||||
"uploaded_at": self.uploaded_at.isoformat()
|
||||
"thumbnail_url": self.thumbnail_url,
|
||||
"uploaded_at": self.uploaded_at.isoformat() + "Z"
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +295,7 @@ class FileDeletedBroadcast(BaseModel):
|
||||
"""Broadcast when a file is deleted from a room"""
|
||||
type: str = "file_deleted"
|
||||
file_id: str
|
||||
message_id: Optional[str] = None # Associated chat message ID (also deleted)
|
||||
room_id: str
|
||||
deleted_by: str
|
||||
deleted_at: datetime
|
||||
@@ -258,7 +305,27 @@ class FileDeletedBroadcast(BaseModel):
|
||||
return {
|
||||
"type": self.type,
|
||||
"file_id": self.file_id,
|
||||
"message_id": self.message_id,
|
||||
"room_id": self.room_id,
|
||||
"deleted_by": self.deleted_by,
|
||||
"deleted_at": self.deleted_at.isoformat()
|
||||
"deleted_at": self.deleted_at.isoformat() + "Z"
|
||||
}
|
||||
|
||||
|
||||
class MessageDeletedBroadcast(BaseModel):
|
||||
"""Broadcast when a message is deleted"""
|
||||
type: str = "message_deleted"
|
||||
message_id: str
|
||||
room_id: str
|
||||
deleted_by: str
|
||||
deleted_at: datetime
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for WebSocket broadcast"""
|
||||
return {
|
||||
"type": self.type,
|
||||
"message_id": self.message_id,
|
||||
"room_id": self.room_id,
|
||||
"deleted_by": self.deleted_by,
|
||||
"deleted_at": self.deleted_at.isoformat() + "Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user