feat(gerp): 真应用注册协议 Manifest + 严格可用收紧 + 应用配置
- 新增 app_manifests.py:应用清单协议(元数据/菜单/前端入口/配置模板),内置6应用 - 菜单由 Manifest 全权管理:安装注入(挂已知父菜单)/卸载删除,删除手工 menu 项 - App 表新增 FrontendRoute/FrontendType/LegacySrc(0025);新增 AppConfig 键值表(0026) - app_manager 配置服务(模板/读取/保存)+ AppConfigView(/API/App/Config) - 严格收紧:AppAccessPermission 移除管理员豁免、menu 过滤全员执行按 Manifest 全菜单 - mail/gitea 管理视图补挂应用权限;Uninstall 支持 TenantOnly - ManageCompanies 支持按应用查公司安装状态;修复 UUIDObj 无 .hex(转 bytes) - 新增 sync_apps 管理命令(按安装状态收敛菜单)
This commit is contained in:
1 parent
4de301aa78
commit
06d43b5a6e
14 files changed
+875
-56
No files matched your search
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GERP 管理系统</title>
|
||||
<script type="module" crossorigin src="/assets/index-DxQCUaG4.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BCZ_yh-A.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/element-plus-DtMR4CIi.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-C0vl14oO.js">
|
||||
@@ -14,7 +14,7 @@
|
||||
<link rel="modulepreload" crossorigin href="/assets/formkit-DL7Nej8X.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vue-vendor-DmO0PmrK.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/serverScope-C6yhSV7-.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/style-Xc5aXuRA.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/style-C-s7UXLv.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""模块:应用级访问控制(AppAccess)
|
||||
|
||||
将功能/应用体系的 CheckAccess 硬校验挂到各应用 API 入口:
|
||||
- 管理员(admin:super / admin:tenant):豁免——与菜单过滤逻辑一致,管理员全览
|
||||
- 普通用户:按 AppManager.CheckAccess 校验(企业功能按租户安装状态、个人应用按个人启用状态)
|
||||
- 所有用户(含管理员):严格按安装状态校验——
|
||||
企业功能 = 当前租户已装 或 当前公司已装;个人应用 = 个人已启用
|
||||
- 管理员不豁免业务 API:未安装应用对管理员同样不可用(避免白嫖未安装功能)
|
||||
- 管理员仅豁免应用中心管理(安装/停用/审批视图走 IsAdminUser,不挂本权限)
|
||||
- 未安装/未启用:返回 403(DRF permission 拒绝)
|
||||
|
||||
用法(视图 permission_classes):
|
||||
@@ -28,10 +30,6 @@ def AppAccessPermission(AppCode):
|
||||
user = getattr(request, 'user', None)
|
||||
if not (user and getattr(user, 'IsAuthenticated', False)):
|
||||
return False
|
||||
# 管理员豁免(与菜单过滤一致:admin:super/admin:tenant 全览)
|
||||
from gvsdsdk.auth.admin_service import IsAdmin
|
||||
if IsAdmin(request=request, user=user):
|
||||
return True
|
||||
# 解析当前租户/用户上下文(CurrentRoleMiddleware 已设置 request.current_tenant_uuid)
|
||||
tenant_uuid = getattr(request, 'current_tenant_uuid', None)
|
||||
user_uuid = getattr(user, 'UserUUID', None)
|
||||
|
||||
@@ -66,6 +66,10 @@ def _AppFlat(app, tenant_installed=None, user_record=None, company_installed=Non
|
||||
'AuthRequired': app.AuthRequired,
|
||||
'MenuRootUUID': UUIDObj(app.MenuRootUUID).str if app.MenuRootUUID else '',
|
||||
'AppSort': app.AppSort or 0,
|
||||
# 前端入口(真应用注册协议:安装后"打开应用"跳转目标)
|
||||
'FrontendRoute': app.FrontendRoute or '',
|
||||
'FrontendType': app.FrontendType or 'vue',
|
||||
'LegacySrc': app.LegacySrc or '',
|
||||
# 状态附加(市场页/我的应用展示用)
|
||||
'TenantInstalled': 1 if tenant_installed and tenant_installed.InstallStatus == 1 else 0,
|
||||
'TenantAuth': tenant_installed.AuthStatus if tenant_installed else 0,
|
||||
@@ -213,8 +217,39 @@ class AppManager:
|
||||
if app.AuthRequired == 1 and ta.AuthStatus != AUTH_OK:
|
||||
ta.AuthStatus = AUTH_OK
|
||||
ta.save(update_fields=['InstallStatus', 'AuthStatus'])
|
||||
# 真应用协议:安装后注入菜单(MenuStatus=1)
|
||||
try:
|
||||
from .app_manifests import InjectAppMenus
|
||||
InjectAppMenus(app.AppCode)
|
||||
except Exception:
|
||||
pass
|
||||
return True, '', _AppFlat(app, tenant_installed=ta)
|
||||
|
||||
@staticmethod
|
||||
def _AnyEnabledInstall(AppUUID):
|
||||
"""应用是否仍存在任意启用中的安装/启用记录(跨租户/公司/个人)"""
|
||||
app_bytes = _ToBytes(AppUUID)
|
||||
if TenantApp.objects.filter(AppUUID=app_bytes, InstallStatus=1).exists():
|
||||
return True
|
||||
if CompanyApp.objects.filter(AppUUID=app_bytes, InstallStatus=1).exists():
|
||||
return True
|
||||
if UserApp.objects.filter(AppUUID=app_bytes, UseStatus=1).exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _MaybeRemoveAppMenus(AppUUID):
|
||||
"""真应用协议:全局已无任何启用安装记录时移除菜单(MenuStatus=0)"""
|
||||
app = AppManager.GetApp(AppUUID)
|
||||
if app is None:
|
||||
return
|
||||
try:
|
||||
from .app_manifests import RemoveAppMenus
|
||||
if not AppManager._AnyEnabledInstall(AppUUID):
|
||||
RemoveAppMenus(app.AppCode)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def UninstallTenantApp(TenantUUID, AppUUID):
|
||||
"""租户停用企业功能"""
|
||||
@@ -224,6 +259,7 @@ class AppManager:
|
||||
return False, '该功能未安装'
|
||||
ta.InstallStatus = 0
|
||||
ta.save(update_fields=['InstallStatus'])
|
||||
AppManager._MaybeRemoveAppMenus(AppUUID)
|
||||
return True, ''
|
||||
|
||||
# ==================== 公司维度安装/停用 ====================
|
||||
@@ -265,6 +301,12 @@ class AppManager:
|
||||
if install_by:
|
||||
ca.InstallBy = install_by
|
||||
ca.save(update_fields=['InstallStatus', 'InstallBy'])
|
||||
# 真应用协议:安装后注入菜单(MenuStatus=1)
|
||||
try:
|
||||
from .app_manifests import InjectAppMenus
|
||||
InjectAppMenus(app.AppCode)
|
||||
except Exception:
|
||||
pass
|
||||
return True, '', len(company_list)
|
||||
|
||||
@staticmethod
|
||||
@@ -286,6 +328,7 @@ class AppManager:
|
||||
updated += 1
|
||||
if updated == 0:
|
||||
return False, '所选公司均未安装该功能'
|
||||
AppManager._MaybeRemoveAppMenus(AppUUID)
|
||||
return True, ''
|
||||
|
||||
@staticmethod
|
||||
@@ -307,6 +350,137 @@ class AppManager:
|
||||
}
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def ListAppCompanyInstalls(AppUUID, CompanyUUIDs):
|
||||
"""查询指定应用在若干公司的安装状态(管理弹窗用)
|
||||
|
||||
Args:
|
||||
AppUUID: 应用 UUID(str/bytes/UUIDObj)
|
||||
CompanyUUIDs: 公司 UUID 列表(bytes 列表)
|
||||
|
||||
Returns:
|
||||
dict[bytes, int]:公司 UUID bytes → InstallStatus(1已装/0未装)
|
||||
"""
|
||||
app_bytes = _ToBytes(AppUUID)
|
||||
if app_bytes is None:
|
||||
return {}
|
||||
company_list = [_ToBytes(c) for c in (CompanyUUIDs or []) if _ToBytes(c)]
|
||||
if not company_list:
|
||||
return {}
|
||||
result = {c: 0 for c in company_list}
|
||||
for ca in CompanyApp.objects.filter(AppUUID=app_bytes, CompanyUUID__in=company_list):
|
||||
result[_ToBytes(ca.CompanyUUID)] = ca.InstallStatus
|
||||
return result
|
||||
|
||||
# ==================== 应用配置(AppConfig 键值表) ====================
|
||||
|
||||
@staticmethod
|
||||
def GetAppConfigTemplate(AppCode):
|
||||
"""返回应用配置模板(Manifest 声明的配置键)
|
||||
|
||||
Returns:
|
||||
list[dict]:{Key, Label, Default, Secret, Placeholder}
|
||||
"""
|
||||
from .app_manifests import GetAppManifest
|
||||
manifest = GetAppManifest(AppCode)
|
||||
if manifest is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
'Key': spec.Key,
|
||||
'Label': spec.Label or spec.Key,
|
||||
'Default': spec.Default or '',
|
||||
'Secret': 1 if spec.Secret else 0,
|
||||
'Placeholder': spec.Placeholder or '',
|
||||
}
|
||||
for spec in manifest.ConfigKeys
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def GetAppConfig(TenantUUID, AppUUID):
|
||||
"""读取指定租户下应用配置(模板 + 已保存值)
|
||||
|
||||
Returns:
|
||||
list[dict]:{Key, Label, Value, Default, Secret, Placeholder, Updated}
|
||||
"""
|
||||
from .models import AppConfig
|
||||
app = AppManager.GetApp(AppUUID)
|
||||
if app is None:
|
||||
return []
|
||||
template = AppManager.GetAppConfigTemplate(app.AppCode)
|
||||
if not template:
|
||||
return []
|
||||
tenant_bytes = _ToBytes(TenantUUID)
|
||||
app_bytes = _ToBytes(AppUUID)
|
||||
saved = {}
|
||||
if tenant_bytes:
|
||||
for cfg in AppConfig.objects.filter(TenantUUID=tenant_bytes, AppUUID=app_bytes):
|
||||
saved[cfg.ConfigKey] = cfg
|
||||
result = []
|
||||
for t in template:
|
||||
key = t['Key']
|
||||
cfg = saved.get(key)
|
||||
result.append({
|
||||
'Key': key,
|
||||
'Label': t['Label'],
|
||||
'Default': t['Default'],
|
||||
'Secret': t['Secret'],
|
||||
'Placeholder': t['Placeholder'],
|
||||
'Value': cfg.ConfigValue if cfg else t['Default'],
|
||||
'Updated': cfg is not None,
|
||||
})
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def SaveAppConfig(TenantUUID, AppUUID, Updates, OperatorUUID=None):
|
||||
"""保存租户应用配置(按模板键 upsert)
|
||||
|
||||
Args:
|
||||
TenantUUID: 租户 UUID
|
||||
AppUUID: 应用 UUID
|
||||
Updates: dict {Key: Value}
|
||||
OperatorUUID: 更新人 UUID(可选)
|
||||
|
||||
Returns:
|
||||
(ok, err, updated_keys)
|
||||
"""
|
||||
import datetime as _dt
|
||||
from .models import AppConfig
|
||||
app = AppManager.GetApp(AppUUID)
|
||||
if app is None:
|
||||
return False, '应用不存在', []
|
||||
template = AppManager.GetAppConfigTemplate(app.AppCode)
|
||||
if not template:
|
||||
return False, '该应用未声明配置项', []
|
||||
allowed = {t['Key'] for t in template}
|
||||
tenant_bytes = _ToBytes(TenantUUID)
|
||||
app_bytes = _ToBytes(AppUUID)
|
||||
operator_bytes = _ToBytes(OperatorUUID)
|
||||
updated = []
|
||||
for key, value in (Updates or {}).items():
|
||||
if key not in allowed:
|
||||
continue
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
cfg, created = AppConfig.objects.get_or_create(
|
||||
TenantUUID=tenant_bytes,
|
||||
AppUUID=app_bytes,
|
||||
ConfigKey=key,
|
||||
defaults={
|
||||
'AppConfigUUID': uuid.uuid4().bytes,
|
||||
'ConfigValue': value,
|
||||
'UpdateBy': operator_bytes,
|
||||
'UpdateTime': _dt.datetime.utcnow(),
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
cfg.ConfigValue = value
|
||||
cfg.UpdateBy = operator_bytes
|
||||
cfg.UpdateTime = _dt.datetime.utcnow()
|
||||
cfg.save(update_fields=['ConfigValue', 'UpdateBy', 'UpdateTime'])
|
||||
updated.append(key)
|
||||
return True, '', updated
|
||||
|
||||
# ==================== 个人启用/申请/审批 ====================
|
||||
|
||||
@staticmethod
|
||||
@@ -335,11 +509,22 @@ class AppManager:
|
||||
return False, '该应用需企业授权,请先申请(待租户管理员审批)', None
|
||||
ua.UseStatus = 1
|
||||
ua.save(update_fields=['UseStatus'])
|
||||
AppManager._InjectMenusSafe(app.AppCode)
|
||||
return True, '', _AppFlat(app, user_record=ua)
|
||||
ua.UseStatus = 1
|
||||
ua.save(update_fields=['UseStatus'])
|
||||
AppManager._InjectMenusSafe(app.AppCode)
|
||||
return True, '', _AppFlat(app, user_record=ua)
|
||||
|
||||
@staticmethod
|
||||
def _InjectMenusSafe(AppCode):
|
||||
"""真应用协议:安装/启用后注入菜单(异常静默)"""
|
||||
try:
|
||||
from .app_manifests import InjectAppMenus
|
||||
InjectAppMenus(AppCode)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def DisableUserApp(UserUUID, AppUUID):
|
||||
"""个人停用应用"""
|
||||
@@ -349,6 +534,7 @@ class AppManager:
|
||||
return False, '未启用该应用'
|
||||
ua.UseStatus = 0
|
||||
ua.save(update_fields=['UseStatus'])
|
||||
AppManager._MaybeRemoveAppMenus(AppUUID)
|
||||
return True, ''
|
||||
|
||||
@staticmethod
|
||||
@@ -484,13 +670,13 @@ class AppManager:
|
||||
from gvsdsdk.auth.admin_service import IsSuperAdmin, IsTenantAdmin, IsCompanyAdmin
|
||||
if IsSuperAdmin(request=request):
|
||||
from gvsdsdk.models import Company as SdkCompany
|
||||
return [c.CompanyUUID for c in SdkCompany.objects.filter(CompanyStatus=1)], 'all'
|
||||
return [_ToBytes(c.CompanyUUID) for c in SdkCompany.objects.filter(CompanyStatus=1)], 'all'
|
||||
tenant_uuid = getattr(request, 'current_tenant_uuid', None)
|
||||
tenant_bytes = _ToBytes(tenant_uuid)
|
||||
if IsTenantAdmin(request=request):
|
||||
from gvsdsdk.models import Company as SdkCompany
|
||||
qs = SdkCompany.objects.filter(TenantUUID=tenant_bytes, CompanyStatus=1)
|
||||
return [c.CompanyUUID for c in qs], 'tenant'
|
||||
return [_ToBytes(c.CompanyUUID) for c in qs], 'tenant'
|
||||
if IsCompanyAdmin(request=request):
|
||||
# admin:company:取当前角色被授权公司(RolePermission.CompanyUUID 维度)
|
||||
role = getattr(request, 'current_role', None)
|
||||
@@ -503,6 +689,6 @@ class AppManager:
|
||||
for rp in RolePermission.objects.filter(
|
||||
RoleUUID=role.RoleUUID, PermUUID__in=perm_uuids):
|
||||
if rp.CompanyUUID and rp.CompanyUUID != b'\x00' * 16:
|
||||
companies.append(rp.CompanyUUID)
|
||||
companies.append(_ToBytes(rp.CompanyUUID))
|
||||
return companies, 'company'
|
||||
return [], ''
|
||||
@@ -0,0 +1,377 @@
|
||||
"""模块:应用清单(AppManifest)注册协议
|
||||
|
||||
把"前端叫应用的这些"当成真正的应用去注册和处理——每个应用在代码中
|
||||
声明一份清单(Manifest),统一描述:
|
||||
- 元数据 :AppCode/AppName/AppIcon/AppDesc/AppType/AuthRequired/排序
|
||||
- 菜单协议 :安装后注入 GERP 侧边栏的菜单树(含挂载父与权限)
|
||||
- 前端协议 :前端路由入口(vue 组件路由 / legacy 旧页面 iframe)
|
||||
- 执行协议 :前端类型与入口页,配合 AppAccessPermission 统一鉴权
|
||||
|
||||
注册流程:
|
||||
1. SyncAppManifests():按清单同步 App 表 + Menu 表(幂等 upsert),
|
||||
供管理命令 sync_apps 调用,亦可在部署后执行一次
|
||||
2. InjectAppMenus(AppCode):安装应用时激活菜单(MenuStatus=1)
|
||||
3. RemoveAppMenus(AppCode):卸载应用时移除菜单(MenuStatus=0)
|
||||
|
||||
扩展(MSYNC 预留):
|
||||
RegisterRemoteManifest():未来 MSYNC 子节点上报应用清单时,
|
||||
以同一 AppManifest 结构注册到远端注册中心,ListAppManifests()
|
||||
合并本地 + 远端清单统一下发。
|
||||
"""
|
||||
|
||||
import uuid as _uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from gvsdsdk.model_utils import UUIDObj
|
||||
from gvsdsdk.models import Menu as SdkMenu
|
||||
|
||||
from .models import App
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppMenuSpec:
|
||||
"""应用菜单节点声明(对应 Menu 表)
|
||||
|
||||
ParentMenuName 用父菜单的 MenuName 引用(应用菜单挂载点),
|
||||
None 表示一级菜单。MenuName 为全局唯一标识,用作 upsert 定位键。
|
||||
"""
|
||||
|
||||
MenuName: str
|
||||
MenuTitle: str
|
||||
MenuLink: str = ''
|
||||
MenuIcon: str = ''
|
||||
MenuSort: int = 0
|
||||
PermissionLevel: float = 1.0
|
||||
PermissionAttr: str = ''
|
||||
ParentMenuName: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfigSpec:
|
||||
"""应用配置键声明(对应 AppConfig 键值表)
|
||||
|
||||
应用在 Manifest 中声明自己的配置模板:
|
||||
- Key:配置键名(ConfigKey)
|
||||
- Label:配置项显示名(配置页表单用)
|
||||
- Default:默认值(未保存时回退)
|
||||
- Secret:是否敏感(true 时前端脱敏显示,提交占位符视为未修改)
|
||||
- Placeholder:输入提示(可选)
|
||||
"""
|
||||
|
||||
Key: str
|
||||
Label: str = ''
|
||||
Default: str = ''
|
||||
Secret: bool = False
|
||||
Placeholder: str = ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppManifest:
|
||||
"""应用清单——真应用注册协议的数据载体"""
|
||||
|
||||
AppCode: str
|
||||
AppName: str
|
||||
AppIcon: str = ''
|
||||
AppDesc: str = ''
|
||||
AppType: str = 'enterprise' # enterprise/personal/both
|
||||
AuthRequired: int = 0 # 0/1
|
||||
AppSort: int = 0
|
||||
DefaultPerms: str = '' # ; 分隔的默认权限码
|
||||
FrontendRoute: str = '' # 前端路由(安装后"打开应用"跳转)
|
||||
FrontendType: str = 'vue' # vue=前端组件 / legacy=旧页面 iframe
|
||||
LegacySrc: str = '' # legacy 页面路径(pages/xxx.html)
|
||||
Menus: List[AppMenuSpec] = field(default_factory=list)
|
||||
ConfigKeys: List[AppConfigSpec] = field(default_factory=list)
|
||||
Source: str = 'local' # local=本地清单 / msync=远端注册
|
||||
|
||||
|
||||
# ==================== 内置应用清单(local) ====================
|
||||
# 现状 6 个应用均为企业功能(enterprise),菜单挂载点保持与数据库现状一致。
|
||||
|
||||
APP_MANIFESTS: List[AppManifest] = [
|
||||
AppManifest(
|
||||
AppCode='mailbox',
|
||||
AppName='企业邮箱',
|
||||
AppIcon='Message',
|
||||
AppDesc='企业级邮件服务(Stalwart),支持多邮箱申请与安全浏览',
|
||||
AppType='enterprise',
|
||||
AppSort=1,
|
||||
FrontendRoute='/mail/mailbox',
|
||||
FrontendType='vue',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='mailbox', MenuTitle='我的邮箱',
|
||||
MenuLink='pages/mail/mailbox.html', MenuIcon='Message',
|
||||
MenuSort=3, ParentMenuName='myself',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='mailbox-manage', MenuTitle='邮箱管理',
|
||||
MenuLink='pages/mail/admin.html', MenuIcon='Message',
|
||||
MenuSort=6, ParentMenuName='company',
|
||||
),
|
||||
],
|
||||
ConfigKeys=[
|
||||
AppConfigSpec(Key='SMTP_HOST', Label='SMTP 服务器', Placeholder='如 smtp.gvsds.com'),
|
||||
AppConfigSpec(Key='SMTP_PORT', Label='SMTP 端口', Default='465'),
|
||||
AppConfigSpec(Key='DOMAIN', Label='邮箱域名', Placeholder='如 gvsds.com'),
|
||||
AppConfigSpec(Key='ALLOW_COMPANY', Label='可用公司(留空=全部公司)', Placeholder='公司UUID,多个用逗号分隔'),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='gitea',
|
||||
AppName='代码托管 Gitea',
|
||||
AppIcon='Connection',
|
||||
AppDesc='企业代码托管平台,支持免密登录与组织协作',
|
||||
AppType='enterprise',
|
||||
AppSort=2,
|
||||
FrontendRoute='/gitea/mine',
|
||||
FrontendType='vue',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='gitea-mine', MenuTitle='我的 Gitea',
|
||||
MenuLink='pages/gitea/mine.html', MenuIcon='Connection',
|
||||
MenuSort=4, ParentMenuName='myself',
|
||||
),
|
||||
AppMenuSpec(
|
||||
MenuName='gitea-admin', MenuTitle='Gitea 账号管理',
|
||||
MenuLink='pages/gitea/admin.html', MenuIcon='Connection',
|
||||
MenuSort=7, ParentMenuName='company',
|
||||
),
|
||||
],
|
||||
ConfigKeys=[
|
||||
AppConfigSpec(Key='ORG_NAME', Label='组织名', Placeholder='如 GVSDS'),
|
||||
AppConfigSpec(Key='DOMAIN', Label='托管域名', Placeholder='如 git.gvsds.com'),
|
||||
AppConfigSpec(Key='ALLOW_COMPANY', Label='可用公司(留空=全部公司)', Placeholder='公司UUID,多个用逗号分隔'),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='shop',
|
||||
AppName='枢机协律-商店',
|
||||
AppIcon='ShoppingCart',
|
||||
AppDesc='枢机协律-商店:企业内部商店与商城订单管理',
|
||||
AppType='enterprise',
|
||||
AppSort=3,
|
||||
FrontendRoute='/private/CreateE/shop',
|
||||
FrontendType='legacy',
|
||||
LegacySrc='pages/private/CreateE/shop.html',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='create-e-shop', MenuTitle='商店',
|
||||
MenuLink='pages/private/CreateE/shop.html',
|
||||
MenuIcon='ShoppingCart', MenuSort=4, ParentMenuName='create-e',
|
||||
),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='store-value',
|
||||
AppName='枢机协律-储值',
|
||||
AppIcon='Wallet',
|
||||
AppDesc='枢机协律-储值:储值卡充值与管理',
|
||||
AppType='enterprise',
|
||||
AppSort=4,
|
||||
FrontendRoute='/private/CreateE/StoreValue',
|
||||
FrontendType='legacy',
|
||||
LegacySrc='pages/private/CreateE/StoreValue.html',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='create-e-store-value', MenuTitle='储值查看',
|
||||
MenuLink='pages/private/CreateE/StoreValue.html',
|
||||
MenuIcon='Wallet', MenuSort=3, ParentMenuName='create-e',
|
||||
),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='wallet',
|
||||
AppName='枢机协律-支付系统',
|
||||
AppIcon='Wallet',
|
||||
AppDesc='枢机协律-支付系统:个人钱包账户与支付',
|
||||
AppType='enterprise',
|
||||
AppSort=5,
|
||||
FrontendRoute='/private/CreateE/wallet',
|
||||
FrontendType='legacy',
|
||||
LegacySrc='pages/private/CreateE/wallet.html',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='create-e-wallet', MenuTitle='钱包',
|
||||
MenuLink='pages/private/CreateE/wallet.html',
|
||||
MenuIcon='Wallet', MenuSort=0, ParentMenuName='create-e',
|
||||
),
|
||||
],
|
||||
),
|
||||
AppManifest(
|
||||
AppCode='enterprise',
|
||||
AppName='枢机协律-模拟企业',
|
||||
AppIcon='OfficeBuilding',
|
||||
AppDesc='枢机协律-模拟企业:企业信息管理(工商注册与企业详情)',
|
||||
AppType='enterprise',
|
||||
AppSort=6,
|
||||
FrontendRoute='/private/CreateE/enterprise',
|
||||
FrontendType='legacy',
|
||||
LegacySrc='pages/private/CreateE/enterprise.html',
|
||||
Menus=[
|
||||
AppMenuSpec(
|
||||
MenuName='create-e-enterprise', MenuTitle='企业管理',
|
||||
MenuLink='pages/private/CreateE/enterprise.html',
|
||||
MenuIcon='OfficeBuilding', MenuSort=1, ParentMenuName='create-e',
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
# ==================== MSYNC 远端清单注册中心(预留) ====================
|
||||
|
||||
_REMOTE_MANIFESTS: List[AppManifest] = []
|
||||
|
||||
|
||||
def RegisterRemoteManifest(manifest: AppManifest) -> None:
|
||||
"""注册 MSYNC 远端应用清单(未来子节点上报接入点)。
|
||||
|
||||
与本地清单同构:AppCode 冲突时远端优先(避免覆盖已安装租户数据除外)。
|
||||
"""
|
||||
for i, m in enumerate(_REMOTE_MANIFESTS):
|
||||
if m.AppCode == manifest.AppCode:
|
||||
_REMOTE_MANIFESTS[i] = manifest
|
||||
return
|
||||
_REMOTE_MANIFESTS.append(manifest)
|
||||
|
||||
|
||||
def ListAppManifests() -> List[AppManifest]:
|
||||
"""全部应用清单(本地 + MSYNC 远端)"""
|
||||
return APP_MANIFESTS + _REMOTE_MANIFESTS
|
||||
|
||||
|
||||
def GetAppManifest(AppCode: str) -> Optional[AppManifest]:
|
||||
for m in ListAppManifests():
|
||||
if m.AppCode == AppCode:
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
# ==================== 同步与注入 ====================
|
||||
|
||||
def _ToBytes(value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
if hasattr(value, 'bytes'):
|
||||
return value.bytes
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return UUIDObj(value).bytes
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _UpsertMenu(spec: AppMenuSpec) -> SdkMenu:
|
||||
"""按 MenuName upsert 菜单(存在则更新字段,不存在则创建)。
|
||||
|
||||
MenuStatus 不在此覆盖(由 Inject/Remove 控制)。
|
||||
"""
|
||||
menu = SdkMenu.objects.filter(MenuName=spec.MenuName).first()
|
||||
# 解析挂载父(按父 MenuName 查)
|
||||
parent_bytes = None
|
||||
if spec.ParentMenuName:
|
||||
parent = SdkMenu.objects.filter(MenuName=spec.ParentMenuName).first()
|
||||
if parent is not None:
|
||||
parent_bytes = parent.MenuUUID
|
||||
if menu is None:
|
||||
menu = SdkMenu(
|
||||
MenuUUID=_uuid.uuid4().bytes,
|
||||
MenuName=spec.MenuName,
|
||||
)
|
||||
menu.MenuTitle = spec.MenuTitle
|
||||
menu.MenuLink = spec.MenuLink
|
||||
menu.MenuIcon = spec.MenuIcon
|
||||
menu.MenuSort = spec.MenuSort
|
||||
menu.PermissionLevel = spec.PermissionLevel
|
||||
menu.PermissionAttr = spec.PermissionAttr
|
||||
menu.ParentMenuUUID = parent_bytes
|
||||
menu.save()
|
||||
return menu
|
||||
|
||||
|
||||
def _SyncAppRecord(manifest: AppManifest) -> App:
|
||||
"""按 AppCode upsert App 记录,回填前端入口字段"""
|
||||
app = App.objects.filter(AppCode=manifest.AppCode).first()
|
||||
if app is None:
|
||||
app = App(AppUUID=_uuid.uuid4().bytes, AppCode=manifest.AppCode)
|
||||
app.AppName = manifest.AppName
|
||||
app.AppIcon = manifest.AppIcon
|
||||
app.AppDesc = manifest.AppDesc
|
||||
app.AppType = manifest.AppType
|
||||
app.AuthRequired = manifest.AuthRequired
|
||||
app.AppSort = manifest.AppSort
|
||||
app.DefaultPerms = manifest.DefaultPerms
|
||||
app.FrontendRoute = manifest.FrontendRoute
|
||||
app.FrontendType = manifest.FrontendType
|
||||
app.LegacySrc = manifest.LegacySrc
|
||||
app.save()
|
||||
return app
|
||||
|
||||
|
||||
def SyncAppManifests() -> List[str]:
|
||||
"""同步全部应用清单到 App 表(幂等)。
|
||||
|
||||
仅同步 App 表元数据与前端入口;菜单不在此创建——
|
||||
菜单由安装状态驱动(InjectAppMenus 创建/回填,RemoveAppMenus 删除),
|
||||
不在 Menu 表中手工维护应用菜单项。
|
||||
|
||||
Returns:
|
||||
同步的应用 AppCode 列表
|
||||
"""
|
||||
synced = []
|
||||
for manifest in ListAppManifests():
|
||||
app = _SyncAppRecord(manifest)
|
||||
synced.append(manifest.AppCode)
|
||||
return synced
|
||||
|
||||
|
||||
def _ManifestMenuNames(manifest: AppManifest) -> List[str]:
|
||||
return [spec.MenuName for spec in manifest.Menus]
|
||||
|
||||
|
||||
def InjectAppMenus(AppCode: str) -> bool:
|
||||
"""安装应用:注入菜单(按 Manifest 创建/更新,MenuStatus=1),回填 MenuRootUUID
|
||||
|
||||
菜单挂载点由 AppMenuSpec.ParentMenuName 声明(已知父菜单,如 myself/company),
|
||||
应用在安装时自动在父菜单下新增自己的页面。
|
||||
|
||||
Returns:
|
||||
True=成功(含无菜单声明的应用)
|
||||
"""
|
||||
manifest = GetAppManifest(AppCode)
|
||||
if manifest is None:
|
||||
return False
|
||||
root_menu = None
|
||||
for spec in manifest.Menus:
|
||||
menu = _UpsertMenu(spec)
|
||||
if menu.MenuStatus != 1:
|
||||
menu.MenuStatus = 1
|
||||
menu.save(update_fields=['MenuStatus'])
|
||||
if root_menu is None:
|
||||
root_menu = menu
|
||||
# 回填 MenuRootUUID:指向第一个菜单节点(主入口,供前端打开应用/菜单过滤使用)
|
||||
app = App.objects.filter(AppCode=AppCode).first()
|
||||
if root_menu is not None and app is not None:
|
||||
if app.MenuRootUUID != root_menu.MenuUUID:
|
||||
app.MenuRootUUID = root_menu.MenuUUID
|
||||
app.save(update_fields=['MenuRootUUID'])
|
||||
return True
|
||||
|
||||
|
||||
def RemoveAppMenus(AppCode: str) -> bool:
|
||||
"""卸载应用:删除 Manifest 声明的全部菜单行(含散挂页面)
|
||||
|
||||
菜单完全由 Manifest 管理:安装时注入创建,卸载时删除,
|
||||
不在 Menu 表中手工维护 gitea/mail 等应用菜单项。
|
||||
"""
|
||||
manifest = GetAppManifest(AppCode)
|
||||
if manifest is None:
|
||||
return False
|
||||
names = _ManifestMenuNames(manifest)
|
||||
if names:
|
||||
SdkMenu.objects.filter(MenuName__in=names).delete()
|
||||
return True
|
||||
@@ -0,0 +1,73 @@
|
||||
"""管理命令:sync_apps
|
||||
|
||||
真应用注册协议——把应用清单(Manifest)同步到 App 表,并按安装状态收敛菜单:
|
||||
- 同步 App 表:按清单 upsert 应用记录(元数据 + 前端入口字段)
|
||||
- 收敛菜单(默认执行):
|
||||
- 应用存在启用中的安装/启用记录(租户/公司/个人任一)→ 注入菜单(创建+MenuStatus=1)
|
||||
- 应用无任何启用记录 → 删除 Manifest 声明的菜单(清理手工遗留/卸载残留)
|
||||
菜单完全由 Manifest + 安装状态驱动,不在 Menu 表中手工维护应用菜单项。
|
||||
|
||||
用法:
|
||||
python manage.py sync_apps
|
||||
python manage.py sync_apps --inject # 强制注入全部应用菜单(含未安装,调试用)
|
||||
python manage.py sync_apps --no-menus # 仅同步 App 表,不动菜单
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '把应用清单(Manifest)同步到 App 表 + 按安装状态收敛菜单(真应用注册协议)'
|
||||
|
||||
# 跳过系统检查:历史遗留的 E030/E012(SDK 双轨模型 index/unique 噪音)与本次无关
|
||||
requires_system_checks = []
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--inject', action='store_true', dest='inject',
|
||||
help='强制注入全部应用菜单(MenuStatus=1,含未安装,调试用)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-menus', action='store_true', dest='no_menus',
|
||||
help='仅同步 App 表,不动菜单',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from gvsds.apps.gerp.app_manifests import (
|
||||
InjectAppMenus, ListAppManifests, RemoveAppMenus, SyncAppManifests,
|
||||
)
|
||||
from gvsds.apps.gerp.models import App
|
||||
|
||||
synced = SyncAppManifests()
|
||||
self.stdout.write(self.style.SUCCESS(f'已同步 {len(synced)} 个应用:{", ".join(synced)}'))
|
||||
|
||||
if options['no_menus']:
|
||||
return
|
||||
|
||||
if options['inject']:
|
||||
# 调试模式:无条件注入全部应用菜单
|
||||
for manifest in ListAppManifests():
|
||||
if manifest.Menus:
|
||||
InjectAppMenus(manifest.AppCode)
|
||||
self.stdout.write(self.style.SUCCESS('已强制注入全部应用菜单(MenuStatus=1)'))
|
||||
return
|
||||
|
||||
# 默认模式:按安装状态收敛菜单(安装注入 / 未安装删除)
|
||||
injected, removed = [], []
|
||||
for manifest in ListAppManifests():
|
||||
app = App.objects.filter(AppCode=manifest.AppCode).first()
|
||||
if app is None:
|
||||
continue
|
||||
from gvsds.apps.gerp.app_manager import AppManager
|
||||
if AppManager._AnyEnabledInstall(app.AppUUID):
|
||||
if manifest.Menus:
|
||||
InjectAppMenus(manifest.AppCode)
|
||||
injected.append(manifest.AppCode)
|
||||
else:
|
||||
if manifest.Menus:
|
||||
RemoveAppMenus(manifest.AppCode)
|
||||
removed.append(manifest.AppCode)
|
||||
if injected:
|
||||
self.stdout.write(self.style.SUCCESS(f'已注入菜单(有安装记录):{", ".join(injected)}'))
|
||||
if removed:
|
||||
self.stdout.write(self.style.SUCCESS(f'已删除菜单(无安装记录):{", ".join(removed)}'))
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add App frontend entry fields (FrontendRoute/FrontendType/LegacySrc)
|
||||
|
||||
真应用注册协议——应用声明前端入口:
|
||||
- FrontendRoute:前端路由路径(安装后"打开应用"跳转目标,如 /mail/mailbox)
|
||||
- FrontendType :前端类型(vue=前端组件 / legacy=旧版页面 iframe 直载)
|
||||
- LegacySrc :旧版页面路径(FrontendType=legacy 时生效,如 pages/private/CreateE/shop.html)
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gerp', '0024_companyapp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='App',
|
||||
name='FrontendRoute',
|
||||
field=models.CharField(db_column='FrontendRoute', default='', max_length=256, verbose_name='前端路由(安装后跳转)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='App',
|
||||
name='FrontendType',
|
||||
field=models.CharField(db_column='FrontendType', default='vue', max_length=16, verbose_name='前端类型(vue/legacy)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='App',
|
||||
name='LegacySrc',
|
||||
field=models.CharField(db_column='LegacySrc', default='', max_length=256, verbose_name='旧版页面路径(legacy)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add AppConfig (per-tenant app config key-value)
|
||||
|
||||
应用配置键值表——按租户隔离的应用运行参数,供有安装权限者编辑。
|
||||
"""
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
import gvsds.apps.core.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gerp', '0025_app_frontend_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AppConfig',
|
||||
fields=[
|
||||
('app_config_uuid', gvsds.apps.core.models.UUIDField(db_column='AppConfigUUID', primary_key=True, serialize=False, verbose_name='应用配置UUID')),
|
||||
('tenant_uuid', gvsds.apps.core.models.UUIDField(db_column='TenantUUID', verbose_name='租户UUID')),
|
||||
('app_uuid', gvsds.apps.core.models.UUIDField(db_column='AppUUID', verbose_name='应用UUID')),
|
||||
('config_key', models.CharField(db_column='ConfigKey', max_length=128, verbose_name='配置键名')),
|
||||
('config_value', models.TextField(db_column='ConfigValue', default='', verbose_name='配置值')),
|
||||
('update_by', gvsds.apps.core.models.UUIDField(blank=True, db_column='UpdateBy', null=True, verbose_name='更新人UUID')),
|
||||
('update_time', models.DateTimeField(blank=True, db_column='UpdateTime', null=True, verbose_name='更新时间')),
|
||||
('create_time', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'AppConfig',
|
||||
'verbose_name': '应用配置',
|
||||
'verbose_name_plural': '应用配置',
|
||||
'unique_together': {('tenant_uuid', 'app_uuid', 'config_key')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -792,6 +792,9 @@ class App(QModel):
|
||||
AuthRequired = models.SmallIntegerField(default=0, verbose_name='是否需企业授权(0/1)')
|
||||
MenuRootUUID = UUIDField(null=True, blank=True, verbose_name='关联菜单根UUID(安装后注入)')
|
||||
DefaultPerms = models.TextField(default='', verbose_name='默认权限码集(;分隔)')
|
||||
FrontendRoute = models.CharField(max_length=256, default='', verbose_name='前端路由(安装后跳转)')
|
||||
FrontendType = models.CharField(max_length=16, default='vue', verbose_name='前端类型(vue/legacy)')
|
||||
LegacySrc = models.CharField(max_length=256, default='', verbose_name='旧版页面路径(legacy)')
|
||||
AppSort = models.IntegerField(default=0, verbose_name='排序')
|
||||
AppStatus = models.SmallIntegerField(default=1, verbose_name='状态(1启用/0禁用)')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
@@ -882,6 +885,34 @@ class UserApp(QModel):
|
||||
verbose_name_plural = '个人应用'
|
||||
|
||||
|
||||
class AppConfig(QModel):
|
||||
"""应用配置键值表——按租户隔离的应用运行参数
|
||||
|
||||
每个应用可声明若干配置键(ConfigKey),租户管理员/公司管理员
|
||||
(有安装权限者)可在"配置"入口编辑本租户下的配置值。
|
||||
|
||||
键值约定(由应用自行定义与读取,如 [stalwart] 域名映射):
|
||||
ConfigKey 配置键名(如 SMTP_HOST / DOMAIN)
|
||||
ConfigValue 配置值(自由文本)
|
||||
"""
|
||||
|
||||
AppConfigUUID = UUIDField(primary_key=True, verbose_name='应用配置UUID')
|
||||
TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID')
|
||||
AppUUID = UUIDField(db_column='AppUUID', verbose_name='应用UUID')
|
||||
ConfigKey = models.CharField(max_length=128, verbose_name='配置键名')
|
||||
ConfigValue = models.TextField(default='', verbose_name='配置值')
|
||||
UpdateBy = UUIDField(null=True, blank=True, verbose_name='更新人UUID')
|
||||
UpdateTime = models.DateTimeField(null=True, blank=True, verbose_name='更新时间')
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'AppConfig'
|
||||
app_label = 'gerp'
|
||||
unique_together = (('tenant_uuid', 'app_uuid', 'config_key'),)
|
||||
verbose_name = '应用配置'
|
||||
verbose_name_plural = '应用配置'
|
||||
|
||||
|
||||
# ==================== 从 gvsdsdk 导入重复模型 ====================
|
||||
# 以下模型原在 gerp/models.py 定义,现统一依赖 gvsdsdk.models(映射 SAAS 主库)
|
||||
# 注意:gvsdsdk 模型用 UUIDField(非 ForeignKey),不支持 select_related 和关联属性访问
|
||||
|
||||
@@ -155,6 +155,7 @@ from .app import (
|
||||
AppApplyView,
|
||||
AppApproveView,
|
||||
AppPendingView,
|
||||
AppConfigView,
|
||||
)
|
||||
from .system_ops import (
|
||||
GERPSystemStatusView,
|
||||
|
||||
@@ -87,6 +87,22 @@ def _ParseUUIDList(values):
|
||||
return result
|
||||
|
||||
|
||||
def _ToBytes(value):
|
||||
"""统一转 bytes(兼容 UUIDObj/bytes/str/uuid.UUID)"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
if hasattr(value, 'bytes'):
|
||||
return value.bytes
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return UUIDObj(value).bytes
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class AppMarketView(ELTAuthenticatedAPIView):
|
||||
"""应用市场——全部启用应用,附租户/公司安装/个人启用/申请状态。"""
|
||||
|
||||
@@ -116,23 +132,37 @@ class AppMyAppsView(ELTAuthenticatedAPIView):
|
||||
|
||||
|
||||
class AppManageCompaniesView(ELTAuthenticatedAPIView):
|
||||
"""管理员可安装应用的公司列表(按权限范围:super=全部 / tenant=租户内 / company=被授权公司)"""
|
||||
"""管理员可安装应用的公司列表(按权限范围:super=全部 / tenant=租户内 / company=被授权公司)
|
||||
|
||||
GET /API/App/ManageCompanies
|
||||
- 不传 AppUUID:返回全部可管理公司(安装弹窗用)
|
||||
- 传 AppUUID :返回可管理公司 + 该应用在各公司的安装状态(管理弹窗用)
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
|
||||
def get(self, request):
|
||||
companies, scope = AppManager.GetManageableCompanies(request)
|
||||
app_uuid_str = (request.query_params.get('AppUUID') or '').strip()
|
||||
install_map = {}
|
||||
if app_uuid_str:
|
||||
install_map = AppManager.ListAppCompanyInstalls(
|
||||
app_uuid_str, companies,
|
||||
)
|
||||
rows = []
|
||||
for c in companies:
|
||||
from gvsdsdk.models import Company as SdkCompany
|
||||
company = SdkCompany.objects.filter(CompanyUUID=c).first()
|
||||
if company is None:
|
||||
continue
|
||||
rows.append({
|
||||
row = {
|
||||
'CompanyUUID': UUIDObj(company.CompanyUUID).str,
|
||||
'CompanyName': company.CompanyName,
|
||||
'CompanyShort': company.CompanyShort,
|
||||
})
|
||||
}
|
||||
if app_uuid_str:
|
||||
row['Installed'] = 1 if install_map.get(c, 0) else 0
|
||||
rows.append(row)
|
||||
return Response({'code': 0, 'data': {'Scope': scope, 'Companies': rows}})
|
||||
|
||||
|
||||
@@ -185,10 +215,11 @@ class AppInstallView(ELTAuthenticatedAPIView):
|
||||
class AppUninstallView(ELTAuthenticatedAPIView):
|
||||
"""管理员:停用企业功能。
|
||||
|
||||
支持两种粒度:
|
||||
支持三种粒度:
|
||||
- 传 CompanyUUIDs → 停用指定公司安装(CompanyApp)
|
||||
- 不传 CompanyUUIDs → 停用全租户安装(TenantApp)+ 停用当前公司安装(CompanyApp)
|
||||
(前端停用当前公司功能时无需知道当前公司 UUID,后端按当前上下文双停)
|
||||
- 传 TenantOnly=1 → 仅停用全租户安装(TenantApp),公司级记录不动
|
||||
- 不传(或空)→ 停用全租户安装(TenantApp)+ 停用当前公司安装(CompanyApp)
|
||||
(兼容旧调用:前端停用当前公司功能时双停)
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
@@ -198,8 +229,19 @@ class AppUninstallView(ELTAuthenticatedAPIView):
|
||||
if not app_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
|
||||
|
||||
tenant_only = request.data.get('TenantOnly')
|
||||
if isinstance(tenant_only, str):
|
||||
tenant_only = tenant_only.lower() in ('1', 'true', 'yes')
|
||||
|
||||
company_uuids = _ParseUUIDList(request.data.get('CompanyUUIDs'))
|
||||
|
||||
# 仅停用全租户安装(公司级记录不动)
|
||||
if tenant_only:
|
||||
tenant_bytes = _GetTenantBytes(request)
|
||||
if tenant_bytes:
|
||||
AppManager.UninstallTenantApp(tenant_bytes, app_uuid)
|
||||
return Response({'code': 0, 'msg': '已停用全租户安装'})
|
||||
|
||||
# 停用指定公司安装
|
||||
if company_uuids:
|
||||
manageable, scope = AppManager.GetManageableCompanies(request)
|
||||
@@ -312,3 +354,38 @@ class AppPendingView(ELTAuthenticatedAPIView):
|
||||
return Response({'code': 0, 'data': {'list': [], 'total': 0}})
|
||||
rows = AppManager.ListPendingApplications(tenant_bytes)
|
||||
return Response({'code': 0, 'data': {'list': rows, 'total': len(rows)}})
|
||||
|
||||
|
||||
class AppConfigView(ELTAuthenticatedAPIView):
|
||||
"""应用配置(AppConfig 键值表)——有安装权限者(管理员)可读写
|
||||
|
||||
GET /API/App/Config?AppUUID=xxx — 返回该应用配置模板 + 当前租户已保存值
|
||||
POST /API/App/Config — Body: {AppUUID, Config: {Key: Value}}
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
|
||||
def get(self, request):
|
||||
app_uuid = (request.query_params.get('AppUUID') or '').strip()
|
||||
if not app_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
|
||||
tenant_bytes = _GetTenantBytes(request)
|
||||
rows = AppManager.GetAppConfig(tenant_bytes, app_uuid)
|
||||
return Response({'code': 0, 'data': {'Configs': rows, 'Total': len(rows)}})
|
||||
|
||||
def post(self, request):
|
||||
app_uuid = (request.data.get('AppUUID') or '').strip()
|
||||
config = request.data.get('Config') or {}
|
||||
if not app_uuid:
|
||||
return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
|
||||
if not isinstance(config, dict) or not config:
|
||||
return Response({'code': 1, 'msg': '缺少配置项 Config'}, status=400)
|
||||
tenant_bytes = _GetTenantBytes(request)
|
||||
if not tenant_bytes:
|
||||
return Response({'code': 1, 'msg': '无法确定当前租户'}, status=400)
|
||||
ok, err, updated = AppManager.SaveAppConfig(
|
||||
tenant_bytes, app_uuid, config, OperatorUUID=_GetUserBytes(request),
|
||||
)
|
||||
if not ok:
|
||||
return Response({'code': 1, 'msg': err}, status=400)
|
||||
return Response({'code': 0, 'msg': f'已保存 {len(updated)} 项配置', 'data': {'Updated': updated}})
|
||||
@@ -285,7 +285,7 @@ class GiteaAvatarView(ELTAuthenticatedAPIView):
|
||||
class GiteaAdminListView(ELTAuthenticatedAPIView):
|
||||
"""管理员:全部员工 Gitea 账号列表(含用户名)。"""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
permission_classes = [IsAuthenticated, IsAdminUser, _GITEA_ACCESS]
|
||||
|
||||
def get(self, request):
|
||||
from gvsds.apps.core.models import User as CoreUser
|
||||
@@ -318,7 +318,7 @@ class GiteaAdminListView(ELTAuthenticatedAPIView):
|
||||
class GiteaDisableView(ELTAuthenticatedAPIView):
|
||||
"""管理员:禁用账号(删除 Gitea 用户并置状态为 0)。"""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
permission_classes = [IsAuthenticated, IsAdminUser, _GITEA_ACCESS]
|
||||
|
||||
def post(self, request):
|
||||
acct_uuid = request.data.get('UserGiteaAccountUUID')
|
||||
|
||||
@@ -347,7 +347,7 @@ class MailMyMailboxView(ELTAuthenticatedAPIView):
|
||||
class MailAdminListView(ELTAuthenticatedAPIView):
|
||||
"""管理员:全部员工邮箱列表(含用户名)。"""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
permission_classes = [IsAuthenticated, IsAdminUser, _MAIL_ACCESS]
|
||||
|
||||
def get(self, request):
|
||||
from gvsds.apps.core.models import User as CoreUser
|
||||
@@ -380,7 +380,7 @@ class MailAdminListView(ELTAuthenticatedAPIView):
|
||||
class MailDisableView(ELTAuthenticatedAPIView):
|
||||
"""管理员:禁用邮箱(删除 Stalwart 账户并置状态为 0)。"""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
permission_classes = [IsAuthenticated, IsAdminUser, _MAIL_ACCESS]
|
||||
|
||||
def post(self, request):
|
||||
from gvsdsdk.model_utils import UUIDObj as _UUIDObj
|
||||
|
||||
@@ -159,41 +159,47 @@ class MenuListCompatView(ELTAuthenticatedAPIView):
|
||||
for uuid_str in no_perm_uuids:
|
||||
menu_map.pop(uuid_str, None)
|
||||
|
||||
# 应用/功能菜单过滤(仅普通用户):企业功能按租户安装状态、个人应用按个人启用状态
|
||||
# 管理员(admin:super/tenant)跳过过滤——管理视角需全览,便于在应用中心管理
|
||||
if not is_admin:
|
||||
try:
|
||||
from ..models import App
|
||||
from ..app_manager import AppManager
|
||||
# 当前租户 UUID:与 ResolveRoleContext 一致,取自当前角色 Role.TenantUUID
|
||||
current_tenant_uuid = None
|
||||
if role_uuid_bytes:
|
||||
from gvsdsdk.models import Role as _SdkRole
|
||||
_role_obj = _SdkRole.objects.filter(RoleUUID=role_uuid_bytes).first()
|
||||
if _role_obj is not None:
|
||||
current_tenant_uuid = _role_obj.TenantUUID
|
||||
app_roots = [
|
||||
a for a in App.objects.filter(AppStatus=1, MenuRootUUID__isnull=False)
|
||||
]
|
||||
for _app in app_roots:
|
||||
if AppManager.CheckAccess(
|
||||
current_tenant_uuid, user.UserUUID, _app.AppCode,
|
||||
CompanyUUID=current_company,
|
||||
) is None:
|
||||
root_str = UUIDObj(_app.MenuRootUUID).str
|
||||
if root_str not in menu_map:
|
||||
continue
|
||||
_invalid = set()
|
||||
def _CollectAppSub(uuid_str, acc):
|
||||
acc.add(uuid_str)
|
||||
for uid, item in menu_map.items():
|
||||
if item['ParentUUID'] == uuid_str and uid not in acc:
|
||||
_CollectAppSub(uid, acc)
|
||||
_CollectAppSub(root_str, _invalid)
|
||||
for _u in _invalid:
|
||||
menu_map.pop(_u, None)
|
||||
except Exception:
|
||||
logger.exception('MenuListCompatView 应用菜单过滤失败')
|
||||
# 应用/功能菜单过滤(严格收紧,所有用户含管理员):
|
||||
# 未安装应用(租户/公司/个人均未启用)的 Manifest 声明的全部菜单一律隐藏,
|
||||
# 含散挂在其他父节点下的页面(如"邮箱管理"挂公司下)。
|
||||
# 管理员仅应用中心管理(安装/停用)豁免,业务菜单同样按安装状态隐藏。
|
||||
try:
|
||||
from ..models import App
|
||||
from ..app_manager import AppManager
|
||||
from ..app_manifests import GetAppManifest
|
||||
# 当前租户 UUID:与 ResolveRoleContext 一致,取自当前角色 Role.TenantUUID
|
||||
current_tenant_uuid = None
|
||||
if role_uuid_bytes:
|
||||
from gvsdsdk.models import Role as _SdkRole
|
||||
_role_obj = _SdkRole.objects.filter(RoleUUID=role_uuid_bytes).first()
|
||||
if _role_obj is not None:
|
||||
current_tenant_uuid = _role_obj.TenantUUID
|
||||
|
||||
def _CollectAppSub(uuid_str, acc):
|
||||
acc.add(uuid_str)
|
||||
for uid, item in menu_map.items():
|
||||
if item['ParentUUID'] == uuid_str and uid not in acc:
|
||||
_CollectAppSub(uid, acc)
|
||||
|
||||
for _app in App.objects.filter(AppStatus=1):
|
||||
if AppManager.CheckAccess(
|
||||
current_tenant_uuid, user.UserUUID, _app.AppCode,
|
||||
CompanyUUID=current_company,
|
||||
) is not None:
|
||||
continue
|
||||
manifest = GetAppManifest(_app.AppCode)
|
||||
if manifest is None:
|
||||
continue
|
||||
# 收集该应用 Manifest 声明的全部菜单(MenuName 匹配 Id 字段)
|
||||
app_menu_names = {s.MenuName for s in manifest.Menus}
|
||||
_invalid = set()
|
||||
for _uid, _item in menu_map.items():
|
||||
if _item.get('Id') in app_menu_names:
|
||||
_CollectAppSub(_uid, _invalid)
|
||||
for _u in _invalid:
|
||||
menu_map.pop(_u, None)
|
||||
except Exception:
|
||||
logger.exception('MenuListCompatView 应用菜单过滤失败')
|
||||
|
||||
# 构建树
|
||||
root_menus = []
|
||||
|
||||
@@ -157,6 +157,7 @@ urlpatterns = [
|
||||
path('API/App/Apply', gerp_views.AppApplyView.as_view(), name='app_apply'),
|
||||
path('API/App/Approve', gerp_views.AppApproveView.as_view(), name='app_approve'),
|
||||
path('API/App/Pending', gerp_views.AppPendingView.as_view(), name='app_pending'),
|
||||
path('API/App/Config', gerp_views.AppConfigView.as_view(), name='app_config'),
|
||||
|
||||
# 角色权限分配(权限树勾选)
|
||||
path('API/Role/PermissionsTree', gerp_views.RolePermissionsTreeView.as_view(), name='role_permissions_tree'),
|
||||
|
||||
Reference in new issue
Block a user