This commit is contained in:
Marsway 2026-03-04 10:05:45 +08:00
parent 6566549a05
commit e84d056f7f
39 changed files with 3841 additions and 30 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -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()

37
app/admin/secure.py Normal file
View File

@ -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)

View File

@ -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' %}

View File

@ -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' %}

View File

@ -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' %}

View File

@ -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>

View File

@ -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

1
app/api/__init__.py Normal file
View File

@ -0,0 +1 @@
from __future__ import annotations

51
app/api/auth_routes.py Normal file
View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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"]

114
app/integrations/ehr.py Normal file
View File

@ -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_tokengrant_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)

View File

@ -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"},
)

View File

@ -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

34
app/security/audit.py Normal file
View File

@ -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()

88
app/security/auth.py Normal file
View File

@ -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)

42
app/security/bootstrap.py Normal file
View File

@ -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()

View File

@ -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()

67
app/security/ldap_sync.py Normal file
View File

@ -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()

View File

@ -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}"

90
app/security/session.py Normal file
View File

@ -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()

View File

@ -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
)

9
app/tasks/ldap_sync.py Normal file
View File

@ -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()

1
data/admin.pass Normal file
View File

@ -0,0 +1 @@
ntpwKPBXc3w2nydPc4N-JQ

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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"]

View File

@ -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,
}

View File

@ -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()

View File

@ -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",