merge
This commit is contained in:
parent
6566549a05
commit
e84d056f7f
Binary file not shown.
|
|
@ -9,6 +9,8 @@ from starlette.responses import RedirectResponse
|
|||
from app.db import crud
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import JobStatus
|
||||
from app.security.audit import log_event
|
||||
from app.security.permissions import button_permission_code, request_has_permission
|
||||
from app.tasks.execute import execute_job
|
||||
|
||||
|
||||
|
|
@ -21,6 +23,9 @@ def _redirect_with_error(referer: str, msg: str) -> RedirectResponse:
|
|||
|
||||
@router.post("/admin/joblogs/{log_id}/retry")
|
||||
def retry_joblog(request: Request, log_id: int):
|
||||
if not request_has_permission(request, button_permission_code("joblog:retry")):
|
||||
referer = request.headers.get("Referer") or str(request.url_for("admin:list", identity="job-log"))
|
||||
return _redirect_with_error(referer, "无权限执行该操作。")
|
||||
session = get_session()
|
||||
try:
|
||||
log = crud.get_job_log(session, log_id)
|
||||
|
|
@ -52,12 +57,22 @@ def retry_joblog(request: Request, log_id: int):
|
|||
)
|
||||
execute_job.delay(snapshot_params=snapshot, log_id=int(new_log.id))
|
||||
url = request.url_for("admin:details", identity="job-log", pk=str(new_log.id))
|
||||
log_event(
|
||||
session,
|
||||
action="admin.action",
|
||||
target=f"joblog:{log_id}:retry",
|
||||
detail={"new_log_id": int(new_log.id)},
|
||||
request=request,
|
||||
)
|
||||
return RedirectResponse(url, status_code=303)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@router.post("/admin/jobs/{job_id}/run")
|
||||
def run_job(request: Request, job_id: str):
|
||||
if not request_has_permission(request, button_permission_code("job:run")):
|
||||
referer = request.headers.get("Referer") or str(request.url_for("admin:list", identity="job"))
|
||||
return _redirect_with_error(referer, "无权限执行该操作。")
|
||||
session = get_session()
|
||||
try:
|
||||
job = crud.get_job(session, job_id)
|
||||
|
|
@ -86,6 +101,13 @@ def run_job(request: Request, job_id: str):
|
|||
)
|
||||
execute_job.delay(job_id=job.id, log_id=int(new_log.id))
|
||||
url = request.url_for("admin:details", identity="job-log", pk=str(new_log.id))
|
||||
log_event(
|
||||
session,
|
||||
action="admin.action",
|
||||
target=f"job:{job_id}:run",
|
||||
detail={"new_log_id": int(new_log.id)},
|
||||
request=request,
|
||||
)
|
||||
return RedirectResponse(url, status_code=303)
|
||||
finally:
|
||||
session.close()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqladmin import ModelView
|
||||
|
||||
from app.security.permissions import request_has_permission, table_permission_code
|
||||
|
||||
|
||||
class SecureModelView(ModelView):
|
||||
def _table_name(self) -> str:
|
||||
return getattr(self.model, "__tablename__", self.identity)
|
||||
|
||||
def _table_permission(self, action: str) -> str:
|
||||
return table_permission_code(self._table_name(), action)
|
||||
|
||||
def is_accessible(self, request) -> bool: # type: ignore[override]
|
||||
return request_has_permission(request, self._table_permission("read"))
|
||||
|
||||
def is_create_allowed(self, request) -> bool: # type: ignore[override]
|
||||
return request_has_permission(request, self._table_permission("write"))
|
||||
|
||||
def is_edit_allowed(self, request) -> bool: # type: ignore[override]
|
||||
return request_has_permission(request, self._table_permission("write"))
|
||||
|
||||
def is_delete_allowed(self, request) -> bool: # type: ignore[override]
|
||||
return request_has_permission(request, self._table_permission("write"))
|
||||
|
||||
def has_action_permission(self, request, action_name: str) -> bool:
|
||||
code = self.get_action_permission_code(action_name)
|
||||
if not code:
|
||||
return True
|
||||
return request_has_permission(request, code)
|
||||
|
||||
def get_action_permission_code(self, action_name: str) -> str | None:
|
||||
return None
|
||||
|
||||
def has_permission_code(self, request, code: str) -> bool:
|
||||
return request_has_permission(request, code)
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if model_view.can_create %}
|
||||
{% if model_view.is_create_allowed(request) %}
|
||||
<div class="ms-3 d-inline-block">
|
||||
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
||||
+ 新建{{ model_view.name }}
|
||||
|
|
@ -53,19 +53,20 @@
|
|||
<div class="card-body border-bottom py-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div class="dropdown col-4">
|
||||
<button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %}
|
||||
<button {% if not model_view.is_delete_allowed(request) and not model_view._custom_actions_in_list %} disabled {% endif %}
|
||||
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
操作
|
||||
</button>
|
||||
{% if model_view.can_delete or model_view._custom_actions_in_list %}
|
||||
{% if model_view.is_delete_allowed(request) or model_view._custom_actions_in_list %}
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
||||
{% if model_view.can_delete %}
|
||||
{% if model_view.is_delete_allowed(request) %}
|
||||
<a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}"
|
||||
data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-delete">删除所选</a>
|
||||
{% endif %}
|
||||
{% for custom_action, label in model_view._custom_actions_in_list.items() %}
|
||||
{% if model_view.has_action_permission(request, custom_action) %}
|
||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||
|
|
@ -77,6 +78,7 @@
|
|||
{{ label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -131,19 +133,19 @@
|
|||
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if model_view.can_view_details %}
|
||||
{% if model_view.is_accessible(request) %}
|
||||
<a href="{{ model_view._build_url_for('admin:details', request, row) }}" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" title="View">
|
||||
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if model_view.can_edit %}
|
||||
{% if model_view.is_edit_allowed(request) %}
|
||||
<a href="{{ model_view._build_url_for('admin:edit', request, row) }}" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" title="Edit">
|
||||
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if model_view.can_delete %}
|
||||
{% if model_view.is_delete_allowed(request) %}
|
||||
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(row) }}"
|
||||
data-url="{{ model_view._url_for_delete(request, row) }}" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-delete" title="Delete">
|
||||
|
|
@ -172,9 +174,11 @@
|
|||
{% endif %}
|
||||
{% endfor %}
|
||||
<td>
|
||||
{% if model_view.has_permission_code(request, "button:job:run") %}
|
||||
<form class="connecthub-run-form" method="post" action="/admin/jobs/{{ get_object_identifier(row) }}/run" onsubmit="return confirm('确认立即执行该任务?');">
|
||||
<button type="submit" class="btn btn-primary btn-sm">立即运行</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
@ -291,12 +295,12 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if model_view.can_delete %}
|
||||
{% if model_view.is_delete_allowed(request) %}
|
||||
{% include 'sqladmin/modals/delete.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% for custom_action in model_view._custom_actions_in_list %}
|
||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||
{% if model_view.has_action_permission(request, custom_action) and custom_action in model_view._custom_actions_confirmation %}
|
||||
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
||||
url=model_view._url_for_action(request, custom_action) %}
|
||||
{% include 'sqladmin/modals/list_action_confirmation.html' %}
|
||||
|
|
|
|||
|
|
@ -66,12 +66,14 @@
|
|||
返回
|
||||
</a>
|
||||
</div>
|
||||
{% if model_view.has_permission_code(request, "button:joblog:retry") %}
|
||||
<div class="col-md-1">
|
||||
<form method="post" action="/admin/joblogs/{{ get_object_identifier(model) }}/retry" style="display:inline;" onsubmit="return confirm('确认重试该任务日志?');">
|
||||
<button type="submit" class="btn btn-warning">重试</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if model_view.can_delete %}
|
||||
{% endif %}
|
||||
{% if model_view.is_delete_allowed(request) %}
|
||||
<div class="col-md-1">
|
||||
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(model) }}"
|
||||
data-url="{{ model_view._url_for_delete(request, model) }}" data-bs-toggle="modal"
|
||||
|
|
@ -80,7 +82,7 @@
|
|||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if model_view.can_edit %}
|
||||
{% if model_view.is_edit_allowed(request) %}
|
||||
<div class="col-md-1">
|
||||
<a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
|
||||
编辑
|
||||
|
|
@ -89,6 +91,7 @@
|
|||
{% endif %}
|
||||
{% for custom_action,label in model_view._custom_actions_in_detail.items() %}
|
||||
<div class="col-md-1">
|
||||
{% if model_view.has_action_permission(request, custom_action) %}
|
||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||
<a href="#" class="btn btn-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||
|
|
@ -100,6 +103,7 @@
|
|||
{{ label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -107,12 +111,12 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if model_view.can_delete %}
|
||||
{% if model_view.is_delete_allowed(request) %}
|
||||
{% include 'sqladmin/modals/delete.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% for custom_action in model_view._custom_actions_in_detail %}
|
||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||
{% if model_view.has_action_permission(request, custom_action) and custom_action in model_view._custom_actions_confirmation %}
|
||||
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
||||
url=model_view._url_for_action(request, custom_action) + '?pks=' + (get_object_identifier(model) | string) %}
|
||||
{% include 'sqladmin/modals/details_action_confirmation.html' %}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if model_view.can_create %}
|
||||
{% if model_view.is_create_allowed(request) %}
|
||||
<div class="ms-3 d-inline-block">
|
||||
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
||||
+ 新建{{ model_view.name }}
|
||||
|
|
@ -58,19 +58,20 @@
|
|||
<div class="card-body border-bottom py-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div class="dropdown col-4">
|
||||
<button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %}
|
||||
<button {% if not model_view.is_delete_allowed(request) and not model_view._custom_actions_in_list %} disabled {% endif %}
|
||||
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
操作
|
||||
</button>
|
||||
{% if model_view.can_delete or model_view._custom_actions_in_list %}
|
||||
{% if model_view.is_delete_allowed(request) or model_view._custom_actions_in_list %}
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
||||
{% if model_view.can_delete %}
|
||||
{% if model_view.is_delete_allowed(request) %}
|
||||
<a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}"
|
||||
data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-delete">删除所选</a>
|
||||
{% endif %}
|
||||
{% for custom_action, label in model_view._custom_actions_in_list.items() %}
|
||||
{% if model_view.has_action_permission(request, custom_action) %}
|
||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||
|
|
@ -82,6 +83,7 @@
|
|||
{{ label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -136,19 +138,19 @@
|
|||
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if model_view.can_view_details %}
|
||||
{% if model_view.is_accessible(request) %}
|
||||
<a href="{{ model_view._build_url_for('admin:details', request, row) }}" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" title="View">
|
||||
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if model_view.can_edit %}
|
||||
{% if model_view.is_edit_allowed(request) %}
|
||||
<a href="{{ model_view._build_url_for('admin:edit', request, row) }}" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" title="Edit">
|
||||
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if model_view.can_delete %}
|
||||
{% if model_view.is_delete_allowed(request) %}
|
||||
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(row) }}"
|
||||
data-url="{{ model_view._url_for_delete(request, row) }}" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-delete" title="Delete">
|
||||
|
|
@ -177,9 +179,11 @@
|
|||
{% endif %}
|
||||
{% endfor %}
|
||||
<td>
|
||||
{% if model_view.has_permission_code(request, "button:joblog:retry") %}
|
||||
<form class="connecthub-retry-form" method="post" action="/admin/joblogs/{{ get_object_identifier(row) }}/retry" onsubmit="return confirm('确认重试该任务日志?');">
|
||||
<button type="submit" class="btn btn-warning btn-sm">重试</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
@ -296,12 +300,12 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if model_view.can_delete %}
|
||||
{% if model_view.is_delete_allowed(request) %}
|
||||
{% include 'sqladmin/modals/delete.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% for custom_action in model_view._custom_actions_in_list %}
|
||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||
{% if model_view.has_action_permission(request, custom_action) and custom_action in model_view._custom_actions_confirmation %}
|
||||
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
||||
url=model_view._url_for_action(request, custom_action) %}
|
||||
{% include 'sqladmin/modals/list_action_confirmation.html' %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.card {
|
||||
max-width: 360px;
|
||||
margin: 120px auto;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="title">系统登录</div>
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
<div class="field">
|
||||
<input type="text" name="username" placeholder="用户名" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<input type="password" name="password" placeholder="密码" required />
|
||||
</div>
|
||||
<button class="btn" type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -8,7 +8,7 @@ from zoneinfo import ZoneInfo
|
|||
|
||||
from croniter import croniter
|
||||
from markupsafe import Markup
|
||||
from sqladmin import ModelView, action
|
||||
from sqladmin import action
|
||||
from sqladmin.filters import OperationColumnFilter
|
||||
from sqladmin.models import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
|
@ -17,9 +17,15 @@ from app.db import crud
|
|||
from app.db.engine import get_session
|
||||
from app.db.models import JobStatus
|
||||
from app.db.models import Job, JobLog
|
||||
from app.db.models import AuditLog, LdapGroup, LdapGroupRole, Permission, Role, User
|
||||
from app.plugins.manager import load_job_class
|
||||
from app.security.fernet import encrypt_json
|
||||
from app.tasks.execute import execute_job
|
||||
from app.admin.secure import SecureModelView
|
||||
from app.security.permissions import button_permission_code
|
||||
from app.security.audit import log_event
|
||||
from app.security.ldap_sync import sync_all_ldap_users
|
||||
from app.security.auth import hash_password
|
||||
|
||||
|
||||
def _maybe_json(value: Any) -> Any:
|
||||
|
|
@ -49,7 +55,7 @@ def _truncate(s: str, n: int = 120) -> str:
|
|||
return (s[: n - 3] + "...") if len(s) > n else s
|
||||
|
||||
|
||||
class JobAdmin(ModelView, model=Job):
|
||||
class JobAdmin(SecureModelView, model=Job):
|
||||
name = "任务"
|
||||
name_plural = "任务"
|
||||
icon = "fa fa-cogs"
|
||||
|
|
@ -117,6 +123,8 @@ class JobAdmin(ModelView, model=Job):
|
|||
add_in_detail=True,
|
||||
)
|
||||
async def run_now(self, request: Request): # type: ignore[override]
|
||||
if not self.has_action_permission(request, "run_now"):
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
pks = request.query_params.get("pks", "").split(",")
|
||||
ids = [p for p in pks if p]
|
||||
if not ids:
|
||||
|
|
@ -156,6 +164,17 @@ class JobAdmin(ModelView, model=Job):
|
|||
session.close()
|
||||
|
||||
if created_log_id is not None:
|
||||
session = get_session()
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
action="admin.action",
|
||||
target=f"job:run_now:{','.join(ids)}",
|
||||
detail={"created_log_id": created_log_id},
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
url = request.url_for("admin:details", identity="job-log", pk=str(created_log_id))
|
||||
return RedirectResponse(url, status_code=303)
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
|
|
@ -167,9 +186,22 @@ class JobAdmin(ModelView, model=Job):
|
|||
add_in_detail=True,
|
||||
)
|
||||
async def view_logs(self, request: Request): # type: ignore[override]
|
||||
if not self.has_action_permission(request, "view_logs"):
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
pks = request.query_params.get("pks", "").split(",")
|
||||
pk = next((p for p in pks if p), "")
|
||||
base = str(request.url_for("admin:list", identity="job-log"))
|
||||
session = get_session()
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
action="admin.action",
|
||||
target=f"job:view_logs:{pk}",
|
||||
detail={},
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
if pk:
|
||||
return RedirectResponse(f"{base}?search={quote_plus(pk)}", status_code=303)
|
||||
return RedirectResponse(base, status_code=303)
|
||||
|
|
@ -182,6 +214,8 @@ class JobAdmin(ModelView, model=Job):
|
|||
add_in_detail=False,
|
||||
)
|
||||
async def disable_job(self, request: Request): # type: ignore[override]
|
||||
if not self.has_action_permission(request, "disable_job"):
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
pks = request.query_params.get("pks", "").split(",")
|
||||
ids = [p for p in pks if p]
|
||||
session = get_session()
|
||||
|
|
@ -195,6 +229,17 @@ class JobAdmin(ModelView, model=Job):
|
|||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
session = get_session()
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
action="admin.action",
|
||||
target=f"job:disable:{','.join(ids)}",
|
||||
detail={},
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
referer = request.headers.get("Referer")
|
||||
return RedirectResponse(referer or request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
|
||||
|
|
@ -206,6 +251,8 @@ class JobAdmin(ModelView, model=Job):
|
|||
add_in_detail=False,
|
||||
)
|
||||
async def clear_job_logs(self, request: Request): # type: ignore[override]
|
||||
if not self.has_action_permission(request, "clear_job_logs"):
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
pks = request.query_params.get("pks", "").split(",")
|
||||
ids = [p for p in pks if p]
|
||||
session = get_session()
|
||||
|
|
@ -214,6 +261,17 @@ class JobAdmin(ModelView, model=Job):
|
|||
crud.delete_job_logs_by_job_id(session, pk)
|
||||
finally:
|
||||
session.close()
|
||||
session = get_session()
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
action="admin.action",
|
||||
target=f"job:clear_logs:{','.join(ids)}",
|
||||
detail={},
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
referer = request.headers.get("Referer")
|
||||
return RedirectResponse(referer or request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
|
||||
|
|
@ -225,6 +283,8 @@ class JobAdmin(ModelView, model=Job):
|
|||
add_in_detail=False,
|
||||
)
|
||||
async def delete_job_with_logs(self, request: Request): # type: ignore[override]
|
||||
if not self.has_action_permission(request, "delete_job_with_logs"):
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
pks = request.query_params.get("pks", "").split(",")
|
||||
ids = [p for p in pks if p]
|
||||
session = get_session()
|
||||
|
|
@ -238,6 +298,17 @@ class JobAdmin(ModelView, model=Job):
|
|||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
session = get_session()
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
action="admin.action",
|
||||
target=f"job:delete_with_logs:{','.join(ids)}",
|
||||
detail={},
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
|
||||
async def on_model_change(self, data: dict, model: Job, is_created: bool, request) -> None: # type: ignore[override]
|
||||
|
|
@ -304,8 +375,18 @@ class JobAdmin(ModelView, model=Job):
|
|||
raise ValueError("secret_cfg must be a JSON object")
|
||||
data["secret_cfg"] = encrypt_json(scfg2)
|
||||
|
||||
def get_action_permission_code(self, action_name: str) -> str | None:
|
||||
mapping = {
|
||||
"run_now": button_permission_code("job:run_now"),
|
||||
"view_logs": button_permission_code("job:view_logs"),
|
||||
"disable_job": button_permission_code("job:disable"),
|
||||
"clear_job_logs": button_permission_code("job:clear_logs"),
|
||||
"delete_job_with_logs": button_permission_code("job:delete_with_logs"),
|
||||
}
|
||||
return mapping.get(action_name)
|
||||
|
||||
class JobLogAdmin(ModelView, model=JobLog):
|
||||
|
||||
class JobLogAdmin(SecureModelView, model=JobLog):
|
||||
name = "任务日志"
|
||||
name_plural = "任务日志"
|
||||
icon = "fa fa-list"
|
||||
|
|
@ -381,3 +462,135 @@ class JobLogAdmin(ModelView, model=JobLog):
|
|||
+ "</pre>"
|
||||
),
|
||||
}
|
||||
|
||||
def get_action_permission_code(self, action_name: str) -> str | None:
|
||||
return button_permission_code(f"joblog:{action_name}")
|
||||
|
||||
|
||||
class UserAdmin(SecureModelView, model=User):
|
||||
name = "用户"
|
||||
name_plural = "用户"
|
||||
icon = "fa fa-user"
|
||||
column_list = [User.id, User.username, User.is_active, User.is_superuser, User.is_ldap, User.last_login_at]
|
||||
column_searchable_list = [User.username]
|
||||
form_excluded_columns = [User.created_at, User.updated_at, User.last_login_at]
|
||||
column_labels = {"password_hash": "密码(明文或哈希)"}
|
||||
|
||||
async def on_model_change(self, data: dict, model: User, is_created: bool, request) -> None: # type: ignore[override]
|
||||
raw = data.get("password_hash")
|
||||
if raw:
|
||||
raw_s = str(raw)
|
||||
if not raw_s.startswith("$2"):
|
||||
data["password_hash"] = hash_password(raw_s)
|
||||
session = get_session()
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
action="permission.change",
|
||||
target=f"user:{model.username}",
|
||||
detail={"created": is_created},
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
class RoleAdmin(SecureModelView, model=Role):
|
||||
name = "角色"
|
||||
name_plural = "角色"
|
||||
icon = "fa fa-users"
|
||||
|
||||
async def on_model_change(self, data: dict, model: Role, is_created: bool, request) -> None: # type: ignore[override]
|
||||
session = get_session()
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
action="permission.change",
|
||||
target=f"role:{model.name}",
|
||||
detail={"created": is_created},
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
class PermissionAdmin(SecureModelView, model=Permission):
|
||||
name = "权限"
|
||||
name_plural = "权限"
|
||||
icon = "fa fa-key"
|
||||
|
||||
async def on_model_change(self, data: dict, model: Permission, is_created: bool, request) -> None: # type: ignore[override]
|
||||
session = get_session()
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
action="permission.change",
|
||||
target=f"permission:{model.code}",
|
||||
detail={"created": is_created},
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
class LdapGroupAdmin(SecureModelView, model=LdapGroup):
|
||||
name = "LDAP组"
|
||||
name_plural = "LDAP组"
|
||||
icon = "fa fa-sitemap"
|
||||
|
||||
@action(
|
||||
name="sync_ldap",
|
||||
label="同步LDAP",
|
||||
confirmation_message="确认从 LDAP 同步组与成员?",
|
||||
add_in_list=True,
|
||||
add_in_detail=False,
|
||||
)
|
||||
async def sync_ldap(self, request: Request): # type: ignore[override]
|
||||
if not self.has_action_permission(request, "sync_ldap"):
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
updated = sync_all_ldap_users()
|
||||
session = get_session()
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
action="ldap.sync.manual",
|
||||
target="ldap",
|
||||
detail={"updated_users": updated},
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
|
||||
|
||||
def get_action_permission_code(self, action_name: str) -> str | None:
|
||||
if action_name == "sync_ldap":
|
||||
return button_permission_code("ldap:sync")
|
||||
return None
|
||||
|
||||
|
||||
class LdapGroupRoleAdmin(SecureModelView, model=LdapGroupRole):
|
||||
name = "LDAP组映射"
|
||||
name_plural = "LDAP组映射"
|
||||
icon = "fa fa-random"
|
||||
|
||||
async def on_model_change(self, data: dict, model: LdapGroupRole, is_created: bool, request) -> None: # type: ignore[override]
|
||||
session = get_session()
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
action="permission.change",
|
||||
target=f"ldap_group_role:{model.id}",
|
||||
detail={"created": is_created},
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
class AuditLogAdmin(SecureModelView, model=AuditLog):
|
||||
name = "审计日志"
|
||||
name_plural = "审计日志"
|
||||
icon = "fa fa-shield"
|
||||
can_create = False
|
||||
can_edit = False
|
||||
can_delete = False
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.security.auth import authenticate
|
||||
from app.security.session import (
|
||||
SESSION_COOKIE_NAME,
|
||||
clear_session_cookie,
|
||||
create_session,
|
||||
delete_session,
|
||||
set_session_cookie,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/admin/templates")
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login_page(request: Request, next: str | None = None): # noqa: A002
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "error": "", "next": next or "/admin"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login_action(request: Request, username: str = Form(...), password: str = Form(...), next: str = Form("/admin")):
|
||||
user_id = authenticate(username, password, request=request)
|
||||
if not user_id:
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "error": "用户名或密码错误", "next": next},
|
||||
status_code=401,
|
||||
)
|
||||
session_id = create_session(user_id, request)
|
||||
response = RedirectResponse(next or "/admin", status_code=303)
|
||||
set_session_cookie(response, session_id)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(request: Request):
|
||||
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if session_id:
|
||||
delete_session(session_id)
|
||||
response = RedirectResponse("/login", status_code=303)
|
||||
clear_session_cookie(response)
|
||||
return response
|
||||
Binary file not shown.
|
|
@ -14,6 +14,28 @@ class Settings(BaseSettings):
|
|||
dev_mode: bool = False
|
||||
log_dir: str | None = "/data/logs"
|
||||
|
||||
# Auth / session
|
||||
auth_enabled: bool = True
|
||||
session_secret: str = "change_me_session_secret"
|
||||
session_expiry_minutes: int = 1440
|
||||
password_hash_algo: str = "bcrypt"
|
||||
|
||||
# Bootstrap admin
|
||||
bootstrap_admin_username: str = "admin"
|
||||
bootstrap_admin_generate: bool = True
|
||||
bootstrap_admin_pass_path: str = "/data/admin.pass"
|
||||
|
||||
# LDAP
|
||||
ldap_uri: str = "ldap://localhost:389"
|
||||
ldap_bind_dn: str = ""
|
||||
ldap_bind_password: str = ""
|
||||
ldap_base_dn: str = ""
|
||||
ldap_user_filter: str = "(uid={username})"
|
||||
ldap_group_filter: str = "(member={user_dn})"
|
||||
ldap_use_starttls: bool = False
|
||||
ldap_verify_tls: bool = True
|
||||
ldap_sync_interval_minutes: int = 5
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -4,7 +4,7 @@ import enum
|
|||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Enum, ForeignKey, Integer, String, Table, Text, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
|
|
@ -12,6 +12,21 @@ class Base(DeclarativeBase):
|
|||
pass
|
||||
|
||||
|
||||
user_roles = Table(
|
||||
"user_roles",
|
||||
Base.metadata,
|
||||
Column("user_id", ForeignKey("users.id"), primary_key=True),
|
||||
Column("role_id", ForeignKey("roles.id"), primary_key=True),
|
||||
)
|
||||
|
||||
role_permissions = Table(
|
||||
"role_permissions",
|
||||
Base.metadata,
|
||||
Column("role_id", ForeignKey("roles.id"), primary_key=True),
|
||||
Column("permission_id", ForeignKey("permissions.id"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
|
|
@ -64,3 +79,80 @@ class JobLog(Base):
|
|||
job: Mapped[Job] = relationship(back_populates="logs")
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_ldap: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
roles: Mapped[list["Role"]] = relationship(secondary=user_roles, back_populates="users")
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
|
||||
users: Mapped[list[User]] = relationship(secondary=user_roles, back_populates="roles")
|
||||
permissions: Mapped[list["Permission"]] = relationship(secondary=role_permissions, back_populates="roles")
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
|
||||
roles: Mapped[list[Role]] = relationship(secondary=role_permissions, back_populates="permissions")
|
||||
|
||||
|
||||
class LdapGroup(Base):
|
||||
__tablename__ = "ldap_groups"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
dn: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
|
||||
|
||||
class LdapGroupRole(Base):
|
||||
__tablename__ = "ldap_group_roles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ldap_group_id: Mapped[int] = mapped_column(ForeignKey("ldap_groups.id"), nullable=False)
|
||||
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
target: Mapped[str] = mapped_column(String, default="", nullable=False)
|
||||
detail: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
ip: Mapped[str] = mapped_column(String, default="", nullable=False)
|
||||
user_agent: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
ip: Mapped[str] = mapped_column(String, default="", nullable=False)
|
||||
user_agent: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from app.integrations.base import BaseClient
|
||||
from app.integrations.didi import DidiClient
|
||||
from app.integrations.ehr import EhrClient
|
||||
from app.integrations.seeyon import SeeyonClient
|
||||
|
||||
__all__ = ["BaseClient", "DidiClient", "SeeyonClient"]
|
||||
__all__ = ["BaseClient", "DidiClient", "SeeyonClient", "EhrClient"]
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,114 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.integrations.base import BaseClient
|
||||
|
||||
|
||||
logger = logging.getLogger("connecthub.integrations.ehr")
|
||||
|
||||
|
||||
class EhrClient(BaseClient):
|
||||
"""
|
||||
北森 EHR OpenAPI Client:
|
||||
- POST /token 获取 access_token(grant_type=client_credentials)
|
||||
- 业务请求自动携带 Authorization: Bearer <token>
|
||||
- 401 自动刷新 token 并重试一次
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str = "https://openapi.italent.cn",
|
||||
secret_params: dict[str, str],
|
||||
grant_type: str = "client_credentials",
|
||||
token_skew_s: int = 30,
|
||||
timeout_s: float = 10.0,
|
||||
retries: int = 2,
|
||||
retry_backoff_s: float = 0.5,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
timeout_s=timeout_s,
|
||||
retries=retries,
|
||||
retry_backoff_s=retry_backoff_s,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
app_key = str((secret_params or {}).get("app_key", "") or "")
|
||||
app_secret = str((secret_params or {}).get("app_secret", "") or "")
|
||||
if not app_key or not app_secret:
|
||||
raise ValueError("secret_params must contain app_key and app_secret")
|
||||
|
||||
self.secret_params = dict(secret_params)
|
||||
self.grant_type = grant_type
|
||||
self.token_skew_s = token_skew_s
|
||||
|
||||
self._access_token: str | None = None
|
||||
self._token_type: str | None = None
|
||||
self._token_expires_at: float | None = None
|
||||
|
||||
def authenticate(self) -> str:
|
||||
body: dict[str, Any] = {
|
||||
"grant_type": self.grant_type,
|
||||
"app_key": self.secret_params["app_key"],
|
||||
"app_secret": self.secret_params["app_secret"],
|
||||
}
|
||||
resp = super().request(
|
||||
"POST",
|
||||
"/token",
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
data = resp.json() if resp.content else {}
|
||||
access_token = str(data.get("access_token", "") or "")
|
||||
token_type = str(data.get("token_type", "") or "Bearer")
|
||||
expires_in = int(data.get("expires_in", 0) or 0)
|
||||
if not access_token:
|
||||
raise RuntimeError("EHR authenticate failed (access_token missing)")
|
||||
|
||||
now = time.time()
|
||||
skew = max(0, int(self.token_skew_s or 0))
|
||||
self._access_token = access_token
|
||||
self._token_type = token_type
|
||||
self._token_expires_at = now + max(0, expires_in - skew)
|
||||
logger.info("EHR access_token acquired (cached) expires_in=%s token_type=%s", expires_in, token_type)
|
||||
return access_token
|
||||
|
||||
def _get_access_token(self) -> str:
|
||||
now = time.time()
|
||||
if self._access_token and self._token_expires_at and now < self._token_expires_at:
|
||||
return self._access_token
|
||||
return self.authenticate()
|
||||
|
||||
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: # type: ignore[override]
|
||||
token = self._get_access_token()
|
||||
token_type = self._token_type or "Bearer"
|
||||
|
||||
headers = dict(kwargs.pop("headers", {}) or {})
|
||||
headers["Authorization"] = f"{token_type} {token}"
|
||||
|
||||
try:
|
||||
return super().request(method, path, headers=headers, **kwargs)
|
||||
except httpx.HTTPStatusError as e:
|
||||
resp = e.response
|
||||
if resp.status_code != 401:
|
||||
raise
|
||||
|
||||
logger.info("EHR access_token invalid (401), refreshing and retrying once")
|
||||
self._access_token = None
|
||||
self._token_type = None
|
||||
self._token_expires_at = None
|
||||
|
||||
token2 = self._get_access_token()
|
||||
token_type2 = self._token_type or "Bearer"
|
||||
headers["Authorization"] = f"{token_type2} {token2}"
|
||||
return super().request(method, path, headers=headers, **kwargs)
|
||||
|
||||
def request_authed(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
||||
return self.request(method, path, **kwargs)
|
||||
|
|
@ -114,3 +114,53 @@ class SeeyonClient(BaseClient):
|
|||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
def batch_update_cap4_form_soap(
|
||||
self,
|
||||
*,
|
||||
formCode: str,
|
||||
loginName: str,
|
||||
rightId: str,
|
||||
dataList: list[dict[str, Any]],
|
||||
uniqueFiled: list[str] | None = None,
|
||||
doTrigger: bool | None = None,
|
||||
) -> httpx.Response:
|
||||
"""
|
||||
无流程批量更新:
|
||||
POST /seeyon/rest/cap4/form/soap/batch-update
|
||||
|
||||
参数对齐致远接口:
|
||||
- formCode/loginName/rightId/dataList 必填
|
||||
- uniqueFiled/doTrigger 可选
|
||||
"""
|
||||
form_code = str(formCode or "").strip()
|
||||
login_name = str(loginName or "").strip()
|
||||
right_id = str(rightId or "").strip()
|
||||
if not form_code:
|
||||
raise ValueError("formCode is required")
|
||||
if not login_name:
|
||||
raise ValueError("loginName is required")
|
||||
if not right_id:
|
||||
raise ValueError("rightId is required")
|
||||
if not isinstance(dataList, list) or len(dataList) == 0:
|
||||
raise ValueError("dataList is required and must be a non-empty list")
|
||||
if uniqueFiled is not None and not isinstance(uniqueFiled, list):
|
||||
raise ValueError("uniqueFiled must be a list if provided")
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"formCode": form_code,
|
||||
"loginName": login_name,
|
||||
"rightId": right_id,
|
||||
"dataList": dataList,
|
||||
}
|
||||
if uniqueFiled is not None:
|
||||
body["uniqueFiled"] = uniqueFiled
|
||||
if doTrigger is not None:
|
||||
body["doTrigger"] = doTrigger
|
||||
|
||||
return self.request(
|
||||
"POST",
|
||||
"/seeyon/rest/cap4/form/soap/batch-update",
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
|
|
|||
48
app/main.py
48
app/main.py
|
|
@ -2,16 +2,29 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from sqladmin import Admin
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from app.admin.routes import router as admin_router
|
||||
from app.admin.views import JobAdmin, JobLogAdmin
|
||||
from app.admin.views import (
|
||||
AuditLogAdmin,
|
||||
JobAdmin,
|
||||
JobLogAdmin,
|
||||
LdapGroupAdmin,
|
||||
LdapGroupRoleAdmin,
|
||||
PermissionAdmin,
|
||||
RoleAdmin,
|
||||
UserAdmin,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
from app.db.engine import engine
|
||||
from app.db.schema import ensure_schema
|
||||
from app.security.bootstrap import bootstrap_admin
|
||||
from app.security.session import get_current_user
|
||||
from app.security.fernet import get_or_create_fernet_key
|
||||
from app.api.auth_routes import router as auth_router
|
||||
|
||||
|
||||
def _init_db() -> None:
|
||||
|
|
@ -26,6 +39,7 @@ def _ensure_runtime() -> None:
|
|||
# 确保 Fernet key 准备好(或自动生成)
|
||||
get_or_create_fernet_key(settings.fernet_key_path)
|
||||
_init_db()
|
||||
bootstrap_admin()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
|
@ -34,16 +48,46 @@ def create_app() -> FastAPI:
|
|||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
admin = Admin(app=app, engine=engine, templates_dir="app/admin/templates")
|
||||
admin.add_view(JobAdmin)
|
||||
admin.add_view(JobLogAdmin)
|
||||
admin.add_view(UserAdmin)
|
||||
admin.add_view(RoleAdmin)
|
||||
admin.add_view(PermissionAdmin)
|
||||
admin.add_view(LdapGroupAdmin)
|
||||
admin.add_view(LdapGroupRoleAdmin)
|
||||
admin.add_view(AuditLogAdmin)
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
if not settings.auth_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
public_prefixes = ("/login", "/logout", "/health")
|
||||
static_prefixes = ("/admin/static", "/static")
|
||||
if path.startswith(public_prefixes) or path.startswith(static_prefixes):
|
||||
return await call_next(request)
|
||||
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
next_url = request.url.path
|
||||
if request.url.query:
|
||||
next_url = f"{next_url}?{request.url.query}"
|
||||
return RedirectResponse(f"/login?next={next_url}", status_code=303)
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "name": settings.app_name}
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import AuditLog, User
|
||||
|
||||
|
||||
def log_event(
|
||||
session: Session,
|
||||
*,
|
||||
action: str,
|
||||
target: str = "",
|
||||
detail: dict[str, Any] | None = None,
|
||||
request=None,
|
||||
actor: User | None = None,
|
||||
) -> None:
|
||||
ip = ""
|
||||
user_agent = ""
|
||||
if request is not None:
|
||||
ip = request.client.host if getattr(request, "client", None) else ""
|
||||
user_agent = request.headers.get("User-Agent", "") if getattr(request, "headers", None) else ""
|
||||
|
||||
log = AuditLog(
|
||||
actor_user_id=actor.id if actor else None,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail or {},
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
session.add(log)
|
||||
session.commit()
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import User
|
||||
from app.security.audit import log_event
|
||||
from app.security.ldap_client import LdapClient
|
||||
from app.security.ldap_sync import sync_user_ldap_roles
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_user_by_username(session, username: str) -> User | None:
|
||||
return session.scalar(select(User).where(User.username == username))
|
||||
|
||||
|
||||
def authenticate_local(username: str, password: str, request=None) -> int | None:
|
||||
db = get_session()
|
||||
try:
|
||||
user = get_user_by_username(db, username)
|
||||
if not user or not user.is_active or user.is_ldap:
|
||||
if user:
|
||||
log_event(db, action="login.failed", target=username, detail={"reason": "local_denied"}, request=request)
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
log_event(db, action="login.failed", target=username, detail={"reason": "password"}, request=request)
|
||||
return None
|
||||
user.last_login_at = datetime.utcnow()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
log_event(db, action="login.success", target=username, detail={"provider": "local"}, request=request, actor=user)
|
||||
return int(user.id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def authenticate_ldap(username: str, password: str, request=None) -> int | None:
|
||||
client = LdapClient()
|
||||
result = client.authenticate(username, password)
|
||||
if not result:
|
||||
db = get_session()
|
||||
try:
|
||||
log_event(db, action="login.failed", target=username, detail={"reason": "ldap"}, request=request)
|
||||
finally:
|
||||
db.close()
|
||||
return None
|
||||
|
||||
user_dn = result["user_dn"]
|
||||
db = get_session()
|
||||
try:
|
||||
user = get_user_by_username(db, username)
|
||||
if not user:
|
||||
user = User(username=username, is_active=True, is_superuser=False, is_ldap=True, password_hash="")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
user.is_ldap = True
|
||||
user.is_active = True
|
||||
user.last_login_at = datetime.utcnow()
|
||||
sync_user_ldap_roles(session=db, user=user, username=username, user_dn=user_dn)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
log_event(db, action="login.success", target=username, detail={"provider": "ldap"}, request=request, actor=user)
|
||||
return int(user.id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def authenticate(username: str, password: str, request=None) -> int | None:
|
||||
user_id = authenticate_local(username, password, request=request)
|
||||
if user_id:
|
||||
return user_id
|
||||
return authenticate_ldap(username, password, request=request)
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import User
|
||||
from app.security.audit import log_event
|
||||
from app.security.auth import hash_password
|
||||
|
||||
|
||||
def bootstrap_admin() -> None:
|
||||
if not settings.bootstrap_admin_generate:
|
||||
return
|
||||
db = get_session()
|
||||
try:
|
||||
existing = db.scalar(select(User).where(User.is_superuser.is_(True)))
|
||||
if existing:
|
||||
return
|
||||
password = secrets.token_urlsafe(16)
|
||||
user = User(
|
||||
username=settings.bootstrap_admin_username,
|
||||
password_hash=hash_password(password),
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
is_ldap=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
pass_path = settings.bootstrap_admin_pass_path
|
||||
os.makedirs(os.path.dirname(pass_path), exist_ok=True)
|
||||
with open(pass_path, "w", encoding="utf-8") as f:
|
||||
f.write(password + "\n")
|
||||
|
||||
log_event(db, action="bootstrap.admin.created", target=user.username, detail={}, request=None, actor=user)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
from ldap3 import ALL, Connection, Server, Tls
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class LdapClient:
|
||||
def __init__(self) -> None:
|
||||
tls = None
|
||||
if settings.ldap_uri.startswith("ldaps://") or settings.ldap_use_starttls:
|
||||
tls = Tls(validate=ssl.CERT_REQUIRED if settings.ldap_verify_tls else ssl.CERT_NONE)
|
||||
self._server = Server(settings.ldap_uri, use_ssl=settings.ldap_uri.startswith("ldaps://"), get_info=ALL, tls=tls)
|
||||
|
||||
def _connect(self, *, bind_dn: str | None = None, password: str | None = None) -> Connection:
|
||||
conn = Connection(self._server, user=bind_dn, password=password, auto_bind=False)
|
||||
conn.open()
|
||||
if settings.ldap_use_starttls and self._server.ssl is False:
|
||||
conn.start_tls()
|
||||
conn.bind()
|
||||
return conn
|
||||
|
||||
def _service_conn(self) -> Connection:
|
||||
return self._connect(bind_dn=settings.ldap_bind_dn, password=settings.ldap_bind_password)
|
||||
|
||||
def find_user_dn(self, username: str) -> str | None:
|
||||
if not settings.ldap_base_dn:
|
||||
return None
|
||||
conn = self._service_conn()
|
||||
try:
|
||||
search_filter = settings.ldap_user_filter.format(username=username)
|
||||
conn.search(settings.ldap_base_dn, search_filter, attributes=["dn"])
|
||||
if not conn.entries:
|
||||
return None
|
||||
return conn.entries[0].entry_dn
|
||||
finally:
|
||||
conn.unbind()
|
||||
|
||||
def authenticate(self, username: str, password: str) -> dict[str, Any] | None:
|
||||
user_dn = self.find_user_dn(username)
|
||||
if not user_dn:
|
||||
return None
|
||||
try:
|
||||
conn = self._connect(bind_dn=user_dn, password=password)
|
||||
conn.unbind()
|
||||
return {"user_dn": user_dn}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_user_groups(self, *, user_dn: str, username: str) -> list[dict[str, str]]:
|
||||
if not settings.ldap_base_dn:
|
||||
return []
|
||||
conn = self._service_conn()
|
||||
try:
|
||||
search_filter = settings.ldap_group_filter.format(user_dn=user_dn, username=username)
|
||||
conn.search(settings.ldap_base_dn, search_filter, attributes=["cn"])
|
||||
results: list[dict[str, str]] = []
|
||||
for entry in conn.entries:
|
||||
dn = entry.entry_dn
|
||||
name = ""
|
||||
try:
|
||||
name = str(entry.cn.value) if hasattr(entry, "cn") else ""
|
||||
except Exception:
|
||||
name = ""
|
||||
results.append({"dn": dn, "name": name or dn})
|
||||
return results
|
||||
finally:
|
||||
conn.unbind()
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import LdapGroup, LdapGroupRole, Role, User
|
||||
from app.security.ldap_client import LdapClient
|
||||
|
||||
|
||||
def _ensure_groups(session, groups: list[dict[str, str]]) -> list[LdapGroup]:
|
||||
existing = {
|
||||
g.dn: g for g in session.scalars(select(LdapGroup).where(LdapGroup.dn.in_([g["dn"] for g in groups])))
|
||||
}
|
||||
results: list[LdapGroup] = []
|
||||
for g in groups:
|
||||
if g["dn"] in existing:
|
||||
obj = existing[g["dn"]]
|
||||
if g["name"] and obj.name != g["name"]:
|
||||
obj.name = g["name"]
|
||||
session.add(obj)
|
||||
results.append(obj)
|
||||
else:
|
||||
obj = LdapGroup(dn=g["dn"], name=g["name"])
|
||||
session.add(obj)
|
||||
session.flush()
|
||||
results.append(obj)
|
||||
return results
|
||||
|
||||
|
||||
def sync_user_ldap_roles(*, session, user: User, username: str, user_dn: str) -> None:
|
||||
client = LdapClient()
|
||||
groups = client.get_user_groups(user_dn=user_dn, username=username)
|
||||
group_objs = _ensure_groups(session, groups)
|
||||
if not group_objs:
|
||||
user.roles = []
|
||||
session.add(user)
|
||||
return
|
||||
|
||||
group_ids = [g.id for g in group_objs]
|
||||
role_ids = list(
|
||||
session.scalars(select(LdapGroupRole.role_id).where(LdapGroupRole.ldap_group_id.in_(group_ids)))
|
||||
)
|
||||
if not role_ids:
|
||||
user.roles = []
|
||||
session.add(user)
|
||||
return
|
||||
roles = list(session.scalars(select(Role).where(Role.id.in_(role_ids))))
|
||||
user.roles = roles
|
||||
session.add(user)
|
||||
|
||||
|
||||
def sync_all_ldap_users() -> int:
|
||||
db = get_session()
|
||||
client = LdapClient()
|
||||
updated = 0
|
||||
try:
|
||||
users = list(db.scalars(select(User).where(User.is_ldap.is_(True))))
|
||||
for user in users:
|
||||
user_dn = client.find_user_dn(user.username)
|
||||
if not user_dn:
|
||||
continue
|
||||
sync_user_ldap_roles(session=db, user=user, username=user.username, user_dn=user_dn)
|
||||
updated += 1
|
||||
db.commit()
|
||||
return updated
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import Permission, Role, User, role_permissions, user_roles
|
||||
from app.security.session import get_current_user
|
||||
|
||||
|
||||
def _load_user_permissions(session, user: User) -> set[str]:
|
||||
stmt = (
|
||||
select(Permission.code)
|
||||
.select_from(Permission)
|
||||
.join(role_permissions, Permission.id == role_permissions.c.permission_id)
|
||||
.join(Role, Role.id == role_permissions.c.role_id)
|
||||
.join(user_roles, Role.id == user_roles.c.role_id)
|
||||
.where(user_roles.c.user_id == user.id)
|
||||
)
|
||||
return {code for code in session.scalars(stmt)}
|
||||
|
||||
|
||||
def user_has_permission(session, user: User, code: str) -> bool:
|
||||
if user.is_superuser:
|
||||
return True
|
||||
if not code:
|
||||
return True
|
||||
perms = _load_user_permissions(session, user)
|
||||
return code in perms
|
||||
|
||||
|
||||
def request_has_permission(request, code: str) -> bool:
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
if not code:
|
||||
return True
|
||||
|
||||
if hasattr(request.state, "permissions"):
|
||||
perms = request.state.permissions
|
||||
else:
|
||||
db = get_session()
|
||||
try:
|
||||
perms = _load_user_permissions(db, user)
|
||||
request.state.permissions = perms
|
||||
finally:
|
||||
db.close()
|
||||
return code in perms
|
||||
|
||||
|
||||
def table_permission_code(table: str, action: str) -> str:
|
||||
return f"table:{table}:{action}"
|
||||
|
||||
|
||||
def button_permission_code(code: str) -> str:
|
||||
return f"button:{code}"
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Request, Response
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import Session
|
||||
|
||||
|
||||
SESSION_COOKIE_NAME = "session_id"
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def create_session(user_id: int, request: Request) -> str:
|
||||
session_id = uuid4().hex
|
||||
expires_at = _now_utc() + timedelta(minutes=settings.session_expiry_minutes)
|
||||
db = get_session()
|
||||
try:
|
||||
record = Session(
|
||||
id=session_id,
|
||||
user_id=user_id,
|
||||
expires_at=expires_at,
|
||||
ip=request.client.host if request.client else "",
|
||||
user_agent=request.headers.get("User-Agent", ""),
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
return session_id
|
||||
|
||||
|
||||
def delete_session(session_id: str) -> None:
|
||||
db = get_session()
|
||||
try:
|
||||
db.execute(delete(Session).where(Session.id == session_id))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, session_id: str) -> None:
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
session_id,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=settings.session_expiry_minutes * 60,
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> User | None:
|
||||
if hasattr(request.state, "user"):
|
||||
return request.state.user
|
||||
|
||||
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if not session_id:
|
||||
request.state.user = None
|
||||
return None
|
||||
|
||||
db = get_session()
|
||||
try:
|
||||
record = db.scalar(select(Session).where(Session.id == session_id))
|
||||
if not record:
|
||||
request.state.user = None
|
||||
return None
|
||||
if record.expires_at <= _now_utc():
|
||||
db.execute(delete(Session).where(Session.id == session_id))
|
||||
db.commit()
|
||||
request.state.user = None
|
||||
return None
|
||||
user = db.get(User, record.user_id)
|
||||
if not user or not user.is_active:
|
||||
request.state.user = None
|
||||
return None
|
||||
request.state.user = user
|
||||
return user
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -21,12 +21,17 @@ celery_app.conf.update(
|
|||
include=[
|
||||
"app.tasks.execute",
|
||||
"app.tasks.dispatcher",
|
||||
"app.tasks.ldap_sync",
|
||||
],
|
||||
beat_schedule={
|
||||
"connecthub-dispatcher-tick-every-minute": {
|
||||
"task": "connecthub.dispatcher.tick",
|
||||
"schedule": 60.0,
|
||||
}
|
||||
},
|
||||
"connecthub-ldap-sync-every-5-minutes": {
|
||||
"task": "connecthub.ldap.sync",
|
||||
"schedule": 300.0,
|
||||
},
|
||||
},
|
||||
worker_redirect_stdouts=False
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.security.ldap_sync import sync_all_ldap_users
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
|
||||
@celery_app.task(name="connecthub.ldap.sync")
|
||||
def ldap_sync_task() -> int:
|
||||
return sync_all_ldap_users()
|
||||
|
|
@ -0,0 +1 @@
|
|||
ntpwKPBXc3w2nydPc4N-JQ
|
||||
File diff suppressed because it is too large
Load Diff
22
env.example
22
env.example
|
|
@ -6,4 +6,26 @@ FERNET_KEY_PATH=/data/fernet.key
|
|||
DEV_MODE=1
|
||||
LOG_DIR=/data/logs
|
||||
|
||||
# Auth / session
|
||||
AUTH_ENABLED=1
|
||||
SESSION_SECRET=change_me_session_secret
|
||||
SESSION_EXPIRY_MINUTES=1440
|
||||
PASSWORD_HASH_ALGO=bcrypt
|
||||
|
||||
# Bootstrap admin
|
||||
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
BOOTSTRAP_ADMIN_GENERATE=1
|
||||
BOOTSTRAP_ADMIN_PASS_PATH=/data/admin.pass
|
||||
|
||||
# LDAP
|
||||
LDAP_URI=ldaps://SHDC01.senasic.cn:636
|
||||
LDAP_BIND_DN=dcadmin@senasic.cn
|
||||
LDAP_BIND_PASSWORD=Jj_Window$
|
||||
LDAP_BASE_DN=ou=People,dc=senasic,dc=com
|
||||
LDAP_USER_FILTER=(uid={sAMAccountName})
|
||||
LDAP_GROUP_FILTER=(member={user_dn})
|
||||
LDAP_USE_STARTTLS=0
|
||||
LDAP_VERIFY_TLS=0
|
||||
LDAP_SYNC_INTERVAL_MINUTES=5
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
"""EHR 到 OA 的同步 API 包。"""
|
||||
|
||||
from extensions.sync_ehr_to_oa.api import SyncEhrToOaApi
|
||||
from extensions.sync_ehr_to_oa.job import SyncEhrToOaFormJob
|
||||
|
||||
__all__ = ["SyncEhrToOaApi", "SyncEhrToOaFormJob"]
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.integrations.ehr import EhrClient
|
||||
|
||||
|
||||
logger = logging.getLogger("connecthub.extensions.sync_ehr_to_oa")
|
||||
|
||||
|
||||
class SyncEhrToOaApi:
|
||||
"""
|
||||
北森 EHR -> OA 同步 API 封装。
|
||||
|
||||
已封装 API:
|
||||
- 员工与单条任职时间窗滚动查询
|
||||
- 组织单元时间窗滚动查询
|
||||
- 职务时间窗滚动查询
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
secret_params: dict[str, str],
|
||||
base_url: str = "https://openapi.italent.cn",
|
||||
timeout_s: float = 10.0,
|
||||
retries: int = 2,
|
||||
retry_backoff_s: float = 0.5,
|
||||
) -> None:
|
||||
self._client = EhrClient(
|
||||
base_url=base_url,
|
||||
secret_params=secret_params,
|
||||
timeout_s=timeout_s,
|
||||
retries=retries,
|
||||
retry_backoff_s=retry_backoff_s,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
@staticmethod
|
||||
def _to_api_datetime(value: datetime | str) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
raise ValueError("datetime string cannot be empty")
|
||||
return s
|
||||
|
||||
def get_all_employees_with_record_by_time_window(
|
||||
self,
|
||||
*,
|
||||
stop_time: datetime | str | None = None,
|
||||
capacity: int = 300,
|
||||
time_window_query_type: int = 1,
|
||||
with_disabled: bool = True,
|
||||
is_with_deleted: bool = True,
|
||||
max_pages: int = 100000,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
滚动查询“员工 + 单条任职”全量结果。
|
||||
|
||||
固定起始时间:
|
||||
- 2001-01-01T00:00:00
|
||||
"""
|
||||
if capacity <= 0 or capacity > 300:
|
||||
raise ValueError("capacity must be in range [1, 300]")
|
||||
if max_pages <= 0:
|
||||
raise ValueError("max_pages must be > 0")
|
||||
|
||||
start_time = "2001-01-01T00:00:00"
|
||||
stop_time_s = self._to_api_datetime(stop_time or datetime.now())
|
||||
|
||||
all_data: list[dict[str, Any]] = []
|
||||
scroll_id = ""
|
||||
total = 0
|
||||
page = 0
|
||||
|
||||
while True:
|
||||
page += 1
|
||||
if page > max_pages:
|
||||
raise RuntimeError(f"scroll pages exceed max_pages={max_pages}")
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"startTime": start_time,
|
||||
"stopTime": stop_time_s,
|
||||
"timeWindowQueryType": time_window_query_type,
|
||||
"scrollId": scroll_id,
|
||||
"capacity": capacity,
|
||||
"withDisabled": with_disabled,
|
||||
"isWithDeleted": is_with_deleted,
|
||||
}
|
||||
|
||||
resp = self._client.request(
|
||||
"POST",
|
||||
"/TenantBaseExternal/api/v5/Employee/GetByTimeWindow",
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
payload = resp.json() if resp.content else {}
|
||||
|
||||
code = str(payload.get("code", "") or "")
|
||||
if code != "200":
|
||||
message = payload.get("message")
|
||||
raise RuntimeError(f"EHR GetByTimeWindow failed code={code!r} message={message!r}")
|
||||
|
||||
batch = payload.get("data") or []
|
||||
if not isinstance(batch, list):
|
||||
raise RuntimeError("EHR GetByTimeWindow invalid response: data is not a list")
|
||||
all_data.extend([x for x in batch if isinstance(x, dict)])
|
||||
|
||||
total_val = payload.get("total")
|
||||
if total_val is not None:
|
||||
try:
|
||||
total = int(total_val)
|
||||
except (TypeError, ValueError):
|
||||
total = total
|
||||
|
||||
is_last_data = bool(payload.get("isLastData", False))
|
||||
scroll_id = str(payload.get("scrollId", "") or "")
|
||||
|
||||
logger.info(
|
||||
"EHR GetByTimeWindow page=%s batch=%s total=%s isLastData=%s",
|
||||
page,
|
||||
len(batch),
|
||||
total,
|
||||
is_last_data,
|
||||
)
|
||||
|
||||
if is_last_data:
|
||||
break
|
||||
|
||||
return {
|
||||
"startTime": start_time,
|
||||
"stopTime": stop_time_s,
|
||||
"total": total,
|
||||
"pages": page,
|
||||
"count": len(all_data),
|
||||
"data": all_data,
|
||||
"lastScrollId": scroll_id,
|
||||
}
|
||||
|
||||
def get_all_organizations_by_time_window(
|
||||
self,
|
||||
*,
|
||||
stop_time: datetime | str | None = None,
|
||||
capacity: int = 300,
|
||||
time_window_query_type: int = 1,
|
||||
with_disabled: bool = True,
|
||||
is_with_deleted: bool = True,
|
||||
max_pages: int = 100000,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
滚动查询“组织单元”全量结果。
|
||||
|
||||
固定起始时间:
|
||||
- 2001-01-01T00:00:00
|
||||
"""
|
||||
if capacity <= 0 or capacity > 300:
|
||||
raise ValueError("capacity must be in range [1, 300]")
|
||||
if max_pages <= 0:
|
||||
raise ValueError("max_pages must be > 0")
|
||||
|
||||
start_time = "2001-01-01T00:00:00"
|
||||
stop_time_s = self._to_api_datetime(stop_time or datetime.now())
|
||||
|
||||
all_data: list[dict[str, Any]] = []
|
||||
scroll_id = ""
|
||||
total = 0
|
||||
page = 0
|
||||
|
||||
while True:
|
||||
page += 1
|
||||
if page > max_pages:
|
||||
raise RuntimeError(f"scroll pages exceed max_pages={max_pages}")
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"startTime": start_time,
|
||||
"stopTime": stop_time_s,
|
||||
"timeWindowQueryType": time_window_query_type,
|
||||
"scrollId": scroll_id,
|
||||
"capacity": capacity,
|
||||
"withDisabled": with_disabled,
|
||||
"isWithDeleted": is_with_deleted,
|
||||
}
|
||||
|
||||
resp = self._client.request(
|
||||
"POST",
|
||||
"/TenantBaseExternal/api/v5/Organization/GetByTimeWindow",
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
payload = resp.json() if resp.content else {}
|
||||
|
||||
code = str(payload.get("code", "") or "")
|
||||
if code != "200":
|
||||
message = payload.get("message")
|
||||
raise RuntimeError(f"EHR Organization.GetByTimeWindow failed code={code!r} message={message!r}")
|
||||
|
||||
batch = payload.get("data") or []
|
||||
if not isinstance(batch, list):
|
||||
raise RuntimeError("EHR Organization.GetByTimeWindow invalid response: data is not a list")
|
||||
all_data.extend([x for x in batch if isinstance(x, dict)])
|
||||
|
||||
total_val = payload.get("total")
|
||||
if total_val is not None:
|
||||
try:
|
||||
total = int(total_val)
|
||||
except (TypeError, ValueError):
|
||||
total = total
|
||||
|
||||
is_last_data = bool(payload.get("isLastData", False))
|
||||
scroll_id = str(payload.get("scrollId", "") or "")
|
||||
|
||||
logger.info(
|
||||
"EHR Organization.GetByTimeWindow page=%s batch=%s total=%s isLastData=%s",
|
||||
page,
|
||||
len(batch),
|
||||
total,
|
||||
is_last_data,
|
||||
)
|
||||
|
||||
if is_last_data:
|
||||
break
|
||||
|
||||
return {
|
||||
"startTime": start_time,
|
||||
"stopTime": stop_time_s,
|
||||
"total": total,
|
||||
"pages": page,
|
||||
"count": len(all_data),
|
||||
"data": all_data,
|
||||
"lastScrollId": scroll_id,
|
||||
}
|
||||
|
||||
def get_all_job_posts_by_time_window(
|
||||
self,
|
||||
*,
|
||||
stop_time: datetime | str | None = None,
|
||||
capacity: int = 300,
|
||||
time_window_query_type: int = 1,
|
||||
with_disabled: bool = True,
|
||||
is_with_deleted: bool = True,
|
||||
max_pages: int = 100000,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
滚动查询“职务”全量结果。
|
||||
|
||||
固定起始时间:
|
||||
- 2001-01-01T00:00:00
|
||||
"""
|
||||
if capacity <= 0 or capacity > 300:
|
||||
raise ValueError("capacity must be in range [1, 300]")
|
||||
if max_pages <= 0:
|
||||
raise ValueError("max_pages must be > 0")
|
||||
|
||||
start_time = "2001-01-01T00:00:00"
|
||||
stop_time_s = self._to_api_datetime(stop_time or datetime.now())
|
||||
|
||||
all_data: list[dict[str, Any]] = []
|
||||
scroll_id = ""
|
||||
total = 0
|
||||
page = 0
|
||||
|
||||
while True:
|
||||
page += 1
|
||||
if page > max_pages:
|
||||
raise RuntimeError(f"scroll pages exceed max_pages={max_pages}")
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"startTime": start_time,
|
||||
"stopTime": stop_time_s,
|
||||
"timeWindowQueryType": time_window_query_type,
|
||||
"scrollId": scroll_id,
|
||||
"capacity": capacity,
|
||||
"withDisabled": with_disabled,
|
||||
"isWithDeleted": is_with_deleted,
|
||||
}
|
||||
|
||||
resp = self._client.request(
|
||||
"POST",
|
||||
"/TenantBaseExternal/api/v5/JobPost/GetByTimeWindow",
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
payload = resp.json() if resp.content else {}
|
||||
|
||||
code = str(payload.get("code", "") or "")
|
||||
if code != "200":
|
||||
message = payload.get("message")
|
||||
raise RuntimeError(f"EHR JobPost.GetByTimeWindow failed code={code!r} message={message!r}")
|
||||
|
||||
batch = payload.get("data") or []
|
||||
if not isinstance(batch, list):
|
||||
raise RuntimeError("EHR JobPost.GetByTimeWindow invalid response: data is not a list")
|
||||
all_data.extend([x for x in batch if isinstance(x, dict)])
|
||||
|
||||
total_val = payload.get("total")
|
||||
if total_val is not None:
|
||||
try:
|
||||
total = int(total_val)
|
||||
except (TypeError, ValueError):
|
||||
total = total
|
||||
|
||||
is_last_data = bool(payload.get("isLastData", False))
|
||||
scroll_id = str(payload.get("scrollId", "") or "")
|
||||
|
||||
logger.info(
|
||||
"EHR JobPost.GetByTimeWindow page=%s batch=%s total=%s isLastData=%s",
|
||||
page,
|
||||
len(batch),
|
||||
total,
|
||||
is_last_data,
|
||||
)
|
||||
|
||||
if is_last_data:
|
||||
break
|
||||
|
||||
return {
|
||||
"startTime": start_time,
|
||||
"stopTime": stop_time_s,
|
||||
"total": total,
|
||||
"pages": page,
|
||||
"count": len(all_data),
|
||||
"data": all_data,
|
||||
"lastScrollId": scroll_id,
|
||||
}
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.integrations.seeyon import SeeyonClient
|
||||
from app.jobs.base import BaseJob
|
||||
from extensions.sync_ehr_to_oa.api import SyncEhrToOaApi
|
||||
|
||||
|
||||
logger = logging.getLogger("connecthub.extensions.sync_ehr_to_oa")
|
||||
|
||||
|
||||
def _cell_value(cell: Any) -> str:
|
||||
if isinstance(cell, dict):
|
||||
v = cell.get("value")
|
||||
if v is None or str(v).strip() == "":
|
||||
v = cell.get("showValue")
|
||||
return str(v or "").strip()
|
||||
return str(cell or "").strip()
|
||||
|
||||
|
||||
def _date_only(s: Any) -> str:
|
||||
v = str(s or "").strip()
|
||||
if not v:
|
||||
return ""
|
||||
if "T" in v:
|
||||
return v.split("T", 1)[0]
|
||||
if " " in v:
|
||||
return v.split(" ", 1)[0]
|
||||
return v
|
||||
|
||||
|
||||
def _custom_prop_value(custom_props: Any, key: str | None) -> str:
|
||||
if not key:
|
||||
return ""
|
||||
if not isinstance(custom_props, dict):
|
||||
return ""
|
||||
raw = custom_props.get(key)
|
||||
if isinstance(raw, dict):
|
||||
val = raw.get("value")
|
||||
if val is None or str(val).strip() == "":
|
||||
val = raw.get("showValue")
|
||||
return str(val or "").strip()
|
||||
return str(raw or "").strip()
|
||||
|
||||
|
||||
def _choose_better_record(current: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
|
||||
def _score(item: dict[str, Any]) -> str:
|
||||
record = item.get("recordInfo") or {}
|
||||
emp = item.get("employeeInfo") or {}
|
||||
parts = [
|
||||
str(record.get("businessModifiedTime") or ""),
|
||||
str(record.get("modifiedTime") or ""),
|
||||
str(emp.get("businessModifiedTime") or ""),
|
||||
str(emp.get("modifiedTime") or ""),
|
||||
str(record.get("createdTime") or ""),
|
||||
str(emp.get("createdTime") or ""),
|
||||
]
|
||||
return "|".join(parts)
|
||||
|
||||
return candidate if _score(candidate) >= _score(current) else current
|
||||
|
||||
|
||||
class SyncEhrToOaFormJob(BaseJob):
|
||||
"""
|
||||
EHR -> OA 无流程表单字段同步。
|
||||
|
||||
同步字段:
|
||||
- 工号(作为唯一对应关系,不写入)
|
||||
- 所属公司、姓名、研发属性、工作地点、入职日期、离职日期、身份证号、HRBP、汇报人、在离职、域账号
|
||||
"""
|
||||
|
||||
job_id = "sync_ehr_to_oa.sync_form"
|
||||
|
||||
def run(self, params: dict[str, Any], secrets: dict[str, Any]) -> dict[str, Any]:
|
||||
oa_base_url = str(params.get("oa_base_url") or "").strip()
|
||||
oa_form_code = str(params.get("oa_form_code") or "").strip()
|
||||
oa_right_id = str(params.get("oa_right_id") or "").strip()
|
||||
oa_login_name = str(params.get("oa_login_name") or "").strip()
|
||||
if not oa_base_url:
|
||||
raise ValueError("public_cfg.oa_base_url is required")
|
||||
if not oa_form_code:
|
||||
raise ValueError("public_cfg.oa_form_code is required")
|
||||
if not oa_right_id:
|
||||
raise ValueError("public_cfg.oa_right_id is required")
|
||||
if not oa_login_name:
|
||||
raise ValueError("public_cfg.oa_login_name is required")
|
||||
|
||||
oa_template_code = str(params.get("oa_template_code") or oa_form_code).strip()
|
||||
oa_master_table_name = str(params.get("oa_master_table_name") or "").strip()
|
||||
batch_size = int(params.get("batch_size") or 100)
|
||||
if batch_size <= 0:
|
||||
batch_size = 100
|
||||
|
||||
stop_time = params.get("stop_time")
|
||||
capacity = int(params.get("capacity") or 300)
|
||||
do_trigger = params.get("do_trigger")
|
||||
sender_login_name = params.get("senderLoginName")
|
||||
sender_login_name = str(sender_login_name).strip() if sender_login_name else None
|
||||
|
||||
rest_user = str(secrets.get("rest_user") or "").strip()
|
||||
rest_password = str(secrets.get("rest_password") or "").strip()
|
||||
login_name = secrets.get("loginName")
|
||||
login_name = str(login_name).strip() if login_name else None
|
||||
if not rest_user or not rest_password:
|
||||
raise ValueError("secret_cfg.rest_user and secret_cfg.rest_password are required")
|
||||
|
||||
app_key = str(secrets.get("app_key") or "").strip()
|
||||
app_secret = str(secrets.get("app_secret") or "").strip()
|
||||
if not app_key or not app_secret:
|
||||
raise ValueError("secret_cfg.app_key and secret_cfg.app_secret are required")
|
||||
|
||||
rd_attr_custom_key = str(params.get("rd_attr_custom_key") or "").strip() or None
|
||||
domain_custom_key = str(params.get("domain_account_custom_key") or "").strip() or None
|
||||
|
||||
seeyon = SeeyonClient(base_url=oa_base_url, rest_user=rest_user, rest_password=rest_password, loginName=login_name)
|
||||
ehr = SyncEhrToOaApi(secret_params={"app_key": app_key, "app_secret": app_secret})
|
||||
try:
|
||||
# 1) EHR 拉取员工任职与组织
|
||||
emp_res = ehr.get_all_employees_with_record_by_time_window(stop_time=stop_time, capacity=capacity)
|
||||
org_res = ehr.get_all_organizations_by_time_window(stop_time=stop_time, capacity=capacity)
|
||||
emp_rows = emp_res.get("data") or []
|
||||
org_rows = org_res.get("data") or []
|
||||
if not isinstance(emp_rows, list):
|
||||
raise RuntimeError("EHR employee result invalid: data is not list")
|
||||
if not isinstance(org_rows, list):
|
||||
raise RuntimeError("EHR organization result invalid: data is not list")
|
||||
|
||||
# 2) 组织映射
|
||||
org_by_oid: dict[str, dict[str, Any]] = {}
|
||||
for o in org_rows:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
oid = str(o.get("oId") or "").strip()
|
||||
if oid:
|
||||
org_by_oid[oid] = o
|
||||
|
||||
# 3) 员工按工号归并(同工号保留“最新”记录)
|
||||
ehr_by_job_no: dict[str, dict[str, Any]] = {}
|
||||
for item in emp_rows:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
record = item.get("recordInfo") or {}
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
job_no = str(record.get("jobNumber") or "").strip()
|
||||
if not job_no:
|
||||
continue
|
||||
existing = ehr_by_job_no.get(job_no)
|
||||
ehr_by_job_no[job_no] = item if existing is None else _choose_better_record(existing, item)
|
||||
|
||||
# 4) 导出 OA 表单,建立字段映射 + 工号到记录ID映射
|
||||
exp_resp = seeyon.export_cap4_form_soap(templateCode=oa_template_code, senderLoginName=sender_login_name, rightId=oa_right_id)
|
||||
raw = exp_resp.text or ""
|
||||
payload = json.loads(raw) if raw else {}
|
||||
outer = payload.get("data") or {}
|
||||
form = outer.get("data") or {}
|
||||
if not isinstance(form, dict):
|
||||
raise RuntimeError("OA export invalid: data.data is not an object")
|
||||
|
||||
definition = form.get("definition") or {}
|
||||
fields = definition.get("fields") or []
|
||||
if not isinstance(fields, list):
|
||||
raise RuntimeError("OA export invalid: definition.fields is not a list")
|
||||
|
||||
display_to_code: dict[str, str] = {}
|
||||
for f in fields:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
display = str(f.get("display") or "").strip()
|
||||
name = str(f.get("name") or "").strip()
|
||||
if display and name:
|
||||
display_to_code[display] = name
|
||||
|
||||
needed_displays = [
|
||||
"工号",
|
||||
"所属公司",
|
||||
"姓名",
|
||||
"研发属性",
|
||||
"工作地点",
|
||||
"入职日期",
|
||||
"离职日期",
|
||||
"身份证号",
|
||||
"HRBP",
|
||||
"汇报人",
|
||||
"在离职",
|
||||
"域账号",
|
||||
]
|
||||
missing = [x for x in needed_displays if x not in display_to_code]
|
||||
if missing:
|
||||
raise RuntimeError(f"OA export invalid: missing form fields by display names: {missing}")
|
||||
|
||||
rows = form.get("data") or []
|
||||
if not isinstance(rows, list):
|
||||
raise RuntimeError("OA export invalid: data is not a list")
|
||||
|
||||
if not oa_master_table_name:
|
||||
for key in ("masterTableName", "masterTable", "masterTableCode"):
|
||||
v = str((definition or {}).get(key) or "").strip()
|
||||
if v:
|
||||
oa_master_table_name = v
|
||||
break
|
||||
if not oa_master_table_name and rows:
|
||||
r0 = rows[0] if isinstance(rows[0], dict) else {}
|
||||
master_tbl = r0.get("masterTable")
|
||||
if isinstance(master_tbl, dict):
|
||||
oa_master_table_name = str(master_tbl.get("name") or "").strip()
|
||||
if not oa_master_table_name:
|
||||
raise RuntimeError("public_cfg.oa_master_table_name is required (cannot infer from OA export)")
|
||||
|
||||
job_field_code = display_to_code["工号"]
|
||||
oa_id_by_job_no: dict[str, int] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
master = row.get("masterData") or {}
|
||||
if not isinstance(master, dict):
|
||||
continue
|
||||
job_no = _cell_value(master.get(job_field_code))
|
||||
if not job_no:
|
||||
continue
|
||||
|
||||
row_id_raw = row.get("id")
|
||||
if row_id_raw is None:
|
||||
row_id_raw = row.get("masterDataId")
|
||||
if row_id_raw is None:
|
||||
row_id_raw = master.get("id")
|
||||
if row_id_raw is None:
|
||||
continue
|
||||
try:
|
||||
row_id = int(str(row_id_raw))
|
||||
except Exception:
|
||||
continue
|
||||
oa_id_by_job_no[job_no] = row_id
|
||||
|
||||
# 5) 组装批量更新数据
|
||||
data_list: list[dict[str, Any]] = []
|
||||
not_found_in_oa = 0
|
||||
for job_no, item in ehr_by_job_no.items():
|
||||
oa_record_id = oa_id_by_job_no.get(job_no)
|
||||
if oa_record_id is None:
|
||||
not_found_in_oa += 1
|
||||
continue
|
||||
|
||||
emp = item.get("employeeInfo") or {}
|
||||
rec = item.get("recordInfo") or {}
|
||||
if not isinstance(emp, dict):
|
||||
emp = {}
|
||||
if not isinstance(rec, dict):
|
||||
rec = {}
|
||||
|
||||
org_oid = str(rec.get("oIdOrganization") or rec.get("oIdDepartment") or "").strip()
|
||||
org = org_by_oid.get(org_oid, {})
|
||||
|
||||
company = str((org or {}).get("name") or "")
|
||||
name = str(emp.get("name") or "")
|
||||
rd_attr = _custom_prop_value(rec.get("customProperties"), rd_attr_custom_key) or _custom_prop_value(
|
||||
emp.get("customProperties"), rd_attr_custom_key
|
||||
)
|
||||
place = str(rec.get("place") or "")
|
||||
entry_date = _date_only(rec.get("entryDate"))
|
||||
leave_date = _date_only(rec.get("lastWorkDate")) or "2099-12-31"
|
||||
id_number = str(emp.get("iDNumber") or "")
|
||||
hrbp = str((org or {}).get("hRBP") or "")
|
||||
manager = str(rec.get("pOIdEmpAdmin") or "")
|
||||
is_leaving = "是" if _date_only(rec.get("lastWorkDate")) else "否"
|
||||
domain_account = _custom_prop_value(emp.get("customProperties"), domain_custom_key) or str(emp.get("_Name") or "")
|
||||
|
||||
fields_payload = [
|
||||
{"name": display_to_code["所属公司"], "value": company, "showValue": company},
|
||||
{"name": display_to_code["姓名"], "value": name, "showValue": name},
|
||||
{"name": display_to_code["研发属性"], "value": rd_attr, "showValue": rd_attr},
|
||||
{"name": display_to_code["工作地点"], "value": place, "showValue": place},
|
||||
{"name": display_to_code["入职日期"], "value": entry_date, "showValue": entry_date},
|
||||
{"name": display_to_code["离职日期"], "value": leave_date, "showValue": leave_date},
|
||||
{"name": display_to_code["身份证号"], "value": id_number, "showValue": id_number},
|
||||
{"name": display_to_code["HRBP"], "value": hrbp, "showValue": hrbp},
|
||||
{"name": display_to_code["汇报人"], "value": manager, "showValue": manager},
|
||||
{"name": display_to_code["在离职"], "value": is_leaving, "showValue": is_leaving},
|
||||
{"name": display_to_code["域账号"], "value": domain_account, "showValue": domain_account},
|
||||
]
|
||||
|
||||
data_list.append(
|
||||
{
|
||||
"masterTable": {
|
||||
"name": oa_master_table_name,
|
||||
"record": {
|
||||
"id": oa_record_id,
|
||||
"fields": fields_payload,
|
||||
},
|
||||
"changedFields": [f["name"] for f in fields_payload],
|
||||
},
|
||||
"subTables": [],
|
||||
}
|
||||
)
|
||||
|
||||
# 6) 分批执行 batch-update
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
failed_data: dict[str, str] = {}
|
||||
for i in range(0, len(data_list), batch_size):
|
||||
chunk = data_list[i : i + batch_size]
|
||||
resp = seeyon.batch_update_cap4_form_soap(
|
||||
formCode=oa_form_code,
|
||||
loginName=oa_login_name,
|
||||
rightId=oa_right_id,
|
||||
dataList=chunk,
|
||||
uniqueFiled=[job_field_code],
|
||||
doTrigger=bool(do_trigger) if do_trigger is not None else None,
|
||||
)
|
||||
rj = resp.json() if resp.content else {}
|
||||
code = int(rj.get("code", -1))
|
||||
if code != 0:
|
||||
raise RuntimeError(f"OA batch-update failed code={code} message={rj.get('message')!r}")
|
||||
|
||||
data = rj.get("data") or {}
|
||||
success_count += int(data.get("successCount", 0) or 0)
|
||||
failed_count += int(data.get("failedCount", 0) or 0)
|
||||
fd = data.get("failedData") or {}
|
||||
if isinstance(fd, dict):
|
||||
for k, v in fd.items():
|
||||
failed_data[str(k)] = str(v)
|
||||
|
||||
logger.info(
|
||||
"OA batch-update chunk done chunk_size=%s success=%s failed=%s",
|
||||
len(chunk),
|
||||
int(data.get("successCount", 0) or 0),
|
||||
int(data.get("failedCount", 0) or 0),
|
||||
)
|
||||
|
||||
return {
|
||||
"ehr_total_rows": len(emp_rows),
|
||||
"ehr_distinct_job_numbers": len(ehr_by_job_no),
|
||||
"oa_existing_job_numbers": len(oa_id_by_job_no),
|
||||
"prepared_updates": len(data_list),
|
||||
"not_found_in_oa": not_found_in_oa,
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"failed_data": dict(list(failed_data.items())[:100]),
|
||||
}
|
||||
finally:
|
||||
ehr.close()
|
||||
seeyon.close()
|
||||
|
||||
|
|
@ -12,6 +12,8 @@ dependencies = [
|
|||
"pydantic>=2.6",
|
||||
"pydantic-settings>=2.1",
|
||||
"cryptography>=41",
|
||||
"bcrypt>=4.1",
|
||||
"ldap3>=2.9",
|
||||
"celery>=5.3,<6",
|
||||
"redis>=5",
|
||||
"croniter>=2.0",
|
||||
|
|
|
|||
Loading…
Reference in New Issue