diff --git a/apps/common/drf/metadata.py b/apps/common/drf/metadata.py index d0b0de6b7..07226b042 100644 --- a/apps/common/drf/metadata.py +++ b/apps/common/drf/metadata.py @@ -34,10 +34,14 @@ class SimpleMetadataWithFilters(SimpleMetadata): """ actions = {} view.raw_action = getattr(view, "action", None) + query_action = request.query_params.get("action", None) for method in self.methods & set(view.allowed_methods): if hasattr(view, "action_map"): view.action = view.action_map.get(method.lower(), view.action) + if query_action and query_action.lower() != method.lower(): + continue + view.request = clone_request(request, method) try: # Test global permissions diff --git a/apps/jumpserver/api/__init__.py b/apps/jumpserver/api/__init__.py new file mode 100644 index 000000000..34fb94102 --- /dev/null +++ b/apps/jumpserver/api/__init__.py @@ -0,0 +1,3 @@ +from .aggregate import * +from .dashboard import IndexApi +from .health import PrometheusMetricsApi, HealthCheckView diff --git a/apps/jumpserver/api/aggregate/__init__.py b/apps/jumpserver/api/aggregate/__init__.py new file mode 100644 index 000000000..407fd3d26 --- /dev/null +++ b/apps/jumpserver/api/aggregate/__init__.py @@ -0,0 +1,9 @@ +from .detail import ResourceDetailApi +from .list import ResourceListApi +from .supported import ResourceTypeListApi + +__all__ = [ + 'ResourceListApi', + 'ResourceDetailApi', + 'ResourceTypeListApi', +] diff --git a/apps/jumpserver/api/aggregate/const.py b/apps/jumpserver/api/aggregate/const.py new file mode 100644 index 000000000..8a50009f9 --- /dev/null +++ b/apps/jumpserver/api/aggregate/const.py @@ -0,0 +1,57 @@ +list_params = [ + { + "name": "search", + "in": "query", + "description": "A search term.", + "required": False, + "type": "string" + }, + { + "name": "order", + "in": "query", + "description": "Which field to use when ordering the results.", + "required": False, + "type": "string" + }, + { + "name": "limit", + "in": "query", + "description": "Number of results to return per page. Default is 10.", + "required": False, + "type": "integer" + }, + { + "name": "offset", + "in": "query", + "description": "The initial index from which to return the results.", + "required": False, + "type": "integer" + }, + +] + +common_params = [ + { + "name": "resource", + "in": "path", + "description": """Resource to query, e.g. users, assets, permissions, acls, user-groups, policies, nodes, hosts, + devices, clouds, webs, databases, + gpts, ds, customs, platforms, zones, gateways, protocol-settings, labels, virtual-accounts, + gathered-accounts, account-templates, account-template-secrets, account-backups, account-backup-executions, + change-secret-automations, change-secret-executions, change-secret-records, gather-account-automations, + gather-account-executions, push-account-automations, push-account-executions, push-account-records, + check-account-automations, check-account-executions, account-risks, integration-apps, asset-permissions, + zones, gateways, virtual-accounts, gathered-accounts, account-templates, account-template-secrets,, + GET /api/v1/resources/ to get full supported resource. + """, + "required": True, + "type": "string" + }, + { + "name": "X-JMS-ORG", + "in": "header", + "description": "The organization ID to use for the request. Organization is the namespace for resources, if not set, use default org", + "required": False, + "type": "string" + } +] diff --git a/apps/jumpserver/api/aggregate/detail.py b/apps/jumpserver/api/aggregate/detail.py new file mode 100644 index 000000000..038bbf205 --- /dev/null +++ b/apps/jumpserver/api/aggregate/detail.py @@ -0,0 +1,73 @@ +# views.py + +from drf_yasg.utils import swagger_auto_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +from .const import common_params +from .proxy import ProxyMixin +from .utils import param_dic_to_param + +one_param = [ + { + 'name': 'id', + 'in': 'path', + 'required': True, + 'description': 'Resource ID', + 'type': 'string', + } +] + +object_params = [ + param_dic_to_param(d) + for d in common_params + one_param +] + + +class ResourceDetailApi(ProxyMixin, APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_id="get_resource_detail", + operation_summary="Get resource detail", + manual_parameters=object_params, + operation_description=""" + Get resource detail. + {resource} is the resource name, GET /api/v1/resources/ to get full supported resource. + + """, ) + def get(self, request, resource, pk=None): + return self._proxy(request, resource, pk=pk, action='retrieve') + + @swagger_auto_schema( + operation_id="delete_resource", + operation_summary="Delete the resource ", + manual_parameters=object_params, + operation_description="Delete the resource, and can not be restored", + ) + def delete(self, request, resource, pk=None): + return self._proxy(request, resource, pk, action='destroy') + + @swagger_auto_schema( + operation_id="update_resource", + operation_summary="Update the resource property", + manual_parameters=object_params, + operation_description=""" + Update the resource property, all property will be update, + {resource} is the resource name, GET /api/v1/resources/ to get full supported resource. + + OPTION /api/v1/resources/{resource}/{id}/?action=put to get field type and helptext. + """) + def put(self, request, resource, pk=None): + return self._proxy(request, resource, pk, action='update') + + @swagger_auto_schema( + operation_id="partial_update_resource", + operation_summary="Update the resource property", + manual_parameters=object_params, + operation_description=""" + Partial update the resource property, only request property will be update, + OPTION /api/v1/resources/{resource}/{id}/?action=patch to get field type and helptext. + """) + def patch(self, request, resource, pk=None): + return self._proxy(request, resource, pk, action='partial_update') diff --git a/apps/jumpserver/api/aggregate/list.py b/apps/jumpserver/api/aggregate/list.py new file mode 100644 index 000000000..7a5d33eda --- /dev/null +++ b/apps/jumpserver/api/aggregate/list.py @@ -0,0 +1,86 @@ +# views.py + +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework.routers import DefaultRouter +from rest_framework.views import APIView + +from .const import list_params, common_params +from .proxy import ProxyMixin +from .utils import param_dic_to_param + +router = DefaultRouter() + +BASE_URL = "http://localhost:8080" + +list_params = [ + param_dic_to_param(d) + for d in list_params + common_params +] + +create_params = [ + param_dic_to_param(d) + for d in common_params +] + +list_schema = { + "required": [ + "count", + "results" + ], + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "format": "uri", + "x-nullable": True + }, + "previous": { + "type": "string", + "format": "uri", + "x-nullable": True + }, + "results": { + "type": "array", + "items": { + } + } + } +} + +list_response = openapi.Response("Resource list response", schema=openapi.Schema(**list_schema)) + + +class ResourceListApi(ProxyMixin, APIView): + @swagger_auto_schema( + operation_id="get_resource_list", + operation_summary="Get resource list", + manual_parameters=list_params, + responses={200: list_response}, + operation_description=""" + Get resource list, you should set the resource name in the url. + OPTIONS /api/v1/resources/{resource}/?action=get to get every type resource's field type and help text. + """, ) + # ↓↓↓ Swagger 自动文档 ↓↓↓ + def get(self, request, resource): + return self._proxy(request, resource) + + @swagger_auto_schema( + operation_id="create_resource_by_type", + operation_summary="Create resource", + manual_parameters=create_params, + operation_description=""" + Create resource, + OPTIONS /api/v1/resources/{resource}/?action=post to get every resource type field type and helptext, and + you will know how to create it. + """) + def post(self, request, resource, pk=None): + if not resource: + resource = request.data.pop('resource', '') + return self._proxy(request, resource, pk, action='create') + + def options(self, request, resource, pk=None): + return self._proxy(request, resource, pk, action='metadata') diff --git a/apps/jumpserver/api/aggregate/proxy.py b/apps/jumpserver/api/aggregate/proxy.py new file mode 100644 index 000000000..0997d710f --- /dev/null +++ b/apps/jumpserver/api/aggregate/proxy.py @@ -0,0 +1,68 @@ +# views.py + +from urllib.parse import urlencode + +import requests +from rest_framework.exceptions import NotFound, APIException +from rest_framework.permissions import AllowAny +from rest_framework.routers import DefaultRouter +from rest_framework.views import APIView + +from .utils import get_full_resource_map + +router = DefaultRouter() + +BASE_URL = "http://localhost:8080" + + +class ProxyMixin(APIView): + """ + 通用资源代理 API,支持动态路径、自动文档生成 + """ + permission_classes = [AllowAny] + + def _build_url(self, resource_name: str, pk: str = None, query_params=None): + resource_map = get_full_resource_map() + resource = resource_map.get(resource_name) + if not resource: + raise NotFound(f"Unknown resource: {resource_name}") + + base_path = resource['path'] + if pk: + base_path += f"{pk}/" + + if query_params: + base_path += f"?{urlencode(query_params)}" + + return f"{BASE_URL}{base_path}" + + def _proxy(self, request, resource: str, pk: str = None, action='list'): + method = request.method.lower() + if method not in ['get', 'post', 'put', 'patch', 'delete', 'options']: + raise APIException("Unsupported method") + + if not resource or resource == '{resource}': + if request.data: + resource = request.data.get('resource') + + query_params = request.query_params.dict() + if action == 'list': + query_params['limit'] = 10 + + url = self._build_url(resource, pk, query_params) + headers = {k: v for k, v in request.headers.items() if k.lower() != 'host'} + cookies = request.COOKIES + body = request.body if method in ['post', 'put', 'patch'] else None + + try: + resp = requests.request( + method=method, + url=url, + headers=headers, + cookies=cookies, + data=body, + timeout=10, + ) + return resp + except requests.RequestException as e: + raise APIException(f"Proxy request failed: {str(e)}") diff --git a/apps/jumpserver/api/aggregate/supported.py b/apps/jumpserver/api/aggregate/supported.py new file mode 100644 index 000000000..14e0c2485 --- /dev/null +++ b/apps/jumpserver/api/aggregate/supported.py @@ -0,0 +1,43 @@ +# views.py + +from drf_yasg.utils import swagger_auto_schema +from rest_framework import serializers +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.routers import DefaultRouter +from rest_framework.views import APIView + +router = DefaultRouter() + +BASE_URL = "http://localhost:8080" + + +class ResourceTypeResourceSerializer(serializers.Serializer): + name = serializers.CharField() + path = serializers.CharField() + app = serializers.CharField() + verbose_name = serializers.CharField() + description = serializers.CharField() + + +class ResourceTypeListApi(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_id="get_supported_resources", + operation_summary="Get-all-support-resources", + operation_description="Get all support resources, name, path, verbose_name description", + responses={200: ResourceTypeResourceSerializer(many=True)}, # Specify the response serializer + ) + def get(self, request): + result = [] + resource_map = get_full_resource_map() + for name, desc in resource_map.items(): + desc = resource_map.get(name, {}) + resource = { + "name": name, + **desc, + "path": f'/api/v1/resources/{name}/', + } + result.append(resource) + return Response(result) diff --git a/apps/jumpserver/api/aggregate/utils.py b/apps/jumpserver/api/aggregate/utils.py new file mode 100644 index 000000000..cdfdc58d9 --- /dev/null +++ b/apps/jumpserver/api/aggregate/utils.py @@ -0,0 +1,128 @@ +# views.py + +import re +from functools import lru_cache +from typing import Dict + +from django.urls import URLPattern +from django.urls import URLResolver +from drf_yasg import openapi +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() + +BASE_URL = "http://localhost:8080" + + +def clean_path(path: str) -> str: + """ + 清理掉 DRF 自动生成的正则格式内容,让其变成普通 RESTful URL path。 + """ + + # 去掉格式后缀匹配: \.(?Pxxx) + path = re.sub(r'\\\.\(\?P[^)]+\)', '', path) + + # 去掉括号式格式匹配 + path = re.sub(r'\(\?P[^)]+\)', '', path) + + # 移除 DRF 中正则参数的部分 (?Ppattern) + path = re.sub(r'\(\?P<\w+>[^)]+\)', '{param}', path) + + # 如果有多个括号包裹的正则(比如前缀路径),去掉可选部分包装 + path = re.sub(r'\(\(([^)]+)\)\?\)', r'\1', path) # ((...))? => ... + + # 去掉中间和两边的 ^ 和 $ + path = path.replace('^', '').replace('$', '') + + # 去掉尾部 ?/ + path = re.sub(r'\?/?$', '', path) + + # 去掉反斜杠 + path = path.replace('\\', '') + + # 替换多重斜杠 + path = re.sub(r'/+', '/', path) + + # 添加开头斜杠,移除多余空格 + path = path.strip() + if not path.startswith('/'): + path = '/' + path + if not path.endswith('/'): + path += '/' + + return path + + +def extract_resource_paths(urlpatterns, prefix='/api/v1/') -> Dict[str, Dict[str, str]]: + resource_map = {} + + for pattern in urlpatterns: + if isinstance(pattern, URLResolver): + nested_prefix = prefix + str(pattern.pattern) + resource_map.update(extract_resource_paths(pattern.url_patterns, nested_prefix)) + + elif isinstance(pattern, URLPattern): + callback = pattern.callback + actions = getattr(callback, 'actions', {}) + if not actions: + continue + + if 'get' in actions and actions['get'] == 'list': + path = clean_path(prefix + str(pattern.pattern)) + + # 尝试获取资源名称 + name = pattern.name + if name and name.endswith('-list'): + resource = name[:-5] + else: + resource = path.strip('/').split('/')[-1] + + # 不强行加 s,资源名保持原状即可 + resource = resource if resource.endswith('s') else resource + 's' + + # 获取 View 类和 model 的 verbose_name + view_cls = getattr(callback, 'cls', None) + model = None + + if view_cls: + queryset = getattr(view_cls, 'queryset', None) + if queryset is not None: + model = getattr(queryset, 'model', None) + else: + # 有些 View 用 get_queryset() + try: + instance = view_cls() + qs = instance.get_queryset() + model = getattr(qs, 'model', None) + except Exception: + pass + + if not model: + continue + + app = str(getattr(model._meta, 'app_label', '')) + verbose_name = str(getattr(model._meta, 'verbose_name', '')) + resource_map[resource] = { + 'path': path, + 'app': app, + 'verbose_name': verbose_name, + 'description': model.__doc__.__str__() + } + + print("Extracted resource paths:", list(resource_map.keys())) + return resource_map + + +def param_dic_to_param(d): + return openapi.Parameter( + d['name'], d['in'], + description=d['description'], type=d['type'], required=d.get('required', False) + ) + + +@lru_cache() +def get_full_resource_map(): + from apps.jumpserver.urls import resource_api + resource_map = extract_resource_paths(resource_api) + print("Building URL for resource:", resource_map) + return resource_map diff --git a/apps/jumpserver/api.py b/apps/jumpserver/api/dashboard.py similarity index 90% rename from apps/jumpserver/api.py rename to apps/jumpserver/api/dashboard.py index eb7bbd9e0..b265ae737 100644 --- a/apps/jumpserver/api.py +++ b/apps/jumpserver/api/dashboard.py @@ -1,15 +1,11 @@ -import time from collections import defaultdict -from django.core.cache import cache from django.db.models import Count, Max, F, CharField from django.db.models.functions import Cast -from django.http.response import JsonResponse, HttpResponse +from django.http.response import JsonResponse from django.utils import timezone from django.utils.timesince import timesince -from rest_framework.permissions import AllowAny from rest_framework.request import Request -from rest_framework.response import Response from rest_framework.views import APIView from assets.const import AllTypes @@ -25,8 +21,6 @@ from orgs.caches import OrgResourceStatisticsCache from orgs.utils import current_org from terminal.const import RiskLevelChoices from terminal.models import Session, Command -from terminal.utils import ComponentsPrometheusMetricsUtil -from users.models import User __all__ = ['IndexApi'] @@ -466,61 +460,3 @@ class IndexApi(DateTimeMixin, DatesLoginMetricMixin, APIView): }) return JsonResponse(data, status=200) - - -class HealthApiMixin(APIView): - pass - - -class HealthCheckView(HealthApiMixin): - permission_classes = (AllowAny,) - - @staticmethod - def get_db_status(): - t1 = time.time() - try: - ok = User.objects.first() is not None - t2 = time.time() - return ok, t2 - t1 - except Exception as e: - return False, str(e) - - @staticmethod - def get_redis_status(): - key = 'HEALTH_CHECK' - - t1 = time.time() - try: - value = '1' - cache.set(key, '1', 10) - got = cache.get(key) - t2 = time.time() - - if value == got: - return True, t2 - t1 - return False, 'Value not match' - except Exception as e: - return False, str(e) - - def get(self, request): - redis_status, redis_time = self.get_redis_status() - db_status, db_time = self.get_db_status() - status = all([redis_status, db_status]) - data = { - 'status': status, - 'db_status': db_status, - 'db_time': db_time, - 'redis_status': redis_status, - 'redis_time': redis_time, - 'time': int(time.time()), - } - return Response(data) - - -class PrometheusMetricsApi(HealthApiMixin): - permission_classes = (AllowAny,) - - def get(self, request, *args, **kwargs): - util = ComponentsPrometheusMetricsUtil() - metrics_text = util.get_prometheus_metrics_text() - return HttpResponse(metrics_text, content_type='text/plain; version=0.0.4; charset=utf-8') diff --git a/apps/jumpserver/api/health.py b/apps/jumpserver/api/health.py new file mode 100644 index 000000000..a837e888b --- /dev/null +++ b/apps/jumpserver/api/health.py @@ -0,0 +1,68 @@ +import time + +from django.core.cache import cache +from django.http.response import HttpResponse +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from terminal.utils import ComponentsPrometheusMetricsUtil +from users.models import User + + +class HealthApiMixin(APIView): + pass + + +class HealthCheckView(HealthApiMixin): + permission_classes = (AllowAny,) + + @staticmethod + def get_db_status(): + t1 = time.time() + try: + ok = User.objects.first() is not None + t2 = time.time() + return ok, t2 - t1 + except Exception as e: + return False, str(e) + + @staticmethod + def get_redis_status(): + key = 'HEALTH_CHECK' + + t1 = time.time() + try: + value = '1' + cache.set(key, '1', 10) + got = cache.get(key) + t2 = time.time() + + if value == got: + return True, t2 - t1 + return False, 'Value not match' + except Exception as e: + return False, str(e) + + def get(self, request): + redis_status, redis_time = self.get_redis_status() + db_status, db_time = self.get_db_status() + status = all([redis_status, db_status]) + data = { + 'status': status, + 'db_status': db_status, + 'db_time': db_time, + 'redis_status': redis_status, + 'redis_time': redis_time, + 'time': int(time.time()), + } + return Response(data) + + +class PrometheusMetricsApi(HealthApiMixin): + permission_classes = (AllowAny,) + + def get(self, request, *args, **kwargs): + util = ComponentsPrometheusMetricsUtil() + metrics_text = util.get_prometheus_metrics_text() + return HttpResponse(metrics_text, content_type='text/plain; version=0.0.4; charset=utf-8') diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 2cc8e02d1..b6e2cc5de 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -12,7 +12,7 @@ from django.views.i18n import JavaScriptCatalog from . import views, api -api_v1 = [ +resource_api = [ path('index/', api.IndexApi.as_view()), path('users/', include('users.urls.api_urls', namespace='api-users')), path('assets/', include('assets.urls.api_urls', namespace='api-assets')), @@ -30,7 +30,13 @@ api_v1 = [ 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')), +] + +api_v1 = resource_api + [ path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()), + path('resources/', api.ResourceTypeListApi.as_view(), name='resource-list'), + path('resources//', api.ResourceListApi.as_view()), + path('resources///', api.ResourceDetailApi.as_view()), ] app_view_patterns = [ diff --git a/apps/jumpserver/views/swagger.py b/apps/jumpserver/views/swagger.py index 5ca1dd330..91d270b43 100644 --- a/apps/jumpserver/views/swagger.py +++ b/apps/jumpserver/views/swagger.py @@ -19,41 +19,78 @@ class CustomSchemaGenerator(OpenAPISchemaGenerator): '/report/', '/render-to-json/', '/suggestions/', 'executions', 'automations', 'change-secret-records', 'change-secret-dashboard', '/copy-to-assets/', - '/move-to-assets/', 'dashboard', - + '/move-to-assets/', 'dashboard', 'index', 'countries', + '/resources/cache/', 'profile/mfa', 'profile/password', + 'profile/permissions', 'prometheus', 'constraints' ] for p in excludes: if path.find(p) >= 0: return True return False - def exclude_some_app(self, path): + def exclude_some_app_model(self, path): parts = path.split('/') - if len(parts) < 4: + if len(parts) < 5: return False apps = [] if self.from_mcp: apps = [ - 'ops', 'tickets', 'common', 'authentication', - 'settings', 'xpack', 'terminal', 'rbac' + 'ops', 'tickets', 'authentication', + 'settings', 'xpack', 'terminal', 'rbac', + 'notifications', 'promethues', 'acls' ] app_name = parts[3] if app_name in apps: return True + models = [] + model = parts[4] + if self.from_mcp: + models = [ + 'users', 'user-groups', 'users-groups-relations', 'assets', 'hosts', 'devices', 'databases', + 'webs', 'clouds', 'gpts', 'ds', 'customs', 'platforms', 'nodes', 'zones', 'gateways', + 'protocol-settings', 'labels', 'virtual-accounts', 'gathered-accounts', 'account-templates', + 'account-template-secrets', 'account-backups', 'account-backup-executions', + 'change-secret-automations', 'change-secret-executions', 'change-secret-records', + 'gather-account-automations', 'gather-account-executions', 'push-account-automations', + 'push-account-executions', 'push-account-records', 'check-account-automations', + 'check-account-executions', 'account-risks', 'integration-apps', 'asset-permissions', + 'asset-permissions-users-relations', 'asset-permissions-user-groups-relations', + 'asset-permissions-assets-relations', 'asset-permissions-nodes-relations', 'terminal-status', + 'terminals', 'tasks', 'status', 'replay-storages', 'command-storages', 'session-sharing-records', + 'endpoints', 'endpoint-rules', 'applets', 'applet-hosts', 'applet-publications', + 'applet-host-deployments', 'virtual-apps', 'app-providers', 'virtual-app-publications', + 'celery-period-tasks', 'task-executions', 'adhocs', 'playbooks', 'variables', 'ftp-logs', + 'login-logs', 'operate-logs', 'password-change-logs', 'job-logs', 'jobs', 'user-sessions', + 'service-access-logs', 'chatai-prompts', 'super-connection-tokens', 'flows', + 'apply-assets', 'apply-nodes', 'login-acls', 'login-asset-acls', 'command-filter-acls', + 'command-groups', 'connect-method-acls', 'system-msg-subscriptions', 'roles', 'role-bindings', + 'system-roles', 'system-role-bindings', 'org-roles', 'org-role-bindings', 'content-types', + 'labeled-resources', 'account-backup-plans', 'account-check-engines', 'account-secrets', + 'change-secret', 'integration-applications', 'push-account', 'directories', 'connection-token', + 'groups', 'accounts', 'resource-types', 'favorite-assets', 'activities', 'platform-automation-methods', + ] + if model in models: + return True return False def get_operation(self, view, path, prefix, method, components, request): # 这里可以对 path 进行处理 if self.exclude_some_paths(path): return None - if self.exclude_some_app(path): + if self.exclude_some_app_model(path): return None operation = super().get_operation(view, path, prefix, method, components, request) operation_id = operation.get('operationId') if 'bulk' in operation_id: return None + exclude_operations = [ + 'orgs_orgs_read', 'orgs_orgs_update', 'orgs_orgs_delete', 'orgs_orgs_create', + 'orgs_orgs_partial_update', + ] + if operation_id in exclude_operations: + return None return operation @@ -82,7 +119,8 @@ class CustomSwaggerAutoSchema(SwaggerAutoSchema): def get_operation(self, operation_keys): operation = super().get_operation(operation_keys) - operation.summary = operation.operation_id + if not getattr(operation, 'summary', ''): + operation.summary = operation.operation_id return operation def get_filter_parameters(self): diff --git a/apps/users/models/group.py b/apps/users/models/group.py index 4f3f612d3..2a6677241 100644 --- a/apps/users/models/group.py +++ b/apps/users/models/group.py @@ -10,6 +10,9 @@ __all__ = ['UserGroup'] class UserGroup(LabeledMixin, JMSOrgBaseModel): + """ + User group, When a user is added to a group, they inherit its asset permissions for access control consistency. + """ name = models.CharField(max_length=128, verbose_name=_('Name')) def __str__(self): diff --git a/apps/users/models/user/__init__.py b/apps/users/models/user/__init__.py index ec2501c5f..c3c398a0b 100644 --- a/apps/users/models/user/__init__.py +++ b/apps/users/models/user/__init__.py @@ -55,6 +55,11 @@ class User( JSONFilterMixin, AbstractUser, ): + """ + User model, used for authentication and authorization. User can join multiple groups. + User can have multiple roles, and each role can have multiple permissions. + User can connect to multiple assets, If he has the permission. Permission was defined in Asset Permission. + """ id = models.UUIDField(default=uuid.uuid4, primary_key=True) username = models.CharField(max_length=128, unique=True, verbose_name=_("Username")) name = models.CharField(max_length=128, verbose_name=_("Name"))