152 lines
4.8 KiB
Python
152 lines
4.8 KiB
Python
"""
|
|
Copyright 2024-2026 ChatterMate
|
|
|
|
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
|