feat: restore source parity and harden agent runtime
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,612 @@
|
||||
"""
|
||||
Safe formula engine for smart table.
|
||||
Supports field references {FieldName}, arithmetic, comparisons, and built-in functions.
|
||||
No eval/exec - all evaluation done via AST traversal.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import date, datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ==================== Tokenizer ====================
|
||||
|
||||
class TokenType(Enum):
|
||||
NUMBER = "NUMBER"
|
||||
STRING = "STRING"
|
||||
FIELD_REF = "FIELD_REF"
|
||||
FUNCTION = "FUNCTION"
|
||||
OPERATOR = "OPERATOR"
|
||||
LPAREN = "LPAREN"
|
||||
RPAREN = "RPAREN"
|
||||
COMMA = "COMMA"
|
||||
BOOLEAN = "BOOLEAN"
|
||||
EOF = "EOF"
|
||||
|
||||
|
||||
class Token:
|
||||
__slots__ = ("type", "value")
|
||||
|
||||
def __init__(self, type_: TokenType, value: Any):
|
||||
self.type = type_
|
||||
self.value = value
|
||||
|
||||
def __repr__(self):
|
||||
return f"Token({self.type.name}, {self.value!r})"
|
||||
|
||||
|
||||
_FUNC_NAMES = {
|
||||
"IF", "AND", "OR", "NOT",
|
||||
"CONCATENATE", "CONCAT",
|
||||
"ABS", "ROUND", "CEIL", "FLOOR", "INT", "MOD", "POWER", "SQRT",
|
||||
"UPPER", "LOWER", "LEN", "LEFT", "RIGHT", "MID", "TRIM", "SUBSTITUTE",
|
||||
"NOW", "TODAY", "DATEDIFF", "DATEADD", "YEAR", "MONTH", "DAY",
|
||||
"MIN", "MAX", "SUM", "AVERAGE",
|
||||
"ISNULL", "VALUE", "TEXT", "FIXED",
|
||||
}
|
||||
|
||||
_TWO_CHAR_OPS = {"!=", ">=", "<=", "<>", "&&", "||"}
|
||||
_ONE_CHAR_OPS = {"+", "-", "*", "/", "%", "=", ">", "<", "&"}
|
||||
|
||||
|
||||
def tokenize(formula: str) -> List[Token]:
|
||||
tokens: List[Token] = []
|
||||
i = 0
|
||||
n = len(formula)
|
||||
|
||||
while i < n:
|
||||
ch = formula[i]
|
||||
|
||||
if ch in (" ", "\t", "\n", "\r"):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == "{":
|
||||
end = formula.find("}", i + 1)
|
||||
if end == -1:
|
||||
raise FormulaError(f"未闭合的字段引用 '{{' 在位置 {i}")
|
||||
tokens.append(Token(TokenType.FIELD_REF, formula[i + 1:end]))
|
||||
i = end + 1
|
||||
continue
|
||||
|
||||
if ch == '"' or ch == "'":
|
||||
quote = ch
|
||||
j = i + 1
|
||||
parts = []
|
||||
while j < n:
|
||||
if formula[j] == "\\" and j + 1 < n:
|
||||
parts.append(formula[j + 1])
|
||||
j += 2
|
||||
elif formula[j] == quote:
|
||||
break
|
||||
else:
|
||||
parts.append(formula[j])
|
||||
j += 1
|
||||
if j >= n:
|
||||
raise FormulaError(f"未闭合的字符串在位置 {i}")
|
||||
tokens.append(Token(TokenType.STRING, "".join(parts)))
|
||||
i = j + 1
|
||||
continue
|
||||
|
||||
if ch.isdigit() or (ch == "." and i + 1 < n and formula[i + 1].isdigit()):
|
||||
j = i
|
||||
has_dot = False
|
||||
while j < n and (formula[j].isdigit() or (formula[j] == "." and not has_dot)):
|
||||
if formula[j] == ".":
|
||||
has_dot = True
|
||||
j += 1
|
||||
tokens.append(Token(TokenType.NUMBER, float(formula[i:j])))
|
||||
i = j
|
||||
continue
|
||||
|
||||
if ch == "(":
|
||||
tokens.append(Token(TokenType.LPAREN, "("))
|
||||
i += 1
|
||||
continue
|
||||
if ch == ")":
|
||||
tokens.append(Token(TokenType.RPAREN, ")"))
|
||||
i += 1
|
||||
continue
|
||||
if ch == ",":
|
||||
tokens.append(Token(TokenType.COMMA, ","))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
two = formula[i:i + 2] if i + 1 < n else ""
|
||||
if two in _TWO_CHAR_OPS:
|
||||
tokens.append(Token(TokenType.OPERATOR, two))
|
||||
i += 2
|
||||
continue
|
||||
if ch in _ONE_CHAR_OPS:
|
||||
tokens.append(Token(TokenType.OPERATOR, ch))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch.isalpha() or ch == "_":
|
||||
j = i
|
||||
while j < n and (formula[j].isalnum() or formula[j] == "_"):
|
||||
j += 1
|
||||
word = formula[i:j]
|
||||
upper = word.upper()
|
||||
if upper in ("TRUE", "FALSE"):
|
||||
tokens.append(Token(TokenType.BOOLEAN, upper == "TRUE"))
|
||||
elif upper in _FUNC_NAMES:
|
||||
tokens.append(Token(TokenType.FUNCTION, upper))
|
||||
else:
|
||||
tokens.append(Token(TokenType.FIELD_REF, word))
|
||||
i = j
|
||||
continue
|
||||
|
||||
raise FormulaError(f"无法识别的字符 '{ch}' 在位置 {i}")
|
||||
|
||||
tokens.append(Token(TokenType.EOF, None))
|
||||
return tokens
|
||||
|
||||
|
||||
# ==================== AST Nodes ====================
|
||||
|
||||
class ASTNode:
|
||||
pass
|
||||
|
||||
|
||||
class NumberLiteral(ASTNode):
|
||||
__slots__ = ("value",)
|
||||
def __init__(self, value: float):
|
||||
self.value = value
|
||||
|
||||
|
||||
class StringLiteral(ASTNode):
|
||||
__slots__ = ("value",)
|
||||
def __init__(self, value: str):
|
||||
self.value = value
|
||||
|
||||
|
||||
class BooleanLiteral(ASTNode):
|
||||
__slots__ = ("value",)
|
||||
def __init__(self, value: bool):
|
||||
self.value = value
|
||||
|
||||
|
||||
class FieldReference(ASTNode):
|
||||
__slots__ = ("name",)
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
|
||||
class BinaryOp(ASTNode):
|
||||
__slots__ = ("op", "left", "right")
|
||||
def __init__(self, op: str, left: ASTNode, right: ASTNode):
|
||||
self.op = op
|
||||
self.left = left
|
||||
self.right = right
|
||||
|
||||
|
||||
class UnaryOp(ASTNode):
|
||||
__slots__ = ("op", "operand")
|
||||
def __init__(self, op: str, operand: ASTNode):
|
||||
self.op = op
|
||||
self.operand = operand
|
||||
|
||||
|
||||
class FunctionCall(ASTNode):
|
||||
__slots__ = ("name", "args")
|
||||
def __init__(self, name: str, args: List[ASTNode]):
|
||||
self.name = name
|
||||
self.args = args
|
||||
|
||||
|
||||
# ==================== Parser ====================
|
||||
|
||||
class FormulaError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Parser:
|
||||
def __init__(self, tokens: List[Token]):
|
||||
self.tokens = tokens
|
||||
self.pos = 0
|
||||
|
||||
def _current(self) -> Token:
|
||||
return self.tokens[self.pos]
|
||||
|
||||
def _eat(self, expected_type: Optional[TokenType] = None) -> Token:
|
||||
tok = self._current()
|
||||
if expected_type and tok.type != expected_type:
|
||||
raise FormulaError(f"期望 {expected_type.name},实际 {tok.type.name}({tok.value!r})")
|
||||
self.pos += 1
|
||||
return tok
|
||||
|
||||
def parse(self) -> ASTNode:
|
||||
node = self._expr()
|
||||
if self._current().type != TokenType.EOF:
|
||||
raise FormulaError(f"意外的 token: {self._current()}")
|
||||
return node
|
||||
|
||||
def _expr(self) -> ASTNode:
|
||||
return self._logic_or()
|
||||
|
||||
def _logic_or(self) -> ASTNode:
|
||||
node = self._logic_and()
|
||||
while self._current().type == TokenType.OPERATOR and self._current().value in ("||",):
|
||||
op = self._eat().value
|
||||
right = self._logic_and()
|
||||
node = BinaryOp(op, node, right)
|
||||
return node
|
||||
|
||||
def _logic_and(self) -> ASTNode:
|
||||
node = self._comparison()
|
||||
while self._current().type == TokenType.OPERATOR and self._current().value in ("&&",):
|
||||
op = self._eat().value
|
||||
right = self._comparison()
|
||||
node = BinaryOp(op, node, right)
|
||||
return node
|
||||
|
||||
def _comparison(self) -> ASTNode:
|
||||
node = self._concat()
|
||||
while self._current().type == TokenType.OPERATOR and self._current().value in ("=", "!=", "<>", ">", "<", ">=", "<="):
|
||||
op = self._eat().value
|
||||
right = self._concat()
|
||||
node = BinaryOp(op, node, right)
|
||||
return node
|
||||
|
||||
def _concat(self) -> ASTNode:
|
||||
node = self._addition()
|
||||
while self._current().type == TokenType.OPERATOR and self._current().value == "&":
|
||||
self._eat()
|
||||
right = self._addition()
|
||||
node = BinaryOp("&", node, right)
|
||||
return node
|
||||
|
||||
def _addition(self) -> ASTNode:
|
||||
node = self._multiplication()
|
||||
while self._current().type == TokenType.OPERATOR and self._current().value in ("+", "-"):
|
||||
op = self._eat().value
|
||||
right = self._multiplication()
|
||||
node = BinaryOp(op, node, right)
|
||||
return node
|
||||
|
||||
def _multiplication(self) -> ASTNode:
|
||||
node = self._unary()
|
||||
while self._current().type == TokenType.OPERATOR and self._current().value in ("*", "/", "%"):
|
||||
op = self._eat().value
|
||||
right = self._unary()
|
||||
node = BinaryOp(op, node, right)
|
||||
return node
|
||||
|
||||
def _unary(self) -> ASTNode:
|
||||
if self._current().type == TokenType.OPERATOR and self._current().value == "-":
|
||||
self._eat()
|
||||
operand = self._unary()
|
||||
return UnaryOp("-", operand)
|
||||
return self._primary()
|
||||
|
||||
def _primary(self) -> ASTNode:
|
||||
tok = self._current()
|
||||
|
||||
if tok.type == TokenType.NUMBER:
|
||||
self._eat()
|
||||
return NumberLiteral(tok.value)
|
||||
|
||||
if tok.type == TokenType.STRING:
|
||||
self._eat()
|
||||
return StringLiteral(tok.value)
|
||||
|
||||
if tok.type == TokenType.BOOLEAN:
|
||||
self._eat()
|
||||
return BooleanLiteral(tok.value)
|
||||
|
||||
if tok.type == TokenType.FIELD_REF:
|
||||
self._eat()
|
||||
return FieldReference(tok.value)
|
||||
|
||||
if tok.type == TokenType.FUNCTION:
|
||||
return self._function_call()
|
||||
|
||||
if tok.type == TokenType.LPAREN:
|
||||
self._eat()
|
||||
node = self._expr()
|
||||
self._eat(TokenType.RPAREN)
|
||||
return node
|
||||
|
||||
raise FormulaError(f"意外的 token: {tok}")
|
||||
|
||||
def _function_call(self) -> ASTNode:
|
||||
name = self._eat(TokenType.FUNCTION).value
|
||||
self._eat(TokenType.LPAREN)
|
||||
args: List[ASTNode] = []
|
||||
if self._current().type != TokenType.RPAREN:
|
||||
args.append(self._expr())
|
||||
while self._current().type == TokenType.COMMA:
|
||||
self._eat()
|
||||
args.append(self._expr())
|
||||
self._eat(TokenType.RPAREN)
|
||||
return FunctionCall(name, args)
|
||||
|
||||
|
||||
# ==================== Evaluator ====================
|
||||
|
||||
def _to_number(v: Any) -> float:
|
||||
if v is None or v == "":
|
||||
return 0.0
|
||||
try:
|
||||
return float(v)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _to_string(v: Any) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, bool):
|
||||
return "TRUE" if v else "FALSE"
|
||||
if isinstance(v, float) and v == int(v):
|
||||
return str(int(v))
|
||||
return str(v)
|
||||
|
||||
|
||||
def _to_bool(v: Any) -> bool:
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
if isinstance(v, (int, float)):
|
||||
return v != 0
|
||||
if isinstance(v, str):
|
||||
return v.upper() not in ("", "FALSE", "0")
|
||||
return bool(v)
|
||||
|
||||
|
||||
def _parse_date(v: Any) -> Optional[datetime]:
|
||||
if isinstance(v, datetime):
|
||||
return v
|
||||
if isinstance(v, date):
|
||||
return datetime.combine(v, datetime.min.time())
|
||||
if isinstance(v, str):
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%Y/%m/%d"):
|
||||
try:
|
||||
return datetime.strptime(v.strip()[:19], fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _evaluate_function(name: str, args: List[Any]) -> Any:
|
||||
n = len(args)
|
||||
|
||||
if name == "IF":
|
||||
if n < 2:
|
||||
raise FormulaError("IF 需要至少 2 个参数")
|
||||
cond = _to_bool(args[0])
|
||||
return args[1] if cond else (args[2] if n > 2 else "")
|
||||
|
||||
if name == "AND":
|
||||
return all(_to_bool(a) for a in args)
|
||||
if name == "OR":
|
||||
return any(_to_bool(a) for a in args)
|
||||
if name == "NOT":
|
||||
return not _to_bool(args[0]) if n > 0 else True
|
||||
|
||||
if name in ("CONCATENATE", "CONCAT"):
|
||||
return "".join(_to_string(a) for a in args)
|
||||
|
||||
if name == "ABS":
|
||||
return abs(_to_number(args[0])) if n > 0 else 0
|
||||
if name == "ROUND":
|
||||
digits = int(_to_number(args[1])) if n > 1 else 0
|
||||
return round(_to_number(args[0]), digits) if n > 0 else 0
|
||||
if name == "CEIL":
|
||||
return math.ceil(_to_number(args[0])) if n > 0 else 0
|
||||
if name == "FLOOR":
|
||||
return math.floor(_to_number(args[0])) if n > 0 else 0
|
||||
if name == "INT":
|
||||
return int(_to_number(args[0])) if n > 0 else 0
|
||||
if name == "MOD":
|
||||
if n < 2:
|
||||
return 0
|
||||
divisor = _to_number(args[1])
|
||||
return _to_number(args[0]) % divisor if divisor != 0 else 0
|
||||
if name == "POWER":
|
||||
return _to_number(args[0]) ** _to_number(args[1]) if n >= 2 else 0
|
||||
if name == "SQRT":
|
||||
val = _to_number(args[0]) if n > 0 else 0
|
||||
return math.sqrt(val) if val >= 0 else None
|
||||
|
||||
if name == "UPPER":
|
||||
return _to_string(args[0]).upper() if n > 0 else ""
|
||||
if name == "LOWER":
|
||||
return _to_string(args[0]).lower() if n > 0 else ""
|
||||
if name == "LEN":
|
||||
return len(_to_string(args[0])) if n > 0 else 0
|
||||
if name == "LEFT":
|
||||
s = _to_string(args[0]) if n > 0 else ""
|
||||
count = int(_to_number(args[1])) if n > 1 else 1
|
||||
return s[:count]
|
||||
if name == "RIGHT":
|
||||
s = _to_string(args[0]) if n > 0 else ""
|
||||
count = int(_to_number(args[1])) if n > 1 else 1
|
||||
return s[-count:] if count > 0 else ""
|
||||
if name == "MID":
|
||||
s = _to_string(args[0]) if n > 0 else ""
|
||||
start = max(1, int(_to_number(args[1]))) if n > 1 else 1
|
||||
length = int(_to_number(args[2])) if n > 2 else 1
|
||||
return s[start - 1:start - 1 + length]
|
||||
if name == "TRIM":
|
||||
return _to_string(args[0]).strip() if n > 0 else ""
|
||||
if name == "SUBSTITUTE":
|
||||
if n < 3:
|
||||
return _to_string(args[0]) if n > 0 else ""
|
||||
s = _to_string(args[0])
|
||||
old = _to_string(args[1])
|
||||
new = _to_string(args[2])
|
||||
return s.replace(old, new)
|
||||
|
||||
if name == "NOW":
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if name == "TODAY":
|
||||
return date.today().isoformat()
|
||||
if name == "YEAR":
|
||||
d = _parse_date(args[0]) if n > 0 else None
|
||||
return d.year if d else None
|
||||
if name == "MONTH":
|
||||
d = _parse_date(args[0]) if n > 0 else None
|
||||
return d.month if d else None
|
||||
if name == "DAY":
|
||||
d = _parse_date(args[0]) if n > 0 else None
|
||||
return d.day if d else None
|
||||
if name == "DATEDIFF":
|
||||
if n < 2:
|
||||
return None
|
||||
d1 = _parse_date(args[0])
|
||||
d2 = _parse_date(args[1])
|
||||
if d1 and d2:
|
||||
unit = _to_string(args[2]).upper() if n > 2 else "DAYS"
|
||||
diff = d2 - d1
|
||||
if unit in ("DAYS", "D"):
|
||||
return diff.days
|
||||
if unit in ("HOURS", "H"):
|
||||
return diff.total_seconds() / 3600
|
||||
if unit in ("MONTHS", "M"):
|
||||
return (d2.year - d1.year) * 12 + (d2.month - d1.month)
|
||||
if unit in ("YEARS", "Y"):
|
||||
return d2.year - d1.year
|
||||
return diff.days
|
||||
return None
|
||||
if name == "DATEADD":
|
||||
if n < 2:
|
||||
return None
|
||||
d = _parse_date(args[0])
|
||||
amount = int(_to_number(args[1]))
|
||||
unit = _to_string(args[2]).upper() if n > 2 else "DAYS"
|
||||
if d:
|
||||
if unit in ("DAYS", "D"):
|
||||
return (d + timedelta(days=amount)).strftime("%Y-%m-%d")
|
||||
if unit in ("HOURS", "H"):
|
||||
return (d + timedelta(hours=amount)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
if unit in ("MONTHS", "M"):
|
||||
month = d.month + amount
|
||||
year = d.year + (month - 1) // 12
|
||||
month = (month - 1) % 12 + 1
|
||||
day = min(d.day, 28)
|
||||
return date(year, month, day).isoformat()
|
||||
return None
|
||||
|
||||
if name == "MIN":
|
||||
nums = [_to_number(a) for a in args if a is not None and a != ""]
|
||||
return min(nums) if nums else None
|
||||
if name == "MAX":
|
||||
nums = [_to_number(a) for a in args if a is not None and a != ""]
|
||||
return max(nums) if nums else None
|
||||
if name == "SUM":
|
||||
return sum(_to_number(a) for a in args if a is not None and a != "")
|
||||
if name == "AVERAGE":
|
||||
nums = [_to_number(a) for a in args if a is not None and a != ""]
|
||||
return sum(nums) / len(nums) if nums else None
|
||||
|
||||
if name == "ISNULL":
|
||||
return args[0] is None or args[0] == "" if n > 0 else True
|
||||
if name == "VALUE":
|
||||
return _to_number(args[0]) if n > 0 else 0
|
||||
if name == "TEXT":
|
||||
return _to_string(args[0]) if n > 0 else ""
|
||||
if name == "FIXED":
|
||||
val = _to_number(args[0]) if n > 0 else 0
|
||||
digits = int(_to_number(args[1])) if n > 1 else 2
|
||||
return f"{val:.{digits}f}"
|
||||
|
||||
raise FormulaError(f"未知函数: {name}")
|
||||
|
||||
|
||||
def evaluate(node: ASTNode, context: Dict[str, Any], field_name_map: Dict[str, str]) -> Any:
|
||||
if isinstance(node, NumberLiteral):
|
||||
return node.value
|
||||
|
||||
if isinstance(node, StringLiteral):
|
||||
return node.value
|
||||
|
||||
if isinstance(node, BooleanLiteral):
|
||||
return node.value
|
||||
|
||||
if isinstance(node, FieldReference):
|
||||
field_id = field_name_map.get(node.name)
|
||||
if field_id is None:
|
||||
field_id = node.name
|
||||
return context.get(field_id)
|
||||
|
||||
if isinstance(node, UnaryOp):
|
||||
val = evaluate(node.operand, context, field_name_map)
|
||||
if node.op == "-":
|
||||
return -_to_number(val)
|
||||
return val
|
||||
|
||||
if isinstance(node, BinaryOp):
|
||||
left = evaluate(node.left, context, field_name_map)
|
||||
right = evaluate(node.right, context, field_name_map)
|
||||
op = node.op
|
||||
|
||||
if op == "+":
|
||||
return _to_number(left) + _to_number(right)
|
||||
if op == "-":
|
||||
return _to_number(left) - _to_number(right)
|
||||
if op == "*":
|
||||
return _to_number(left) * _to_number(right)
|
||||
if op == "/":
|
||||
r = _to_number(right)
|
||||
return _to_number(left) / r if r != 0 else None
|
||||
if op == "%":
|
||||
r = _to_number(right)
|
||||
return _to_number(left) % r if r != 0 else None
|
||||
if op == "&":
|
||||
return _to_string(left) + _to_string(right)
|
||||
if op in ("=", "=="):
|
||||
return left == right
|
||||
if op in ("!=", "<>"):
|
||||
return left != right
|
||||
if op == ">":
|
||||
return _to_number(left) > _to_number(right)
|
||||
if op == "<":
|
||||
return _to_number(left) < _to_number(right)
|
||||
if op == ">=":
|
||||
return _to_number(left) >= _to_number(right)
|
||||
if op == "<=":
|
||||
return _to_number(left) <= _to_number(right)
|
||||
if op == "&&":
|
||||
return _to_bool(left) and _to_bool(right)
|
||||
if op == "||":
|
||||
return _to_bool(left) or _to_bool(right)
|
||||
|
||||
raise FormulaError(f"未知运算符: {op}")
|
||||
|
||||
if isinstance(node, FunctionCall):
|
||||
evaluated_args = [evaluate(a, context, field_name_map) for a in node.args]
|
||||
return _evaluate_function(node.name, evaluated_args)
|
||||
|
||||
raise FormulaError(f"未知 AST 节点: {type(node)}")
|
||||
|
||||
|
||||
# ==================== Public API ====================
|
||||
|
||||
def compute_formula(
|
||||
formula: str,
|
||||
record_values: Dict[str, Any],
|
||||
field_name_map: Dict[str, str],
|
||||
) -> Any:
|
||||
"""
|
||||
计算公式。
|
||||
formula: 公式字符串,如 "IF({状态}=\"完成\", {金额} * 1.1, {金额})"
|
||||
record_values: {fieldId: value}
|
||||
field_name_map: {fieldName: fieldId}
|
||||
返回计算结果;出错时返回 '#ERROR'
|
||||
"""
|
||||
if not formula or not formula.strip():
|
||||
return ""
|
||||
try:
|
||||
tokens = tokenize(formula)
|
||||
parser = Parser(tokens)
|
||||
ast = parser.parse()
|
||||
result = evaluate(ast, record_values, field_name_map)
|
||||
if isinstance(result, float):
|
||||
if result == int(result) and abs(result) < 1e15:
|
||||
return int(result)
|
||||
return round(result, 10)
|
||||
return result
|
||||
except Exception:
|
||||
return "#ERROR"
|
||||
@@ -0,0 +1,138 @@
|
||||
from sqlalchemy import Column, String, Text, Boolean, Integer, JSON, Index
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
SmartJSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class SmartTable(BaseModel):
|
||||
"""多维表格 / 文档"""
|
||||
__tablename__ = "smart_table"
|
||||
|
||||
name = Column(String(200), nullable=False, comment="表名")
|
||||
icon = Column(String(50), default="Grid", comment="图标")
|
||||
description = Column(Text, nullable=True, comment="描述")
|
||||
active_view_id = Column(String(21), nullable=True, comment="当前激活视图ID")
|
||||
type = Column(String(20), default="table", nullable=False, comment="类型: table / document")
|
||||
content = Column(SmartJSON, nullable=True, comment="文档内容(Tiptap JSON), 仅 type=document 时使用")
|
||||
parent_id = Column(String(21), nullable=True, index=True, comment="父页面ID(自引用,用于子页面层级嵌套)")
|
||||
wiki_space_id = Column(String(21), nullable=True, index=True, comment="所属文档库ID")
|
||||
|
||||
|
||||
class SmartField(BaseModel):
|
||||
"""多维表格字段"""
|
||||
__tablename__ = "smart_field"
|
||||
|
||||
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
|
||||
name = Column(String(200), nullable=False, comment="字段名")
|
||||
type = Column(String(30), nullable=False, comment="字段类型")
|
||||
width = Column(Integer, default=150, comment="列宽")
|
||||
visible = Column(Boolean, default=True, comment="是否可见")
|
||||
required = Column(Boolean, default=False, comment="是否必填")
|
||||
description = Column(Text, nullable=True, comment="描述")
|
||||
config = Column(SmartJSON, default=dict, comment="扩展配置(options/format/precision等)")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_smart_field_table_sort", "table_id", "sort"),
|
||||
)
|
||||
|
||||
|
||||
class SmartRecord(BaseModel):
|
||||
"""多维表格记录"""
|
||||
__tablename__ = "smart_record"
|
||||
|
||||
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
|
||||
values = Column(SmartJSON, default=dict, comment="字段值映射 {fieldId: cellValue}")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_smart_record_table_sort", "table_id", "sort"),
|
||||
)
|
||||
|
||||
|
||||
class SmartTableLink(BaseModel):
|
||||
"""记录关联关系(多对多)"""
|
||||
__tablename__ = "smart_table_link"
|
||||
|
||||
field_id = Column(String(21), nullable=False, index=True, comment="Link字段ID")
|
||||
source_record_id = Column(String(21), nullable=False, index=True, comment="源记录ID")
|
||||
target_record_id = Column(String(21), nullable=False, index=True, comment="目标记录ID")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_link_field_source", "field_id", "source_record_id"),
|
||||
Index("ix_link_field_target", "field_id", "target_record_id"),
|
||||
Index("uq_link_pair", "field_id", "source_record_id", "target_record_id", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class SmartView(BaseModel):
|
||||
"""多维表格视图"""
|
||||
__tablename__ = "smart_view"
|
||||
|
||||
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
|
||||
name = Column(String(200), nullable=False, comment="视图名")
|
||||
type = Column(String(30), default="grid", comment="视图类型")
|
||||
config = Column(SmartJSON, default=dict, comment="视图配置(filters/sorts/groups/visibleFieldIds等)")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_smart_view_table_sort", "table_id", "sort"),
|
||||
)
|
||||
|
||||
|
||||
class SmartTableComment(BaseModel):
|
||||
"""记录评论"""
|
||||
__tablename__ = "smart_table_comment"
|
||||
|
||||
record_id = Column(String(21), nullable=False, index=True, comment="所属记录ID")
|
||||
user_id = Column(String(21), nullable=False, index=True, comment="评论者用户ID")
|
||||
content = Column(Text, nullable=False, comment="评论内容(纯文本,含@提及标记)")
|
||||
mentions = Column(SmartJSON, default=list, comment="被@提及的用户ID列表")
|
||||
parent_id = Column(String(21), nullable=True, index=True, comment="父评论ID(用于回复)")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_comment_record_created", "record_id", "sys_create_datetime"),
|
||||
)
|
||||
|
||||
|
||||
class SmartDocumentVersion(BaseModel):
|
||||
"""文档版本历史"""
|
||||
__tablename__ = "smart_document_version"
|
||||
|
||||
document_id = Column(String(21), nullable=False, index=True, comment="文档ID(逻辑外键关联smart_table)")
|
||||
version = Column(Integer, nullable=False, comment="版本号")
|
||||
content = Column(SmartJSON, nullable=False, comment="文档内容快照(Tiptap JSON)")
|
||||
title = Column(String(200), nullable=True, comment="版本标题/文档名称快照")
|
||||
change_summary = Column(String(500), nullable=True, comment="变更摘要")
|
||||
content_size = Column(Integer, default=0, comment="内容大小(字节)")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_doc_version_doc_ver", "document_id", "version"),
|
||||
Index("ix_doc_version_doc_created", "document_id", "sys_create_datetime"),
|
||||
)
|
||||
|
||||
|
||||
class SmartDocumentTemplate(BaseModel):
|
||||
"""文档模板"""
|
||||
__tablename__ = "smart_document_template"
|
||||
|
||||
name = Column(String(200), nullable=False, comment="模板名称")
|
||||
description = Column(Text, nullable=True, comment="模板描述")
|
||||
icon = Column(String(50), default="FileText", comment="模板图标")
|
||||
category = Column(String(50), default="custom", comment="分类: system / custom")
|
||||
content = Column(SmartJSON, nullable=False, comment="模板内容(Tiptap JSON)")
|
||||
preview_image = Column(String(500), nullable=True, comment="预览图URL")
|
||||
is_system = Column(Boolean, default=False, comment="是否系统预设模板")
|
||||
use_count = Column(Integer, default=0, comment="使用次数")
|
||||
|
||||
|
||||
class SmartWikiSpace(BaseModel):
|
||||
"""文档库/知识空间"""
|
||||
__tablename__ = "smart_wiki_space"
|
||||
|
||||
name = Column(String(200), nullable=False, comment="文档库名称")
|
||||
icon = Column(String(50), default="BookOpen", comment="图标")
|
||||
avatar = Column(String(500), nullable=True, comment="头像文件ID")
|
||||
description = Column(Text, nullable=True, comment="描述")
|
||||
cover = Column(String(500), nullable=True, comment="封面图URL")
|
||||
category = Column(String(50), default="default", comment="分类标签")
|
||||
visibility = Column(String(20), default="private", comment="可见性: private/team/public")
|
||||
@@ -0,0 +1,299 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.base_schema import ResponseModel
|
||||
from zq_smart_table.permission.schema import (
|
||||
SmartTableRoleCreate, SmartTableRoleUpdate, SmartTableRoleResponse,
|
||||
CollaboratorCreate, CollaboratorUpdate, CollaboratorResponse,
|
||||
FieldPermBatchUpdate, FieldPermMatrix, FieldPermItem,
|
||||
RowRuleUpdate, RowRuleResponse,
|
||||
MyPermissionResponse,
|
||||
)
|
||||
from zq_smart_table.permission.service import PermissionService
|
||||
from zq_smart_table.permission.model import SmartTableRole
|
||||
|
||||
router = APIRouter(tags=["多维表格-权限"])
|
||||
|
||||
|
||||
# ==================== My Permission ====================
|
||||
|
||||
@router.get(
|
||||
"/tables/{table_id}/my-permission",
|
||||
response_model=MyPermissionResponse,
|
||||
summary="获取当前用户对该表的有效权限",
|
||||
)
|
||||
async def get_my_permission(table_id: str, db: AsyncSession = Depends(get_db)):
|
||||
perm = await PermissionService.get_my_permission(db, table_id)
|
||||
return MyPermissionResponse(**perm)
|
||||
|
||||
|
||||
# ==================== Role ====================
|
||||
|
||||
@router.get(
|
||||
"/tables/{table_id}/roles",
|
||||
response_model=List[SmartTableRoleResponse],
|
||||
summary="获取表角色列表",
|
||||
)
|
||||
async def get_roles(table_id: str, db: AsyncSession = Depends(get_db)):
|
||||
await PermissionService.ensure_system_roles(db, table_id)
|
||||
roles = await PermissionService.get_roles(db, table_id)
|
||||
return roles
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tables/{table_id}/roles",
|
||||
response_model=SmartTableRoleResponse,
|
||||
summary="创建自定义角色",
|
||||
)
|
||||
async def create_role(
|
||||
table_id: str,
|
||||
data: SmartTableRoleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await PermissionService.require_capability(db, table_id, "manage_permission")
|
||||
role = await PermissionService.create_custom_role(
|
||||
db, table_id, data.name, data.capabilities
|
||||
)
|
||||
return role
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tables/{table_id}/roles/{role_id}",
|
||||
response_model=SmartTableRoleResponse,
|
||||
summary="更新角色",
|
||||
)
|
||||
async def update_role(
|
||||
table_id: str, role_id: str,
|
||||
data: SmartTableRoleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await PermissionService.require_capability(db, table_id, "manage_permission")
|
||||
role = await PermissionService.update_role(
|
||||
db, role_id, name=data.name, capabilities=data.capabilities
|
||||
)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
return role
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tables/{table_id}/roles/{role_id}",
|
||||
response_model=ResponseModel,
|
||||
summary="删除自定义角色",
|
||||
)
|
||||
async def delete_role(
|
||||
table_id: str, role_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await PermissionService.require_capability(db, table_id, "manage_permission")
|
||||
success = await PermissionService.delete_role(db, role_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="角色不存在或为系统预置角色")
|
||||
return ResponseModel(message="删除成功")
|
||||
|
||||
|
||||
# ==================== Collaborator ====================
|
||||
|
||||
@router.get(
|
||||
"/tables/{table_id}/collaborators",
|
||||
response_model=List[CollaboratorResponse],
|
||||
summary="获取协作者列表",
|
||||
)
|
||||
async def get_collaborators(table_id: str, db: AsyncSession = Depends(get_db)):
|
||||
await PermissionService.require_table_access(db, table_id)
|
||||
collabs = await PermissionService.get_collaborators(db, table_id)
|
||||
|
||||
role_cache: dict = {}
|
||||
results = []
|
||||
for c in collabs:
|
||||
if c.role_id not in role_cache:
|
||||
from sqlalchemy import select
|
||||
r = await db.execute(
|
||||
select(SmartTableRole).where(SmartTableRole.id == c.role_id)
|
||||
)
|
||||
role_cache[c.role_id] = r.scalar_one_or_none()
|
||||
|
||||
role = role_cache.get(c.role_id)
|
||||
subject_name = None
|
||||
subject_avatar = None
|
||||
|
||||
if c.subject_type == "user":
|
||||
from core.user.model import User
|
||||
from sqlalchemy import select as sel
|
||||
u = await db.execute(sel(User).where(User.id == c.subject_id))
|
||||
user = u.scalar_one_or_none()
|
||||
if user:
|
||||
subject_name = user.name or user.username
|
||||
subject_avatar = user.avatar
|
||||
elif c.subject_type == "dept":
|
||||
from core.dept.model import Dept
|
||||
from sqlalchemy import select as sel2
|
||||
d = await db.execute(sel2(Dept).where(Dept.id == c.subject_id))
|
||||
dept = d.scalar_one_or_none()
|
||||
if dept:
|
||||
subject_name = dept.name
|
||||
|
||||
results.append(CollaboratorResponse(
|
||||
id=c.id,
|
||||
table_id=c.table_id,
|
||||
subject_type=c.subject_type,
|
||||
subject_id=c.subject_id,
|
||||
role_id=c.role_id,
|
||||
role_name=role.name if role else None,
|
||||
role_type=role.role_type if role else None,
|
||||
subject_name=subject_name,
|
||||
subject_avatar=subject_avatar,
|
||||
sys_create_datetime=c.sys_create_datetime,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tables/{table_id}/collaborators",
|
||||
response_model=CollaboratorResponse,
|
||||
summary="添加协作者",
|
||||
)
|
||||
async def add_collaborator(
|
||||
table_id: str,
|
||||
data: CollaboratorCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await PermissionService.require_capability(db, table_id, "manage_permission")
|
||||
collab = await PermissionService.add_collaborator(
|
||||
db, table_id, data.subject_type, data.subject_id, data.role_id,
|
||||
)
|
||||
return CollaboratorResponse(
|
||||
id=collab.id,
|
||||
table_id=collab.table_id,
|
||||
subject_type=collab.subject_type,
|
||||
subject_id=collab.subject_id,
|
||||
role_id=collab.role_id,
|
||||
sys_create_datetime=collab.sys_create_datetime,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tables/{table_id}/collaborators/{collab_id}",
|
||||
response_model=CollaboratorResponse,
|
||||
summary="更新协作者角色",
|
||||
)
|
||||
async def update_collaborator(
|
||||
table_id: str, collab_id: str,
|
||||
data: CollaboratorUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await PermissionService.require_capability(db, table_id, "manage_permission")
|
||||
collab = await PermissionService.update_collaborator_role(db, collab_id, data.role_id)
|
||||
if not collab:
|
||||
raise HTTPException(status_code=404, detail="协作者不存在")
|
||||
return CollaboratorResponse(
|
||||
id=collab.id,
|
||||
table_id=collab.table_id,
|
||||
subject_type=collab.subject_type,
|
||||
subject_id=collab.subject_id,
|
||||
role_id=collab.role_id,
|
||||
sys_create_datetime=collab.sys_create_datetime,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tables/{table_id}/collaborators/{collab_id}",
|
||||
response_model=ResponseModel,
|
||||
summary="移除协作者",
|
||||
)
|
||||
async def remove_collaborator(
|
||||
table_id: str, collab_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await PermissionService.require_capability(db, table_id, "manage_permission")
|
||||
success = await PermissionService.remove_collaborator(db, collab_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="协作者不存在")
|
||||
return ResponseModel(message="移除成功")
|
||||
|
||||
|
||||
# ==================== Field Permission ====================
|
||||
|
||||
@router.get(
|
||||
"/tables/{table_id}/field-permissions",
|
||||
response_model=List[FieldPermMatrix],
|
||||
summary="获取列权限矩阵",
|
||||
)
|
||||
async def get_field_permissions(table_id: str, db: AsyncSession = Depends(get_db)):
|
||||
await PermissionService.require_capability(db, table_id, "manage_permission")
|
||||
await PermissionService.require_multidimensional_table(db, table_id)
|
||||
roles = await PermissionService.get_roles(db, table_id)
|
||||
|
||||
result = []
|
||||
for role in roles:
|
||||
perms = await PermissionService.get_field_permissions(db, table_id, role.id)
|
||||
fields = [FieldPermItem(field_id=fid, access=acc) for fid, acc in perms.items()]
|
||||
result.append(FieldPermMatrix(
|
||||
role_id=role.id,
|
||||
role_name=role.name,
|
||||
role_type=role.role_type,
|
||||
fields=fields,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tables/{table_id}/field-permissions",
|
||||
response_model=ResponseModel,
|
||||
summary="批量更新列权限",
|
||||
)
|
||||
async def update_field_permissions(
|
||||
table_id: str,
|
||||
data: FieldPermBatchUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await PermissionService.require_capability(db, table_id, "manage_permission")
|
||||
await PermissionService.require_multidimensional_table(db, table_id)
|
||||
permissions = [{"field_id": p.field_id, "access": p.access} for p in data.permissions]
|
||||
await PermissionService.batch_set_field_permissions(db, table_id, data.role_id, permissions)
|
||||
return ResponseModel(message="更新成功")
|
||||
|
||||
|
||||
# ==================== Row Rule ====================
|
||||
|
||||
@router.get(
|
||||
"/tables/{table_id}/row-rules",
|
||||
response_model=List[RowRuleResponse],
|
||||
summary="获取行权限规则",
|
||||
)
|
||||
async def get_row_rules(table_id: str, db: AsyncSession = Depends(get_db)):
|
||||
await PermissionService.require_capability(db, table_id, "manage_permission")
|
||||
await PermissionService.require_multidimensional_table(db, table_id)
|
||||
|
||||
from sqlalchemy import select
|
||||
from zq_smart_table.permission.model import SmartTableRowRule
|
||||
result = await db.execute(
|
||||
select(SmartTableRowRule).where(
|
||||
SmartTableRowRule.table_id == table_id,
|
||||
SmartTableRowRule.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tables/{table_id}/row-rules",
|
||||
response_model=RowRuleResponse,
|
||||
summary="更新行权限规则",
|
||||
)
|
||||
async def update_row_rule(
|
||||
table_id: str,
|
||||
data: RowRuleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await PermissionService.require_capability(db, table_id, "manage_permission")
|
||||
await PermissionService.require_multidimensional_table(db, table_id)
|
||||
rule = await PermissionService.upsert_row_rule(
|
||||
db, table_id, data.role_id, data.rule_type, data.mode, data.conditions,
|
||||
)
|
||||
return rule
|
||||
@@ -0,0 +1,115 @@
|
||||
from sqlalchemy import Column, String, Boolean, Index
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import JSON
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
PermJSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
SYSTEM_CAPABILITIES = {
|
||||
"owner": {
|
||||
"manage_table": True,
|
||||
"manage_permission": True,
|
||||
"manage_field": True,
|
||||
"manage_view": True,
|
||||
"add_record": True,
|
||||
"edit_record": True,
|
||||
"delete_record": True,
|
||||
"export_data": True,
|
||||
"import_data": True,
|
||||
},
|
||||
"manager": {
|
||||
"manage_table": False,
|
||||
"manage_permission": True,
|
||||
"manage_field": True,
|
||||
"manage_view": True,
|
||||
"add_record": True,
|
||||
"edit_record": True,
|
||||
"delete_record": True,
|
||||
"export_data": True,
|
||||
"import_data": True,
|
||||
},
|
||||
"editor": {
|
||||
"manage_table": False,
|
||||
"manage_permission": False,
|
||||
"manage_field": False,
|
||||
"manage_view": False,
|
||||
"add_record": True,
|
||||
"edit_record": True,
|
||||
"delete_record": False,
|
||||
"export_data": True,
|
||||
"import_data": False,
|
||||
},
|
||||
"viewer": {
|
||||
"manage_table": False,
|
||||
"manage_permission": False,
|
||||
"manage_field": False,
|
||||
"manage_view": False,
|
||||
"add_record": False,
|
||||
"edit_record": False,
|
||||
"delete_record": False,
|
||||
"export_data": False,
|
||||
"import_data": False,
|
||||
},
|
||||
}
|
||||
|
||||
ROLE_PRIORITY = {"owner": 100, "manager": 80, "editor": 60, "viewer": 40, "custom": 50}
|
||||
|
||||
|
||||
class SmartTableRole(BaseModel):
|
||||
"""多维表格 - 表角色定义"""
|
||||
__tablename__ = "smart_table_role"
|
||||
|
||||
table_id = Column(String(21), nullable=True, index=True, comment="所属表ID(null=系统预置)")
|
||||
name = Column(String(64), nullable=False, comment="角色名称")
|
||||
role_type = Column(String(20), nullable=False, default="custom", comment="owner/manager/editor/viewer/custom")
|
||||
capabilities = Column(PermJSON, default=dict, comment="能力配置JSON")
|
||||
is_system = Column(Boolean, default=False, comment="是否系统预置(不可删除)")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_smart_table_role_table", "table_id", "role_type"),
|
||||
)
|
||||
|
||||
|
||||
class SmartTableCollaborator(BaseModel):
|
||||
"""多维表格 - 表协作者"""
|
||||
__tablename__ = "smart_table_collaborator"
|
||||
|
||||
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
|
||||
subject_type = Column(String(20), nullable=False, comment="授权对象类型: user/dept/role")
|
||||
subject_id = Column(String(21), nullable=False, index=True, comment="授权对象ID(用户/部门/角色)")
|
||||
role_id = Column(String(21), nullable=False, index=True, comment="表角色ID")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_smart_collab_table_subject", "table_id", "subject_type", "subject_id", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class SmartTableFieldPerm(BaseModel):
|
||||
"""多维表格 - 列权限"""
|
||||
__tablename__ = "smart_table_field_perm"
|
||||
|
||||
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
|
||||
role_id = Column(String(21), nullable=False, index=True, comment="表角色ID")
|
||||
field_id = Column(String(21), nullable=False, index=True, comment="字段ID")
|
||||
access = Column(String(10), default="write", comment="访问级别: write/read/hidden")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_smart_fperm_role_field", "table_id", "role_id", "field_id", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class SmartTableRowRule(BaseModel):
|
||||
"""多维表格 - 行权限规则"""
|
||||
__tablename__ = "smart_table_row_rule"
|
||||
|
||||
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
|
||||
role_id = Column(String(21), nullable=False, index=True, comment="表角色ID")
|
||||
rule_type = Column(String(10), nullable=False, comment="规则类型: view/edit")
|
||||
mode = Column(String(20), default="all", comment="模式: all/conditions/creator_only")
|
||||
conditions = Column(PermJSON, default=list, comment="过滤条件(复用视图filter结构)")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_smart_row_rule_role_type", "table_id", "role_id", "rule_type", unique=True),
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
from typing import Optional, List, Any, Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.base_schema import CSTDatetime
|
||||
|
||||
|
||||
# ==================== Role ====================
|
||||
|
||||
class SmartTableRoleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=64, description="角色名称")
|
||||
role_type: str = Field(default="custom", description="角色类型")
|
||||
capabilities: Dict[str, bool] = Field(default_factory=dict, description="能力配置")
|
||||
|
||||
|
||||
class SmartTableRoleUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=64, description="角色名称")
|
||||
capabilities: Optional[Dict[str, bool]] = Field(None, description="能力配置")
|
||||
|
||||
|
||||
class SmartTableRoleResponse(BaseModel):
|
||||
id: str
|
||||
table_id: Optional[str] = None
|
||||
name: str
|
||||
role_type: str
|
||||
capabilities: Dict[str, bool] = {}
|
||||
is_system: bool = False
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ==================== Collaborator ====================
|
||||
|
||||
class CollaboratorCreate(BaseModel):
|
||||
subject_type: str = Field(..., description="授权对象类型: user/dept/role")
|
||||
subject_id: str = Field(..., description="授权对象ID")
|
||||
role_id: str = Field(..., description="表角色ID")
|
||||
|
||||
|
||||
class CollaboratorUpdate(BaseModel):
|
||||
role_id: str = Field(..., description="表角色ID")
|
||||
|
||||
|
||||
class CollaboratorResponse(BaseModel):
|
||||
id: str
|
||||
table_id: str
|
||||
subject_type: str
|
||||
subject_id: str
|
||||
role_id: str
|
||||
role_name: Optional[str] = None
|
||||
role_type: Optional[str] = None
|
||||
subject_name: Optional[str] = None
|
||||
subject_avatar: Optional[str] = None
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ==================== Field Permission ====================
|
||||
|
||||
class FieldPermItem(BaseModel):
|
||||
field_id: str = Field(..., description="字段ID")
|
||||
access: str = Field(default="write", description="访问级别: write/read/hidden")
|
||||
|
||||
|
||||
class FieldPermBatchUpdate(BaseModel):
|
||||
role_id: str = Field(..., description="表角色ID")
|
||||
permissions: List[FieldPermItem] = Field(..., description="列权限列表")
|
||||
|
||||
|
||||
class FieldPermResponse(BaseModel):
|
||||
id: str
|
||||
table_id: str
|
||||
role_id: str
|
||||
field_id: str
|
||||
access: str = "write"
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FieldPermMatrix(BaseModel):
|
||||
"""列权限矩阵:按角色分组"""
|
||||
role_id: str
|
||||
role_name: str
|
||||
role_type: str
|
||||
fields: List[FieldPermItem] = []
|
||||
|
||||
|
||||
# ==================== Row Rule ====================
|
||||
|
||||
class RowRuleUpdate(BaseModel):
|
||||
role_id: str = Field(..., description="表角色ID")
|
||||
rule_type: str = Field(..., description="规则类型: view/edit")
|
||||
mode: str = Field(default="all", description="模式: all/conditions/creator_only")
|
||||
conditions: List[Dict[str, Any]] = Field(default_factory=list, description="过滤条件")
|
||||
|
||||
|
||||
class RowRuleResponse(BaseModel):
|
||||
id: str
|
||||
table_id: str
|
||||
role_id: str
|
||||
rule_type: str
|
||||
mode: str = "all"
|
||||
conditions: List[Dict[str, Any]] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ==================== My Permission ====================
|
||||
|
||||
class MyPermissionResponse(BaseModel):
|
||||
"""当前用户对某表的有效权限"""
|
||||
role_type: str = Field(description="角色类型: owner/manager/editor/viewer/custom/superadmin")
|
||||
role_name: str = Field(description="角色名称")
|
||||
capabilities: Dict[str, bool] = Field(default_factory=dict, description="能力配置")
|
||||
field_permissions: Dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="列权限映射: fieldId -> write/read/hidden"
|
||||
)
|
||||
row_view_mode: str = Field(default="all", description="行查看模式")
|
||||
row_edit_mode: str = Field(default="all", description="行编辑模式")
|
||||
@@ -0,0 +1,709 @@
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
|
||||
from sqlalchemy import select, delete as sa_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from zq_smart_table.permission.model import (
|
||||
SmartTableRole, SmartTableCollaborator, SmartTableFieldPerm, SmartTableRowRule,
|
||||
SYSTEM_CAPABILITIES, ROLE_PRIORITY,
|
||||
)
|
||||
from zq_smart_table.model import SmartTable
|
||||
from utils.context import get_current_user_info_from_context
|
||||
|
||||
|
||||
SUPERADMIN_CAPABILITIES = {k: True for k in SYSTEM_CAPABILITIES["owner"]}
|
||||
|
||||
|
||||
class PermissionService:
|
||||
"""多维表格权限判定服务"""
|
||||
|
||||
# ─── 系统预置角色 ───
|
||||
|
||||
@classmethod
|
||||
async def ensure_system_roles(cls, db: AsyncSession, table_id: str) -> Dict[str, str]:
|
||||
"""确保表拥有 4 个系统预置角色,返回 {role_type: role_id}"""
|
||||
result = await db.execute(
|
||||
select(SmartTableRole).where(
|
||||
SmartTableRole.table_id == table_id,
|
||||
SmartTableRole.is_system == True, # noqa: E712
|
||||
SmartTableRole.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
existing = {r.role_type: r for r in result.scalars().all()}
|
||||
|
||||
role_map: Dict[str, str] = {}
|
||||
names = {"owner": "所有者", "manager": "管理者", "editor": "编辑者", "viewer": "只读者"}
|
||||
|
||||
for rt, caps in SYSTEM_CAPABILITIES.items():
|
||||
if rt in existing:
|
||||
role_map[rt] = existing[rt].id
|
||||
else:
|
||||
role = SmartTableRole(
|
||||
table_id=table_id,
|
||||
name=names[rt],
|
||||
role_type=rt,
|
||||
capabilities=caps,
|
||||
is_system=True,
|
||||
)
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
role_map[rt] = role.id
|
||||
|
||||
await db.commit()
|
||||
return role_map
|
||||
|
||||
# ─── 初始化所有者 ───
|
||||
|
||||
@classmethod
|
||||
async def init_table_owner(cls, db: AsyncSession, table_id: str, user_id: str) -> None:
|
||||
"""创建表时自动将创建者设为所有者"""
|
||||
role_map = await cls.ensure_system_roles(db, table_id)
|
||||
owner_role_id = role_map["owner"]
|
||||
|
||||
existing = await db.execute(
|
||||
select(SmartTableCollaborator).where(
|
||||
SmartTableCollaborator.table_id == table_id,
|
||||
SmartTableCollaborator.subject_type == "user",
|
||||
SmartTableCollaborator.subject_id == user_id,
|
||||
SmartTableCollaborator.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return
|
||||
|
||||
collab = SmartTableCollaborator(
|
||||
table_id=table_id,
|
||||
subject_type="user",
|
||||
subject_id=user_id,
|
||||
role_id=owner_role_id,
|
||||
)
|
||||
db.add(collab)
|
||||
await db.commit()
|
||||
|
||||
# ─── 获取有效角色 ───
|
||||
|
||||
@classmethod
|
||||
async def get_effective_role(
|
||||
cls, db: AsyncSession, table_id: str,
|
||||
user_id: str, dept_id: Optional[str] = None,
|
||||
role_ids: Optional[List[str]] = None,
|
||||
is_superuser: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取用户对某表的有效角色。
|
||||
优先级: superadmin > user直接 > dept > system_role
|
||||
多个匹配时取权限最高的角色。
|
||||
"""
|
||||
if is_superuser:
|
||||
return {
|
||||
"role_type": "superadmin",
|
||||
"role_name": "超级管理员",
|
||||
"capabilities": SUPERADMIN_CAPABILITIES,
|
||||
"role_id": None,
|
||||
}
|
||||
|
||||
# 查同表创建者(兜底:如果 collaborator 记录缺失但用户是表的创建者)
|
||||
table = await db.execute(
|
||||
select(SmartTable).where(SmartTable.id == table_id, SmartTable.is_deleted == False) # noqa: E712
|
||||
)
|
||||
table_obj = table.scalar_one_or_none()
|
||||
if table_obj and table_obj.sys_creator_id == user_id:
|
||||
role_map = await cls.ensure_system_roles(db, table_id)
|
||||
owner_role_id = role_map["owner"]
|
||||
return {
|
||||
"role_type": "owner",
|
||||
"role_name": "所有者",
|
||||
"capabilities": dict(SYSTEM_CAPABILITIES["owner"]),
|
||||
"role_id": owner_role_id,
|
||||
}
|
||||
|
||||
# 查询所有匹配的协作者记录
|
||||
conditions = [
|
||||
SmartTableCollaborator.table_id == table_id,
|
||||
SmartTableCollaborator.is_deleted == False, # noqa: E712
|
||||
]
|
||||
|
||||
subject_conditions = [
|
||||
(SmartTableCollaborator.subject_type == "user") & (SmartTableCollaborator.subject_id == user_id)
|
||||
]
|
||||
if dept_id:
|
||||
subject_conditions.append(
|
||||
(SmartTableCollaborator.subject_type == "dept") & (SmartTableCollaborator.subject_id == dept_id)
|
||||
)
|
||||
if role_ids:
|
||||
for rid in role_ids:
|
||||
subject_conditions.append(
|
||||
(SmartTableCollaborator.subject_type == "role") & (SmartTableCollaborator.subject_id == rid)
|
||||
)
|
||||
|
||||
from sqlalchemy import or_
|
||||
conditions.append(or_(*subject_conditions))
|
||||
|
||||
result = await db.execute(
|
||||
select(SmartTableCollaborator).where(*conditions)
|
||||
)
|
||||
collabs = list(result.scalars().all())
|
||||
|
||||
if not collabs:
|
||||
return None
|
||||
|
||||
# 加载对应角色,取优先级最高的
|
||||
best_collab = None
|
||||
best_role = None
|
||||
best_priority = -1
|
||||
|
||||
for c in collabs:
|
||||
role_result = await db.execute(
|
||||
select(SmartTableRole).where(
|
||||
SmartTableRole.id == c.role_id,
|
||||
SmartTableRole.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
role = role_result.scalar_one_or_none()
|
||||
if not role:
|
||||
continue
|
||||
|
||||
# user 直接授权优先级额外 +10
|
||||
priority = ROLE_PRIORITY.get(role.role_type, 50)
|
||||
if c.subject_type == "user":
|
||||
priority += 10
|
||||
|
||||
if priority > best_priority:
|
||||
best_priority = priority
|
||||
best_collab = c
|
||||
best_role = role
|
||||
|
||||
if not best_role:
|
||||
return None
|
||||
|
||||
return {
|
||||
"role_type": best_role.role_type,
|
||||
"role_name": best_role.name,
|
||||
"capabilities": dict(best_role.capabilities) if best_role.capabilities else {},
|
||||
"role_id": best_role.id,
|
||||
}
|
||||
|
||||
# ─── 能力检查 ───
|
||||
|
||||
@classmethod
|
||||
async def check_capability(
|
||||
cls, db: AsyncSession, table_id: str, capability: str,
|
||||
) -> bool:
|
||||
"""检查当前用户是否具有某项能力"""
|
||||
user_info = get_current_user_info_from_context()
|
||||
if not user_info:
|
||||
return False
|
||||
|
||||
role_info = await cls.get_effective_role(
|
||||
db, table_id,
|
||||
user_id=user_info.get("user_id", ""),
|
||||
dept_id=user_info.get("dept_id"),
|
||||
role_ids=user_info.get("role_ids", []),
|
||||
is_superuser=user_info.get("is_superuser", False),
|
||||
)
|
||||
if not role_info:
|
||||
return False
|
||||
|
||||
return role_info["capabilities"].get(capability, False)
|
||||
|
||||
@classmethod
|
||||
async def require_capability(
|
||||
cls, db: AsyncSession, table_id: str, capability: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""检查能力,无权限则抛 403"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
user_info = get_current_user_info_from_context()
|
||||
if not user_info:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
role_info = await cls.get_effective_role(
|
||||
db, table_id,
|
||||
user_id=user_info.get("user_id", ""),
|
||||
dept_id=user_info.get("dept_id"),
|
||||
role_ids=user_info.get("role_ids", []),
|
||||
is_superuser=user_info.get("is_superuser", False),
|
||||
)
|
||||
if not role_info:
|
||||
raise HTTPException(status_code=403, detail="无权访问此表")
|
||||
|
||||
if not role_info["capabilities"].get(capability, False):
|
||||
raise HTTPException(status_code=403, detail=f"无权执行此操作({capability})")
|
||||
|
||||
return role_info
|
||||
|
||||
@classmethod
|
||||
async def require_table_access(
|
||||
cls, db: AsyncSession, table_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""检查表访问权限(至少 viewer),无权限则抛 403"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
user_info = get_current_user_info_from_context()
|
||||
if not user_info:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
role_info = await cls.get_effective_role(
|
||||
db, table_id,
|
||||
user_id=user_info.get("user_id", ""),
|
||||
dept_id=user_info.get("dept_id"),
|
||||
role_ids=user_info.get("role_ids", []),
|
||||
is_superuser=user_info.get("is_superuser", False),
|
||||
)
|
||||
if not role_info:
|
||||
raise HTTPException(status_code=403, detail="无权访问此表")
|
||||
|
||||
return role_info
|
||||
|
||||
# ─── 列权限 ───
|
||||
|
||||
@classmethod
|
||||
async def get_field_permissions(
|
||||
cls, db: AsyncSession, table_id: str, role_id: str,
|
||||
) -> Dict[str, str]:
|
||||
"""获取某角色的列权限映射: {field_id: access}"""
|
||||
result = await db.execute(
|
||||
select(SmartTableFieldPerm).where(
|
||||
SmartTableFieldPerm.table_id == table_id,
|
||||
SmartTableFieldPerm.role_id == role_id,
|
||||
SmartTableFieldPerm.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return {fp.field_id: fp.access for fp in result.scalars().all()}
|
||||
|
||||
@classmethod
|
||||
async def get_accessible_field_ids(
|
||||
cls, db: AsyncSession, table_id: str, role_id: Optional[str],
|
||||
mode: str = "read",
|
||||
) -> Optional[List[str]]:
|
||||
"""
|
||||
获取角色可访问的字段ID列表。
|
||||
mode="read" 返回 access != "hidden" 的字段
|
||||
mode="write" 返回 access == "write" 的字段
|
||||
如果没有配置列权限,返回 None 表示不限制。
|
||||
"""
|
||||
if not role_id:
|
||||
return None
|
||||
|
||||
perms = await cls.get_field_permissions(db, table_id, role_id)
|
||||
if not perms:
|
||||
return None
|
||||
|
||||
if mode == "write":
|
||||
return [fid for fid, acc in perms.items() if acc == "write"]
|
||||
else:
|
||||
return [fid for fid, acc in perms.items() if acc != "hidden"]
|
||||
|
||||
@classmethod
|
||||
async def batch_set_field_permissions(
|
||||
cls, db: AsyncSession, table_id: str, role_id: str,
|
||||
permissions: List[Dict[str, str]],
|
||||
) -> None:
|
||||
"""批量设置列权限"""
|
||||
await db.execute(
|
||||
sa_delete(SmartTableFieldPerm).where(
|
||||
SmartTableFieldPerm.table_id == table_id,
|
||||
SmartTableFieldPerm.role_id == role_id,
|
||||
)
|
||||
)
|
||||
|
||||
for p in permissions:
|
||||
fp = SmartTableFieldPerm(
|
||||
table_id=table_id,
|
||||
role_id=role_id,
|
||||
field_id=p["field_id"],
|
||||
access=p.get("access", "write"),
|
||||
)
|
||||
db.add(fp)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# ─── 行权限 ───
|
||||
|
||||
@classmethod
|
||||
async def get_row_rules(
|
||||
cls, db: AsyncSession, table_id: str, role_id: str,
|
||||
) -> List[SmartTableRowRule]:
|
||||
result = await db.execute(
|
||||
select(SmartTableRowRule).where(
|
||||
SmartTableRowRule.table_id == table_id,
|
||||
SmartTableRowRule.role_id == role_id,
|
||||
SmartTableRowRule.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def upsert_row_rule(
|
||||
cls, db: AsyncSession, table_id: str, role_id: str,
|
||||
rule_type: str, mode: str, conditions: List[Dict[str, Any]],
|
||||
) -> SmartTableRowRule:
|
||||
"""创建或更新行权限规则"""
|
||||
result = await db.execute(
|
||||
select(SmartTableRowRule).where(
|
||||
SmartTableRowRule.table_id == table_id,
|
||||
SmartTableRowRule.role_id == role_id,
|
||||
SmartTableRowRule.rule_type == rule_type,
|
||||
SmartTableRowRule.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
rule = result.scalar_one_or_none()
|
||||
|
||||
if rule:
|
||||
rule.mode = mode
|
||||
rule.conditions = conditions
|
||||
else:
|
||||
rule = SmartTableRowRule(
|
||||
table_id=table_id,
|
||||
role_id=role_id,
|
||||
rule_type=rule_type,
|
||||
mode=mode,
|
||||
conditions=conditions,
|
||||
)
|
||||
db.add(rule)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
# ─── 协作者管理 ───
|
||||
|
||||
@classmethod
|
||||
async def get_collaborators(
|
||||
cls, db: AsyncSession, table_id: str,
|
||||
) -> List[SmartTableCollaborator]:
|
||||
result = await db.execute(
|
||||
select(SmartTableCollaborator).where(
|
||||
SmartTableCollaborator.table_id == table_id,
|
||||
SmartTableCollaborator.is_deleted == False, # noqa: E712
|
||||
).order_by(SmartTableCollaborator.sys_create_datetime)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def add_collaborator(
|
||||
cls, db: AsyncSession, table_id: str,
|
||||
subject_type: str, subject_id: str, role_id: str,
|
||||
) -> SmartTableCollaborator:
|
||||
existing = await db.execute(
|
||||
select(SmartTableCollaborator).where(
|
||||
SmartTableCollaborator.table_id == table_id,
|
||||
SmartTableCollaborator.subject_type == subject_type,
|
||||
SmartTableCollaborator.subject_id == subject_id,
|
||||
SmartTableCollaborator.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
collab = existing.scalar_one_or_none()
|
||||
if collab:
|
||||
collab.role_id = role_id
|
||||
else:
|
||||
collab = SmartTableCollaborator(
|
||||
table_id=table_id,
|
||||
subject_type=subject_type,
|
||||
subject_id=subject_id,
|
||||
role_id=role_id,
|
||||
)
|
||||
db.add(collab)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(collab)
|
||||
return collab
|
||||
|
||||
@classmethod
|
||||
async def update_collaborator_role(
|
||||
cls, db: AsyncSession, collab_id: str, role_id: str,
|
||||
) -> Optional[SmartTableCollaborator]:
|
||||
result = await db.execute(
|
||||
select(SmartTableCollaborator).where(
|
||||
SmartTableCollaborator.id == collab_id,
|
||||
SmartTableCollaborator.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
collab = result.scalar_one_or_none()
|
||||
if not collab:
|
||||
return None
|
||||
collab.role_id = role_id
|
||||
await db.commit()
|
||||
await db.refresh(collab)
|
||||
return collab
|
||||
|
||||
@classmethod
|
||||
async def remove_collaborator(
|
||||
cls, db: AsyncSession, collab_id: str,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(SmartTableCollaborator).where(
|
||||
SmartTableCollaborator.id == collab_id,
|
||||
SmartTableCollaborator.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
collab = result.scalar_one_or_none()
|
||||
if not collab:
|
||||
return False
|
||||
collab.is_deleted = True
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
# ─── 角色管理 ───
|
||||
|
||||
@classmethod
|
||||
async def get_roles(
|
||||
cls, db: AsyncSession, table_id: str,
|
||||
) -> List[SmartTableRole]:
|
||||
result = await db.execute(
|
||||
select(SmartTableRole).where(
|
||||
SmartTableRole.table_id == table_id,
|
||||
SmartTableRole.is_deleted == False, # noqa: E712
|
||||
).order_by(SmartTableRole.sys_create_datetime)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def create_custom_role(
|
||||
cls, db: AsyncSession, table_id: str,
|
||||
name: str, capabilities: Dict[str, bool],
|
||||
) -> SmartTableRole:
|
||||
role = SmartTableRole(
|
||||
table_id=table_id,
|
||||
name=name,
|
||||
role_type="custom",
|
||||
capabilities=capabilities,
|
||||
is_system=False,
|
||||
)
|
||||
db.add(role)
|
||||
await db.commit()
|
||||
await db.refresh(role)
|
||||
return role
|
||||
|
||||
@classmethod
|
||||
async def update_role(
|
||||
cls, db: AsyncSession, role_id: str,
|
||||
name: Optional[str] = None, capabilities: Optional[Dict[str, bool]] = None,
|
||||
) -> Optional[SmartTableRole]:
|
||||
result = await db.execute(
|
||||
select(SmartTableRole).where(
|
||||
SmartTableRole.id == role_id,
|
||||
SmartTableRole.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
role = result.scalar_one_or_none()
|
||||
if not role:
|
||||
return None
|
||||
if name is not None:
|
||||
role.name = name
|
||||
if capabilities is not None:
|
||||
role.capabilities = capabilities
|
||||
await db.commit()
|
||||
await db.refresh(role)
|
||||
return role
|
||||
|
||||
@classmethod
|
||||
async def delete_role(
|
||||
cls, db: AsyncSession, role_id: str,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(SmartTableRole).where(
|
||||
SmartTableRole.id == role_id,
|
||||
SmartTableRole.is_system == False, # noqa: E712
|
||||
SmartTableRole.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
role = result.scalar_one_or_none()
|
||||
if not role:
|
||||
return False
|
||||
role.is_deleted = True
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
# ─── 获取 my-permission ───
|
||||
|
||||
@classmethod
|
||||
async def get_my_permission(
|
||||
cls, db: AsyncSession, table_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取当前用户对某表的完整权限信息"""
|
||||
user_info = get_current_user_info_from_context()
|
||||
if not user_info:
|
||||
return {
|
||||
"role_type": "none",
|
||||
"role_name": "无权限",
|
||||
"capabilities": {},
|
||||
"field_permissions": {},
|
||||
"row_view_mode": "none",
|
||||
"row_edit_mode": "none",
|
||||
}
|
||||
|
||||
role_info = await cls.get_effective_role(
|
||||
db, table_id,
|
||||
user_id=user_info.get("user_id", ""),
|
||||
dept_id=user_info.get("dept_id"),
|
||||
role_ids=user_info.get("role_ids", []),
|
||||
is_superuser=user_info.get("is_superuser", False),
|
||||
)
|
||||
|
||||
if not role_info:
|
||||
return {
|
||||
"role_type": "none",
|
||||
"role_name": "无权限",
|
||||
"capabilities": {},
|
||||
"field_permissions": {},
|
||||
"row_view_mode": "none",
|
||||
"row_edit_mode": "none",
|
||||
}
|
||||
|
||||
role_id = role_info.get("role_id")
|
||||
|
||||
field_perms: Dict[str, str] = {}
|
||||
row_view_mode = "all"
|
||||
row_edit_mode = "all"
|
||||
|
||||
if role_id:
|
||||
field_perms = await cls.get_field_permissions(db, table_id, role_id)
|
||||
|
||||
row_rules = await cls.get_row_rules(db, table_id, role_id)
|
||||
for rr in row_rules:
|
||||
if rr.rule_type == "view":
|
||||
row_view_mode = rr.mode
|
||||
elif rr.rule_type == "edit":
|
||||
row_edit_mode = rr.mode
|
||||
|
||||
out: Dict[str, Any] = {
|
||||
"role_type": role_info["role_type"],
|
||||
"role_name": role_info["role_name"],
|
||||
"capabilities": role_info["capabilities"],
|
||||
"field_permissions": field_perms,
|
||||
"row_view_mode": row_view_mode,
|
||||
"row_edit_mode": row_edit_mode,
|
||||
}
|
||||
|
||||
# 文档无列/行数据维度,列权限与行规则不对文档内容生效;归一化返回值以免与多维表格混淆
|
||||
tbl_row = await db.execute(
|
||||
select(SmartTable).where(SmartTable.id == table_id, SmartTable.is_deleted == False) # noqa: E712
|
||||
)
|
||||
tbl_obj = tbl_row.scalar_one_or_none()
|
||||
if tbl_obj and getattr(tbl_obj, "type", "table") == "document":
|
||||
out["field_permissions"] = {}
|
||||
out["row_view_mode"] = "all"
|
||||
out["row_edit_mode"] = "all"
|
||||
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
async def require_multidimensional_table(cls, db: AsyncSession, table_id: str) -> None:
|
||||
"""列权限、行权限仅适用于多维表格(type=table),文档页应使用协作者角色与能力位。"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
result = await db.execute(
|
||||
select(SmartTable).where(SmartTable.id == table_id, SmartTable.is_deleted == False) # noqa: E712
|
||||
)
|
||||
tbl = result.scalar_one_or_none()
|
||||
if not tbl:
|
||||
raise HTTPException(status_code=404, detail="表不存在")
|
||||
if getattr(tbl, "type", "table") == "document":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="文档不支持列权限与行权限配置,请通过协作者角色控制访问",
|
||||
)
|
||||
|
||||
# ─── 行权限过滤 ───
|
||||
|
||||
@classmethod
|
||||
async def build_row_filter_conditions(
|
||||
cls, db: AsyncSession, table_id: str, role_id: Optional[str],
|
||||
user_id: str, rule_type: str = "view",
|
||||
) -> list:
|
||||
"""
|
||||
根据行权限规则构建 SQLAlchemy 过滤条件列表。
|
||||
rule_type: "view" 或 "edit"
|
||||
返回空列表表示不限制。
|
||||
"""
|
||||
from zq_smart_table.model import SmartRecord
|
||||
from app.db_compat import json_extract
|
||||
|
||||
if not role_id:
|
||||
return []
|
||||
|
||||
row_rules = await cls.get_row_rules(db, table_id, role_id)
|
||||
rule = next((r for r in row_rules if r.rule_type == rule_type), None)
|
||||
if not rule or rule.mode == "all":
|
||||
return []
|
||||
|
||||
if rule.mode == "creator_only":
|
||||
return [SmartRecord.sys_creator_id == user_id]
|
||||
|
||||
if rule.mode == "conditions" and rule.conditions:
|
||||
from sqlalchemy import or_, and_
|
||||
conds = []
|
||||
for c in rule.conditions:
|
||||
fid = c.get("field_id")
|
||||
op = c.get("operator", "equals")
|
||||
val = c.get("value")
|
||||
if not fid:
|
||||
continue
|
||||
col = json_extract(SmartRecord.values, fid)
|
||||
if op == "equals":
|
||||
conds.append(col == str(val) if val is not None else col == None) # noqa: E711
|
||||
elif op == "contains":
|
||||
conds.append(col.ilike(f"%{val}%") if val else col == col)
|
||||
elif op == "isEmpty":
|
||||
conds.append(or_(col == None, col == "")) # noqa: E711
|
||||
elif op == "isNotEmpty":
|
||||
conds.append(and_(col != None, col != "")) # noqa: E711
|
||||
elif op == "greaterThan":
|
||||
from sqlalchemy import cast, String
|
||||
conds.append(cast(col, String) > str(val))
|
||||
elif op == "lessThan":
|
||||
from sqlalchemy import cast, String
|
||||
conds.append(cast(col, String) < str(val))
|
||||
return conds
|
||||
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
async def check_row_edit_permission(
|
||||
cls, db: AsyncSession, table_id: str, record, role_id: Optional[str], user_id: str,
|
||||
) -> bool:
|
||||
"""检查当前用户是否有权编辑指定记录(基于行编辑权限规则)"""
|
||||
if not role_id:
|
||||
return True
|
||||
|
||||
row_rules = await cls.get_row_rules(db, table_id, role_id)
|
||||
rule = next((r for r in row_rules if r.rule_type == "edit"), None)
|
||||
if not rule or rule.mode == "all":
|
||||
return True
|
||||
|
||||
if rule.mode == "creator_only":
|
||||
return getattr(record, "sys_creator_id", None) == user_id
|
||||
|
||||
if rule.mode == "conditions" and rule.conditions:
|
||||
from app.db_compat import json_extract
|
||||
values = record.values or {}
|
||||
for c in rule.conditions:
|
||||
fid = c.get("field_id")
|
||||
op = c.get("operator", "equals")
|
||||
val = c.get("value")
|
||||
if not fid:
|
||||
continue
|
||||
cell_val = str(values.get(fid, ""))
|
||||
if op == "equals" and cell_val != str(val):
|
||||
return False
|
||||
if op == "contains" and str(val or "") not in cell_val:
|
||||
return False
|
||||
if op == "isEmpty" and cell_val != "":
|
||||
return False
|
||||
if op == "isNotEmpty" and cell_val == "":
|
||||
return False
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
# ─── 过滤记录值(去掉不可见字段) ───
|
||||
|
||||
@classmethod
|
||||
def filter_record_values(
|
||||
cls, values: Dict[str, Any], accessible_fields: Optional[List[str]],
|
||||
) -> Dict[str, Any]:
|
||||
"""过滤记录中不可见的字段值"""
|
||||
if accessible_fields is None:
|
||||
return values
|
||||
return {k: v for k, v in values.items() if k in accessible_fields}
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from zq_smart_table.api import router as smart_table_api_router
|
||||
from zq_smart_table.permission.api import router as permission_api_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(smart_table_api_router)
|
||||
router.include_router(permission_api_router)
|
||||
@@ -0,0 +1,495 @@
|
||||
from typing import Optional, List, Any, Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.base_schema import CSTDatetime
|
||||
|
||||
|
||||
# ==================== SmartTable ====================
|
||||
|
||||
class SmartTableCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200, description="表名")
|
||||
icon: str = Field(default="Grid", max_length=50, description="图标")
|
||||
description: Optional[str] = Field(None, description="描述")
|
||||
type: str = Field(default="table", description="类型: table / document")
|
||||
content: Optional[Dict[str, Any]] = Field(None, description="文档内容(Tiptap JSON), 仅 type=document 时使用")
|
||||
parent_id: Optional[str] = Field(None, description="父页面ID")
|
||||
wiki_space_id: Optional[str] = Field(None, description="所属文档库ID")
|
||||
|
||||
|
||||
class SmartTableUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200, description="表名")
|
||||
icon: Optional[str] = Field(None, max_length=50, description="图标")
|
||||
description: Optional[str] = Field(None, description="描述")
|
||||
active_view_id: Optional[str] = Field(None, description="当前激活视图ID")
|
||||
content: Optional[Dict[str, Any]] = Field(None, description="文档内容(Tiptap JSON)")
|
||||
parent_id: Optional[str] = Field(None, description="父页面ID")
|
||||
|
||||
|
||||
class SmartTableMove(BaseModel):
|
||||
parent_id: Optional[str] = Field(None, description="目标父页面ID,null 表示移到根级")
|
||||
after_id: Optional[str] = Field(None, description="排在此项之后,null 表示排在同级首位")
|
||||
|
||||
|
||||
class SmartTableResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
icon: str = "Grid"
|
||||
description: Optional[str] = None
|
||||
active_view_id: Optional[str] = None
|
||||
type: str = "table"
|
||||
parent_id: Optional[str] = None
|
||||
wiki_space_id: Optional[str] = None
|
||||
sort: int = 0
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_update_datetime: Optional[CSTDatetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SmartTableSimple(BaseModel):
|
||||
"""列表简要信息(含 parent_id 以便前端构建树)"""
|
||||
id: str
|
||||
name: str
|
||||
icon: str = "Grid"
|
||||
type: str = "table"
|
||||
parent_id: Optional[str] = None
|
||||
wiki_space_id: Optional[str] = None
|
||||
sort: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ==================== SmartField ====================
|
||||
|
||||
class SmartFieldCreate(BaseModel):
|
||||
table_id: Optional[str] = Field(None, description="所属表ID(从URL路径注入)")
|
||||
name: str = Field(..., min_length=1, max_length=200, description="字段名")
|
||||
type: str = Field(..., description="字段类型")
|
||||
width: int = Field(default=150, description="列宽")
|
||||
visible: bool = Field(default=True, description="是否可见")
|
||||
required: bool = Field(default=False, description="是否必填")
|
||||
description: Optional[str] = Field(None, description="描述")
|
||||
config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="扩展配置")
|
||||
sort: int = Field(default=0, description="排序")
|
||||
|
||||
|
||||
class SmartFieldUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200, description="字段名")
|
||||
type: Optional[str] = Field(None, description="字段类型")
|
||||
width: Optional[int] = Field(None, description="列宽")
|
||||
visible: Optional[bool] = Field(None, description="是否可见")
|
||||
required: Optional[bool] = Field(None, description="是否必填")
|
||||
description: Optional[str] = Field(None, description="描述")
|
||||
config: Optional[Dict[str, Any]] = Field(None, description="扩展配置")
|
||||
sort: Optional[int] = Field(None, description="排序")
|
||||
|
||||
|
||||
class SmartFieldResponse(BaseModel):
|
||||
id: str
|
||||
table_id: str
|
||||
name: str
|
||||
type: str
|
||||
width: int = 150
|
||||
visible: bool = True
|
||||
required: bool = False
|
||||
description: Optional[str] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
sort: int = 0
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_update_datetime: Optional[CSTDatetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SmartFieldReorder(BaseModel):
|
||||
field_ids: List[str] = Field(..., description="按顺序排列的字段ID列表")
|
||||
|
||||
|
||||
# ==================== SmartRecord ====================
|
||||
|
||||
class SmartRecordCreate(BaseModel):
|
||||
table_id: Optional[str] = Field(None, description="所属表ID(从URL路径注入)")
|
||||
values: Dict[str, Any] = Field(default_factory=dict, description="字段值映射")
|
||||
|
||||
|
||||
class SmartRecordUpdate(BaseModel):
|
||||
values: Optional[Dict[str, Any]] = Field(None, description="字段值映射")
|
||||
|
||||
|
||||
class SmartRecordCellUpdate(BaseModel):
|
||||
"""更新单个单元格"""
|
||||
field_id: str = Field(..., description="字段ID")
|
||||
value: Any = Field(None, description="单元格值")
|
||||
|
||||
|
||||
class SmartRecordResponse(BaseModel):
|
||||
id: str
|
||||
table_id: str
|
||||
values: Dict[str, Any] = {}
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_update_datetime: Optional[CSTDatetime] = None
|
||||
sys_creator_id: Optional[str] = None
|
||||
sys_modifier_id: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SmartRecordBatchDelete(BaseModel):
|
||||
ids: List[str] = Field(..., description="要删除的记录ID列表")
|
||||
|
||||
|
||||
class SmartRecordBatchRestore(BaseModel):
|
||||
ids: List[str] = Field(..., description="要恢复的记录ID列表")
|
||||
|
||||
|
||||
class TrashRecordResponse(BaseModel):
|
||||
id: str
|
||||
table_id: str
|
||||
values: Dict[str, Any] = {}
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_update_datetime: Optional[CSTDatetime] = None
|
||||
sys_creator_id: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TrashListResponse(BaseModel):
|
||||
items: List[TrashRecordResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ==================== SmartView ====================
|
||||
|
||||
class SmartViewCreate(BaseModel):
|
||||
table_id: Optional[str] = Field(None, description="所属表ID(从URL路径注入)")
|
||||
name: str = Field(..., min_length=1, max_length=200, description="视图名")
|
||||
type: str = Field(default="grid", description="视图类型")
|
||||
config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="视图配置")
|
||||
sort: int = Field(default=0, description="排序")
|
||||
|
||||
|
||||
class SmartViewUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200, description="视图名")
|
||||
type: Optional[str] = Field(None, description="视图类型")
|
||||
config: Optional[Dict[str, Any]] = Field(None, description="视图配置")
|
||||
sort: Optional[int] = Field(None, description="排序")
|
||||
|
||||
|
||||
class SmartViewResponse(BaseModel):
|
||||
id: str
|
||||
table_id: str
|
||||
name: str
|
||||
type: str = "grid"
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
sort: int = 0
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_update_datetime: Optional[CSTDatetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ==================== Record Query (Server-side filter/sort/search) ====================
|
||||
|
||||
class RecordFilterRule(BaseModel):
|
||||
field_id: str = Field(..., description="字段ID")
|
||||
operator: str = Field(..., description="操作符: equals, notEquals, contains, notContains, isEmpty, isNotEmpty, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual")
|
||||
value: Any = Field(None, description="筛选值")
|
||||
|
||||
|
||||
class RecordSortRule(BaseModel):
|
||||
field_id: str = Field(..., description="字段ID")
|
||||
direction: str = Field(default="asc", description="排序方向: asc / desc")
|
||||
|
||||
|
||||
class RecordQuery(BaseModel):
|
||||
filters: List[RecordFilterRule] = Field(default_factory=list, description="筛选条件")
|
||||
filter_logic: str = Field(default="and", description="筛选逻辑: and / or")
|
||||
sorts: List[RecordSortRule] = Field(default_factory=list, description="排序规则")
|
||||
search: Optional[str] = Field(None, description="全文搜索关键词")
|
||||
search_field_ids: Optional[List[str]] = Field(None, description="搜索范围字段ID列表,为空搜索所有文本字段")
|
||||
group_field_id: Optional[str] = Field(None, description="分组字段ID")
|
||||
cursor: Optional[str] = Field(None, description="游标(上一页最后一条记录的ID)")
|
||||
limit: int = Field(default=200, ge=1, le=5000, description="每页数量")
|
||||
|
||||
|
||||
class RecordGroupItem(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
records: List[SmartRecordResponse] = []
|
||||
|
||||
|
||||
class GroupedRecordsResponse(BaseModel):
|
||||
groups: List[RecordGroupItem]
|
||||
total: int
|
||||
|
||||
|
||||
class SmartRecordReorder(BaseModel):
|
||||
record_ids: List[str] = Field(..., description="按顺序排列的记录ID列表")
|
||||
|
||||
|
||||
# ==================== Batch Cell Update ====================
|
||||
|
||||
class SmartRecordBatchCellUpdate(BaseModel):
|
||||
"""批量更新多个单元格"""
|
||||
cells: Dict[str, Any] = Field(..., description="fieldId->value 映射")
|
||||
|
||||
|
||||
class MultiRecordCellUpdate(BaseModel):
|
||||
"""单条记录的更新项"""
|
||||
record_id: str = Field(..., description="记录ID")
|
||||
cells: Dict[str, Any] = Field(..., description="fieldId->value 映射")
|
||||
|
||||
|
||||
class MultiRecordBatchUpdate(BaseModel):
|
||||
"""批量更新多条记录的单元格 - 合并为单次事务"""
|
||||
updates: List[MultiRecordCellUpdate] = Field(..., description="更新列表", max_length=200)
|
||||
|
||||
|
||||
# ==================== Cursor Pagination ====================
|
||||
|
||||
class CursorPaginatedRecords(BaseModel):
|
||||
items: List[SmartRecordResponse]
|
||||
total: int
|
||||
next_cursor: Optional[str] = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
# ==================== Full Table Load ====================
|
||||
|
||||
# ==================== Link / Lookup / Rollup ====================
|
||||
|
||||
class LinkCellUpdate(BaseModel):
|
||||
"""更新 Link 字段的关联记录"""
|
||||
record_ids: List[str] = Field(..., description="目标记录ID列表")
|
||||
|
||||
|
||||
class LinkedRecordItem(BaseModel):
|
||||
"""关联记录摘要"""
|
||||
id: str
|
||||
title: str = ""
|
||||
|
||||
|
||||
class RecordSearchResult(BaseModel):
|
||||
"""搜索目标表记录结果"""
|
||||
id: str
|
||||
title: str = ""
|
||||
|
||||
|
||||
class RecordSearchQuery(BaseModel):
|
||||
"""搜索请求"""
|
||||
keyword: str = Field(default="", description="搜索关键词")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="返回数量")
|
||||
|
||||
|
||||
# ==================== Full Table Load ====================
|
||||
|
||||
class SmartTableFull(BaseModel):
|
||||
"""完整表数据(含字段、首页记录、视图)"""
|
||||
id: str
|
||||
name: str
|
||||
icon: str = "Grid"
|
||||
description: Optional[str] = None
|
||||
active_view_id: Optional[str] = None
|
||||
type: str = "table"
|
||||
parent_id: Optional[str] = None
|
||||
wiki_space_id: Optional[str] = None
|
||||
content: Optional[Dict[str, Any]] = None
|
||||
fields: List[SmartFieldResponse] = []
|
||||
records: List[SmartRecordResponse] = []
|
||||
views: List[SmartViewResponse] = []
|
||||
record_total: int = 0
|
||||
next_cursor: Optional[str] = None
|
||||
has_more: bool = False
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_update_datetime: Optional[CSTDatetime] = None
|
||||
sys_creator_id: Optional[str] = None
|
||||
creator_name: Optional[str] = None
|
||||
creator_avatar: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ==================== Comment ====================
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=5000, description="评论内容")
|
||||
mentions: List[str] = Field(default_factory=list, description="被@提及的用户ID列表")
|
||||
parent_id: Optional[str] = Field(None, description="父评论ID(回复)")
|
||||
|
||||
|
||||
class CommentUpdate(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=5000, description="评论内容")
|
||||
mentions: List[str] = Field(default_factory=list, description="被@提及的用户ID列表")
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
id: str
|
||||
record_id: str
|
||||
user_id: str
|
||||
content: str
|
||||
mentions: List[str] = []
|
||||
parent_id: Optional[str] = None
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_update_datetime: Optional[CSTDatetime] = None
|
||||
user_name: Optional[str] = None
|
||||
user_avatar: Optional[str] = None
|
||||
replies: List["CommentResponse"] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
CommentResponse.model_rebuild()
|
||||
|
||||
|
||||
# ==================== Summary / Aggregation ====================
|
||||
|
||||
class SummaryRequest(BaseModel):
|
||||
"""汇总请求:指定字段及聚合方式"""
|
||||
aggregations: Dict[str, str] = Field(
|
||||
..., description="fieldId -> aggregation_type 映射,如 {'fld_abc': 'SUM'}"
|
||||
)
|
||||
filters: List[RecordFilterRule] = Field(default_factory=list, description="筛选条件")
|
||||
filter_logic: str = Field(default="and", description="筛选逻辑: and / or")
|
||||
search: Optional[str] = Field(None, description="搜索关键词")
|
||||
|
||||
|
||||
class SummaryResponse(BaseModel):
|
||||
"""汇总响应"""
|
||||
summaries: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="fieldId -> 聚合结果,如 {'fld_abc': 12345.67}"
|
||||
)
|
||||
total_count: int = 0
|
||||
|
||||
|
||||
# ==================== Document ====================
|
||||
|
||||
class DocumentContentUpdate(BaseModel):
|
||||
"""更新文档内容"""
|
||||
content: Dict[str, Any] = Field(..., description="Tiptap JSON 文档内容")
|
||||
|
||||
|
||||
# ==================== Document Version ====================
|
||||
|
||||
class DocumentVersionCreate(BaseModel):
|
||||
"""手动创建版本"""
|
||||
change_summary: Optional[str] = Field(None, max_length=500, description="变更摘要")
|
||||
|
||||
|
||||
class DocumentVersionResponse(BaseModel):
|
||||
id: str
|
||||
document_id: str
|
||||
version: int
|
||||
title: Optional[str] = None
|
||||
change_summary: Optional[str] = None
|
||||
content_size: int = 0
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_creator_id: Optional[str] = None
|
||||
creator_name: Optional[str] = None
|
||||
creator_avatar: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DocumentVersionDetail(DocumentVersionResponse):
|
||||
"""版本详情(含完整内容)"""
|
||||
content: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class DocumentVersionCompare(BaseModel):
|
||||
"""版本对比结果"""
|
||||
version_from: DocumentVersionDetail
|
||||
version_to: DocumentVersionDetail
|
||||
|
||||
|
||||
# ==================== Document Template ====================
|
||||
|
||||
class DocumentTemplateCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
|
||||
description: Optional[str] = Field(None, description="模板描述")
|
||||
icon: str = Field(default="FileText", max_length=50, description="模板图标")
|
||||
category: str = Field(default="custom", max_length=50, description="分类")
|
||||
content: Dict[str, Any] = Field(..., description="模板内容(Tiptap JSON)")
|
||||
preview_image: Optional[str] = Field(None, description="预览图URL")
|
||||
|
||||
|
||||
class DocumentTemplateUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200, description="模板名称")
|
||||
description: Optional[str] = Field(None, description="模板描述")
|
||||
icon: Optional[str] = Field(None, max_length=50, description="模板图标")
|
||||
category: Optional[str] = Field(None, max_length=50, description="分类")
|
||||
content: Optional[Dict[str, Any]] = Field(None, description="模板内容(Tiptap JSON)")
|
||||
preview_image: Optional[str] = Field(None, description="预览图URL")
|
||||
|
||||
|
||||
class DocumentTemplateResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
icon: str = "FileText"
|
||||
category: str = "custom"
|
||||
preview_image: Optional[str] = None
|
||||
is_system: bool = False
|
||||
use_count: int = 0
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_update_datetime: Optional[CSTDatetime] = None
|
||||
sys_creator_id: Optional[str] = None
|
||||
creator_name: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DocumentTemplateDetail(DocumentTemplateResponse):
|
||||
"""模板详情(含完整内容)"""
|
||||
content: Dict[str, Any] = {}
|
||||
|
||||
|
||||
# ==================== Wiki Space ====================
|
||||
|
||||
class WikiSpaceCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200, description="文档库名称")
|
||||
icon: str = Field(default="BookOpen", max_length=50, description="图标")
|
||||
avatar: Optional[str] = Field(None, description="头像文件ID")
|
||||
description: Optional[str] = Field(None, description="描述")
|
||||
cover: Optional[str] = Field(None, description="封面图URL")
|
||||
category: str = Field(default="default", max_length=50, description="分类标签")
|
||||
visibility: str = Field(default="private", description="可见性: private/team/public")
|
||||
|
||||
|
||||
class WikiSpaceUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200, description="文档库名称")
|
||||
icon: Optional[str] = Field(None, max_length=50, description="图标")
|
||||
avatar: Optional[str] = Field(None, description="头像文件ID")
|
||||
description: Optional[str] = Field(None, description="描述")
|
||||
cover: Optional[str] = Field(None, description="封面图URL")
|
||||
category: Optional[str] = Field(None, max_length=50, description="分类标签")
|
||||
visibility: Optional[str] = Field(None, description="可见性: private/team/public")
|
||||
|
||||
|
||||
class WikiSpaceResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
icon: str = "BookOpen"
|
||||
avatar: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
cover: Optional[str] = None
|
||||
category: str = "default"
|
||||
visibility: str = "private"
|
||||
sort: int = 0
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_update_datetime: Optional[CSTDatetime] = None
|
||||
sys_creator_id: Optional[str] = None
|
||||
creator_name: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WikiSpaceListItem(WikiSpaceResponse):
|
||||
"""列表项,含文档数量"""
|
||||
document_count: int = 0
|
||||
|
||||
|
||||
class WikiSpaceDetail(WikiSpaceResponse):
|
||||
"""详情,含文档列表"""
|
||||
documents: List[SmartTableSimple] = []
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user