diff --git a/TempLate/GERP/index.html b/TempLate/GERP/index.html index 30021d9c..a585c831 100644 --- a/TempLate/GERP/index.html +++ b/TempLate/GERP/index.html @@ -5,7 +5,7 @@ GERP 管理系统 - + @@ -14,7 +14,7 @@ - +
diff --git a/gvsds/apps/gerp/app_manager.py b/gvsds/apps/gerp/app_manager.py index 0bcee476..f088c843 100644 --- a/gvsds/apps/gerp/app_manager.py +++ b/gvsds/apps/gerp/app_manager.py @@ -426,8 +426,15 @@ class AppManager: ] @staticmethod - def GetAppConfig(TenantUUID, AppUUID): - """读取指定租户下应用配置(模板 + 已保存值) + def GetAppConfig(TenantUUID, AppUUID, CompanyUUID=None): + """读取指定租户(+可选公司)下应用配置(模板 + 已保存值) + + 读取回退链:公司级配置 → 租户默认配置 → 模板默认值。 + + Args: + TenantUUID: 租户 UUID + AppUUID: 应用 UUID + CompanyUUID: 公司 UUID(可选);传公司时优先读公司配置,缺失键回退租户默认 Returns: list[dict]:{Key, Label, Value, Default, Secret, Placeholder, Updated} @@ -441,14 +448,21 @@ class AppManager: return [] tenant_bytes = _ToBytes(TenantUUID) app_bytes = _ToBytes(AppUUID) - saved = {} + company_bytes = _ToBytes(CompanyUUID) + saved_company = {} + if company_bytes: + for cfg in AppConfig.objects.filter( + TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID=company_bytes): + saved_company[cfg.ConfigKey] = cfg + saved_tenant = {} if tenant_bytes: - for cfg in AppConfig.objects.filter(TenantUUID=tenant_bytes, AppUUID=app_bytes): - saved[cfg.ConfigKey] = cfg + for cfg in AppConfig.objects.filter( + TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID__isnull=True): + saved_tenant[cfg.ConfigKey] = cfg result = [] for t in template: key = t['Key'] - cfg = saved.get(key) + cfg = saved_company.get(key) or saved_tenant.get(key) result.append({ 'Key': key, 'Label': t['Label'], @@ -461,13 +475,14 @@ class AppManager: return result @staticmethod - def SaveAppConfig(TenantUUID, AppUUID, Updates, OperatorUUID=None): - """保存租户应用配置(按模板键 upsert) + def SaveAppConfig(TenantUUID, AppUUID, Updates, CompanyUUID=None, OperatorUUID=None): + """保存应用配置(按模板键 upsert,支持租户默认/公司级) Args: TenantUUID: 租户 UUID AppUUID: 应用 UUID Updates: dict {Key: Value} + CompanyUUID: 公司 UUID(None=租户级默认配置) OperatorUUID: 更新人 UUID(可选) Returns: @@ -483,6 +498,7 @@ class AppManager: return False, '该应用未声明配置项', [] allowed = {t['Key'] for t in template} tenant_bytes = _ToBytes(TenantUUID) + company_bytes = _ToBytes(CompanyUUID) app_bytes = _ToBytes(AppUUID) operator_bytes = _ToBytes(OperatorUUID) updated = [] @@ -493,6 +509,7 @@ class AppManager: value = str(value) cfg, created = AppConfig.objects.get_or_create( TenantUUID=tenant_bytes, + CompanyUUID=company_bytes, AppUUID=app_bytes, ConfigKey=key, defaults={ @@ -510,6 +527,61 @@ class AppManager: updated.append(key) return True, '', updated + @staticmethod + def CopyAppConfig(TenantUUID, AppUUID, SourceCompanyUUID, TargetCompanyUUIDs, OperatorUUID=None): + """把源配置(租户默认或指定公司)批量复制到目标公司(覆盖目标同名键) + + Args: + TenantUUID: 租户 UUID + AppUUID: 应用 UUID + SourceCompanyUUID: 源公司 UUID(None=以租户默认配置为源) + TargetCompanyUUIDs: 目标公司 UUID 列表(str/bytes/UUIDObj 均可) + OperatorUUID: 复制人 UUID(可选) + + Returns: + (ok, err, copied_count) + """ + import datetime as _dt + from .models import AppConfig + tenant_bytes = _ToBytes(TenantUUID) + app_bytes = _ToBytes(AppUUID) + source_bytes = _ToBytes(SourceCompanyUUID) + if source_bytes: + source_rows = list(AppConfig.objects.filter( + TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID=source_bytes)) + else: + source_rows = list(AppConfig.objects.filter( + TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID__isnull=True)) + if not source_rows: + return False, '源配置不存在(无已保存的配置项)', 0 + targets = [_ToBytes(c) for c in (TargetCompanyUUIDs or []) if _ToBytes(c)] + if not targets: + return False, '未指定目标公司', 0 + operator_bytes = _ToBytes(OperatorUUID) + now = _dt.datetime.utcnow() + copied = 0 + for company_bytes in targets: + for row in source_rows: + cfg, created = AppConfig.objects.get_or_create( + TenantUUID=tenant_bytes, + CompanyUUID=company_bytes, + AppUUID=app_bytes, + ConfigKey=row.ConfigKey, + defaults={ + 'AppConfigUUID': uuid.uuid4().bytes, + 'ConfigValue': row.ConfigValue, + 'UpdateBy': operator_bytes, + 'UpdateTime': now, + }, + ) + if not created: + cfg.ConfigValue = row.ConfigValue + cfg.UpdateBy = operator_bytes + cfg.UpdateTime = now + cfg.save(update_fields=['ConfigValue', 'UpdateBy', 'UpdateTime']) + copied += 1 + return True, '', copied + # ==================== 个人启用/申请/审批 ==================== @staticmethod diff --git a/gvsds/apps/gerp/migrations/0027_appconfig_company.py b/gvsds/apps/gerp/migrations/0027_appconfig_company.py new file mode 100644 index 00000000..02317417 --- /dev/null +++ b/gvsds/apps/gerp/migrations/0027_appconfig_company.py @@ -0,0 +1,28 @@ +"""Add CompanyUUID to AppConfig (per-company config, NULL = tenant default) + +应用配置从租户级扩展为公司级: +- CompanyUUID 为空(NULL)→ 租户级默认配置(全租户共享) +- CompanyUUID 有值 → 公司级配置(覆盖租户默认) +唯一约束扩为 (tenant, company, app, key),MySQL BINARY(16) NULL 允许多条租户默认。 +""" +from django.db import migrations +import gvsds.apps.core.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0026_appconfig'), + ] + + operations = [ + migrations.AddField( + model_name='appconfig', + name='company_uuid', + field=gvsds.apps.core.models.UUIDField(blank=True, db_column='CompanyUUID', null=True, verbose_name='公司UUID(空=租户级默认配置)'), + ), + migrations.AlterUniqueTogether( + name='appconfig', + unique_together={('tenant_uuid', 'company_uuid', 'app_uuid', 'config_key')}, + ), + ] diff --git a/gvsds/apps/gerp/models.py b/gvsds/apps/gerp/models.py index 559a1aa1..fa0ad85b 100644 --- a/gvsds/apps/gerp/models.py +++ b/gvsds/apps/gerp/models.py @@ -829,7 +829,7 @@ class TenantApp(QModel): class Meta: db_table = 'TenantApp' app_label = 'gerp' - unique_together = (('tenant_uuid', 'app_uuid'),) + unique_together = (('TenantUUID', 'AppUUID'),) verbose_name = '企业应用安装' verbose_name_plural = '企业应用安装' @@ -855,7 +855,7 @@ class CompanyApp(QModel): class Meta: db_table = 'CompanyApp' app_label = 'gerp' - unique_together = (('company_uuid', 'app_uuid'),) + unique_together = (('CompanyUUID', 'AppUUID'),) verbose_name = '企业应用安装(公司维度)' verbose_name_plural = '企业应用安装(公司维度)' @@ -880,16 +880,21 @@ class UserApp(QModel): class Meta: db_table = 'UserApp' app_label = 'gerp' - unique_together = (('user_uuid', 'app_uuid'),) + unique_together = (('UserUUID', 'AppUUID'),) verbose_name = '个人应用' verbose_name_plural = '个人应用' class AppConfig(QModel): - """应用配置键值表——按租户隔离的应用运行参数 + """应用配置键值表——按租户+公司隔离的应用运行参数 - 每个应用可声明若干配置键(ConfigKey),租户管理员/公司管理员 - (有安装权限者)可在"配置"入口编辑本租户下的配置值。 + 每个应用可声明若干配置键(ConfigKey),管理员(有安装权限者)可在 + "配置"入口编辑配置值。 + + 配置粒度: + - CompanyUUID 为空(NULL)→ 租户级默认配置(全租户共享) + - CompanyUUID 有值 → 公司级配置(覆盖租户默认) + 读取回退链:公司级 → 租户默认 → 模板默认值。 键值约定(由应用自行定义与读取,如 [stalwart] 域名映射): ConfigKey 配置键名(如 SMTP_HOST / DOMAIN) @@ -898,6 +903,7 @@ class AppConfig(QModel): AppConfigUUID = UUIDField(primary_key=True, verbose_name='应用配置UUID') TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID') + CompanyUUID = UUIDField(null=True, blank=True, db_column='CompanyUUID', 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='配置值') @@ -908,7 +914,7 @@ class AppConfig(QModel): class Meta: db_table = 'AppConfig' app_label = 'gerp' - unique_together = (('tenant_uuid', 'app_uuid', 'config_key'),) + unique_together = (('TenantUUID', 'CompanyUUID', 'AppUUID', 'ConfigKey'),) verbose_name = '应用配置' verbose_name_plural = '应用配置' diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py index a8230a25..0276a7ee 100644 --- a/gvsds/apps/gerp/views/__init__.py +++ b/gvsds/apps/gerp/views/__init__.py @@ -156,6 +156,7 @@ from .app import ( AppApproveView, AppPendingView, AppConfigView, + AppConfigCopyView, ) from .system_ops import ( GERPSystemStatusView, diff --git a/gvsds/apps/gerp/views/app.py b/gvsds/apps/gerp/views/app.py index 91571b14..18128434 100644 --- a/gvsds/apps/gerp/views/app.py +++ b/gvsds/apps/gerp/views/app.py @@ -104,6 +104,34 @@ def _ToBytes(value): return None +def _ResolveConfigCompany(request, company_uuid_str): + """解析应用配置目标范围并校验权限(租户默认/公司级) + + 规则: + - 不传公司 UUID:租户管理员(scope all/tenant)→ 租户默认;公司管理员 → 默认本公司 + - 传公司 UUID :租户管理员可任意;公司管理员仅授权公司 + + Returns: + (company_bytes_or_None, err);err 非空表示拒绝 + """ + manageable, scope = AppManager.GetManageableCompanies(request) + if not company_uuid_str: + if scope in ('all', 'tenant'): + return None, None + if manageable: + return manageable[0], None + return None, '您没有可管理的公司' + target = _ParseUUIDList([company_uuid_str]) + if not target: + return None, '无效的公司 UUID' + if scope in ('all', 'tenant'): + return target[0], None + manageable_set = {c.hex() for c in manageable} + if target[0].hex() in manageable_set: + return target[0], None + return None, '无权管理该公司的配置' + + class AppMarketView(ELTAuthenticatedAPIView): """应用市场——全部启用应用,附租户/公司安装/个人启用/申请状态。""" @@ -363,10 +391,15 @@ class AppPendingView(ELTAuthenticatedAPIView): class AppConfigView(ELTAuthenticatedAPIView): - """应用配置(AppConfig 键值表)——有安装权限者(管理员)可读写 + """应用配置(租户默认/公司级) - GET /API/App/Config?AppUUID=xxx — 返回该应用配置模板 + 当前租户已保存值 - POST /API/App/Config — Body: {AppUUID, Config: {Key: Value}} + GET /API/App/Config?AppUUID=xxx[&CompanyUUID=yyy] + - 返回该应用配置模板 + 当前范围已保存值(公司缺失键回退租户默认) + - 不传 CompanyUUID = 租户默认配置(全租户共享) + - 租户管理员可传任意公司;公司管理员只能读本公司(不传时默认本公司) + POST /API/App/Config — Body: {AppUUID, Config: {Key: Value}, CompanyUUID?} + - 保存到指定范围(CompanyUUID 空=租户默认) + - 租户管理员可写任意范围;公司管理员只能写本公司 """ permission_classes = [IsAuthenticated, IsAdminUser] @@ -375,9 +408,18 @@ class AppConfigView(ELTAuthenticatedAPIView): app_uuid = (request.query_params.get('AppUUID') or '').strip() if not app_uuid: return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400) + company_str = (request.query_params.get('CompanyUUID') or '').strip() + company_bytes, err = _ResolveConfigCompany(request, company_str) + if err: + return Response({'code': 403, 'msg': err}, status=403) tenant_bytes = _GetTenantBytes(request) - rows = AppManager.GetAppConfig(tenant_bytes, app_uuid) - return Response({'code': 0, 'data': {'Configs': rows, 'Total': len(rows)}}) + rows = AppManager.GetAppConfig(tenant_bytes, app_uuid, company_bytes) + return Response({'code': 0, 'data': { + 'Configs': rows, + 'Total': len(rows), + 'Scope': 'company' if company_bytes else 'tenant', + 'CompanyUUID': UUIDObj(company_bytes).str if company_bytes else '', + }}) def post(self, request): app_uuid = (request.data.get('AppUUID') or '').strip() @@ -386,12 +428,66 @@ class AppConfigView(ELTAuthenticatedAPIView): return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400) if not isinstance(config, dict) or not config: return Response({'code': 1, 'msg': '缺少配置项 Config'}, status=400) + company_str = (request.data.get('CompanyUUID') or '').strip() + company_bytes, err = _ResolveConfigCompany(request, company_str) + if err: + return Response({'code': 403, 'msg': err}, status=403) 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), + tenant_bytes, app_uuid, config, + CompanyUUID=company_bytes, + 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}}) + + +class AppConfigCopyView(ELTAuthenticatedAPIView): + """配置批量复制——把源配置(租户默认/某公司)复制到目标公司(覆盖同名键) + + POST /API/App/Config/Copy — Body: {AppUUID, SourceCompanyUUID?, TargetCompanyUUIDs[]} + - SourceCompanyUUID 不传 = 以租户默认配置为源 + - 权限:租户管理员任意;公司管理员仅可复制到本公司且源必须是本公司/租户默认 + """ + + permission_classes = [IsAuthenticated, IsAdminUser] + + def post(self, request): + app_uuid = (request.data.get('AppUUID') or '').strip() + if not app_uuid: + return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400) + targets = _ParseUUIDList(request.data.get('TargetCompanyUUIDs')) + if not targets: + return Response({'code': 1, 'msg': '未指定目标公司 TargetCompanyUUIDs'}, status=400) + tenant_bytes = _GetTenantBytes(request) + if not tenant_bytes: + return Response({'code': 1, 'msg': '无法确定当前租户'}, status=400) + + # 权限校验:租户管理员(scope all/tenant)任意;公司管理员(scope company)仅本公司 + manageable, scope = AppManager.GetManageableCompanies(request) + if scope not in ('all', 'tenant'): + manageable_set = {c.hex() for c in manageable} + invalid = [t for t in targets if t.hex() not in manageable_set] + if invalid: + return Response({'code': 403, 'msg': '无权复制到以下公司(不在您的管理范围)'}, status=403) + + source_bytes = None + source_str = (request.data.get('SourceCompanyUUID') or '').strip() + if source_str: + src = _ParseUUIDList([source_str]) + if not src: + return Response({'code': 1, 'msg': '无效的源公司 UUID'}, status=400) + source_bytes = src[0] + if scope not in ('all', 'tenant') and source_bytes.hex() not in manageable_set: + return Response({'code': 403, 'msg': '无权使用该公司的配置作为源'}, status=403) + + ok, err, copied = AppManager.CopyAppConfig( + tenant_bytes, app_uuid, source_bytes, targets, + OperatorUUID=_GetUserBytes(request), + ) + if not ok: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': f'已复制 {copied} 项配置到 {len(targets)} 家公司', 'data': {'Copied': copied}}) diff --git a/gvsds/urls.py b/gvsds/urls.py index 31229a95..a3228544 100644 --- a/gvsds/urls.py +++ b/gvsds/urls.py @@ -158,6 +158,7 @@ urlpatterns = [ 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/App/Config/Copy', gerp_views.AppConfigCopyView.as_view(), name='app_config_copy'), # 角色权限分配(权限树勾选) path('API/Role/PermissionsTree', gerp_views.RolePermissionsTreeView.as_view(), name='role_permissions_tree'),