Skip to content

Commit

Permalink
fix: TencentBlueKing#58 add api for exporting login log in saas & upd…
Browse files Browse the repository at this point in the history
…ate sdk due to #1
  • Loading branch information
IMBlues committed Sep 27, 2021
1 parent d55810c commit 18be94a
Show file tree
Hide file tree
Showing 36 changed files with 1,103 additions and 259 deletions.
1 change: 1 addition & 0 deletions src/api/bkuser_core/audit/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class LoginLogSerializer(CustomFieldsMixin, serializers.Serializer):
reason = serializers.CharField(help_text=_("失败原因"))
create_time = serializers.DateTimeField(help_text=_("创建时间"))
username = serializers.CharField(help_text=_("登录用户"), source="profile.username")
profile_id = serializers.CharField(help_text=_("登录用户ID"), source="profile.id")
category_id = serializers.CharField(help_text=_("登录用户"), source="profile.category_id")


Expand Down
2 changes: 2 additions & 0 deletions src/api/bkuser_core/common/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,13 @@ class AdvancedListSerializer(serializers.Serializer):
input_formats=["iso-8601", "%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%S"],
help_text=_("筛选某个时间点前的记录"),
)
include_disabled = serializers.BooleanField(required=False, default=False, help_text=_("是否包含已软删除的数据"))


class AdvancedRetrieveSerialzier(serializers.Serializer):
fields = serializers.CharField(required=False, help_text=_("指定对象返回字段,支持多选,以逗号分隔,例如: username,status,id"))
lookup_field = serializers.CharField(required=False, help_text=_("指定查询字段,内容为 lookup_value 所属字段, 例如: username"))
include_disabled = serializers.BooleanField(required=False, default=False, help_text=_("是否包含已软删除的数据"))


