Pr@main@feat user batch import (#694)

* feat: User Batch Import

* perf: Disable user import option
This commit is contained in:
fit2cloud-chenyw
2025-12-26 17:55:36 +08:00
committed by GitHub
parent 1c06b4bfe5
commit 77f2b4d7c7
8 changed files with 419 additions and 31 deletions
+20 -3
View File
@@ -1,9 +1,9 @@
from collections import defaultdict
from typing import Optional
from fastapi import APIRouter, Path, Query
from pydantic import Field
from fastapi import APIRouter, File, Path, Query, UploadFile
from sqlmodel import SQLModel, or_, select, delete as sqlmodel_delete
from apps.system.crud.user import check_account_exists, check_email_exists, check_email_format, check_pwd_format, get_db_user, single_delete, user_ws_options
from apps.system.crud.user_excel import batchUpload, downTemplate, download_error_file
from apps.system.models.system_model import UserWsModel, WorkspaceModel
from apps.system.models.user import UserModel
from apps.system.schemas.auth import CacheName, CacheNamespace
@@ -21,6 +21,23 @@ from apps.swagger.i18n import PLACEHOLDER_PREFIX
router = APIRouter(tags=["system_user"], prefix="/user")
@router.get("/template", include_in_schema=False)
@require_permissions(permission=SqlbotPermission(role=['admin']))
async def templateExcel(trans: Trans):
return await downTemplate(trans)
@router.post("/batchImport", include_in_schema=False)
@require_permissions(permission=SqlbotPermission(role=['admin']))
async def upload_excel(session: SessionDep, trans: Trans, current_user: CurrentUser, file: UploadFile = File(...)):
return await batchUpload(session, trans, file)
@router.get("/errorRecord/{file_id}", include_in_schema=False)
@require_permissions(permission=SqlbotPermission(role=['admin']))
async def download_error(file_id: str):
return download_error_file(file_id)
@router.get("/info", summary=f"{PLACEHOLDER_PREFIX}system_user_current_user", description=f"{PLACEHOLDER_PREFIX}system_user_current_user_desc")
async def user_info(current_user: CurrentUser) -> UserInfoDTO:
return current_user
@@ -301,4 +318,4 @@ async def statusChange(session: SessionDep, current_user: CurrentUser, trans: Tr
return {"message": "status not supported"}
db_user: UserModel = get_db_user(session=session, user_id=statusDto.id)
db_user.status = status
session.add(db_user)
session.add(db_user)
+340
View File
@@ -0,0 +1,340 @@
import asyncio
from http.client import HTTPException
import io
import sys
import tempfile
import uuid
import atexit
import threading
from fastapi.responses import StreamingResponse, FileResponse
import os
from openai import BaseModel
import pandas as pd
from apps.system.models.user import UserModel
from common.core.deps import SessionDep
class RowValidator:
def __init__(self, success: bool = False, row=list[str], error_info: dict = None):
self.success = success
self.row = row
self.dict_data = {}
self.error_info = error_info or {}
class CellValidator:
def __init__(self, success: bool = False, value: str | int | list = None, message: str = ""):
self.success = success
self.value = value
self.message = message
class UploadResultDTO(BaseModel):
successCount: int
errorCount: int
dataKey: str | None = None
async def downTemplate(trans):
def inner():
data = {
trans('i18n_user.account'): ['sqlbot1', 'sqlbot2'],
trans('i18n_user.name'): ['sqlbot_employee1', 'sqlbot_employee2'],
trans('i18n_user.email'): ['[email protected]', '[email protected]'],
trans('i18n_user.workspace'): [trans('i18n_default_workspace'), trans('i18n_default_workspace')],
trans('i18n_user.role'): [trans('i18n_user.administrator'), trans('i18n_user.ordinary_member')],
trans('i18n_user.status'): [trans('i18n_user.status_enabled'), trans('i18n_user.status_disabled')],
trans('i18n_user.origin'): [trans('i18n_user.local_creation'), trans('i18n_user.local_creation')],
trans('i18n_user.platform_user_id'): [None, None],
}
df = pd.DataFrame(data)
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine='xlsxwriter', engine_kwargs={'options': {'strings_to_numbers': False}}) as writer:
df.to_excel(writer, sheet_name='Sheet1', index=False)
workbook = writer.book
worksheet = writer.sheets['Sheet1']
header_format = workbook.add_format({
'bold': True,
'font_size': 12,
'font_name': '微软雅黑',
'align': 'center',
'valign': 'vcenter',
'border': 0,
'text_wrap': False,
})
for i, col in enumerate(df.columns):
max_length = max(
len(str(col).encode('utf-8')) * 1.1,
(df[col].astype(str)).apply(len).max()
)
worksheet.set_column(i, i, max_length + 12)
worksheet.write(0, i, col, header_format)
worksheet.set_row(0, 30)
for row in range(1, len(df) + 1):
worksheet.set_row(row, 25)
buffer.seek(0)
return io.BytesIO(buffer.getvalue())
result = await asyncio.to_thread(inner)
return StreamingResponse(result, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
async def batchUpload(session: SessionDep, trans, file) -> UploadResultDTO:
ALLOWED_EXTENSIONS = {"xlsx", "xls"}
if not file.filename.lower().endswith(tuple(ALLOWED_EXTENSIONS)):
raise HTTPException(400, "Only support .xlsx/.xls")
# Support FastAPI UploadFile (async read) and file-like objects.
NA_VALUES = ['', 'NA', 'N/A', 'NULL']
df = None
# If file provides an async read (UploadFile), read bytes first
if hasattr(file, 'read') and asyncio.iscoroutinefunction(getattr(file, 'read')):
content = await file.read()
df = pd.read_excel(io.BytesIO(content), sheet_name=0, na_values=NA_VALUES)
else:
# If it's a Starlette UploadFile-like with a .file attribute, use that
if hasattr(file, 'file'):
fobj = file.file
try:
fobj.seek(0)
except Exception:
pass
df = pd.read_excel(fobj, sheet_name=0, na_values=NA_VALUES)
else:
# fallback: assume a path or file-like object
try:
file.seek(0)
except Exception:
pass
df = pd.read_excel(file, sheet_name=0, na_values=NA_VALUES)
head_list = list(df.columns)
i18n_head_list = get_i18n_head_list()
if not validate_head(trans=trans, head_i18n_list=i18n_head_list, head_list=head_list):
raise HTTPException(400, "Excel header validation failed")
success_list = []
error_list = []
for row in df.itertuples():
row_validator = validate_row(trans=trans, head_i18n_list=i18n_head_list, row=row)
if row_validator.success:
success_list.append(row_validator.dict_data)
else:
error_list.append(row_validator)
error_file_id = None
if error_list:
error_file_id = generate_error_file(error_list, head_list)
result = UploadResultDTO(successCount=len(success_list), errorCount=len(error_list), dataKey=error_file_id)
if success_list:
user_po_list = [UserModel.model_validate(row) for row in success_list]
session.add_all(user_po_list)
session.commit()
return result
def get_i18n_head_list():
return [
'i18n_user.account',
'i18n_user.name',
'i18n_user.email',
'i18n_user.workspace',
'i18n_user.role',
'i18n_user.status',
'i18n_user.origin',
'i18n_user.platform_user_id',
]
def validate_head(trans, head_i18n_list: list[str], head_list: list):
if len(head_list) != len(head_i18n_list):
return False
for i in range(len(head_i18n_list)):
if head_list[i] != trans(head_i18n_list[i]):
return False
return True
def validate_row(trans, head_i18n_list: list[str], row):
validator = RowValidator(success=True, row=[], error_info={})
for i in range(len(head_i18n_list)):
col_name = trans(head_i18n_list[i])
row_value = getattr(row, col_name)
validator.row.append(row_value)
_attr_name = f"{head_i18n_list[i].split('.')[-1]}"
_method_name = f"validate_{_attr_name}"
cellValidator = dynamic_call(_method_name, row_value)
if not cellValidator.success:
validator.success = False
validator.error_info[i] = cellValidator.message
else:
validator.dict_data[_attr_name] = cellValidator.value
return validator
def generate_error_file(error_list: list[RowValidator], head_list: list[str]) -> str:
# If no errors, return empty string
if not error_list:
return ""
# Build DataFrame from error rows (only include rows that had errors)
df_rows = [err.row for err in error_list]
df = pd.DataFrame(df_rows, columns=head_list)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx")
tmp_name = tmp.name
tmp.close()
with pd.ExcelWriter(tmp_name, engine='xlsxwriter', engine_kwargs={'options': {'strings_to_numbers': False}}) as writer:
df.to_excel(writer, sheet_name='Errors', index=False)
workbook = writer.book
worksheet = writer.sheets['Errors']
# header format similar to downTemplate
header_format = workbook.add_format({
'bold': True,
'font_size': 12,
'font_name': '微软雅黑',
'align': 'center',
'valign': 'vcenter',
'border': 0,
'text_wrap': False,
})
# apply header format and column widths
for i, col in enumerate(df.columns):
max_length = max(
len(str(col).encode('utf-8')) * 1.1,
(df[col].astype(str)).apply(len).max() if len(df) > 0 else 0
)
worksheet.set_column(i, i, max_length + 12)
worksheet.write(0, i, col, header_format)
worksheet.set_row(0, 30)
for row_idx in range(1, len(df) + 1):
worksheet.set_row(row_idx, 25)
red_format = workbook.add_format({'font_color': 'red'})
# Add comments and set red font for each erroneous cell.
# Note: pandas wrote header at row 0, data starts from row 1 in the sheet.
for sheet_row_idx, err in enumerate(error_list, start=1):
for col_idx, message in err.error_info.items():
if message:
comment_text = str(message)
worksheet.write_comment(sheet_row_idx, col_idx, comment_text)
try:
cell_value = df.iat[sheet_row_idx - 1, col_idx]
except Exception:
cell_value = None
worksheet.write(sheet_row_idx, col_idx, cell_value, red_format)
# register temp file in map and return an opaque file id
file_id = uuid.uuid4().hex
with _TEMP_FILE_LOCK:
_TEMP_FILE_MAP[file_id] = tmp_name
return file_id
def download_error_file(file_id: str) -> FileResponse:
"""Return a FileResponse for the given generated file id.
Look up the actual temp path from the internal map. Only files
created by `generate_error_file` are allowed.
"""
if not file_id:
raise HTTPException(400, "file_id required")
with _TEMP_FILE_LOCK:
file_path = _TEMP_FILE_MAP.get(file_id)
if not file_path:
raise HTTPException(404, "File not found")
# ensure file is inside tempdir
tempdir = tempfile.gettempdir()
try:
common = os.path.commonpath([tempdir, os.path.abspath(file_path)])
except Exception:
raise HTTPException(403, "Unauthorized file access")
if os.path.abspath(common) != os.path.abspath(tempdir):
raise HTTPException(403, "Unauthorized file access")
if not os.path.exists(file_path):
raise HTTPException(404, "File not found")
return FileResponse(
path=file_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=os.path.basename(file_path),
)
def validate_account(value: str) -> CellValidator:
return CellValidator(True, value, None)
def validate_name(value: str) -> CellValidator:
return CellValidator(True, value, None)
def validate_email(value: str) -> CellValidator:
return CellValidator(True, value, None)
def validate_workspace(value: str) -> CellValidator:
return CellValidator(True, value, None)
def validate_role(value: str) -> CellValidator:
return CellValidator(True, value, None)
def validate_status(value: str) -> CellValidator:
if value == '已启用': return CellValidator(True, 1, None)
if value == '已禁用': return CellValidator(True, 0, None)
return CellValidator(False, None, "状态只能是已启用或已禁用")
def validate_origin(value: str) -> CellValidator:
if value == '本地创建': return CellValidator(True, 0, None)
return CellValidator(False, None, "不支持当前来源")
def validate_platform_id(value: str) -> CellValidator:
return CellValidator(True, value, None)
_method_cache = {
'validate_account': validate_account,
'validate_name': validate_name,
'validate_email': validate_email,
'validate_workspace': validate_workspace,
'validate_role': validate_role,
'validate_status': validate_status,
'validate_origin': validate_origin,
'validate_platform_user_id': validate_platform_id,
}
_module = sys.modules[__name__]
def dynamic_call(method_name: str, *args, **kwargs):
if method_name in _method_cache:
return _method_cache[method_name](*args, **kwargs)
if hasattr(_module, method_name):
func = getattr(_module, method_name)
_method_cache[method_name] = func
return func(*args, **kwargs)
raise AttributeError(f"Function '{method_name}' not found")
# Map of file_id -> temp path for generated error files
_TEMP_FILE_MAP: dict[str, str] = {}
_TEMP_FILE_LOCK = threading.Lock()
def _cleanup_temp_files():
with _TEMP_FILE_LOCK:
for fid, path in list(_TEMP_FILE_MAP.items()):
try:
if os.path.exists(path):
os.remove(path)
except Exception:
pass
_TEMP_FILE_MAP.clear()
atexit.register(_cleanup_temp_files)
+12 -1
View File
@@ -20,7 +20,18 @@
"email": "Email",
"password": "Password",
"language_not_support": "The system does not support [{key}] language!",
"ws_miss": "Current user is not in the workspace [{ws}]!"
"ws_miss": "Current user is not in the workspace [{ws}]!",
"name": "Name",
"status": "User Status",
"origin": "User Source",
"workspace": "Workspace",
"role": "Role",
"platform_user_id": "External User Unique Identifier",
"administrator": "Administrator",
"ordinary_member": "Member",
"status_enabled": "Enabled",
"status_disabled": "Disabled",
"local_creation": "Local"
},
"i18n_ws": {
"title": "Workspace"
+12 -1
View File
@@ -20,7 +20,18 @@
"email": "이메일",
"password": "비밀번호",
"language_not_support": "시스템이 [{key}] 언어를 지원하지 않습니다!",
"ws_miss": "현재 사용자가 [{ws}] 작업 공간에 속해 있지 않습니다!"
"ws_miss": "현재 사용자가 [{ws}] 작업 공간에 속해 있지 않습니다!",
"name": "성명",
"status": "사용자 상태",
"origin": "사용자 출처",
"workspace": "작업 공간",
"role": "역할",
"platform_user_id": "외부 사용자 고유 식별자",
"administrator": "관리자",
"ordinary_member": "일반 멤버",
"status_enabled": "활성화됨",
"status_disabled": "비활성화됨",
"local_creation": "로컬 생성"
},
"i18n_ws": {
"title": "작업 공간"
+12 -1
View File
@@ -20,7 +20,18 @@
"email": "邮箱",
"password": "密码",
"language_not_support": "系统不支持[{key}]语言!",
"ws_miss": "当前用户不在工作空间[{ws}]中!"
"ws_miss": "当前用户不在工作空间[{ws}]中!",
"name": "姓名",
"status": "用户状态",
"origin": "用户来源",
"workspace": "工作空间",
"role": "角色",
"platform_user_id": "外部用户唯一标识",
"administrator": "管理员",
"ordinary_member": "普通成员",
"status_enabled": "已启用",
"status_disabled": "已禁用",
"local_creation": "本地创建"
},
"i18n_ws": {
"title": "工作空间"
+1 -1
View File
@@ -1,7 +1,7 @@
import { request } from '@/utils/request'
export const userImportApi = {
downExcelTemplateApi: () => request.post('/user/excelTemplate', {}, { responseType: 'blob' }),
downExcelTemplateApi: () => request.get('/user/template', { responseType: 'blob' }),
importUserApi: (data: any) =>
request.post('/user/batchImport', data, {
headers: {
+5 -5
View File
@@ -23,7 +23,7 @@
</template>
{{ $t('user.filter') }}
</el-button>
<!-- <el-button secondary @click="handleUserImport">
<!-- <el-button secondary @click="handleUserImport">
<template #icon>
<ccmUpload></ccmUpload>
</template>
@@ -371,7 +371,7 @@
</div>
</template>
</el-dialog>
<UserImport ref="userImportRef"></UserImport>
<UserImport ref="userImportRef" @refresh-grid="search"></UserImport>
<drawer-main
ref="drawerMainRef"
:filter-options="filterOption"
@@ -579,9 +579,9 @@ const handleEditPassword = (id: any) => {
})
}
// const handleUserImport = () => {
// userImportRef.value.showDialog()
// }
/* const handleUserImport = () => {
userImportRef.value.showDialog()
} */
const handleConfirmPassword = () => {
passwordRef.value.validate((val: any) => {
+17 -19
View File
@@ -91,8 +91,9 @@ const downExcel = () => {
userImportApi
.downExcelTemplateApi()
.then((res) => {
const blobData = res.data
const blob = new Blob([blobData], { type: 'application/vnd.ms-excel' })
const blob = new Blob([res], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
const link = document.createElement('a')
link.style.display = 'none'
link.href = URL.createObjectURL(blob)
@@ -151,7 +152,7 @@ const sure = () => {
.importUserApi(param)
.then((res) => {
closeLoading()
const data = res.data
const data = res
errorFileKey.value = data.dataKey
closeDialog()
showTips(data.successCount, data.errorCount)
@@ -166,12 +167,13 @@ const downErrorExcel = () => {
userImportApi
.downErrorRecordApi(errorFileKey.value)
.then((res) => {
const blobData = res.data
const blob = new Blob([blobData], { type: 'application/vnd.ms-excel' })
const blob = new Blob([res], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
const link = document.createElement('a')
link.style.display = 'none'
link.href = URL.createObjectURL(blob)
link.download = 'error.xlsx' // 下载的文件名
link.download = 'error.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
@@ -184,20 +186,16 @@ const downErrorExcel = () => {
}
}
const showTips = (successCount: any, errorCount: any) => {
let title = !errorCount
? t('user.data_import_successful')
: successCount
? t('user.data_import_failed')
: t('user.data_import_failed_de')
let title = successCount ? t('user.data_import_completed') : t('user.data_import_failed')
const childrenDomList = [
h('strong', null, title),
h('br', {}, {}),
h('span', null, t('user.imported_1_data', { msg: successCount })),
h('span', null, t('user.imported_100_data', { msg: successCount })),
]
if (errorCount) {
const errorCountDom = h('span', null, t('user.import_1_data', { msg: errorCount }))
const errorCountDom = h('span', null, t('user.failed_100_can', { msg: errorCount }))
const errorDom = h('div', { class: 'error-record-tip flex-align-center' }, [
h('span', null, t('user.can')),
/* h('span', null, t('user.can')), */
h(
ElButton,
{
@@ -225,20 +223,20 @@ const showTips = (successCount: any, errorCount: any) => {
confirmButtonText: t('user.continue_importing'),
})
.then(() => {
clearErrorRecord()
// clearErrorRecord()
showDialog()
emits('refresh-grid')
})
.catch(() => {
clearErrorRecord()
// clearErrorRecord()
toGrid()
})
}
const clearErrorRecord = () => {
/* const clearErrorRecord = () => {
if (errorFileKey.value) {
userImportApi.clearErrorApi(errorFileKey.value)
}
}
} */
const rules = {
file: [
@@ -258,7 +256,7 @@ defineExpose({
<el-dialog
v-model="dialogShow"
:title="t('user.batch_import')"
width="400px"
width="600px"
modal-class="user-import-class"
@before-close="closeDialog"
>