Compare commits

...

6 Commits

Author SHA1 Message Date
ibuler
777b655e90 perf: stash it 2025-11-03 18:04:12 +08:00
ibuler
6e1736c1a7 stash 2025-11-03 14:26:36 +08:00
ibuler
fe31eb0a44 feat: mcp internal 2025-10-31 14:14:23 +08:00
feng
3e93034fbc perf: Update remote_client 2025-10-30 10:12:40 +08:00
feng
f4b3a7d73a perf: Sync feishu info 2025-10-29 14:53:45 +08:00
wrd
3781c40179 Revert "perf: update fields serialization and bump django and djangorestframe…"
This reverts commit dd0cacb4bc.
2025-10-29 11:19:50 +08:00
12 changed files with 189 additions and 53 deletions

View File

@@ -603,7 +603,7 @@ class JSONManyToManyField(models.JSONField):
return None
if isinstance(value, RelatedManager):
value = value.value
return value
return json.dumps(value)
def validate(self, value, model_instance):
super().validate(value, model_instance)

View File

@@ -162,6 +162,7 @@ class FeiShu(RequestMixin):
except Exception as e:
logger.error(f'Get user detail error: {e} data={data}')
data.update(kwargs['other_info'] if 'other_info' in kwargs else {})
info = flatten_dict(data)
default_detail = self.default_user_detail(data, user_id)
detail = map_attributes(default_detail, info, self.attributes)

View File

@@ -1,3 +1,5 @@
#!/usr/bin/env python3
list_params = [
{
"name": "search",
@@ -11,41 +13,99 @@ list_params = [
"in": "query",
"description": "Which field to use when ordering the results.",
"required": False,
"type": "string"
"type": "string",
"enum": [
"name", "date_created", "date_updated", "created_by",
]
},
{
"name": "limit",
"in": "query",
"description": "Number of results to return per page. Default is 10.",
"required": False,
"type": "integer"
"type": "integer",
"default": 10
},
{
"name": "offset",
"in": "query",
"description": "The initial index from which to return the results.",
"required": False,
"type": "integer"
"type": "integer",
"default": 0
},
]
unsupported_resources = [
"customs", "protocol-settings", "gathered-accounts",
"account-template-secrets", 'change-secret-records',
'account-backup-executions', 'change-secret-executions',
'change-secret-status', 'gather-account-executions',
'push-account-executions', 'check-account-executions',
'integration-apps', 'asset-permissions-users-relations',
'asset-permissions-user-groups-relations', 'asset-permissions-assets-relations',
'asset-permissions-nodes-relations', 'terminal-status', 'tasks', 'status',
'session-sharing-records', 'endpoints', 'endpoint-rules',
'chatai-prompts', 'leak-passwords', 'super-connection-tokens',
'system-role-bindings', 'org-role-bindings', 'content-types', 'system-role-permissions',
'org-role-permissions', 'system-msg-subscriptions',
'celery-period-tasks', 'task-executions', 'adhocs',
'user-sessions', 'service-access-logs',
'applet-publications', 'applet-host-deployments',
'virtual-app-publications', 'applet-host-accounts', 'applet-host-applets',
'flows'
]
supported_resources = [
# User
'users', 'user-groups', 'users-groups-relations',
# Asset
'assets', 'hosts', 'devices', 'databases', 'webs', 'clouds', 'gpts',
'ds', 'platforms', 'nodes', 'zones', 'gateways',
# Account
'virtual-accounts', 'account-templates', 'account-backups',
# Automation
'change-secret-automations', 'gather-account-automations',
'push-account-automations', 'check-account-automations',
'account-risks',
# Permission
'asset-permissions',
# Terminal
'terminals', 'replay-storages', 'command-storages',
# Applet
'applets', 'applet-hosts', 'virtual-apps',
# Ops
'playbooks', 'jobs',
# Audit
'ftp-logs', 'login-logs', 'operate-logs', 'password-change-logs', 'job-logs',
# Tickets
'tickets', 'comments', 'apply-assets', 'apply-nodes',
# Acls
'login-acls', 'login-asset-acls', 'command-filter-acls',
'command-groups', 'connect-method-acls', 'data-masking-rules',
# RBAC
'roles', 'role-bindings',
'system-roles', 'org-roles',
# Label
'labeled-resources',
'labels',
]
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.
""",
"description": f"""Resource to query, {supported_resources}
GET /api/v1/resources/ to get full supported resource.
if you want to get the resource list, you can set the resource name in the url.
if you want to create a resource, you can set the resource name in the url.
if you want to get the resource detail, you can set the resource name and id in the url.
if you want to update the resource, you can set the resource name and id in the url.
if you want to delete the resource, you can set the resource name and id in the url.
""",
"required": True,
"type": "string"
"type": "string",
"enum": supported_resources
},
{
"name": "X-JMS-ORG",

View File

@@ -69,13 +69,13 @@ class ResourceListApi(ProxyMixin, APIView):
return self._proxy(request, resource)
@extend_schema(
operation_id="create_resource_by_type",
operation_id="create_resource",
summary="Create resource",
parameters=create_params,
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.
OPTIONS /api/v1/resources/{resource}/?action=post to get every resource type field type and help text,
and you will know how to create it.
""",
)
def post(self, request, resource, pk=None):

