Files

2075 lines
73 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Optional, List, Tuple, Any, Dict
from sqlalchemy import select, func, update as sa_update, desc, asc, text, case, literal_column, cast, String, or_, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_service import BaseService
from app.db_compat import get_db_type, json_extract, json_has_key
from zq_smart_table.model import SmartTable, SmartField, SmartRecord, SmartView, SmartTableLink, SmartTableComment, SmartDocumentVersion, SmartDocumentTemplate, SmartWikiSpace
from zq_smart_table.schema import (
SmartTableCreate, SmartTableUpdate,
SmartFieldCreate, SmartFieldUpdate,
SmartRecordCreate, SmartRecordUpdate,
SmartViewCreate, SmartViewUpdate,
RecordFilterRule, RecordSortRule,
LinkedRecordItem,
WikiSpaceCreate, WikiSpaceUpdate,
)
class SmartTableService(BaseService[SmartTable, SmartTableCreate, SmartTableUpdate]):
model = SmartTable
RESOURCE_TYPE = "smart_table"
@classmethod
async def get_user_tables(
cls, db: AsyncSession, page: int = 1, page_size: int = 1000,
wiki_space_id: Optional[str] = None,
user_id: Optional[str] = None,
dept_id: Optional[str] = None,
role_ids: Optional[List[str]] = None,
is_superuser: bool = False,
) -> Tuple[List[SmartTable], int]:
from zq_smart_table.permission.model import SmartTableCollaborator
base_query = select(SmartTable).where(SmartTable.is_deleted == False) # noqa: E712
if wiki_space_id is not None:
base_query = base_query.where(SmartTable.wiki_space_id == wiki_space_id)
else:
base_query = base_query.where(SmartTable.wiki_space_id.is_(None))
if not is_superuser and user_id:
subject_conds = [
and_(
SmartTableCollaborator.subject_type == "user",
SmartTableCollaborator.subject_id == user_id,
)
]
if dept_id:
subject_conds.append(
and_(
SmartTableCollaborator.subject_type == "dept",
SmartTableCollaborator.subject_id == dept_id,
)
)
if role_ids:
for rid in role_ids:
subject_conds.append(
and_(
SmartTableCollaborator.subject_type == "role",
SmartTableCollaborator.subject_id == rid,
)
)
collab_table_ids = (
select(SmartTableCollaborator.table_id)
.where(
SmartTableCollaborator.is_deleted == False, # noqa: E712
or_(*subject_conds),
)
.distinct()
)
base_query = base_query.where(
or_(
SmartTable.sys_creator_id == user_id,
SmartTable.id.in_(collab_table_ids),
)
)
count_result = await db.execute(
select(func.count()).select_from(base_query.subquery())
)
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
base_query.order_by(desc(SmartTable.sort), desc(SmartTable.sys_create_datetime))
.offset(offset)
.limit(page_size)
)
items = list(result.scalars().all())
return items, total
class SmartFieldService(BaseService[SmartField, SmartFieldCreate, SmartFieldUpdate]):
model = SmartField
RESOURCE_TYPE = "smart_field"
@classmethod
async def get_by_table(cls, db: AsyncSession, table_id: str) -> List[SmartField]:
result = await db.execute(
select(SmartField)
.where(SmartField.table_id == table_id, SmartField.is_deleted == False) # noqa: E712
.order_by(SmartField.sort, SmartField.sys_create_datetime)
)
return list(result.scalars().all())
@classmethod
async def reorder(cls, db: AsyncSession, table_id: str, field_ids: List[str]) -> None:
"""批量排序:一条 CASE WHEN SQL 替代 N 次 UPDATE"""
if not field_ids:
return
whens = [(SmartField.id == fid, idx) for idx, fid in enumerate(field_ids)]
await db.execute(
sa_update(SmartField)
.where(SmartField.table_id == table_id, SmartField.id.in_(field_ids))
.values(sort=case(*whens, else_=SmartField.sort))
)
await db.commit()
@classmethod
async def delete_and_clean(cls, db: AsyncSession, field_id: str) -> bool:
"""删除字段并用一条 SQL 清理所有 record 的 values"""
field = await cls.get_by_id(db, field_id)
if not field:
return False
db_type = get_db_type()
if db_type == "postgresql":
await db.execute(
text(
"UPDATE smart_record SET \"values\" = \"values\" - :field_id "
"WHERE table_id = :table_id AND is_deleted = false "
"AND \"values\" \\? :field_id"
),
{"field_id": field_id, "table_id": field.table_id},
)
else:
await db.execute(
text(
"UPDATE smart_record SET `values` = JSON_REMOVE(`values`, CONCAT('$.', :field_id)) "
"WHERE table_id = :table_id AND is_deleted = 0 "
"AND JSON_CONTAINS_PATH(`values`, 'one', CONCAT('$.', :field_id))"
),
{"field_id": field_id, "table_id": field.table_id},
)
field.is_deleted = True
await db.commit()
return True
@classmethod
async def get_next_sort(cls, db: AsyncSession, table_id: str) -> int:
result = await db.execute(
select(func.coalesce(func.max(SmartField.sort), -1))
.where(SmartField.table_id == table_id, SmartField.is_deleted == False) # noqa: E712
)
return (result.scalar() or 0) + 1
class SmartRecordService(BaseService[SmartRecord, SmartRecordCreate, SmartRecordUpdate]):
model = SmartRecord
RESOURCE_TYPE = "smart_record"
@classmethod
async def get_by_table_cursor(
cls, db: AsyncSession, table_id: str,
cursor: Optional[str] = None, limit: int = 200,
) -> Tuple[List[SmartRecord], Optional[str], int]:
"""
游标分页查询(适用于大数据量滚动加载)。
返回 (records, next_cursor, total)
"""
base = select(SmartRecord).where(
SmartRecord.table_id == table_id,
SmartRecord.is_deleted == False, # noqa: E712
)
count_result = await db.execute(
select(func.count()).select_from(base.subquery())
)
total = count_result.scalar() or 0
query = base.order_by(SmartRecord.sort, SmartRecord.id)
if cursor:
query = query.where(SmartRecord.id > cursor)
query = query.limit(limit + 1)
result = await db.execute(query)
items = list(result.scalars().all())
next_cursor = None
if len(items) > limit:
items = items[:limit]
next_cursor = items[-1].id
return items, next_cursor, total
@classmethod
async def get_by_table(
cls, db: AsyncSession, table_id: str,
page: int = 1, page_size: int = 200
) -> Tuple[List[SmartRecord], int]:
filters = [SmartRecord.table_id == table_id]
return await cls.get_list(db, page=page, page_size=page_size, filters=filters)
@classmethod
async def update_cell(
cls, db: AsyncSession, record_id: str, field_id: str, value: Any
) -> Optional[SmartRecord]:
"""使用数据库原生 JSON 原子更新,避免读-改-写"""
import json
db_type = get_db_type()
json_value = json.dumps(value)
if db_type == "postgresql":
await db.execute(
text(
"UPDATE smart_record SET \"values\" = jsonb_set("
"COALESCE(\"values\", CAST('{}' AS jsonb)), "
":path, CAST(:val AS jsonb)), "
"sys_update_datetime = now() "
"WHERE id = :id AND is_deleted = false"
),
{"path": [field_id], "val": json_value, "id": record_id},
)
else:
await db.execute(
text(
"UPDATE smart_record SET `values` = JSON_SET("
"COALESCE(`values`, '{}'), "
"CONCAT('$.', :field_id), CAST(:val AS JSON)), "
"sys_update_datetime = NOW() "
"WHERE id = :id AND is_deleted = 0"
),
{"field_id": field_id, "val": json_value, "id": record_id},
)
await db.commit()
record = await cls.get_by_id(db, record_id)
return record
@classmethod
async def batch_update_cells(
cls, db: AsyncSession, record_id: str, cells: Dict[str, Any]
) -> Optional[SmartRecord]:
"""批量更新多个单元格(一次 SQL"""
import json
record = await cls.get_by_id(db, record_id)
if not record:
return None
current_values = dict(record.values) if record.values else {}
current_values.update(cells)
record.values = current_values
await db.commit()
await db.refresh(record)
return record
@classmethod
async def batch_update_multi_records(
cls, db: AsyncSession, updates: List[Dict[str, Any]]
) -> int:
"""
批量更新多条记录的单元格,合并为单次事务。
updates: [{ "record_id": "...", "cells": { fieldId: value, ... } }, ...]
返回成功更新的记录数。
"""
import json as _json
db_type = get_db_type()
count = 0
for item in updates:
record_id = item.get("record_id")
cells = item.get("cells", {})
if not record_id or not cells:
continue
if db_type == "postgresql":
merge_obj = _json.dumps(cells)
await db.execute(
text(
'UPDATE smart_record SET "values" = '
'COALESCE("values", \'{}\'::jsonb) || CAST(:merge AS jsonb), '
'sys_update_datetime = now() '
'WHERE id = :id AND is_deleted = false'
),
{"merge": merge_obj, "id": record_id},
)
else:
record = await cls.get_by_id(db, record_id)
if not record:
continue
current = dict(record.values) if record.values else {}
current.update(cells)
record.values = current
count += 1
await db.commit()
return count
@classmethod
async def batch_remove_field(cls, db: AsyncSession, table_id: str, field_id: str) -> None:
"""用一条 SQL 从所有 record 中移除某个 field 的值"""
db_type = get_db_type()
if db_type == "postgresql":
await db.execute(
text(
"UPDATE smart_record SET \"values\" = \"values\" - :field_id "
"WHERE table_id = :table_id AND is_deleted = false "
"AND \"values\" \\? :field_id"
),
{"field_id": field_id, "table_id": table_id},
)
else:
await db.execute(
text(
"UPDATE smart_record SET `values` = JSON_REMOVE(`values`, CONCAT('$.', :field_id)) "
"WHERE table_id = :table_id AND is_deleted = 0"
),
{"field_id": field_id, "table_id": table_id},
)
await db.commit()
@classmethod
async def get_deleted_records(
cls, db: AsyncSession, table_id: str,
page: int = 1, page_size: int = 50,
) -> Tuple[List[SmartRecord], int]:
"""查询已删除的记录(回收站)"""
base = select(SmartRecord).where(
SmartRecord.table_id == table_id,
SmartRecord.is_deleted == True, # noqa: E712
)
count_result = await db.execute(
select(func.count()).select_from(base.subquery())
)
total = count_result.scalar() or 0
query = base.order_by(desc(SmartRecord.sys_update_datetime)).offset(
(page - 1) * page_size
).limit(page_size)
result = await db.execute(query)
return list(result.scalars().all()), total
@classmethod
async def batch_restore_records(
cls, db: AsyncSession, table_id: str, record_ids: List[str]
) -> int:
"""批量恢复已删除的记录(仅限指定表)"""
result = await db.execute(
sa_update(SmartRecord)
.where(
SmartRecord.id.in_(record_ids),
SmartRecord.table_id == table_id,
SmartRecord.is_deleted == True, # noqa: E712
)
.values(is_deleted=False)
)
await db.commit()
return result.rowcount # type: ignore
@classmethod
async def permanent_delete(cls, db: AsyncSession, table_id: str, record_id: str) -> bool:
"""永久删除记录(仅限指定表的已删除记录)"""
from sqlalchemy import delete as sa_del
result = await db.execute(
sa_del(SmartRecord).where(
SmartRecord.id == record_id,
SmartRecord.table_id == table_id,
SmartRecord.is_deleted == True, # noqa: E712
)
)
await db.commit()
return result.rowcount > 0 # type: ignore
@classmethod
async def empty_trash(cls, db: AsyncSession, table_id: str) -> int:
"""清空回收站"""
from sqlalchemy import delete as sa_del
result = await db.execute(
sa_del(SmartRecord).where(
SmartRecord.table_id == table_id,
SmartRecord.is_deleted == True, # noqa: E712
)
)
await db.commit()
return result.rowcount # type: ignore
@classmethod
async def get_next_auto_number(cls, db: AsyncSession, table_id: str, field_id: str) -> int:
"""用 SQL 聚合查询获取 AutoNumber 最大值,避免全表加载到 Python"""
db_type = get_db_type()
if db_type == "postgresql":
result = await db.execute(
text(
"SELECT COALESCE(MAX(CAST(\"values\" ->> :field_id AS int)), 0) "
"FROM smart_record "
"WHERE table_id = :table_id AND is_deleted = false "
"AND \"values\" \\? :field_id "
"AND (\"values\" ->> :field_id) ~ '^[0-9]+$'"
),
{"field_id": field_id, "table_id": table_id},
)
else:
result = await db.execute(
text(
"SELECT COALESCE(MAX(CAST(JSON_UNQUOTE(JSON_EXTRACT(`values`, CONCAT('$.', :field_id))) AS SIGNED)), 0) "
"FROM smart_record "
"WHERE table_id = :table_id AND is_deleted = 0 "
"AND JSON_CONTAINS_PATH(`values`, 'one', CONCAT('$.', :field_id))"
),
{"field_id": field_id, "table_id": table_id},
)
return (result.scalar() or 0) + 1
@classmethod
async def get_record_count(cls, db: AsyncSession, table_id: str) -> int:
result = await db.execute(
select(func.count()).where(
SmartRecord.table_id == table_id,
SmartRecord.is_deleted == False, # noqa: E712
)
)
return result.scalar() or 0
# ==================== Server-side filter / sort / search ====================
@classmethod
def _build_filter_condition(cls, rule: RecordFilterRule):
"""将单条 RecordFilterRule 转换为 SQLAlchemy 条件表达式"""
col = json_extract(SmartRecord.values, rule.field_id)
op = rule.operator
val = rule.value
if op == "isEmpty":
return or_(
~json_has_key(SmartRecord.values, rule.field_id),
col == None, # noqa: E711
col == "",
)
if op == "isNotEmpty":
return and_(
json_has_key(SmartRecord.values, rule.field_id),
col != None, # noqa: E711
col != "",
)
if op == "equals":
return col == str(val) if val is not None else col == None # noqa: E711
if op == "notEquals":
return col != str(val) if val is not None else col != None # noqa: E711
if op == "contains":
return col.ilike(f"%{val}%") if val else col == col
if op == "notContains":
return ~col.ilike(f"%{val}%") if val else col == col
if op == "greaterThan":
return cast(col, String) > str(val)
if op == "lessThan":
return cast(col, String) < str(val)
if op == "greaterThanOrEqual":
return cast(col, String) >= str(val)
if op == "lessThanOrEqual":
return cast(col, String) <= str(val)
return True # noqa: fallback
@classmethod
def _build_search_conditions(cls, keyword: str, search_field_ids: Optional[List[str]] = None):
"""构建全文搜索条件:在指定字段或所有字段中匹配关键词"""
if not keyword:
return None
like_pattern = f"%{keyword}%"
if search_field_ids:
conds = [json_extract(SmartRecord.values, fid).ilike(like_pattern) for fid in search_field_ids]
else:
# 对 JSONB 整列做 cast(text) ILIKE,兼容所有字段
conds = [cast(SmartRecord.values, String).ilike(like_pattern)]
return or_(*conds) if conds else None
@classmethod
async def get_by_table_cursor_filtered(
cls,
db: AsyncSession,
table_id: str,
filters: Optional[List[RecordFilterRule]] = None,
filter_logic: str = "and",
sorts: Optional[List[RecordSortRule]] = None,
search: Optional[str] = None,
search_field_ids: Optional[List[str]] = None,
extra_conditions: Optional[list] = None,
cursor: Optional[str] = None,
limit: int = 200,
skip_count: bool = False,
) -> Tuple[List[SmartRecord], Optional[str], int]:
"""
服务端筛选 + 排序 + 搜索 + 游标分页。
extra_conditions: 外部传入的额外 SQLAlchemy 条件(如行权限)
skip_count: 为True时跳过count查询(用于加载更多时提升性能)
"""
base = select(SmartRecord).where(
SmartRecord.table_id == table_id,
SmartRecord.is_deleted == False, # noqa: E712
)
has_complex_filter = False
if filters:
filter_conds = [cls._build_filter_condition(f) for f in filters]
if filter_logic == "or":
base = base.where(or_(*filter_conds))
else:
base = base.where(and_(*filter_conds))
has_complex_filter = True
if search and search.strip():
search_cond = cls._build_search_conditions(search.strip(), search_field_ids)
if search_cond is not None:
base = base.where(search_cond)
has_complex_filter = True
if extra_conditions:
for cond in extra_conditions:
base = base.where(cond)
has_complex_filter = True
total = -1
if not skip_count:
if has_complex_filter:
count_result = await db.execute(
select(func.count()).select_from(base.subquery())
)
else:
count_result = await db.execute(
select(func.count()).where(
SmartRecord.table_id == table_id,
SmartRecord.is_deleted == False, # noqa: E712
)
)
total = count_result.scalar() or 0
query = base
if sorts:
for s in sorts:
col = json_extract(SmartRecord.values, s.field_id)
query = query.order_by(desc(col) if s.direction == "desc" else asc(col))
query = query.order_by(SmartRecord.sort, SmartRecord.id)
if cursor:
query = query.where(SmartRecord.id > cursor)
query = query.limit(limit + 1)
result = await db.execute(query)
items = list(result.scalars().all())
next_cursor = None
if len(items) > limit:
items = items[:limit]
next_cursor = items[-1].id
return items, next_cursor, total
@classmethod
async def reorder(cls, db: AsyncSession, table_id: str, record_ids: List[str]) -> None:
"""批量更新记录排序"""
if not record_ids:
return
whens = [(SmartRecord.id == rid, idx) for idx, rid in enumerate(record_ids)]
await db.execute(
sa_update(SmartRecord)
.where(SmartRecord.table_id == table_id, SmartRecord.id.in_(record_ids))
.values(sort=case(*whens, else_=SmartRecord.sort))
)
await db.commit()
class SmartViewService(BaseService[SmartView, SmartViewCreate, SmartViewUpdate]):
model = SmartView
RESOURCE_TYPE = "smart_view"
@classmethod
async def get_by_table(cls, db: AsyncSession, table_id: str) -> List[SmartView]:
result = await db.execute(
select(SmartView)
.where(SmartView.table_id == table_id, SmartView.is_deleted == False) # noqa: E712
.order_by(SmartView.sort, SmartView.sys_create_datetime)
)
return list(result.scalars().all())
# ==================== Import / Export ====================
class SmartExportService:
"""CSV / Excel 导出"""
@classmethod
def _format_cell(cls, val: Any, field_type: str) -> str:
if val is None:
return ""
if field_type == "link" and isinstance(val, list):
return ", ".join(
(item.get("title", "") if isinstance(item, dict) else str(item))
for item in val
)
if isinstance(val, list):
return ", ".join(str(v) for v in val)
return str(val)
@classmethod
async def export_csv(
cls, db: AsyncSession, table_id: str,
fields: List[SmartField], records: List[SmartRecord],
) -> str:
import csv
import io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([f.name for f in fields])
for r in records:
row = []
for f in fields:
val = (r.values or {}).get(f.id, "")
row.append(cls._format_cell(val, f.type))
writer.writerow(row)
return output.getvalue()
@classmethod
async def export_xlsx(
cls, db: AsyncSession, table_id: str,
fields: List[SmartField], records: List[SmartRecord],
) -> bytes:
from openpyxl import Workbook
import io
wb = Workbook()
ws = wb.active
ws.title = "Sheet1"
ws.append([f.name for f in fields])
for r in records:
row = []
for f in fields:
val = (r.values or {}).get(f.id, "")
row.append(cls._format_cell(val, f.type))
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
class SmartImportService:
"""CSV / Excel 导入"""
BATCH_SIZE = 500
@classmethod
async def import_csv(
cls, db: AsyncSession, table_id: str,
file_content: bytes, fields: List[SmartField],
) -> Dict[str, Any]:
import csv
import io
text = file_content.decode("utf-8-sig")
reader = csv.reader(io.StringIO(text))
headers = next(reader, None)
if not headers:
return {"success": 0, "fail": 0, "errors": ["文件为空或无表头"]}
return await cls._import_rows(db, table_id, headers, list(reader), fields)
@classmethod
async def import_xlsx(
cls, db: AsyncSession, table_id: str,
file_content: bytes, fields: List[SmartField],
) -> Dict[str, Any]:
from openpyxl import load_workbook
import io
wb = load_workbook(io.BytesIO(file_content), read_only=True)
ws = wb.active
rows_iter = ws.iter_rows(values_only=True)
header_row = next(rows_iter, None)
if not header_row:
return {"success": 0, "fail": 0, "errors": ["文件为空或无表头"]}
headers = [str(h) if h else "" for h in header_row]
data_rows = [[str(c) if c is not None else "" for c in row] for row in rows_iter]
return await cls._import_rows(db, table_id, headers, data_rows, fields)
@classmethod
async def _import_rows(
cls, db: AsyncSession, table_id: str,
headers: List[str], rows: List[List[str]],
fields: List[SmartField],
) -> Dict[str, Any]:
field_map: Dict[str, SmartField] = {f.name: f for f in fields}
col_to_field: List[Optional[SmartField]] = []
new_fields_created = 0
for h in headers:
h = h.strip()
if h in field_map:
col_to_field.append(field_map[h])
else:
new_field = SmartField(
table_id=table_id,
name=h,
type="text",
width=150,
visible=True,
required=False,
config={},
sort=len(fields) + new_fields_created,
)
db.add(new_field)
await db.flush()
field_map[h] = new_field
col_to_field.append(new_field)
new_fields_created += 1
success = 0
errors: List[str] = []
batch: List[SmartRecord] = []
for row_idx, row in enumerate(rows):
try:
values: Dict[str, Any] = {}
for col_idx, cell_val in enumerate(row):
if col_idx < len(col_to_field) and col_to_field[col_idx]:
field = col_to_field[col_idx]
if field.type in ("link", "lookup", "rollup"):
continue
values[field.id] = cls._parse_cell(cell_val, field.type)
record = SmartRecord(table_id=table_id, values=values)
batch.append(record)
success += 1
if len(batch) >= cls.BATCH_SIZE:
db.add_all(batch)
await db.commit()
batch = []
except Exception as e:
errors.append(f"{row_idx + 2}行: {str(e)}")
if batch:
db.add_all(batch)
await db.commit()
return {
"success": success,
"fail": len(errors),
"errors": errors[:50],
"new_fields": new_fields_created,
}
@classmethod
def _parse_cell(cls, val: str, field_type: str) -> Any:
if not val:
return None
if field_type == "number":
try:
return float(val) if "." in val else int(val)
except ValueError:
return val
if field_type == "checkbox":
return val.lower() in ("true", "1", "yes", "")
if field_type == "rating":
try:
return int(float(val))
except ValueError:
return 0
return val
# ==================== Link / Lookup / Rollup ====================
class SmartLinkService:
"""管理 Link / Lookup / Rollup 字段的创建、删除、关联值解析"""
# ---------- Link 字段创建 / 删除 ----------
@classmethod
async def create_link_field(
cls,
db: AsyncSession,
table_id: str,
field_name: str,
target_table_id: str,
sort: int = 0,
) -> Tuple[SmartField, SmartField]:
"""
创建 Link 字段及目标表的对称 Link 字段。
返回 (source_field, symmetric_field)。
"""
source_table = await SmartTableService.get_by_id(db, table_id)
target_table = await SmartTableService.get_by_id(db, target_table_id)
if not source_table or not target_table:
raise ValueError("源表或目标表不存在")
source_field_data = SmartFieldCreate(
table_id=table_id,
name=field_name,
type="link",
sort=sort or await SmartFieldService.get_next_sort(db, table_id),
config={
"linkedTableId": target_table_id,
},
)
source_field = await SmartFieldService.create(db, source_field_data)
sym_field_data = SmartFieldCreate(
table_id=target_table_id,
name=source_table.name,
type="link",
sort=await SmartFieldService.get_next_sort(db, target_table_id),
config={
"linkedTableId": table_id,
"symmetricFieldId": source_field.id,
},
)
sym_field = await SmartFieldService.create(db, sym_field_data)
source_field.config = {
**(source_field.config or {}),
"symmetricFieldId": sym_field.id,
}
await db.commit()
await db.refresh(source_field)
return source_field, sym_field
@classmethod
async def delete_link_field(cls, db: AsyncSession, field_id: str) -> bool:
"""删除 Link 字段、对称字段和所有关联记录"""
field = await SmartFieldService.get_by_id(db, field_id)
if not field or field.type != "link":
return False
sym_field_id = (field.config or {}).get("symmetricFieldId")
await db.execute(
sa_update(SmartTableLink)
.where(SmartTableLink.field_id == field_id)
.values(is_deleted=True)
)
if sym_field_id:
await db.execute(
sa_update(SmartTableLink)
.where(SmartTableLink.field_id == sym_field_id)
.values(is_deleted=True)
)
sym_field = await SmartFieldService.get_by_id(db, sym_field_id)
if sym_field:
sym_field.is_deleted = True
field.is_deleted = True
await db.commit()
return True
# ---------- 关联记录操作 ----------
@classmethod
async def set_linked_records(
cls,
db: AsyncSession,
field_id: str,
source_record_id: str,
target_record_ids: List[str],
) -> None:
"""
全量设置关联:先移除旧关联,再插入新关联。
同时维护对称方向。
"""
field = await SmartFieldService.get_by_id(db, field_id)
if not field or field.type != "link":
return
sym_field_id = (field.config or {}).get("symmetricFieldId")
existing = await db.execute(
select(SmartTableLink).where(
SmartTableLink.field_id == field_id,
SmartTableLink.source_record_id == source_record_id,
SmartTableLink.is_deleted == False, # noqa: E712
)
)
existing_links = list(existing.scalars().all())
existing_target_ids = {l.target_record_id for l in existing_links}
to_add = set(target_record_ids) - existing_target_ids
to_remove = existing_target_ids - set(target_record_ids)
for link in existing_links:
if link.target_record_id in to_remove:
link.is_deleted = True
if sym_field_id and to_remove:
sym_result = await db.execute(
select(SmartTableLink).where(
SmartTableLink.field_id == sym_field_id,
SmartTableLink.target_record_id == source_record_id,
SmartTableLink.source_record_id.in_(to_remove),
SmartTableLink.is_deleted == False, # noqa: E712
)
)
for sym_link in sym_result.scalars().all():
sym_link.is_deleted = True
for tid in to_add:
db.add(SmartTableLink(
field_id=field_id,
source_record_id=source_record_id,
target_record_id=tid,
))
if sym_field_id:
db.add(SmartTableLink(
field_id=sym_field_id,
source_record_id=tid,
target_record_id=source_record_id,
))
await db.commit()
@classmethod
async def get_linked_record_ids(
cls, db: AsyncSession, field_id: str, source_record_id: str
) -> List[str]:
result = await db.execute(
select(SmartTableLink.target_record_id).where(
SmartTableLink.field_id == field_id,
SmartTableLink.source_record_id == source_record_id,
SmartTableLink.is_deleted == False, # noqa: E712
)
)
return list(result.scalars().all())
# ---------- 解析关联值(批量注入到 records ----------
@classmethod
async def resolve_link_values(
cls,
db: AsyncSession,
fields: List[SmartField],
records: List[SmartRecord],
) -> None:
"""批量解析 Link 字段值,注入 [{id, title}] 到 record.values"""
link_fields = [f for f in fields if f.type == "link"]
if not link_fields or not records:
return
record_ids = [r.id for r in records]
for lf in link_fields:
result = await db.execute(
select(SmartTableLink.source_record_id, SmartTableLink.target_record_id)
.where(
SmartTableLink.field_id == lf.id,
SmartTableLink.source_record_id.in_(record_ids),
SmartTableLink.is_deleted == False, # noqa: E712
)
)
links = result.all()
target_ids = list({row[1] for row in links})
title_map: Dict[str, str] = {}
if target_ids:
target_table_id = (lf.config or {}).get("linkedTableId")
if target_table_id:
title_map = await cls._get_record_titles(db, target_table_id, target_ids)
src_map: Dict[str, List[LinkedRecordItem]] = {}
for src_id, tgt_id in links:
src_map.setdefault(src_id, []).append(
LinkedRecordItem(id=tgt_id, title=title_map.get(tgt_id, ""))
)
for r in records:
vals = dict(r.values) if r.values else {}
items = src_map.get(r.id, [])
vals[lf.id] = [item.model_dump() for item in items]
r.values = vals
@classmethod
async def resolve_lookup_values(
cls,
db: AsyncSession,
fields: List[SmartField],
records: List[SmartRecord],
) -> None:
"""解析 Lookup 字段值:通过 linkFieldId 找到关联记录,取 lookupFieldId 的值"""
lookup_fields = [f for f in fields if f.type == "lookup"]
if not lookup_fields or not records:
return
record_ids = [r.id for r in records]
for lkf in lookup_fields:
cfg = lkf.config or {}
link_field_id = cfg.get("linkFieldId")
lookup_field_id = cfg.get("lookupFieldId")
if not link_field_id or not lookup_field_id:
continue
result = await db.execute(
select(SmartTableLink.source_record_id, SmartTableLink.target_record_id)
.where(
SmartTableLink.field_id == link_field_id,
SmartTableLink.source_record_id.in_(record_ids),
SmartTableLink.is_deleted == False, # noqa: E712
)
)
links = result.all()
target_ids = list({row[1] for row in links})
target_values: Dict[str, Any] = {}
if target_ids:
tgt_result = await db.execute(
select(SmartRecord.id, SmartRecord.values).where(
SmartRecord.id.in_(target_ids),
SmartRecord.is_deleted == False, # noqa: E712
)
)
for rid, vals in tgt_result.all():
target_values[rid] = (vals or {}).get(lookup_field_id)
src_map: Dict[str, list] = {}
for src_id, tgt_id in links:
v = target_values.get(tgt_id)
if v is not None:
src_map.setdefault(src_id, []).append(v)
for r in records:
vals = dict(r.values) if r.values else {}
vals[lkf.id] = src_map.get(r.id, [])
r.values = vals
@classmethod
async def resolve_rollup_values(
cls,
db: AsyncSession,
fields: List[SmartField],
records: List[SmartRecord],
) -> None:
"""解析 Rollup 字段值:聚合关联记录的某个字段"""
rollup_fields = [f for f in fields if f.type == "rollup"]
if not rollup_fields or not records:
return
record_ids = [r.id for r in records]
for rf in rollup_fields:
cfg = rf.config or {}
link_field_id = cfg.get("linkFieldId")
rollup_field_id = cfg.get("rollupFieldId")
aggregation = cfg.get("aggregation", "COUNT")
if not link_field_id or not rollup_field_id:
continue
result = await db.execute(
select(SmartTableLink.source_record_id, SmartTableLink.target_record_id)
.where(
SmartTableLink.field_id == link_field_id,
SmartTableLink.source_record_id.in_(record_ids),
SmartTableLink.is_deleted == False, # noqa: E712
)
)
links = result.all()
target_ids = list({row[1] for row in links})
target_values: Dict[str, Any] = {}
if target_ids:
tgt_result = await db.execute(
select(SmartRecord.id, SmartRecord.values).where(
SmartRecord.id.in_(target_ids),
SmartRecord.is_deleted == False, # noqa: E712
)
)
for rid, vals in tgt_result.all():
target_values[rid] = (vals or {}).get(rollup_field_id)
src_groups: Dict[str, list] = {}
for src_id, tgt_id in links:
v = target_values.get(tgt_id)
src_groups.setdefault(src_id, []).append(v)
for r in records:
vals = dict(r.values) if r.values else {}
group = src_groups.get(r.id, [])
vals[rf.id] = cls._aggregate(group, aggregation)
r.values = vals
@classmethod
def _aggregate(cls, values: List[Any], aggregation: str) -> Any:
agg = aggregation.upper()
if agg == "COUNT":
return len(values)
if agg == "COUNTA":
return len([v for v in values if v is not None and v != ""])
nums = []
for v in values:
if v is None:
continue
try:
nums.append(float(v))
except (TypeError, ValueError):
pass
if agg == "SUM":
return sum(nums) if nums else 0
if agg == "AVG":
return sum(nums) / len(nums) if nums else None
if agg == "MIN":
return min(nums) if nums else None
if agg == "MAX":
return max(nums) if nums else None
return len(values)
# ---------- 记录删除时清理关联 ----------
@classmethod
async def cleanup_record_links(cls, db: AsyncSession, record_id: str) -> None:
"""删除记录时,软删除所有涉及该记录的关联"""
await db.execute(
sa_update(SmartTableLink)
.where(
or_(
SmartTableLink.source_record_id == record_id,
SmartTableLink.target_record_id == record_id,
),
SmartTableLink.is_deleted == False, # noqa: E712
)
.values(is_deleted=True)
)
# ---------- 搜索目标表记录 ----------
@classmethod
async def search_records(
cls,
db: AsyncSession,
table_id: str,
keyword: str = "",
limit: int = 20,
) -> List[Dict[str, Any]]:
"""搜索指定表的记录,返回 [{id, title}]"""
fields = await SmartFieldService.get_by_table(db, table_id)
primary_field = fields[0] if fields else None
base = select(SmartRecord).where(
SmartRecord.table_id == table_id,
SmartRecord.is_deleted == False, # noqa: E712
)
if keyword.strip() and primary_field:
col = json_extract(SmartRecord.values, primary_field.id)
base = base.where(col.ilike(f"%{keyword.strip()}%"))
base = base.order_by(SmartRecord.sort, SmartRecord.id).limit(limit)
result = await db.execute(base)
rows = list(result.scalars().all())
items = []
for r in rows:
title = ""
if primary_field:
title = str((r.values or {}).get(primary_field.id, "") or "")
items.append({"id": r.id, "title": title})
return items
# ---------- 内部工具 ----------
@classmethod
async def _get_record_titles(
cls, db: AsyncSession, table_id: str, record_ids: List[str]
) -> Dict[str, str]:
"""批量获取记录的标题(取第一个字段的值)"""
fields = await SmartFieldService.get_by_table(db, table_id)
primary_field = fields[0] if fields else None
if not primary_field:
return {}
result = await db.execute(
select(SmartRecord.id, SmartRecord.values).where(
SmartRecord.id.in_(record_ids),
SmartRecord.is_deleted == False, # noqa: E712
)
)
title_map: Dict[str, str] = {}
for rid, vals in result.all():
title_map[rid] = str((vals or {}).get(primary_field.id, "") or "")
return title_map
class SmartSummaryService:
"""字段汇总聚合计算 - 合并多字段聚合为尽量少的SQL"""
SUPPORTED = {"SUM", "AVG", "MIN", "MAX", "COUNT", "COUNTA", "COUNT_EMPTY", "PERCENT_EMPTY", "PERCENT_FILLED"}
@classmethod
async def compute(
cls,
db: AsyncSession,
table_id: str,
field_agg_map: Dict[str, str],
filters: Optional[List[RecordFilterRule]] = None,
filter_logic: str = "and",
search: Optional[str] = None,
extra_conditions: Optional[list] = None,
) -> Tuple[Dict[str, Any], int]:
"""
对指定字段执行聚合计算,将同类聚合合并为单条SQL减少查询次数。
field_agg_map: {fieldId: aggregation_type}
返回 ({fieldId: result_value}, total_count)
"""
base = select(SmartRecord).where(
SmartRecord.table_id == table_id,
SmartRecord.is_deleted == False, # noqa: E712
)
if filters:
filter_conds = [SmartRecordService._build_filter_condition(f) for f in filters]
if filter_logic == "or":
base = base.where(or_(*filter_conds))
else:
base = base.where(and_(*filter_conds))
if search and search.strip():
search_cond = SmartRecordService._build_search_conditions(search.strip())
if search_cond is not None:
base = base.where(search_cond)
if extra_conditions:
for cond in extra_conditions:
base = base.where(cond)
subq = base.subquery()
count_result = await db.execute(select(func.count()).select_from(subq))
total = count_result.scalar() or 0
if total == 0:
return {fid: None for fid in field_agg_map}, 0
summaries: Dict[str, Any] = {}
db_type = get_db_type()
numeric_fields: Dict[str, str] = {}
count_needed_fields: Dict[str, str] = {}
for field_id, agg_type in field_agg_map.items():
agg_upper = agg_type.upper()
if agg_upper not in cls.SUPPORTED:
summaries[field_id] = None
elif agg_upper == "COUNT":
summaries[field_id] = total
elif agg_upper in ("SUM", "AVG", "MIN", "MAX"):
numeric_fields[field_id] = agg_upper
else:
count_needed_fields[field_id] = agg_upper
alias = subq.alias("sub")
agg_fn_map = {"SUM": func.sum, "AVG": func.avg, "MIN": func.min, "MAX": func.max}
all_columns = []
all_labels: List[Tuple[str, str, str]] = []
for field_id, agg_type in numeric_fields.items():
if db_type == "postgresql":
num_expr = text(f"CAST(sub.\"values\" ->> '{field_id}' AS NUMERIC)")
cond = text(f"(sub.\"values\" ->> '{field_id}') ~ '^-?[0-9]+(\\.[0-9]+)?$'")
else:
num_expr = text(
f"CAST(JSON_UNQUOTE(JSON_EXTRACT(sub.`values`, '$.{field_id}')) AS DECIMAL(20,6))"
)
cond = text(
f"JSON_UNQUOTE(JSON_EXTRACT(sub.`values`, '$.{field_id}')) REGEXP '^-?[0-9]+(\\\\.[0-9]+)?$'"
)
agg_fn = agg_fn_map[agg_type]
label = f"num_{field_id}"
all_columns.append(agg_fn(case((cond, num_expr), else_=None)).label(label))
all_labels.append((field_id, label, "numeric"))
unique_count_fields = set(count_needed_fields.keys())
for field_id in unique_count_fields:
if db_type == "postgresql":
not_null_cond = text(
f"sub.\"values\" ->> '{field_id}' IS NOT NULL "
f"AND sub.\"values\" ->> '{field_id}' != ''"
)
else:
not_null_cond = text(
f"JSON_EXTRACT(sub.`values`, '$.{field_id}') IS NOT NULL "
f"AND JSON_UNQUOTE(JSON_EXTRACT(sub.`values`, '$.{field_id}')) != ''"
)
label = f"cnt_{field_id}"
all_columns.append(func.count(case((not_null_cond, 1), else_=None)).label(label))
all_labels.append((field_id, label, "count"))
if all_columns:
stmt = select(*all_columns).select_from(alias)
row = (await db.execute(stmt)).first()
if row:
for field_id, label, col_type in all_labels:
val = getattr(row, label, None)
if col_type == "numeric":
summaries[field_id] = round(float(val), 6) if val is not None else None
else:
non_empty = val or 0
agg_type = count_needed_fields[field_id]
if agg_type == "COUNTA":
summaries[field_id] = non_empty
elif agg_type == "COUNT_EMPTY":
summaries[field_id] = total - non_empty
elif agg_type == "PERCENT_EMPTY":
summaries[field_id] = round((total - non_empty) / total * 100, 1) if total > 0 else 0
elif agg_type == "PERCENT_FILLED":
summaries[field_id] = round(non_empty / total * 100, 1) if total > 0 else 0
return summaries, total
class SmartFormulaService:
"""公式字段计算服务:在返回记录时动态计算公式字段的值"""
@classmethod
def resolve_formula_values(
cls, fields: List[SmartField], records: list
) -> None:
"""遍历公式字段,对每条记录计算公式值并注入 record.values(同步,不需要 DB"""
from zq_smart_table.formula import compute_formula
formula_fields = [
f for f in fields
if f.type == "formula" and f.config and f.config.get("formula")
]
if not formula_fields:
return
field_name_map = {f.name: f.id for f in fields}
for record in records:
values = record.values if record.values else {}
for ff in formula_fields:
formula_str = ff.config["formula"]
result = compute_formula(formula_str, values, field_name_map)
result_type = ff.config.get("formulaResultType", "text")
values[ff.id] = cls._cast_result(result, result_type)
record.values = values
@staticmethod
def _cast_result(value: Any, result_type: str) -> Any:
if value == "#ERROR":
return value
if result_type == "number":
try:
return float(value) if value is not None else None
except (ValueError, TypeError):
return "#ERROR"
if result_type == "boolean":
if isinstance(value, bool):
return value
return bool(value) if value is not None else False
if result_type == "date":
return str(value) if value is not None else None
return str(value) if value is not None else ""
class SmartValidationService:
"""字段数据校验服务"""
@classmethod
async def validate_cell(
cls,
db: AsyncSession,
field: SmartField,
value: Any,
record_id: Optional[str] = None,
) -> Optional[str]:
"""
校验单个单元格值,返回错误消息或 None(通过)。
校验规则存储在 field.config["validation"] 中。
"""
import re as _re
config = field.config or {}
validation = config.get("validation")
if not validation:
return None
if field.required and (value is None or value == "" or value == []):
custom = validation.get("message")
return custom or f"{field.name}」不能为空"
if value is None or value == "" or value == []:
return None
if "min" in validation and validation["min"] is not None:
try:
num_val = float(value)
if num_val < float(validation["min"]):
return validation.get("message") or f"{field.name}」不能小于 {validation['min']}"
except (ValueError, TypeError):
pass
if "max" in validation and validation["max"] is not None:
try:
num_val = float(value)
if num_val > float(validation["max"]):
return validation.get("message") or f"{field.name}」不能大于 {validation['max']}"
except (ValueError, TypeError):
pass
if "minLength" in validation and validation["minLength"] is not None:
str_val = str(value)
if len(str_val) < int(validation["minLength"]):
return validation.get("message") or f"{field.name}」长度不能少于 {validation['minLength']} 个字符"
if "maxLength" in validation and validation["maxLength"] is not None:
str_val = str(value)
if len(str_val) > int(validation["maxLength"]):
return validation.get("message") or f"{field.name}」长度不能超过 {validation['maxLength']} 个字符"
if "pattern" in validation and validation["pattern"]:
str_val = str(value)
try:
if not _re.fullmatch(validation["pattern"], str_val):
return validation.get("message") or f"{field.name}」格式不正确"
except _re.error:
pass
if validation.get("unique"):
is_dup = await cls._check_unique(db, field, value, record_id)
if is_dup:
return validation.get("message") or f"{field.name}」的值已存在,不能重复"
return None
@classmethod
async def _check_unique(
cls,
db: AsyncSession,
field: SmartField,
value: Any,
exclude_record_id: Optional[str] = None,
) -> bool:
db_type = get_db_type()
str_val = str(value)
if db_type == "postgresql":
cond = text(f"\"values\" ->> '{field.id}' = :v")
else:
cond = text(f"JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.{field.id}')) = :v")
stmt = (
select(func.count())
.select_from(SmartRecord.__table__)
.where(
SmartRecord.table_id == field.table_id,
SmartRecord.is_deleted == False, # noqa: E712
cond.bindparams(v=str_val),
)
)
if exclude_record_id:
stmt = stmt.where(SmartRecord.id != exclude_record_id)
result = await db.execute(stmt)
return (result.scalar() or 0) > 0
@classmethod
async def validate_cells_batch(
cls,
db: AsyncSession,
table_id: str,
cells: Dict[str, Any],
record_id: Optional[str] = None,
) -> Dict[str, str]:
"""批量校验多个单元格,返回 {fieldId: errorMessage}"""
fields = await SmartFieldService.get_by_table(db, table_id)
field_map = {f.id: f for f in fields}
errors: Dict[str, str] = {}
for field_id, value in cells.items():
field = field_map.get(field_id)
if not field:
continue
err = await cls.validate_cell(db, field, value, record_id)
if err:
errors[field_id] = err
return errors
class SmartCommentService:
"""记录评论服务"""
@classmethod
async def get_by_record(cls, db: AsyncSession, record_id: str) -> List[SmartTableComment]:
result = await db.execute(
select(SmartTableComment)
.where(SmartTableComment.record_id == record_id, SmartTableComment.is_deleted == False) # noqa: E712
.order_by(SmartTableComment.sys_create_datetime.asc())
)
return list(result.scalars().all())
@classmethod
async def create(cls, db: AsyncSession, record_id: str, user_id: str, content: str,
mentions: List[str] = None, parent_id: str = None) -> SmartTableComment:
comment = SmartTableComment(
record_id=record_id,
user_id=user_id,
content=content,
mentions=mentions or [],
parent_id=parent_id,
sys_creator_id=user_id,
)
db.add(comment)
await db.commit()
await db.refresh(comment)
return comment
@classmethod
async def update(cls, db: AsyncSession, comment_id: str, user_id: str,
content: str, mentions: List[str] = None) -> Optional[SmartTableComment]:
result = await db.execute(
select(SmartTableComment)
.where(SmartTableComment.id == comment_id, SmartTableComment.is_deleted == False) # noqa: E712
)
comment = result.scalar_one_or_none()
if not comment or comment.user_id != user_id:
return None
comment.content = content
comment.mentions = mentions or []
comment.sys_modifier_id = user_id
await db.commit()
await db.refresh(comment)
return comment
@classmethod
async def delete(cls, db: AsyncSession, comment_id: str, user_id: str) -> bool:
result = await db.execute(
select(SmartTableComment)
.where(SmartTableComment.id == comment_id, SmartTableComment.is_deleted == False) # noqa: E712
)
comment = result.scalar_one_or_none()
if not comment or comment.user_id != user_id:
return False
comment.is_deleted = True
comment.sys_modifier_id = user_id
await db.commit()
return True
class SmartDocumentVersionService:
"""文档版本历史服务"""
MAX_VERSIONS_PER_DOC = 100
@classmethod
async def get_next_version(cls, db: AsyncSession, document_id: str) -> int:
result = await db.execute(
select(func.coalesce(func.max(SmartDocumentVersion.version), 0))
.where(
SmartDocumentVersion.document_id == document_id,
SmartDocumentVersion.is_deleted == False, # noqa: E712
)
)
return (result.scalar() or 0) + 1
@classmethod
async def create_version(
cls,
db: AsyncSession,
document_id: str,
content: Dict[str, Any],
title: Optional[str] = None,
change_summary: Optional[str] = None,
user_id: Optional[str] = None,
) -> SmartDocumentVersion:
import json
version_num = await cls.get_next_version(db, document_id)
content_size = len(json.dumps(content, ensure_ascii=False))
version = SmartDocumentVersion(
document_id=document_id,
version=version_num,
content=content,
title=title,
change_summary=change_summary,
content_size=content_size,
sys_creator_id=user_id,
)
db.add(version)
await db.commit()
await db.refresh(version)
await cls._cleanup_old_versions(db, document_id)
return version
@classmethod
async def _cleanup_old_versions(cls, db: AsyncSession, document_id: str) -> None:
"""保留最近 MAX_VERSIONS_PER_DOC 个版本,软删除更早的"""
result = await db.execute(
select(SmartDocumentVersion.id)
.where(
SmartDocumentVersion.document_id == document_id,
SmartDocumentVersion.is_deleted == False, # noqa: E712
)
.order_by(SmartDocumentVersion.version.desc())
.offset(cls.MAX_VERSIONS_PER_DOC)
)
old_ids = [row[0] for row in result.all()]
if old_ids:
await db.execute(
sa_update(SmartDocumentVersion)
.where(SmartDocumentVersion.id.in_(old_ids))
.values(is_deleted=True)
)
await db.commit()
@classmethod
async def get_versions(
cls,
db: AsyncSession,
document_id: str,
page: int = 1,
page_size: int = 20,
) -> Tuple[List[SmartDocumentVersion], int]:
base = (
select(SmartDocumentVersion)
.where(
SmartDocumentVersion.document_id == document_id,
SmartDocumentVersion.is_deleted == False, # noqa: E712
)
)
count_result = await db.execute(
select(func.count()).select_from(base.subquery())
)
total = count_result.scalar() or 0
result = await db.execute(
base.order_by(SmartDocumentVersion.version.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
@classmethod
async def get_version_detail(
cls, db: AsyncSession, version_id: str
) -> Optional[SmartDocumentVersion]:
result = await db.execute(
select(SmartDocumentVersion)
.where(
SmartDocumentVersion.id == version_id,
SmartDocumentVersion.is_deleted == False, # noqa: E712
)
)
return result.scalar_one_or_none()
@classmethod
async def get_version_by_number(
cls, db: AsyncSession, document_id: str, version: int
) -> Optional[SmartDocumentVersion]:
result = await db.execute(
select(SmartDocumentVersion)
.where(
SmartDocumentVersion.document_id == document_id,
SmartDocumentVersion.version == version,
SmartDocumentVersion.is_deleted == False, # noqa: E712
)
)
return result.scalar_one_or_none()
@classmethod
async def restore_version(
cls, db: AsyncSession, document_id: str, version_id: str, user_id: Optional[str] = None
) -> Optional[SmartDocumentVersion]:
"""恢复到指定版本:先快照当前内容,再将目标版本内容写回文档"""
version = await cls.get_version_detail(db, version_id)
if not version or version.document_id != document_id:
return None
table = await db.execute(
select(SmartTable)
.where(SmartTable.id == document_id, SmartTable.is_deleted == False) # noqa: E712
)
doc = table.scalar_one_or_none()
if not doc:
return None
if doc.content:
await cls.create_version(
db, document_id, doc.content,
title=doc.name,
change_summary=f"恢复版本 {version.version} 前的自动快照",
user_id=user_id,
)
doc.content = version.content
doc.sys_modifier_id = user_id
await db.commit()
new_version = await cls.create_version(
db, document_id, version.content,
title=doc.name,
change_summary=f"恢复至版本 {version.version}",
user_id=user_id,
)
return new_version
@classmethod
async def delete_version(cls, db: AsyncSession, version_id: str) -> bool:
result = await db.execute(
select(SmartDocumentVersion)
.where(
SmartDocumentVersion.id == version_id,
SmartDocumentVersion.is_deleted == False, # noqa: E712
)
)
version = result.scalar_one_or_none()
if not version:
return False
version.is_deleted = True
await db.commit()
return True
class SmartDocumentTemplateService:
"""文档模板服务"""
@classmethod
async def get_list(
cls,
db: AsyncSession,
category: Optional[str] = None,
keyword: Optional[str] = None,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[SmartDocumentTemplate], int]:
base = select(SmartDocumentTemplate).where(
SmartDocumentTemplate.is_deleted == False # noqa: E712
)
if category:
base = base.where(SmartDocumentTemplate.category == category)
if keyword:
base = base.where(
or_(
SmartDocumentTemplate.name.ilike(f"%{keyword}%"),
SmartDocumentTemplate.description.ilike(f"%{keyword}%"),
)
)
count_result = await db.execute(
select(func.count()).select_from(base.subquery())
)
total = count_result.scalar() or 0
result = await db.execute(
base.order_by(
SmartDocumentTemplate.is_system.desc(),
SmartDocumentTemplate.use_count.desc(),
SmartDocumentTemplate.sys_create_datetime.desc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
@classmethod
async def get_by_id(
cls, db: AsyncSession, template_id: str
) -> Optional[SmartDocumentTemplate]:
result = await db.execute(
select(SmartDocumentTemplate)
.where(
SmartDocumentTemplate.id == template_id,
SmartDocumentTemplate.is_deleted == False, # noqa: E712
)
)
return result.scalar_one_or_none()
@classmethod
async def create(
cls,
db: AsyncSession,
name: str,
content: Dict[str, Any],
description: Optional[str] = None,
icon: str = "FileText",
category: str = "custom",
preview_image: Optional[str] = None,
user_id: Optional[str] = None,
) -> SmartDocumentTemplate:
template = SmartDocumentTemplate(
name=name,
description=description,
icon=icon,
category=category,
content=content,
preview_image=preview_image,
is_system=False,
sys_creator_id=user_id,
)
db.add(template)
await db.commit()
await db.refresh(template)
return template
@classmethod
async def update(
cls,
db: AsyncSession,
template_id: str,
data: Dict[str, Any],
user_id: Optional[str] = None,
) -> Optional[SmartDocumentTemplate]:
template = await cls.get_by_id(db, template_id)
if not template:
return None
for key, value in data.items():
if value is not None and hasattr(template, key):
setattr(template, key, value)
template.sys_modifier_id = user_id
await db.commit()
await db.refresh(template)
return template
@classmethod
async def delete(cls, db: AsyncSession, template_id: str) -> bool:
template = await cls.get_by_id(db, template_id)
if not template:
return False
if template.is_system:
return False
template.is_deleted = True
await db.commit()
return True
@classmethod
async def increment_use_count(cls, db: AsyncSession, template_id: str) -> None:
await db.execute(
sa_update(SmartDocumentTemplate)
.where(SmartDocumentTemplate.id == template_id)
.values(use_count=SmartDocumentTemplate.use_count + 1)
)
await db.commit()
@classmethod
async def get_categories(cls, db: AsyncSession) -> List[str]:
result = await db.execute(
select(SmartDocumentTemplate.category)
.where(SmartDocumentTemplate.is_deleted == False) # noqa: E712
.distinct()
)
return [row[0] for row in result.all()]
@classmethod
async def create_from_document(
cls,
db: AsyncSession,
document_id: str,
name: str,
description: Optional[str] = None,
category: str = "custom",
user_id: Optional[str] = None,
) -> Optional[SmartDocumentTemplate]:
"""从现有文档创建模板"""
doc_result = await db.execute(
select(SmartTable)
.where(SmartTable.id == document_id, SmartTable.is_deleted == False) # noqa: E712
)
doc = doc_result.scalar_one_or_none()
if not doc or getattr(doc, "type", "table") != "document" or not doc.content:
return None
return await cls.create(
db, name=name, content=doc.content,
description=description, category=category, user_id=user_id,
)
class WikiSpaceService(BaseService[SmartWikiSpace, WikiSpaceCreate, WikiSpaceUpdate]):
model = SmartWikiSpace
RESOURCE_TYPE = "wiki_space"
@classmethod
async def get_list_with_count(
cls, db: AsyncSession, page: int = 1, page_size: int = 100,
user_id: Optional[str] = None,
is_superuser: bool = False,
) -> Tuple[List[dict], int]:
"""获取文档库列表,附带每个库的文档数量。非超管只能看到自己创建的或 public/team 的"""
base_query = select(SmartWikiSpace).where(SmartWikiSpace.is_deleted == False) # noqa: E712
if not is_superuser and user_id:
base_query = base_query.where(
or_(
SmartWikiSpace.sys_creator_id == user_id,
SmartWikiSpace.visibility.in_(["public", "team"]),
)
)
elif not is_superuser:
base_query = base_query.where(
SmartWikiSpace.visibility.in_(["public", "team"])
)
count_result = await db.execute(
select(func.count()).select_from(base_query.subquery())
)
total = count_result.scalar() or 0
offset = (page - 1) * page_size
spaces_result = await db.execute(
base_query.order_by(desc(SmartWikiSpace.sort), desc(SmartWikiSpace.sys_create_datetime))
.offset(offset)
.limit(page_size)
)
spaces = list(spaces_result.scalars().all())
result = []
for space in spaces:
count_q = await db.execute(
select(func.count(SmartTable.id))
.where(
SmartTable.wiki_space_id == space.id,
SmartTable.is_deleted == False, # noqa: E712
)
)
doc_count = count_q.scalar() or 0
result.append({
"space": space,
"document_count": doc_count,
})
return result, total
@classmethod
async def get_documents(
cls, db: AsyncSession, space_id: str,
user_id: Optional[str] = None,
dept_id: Optional[str] = None,
role_ids: Optional[List[str]] = None,
is_superuser: bool = False,
) -> List[SmartTable]:
"""获取文档库内的文档(按 sort 排序),非超管只能看到自己有权限的"""
from zq_smart_table.permission.model import SmartTableCollaborator
base_query = (
select(SmartTable)
.where(
SmartTable.wiki_space_id == space_id,
SmartTable.is_deleted == False, # noqa: E712
)
)
if not is_superuser and user_id:
subject_conds = [
and_(
SmartTableCollaborator.subject_type == "user",
SmartTableCollaborator.subject_id == user_id,
)
]
if dept_id:
subject_conds.append(
and_(
SmartTableCollaborator.subject_type == "dept",
SmartTableCollaborator.subject_id == dept_id,
)
)
if role_ids:
for rid in role_ids:
subject_conds.append(
and_(
SmartTableCollaborator.subject_type == "role",
SmartTableCollaborator.subject_id == rid,
)
)
collab_table_ids = (
select(SmartTableCollaborator.table_id)
.where(
SmartTableCollaborator.is_deleted == False, # noqa: E712
or_(*subject_conds),
)
.distinct()
)
base_query = base_query.where(
or_(
SmartTable.sys_creator_id == user_id,
SmartTable.id.in_(collab_table_ids),
)
)
result = await db.execute(
base_query.order_by(SmartTable.sort, SmartTable.sys_create_datetime)
)
return list(result.scalars().all())
@classmethod
async def check_space_access(
cls, space: SmartWikiSpace,
user_id: Optional[str] = None,
is_superuser: bool = False,
) -> bool:
"""检查用户是否有权访问该文档库"""
if is_superuser:
return True
if space.visibility in ("public", "team"):
return True
if user_id and space.sys_creator_id == user_id:
return True
return False
@classmethod
async def add_document(
cls,
db: AsyncSession,
space_id: str,
name: str,
parent_id: Optional[str] = None,
content: Optional[dict] = None,
user_id: Optional[str] = None,
) -> SmartTable:
"""在文档库中创建文档"""
max_sort_q = await db.execute(
select(func.coalesce(func.max(SmartTable.sort), 0))
.where(
SmartTable.wiki_space_id == space_id,
SmartTable.is_deleted == False, # noqa: E712
)
)
next_sort = (max_sort_q.scalar() or 0) + 1
doc = SmartTable(
name=name,
icon="FileText",
type="document",
wiki_space_id=space_id,
parent_id=parent_id,
content=content,
sort=next_sort,
)
if user_id:
doc.sys_creator_id = user_id
doc.sys_modifier_id = user_id
db.add(doc)
await db.commit()
await db.refresh(doc)
return doc
@classmethod
async def delete_with_documents(cls, db: AsyncSession, space_id: str) -> bool:
"""软删除文档库及其所有文档"""
space = await cls.get_by_id(db, space_id)
if not space:
return False
await db.execute(
sa_update(SmartTable)
.where(
SmartTable.wiki_space_id == space_id,
SmartTable.is_deleted == False, # noqa: E712
)
.values(is_deleted=True)
)
await cls.delete(db, space_id)
return True