class EmptySerializer(serializers.Serializer):
Expand Down
7 changes: 4 additions & 3 deletions src/saas/bkuser_shell/audit/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
"""
from django.conf.urls import url
from django.urls import path

from .views import GeneralLogViewSet, LoginLogViewSet

urlpatterns = [
##############
# audit_logs #
##############
url(r"^api/v2/audit/operation_logs/$", GeneralLogViewSet.as_view({"get": "list"}), name="operation_logs"),
url(r"^api/v2/audit/login_log/$", LoginLogViewSet.as_view({"get": "list"}), name="login_log"),
path("api/v2/audit/operation_logs/", GeneralLogViewSet.as_view({"get": "list"}), name="operation_logs"),
path("api/v2/audit/login_log/", LoginLogViewSet.as_view({"get": "list"}), name="login_log"),
path("api/v2/audit/login_log/export/", LoginLogViewSet.as_view({"get": "export"}), name="export_login_log"),
]
41 changes: 38 additions & 3 deletions src/saas/bkuser_shell/audit/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@
import logging

import bkuser_sdk
from bkuser_shell.audit import serializers
from bkuser_shell.audit.constants import OPERATION_OBJ_VALUE_MAP, OPERATION_VALUE_MAP
from bkuser_shell.bkiam.constants import ActionEnum
from bkuser_shell.common.error_codes import error_codes
from bkuser_shell.common.export import ProfileExcelExporter
from bkuser_shell.common.viewset import BkUserApiViewSet
from django.conf import settings
from django.utils.timezone import make_aware
from openpyxl import load_workbook

from bkuser_global.drf_crown import ResponseParams, inject_serializer
from bkuser_global.utils import get_timezone_offset

from . import serializers
from .constants import OPERATION_OBJ_VALUE_MAP, OPERATION_VALUE_MAP

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -92,3 +95,35 @@ def list(self, request, validated_data: dict):

params = self._get_request_params(validated_data)
return ResponseParams(api_instance.v2_audit_login_log_list(**params), {"context": {"categories": categories}})

@inject_serializer(query_in=serializers.LoginLogListReqeustSerializer, tags=["audit"])
def export(self, request, validated_data: dict):
"""导出登录日志"""
api_instance = bkuser_sdk.AuditApi(self.get_api_client_by_request(request))
profile_api_instance = bkuser_sdk.ProfilesApi(self.get_api_client_by_request(request))
fields_api_instance = bkuser_sdk.DynamicFieldsApi(self.get_api_client_by_request(request))

params = self._get_request_params(validated_data)
login_logs = api_instance.v2_audit_login_log_list(**params)["results"]
if not login_logs:
raise error_codes.CANNOT_EXPORT_EMPTY_LOG

fields = self.get_paging_results(fields_api_instance.v2_dynamic_fields_list)
fields.append(
bkuser_sdk.DynamicFields(name="create_time", display_name="登录时间", type="timer", order=0).to_dict()
)

exporter = ProfileExcelExporter(
load_workbook(settings.EXPORT_LOGIN_TEMPLATE), settings.EXPORT_EXCEL_FILENAME, fields, 1
)

profile_ids = [x["profile_id"] for x in login_logs]
# may too large refer to #88
profiles = self.get_paging_results(
profile_api_instance.v2_profiles_list, lookup_field="id", exact_lookups=profile_ids, include_disabled=True
)

extra_info = {x["profile_id"]: x for x in login_logs}
exporter.update_profiles(profiles, extra_info)

return exporter.get_excel_response()
125 changes: 15 additions & 110 deletions src/saas/bkuser_shell/categories/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,14 @@
from bkuser_shell.common.response import Response
from bkuser_shell.common.serializers import EmptySerializer
from bkuser_shell.common.viewset import BkUserApiViewSet
from bkuser_shell.config_center.constants import DynamicFieldTypeEnum
from bkuser_shell.organization.serializers.profiles import ProfileExportSerializer
from bkuser_shell.organization.utils import get_options_values_by_key
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.http.response import HttpResponse
from django.utils.translation import ugettext_lazy as _
from openpyxl import load_workbook
from openpyxl.styles import Alignment, Font, colors

from bkuser_global.drf_crown import inject_serializer

from ..common.export import ProfileExcelExporter
from .constants import TEST_CONNECTION_TYPES, CategoryStatus, CategoryTypeEnum

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -205,116 +200,26 @@ def export(self, request, category_id, validated_data):

department_ids = validated_data["department_ids"]
api_instance = bkuser_sdk.BatchApi(self.get_api_client_by_request(request))
field_api_instance = bkuser_sdk.DynamicFieldsApi(self.get_api_client_by_request(request))
all_profiles = api_instance.v2_batch_departments_multiple_retrieve_profiles(
department_ids=department_ids, recursive=True
)

try:
export_template = self.load_export_template()
first_sheet = export_template.worksheets[0]
first_sheet.alignment = Alignment(wrapText=True)
except Exception:
logger.exception("读取模版文件失败, Category<%s>", category_id)
raise error_codes.CATEGORY_EXPORT_FAILED.f(_("读取模版文件失败, 请联系管理员"))

try:
all_profiles = ProfileExportSerializer(all_profiles, many=True).data
fields_api_instance = bkuser_sdk.DynamicFieldsApi(self.get_api_client_by_request(request))
required_fields, not_required_fields = self._get_fields(fields_api_instance)
self._update_sheet_titles(required_fields, not_required_fields, first_sheet)
all_fields = required_fields + not_required_fields
except Exception:
logger.exception("导出 Category<%s> 失败", category_id)
raise error_codes.CATEGORY_EXPORT_FAILED.f(_("获取用户信息字段失败, 请联系管理员"))

# 写用户数据
for row_index, row_data in enumerate(all_profiles):
for index, field in enumerate(all_fields):
# 对于任意包含 options 值的内容
try:
if field["builtin"]:
raw_value = row_data[field["name"]]
else:
raw_value = row_data["extras"][field["name"]]
except Exception: # pylint: disable=broad-except
logger.exception("failed to get value from field<%s>", field)
continue

value = raw_value
# options 存储值为 key, 但是 Excel 交互值为 value
if field["type"] == DynamicFieldTypeEnum.ONE_ENUM.value:
value = ",".join(get_options_values_by_key(field["options"], [raw_value]))
elif field["type"] == DynamicFieldTypeEnum.MULTI_ENUM.value:
value = ",".join(get_options_values_by_key(field["options"], raw_value))

# 为电话添加国际号码段
if field["name"] == "telephone":
value = f'+{row_data["country_code"]}{row_data[field["name"]]}'

try:
first_sheet.cell(row=row_index + 3, column=index + 1, value=value)
except Exception: # pylint: disable=broad-except
logger.exception("写入表格数据失败 Category<%s>-Profile<%s>", category_id, row_data)
continue

response = self.make_excel_response(settings.EXPORT_EXCEL_FILENAME)
export_template.save(response)
return response

@inject_serializer(query_in=CategoryExportSerializer, out=EmptySerializer, tags=["categories"])
def export_template(self, request, category_id, validated_data):
"""生成excel导入模板样例文件"""
fields = self.get_paging_results(field_api_instance.v2_dynamic_fields_list)
exporter = ProfileExcelExporter(
load_workbook(settings.EXPORT_ORG_TEMPLATE), settings.EXPORT_EXCEL_FILENAME, fields
)
exporter.update_profiles(all_profiles)

export_template = self.load_export_template()
first_sheet = export_template.worksheets[0]
first_sheet.alignment = Alignment(wrapText=True)
return exporter.get_excel_response()

@inject_serializer(out=EmptySerializer, tags=["categories"])
def export_template(self, request, category_id):
"""生成excel导入模板样例文件"""
api_instance = bkuser_sdk.DynamicFieldsApi(self.get_api_client_by_request(request))
required_fields, not_required_fields = self._get_fields(api_instance)
self._update_sheet_titles(required_fields, not_required_fields, first_sheet)

response = self.make_excel_response(settings.EXPORT_EXCEL_FILENAME + "_template")
export_template.save(response)
return response

@staticmethod
def make_excel_response(file_name: str):
response = HttpResponse(content_type="application/ms-excel")
response["Content-Disposition"] = f"attachment;filename={file_name}.xlsx"
return response

@staticmethod
def load_export_template():
return load_workbook(settings.EXPORT_EXCEL_TEMPLATE)

def _get_fields(self, api_instance):
"""获取所有的字段"""
fields = self.get_paging_results(api_instance.v2_dynamic_fields_list)
exporter = ProfileExcelExporter(
load_workbook(settings.EXPORT_ORG_TEMPLATE), settings.EXPORT_EXCEL_FILENAME, fields
)

required_fields = [x for x in fields if x["require"]]
not_required_fields = [x for x in fields if not x["require"]]
return required_fields, not_required_fields

@staticmethod
def _update_sheet_titles(required_fields, not_required_fields, sheet, title_row_index=2):
"""更新表格标题"""
required_field_names = [x["display_name"] for x in required_fields]
not_required_field_names = [x["display_name"] for x in not_required_fields]

red_ft = Font(color=colors.RED)
black_ft = Font(color=colors.BLACK)
for index, field_name in enumerate(required_field_names):
_cell = sheet.cell(
row=title_row_index,
column=index + 1,
value=field_name,
)
_cell.font = red_ft

for index, field_name in enumerate(not_required_field_names):
_cell = sheet.cell(
row=title_row_index,
column=index + 1 + len(required_field_names),
value=field_name,
)
_cell.font = black_ft
return exporter.get_excel_response()
1 change: 1 addition & 0 deletions src/saas/bkuser_shell/common/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ def __getattr__(self, code_name):
ErrorCode("CANNOT_CREATE_PROFILE", _("无法创建用户")),
# 审计
ErrorCode("CANNOT_GET_AUDIT_LOG", _("无法获取审计日志")),
ErrorCode("CANNOT_EXPORT_EMPTY_LOG", _("审计日志为空,无法导出")),
# 密码重置
ErrorCode("CANNOT_GENERATE_TOKEN", _("无法生成重置链接")),
ErrorCode("CANNOT_GET_PROFILE_BY_TOKEN", _("链接已失效,请重新申请")),
Expand Down
110 changes: 110 additions & 0 deletions src/saas/bkuser_shell/common/export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
"""
Tencent is pleased to support the open source community by making 蓝鲸智云PaaS平台社区版 (BlueKing PaaS Community
Edition) available.
Copyright (C) 2017-2020 THL A29 Limited, a Tencent company. All rights reserved.
Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://opensource.org/licenses/MIT
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
"""
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, List

from bkuser_shell.config_center.constants import DynamicFieldTypeEnum
from bkuser_shell.organization.serializers.profiles import ProfileExportSerializer
from bkuser_shell.organization.utils import get_options_values_by_key
from django.http import HttpResponse
from openpyxl.styles import Alignment, Font, colors

if TYPE_CHECKING:
from openpyxl.workbook.workbook import Workbook


logger = logging.getLogger(__name__)


@dataclass
class ProfileExcelExporter:
"""导出"""

workbook: "Workbook"
exported_file_name: str
fields: list
title_row_index: int = 2

def __post_init__(self):
self.first_sheet = self.workbook.worksheets[0]
self.first_sheet.alignment = Alignment(wrapText=True)

def update_profiles(self, profiles: List[dict], extra_infos: dict = None):
self._update_sheet_titles()

for p_index, p in enumerate(profiles):

exported_profile = ProfileExportSerializer(p).data
for f_index, f in enumerate(self.fields):
try:
if f["builtin"]:
raw_value = exported_profile[f["name"]]
else:
raw_value = exported_profile["extras"][f["name"]]
except KeyError:
# 当无法从当前用户属性中找到对应字段时,尝试从 extra_infos 中获取
if extra_infos is None:
logger.exception("failed to get value from field<%s>", f)
continue

try:
raw_value = extra_infos[str(p["id"])][f["name"]]
except KeyError:
logger.exception("failed to get value from field<%s>", f)
continue

value = raw_value
# options 存储值为 key, 但是 Excel 交互值为 value
if f["type"] == DynamicFieldTypeEnum.ONE_ENUM.value:
value = ",".join(get_options_values_by_key(f["options"], [raw_value]))
elif f["type"] == DynamicFieldTypeEnum.MULTI_ENUM.value:
value = ",".join(get_options_values_by_key(f["options"], raw_value))

# 为电话添加国际号码段
if f["name"] == "telephone":
value = f'+{exported_profile["country_code"]}{exported_profile[f["name"]]}'

if raw_value is None:
continue

self.first_sheet.cell(row=p_index + self.title_row_index + 1, column=f_index + 1, value=value)

def _update_sheet_titles(self):
"""更新表格标题"""
required_field_names = [x["display_name"] for x in self.fields if x["builtin"]]
not_required_field_names = [x["display_name"] for x in self.fields if not x["builtin"]]

red_ft = Font(color=colors.COLOR_INDEX[2])
black_ft = Font(color=colors.BLACK)
for index, field_name in enumerate(required_field_names):
_cell = self.first_sheet.cell(
row=self.title_row_index,
column=index + 1,
value=field_name,
)
_cell.font = red_ft

for index, field_name in enumerate(not_required_field_names):
_cell = self.first_sheet.cell(
row=self.title_row_index,
column=index + 1 + len(required_field_names),
value=field_name,
)
_cell.font = black_ft

def get_excel_response(self):
response = HttpResponse(content_type="application/ms-excel")
response["Content-Disposition"] = f"attachment;filename={self.exported_file_name}.xlsx"
self.workbook.save(response)
return response
3 changes: 2 additions & 1 deletion src/saas/bkuser_shell/config/common/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
##########
# Export #
##########
EXPORT_EXCEL_TEMPLATE = MEDIA_ROOT + "/excel/export_template.xlsx"
EXPORT_ORG_TEMPLATE = MEDIA_ROOT + "/excel/export_org_tmpl.xlsx"
EXPORT_LOGIN_TEMPLATE = MEDIA_ROOT + "/excel/export_login_tmpl.xlsx"

# according to https://docs.qq.com/sheet/DTktLdUtmRldob21P?tab=uty37p&c=C3A0A0
EXPORT_EXCEL_FILENAME = "bk_user_export"
Expand Down
4 changes: 2 additions & 2 deletions src/saas/bkuser_shell/organization/serializers/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ class UpdateProfileSerializer(Serializer):
class ProfileExportSerializer(Serializer):
display_name = CharField()
username = CharField()
leader = ListField()
leader = LeaderSerializer(many=True)
department_name = SubDepartmentSerializer(many=True, source="departments")
staff_status = CharField(required=False)
status = CharField(required=False)
Expand All @@ -148,6 +148,6 @@ class ProfileExportSerializer(Serializer):
def to_representation(self, instance):
data = super().to_representation(instance)

data["leader"] = ",".join(x.username for x in data["leader"])
data["leader"] = ",".join(x["username"] for x in data["leader"])
data["department_name"] = ",".join([x["full_name"] for x in data["department_name"]])
return data
Binary file added src/saas/media/excel/export_login_tmpl.xlsx
Binary file not shown.
File renamed without changes.
Loading

0 comments on commit 18be94a

Please sign in to comment.