From b38d83c57898a61274030aa0dca7cb809518357f Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:05:38 +0800 Subject: [PATCH] feat: report charts (#15630) * perf: initial * perf: basic finished * perf: depend * perf: Update Dockerfile with new base image tag * perf: Add user report api * perf: Update Dockerfile with new base image tag * perf: Use user report api * perf: Update Dockerfile with new base image tag * perf: user login report * perf: Update Dockerfile with new base image tag * perf: user change password * perf: change password dashboard * perf: Update Dockerfile with new base image tag * perf: Translate * perf: asset api * perf: asset activity * perf: Asset report * perf: add charts_map * perf: account report * perf: Translate * perf: account automation * perf: Account automation * perf: title * perf: Update Dockerfile with new base image tag --------- Co-authored-by: ibuler Co-authored-by: feng <1304903146@qq.com> Co-authored-by: github-actions[bot] Co-authored-by: wangruidong <940853815@qq.com> Co-authored-by: feng626 <57284900+feng626@users.noreply.github.com> --- Dockerfile | 2 +- .../automations/change_secret_dashboard.py | 6 +- apps/assets/const/automation.py | 4 + apps/assets/const/category.py | 4 + apps/assets/models/automations/base.py | 2 +- apps/audits/api.py | 12 +- apps/audits/models.py | 18 +++ apps/i18n/lina/en.json | 44 ++++++ apps/i18n/lina/zh.json | 42 +++++ apps/jumpserver/settings/base.py | 1 + apps/jumpserver/urls.py | 2 + apps/reports/__init__.py | 0 apps/reports/admin.py | 3 + apps/reports/api/__init__.py | 4 + apps/reports/api/accouts/__init__.py | 2 + apps/reports/api/accouts/account.py | 96 +++++++++++ apps/reports/api/accouts/automation.py | 87 ++++++++++ apps/reports/api/accouts/base.py | 21 +++ apps/reports/api/assets/__init__.py | 2 + apps/reports/api/assets/activity.py | 78 +++++++++ apps/reports/api/assets/asset.py | 85 ++++++++++ apps/reports/api/assets/base.py | 21 +++ apps/reports/api/report.py | 9 ++ apps/reports/api/users/__init__.py | 2 + apps/reports/api/users/change_password.py | 86 ++++++++++ apps/reports/api/users/user.py | 129 +++++++++++++++ apps/reports/apps.py | 6 + apps/reports/migrations/__init__.py | 0 apps/reports/mixins.py | 46 ++++++ apps/reports/models.py | 3 + apps/reports/tests.py | 3 + apps/reports/urls/__init__.py | 0 apps/reports/urls/api_urls.py | 15 ++ apps/reports/urls/view_urls.py | 15 ++ apps/reports/views.py | 149 ++++++++++++++++++ apps/terminal/const.py | 11 ++ apps/terminal/models/session/session.py | 11 +- apps/terminal/models/session/sharing.py | 8 +- apps/users/models/user/_source.py | 4 + pyproject.toml | 4 +- requirements/mac_pkg.sh | 5 +- 41 files changed, 1008 insertions(+), 34 deletions(-) create mode 100644 apps/reports/__init__.py create mode 100644 apps/reports/admin.py create mode 100644 apps/reports/api/__init__.py create mode 100644 apps/reports/api/accouts/__init__.py create mode 100644 apps/reports/api/accouts/account.py create mode 100644 apps/reports/api/accouts/automation.py create mode 100644 apps/reports/api/accouts/base.py create mode 100644 apps/reports/api/assets/__init__.py create mode 100644 apps/reports/api/assets/activity.py create mode 100644 apps/reports/api/assets/asset.py create mode 100644 apps/reports/api/assets/base.py create mode 100644 apps/reports/api/report.py create mode 100644 apps/reports/api/users/__init__.py create mode 100644 apps/reports/api/users/change_password.py create mode 100644 apps/reports/api/users/user.py create mode 100644 apps/reports/apps.py create mode 100644 apps/reports/migrations/__init__.py create mode 100644 apps/reports/mixins.py create mode 100644 apps/reports/models.py create mode 100644 apps/reports/tests.py create mode 100644 apps/reports/urls/__init__.py create mode 100644 apps/reports/urls/api_urls.py create mode 100644 apps/reports/urls/view_urls.py create mode 100644 apps/reports/views.py diff --git a/Dockerfile b/Dockerfile index e897fc84d..ef644e2dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM jumpserver/core-base:20250509_094529 AS stage-build +FROM jumpserver/core-base:20250805_083232 AS stage-build ARG VERSION diff --git a/apps/accounts/api/automations/change_secret_dashboard.py b/apps/accounts/api/automations/change_secret_dashboard.py index 7214e8cdf..4d6b71266 100644 --- a/apps/accounts/api/automations/change_secret_dashboard.py +++ b/apps/accounts/api/automations/change_secret_dashboard.py @@ -90,10 +90,10 @@ class ChangeSecretDashboardApi(APIView): def get_change_secret_asset_queryset(self): qs = self.change_secrets_queryset - node_ids = qs.filter(nodes__isnull=False).values_list('nodes', flat=True).distinct() - nodes = Node.objects.filter(id__in=node_ids) + node_ids = qs.values_list('nodes', flat=True).distinct() + nodes = Node.objects.filter(id__in=node_ids).only('id', 'key') node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True) - direct_asset_ids = qs.filter(assets__isnull=False).values_list('assets', flat=True).distinct() + direct_asset_ids = qs.values_list('assets', flat=True).distinct() asset_ids = set(list(direct_asset_ids) + list(node_asset_ids)) return Asset.objects.filter(id__in=asset_ids) diff --git a/apps/assets/const/automation.py b/apps/assets/const/automation.py index 1d48575a3..c5971073c 100644 --- a/apps/assets/const/automation.py +++ b/apps/assets/const/automation.py @@ -14,6 +14,10 @@ class Connectivity(TextChoices): NTLM_ERR = 'ntlm_err', _('NTLM credentials rejected error') CREATE_TEMPORARY_ERR = 'create_temp_err', _('Create temporary error') + @classmethod + def as_dict(cls): + return {choice.value: choice.label for choice in cls} + class AutomationTypes(TextChoices): ping = 'ping', _('Ping') diff --git a/apps/assets/const/category.py b/apps/assets/const/category.py index bf109b8ff..b7ff3be12 100644 --- a/apps/assets/const/category.py +++ b/apps/assets/const/category.py @@ -20,3 +20,7 @@ class Category(ChoicesMixin, models.TextChoices): _category = getattr(cls, category.upper(), None) choices = [(_category.value, _category.label)] if _category else cls.choices return choices + + @classmethod + def as_dict(cls): + return {choice.value: choice.label for choice in cls} diff --git a/apps/assets/models/automations/base.py b/apps/assets/models/automations/base.py index cbeadea57..4bf20b5cd 100644 --- a/apps/assets/models/automations/base.py +++ b/apps/assets/models/automations/base.py @@ -53,7 +53,7 @@ class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel): return name def get_all_assets(self): - nodes = self.nodes.all() + nodes = self.nodes.only("id", "key") node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list("id", flat=True) direct_asset_ids = self.assets.all().values_list("id", flat=True) asset_ids = set(list(direct_asset_ids) + list(node_asset_ids)) diff --git a/apps/audits/api.py b/apps/audits/api.py index d60eeb523..b08df5d6e 100644 --- a/apps/audits/api.py +++ b/apps/audits/api.py @@ -172,10 +172,7 @@ class UserLoginLogViewSet(UserLoginCommonMixin, OrgReadonlyModelViewSet): def get_queryset(self): queryset = super().get_queryset() - if current_org.is_root() or not settings.XPACK_ENABLED: - return queryset - users = self.get_org_member_usernames() - queryset = queryset.filter(username__in=users) + queryset = queryset.model.filter_queryset_by_org(queryset) return queryset @@ -297,12 +294,7 @@ class PasswordChangeLogViewSet(OrgReadonlyModelViewSet): def get_queryset(self): queryset = super().get_queryset() - if not current_org.is_root(): - users = current_org.get_members() - queryset = queryset.filter( - user__in=[str(user) for user in users] - ) - return queryset + return self.model.filter_queryset_by_org(queryset) class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet): diff --git a/apps/audits/models.py b/apps/audits/models.py index 1072c7b6e..15ce323b8 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -189,6 +189,15 @@ class PasswordChangeLog(models.Model): class Meta: verbose_name = _("Password change log") + @staticmethod + def filter_queryset_by_org(queryset): + if not current_org.is_root(): + users = current_org.get_members() + queryset = queryset.filter( + user__in=[str(user) for user in users] + ) + return queryset + class UserLoginLog(models.Model): id = models.UUIDField(default=uuid.uuid4, primary_key=True) @@ -258,6 +267,15 @@ class UserLoginLog(models.Model): reason = old_reason_choices.get(self.reason, self.reason) return reason + @staticmethod + def filter_queryset_by_org(queryset): + from audits.utils import construct_userlogin_usernames + if current_org.is_root() or not settings.XPACK_ENABLED: + return queryset + user_queryset = current_org.get_members() + users = construct_userlogin_usernames(user_queryset) + return queryset.filter(username__in=users) + class Meta: ordering = ["-datetime", "username"] verbose_name = _("User login log") diff --git a/apps/i18n/lina/en.json b/apps/i18n/lina/en.json index 2a6db14cc..a980476f9 100644 --- a/apps/i18n/lina/en.json +++ b/apps/i18n/lina/en.json @@ -1559,5 +1559,49 @@ "setVariable": "Set variable", "userId": "User ID", "userName": "User name", + "UserActivity": "User activity", + "AssetActivity": "Asset activity", + "UserReport": "User report", + "AssetReport": "Asset report", + "AccountReport": "Account report", + "NotEnableMfa": "Not enabled mfa", + "FaceVector": "Face vector", + "NeedUpdatePassword": "Need to update password", + "UserOverview": "User overview", + "LoginSource": "Login source", + "LoginCtyDistribution": "Login country distribution", + "VisitTimeDistribution": "Visit time distribution", + "LoginMethodStatistics": "Login method statistics", + "UserLoginReport": "User login report", + "UserChangePasswordReport": "User change password report", + "ChangePasswordOverview": "Change password overview", + "Operator": "Operator", + "TargetUser": "Target user", + "OperatorGeographicDistribution": "Operator geographic distribution", + "UserModificationTrends": "User modification trends", + "ModifyTheTargetUserTopTank": "Modify the target user ranking", + "TopRankOfOperateUsers": "Ranking of operating users", + "AssetStatisticsReport": "Asset statistics report", + "AssetActivityReport": "Asset activity report", + "AssetOverview": "Asset overview", + "LinkedDomains": "Linked domains", + "ConnectedDirectoryServices": "Connected directory services", + "AssetTypeDistribution": "Asset type distribution", + "WeeklyGrowthTrend": "Weekly growth trend", + "DistributionOfAssetLoginMethods": "Distribution of asset login methods", + "RemoteLoginProtocolUsageDistribution": "Remote login protocol usage distribution", + "OperatingSystemDistributionOfLoginAssets": "Operating system distribution of login assets", + "AssetLoginTrends": "Asset login trends", + "ConnectionCount": "Connection count", + "AccountStatisticsReport": "Account statistics report", + "AccountOverview": "Account overview", + "AccountCreationSourceDistribution": "Account creation source distribution", + "AccountConnectivityStatusDistribution": "Account connectivity status distribution", + "AccountPasswordChangeTrends": "Account password change trends", + "RankByNumberOfAssetAccounts": "Ranking by number of asset accounts", + "AccountAndPasswordChangeRank": "Account and password change ranking", + "AccountAutomationReport": "Account automation report", + "AccountAutomationOverview": "Account automation overview", + "TaskExecutionTrends": "Task execution trends", "EmailHelpText": "Please click the 'Submit' button to save the current configuration before clicking 'Test Connection' to ensure the settings take effect." } diff --git a/apps/i18n/lina/zh.json b/apps/i18n/lina/zh.json index 062ecf78f..b1a3c7d93 100644 --- a/apps/i18n/lina/zh.json +++ b/apps/i18n/lina/zh.json @@ -1568,6 +1568,48 @@ "setVariable": "设置参数", "userId": "用户ID", "userName": "用户名", + "UserReport": "用户报表", + "AssetReport": "资产报表", + "AccountReport": "账号报表", + "NotEnableMfa": "未启动 MFA", + "FaceVector": "人脸信息", + "NeedUpdatePassword": "需改密", + "UserOverview": "用户概览", + "LoginSource": "登录来源", + "LoginCtyDistribution": "登录城市分布", + "VisitTimeDistribution": "访问时段分布", + "LoginMethodStatistics": "登录方法统计", + "UserLoginReport": "用户登录报告", + "UserChangePasswordReport": "用户改密报告", + "ChangePasswordOverview": "改密概览", + "Operator": "操作用户", + "TargetUser": "目标用户", + "OperatorGeographicDistribution": "操作用户地域分布", + "UserModificationTrends": "用户修改趋势", + "ModifyTheTargetUserTopTank": "修改目标用户排名", + "TopRankOfOperateUsers": "操作用户排名", + "AssetStatisticsReport": "资产统计报告", + "AssetActivityReport": "资产活动报告", + "AssetOverview": "用户概览", + "LinkedDomains": "已关联网域", + "ConnectedDirectoryServices": "已关联目录服务", + "AssetTypeDistribution": "资产类型分布", + "WeeklyGrowthTrend": "本周新增趋势", + "DistributionOfAssetLoginMethods": "资产登录方式分布", + "RemoteLoginProtocolUsageDistribution": "远程登录协议使用分布", + "OperatingSystemDistributionOfLoginAssets": "登录资产的操作系统分布", + "AssetLoginTrends": "资产登录趋势", + "ConnectionCount": "连接数", + "AccountStatisticsReport": "账号统计报告", + "AccountOverview": "账号概览", + "AccountCreationSourceDistribution": "账号创建来源分布", + "AccountConnectivityStatusDistribution": "账号可连接性状态分布", + "AccountPasswordChangeTrends": "账号改密趋势", + "RankByNumberOfAssetAccounts": "资产账号数量排名", + "AccountAndPasswordChangeRank": "账号密码变更排名", + "AccountAutomationReport": "账号自动化报告", + "AccountAutomationOverview": "自动化概览", + "TaskExecutionTrends": "任务执行趋势", "EmailHelpText": "请点击'提交'按钮保存当前配置后,再点击'测试连接'以确保信息生效", "None": "无" } diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index 58cc38ece..bf929ef97 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -136,6 +136,7 @@ INSTALLED_APPS = [ 'notifications.apps.NotificationsConfig', 'rbac.apps.RBACConfig', 'labels.apps.LabelsConfig', + 'reports.apps.ReportsConfig', 'rest_framework', 'drf_yasg', 'django_cas_ng', diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 15089997d..b1b2372ef 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -30,6 +30,7 @@ resource_api = [ path('notifications/', include('notifications.urls.api_urls', namespace='api-notifications')), path('rbac/', include('rbac.urls.api_urls', namespace='api-rbac')), path('labels/', include('labels.urls', namespace='api-label')), + path('reports/', include('reports.urls.api_urls', namespace='api-reports')), ] api_v1 = resource_api + [ @@ -42,6 +43,7 @@ api_v1 = resource_api + [ app_view_patterns = [ path('auth/', include('authentication.urls.view_urls'), name='auth'), path('ops/', include('ops.urls.view_urls'), name='ops'), + path('reports/', include('reports.urls.view_urls'), name='reports'), path('tickets/', include('tickets.urls.view_urls'), name='tickets'), path('common/', include('common.urls.view_urls'), name='common'), re_path(r'flower/(?P.*)', views.celery_flower_view, name='flower-view'), diff --git a/apps/reports/__init__.py b/apps/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/reports/admin.py b/apps/reports/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/apps/reports/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/reports/api/__init__.py b/apps/reports/api/__init__.py new file mode 100644 index 000000000..879c416a9 --- /dev/null +++ b/apps/reports/api/__init__.py @@ -0,0 +1,4 @@ +from .accouts import * +from .assets import * +from .report import * +from .users import * diff --git a/apps/reports/api/accouts/__init__.py b/apps/reports/api/accouts/__init__.py new file mode 100644 index 000000000..6db502556 --- /dev/null +++ b/apps/reports/api/accouts/__init__.py @@ -0,0 +1,2 @@ +from .account import * +from .automation import * diff --git a/apps/reports/api/accouts/account.py b/apps/reports/api/accouts/account.py new file mode 100644 index 000000000..74203bfe4 --- /dev/null +++ b/apps/reports/api/accouts/account.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# +from collections import defaultdict + +from django.db.models import Count, Q, F, Value +from django.db.models.functions import Concat +from django.http import JsonResponse +from rest_framework.views import APIView + +from accounts.models import Account, AccountTemplate +from assets.const import Connectivity +from common.permissions import IsValidLicense +from common.utils import lazyproperty +from rbac.permissions import RBACPermission +from reports.api.assets.base import group_stats +from reports.mixins import DateRangeMixin + +__all__ = ['AccountStatisticApi'] + + +class AccountStatisticApi(DateRangeMixin, APIView): + http_method_names = ['get'] + rbac_perms = { + 'GET': 'accounts.view_account', + } + permission_classes = [RBACPermission, IsValidLicense] + + @lazyproperty + def base_qs(self): + return Account.objects.all() + + @lazyproperty + def template_qs(self): + return AccountTemplate.objects.all() + + def get_change_secret_account_metrics(self): + filtered_queryset = self.filter_by_date_range(self.base_qs, 'date_change_secret') + + data = defaultdict(set) + for t, _id in filtered_queryset.values_list('date_change_secret', 'id'): + date_str = str(t.date()) + data[date_str].add(_id) + + metrics = [len(data.get(str(d), set())) for d in self.date_range_list] + return metrics + + def get(self, request, *args, **kwargs): + qs = self.base_qs + + stats = qs.aggregate( + total=Count(1), + active=Count(1, filter=Q(is_active=True)), + connected=Count(1, filter=Q(connectivity=Connectivity.OK)), + su_from=Count(1, filter=Q(su_from__isnull=False)), + date_change_secret=Count(1, filter=Q(secret_reset=True)), + ) + + stats['template_total'] = self.template_qs.count() + + source_pie_data = [ + {'name': str(source), 'value': total} + for source, total in + qs.values('source').annotate( + total=Count(1) + ).values_list('source', 'total') + ] + + by_connectivity = group_stats( + qs, 'label', 'connectivity', Connectivity.as_dict(), + ) + + top_assets = qs.values('asset__name') \ + .annotate(account_count=Count('id')) \ + .order_by('-account_count')[:10] + + top_version_accounts = qs.annotate( + display_key=Concat( + F('asset__name'), + Value('('), + F('username'), + Value(')') + ) + ).values('display_key', 'version').order_by('-version')[:10] + + payload = { + 'account_stats': stats, + 'top_assets': list(top_assets), + 'top_version_accounts': list(top_version_accounts), + 'source_pie': source_pie_data, + 'by_connectivity': by_connectivity, + 'change_secret_account_metrics': { + 'dates_metrics_date': self.dates_metrics_date, + 'dates_metrics_total': self.get_change_secret_account_metrics(), + } + } + return JsonResponse(payload, status=200) diff --git a/apps/reports/api/accouts/automation.py b/apps/reports/api/accouts/automation.py new file mode 100644 index 000000000..4bc92b624 --- /dev/null +++ b/apps/reports/api/accouts/automation.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# +from collections import defaultdict + +from django.http import JsonResponse +from rest_framework.views import APIView + +from accounts.const import AutomationTypes +from accounts.models import ChangeSecretAutomation, PushAccountAutomation, BackupAccountAutomation, \ + CheckAccountAutomation, GatherAccountsAutomation, AutomationExecution +from common.permissions import IsValidLicense +from rbac.permissions import RBACPermission +from reports.mixins import DateRangeMixin + +__all__ = ['AccountAutomationApi'] + + +class AccountAutomationApi(DateRangeMixin, APIView): + http_method_names = ['get'] + rbac_perms = { + 'GET': 'accounts.view_account', + } + permission_classes = [RBACPermission, IsValidLicense] + + @property + def change_secret_qs(self): + return ChangeSecretAutomation.objects.all() + + @property + def push_qs(self): + return PushAccountAutomation.objects.all() + + @property + def backup_qs(self): + return BackupAccountAutomation.objects.all() + + @property + def check_qs(self): + return CheckAccountAutomation.objects.all() + + @property + def collect_qs(self): + return GatherAccountsAutomation.objects.all() + + def get_execution_metrics(self): + executions = AutomationExecution.objects.filter(type__in=AutomationTypes.values) + filtered_queryset = self.filter_by_date_range(executions, 'date_start') + + types = set() + data = defaultdict(lambda: defaultdict(int)) + for t, tp in filtered_queryset.values_list('date_start', 'type'): + if not tp: + continue + types.add(tp) + date_str = str(t.date()) + data[date_str][tp] += 1 + + tp_map = defaultdict(list) + for d in self.date_range_list: + tp_data = data.get(str(d), {}) + for tp in types: + tp_map[tp].append(tp_data.get(tp, 0)) + + metrics = {} + for tp, values in tp_map.items(): + _tp = AutomationTypes(tp).label + metrics[str(_tp)] = values + + return metrics + + def get(self, request, *args, **kwargs): + stats = { + 'push': self.push_qs.count(), + 'check': self.check_qs.count(), + 'backup': self.backup_qs.count(), + 'collect': self.collect_qs.count(), + 'change_secret': self.change_secret_qs.count(), + } + + payload = { + 'automation_stats': stats, + 'execution_metrics': { + 'dates_metrics_date': self.dates_metrics_date, + 'data': self.get_execution_metrics() + }, + } + return JsonResponse(payload, status=200) diff --git a/apps/reports/api/accouts/base.py b/apps/reports/api/accouts/base.py new file mode 100644 index 000000000..f905777d4 --- /dev/null +++ b/apps/reports/api/accouts/base.py @@ -0,0 +1,21 @@ +from django.db.models import Count, F + + +def group_stats(queryset, alias, key, label_map=None): + grouped = ( + queryset + .exclude(**{f'{key}__isnull': True}) + .values(**{alias: F(key)}) + .annotate(total=Count('id')) + ) + + data = [ + { + alias: val, + 'total': cnt, + **({'label': label_map.get(val, val)} if label_map else {}) + } + for val, cnt in grouped.values_list(alias, 'total') + ] + + return data diff --git a/apps/reports/api/assets/__init__.py b/apps/reports/api/assets/__init__.py new file mode 100644 index 000000000..d30f81b1f --- /dev/null +++ b/apps/reports/api/assets/__init__.py @@ -0,0 +1,2 @@ +from .activity import * +from .asset import * diff --git a/apps/reports/api/assets/activity.py b/apps/reports/api/assets/activity.py new file mode 100644 index 000000000..a194e8cce --- /dev/null +++ b/apps/reports/api/assets/activity.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# +from collections import defaultdict + +from django.db.models import Count +from django.http.response import JsonResponse +from rest_framework.views import APIView + +from assets.const import AllTypes +from assets.models import Asset +from common.permissions import IsValidLicense +from common.utils import lazyproperty +from rbac.permissions import RBACPermission +from reports.api.assets.base import group_stats +from reports.mixins import DateRangeMixin +from terminal.const import LoginFrom +from terminal.models import Session + +__all__ = ['AssetActivityApi'] + + +class AssetActivityApi(DateRangeMixin, APIView): + http_method_names = ['get'] + rbac_perms = { + 'GET': 'terminal.view_session', + } + permission_classes = [RBACPermission, IsValidLicense] + + def get_asset_login_metrics(self, queryset): + data = defaultdict(set) + for t, _id in queryset.values_list('date_start', 'id'): + date_str = str(t.date()) + data[date_str].add(_id) + + metrics = [len(data.get(str(d), set())) for d in self.date_range_list] + return metrics + + @lazyproperty + def session_qs(self): + return Session.objects.all() + + def get(self, request, *args, **kwargs): + qs = self.session_qs + qs = self.filter_by_date_range(qs, 'date_start') + all_type_dict = dict(AllTypes.choices()) + + stats = qs.aggregate( + total=Count(1), + asset_count=Count('asset_id', distinct=True), + user_count=Count('user_id', distinct=True), + ) + + asset_ids = {str(_id) for _id in qs.values_list('asset_id', flat=True).distinct()} + assets = Asset.objects.filter(id__in=asset_ids) + + asset_login_by_protocol = group_stats( + qs, 'label', 'protocol' + ) + + asset_login_by_from = group_stats( + qs, 'label', 'login_from', LoginFrom.as_dict() + ) + + asset_by_type = group_stats( + assets, 'label', 'platform__type', all_type_dict, + ) + + payload = { + 'session_stats': stats, + 'asset_login_by_type': asset_by_type, + 'asset_login_by_from': asset_login_by_from, + 'asset_login_by_protocol': asset_login_by_protocol, + 'asset_login_log_metrics': { + 'dates_metrics_date': self.dates_metrics_date, + 'dates_metrics_total': self.get_asset_login_metrics(qs), + } + } + return JsonResponse(payload, status=200) diff --git a/apps/reports/api/assets/asset.py b/apps/reports/api/assets/asset.py new file mode 100644 index 000000000..8693aafd7 --- /dev/null +++ b/apps/reports/api/assets/asset.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# +from collections import defaultdict, OrderedDict + +from django.db.models import Count, Q +from django.http import JsonResponse +from rest_framework.views import APIView + +from assets.const import AllTypes, Connectivity +from assets.models import Asset, Platform +from common.permissions import IsValidLicense +from common.utils import lazyproperty +from rbac.permissions import RBACPermission +from reports.api.assets.base import group_stats +from reports.mixins import DateRangeMixin + +__all__ = ['AssetStatisticApi'] + + +class AssetStatisticApi(DateRangeMixin, APIView): + http_method_names = ['get'] + rbac_perms = { + 'GET': 'assets.view_asset', + } + permission_classes = [RBACPermission, IsValidLicense] + + @lazyproperty + def base_qs(self): + return Asset.objects.all() + + def get_added_asset_metrics(self): + filtered_queryset = self.filter_by_date_range(self.base_qs, 'date_created') + + data = defaultdict(set) + for t, _id in filtered_queryset.values_list('date_created', 'id'): + date_str = str(t.date()) + data[date_str].add(_id) + + metrics = [len(data.get(str(d), set())) for d in self.date_range_list] + return metrics + + def get(self, request, *args, **kwargs): + qs = self.base_qs + all_type_dict = dict(AllTypes.choices()) + + stats = qs.aggregate( + total=Count(1), + active=Count(1, filter=Q(is_active=True)), + connected=Count(1, filter=Q(connectivity=Connectivity.OK)), + zone=Count(1, filter=Q(zone__isnull=False)), + directory_services=Count(1, filter=Q(directory_services__isnull=False)), + ) + + type_category_map = { + d['label']: str(d['category'].label) + for d in AllTypes.types() + } + + by_type = group_stats( + qs, 'type', 'platform__type', all_type_dict, + ) + + by_type_category = defaultdict(list) + for item in by_type: + category = type_category_map.get(item['label'], 'Other') + by_type_category[category].append(item) + + sorted_category_assets = OrderedDict() + desired_order = [str(i['label']) for i in AllTypes.categories()] + for category in desired_order: + sorted_category_assets[category] = by_type_category.get(category, []) + + stats.update({ + 'platform_count': Platform.objects.all().count(), + }) + + payload = { + 'asset_stats': stats, + 'assets_by_type_category': sorted_category_assets, + 'added_asset_metrics': { + 'dates_metrics_date': self.dates_metrics_date, + 'dates_metrics_total': self.get_added_asset_metrics(), + } + } + return JsonResponse(payload, status=200) diff --git a/apps/reports/api/assets/base.py b/apps/reports/api/assets/base.py new file mode 100644 index 000000000..f905777d4 --- /dev/null +++ b/apps/reports/api/assets/base.py @@ -0,0 +1,21 @@ +from django.db.models import Count, F + + +def group_stats(queryset, alias, key, label_map=None): + grouped = ( + queryset + .exclude(**{f'{key}__isnull': True}) + .values(**{alias: F(key)}) + .annotate(total=Count('id')) + ) + + data = [ + { + alias: val, + 'total': cnt, + **({'label': label_map.get(val, val)} if label_map else {}) + } + for val, cnt in grouped.values_list(alias, 'total') + ] + + return data diff --git a/apps/reports/api/report.py b/apps/reports/api/report.py new file mode 100644 index 000000000..78233c38e --- /dev/null +++ b/apps/reports/api/report.py @@ -0,0 +1,9 @@ +from rest_framework.generics import ListAPIView +from rest_framework.response import Response + +__all__ = ['ReportViewSet'] + + +class ReportViewSet(ListAPIView): + def list(self, request, *args, **kwargs): + return Response([]) diff --git a/apps/reports/api/users/__init__.py b/apps/reports/api/users/__init__.py new file mode 100644 index 000000000..11287d409 --- /dev/null +++ b/apps/reports/api/users/__init__.py @@ -0,0 +1,2 @@ +from .change_password import * +from .user import * diff --git a/apps/reports/api/users/change_password.py b/apps/reports/api/users/change_password.py new file mode 100644 index 000000000..2b633f65f --- /dev/null +++ b/apps/reports/api/users/change_password.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# +from collections import defaultdict + +from django.db.models import Count +from django.http.response import JsonResponse +from rest_framework.views import APIView + +from audits.models import PasswordChangeLog +from common.permissions import IsValidLicense +from common.utils import lazyproperty, get_ip_city, get_logger +from rbac.permissions import RBACPermission +from reports.mixins import DateRangeMixin + +__all__ = ['UserChangeSecretApi'] + +logger = get_logger(__file__) + + +class UserChangeSecretApi(DateRangeMixin, APIView): + http_method_names = ['get'] + rbac_perms = { + 'GET': 'audits.view_passwordchangelog', + } + permission_classes = [RBACPermission, IsValidLicense] + + @staticmethod + def get_change_password_region_distribution(queryset): + unique_ips = queryset.values_list('remote_addr', flat=True).distinct() + data = defaultdict(int) + for ip in unique_ips: + try: + city = str(get_ip_city(ip)) + if not city: + continue + data[city] += 1 + except Exception: + logger.debug(f"Failed to get city for IP {ip}, skipping", exc_info=True) + continue + + return [{'name': k, 'value': v} for k, v in data.items()] + + def get_change_password_metrics(self, queryset): + filtered_queryset = self.filter_by_date_range(queryset, 'datetime') + + data = defaultdict(set) + for t, username in filtered_queryset.values_list('datetime', 'user'): + date_str = str(t.date()) + data[date_str].add(username) + + metrics = [len(data.get(str(d), set())) for d in self.date_range_list] + return metrics + + @lazyproperty + def change_password_queryset(self): + queryset = PasswordChangeLog.objects.all() + return PasswordChangeLog.filter_queryset_by_org(queryset) + + def get(self, request, *args, **kwargs): + data = {} + + qs = self.filter_by_date_range(self.change_password_queryset, 'datetime') + + total = qs.count() + change_password_top10_users = qs.values( + 'user').annotate(count=Count('id')).order_by('-count')[:10] + + change_password_top10_change_bys = qs.values( + 'change_by').annotate(count=Count('id')).order_by('-count')[:10] + + data['total_count_change_password'] = { + 'total': total, + 'user_total': qs.values('user').distinct().count(), + 'change_by_total': qs.values('change_by').distinct().count(), + } + + data['change_password_top10_users'] = list(change_password_top10_users) + data['change_password_top10_change_bys'] = list(change_password_top10_change_bys) + + data['user_change_password_metrics'] = { + 'dates_metrics_date': self.dates_metrics_date, + 'dates_metrics_total': self.get_change_password_metrics(qs), + } + + data['change_password_region_distribution'] = self.get_change_password_region_distribution(qs) + return JsonResponse(data, status=200) diff --git a/apps/reports/api/users/user.py b/apps/reports/api/users/user.py new file mode 100644 index 000000000..ad3ef8097 --- /dev/null +++ b/apps/reports/api/users/user.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# +from collections import defaultdict + +from django.db.models import Count, Q +from django.http.response import JsonResponse +from rest_framework.views import APIView + +from audits.const import LoginStatusChoices +from audits.models import UserLoginLog +from common.permissions import IsValidLicense +from common.utils import lazyproperty +from rbac.permissions import RBACPermission +from reports.mixins import DateRangeMixin + +__all__ = ['UserReportApi'] + +from users.models import User +from users.models.user import Source + + +class UserReportApi(DateRangeMixin, APIView): + http_method_names = ['get'] + rbac_perms = { + 'GET': 'users.view_users', + } + permission_classes = [RBACPermission, IsValidLicense] + + def get_user_login_metrics(self, queryset): + filtered_queryset = self.filter_by_date_range(queryset, 'datetime') + + data = defaultdict(set) + for t, username in filtered_queryset.values_list('datetime', 'username'): + date_str = str(t.date()) + data[date_str].add(username) + + metrics = [len(data.get(str(d), set())) for d in self.date_range_list] + return metrics + + def get_user_login_method_metrics(self): + filtered_queryset = self.filter_by_date_range(self.user_login_log_queryset, 'datetime') + + backends = set() + data = defaultdict(lambda: defaultdict(set)) + for t, username, backend in filtered_queryset.values_list('datetime', 'username', 'backend'): + backends.add(backend) + date_str = str(t.date()) + data[date_str][backend].add(username) + metrics = defaultdict(list) + for t in self.date_range_list: + date_str = str(t) + for backend in backends: + username = data.get(date_str) if data.get(date_str) else {backend: set()} + metrics[backend].append(len(username.get(backend, set()))) + return metrics + + def get_user_login_region_distribution(self): + filtered_queryset = self.filter_by_date_range(self.user_login_log_queryset, 'datetime') + + data = filtered_queryset.values('city').annotate( + user_count=Count('username', distinct=True) + ).order_by('-user_count') + metrics = [{'name': d['city'], 'value': d['user_count']} for d in data] + return metrics + + def get_user_login_time_metrics(self): + time_buckets = { + '00:00-06:00': (0, 6), + '06:00-12:00': (6, 12), + '12:00-18:00': (12, 18), + '18:00-24:00': (18, 24), + } + filtered_queryset = self.filter_by_date_range(self.user_login_log_queryset, 'datetime').all() + metrics = {bucket: 0 for bucket in time_buckets.keys()} + for date in filtered_queryset: + hour = date.datetime.hour + for bucket, (start, end) in time_buckets.items(): + if start <= hour < end: + metrics[bucket] = metrics.get(bucket, 0) + 1 + return metrics + + @lazyproperty + def user_login_log_queryset(self): + queryset = UserLoginLog.objects.filter(status=LoginStatusChoices.success) + return UserLoginLog.filter_queryset_by_org(queryset) + + @lazyproperty + def user_login_failed_queryset(self): + queryset = UserLoginLog.objects.filter(status=LoginStatusChoices.failed) + return UserLoginLog.filter_queryset_by_org(queryset) + + @lazyproperty + def user_qs(self): + return User.get_org_users() + + def get(self, request, *args, **kwargs): + data = {} + + user_stats = self.user_qs.aggregate( + total=Count(1), + first_login=Count(1, filter=Q(is_first_login=True)), + need_update_password=Count(1, filter=Q(need_update_password=True)), + face_vector=Count(1, filter=Q(face_vector__isnull=False)), + not_enabled_mfa=Count(1, filter=Q(mfa_level=0)), + ) + + user_stats['valid'] = sum(1 for u in self.user_qs if u.is_valid) + data['user_stats'] = user_stats + + source_map = Source.as_dict() + user_by_source = defaultdict(int) + for source in self.user_qs.values_list('source', flat=True): + k = source_map.get(source, source) + user_by_source[str(k)] += 1 + + data['user_by_source'] = [{'name': k, 'value': v} for k, v in user_by_source.items()] + + data['user_login_log_metrics'] = { + 'dates_metrics_date': self.dates_metrics_date, + 'dates_metrics_success_total': self.get_user_login_metrics(self.user_login_log_queryset), + 'dates_metrics_failure_total': self.get_user_login_metrics(self.user_login_failed_queryset), + } + data['user_login_method_metrics'] = { + 'dates_metrics_date': self.dates_metrics_date, + 'dates_metrics_total': self.get_user_login_method_metrics(), + } + data['user_login_region_distribution'] = self.get_user_login_region_distribution() + data['user_login_time_metrics'] = self.get_user_login_time_metrics() + return JsonResponse(data, status=200) diff --git a/apps/reports/apps.py b/apps/reports/apps.py new file mode 100644 index 000000000..072c6441d --- /dev/null +++ b/apps/reports/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "reports" diff --git a/apps/reports/migrations/__init__.py b/apps/reports/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/reports/mixins.py b/apps/reports/mixins.py new file mode 100644 index 000000000..d618a4ed0 --- /dev/null +++ b/apps/reports/mixins.py @@ -0,0 +1,46 @@ +from django.utils import timezone +from rest_framework.request import Request + +from common.utils import lazyproperty +from common.utils.timezone import local_zero_hour, local_now + + +class DateRangeMixin: + request: Request + days_param = 'days' + default_days = 1 + + @lazyproperty + def days(self) -> int: + raw = self.request.query_params.get(self.days_param, self.default_days) + try: + return int(raw) + except (ValueError, TypeError): + return self.default_days + + @property + def start_datetime(self): + if self.days == 1: + return local_zero_hour() + return local_now() - timezone.timedelta(days=self.days) + + @property + def date_range_bounds(self) -> tuple: + start = self.start_datetime.date() + end = (local_now() + timezone.timedelta(days=1)).date() + return start, end + + @lazyproperty + def date_range_list(self) -> list: + return [ + (local_now() - timezone.timedelta(days=i)).date() + for i in range(self.days - 1, -1, -1) + ] + + def filter_by_date_range(self, queryset, field_name: str): + start, end = self.date_range_bounds + return queryset.filter(**{f'{field_name}__range': (start, end)}) + + @lazyproperty + def dates_metrics_date(self): + return [date.strftime('%m-%d') for date in self.date_range_list] or ['0'] diff --git a/apps/reports/models.py b/apps/reports/models.py new file mode 100644 index 000000000..71a836239 --- /dev/null +++ b/apps/reports/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/reports/tests.py b/apps/reports/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/apps/reports/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/reports/urls/__init__.py b/apps/reports/urls/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/reports/urls/api_urls.py b/apps/reports/urls/api_urls.py new file mode 100644 index 000000000..e549f7235 --- /dev/null +++ b/apps/reports/urls/api_urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from reports import api + +app_name = 'reports' + +urlpatterns = [ + path('reports/', api.ReportViewSet.as_view(), name='report-list'), + path('reports/users/', api.UserReportApi.as_view(), name='user-list'), + path('reports/user-change-password/', api.UserChangeSecretApi.as_view(), name='user-change-password'), + path('reports/asset-statistic/', api.AssetStatisticApi.as_view(), name='asset-statistic'), + path('reports/asset-activity/', api.AssetActivityApi.as_view(), name='asset-activity'), + path('reports/account-statistic/', api.AccountStatisticApi.as_view(), name='account-statistic'), + path('reports/account-automation/', api.AccountAutomationApi.as_view(), name='account-automation'), +] diff --git a/apps/reports/urls/view_urls.py b/apps/reports/urls/view_urls.py new file mode 100644 index 000000000..404c78802 --- /dev/null +++ b/apps/reports/urls/view_urls.py @@ -0,0 +1,15 @@ +# ~*~ coding: utf-8 ~*~ +from __future__ import unicode_literals +from django.urls import path + +from .. import views + +__all__ = ["urlpatterns"] + +app_name = "reports" + +urlpatterns = [ + # Resource Task url + path('export-pdf/', views.ExportPdfView.as_view(), name='export-pdf'), + path('send-mail/', views.SendMailView.as_view(), name='send-mail'), +] \ No newline at end of file diff --git a/apps/reports/views.py b/apps/reports/views.py new file mode 100644 index 000000000..adeb486f5 --- /dev/null +++ b/apps/reports/views.py @@ -0,0 +1,149 @@ +import base64 +import io +import urllib.parse +from io import BytesIO +from urllib.parse import urlparse + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.http import FileResponse, HttpResponseBadRequest, JsonResponse +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from pdf2image import convert_from_bytes +from playwright.sync_api import sync_playwright + +charts_map = { + "UserLoginActivity": { + "title": "用户登录报告", + "path": "/ui/#/reports/users/user-activity" + }, + "UserPasswordChange": { + "title": "用户改密报告", + "path": "/ui/#/reports/users/change-password" + }, + "AssetStatistics": { + "title": "资产统计报告", + "path": "/ui/#/reports/assets/asset-statistics" + }, + "AssetReport": { + "title": "资产活动报告", + "path": "/ui/#/reports/assets/asset-activity" + }, + "AccountStatistics": { + "title": "账号统计报告", + "path": "/ui/#/reports/accounts/account-statistics" + }, + "AccountAutomationReport": { + "title": "账号自动化报告", + "path": "/ui/#/reports/accounts/account-automation" + } +} + + +def export_chart_to_pdf(chart_name, sessionid, request=None): + chart_info = charts_map.get(chart_name) + if not chart_info: + return None, None + + if request: + url = request.build_absolute_uri(urllib.parse.unquote(chart_info['path'])) + else: + url = urllib.parse.unquote(chart_info['path']) + + if settings.DEBUG_DEV: + url = url.replace(":8080", ":9528") + print("Url: ", url) + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1000, "height": 800}) + # 设置 sessionid cookie + parsed_url = urlparse(url) + context.add_cookies([ + { + 'name': settings.SESSION_COOKIE_NAME, + 'value': sessionid, + 'domain': parsed_url.hostname, + 'path': '/', + 'httpOnly': True, + 'secure': False, # 如有 https 可改 True + } + ]) + page = context.new_page() + try: + page.goto(url, wait_until='networkidle') + pdf_bytes = page.pdf(format="A4", landscape=True, + margin={"top": "35px", "bottom": "30px", "left": "20px", "right": "20px"}) + except Exception as e: + print(f'Playwright error: {e}') + pdf_bytes = None + finally: + browser.close() + return pdf_bytes, chart_info['title'] + + +@method_decorator(csrf_exempt, name='dispatch') +class ExportPdfView(View): + def get(self, request): + chart_name = request.GET.get('chart') + return self._handle_export(request, chart_name) + + def post(self, request): + chart_name = request.POST.get('chart') + return self._handle_export(request, chart_name) + + def _handle_export(self, request, chart_name): + if not chart_name: + return HttpResponseBadRequest('Missing chart parameter') + sessionid = request.COOKIES.get(settings.SESSION_COOKIE_NAME) + if not sessionid: + return HttpResponseBadRequest('No sessionid found in cookies') + + pdf_bytes, title = export_chart_to_pdf(chart_name, sessionid, request=request) + if not pdf_bytes: + return HttpResponseBadRequest('Failed to generate PDF') + filename = f"{title}-{timezone.now().strftime('%Y%m%d%H%M%S')}.pdf" + response = FileResponse(io.BytesIO(pdf_bytes), as_attachment=True, filename=filename, + content_type='application/pdf') + return response + + +class SendMailView(View): + def get(self, request): + chart_name = request.GET.get('chart') + email = "ibuler@qq.com" + if not chart_name or not email: + return HttpResponseBadRequest('Missing chart or email parameter') + sessionid = request.COOKIES.get(settings.SESSION_COOKIE_NAME) + if not sessionid: + return HttpResponseBadRequest('No sessionid found in cookies') + + # 1. 生成 PDF + pdf_bytes, title = export_chart_to_pdf(chart_name, sessionid, request=request) + if not pdf_bytes: + return HttpResponseBadRequest('Failed to generate PDF') + + # 2. PDF 转图片 + images = convert_from_bytes(pdf_bytes, dpi=200) + # 3. 图片转 base64 + img_tags = [] + for img in images: + buffer = BytesIO() + img.save(buffer, format="PNG") + encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") + img_tags.append(f'') + html_content = "
".join(img_tags) + + # 4. 发送邮件 + subject = f"{title} 报表" + from_email = settings.EMAIL_HOST_USER + to = [email] + msg = EmailMultiAlternatives(subject, '', from_email, to) + msg.attach_alternative(html_content, "text/html") + filename = f"{title}-{timezone.now().strftime('%Y%m%d%H%M%S')}.pdf" + msg.attach(filename, pdf_bytes, "application/pdf") + msg.send() + + return JsonResponse({"message": "邮件发送成功"}) diff --git a/apps/terminal/const.py b/apps/terminal/const.py index 36ea0ee80..099cdcab8 100644 --- a/apps/terminal/const.py +++ b/apps/terminal/const.py @@ -106,3 +106,14 @@ class SessionErrorReason(TextChoices): replay_upload_failed = 'replay_upload_failed', _('Replay upload failed') replay_convert_failed = 'replay_convert_failed', _('Replay convert failed') replay_unsupported = 'replay_unsupported', _('Replay unsupported') + + +class LoginFrom(TextChoices): + ST = 'ST', 'SSH Terminal' + RT = 'RT', 'RDP Terminal' + WT = 'WT', 'Web Terminal' + DT = 'DT', 'DB Terminal' + + @classmethod + def as_dict(cls): + return {choice.value: choice.label for choice in cls} diff --git a/apps/terminal/models/session/session.py b/apps/terminal/models/session/session.py index ceeeac8af..6573e46e4 100644 --- a/apps/terminal/models/session/session.py +++ b/apps/terminal/models/session/session.py @@ -18,18 +18,11 @@ from common.const.signals import OP_LOG_SKIP_SIGNAL from common.utils import get_object_or_none, lazyproperty from orgs.mixins.models import OrgModelMixin from terminal.backends import get_multi_command_storage -from terminal.const import SessionType, TerminalType +from terminal.const import SessionType, TerminalType, LoginFrom from users.models import User class Session(OrgModelMixin): - class LOGIN_FROM(models.TextChoices): - ST = 'ST', 'SSH Terminal' - RT = 'RT', 'RDP Terminal' - WT = 'WT', 'Web Terminal' - DT = 'DT', 'DB Terminal' - VT = 'VT', 'VNC Terminal' - id = models.UUIDField(default=uuid.uuid4, primary_key=True) user = models.CharField(max_length=128, verbose_name=_("User"), db_index=True) user_id = models.CharField(blank=True, default='', max_length=36, db_index=True) @@ -38,7 +31,7 @@ class Session(OrgModelMixin): account = models.CharField(max_length=128, verbose_name=_("Account"), db_index=True) account_id = models.CharField(max_length=128, verbose_name=_("Account ID"), db_index=True) protocol = models.CharField(default='ssh', max_length=16, db_index=True) - login_from = models.CharField(max_length=2, choices=LOGIN_FROM.choices, default="ST", verbose_name=_("Login from")) + login_from = models.CharField(max_length=2, choices=LoginFrom.choices, default="ST", verbose_name=_("Login from")) type = models.CharField(max_length=16, default='normal', db_index=True) remote_addr = models.CharField(max_length=128, verbose_name=_("Remote addr"), blank=True, null=True) is_success = models.BooleanField(default=True, db_index=True) diff --git a/apps/terminal/models/session/sharing.py b/apps/terminal/models/session/sharing.py index 11e06953d..7e99d6688 100644 --- a/apps/terminal/models/session/sharing.py +++ b/apps/terminal/models/session/sharing.py @@ -2,15 +2,15 @@ import datetime from django.db import models from django.utils import timezone -from django.utils.translation import gettext_lazy as _ from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ from common.db.models import JMSBaseModel from common.utils import is_uuid from orgs.mixins.models import OrgModelMixin from orgs.utils import tmp_to_root_org +from terminal.const import LoginFrom from users.models import User -from .session import Session __all__ = ['SessionSharing', 'SessionJoinRecord'] @@ -89,8 +89,6 @@ class SessionSharing(JMSBaseModel, OrgModelMixin): class SessionJoinRecord(JMSBaseModel, OrgModelMixin): - LOGIN_FROM = Session.LOGIN_FROM - session = models.ForeignKey( 'terminal.Session', on_delete=models.CASCADE, verbose_name=_('Session') ) @@ -114,7 +112,7 @@ class SessionJoinRecord(JMSBaseModel, OrgModelMixin): db_index=True ) login_from = models.CharField( - max_length=2, choices=LOGIN_FROM.choices, default="WT", + max_length=2, choices=LoginFrom.choices, default="WT", verbose_name=_("Login from") ) is_success = models.BooleanField( diff --git a/apps/users/models/user/_source.py b/apps/users/models/user/_source.py index d631f86d5..95fe9ade6 100644 --- a/apps/users/models/user/_source.py +++ b/apps/users/models/user/_source.py @@ -25,6 +25,10 @@ class Source(models.TextChoices): slack = "slack", _("Slack") custom = "custom", "Custom" + @classmethod + def as_dict(cls): + return {choice.value: choice.label for choice in cls} + class SourceMixin: source: str diff --git a/pyproject.toml b/pyproject.toml index 1578b17ba..ed630f941 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,7 @@ dependencies = [ 'django-proxy==1.2.2', 'python-daemon==3.0.1', 'eventlet==0.33.3', - 'greenlet==3.0.1', + 'greenlet==3.1.1', 'gunicorn==23.0.0', 'celery==5.3.1', 'flower==2.0.1', @@ -152,6 +152,8 @@ dependencies = [ 'botocore==1.31.9', 's3transfer==0.6.1', 'xmlsec==1.3.14', + 'playwright==1.53.0', + 'pdf2image==1.17.0' ] [project.urls] diff --git a/requirements/mac_pkg.sh b/requirements/mac_pkg.sh index c70080059..3e19608d9 100644 --- a/requirements/mac_pkg.sh +++ b/requirements/mac_pkg.sh @@ -5,16 +5,13 @@ PROJECT_DIR=$(dirname "$BASE_DIR") echo "1. 安装依赖" brew install libtiff libjpeg webp little-cms2 openssl gettext git \ git-lfs libxml2 libxmlsec1 pkg-config postgresql freetds openssl \ - libffi freerdp + libffi freerdp poppler pip install daphne==4.0.0 channels channels-redis echo "2. 下载 IP 数据库" ip_db_path="${PROJECT_DIR}/apps/common/utils/geoip/GeoLite2-City.mmdb" wget "https://download.jumpserver.org/files/GeoLite2-City.mmdb" -O "${ip_db_path}" -echo "3. 安装依赖的插件" -git lfs install - if ! uname -a | grep 'ARM64' &> /dev/null;then exit 0 fi