View File

@@ -7,6 +7,7 @@ from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from .const import supported_resources
from .utils import get_full_resource_map
router = DefaultRouter()
@@ -35,6 +36,8 @@ class ResourceTypeListApi(APIView):
result = []
resource_map = get_full_resource_map()
for name, desc in resource_map.items():
if name not in supported_resources:
continue
desc = resource_map.get(name, {})
resource = {
"name": name,

View File

@@ -4,8 +4,9 @@ import re
from functools import lru_cache
from typing import Dict
from django.urls import URLPattern
from django.urls import URLResolver
from django.utils.functional import LazyObject
from django.urls import URLPattern, URLResolver
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from rest_framework.routers import DefaultRouter
@@ -114,9 +115,37 @@ def extract_resource_paths(urlpatterns, prefix='/api/v1/') -> Dict[str, Dict[str
def param_dic_to_param(d):
# 将 'in' 字段映射到 OpenApiParameter 的 location 常量
location_map = {
'query': OpenApiParameter.QUERY,
'path': OpenApiParameter.PATH,
'header': OpenApiParameter.HEADER,
'cookie': OpenApiParameter.COOKIE,
}
location = location_map.get(d['in'], OpenApiParameter.QUERY)
# 将 type 字符串映射到 OpenApiTypes
type_map = {
'string': OpenApiTypes.STR,
'integer': OpenApiTypes.INT,
'number': OpenApiTypes.FLOAT,
'boolean': OpenApiTypes.BOOL,
'array': OpenApiTypes.OBJECT, # 对于 array 类型,需要特殊处理
'object': OpenApiTypes.OBJECT,
}
param_type = type_map.get(d['type'], OpenApiTypes.STR)
enum = None
if d.get('enum'):
enum = d['enum']
return OpenApiParameter(
name=d['name'], location=d['in'],
description=d['description'], type=d['type'], required=d.get('required', False)
name=d['name'],
location=location,
description=d['description'],
type=param_type,
required=d.get('required', False),
enum=enum
)

View File

@@ -88,7 +88,7 @@ urlpatterns += [
path('core/jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'),
]
DOC_TTL = 60 * 60
DOC_TTL = 1
DOC_VERSION = uuid.uuid4().hex
cache_kwargs = {
'cache_timeout': DOC_TTL,
@@ -98,7 +98,7 @@ cache_kwargs = {
}
# docs 路由
urlpatterns += [
path('api/swagger.json', views.get_swagger_view(ui='json', **cache_kwargs), name='schema-json'),
path('api/swagger.json', views.get_swagger_view(ui='json'), name='schema-json'),
path('api/swagger.yaml', views.get_swagger_view(ui='yaml', **cache_kwargs), name='schema'),
re_path('api/docs/?', views.get_swagger_view(ui='swagger', **cache_kwargs), name="docs"),
re_path('api/redoc/?', views.get_swagger_view(ui='redoc', **cache_kwargs), name='redoc'),

View File

@@ -14,7 +14,7 @@ class CustomSchemaGenerator(SchemaGenerator):
class CustomAutoSchema(AutoSchema):
def __init__(self, *args, **kwargs):
self.from_mcp = kwargs.get('from_mcp', False)
self.from_mcp = True
super().__init__(*args, **kwargs)
def map_parsers(self):
@@ -30,19 +30,19 @@ class CustomAutoSchema(AutoSchema):
tags = ['_'.join(operation_keys[:2])]
return tags
def get_operation(self, path, *args, **kwargs):
if path.endswith('render-to-json/'):
return None
# if not path.startswith('/api/v1/users'):
# return None
operation = super().get_operation(path, *args, **kwargs)
if not operation:
return operation
# def get_operation(self, path, *args, **kwargs):
# if path.endswith('render-to-json/'):
# return None
# # if not path.startswith('/api/v1/users'):
# # return None
# operation = super().get_operation(path, *args, **kwargs)
# if not operation:
# return operation
if not operation.get('summary', ''):
operation['summary'] = operation.get('operationId')
# if not operation.get('summary', ''):
# operation['summary'] = operation.get('operationId')
return operation
# return operation
def get_operation_id(self):
tokenized_path = self._tokenize_path()
@@ -118,7 +118,8 @@ class CustomAutoSchema(AutoSchema):
'change-secret-dashboard', '/copy-to-assets/',
'/move-to-assets/', 'dashboard', 'index', 'countries',
'/resources/cache/', 'profile/mfa', 'profile/password',
'profile/permissions', 'prometheus', 'constraints'
'profile/permissions', 'prometheus', 'constraints',
'/api/swagger.json', '/api/swagger.yaml',
]
for p in excludes:
if path.find(p) >= 0:
@@ -132,14 +133,15 @@ class CustomAutoSchema(AutoSchema):
apps = []
if self.from_mcp:
apps = [
'ops', 'tickets', 'authentication',
'settings', 'xpack', 'terminal', 'rbac',
'notifications', 'promethues', 'acls'
]
# apps = [
# 'ops', 'tickets', 'authentication',
# 'settings', 'xpack', 'terminal', 'rbac',
# 'notifications', 'promethues', 'acls'
# ]
apps = ['resources']
app_name = parts[3]
if app_name in apps:
if app_name not in apps:
return True
models = []
model = parts[4]
@@ -190,6 +192,9 @@ class CustomAutoSchema(AutoSchema):
if not operation.get('summary', ''):
operation['summary'] = operation.get('operationId')
if self.is_excluded():
return None
exclude_operations = [
'orgs_orgs_read', 'orgs_orgs_update', 'orgs_orgs_delete',

View File

@@ -9,15 +9,16 @@ from rest_framework.response import Response
from django.contrib.auth.mixins import LoginRequiredMixin
class SwaggerUI(LoginRequiredMixin, SpectacularSwaggerView):
class SwaggerUI( SpectacularSwaggerView):
pass
class Redoc(LoginRequiredMixin, SpectacularRedocView):
pass
class SchemeMixin:
permission_classes = []
def get(self, request, *args, **kwargs):
schema = super().get(request, *args, **kwargs).data
host = request.get_host()
@@ -37,7 +38,7 @@ class SchemeMixin:
}
return Response(schema)
@method_decorator(cache_page(60 * 5,), name="dispatch")
# @method_decorator(cache_page(60 * 5,), name="dispatch")
class JsonApi(SchemeMixin, SpectacularJSONAPIView):
pass

View File

@@ -160,6 +160,19 @@ class SSHClient:
try:
self.client.connect(**self.connect_params)
self._channel = self.client.invoke_shell()
# Always perform a gentle handshake that works for servers and
# network devices: drain banner, brief settle, send newline, then
# read in quiet mode to avoid blocking on missing prompt.
try:
while self._channel.recv_ready():
self._channel.recv(self.buffer_size)
except Exception:
pass
time.sleep(0.5)
try:
self._channel.send(b'\n')
except Exception:
pass
self._get_match_recv()
self.switch_user()
except Exception as error:
@@ -186,16 +199,40 @@ class SSHClient:
def _get_match_recv(self, answer_reg=DEFAULT_RE):
buffer_str = ''
prev_str = ''
last_change_ts = time.time()
# Quiet-mode reading only when explicitly requested, or when both
# answer regex and prompt are permissive defaults.
use_regex_match = True
if answer_reg == DEFAULT_RE and self.prompt == DEFAULT_RE:
use_regex_match = False
check_reg = self.prompt if answer_reg == DEFAULT_RE else answer_reg
while True:
if self.channel.recv_ready():
chunk = self.channel.recv(self.buffer_size).decode('utf-8', 'replace')
buffer_str += chunk
if chunk:
buffer_str += chunk
last_change_ts = time.time()
if buffer_str and buffer_str != prev_str:
if self.__match(check_reg, buffer_str):
if use_regex_match:
if self.__match(check_reg, buffer_str):
break
else:
# Wait for a brief quiet period to approximate completion
if time.time() - last_change_ts > 0.3:
break
elif not use_regex_match and buffer_str:
# In quiet mode with some data already seen, also break after
# a brief quiet window even if buffer hasn't changed this loop.
if time.time() - last_change_ts > 0.3:
break
elif not use_regex_match and not buffer_str:
# No data at all in quiet mode; bail after short wait
if time.time() - last_change_ts > 1.0:
break
prev_str = buffer_str
time.sleep(0.01)

View File

@@ -68,14 +68,14 @@ dependencies = [
'ipip-ipdb==1.6.1',
'pywinrm==0.4.3',
'python-nmap==0.7.1',
'django==4.2.24',
'django==4.1.13',
'django-bootstrap3==23.4',
'django-filter==23.2',
'django-formtools==2.5.1',
'django-ranged-response==0.2.0',
'django-simple-captcha==0.5.18',
'django-timezone-field==5.1',
'djangorestframework==3.16.1',
'djangorestframework==3.14.0',
'djangorestframework-bulk==0.2.1',
'django-simple-history==3.6.0',
'django-private-storage==3.1',

6
uv.lock generated
View File

@@ -2595,7 +2595,7 @@ requires-dist = [
{ name = "python-cas", specifier = "==1.6.0" },
{ name = "python-daemon", specifier = "==3.0.1" },
{ name = "python-dateutil", specifier = "==2.8.2" },
{ name = "python-ldap", specifier = "==3.4.3" },
{ name = "python-ldap", specifier = "==3.4.5" },
{ name = "python-nmap", specifier = "==0.7.1" },
{ name = "python-redis-lock", specifier = "==4.0.0" },
{ name = "python3-saml", specifier = "==1.16.0" },
@@ -4211,13 +4211,13 @@ wheels = [
[[package]]
name = "python-ldap"
version = "3.4.3"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/7d/de9ae3e5843de77eae3a60c1e70ef5cad9960db50521e8459f7d567a1d1d/python-ldap-3.4.3.tar.gz", hash = "sha256:ab26c519a0ef2a443a2a10391fa3c5cb52d7871323399db949ebfaa9f25ee2a0", size = 377438, upload-time = "2022-09-20T15:46:21.283Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/88/8d2797decc42e1c1cdd926df4f005e938b0643d0d1219c08c2b5ee8ae0c0/python_ldap-3.4.5.tar.gz", hash = "sha256:b2f6ef1c37fe2c6a5a85212efe71311ee21847766a7d45fcb711f3b270a5f79a", size = 388482, upload-time = "2025-10-10T20:00:39.06Z" }
[[package]]
name = "python-nmap"