feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
977783c126
commit
9eb8613c05
126 files changed
+17436
No files matched your search
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, field_serializer, field_validator
|
||||
from typing import Optional, Dict, List
|
||||
from typing_extensions import Annotated
|
||||
from uuid import UUID
|
||||
import enum
|
||||
|
||||
from app.core.s3 import sign_s3_url, strip_s3_signature
|
||||
|
||||
# Cap launcher/welcome copy so it can't balloon the widget (the launcher nudge is
|
||||
# also clamped to 4 lines client-side). Limits match the frontend input maxlengths
|
||||
# so the UI never lets a user type something the API would reject.
|
||||
InitiationMessage = Annotated[str, Field(max_length=100)]
|
||||
|
||||
|
||||
class ChatStyle(str, enum.Enum):
|
||||
CHATBOT = "CHATBOT"
|
||||
ASK_ANYTHING = "ASK_ANYTHING"
|
||||
# Premium design presets
|
||||
GLASS = "GLASS"
|
||||
TERMINAL = "TERMINAL"
|
||||
PLAYFUL = "PLAYFUL"
|
||||
CALM_MINT = "CALM_MINT"
|
||||
AURORA = "AURORA"
|
||||
SUNRISE = "SUNRISE"
|
||||
|
||||
|
||||
class WidgetPosition(str, enum.Enum):
|
||||
FLOATING = "FLOATING"
|
||||
FIXED = "FIXED"
|
||||
|
||||
|
||||
class WidgetDisplayMode(str, enum.Enum):
|
||||
FLOATING = "floating"
|
||||
SIDEBAR_LEFT = "sidebar-left"
|
||||
SIDEBAR_RIGHT = "sidebar-right"
|
||||
SEARCH_BAR = "search-bar"
|
||||
|
||||
|
||||
class WidgetDisplaySide(str, enum.Enum):
|
||||
LEFT = "left"
|
||||
RIGHT = "right"
|
||||
|
||||
|
||||
class WidgetDisplayConfig(BaseModel):
|
||||
"""Dashboard "Widget placement" defaults, stored in customization_metadata.
|
||||
|
||||
The embed loader (replypier.js) merges these under any options the
|
||||
installing developer set on the page. Bounds keep saved values renderable
|
||||
on any reasonable screen; the loader clamps again client-side.
|
||||
"""
|
||||
mode: Optional[WidgetDisplayMode] = None
|
||||
side: Optional[WidgetDisplaySide] = None
|
||||
launcher: Optional[bool] = None
|
||||
width: Optional[int] = Field(default=None, ge=280, le=800)
|
||||
height: Optional[int] = Field(default=None, ge=400, le=900)
|
||||
sidebar_width: Optional[int] = Field(default=None, ge=320, le=640)
|
||||
search_placeholder: Optional[str] = Field(default=None, max_length=80)
|
||||
offset_bottom: Optional[int] = Field(default=None, ge=0, le=200)
|
||||
offset_side: Optional[int] = Field(default=None, ge=0, le=200)
|
||||
z_index: Optional[int] = Field(default=None, ge=1, le=2147483647)
|
||||
|
||||
|
||||
# Predefined chat initiation messages
|
||||
DEFAULT_CHAT_INITIATIONS = [
|
||||
"👋 Hi! Need help? Ask me anything!",
|
||||
"💬 Have a question? I'm here to help!",
|
||||
"🤝 Welcome! How can I assist you today?",
|
||||
"✨ Got questions? Let's chat!",
|
||||
"👨💼 Need support? Click to chat with us!"
|
||||
]
|
||||
|
||||
# Predefined quick-action buttons (clicking sends the label as a message)
|
||||
DEFAULT_QUICK_ACTIONS = [
|
||||
"Track my order",
|
||||
"Start a return",
|
||||
"Talk to a human"
|
||||
]
|
||||
|
||||
|
||||
class CustomizationBase(BaseModel):
|
||||
photo_url: Optional[str] = None
|
||||
|
||||
@field_validator('photo_url')
|
||||
@classmethod
|
||||
def _strip_photo_url_signature(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Keep the bare S3 URL as the stored value.
|
||||
|
||||
CustomizationResponse serializes photo_url signed, and the agent
|
||||
customization form POSTs the value it was given straight back, so
|
||||
without this the expiring signature would be written to the column.
|
||||
"""
|
||||
return strip_s3_signature(v)
|
||||
chat_background_color: Optional[str] = "#F8F9FA"
|
||||
chat_bubble_color: Optional[str] = "#E9ECEF"
|
||||
chat_text_color: Optional[str] = "#212529"
|
||||
icon_url: Optional[str] = None
|
||||
icon_color: Optional[str] = "#6C757D"
|
||||
accent_color: Optional[str] = "#f34611"
|
||||
font_family: Optional[str] = "Inter, system-ui, sans-serif"
|
||||
custom_css: Optional[str] = None
|
||||
customization_metadata: Optional[Dict] = {}
|
||||
chat_style: Optional[ChatStyle] = ChatStyle.CHATBOT
|
||||
widget_position: Optional[WidgetPosition] = WidgetPosition.FLOATING
|
||||
# NOTE: length caps live on CustomizationCreate (input), NOT here — the response
|
||||
# model also inherits this base, and existing rows may exceed a newly-lowered cap;
|
||||
# enforcing on the response would 500 the whole agent list.
|
||||
welcome_title: Optional[str] = None
|
||||
welcome_subtitle: Optional[str] = None
|
||||
welcome_message: Optional[str] = None
|
||||
chat_initiation_messages: Optional[List[str]] = None
|
||||
quick_actions: Optional[List[str]] = None
|
||||
show_citations: Optional[bool] = False
|
||||
collect_email: Optional[bool] = False
|
||||
show_ai_disclaimer: Optional[bool] = True
|
||||
allow_new_chat: Optional[bool] = False
|
||||
|
||||
|
||||
class CustomizationCreate(CustomizationBase):
|
||||
# Enforce length caps on write only, so the UI can't submit values the widget
|
||||
# can't display. Reads (CustomizationResponse) intentionally stay unconstrained.
|
||||
welcome_title: Optional[str] = Field(default=None, max_length=100)
|
||||
welcome_subtitle: Optional[str] = Field(default=None, max_length=250)
|
||||
welcome_message: Optional[str] = Field(default=None, max_length=500)
|
||||
chat_initiation_messages: Optional[List[InitiationMessage]] = None
|
||||
|
||||
@field_validator('customization_metadata')
|
||||
@classmethod
|
||||
def _validate_widget_display(cls, v: Optional[Dict]) -> Optional[Dict]:
|
||||
"""Validate and normalize the structured widget_display key on write.
|
||||
|
||||
The rest of customization_metadata stays free-form (avatar_style etc.);
|
||||
widget_display is consumed by the embed loader on customer pages, so bad
|
||||
values must be rejected here. Unknown keys inside it are dropped and
|
||||
unset keys aren't stored. An explicit null clears the settings.
|
||||
"""
|
||||
if not v or v.get('widget_display') is None:
|
||||
return v
|
||||
validated = WidgetDisplayConfig.model_validate(v['widget_display'])
|
||||
# mode='json' turns enums into plain strings — the dict goes straight into
|
||||
# a JSON column, so it must hold JSON primitives, not enum members.
|
||||
v['widget_display'] = validated.model_dump(mode='json', exclude_none=True)
|
||||
return v
|
||||
|
||||
|
||||
class CustomizationResponse(CustomizationBase):
|
||||
id: int
|
||||
agent_id: UUID
|
||||
|
||||
@field_serializer('photo_url')
|
||||
def _sign_photo_url(self, v: Optional[str]) -> Optional[str]:
|
||||
"""Sign on the way out, every response. Signing is a local HMAC (~0.05ms),
|
||||
so there is nothing to gain by caching the result in the database."""
|
||||
return sign_s3_url(v) if v else v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
Reference in new issue
Block a user