Files
replypier/backend/app/models/schemas/help_center.py
WangShen007 9eb8613c05 feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
2026-09-23 22:05:00 +08:00

152 lines
4.8 KiB
Python

"""
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.
"""
import re
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.models.help_center import DomainStatus, SSLStatus
from app.utils.urls import normalize_url
MAX_HEADER_LINKS = 6
_HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
# RFC 1035-ish hostname: labels of letters/digits/hyphens, at least one dot.
# The TLD alternation admits punycode labels (xn--...) that IDNA encoding of
# internationalized domains produces.
_HOSTNAME_RE = re.compile(
r"^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,}|xn--[a-z0-9-]{2,})$"
)
class HeaderLink(BaseModel):
label: str = Field(min_length=1, max_length=40)
url: str = Field(min_length=1, max_length=2048)
@field_validator("url")
@classmethod
def _url(cls, v: str) -> str:
# Raises on blank/whitespace input rather than returning None into a
# non-optional str field.
return normalize_url(v)
class HelpCenterSettingsUpdate(BaseModel):
enabled: Optional[bool] = None
title: Optional[str] = Field(default=None, max_length=120)
description: Optional[str] = Field(default=None, max_length=300)
brand_color: Optional[str] = None
header_links: Optional[List[HeaderLink]] = Field(default=None, max_length=MAX_HEADER_LINKS)
cta_text: Optional[str] = Field(default=None, max_length=40)
cta_url: Optional[str] = Field(default=None, max_length=2048)
cta_enabled: Optional[bool] = None
auto_generate: Optional[bool] = None
agent_id: Optional[UUID] = None
ai_search_enabled: Optional[bool] = None
chat_widget_enabled: Optional[bool] = None
@field_validator("brand_color")
@classmethod
def _hex_color(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
v = v.strip()
if not _HEX_COLOR_RE.match(v):
raise ValueError("brand_color must be a hex color like #4338CA")
return v
@field_validator("cta_url")
@classmethod
def _cta_url(cls, v: Optional[str]) -> Optional[str]:
# Blank clears the CTA link; anything else is normalized.
if v is None or not v.strip():
return None
return normalize_url(v)
class HelpCenterAgentOption(BaseModel):
"""Org agents offered in the AI-search agent selector."""
id: UUID
name: str
has_widget: bool
class DnsRecord(BaseModel):
"""One row of the DNS-records table in the admin UI."""
type: str # CNAME | TXT
host: str
value: str
verified: bool
class DomainStatusResponse(BaseModel):
custom_domain: Optional[str] = None
domain_status: DomainStatus
ssl_status: SSLStatus
records: List[DnsRecord] = []
domain_verified_at: Optional[datetime] = None
class HelpCenterSettingsResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
enabled: bool
slug: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
logo_url: Optional[str] = None
favicon_url: Optional[str] = None
brand_color: str
header_links: List[HeaderLink] = []
cta_text: Optional[str] = None
cta_url: Optional[str] = None
cta_enabled: bool
auto_generate: bool
agent_id: Optional[UUID] = None
ai_search_enabled: bool
chat_widget_enabled: bool
# Enriched by the API layer (not ORM columns):
live_url: Optional[str] = None
published_count: int = 0
plan_allowed: bool = True
agents: List[HelpCenterAgentOption] = []
domain: Optional[DomainStatusResponse] = None
@field_validator("header_links", mode="before")
@classmethod
def _coerce_null_json(cls, v):
return v or []
class DomainRequest(BaseModel):
domain: str = Field(min_length=4, max_length=255)
@field_validator("domain")
@classmethod
def _hostname(cls, v: str) -> str:
v = v.strip().lower().rstrip(".")
v = re.sub(r"^https?://", "", v).split("/")[0]
try:
v = v.encode("idna").decode("ascii")
except UnicodeError as exc:
raise ValueError("domain is not a valid hostname") from exc
if not _HOSTNAME_RE.match(v):
raise ValueError("domain is not a valid hostname")
return v