merge #1
Binary file not shown.
|
|
@ -9,6 +9,8 @@ from starlette.responses import RedirectResponse
|
||||||
from app.db import crud
|
from app.db import crud
|
||||||
from app.db.engine import get_session
|
from app.db.engine import get_session
|
||||||
from app.db.models import JobStatus
|
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
|
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")
|
@router.post("/admin/joblogs/{log_id}/retry")
|
||||||
def retry_joblog(request: Request, log_id: int):
|
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()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
log = crud.get_job_log(session, log_id)
|
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))
|
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))
|
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)
|
return RedirectResponse(url, status_code=303)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@router.post("/admin/jobs/{job_id}/run")
|
@router.post("/admin/jobs/{job_id}/run")
|
||||||
def run_job(request: Request, job_id: str):
|
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()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
job = crud.get_job(session, job_id)
|
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))
|
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))
|
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)
|
return RedirectResponse(url, status_code=303)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if model_view.can_create %}
|
{% if model_view.is_create_allowed(request) %}
|
||||||
<div class="ms-3 d-inline-block">
|
<div class="ms-3 d-inline-block">
|
||||||
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
||||||
+ 新建{{ model_view.name }}
|
+ 新建{{ model_view.name }}
|
||||||
|
|
@ -53,19 +53,20 @@
|
||||||
<div class="card-body border-bottom py-3">
|
<div class="card-body border-bottom py-3">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div class="dropdown col-4">
|
<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"
|
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
|
||||||
aria-haspopup="true" aria-expanded="false">
|
aria-haspopup="true" aria-expanded="false">
|
||||||
操作
|
操作
|
||||||
</button>
|
</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">
|
<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 }}"
|
<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-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
|
||||||
data-bs-target="#modal-delete">删除所选</a>
|
data-bs-target="#modal-delete">删除所选</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for custom_action, label in model_view._custom_actions_in_list.items() %}
|
{% 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 %}
|
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||||
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
||||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||||
|
|
@ -77,6 +78,7 @@
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -131,19 +133,19 @@
|
||||||
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<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"
|
<a href="{{ model_view._build_url_for('admin:details', request, row) }}" data-bs-toggle="tooltip"
|
||||||
data-bs-placement="top" title="View">
|
data-bs-placement="top" title="View">
|
||||||
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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"
|
<a href="{{ model_view._build_url_for('admin:edit', request, row) }}" data-bs-toggle="tooltip"
|
||||||
data-bs-placement="top" title="Edit">
|
data-bs-placement="top" title="Edit">
|
||||||
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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) }}"
|
<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-url="{{ model_view._url_for_delete(request, row) }}" data-bs-toggle="modal"
|
||||||
data-bs-target="#modal-delete" title="Delete">
|
data-bs-target="#modal-delete" title="Delete">
|
||||||
|
|
@ -172,9 +174,11 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td>
|
<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('确认立即执行该任务?');">
|
<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>
|
<button type="submit" class="btn btn-primary btn-sm">立即运行</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -291,12 +295,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if model_view.can_delete %}
|
{% if model_view.is_delete_allowed(request) %}
|
||||||
{% include 'sqladmin/modals/delete.html' %}
|
{% include 'sqladmin/modals/delete.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for custom_action in model_view._custom_actions_in_list %}
|
{% 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,
|
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
||||||
url=model_view._url_for_action(request, custom_action) %}
|
url=model_view._url_for_action(request, custom_action) %}
|
||||||
{% include 'sqladmin/modals/list_action_confirmation.html' %}
|
{% include 'sqladmin/modals/list_action_confirmation.html' %}
|
||||||
|
|
|
||||||
|
|
@ -66,12 +66,14 @@
|
||||||
返回
|
返回
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% if model_view.has_permission_code(request, "button:joblog:retry") %}
|
||||||
<div class="col-md-1">
|
<div class="col-md-1">
|
||||||
<form method="post" action="/admin/joblogs/{{ get_object_identifier(model) }}/retry" style="display:inline;" onsubmit="return confirm('确认重试该任务日志?');">
|
<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>
|
<button type="submit" class="btn btn-warning">重试</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% if model_view.can_delete %}
|
{% endif %}
|
||||||
|
{% if model_view.is_delete_allowed(request) %}
|
||||||
<div class="col-md-1">
|
<div class="col-md-1">
|
||||||
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(model) }}"
|
<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"
|
data-url="{{ model_view._url_for_delete(request, model) }}" data-bs-toggle="modal"
|
||||||
|
|
@ -80,7 +82,7 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if model_view.can_edit %}
|
{% if model_view.is_edit_allowed(request) %}
|
||||||
<div class="col-md-1">
|
<div class="col-md-1">
|
||||||
<a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
|
<a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
|
||||||
编辑
|
编辑
|
||||||
|
|
@ -89,6 +91,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for custom_action,label in model_view._custom_actions_in_detail.items() %}
|
{% for custom_action,label in model_view._custom_actions_in_detail.items() %}
|
||||||
<div class="col-md-1">
|
<div class="col-md-1">
|
||||||
|
{% if model_view.has_action_permission(request, custom_action) %}
|
||||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||||
<a href="#" class="btn btn-secondary" data-bs-toggle="modal"
|
<a href="#" class="btn btn-secondary" data-bs-toggle="modal"
|
||||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||||
|
|
@ -100,6 +103,7 @@
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -107,12 +111,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if model_view.can_delete %}
|
{% if model_view.is_delete_allowed(request) %}
|
||||||
{% include 'sqladmin/modals/delete.html' %}
|
{% include 'sqladmin/modals/delete.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for custom_action in model_view._custom_actions_in_detail %}
|
{% 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,
|
{% 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) %}
|
url=model_view._url_for_action(request, custom_action) + '?pks=' + (get_object_identifier(model) | string) %}
|
||||||
{% include 'sqladmin/modals/details_action_confirmation.html' %}
|
{% include 'sqladmin/modals/details_action_confirmation.html' %}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if model_view.can_create %}
|
{% if model_view.is_create_allowed(request) %}
|
||||||
<div class="ms-3 d-inline-block">
|
<div class="ms-3 d-inline-block">
|
||||||
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
||||||
+ 新建{{ model_view.name }}
|
+ 新建{{ model_view.name }}
|
||||||
|
|
@ -58,19 +58,20 @@
|
||||||
<div class="card-body border-bottom py-3">
|
<div class="card-body border-bottom py-3">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div class="dropdown col-4">
|
<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"
|
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
|
||||||
aria-haspopup="true" aria-expanded="false">
|
aria-haspopup="true" aria-expanded="false">
|
||||||
操作
|
操作
|
||||||
</button>
|
</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">
|
<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 }}"
|
<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-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
|
||||||
data-bs-target="#modal-delete">删除所选</a>
|
data-bs-target="#modal-delete">删除所选</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for custom_action, label in model_view._custom_actions_in_list.items() %}
|
{% 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 %}
|
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||||
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
||||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||||
|
|
@ -82,6 +83,7 @@
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -136,19 +138,19 @@
|
||||||
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<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"
|
<a href="{{ model_view._build_url_for('admin:details', request, row) }}" data-bs-toggle="tooltip"
|
||||||
data-bs-placement="top" title="View">
|
data-bs-placement="top" title="View">
|
||||||
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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"
|
<a href="{{ model_view._build_url_for('admin:edit', request, row) }}" data-bs-toggle="tooltip"
|
||||||
data-bs-placement="top" title="Edit">
|
data-bs-placement="top" title="Edit">
|
||||||
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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) }}"
|
<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-url="{{ model_view._url_for_delete(request, row) }}" data-bs-toggle="modal"
|
||||||
data-bs-target="#modal-delete" title="Delete">
|
data-bs-target="#modal-delete" title="Delete">
|
||||||
|
|
@ -177,9 +179,11 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td>
|
<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('确认重试该任务日志?');">
|
<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>
|
<button type="submit" class="btn btn-warning btn-sm">重试</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -296,12 +300,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if model_view.can_delete %}
|
{% if model_view.is_delete_allowed(request) %}
|
||||||
{% include 'sqladmin/modals/delete.html' %}
|
{% include 'sqladmin/modals/delete.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for custom_action in model_view._custom_actions_in_list %}
|
{% 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,
|
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
||||||
url=model_view._url_for_action(request, custom_action) %}
|
url=model_view._url_for_action(request, custom_action) %}
|
||||||
{% include 'sqladmin/modals/list_action_confirmation.html' %}
|
{% 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 croniter import croniter
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from sqladmin import ModelView, action
|
from sqladmin import action
|
||||||
from sqladmin.filters import OperationColumnFilter
|
from sqladmin.filters import OperationColumnFilter
|
||||||
from sqladmin.models import Request
|
from sqladmin.models import Request
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
@ -17,9 +17,15 @@ from app.db import crud
|
||||||
from app.db.engine import get_session
|
from app.db.engine import get_session
|
||||||
from app.db.models import JobStatus
|
from app.db.models import JobStatus
|
||||||
from app.db.models import Job, JobLog
|
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.plugins.manager import load_job_class
|
||||||
from app.security.fernet import encrypt_json
|
from app.security.fernet import encrypt_json
|
||||||
from app.tasks.execute import execute_job
|
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:
|
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
|
return (s[: n - 3] + "...") if len(s) > n else s
|
||||||
|
|
||||||
|
|
||||||
class JobAdmin(ModelView, model=Job):
|
class JobAdmin(SecureModelView, model=Job):
|
||||||
name = "任务"
|
name = "任务"
|
||||||
name_plural = "任务"
|
name_plural = "任务"
|
||||||
icon = "fa fa-cogs"
|
icon = "fa fa-cogs"
|
||||||
|
|
@ -117,6 +123,8 @@ class JobAdmin(ModelView, model=Job):
|
||||||
add_in_detail=True,
|
add_in_detail=True,
|
||||||
)
|
)
|
||||||
async def run_now(self, request: Request): # type: ignore[override]
|
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(",")
|
pks = request.query_params.get("pks", "").split(",")
|
||||||
ids = [p for p in pks if p]
|
ids = [p for p in pks if p]
|
||||||
if not ids:
|
if not ids:
|
||||||
|
|
@ -156,6 +164,17 @@ class JobAdmin(ModelView, model=Job):
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
if created_log_id is not None:
|
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))
|
url = request.url_for("admin:details", identity="job-log", pk=str(created_log_id))
|
||||||
return RedirectResponse(url, status_code=303)
|
return RedirectResponse(url, status_code=303)
|
||||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), 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,
|
add_in_detail=True,
|
||||||
)
|
)
|
||||||
async def view_logs(self, request: Request): # type: ignore[override]
|
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(",")
|
pks = request.query_params.get("pks", "").split(",")
|
||||||
pk = next((p for p in pks if p), "")
|
pk = next((p for p in pks if p), "")
|
||||||
base = str(request.url_for("admin:list", identity="job-log"))
|
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:
|
if pk:
|
||||||
return RedirectResponse(f"{base}?search={quote_plus(pk)}", status_code=303)
|
return RedirectResponse(f"{base}?search={quote_plus(pk)}", status_code=303)
|
||||||
return RedirectResponse(base, status_code=303)
|
return RedirectResponse(base, status_code=303)
|
||||||
|
|
@ -182,6 +214,8 @@ class JobAdmin(ModelView, model=Job):
|
||||||
add_in_detail=False,
|
add_in_detail=False,
|
||||||
)
|
)
|
||||||
async def disable_job(self, request: Request): # type: ignore[override]
|
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(",")
|
pks = request.query_params.get("pks", "").split(",")
|
||||||
ids = [p for p in pks if p]
|
ids = [p for p in pks if p]
|
||||||
session = get_session()
|
session = get_session()
|
||||||
|
|
@ -195,6 +229,17 @@ class JobAdmin(ModelView, model=Job):
|
||||||
session.commit()
|
session.commit()
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
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")
|
referer = request.headers.get("Referer")
|
||||||
return RedirectResponse(referer or request.url_for("admin:list", identity=self.identity), status_code=303)
|
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,
|
add_in_detail=False,
|
||||||
)
|
)
|
||||||
async def clear_job_logs(self, request: Request): # type: ignore[override]
|
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(",")
|
pks = request.query_params.get("pks", "").split(",")
|
||||||
ids = [p for p in pks if p]
|
ids = [p for p in pks if p]
|
||||||
session = get_session()
|
session = get_session()
|
||||||
|
|
@ -214,6 +261,17 @@ class JobAdmin(ModelView, model=Job):
|
||||||
crud.delete_job_logs_by_job_id(session, pk)
|
crud.delete_job_logs_by_job_id(session, pk)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
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")
|
referer = request.headers.get("Referer")
|
||||||
return RedirectResponse(referer or request.url_for("admin:list", identity=self.identity), status_code=303)
|
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,
|
add_in_detail=False,
|
||||||
)
|
)
|
||||||
async def delete_job_with_logs(self, request: Request): # type: ignore[override]
|
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(",")
|
pks = request.query_params.get("pks", "").split(",")
|
||||||
ids = [p for p in pks if p]
|
ids = [p for p in pks if p]
|
||||||
session = get_session()
|
session = get_session()
|
||||||
|
|
@ -238,6 +298,17 @@ class JobAdmin(ModelView, model=Job):
|
||||||
session.commit()
|
session.commit()
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
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)
|
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]
|
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")
|
raise ValueError("secret_cfg must be a JSON object")
|
||||||
data["secret_cfg"] = encrypt_json(scfg2)
|
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 = "任务日志"
|
||||||
name_plural = "任务日志"
|
name_plural = "任务日志"
|
||||||
icon = "fa fa-list"
|
icon = "fa fa-list"
|
||||||
|
|
@ -381,3 +462,135 @@ class JobLogAdmin(ModelView, model=JobLog):
|
||||||
+ "</pre>"
|
+ "</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
|
dev_mode: bool = False
|
||||||
log_dir: str | None = "/data/logs"
|
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()
|
settings = Settings()
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -4,7 +4,7 @@ import enum
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
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
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -12,6 +12,21 @@ class Base(DeclarativeBase):
|
||||||
pass
|
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):
|
class Job(Base):
|
||||||
__tablename__ = "jobs"
|
__tablename__ = "jobs"
|
||||||
|
|
||||||
|
|
@ -64,3 +79,80 @@ class JobLog(Base):
|
||||||
job: Mapped[Job] = relationship(back_populates="logs")
|
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.base import BaseClient
|
||||||
from app.integrations.didi import DidiClient
|
from app.integrations.didi import DidiClient
|
||||||
|
from app.integrations.ehr import EhrClient
|
||||||
from app.integrations.seeyon import SeeyonClient
|
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,
|
json=body,
|
||||||
headers={"Content-Type": "application/json"},
|
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
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from sqladmin import Admin
|
from sqladmin import Admin
|
||||||
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
from app.admin.routes import router as admin_router
|
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.config import settings
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
from app.db.engine import engine
|
from app.db.engine import engine
|
||||||
from app.db.schema import ensure_schema
|
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.security.fernet import get_or_create_fernet_key
|
||||||
|
from app.api.auth_routes import router as auth_router
|
||||||
|
|
||||||
|
|
||||||
def _init_db() -> None:
|
def _init_db() -> None:
|
||||||
|
|
@ -26,6 +39,7 @@ def _ensure_runtime() -> None:
|
||||||
# 确保 Fernet key 准备好(或自动生成)
|
# 确保 Fernet key 准备好(或自动生成)
|
||||||
get_or_create_fernet_key(settings.fernet_key_path)
|
get_or_create_fernet_key(settings.fernet_key_path)
|
||||||
_init_db()
|
_init_db()
|
||||||
|
bootstrap_admin()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|
@ -34,16 +48,46 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
app = FastAPI(title=settings.app_name)
|
app = FastAPI(title=settings.app_name)
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
|
||||||
admin = Admin(app=app, engine=engine, templates_dir="app/admin/templates")
|
admin = Admin(app=app, engine=engine, templates_dir="app/admin/templates")
|
||||||
admin.add_view(JobAdmin)
|
admin.add_view(JobAdmin)
|
||||||
admin.add_view(JobLogAdmin)
|
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")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"ok": True, "name": settings.app_name}
|
return {"ok": True, "name": settings.app_name}
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root():
|
||||||
|
return RedirectResponse("/admin", status_code=303)
|
||||||
|
|
||||||
return app
|
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=[
|
include=[
|
||||||
"app.tasks.execute",
|
"app.tasks.execute",
|
||||||
"app.tasks.dispatcher",
|
"app.tasks.dispatcher",
|
||||||
|
"app.tasks.ldap_sync",
|
||||||
],
|
],
|
||||||
beat_schedule={
|
beat_schedule={
|
||||||
"connecthub-dispatcher-tick-every-minute": {
|
"connecthub-dispatcher-tick-every-minute": {
|
||||||
"task": "connecthub.dispatcher.tick",
|
"task": "connecthub.dispatcher.tick",
|
||||||
"schedule": 60.0,
|
"schedule": 60.0,
|
||||||
}
|
},
|
||||||
|
"connecthub-ldap-sync-every-5-minutes": {
|
||||||
|
"task": "connecthub.ldap.sync",
|
||||||
|
"schedule": 300.0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
worker_redirect_stdouts=False
|
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
|
DEV_MODE=1
|
||||||
LOG_DIR=/data/logs
|
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>=2.6",
|
||||||
"pydantic-settings>=2.1",
|
"pydantic-settings>=2.1",
|
||||||
"cryptography>=41",
|
"cryptography>=41",
|
||||||
|
"bcrypt>=4.1",
|
||||||
|
"ldap3>=2.9",
|
||||||
"celery>=5.3,<6",
|
"celery>=5.3,<6",
|
||||||
"redis>=5",
|
"redis>=5",
|
||||||
"croniter>=2.0",
|
"croniter>=2.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue