From 7bc96892fdd214f5247d4562ee333bfaea57c60c Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Thu, 13 Aug 2026 04:06:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(gerp/app):=20=E5=8A=9F=E8=83=BD/=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E4=BD=93=E7=B3=BB=E2=80=94=E2=80=94App/TenantApp/User?= =?UTF-8?q?App=20=E4=B8=89=E8=A1=A8=20+=20=E5=BA=94=E7=94=A8=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E6=9C=8D=E5=8A=A1=E5=B1=82/API=20+=20=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=8C=89=E5=AE=89=E8=A3=85=E7=8A=B6=E6=80=81=E8=BF=87?= =?UTF-8?q?=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gvsds/apps/gerp/app_manager.py | 343 ++++++++++++++++++ .../migrations/0023_app_tenantapp_userapp.py | 82 +++++ gvsds/apps/gerp/models.py | 84 +++++ gvsds/apps/gerp/views/__init__.py | 11 + gvsds/apps/gerp/views/app.py | 206 +++++++++++ gvsds/apps/gerp/views/menu.py | 33 ++ gvsds/urls.py | 11 + 7 files changed, 770 insertions(+) create mode 100644 gvsds/apps/gerp/app_manager.py create mode 100644 gvsds/apps/gerp/migrations/0023_app_tenantapp_userapp.py create mode 100644 gvsds/apps/gerp/views/app.py diff --git a/gvsds/apps/gerp/app_manager.py b/gvsds/apps/gerp/app_manager.py new file mode 100644 index 00000000..09a77ae5 --- /dev/null +++ b/gvsds/apps/gerp/app_manager.py @@ -0,0 +1,343 @@ +"""模块:应用/功能服务层(AppManager) + +功能/应用体系——企业功能(租户安装)与个人应用(个人启用)的统一实体: +- 企业视角叫"功能":租户管理员在应用中心安装后,该租户全员可见可用 +- 个人视角叫"应用":个人启用后仅本人可见可用 +- 企业授权(AuthRequired=1):个人使用须经租户管理员审批授权 + +核心概念: + App.AppType enterprise=企业功能 / personal=个人应用 / both=混合 + TenantApp 租户安装记录(InstallStatus 1启用/0停用;AuthStatus 企业授权 0/1/2) + UserApp 个人记录(UseStatus 1启用/0停用;ApplyStatus 0未申请/1申请中/2已授权/3已拒绝) + +菜单注入(配合 MenuList 扩展): + - enterprise/both:租户 TenantApp.InstallStatus=1 → 注入 App.MenuRootUUID 菜单根 + - personal/both :用户 UserApp.UseStatus=1 → 注入菜单根 +""" +import datetime +import uuid + +from gvsdsdk.model_utils import UUIDObj + +from .models import App, TenantApp, UserApp + +# 投放形态 +TYPE_ENTERPRISE = 'enterprise' +TYPE_PERSONAL = 'personal' +TYPE_BOTH = 'both' + +# 企业授权状态 +AUTH_NONE = 0 +AUTH_OK = 1 +AUTH_DENIED = 2 + +# 个人申请状态 +APPLY_NONE = 0 +APPLY_PENDING = 1 +APPLY_APPROVED = 2 +APPLY_DENIED = 3 + + +def _ToBytes(value): + """统一转 bytes(兼容 bytes/UUIDObj/uuid.UUID/str)""" + 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 value + return value + + +def _AppFlat(app, tenant_installed=None, user_record=None): + """App 实例 → 市场/我的应用视图 dict""" + return { + 'AppUUID': UUIDObj(app.AppUUID).str, + 'AppCode': app.AppCode, + 'AppName': app.AppName, + 'AppIcon': app.AppIcon or '', + 'AppDesc': app.AppDesc or '', + 'AppType': app.AppType, + 'AuthRequired': app.AuthRequired, + 'MenuRootUUID': UUIDObj(app.MenuRootUUID).str if app.MenuRootUUID else '', + 'AppSort': app.AppSort or 0, + # 状态附加(市场页/我的应用展示用) + 'TenantInstalled': 1 if tenant_installed and tenant_installed.InstallStatus == 1 else 0, + 'TenantAuth': tenant_installed.AuthStatus if tenant_installed else 0, + 'UserEnabled': 1 if user_record and user_record.UseStatus == 1 else 0, + 'ApplyStatus': user_record.ApplyStatus if user_record else 0, + } + + +class AppManager: + """应用/功能统一服务层""" + + # ==================== 查询 ==================== + + @staticmethod + def ListEnabledApps(): + """全部启用中的应用(AppStatus=1)""" + return list(App.objects.filter(AppStatus=1).order_by('AppSort', 'CreateTime')) + + @staticmethod + def GetAppByCode(AppCode): + """按应用编码查应用(禁用也返回,供访问校验)""" + return App.objects.filter(AppCode=AppCode).first() + + @staticmethod + def GetApp(AppUUID): + return App.objects.filter(AppUUID=_ToBytes(AppUUID)).first() + + @staticmethod + def ListMarket(TenantUUID=None, UserUUID=None): + """应用市场列表——每个应用附带租户安装状态与个人启用/申请状态 + + Args: + TenantUUID: 当前租户 UUID(bytes/str/UUIDObj),用于标注"企业功能已安装" + UserUUID: 当前用户 UUID,用于标注"个人已启用/申请中" + + Returns: + list[dict] + """ + apps = AppManager.ListEnabledApps() + tenant_bytes = _ToBytes(TenantUUID) + user_bytes = _ToBytes(UserUUID) + tenant_map = {} + if tenant_bytes: + for ta in TenantApp.objects.filter(TenantUUID=tenant_bytes): + tenant_map[UUIDObj(ta.AppUUID).str] = ta + user_map = {} + if user_bytes: + for ua in UserApp.objects.filter(UserUUID=user_bytes): + user_map[UUIDObj(ua.AppUUID).str] = ua + result = [] + for app in apps: + key = UUIDObj(app.AppUUID).str + result.append(_AppFlat( + app, + tenant_installed=tenant_map.get(key), + user_record=user_map.get(key), + )) + return result + + @staticmethod + def ListMyApps(TenantUUID, UserUUID): + """我的应用——当前用户可用应用(租户已安装的企业功能 + 个人已启用的应用) + + 需企业授权(AuthRequired=1)但个人未获授权的应用不出现在可用列表, + 仅在企业功能已安装时对全员生效(企业授权在企业层面开通)。 + """ + apps = AppManager.ListEnabledApps() + tenant_bytes = _ToBytes(TenantUUID) + user_bytes = _ToBytes(UserUUID) + tenant_map = {} + if tenant_bytes: + for ta in TenantApp.objects.filter(TenantUUID=tenant_bytes, InstallStatus=1): + tenant_map[UUIDObj(ta.AppUUID).str] = ta + user_map = {} + if user_bytes: + for ua in UserApp.objects.filter(UserUUID=user_bytes, UseStatus=1): + user_map[UUIDObj(ua.AppUUID).str] = ua + result = [] + for app in apps: + key = UUIDObj(app.AppUUID).str + tenant_installed = tenant_map.get(key) + user_record = user_map.get(key) + if app.AppType == TYPE_ENTERPRISE: + # 企业功能:租户已安装即可用(企业授权在企业层面开通) + if not tenant_installed: + continue + elif app.AppType == TYPE_PERSONAL: + # 个人应用:个人已启用可用 + if not user_record: + continue + else: # both + # 混合:企业已安装 或 个人已启用 + if not tenant_installed and not user_record: + continue + result.append(_AppFlat(app, tenant_installed=tenant_installed, user_record=user_record)) + return result + + # ==================== 企业安装/停用 ==================== + + @staticmethod + def InstallTenantApp(TenantUUID, AppUUID): + """租户安装企业功能(enterprise/both)。 + + Returns: + (ok, err, app_flat) + """ + tenant_bytes = _ToBytes(TenantUUID) + app = AppManager.GetApp(AppUUID) + if app is None or app.AppStatus != 1: + return False, '应用不存在或已禁用', None + if app.AppType == TYPE_PERSONAL: + return False, '该应用为个人应用,不支持企业安装', None + ta, created = TenantApp.objects.get_or_create( + TenantUUID=tenant_bytes, + AppUUID=app.AppUUID, + defaults={ + 'TenantAppUUID': uuid.uuid4().bytes, + 'InstallStatus': 1, + 'AuthStatus': AUTH_OK if app.AuthRequired == 1 else AUTH_NONE, + 'InstallTime': datetime.datetime.utcnow(), + }, + ) + if not created: + ta.InstallStatus = 1 + if app.AuthRequired == 1 and ta.AuthStatus != AUTH_OK: + ta.AuthStatus = AUTH_OK + ta.save(update_fields=['InstallStatus', 'AuthStatus']) + return True, '', _AppFlat(app, tenant_installed=ta) + + @staticmethod + def UninstallTenantApp(TenantUUID, AppUUID): + """租户停用企业功能""" + tenant_bytes = _ToBytes(TenantUUID) + ta = TenantApp.objects.filter(TenantUUID=tenant_bytes, AppUUID=_ToBytes(AppUUID)).first() + if ta is None: + return False, '该功能未安装' + ta.InstallStatus = 0 + ta.save(update_fields=['InstallStatus']) + return True, '' + + # ==================== 个人启用/申请/审批 ==================== + + @staticmethod + def EnableUserApp(UserUUID, AppUUID): + """个人启用应用。 + + 企业功能(enterprise)不参与个人启用(由租户安装控制); + AuthRequired=1 的应用必须先申请授权(ApplyStatus=2 才可启用)。 + + Returns: + (ok, err, app_flat) + """ + user_bytes = _ToBytes(UserUUID) + app = AppManager.GetApp(AppUUID) + if app is None or app.AppStatus != 1: + return False, '应用不存在或已禁用', None + if app.AppType == TYPE_ENTERPRISE: + return False, '该应用为企业功能,由租户管理员统一开通', None + ua, created = UserApp.objects.get_or_create( + UserUUID=user_bytes, + AppUUID=app.AppUUID, + defaults={'UserAppUUID': uuid.uuid4().bytes}, + ) + if app.AuthRequired == 1: + if ua.ApplyStatus != APPLY_APPROVED: + return False, '该应用需企业授权,请先申请(待租户管理员审批)', None + ua.UseStatus = 1 + ua.save(update_fields=['UseStatus']) + return True, '', _AppFlat(app, user_record=ua) + ua.UseStatus = 1 + ua.save(update_fields=['UseStatus']) + return True, '', _AppFlat(app, user_record=ua) + + @staticmethod + def DisableUserApp(UserUUID, AppUUID): + """个人停用应用""" + user_bytes = _ToBytes(UserUUID) + ua = UserApp.objects.filter(UserUUID=user_bytes, AppUUID=_ToBytes(AppUUID)).first() + if ua is None: + return False, '未启用该应用' + ua.UseStatus = 0 + ua.save(update_fields=['UseStatus']) + return True, '' + + @staticmethod + def ApplyUserApp(UserUUID, AppUUID): + """个人申请企业授权(AuthRequired=1 的应用)""" + user_bytes = _ToBytes(UserUUID) + app = AppManager.GetApp(AppUUID) + if app is None or app.AppStatus != 1: + return False, '应用不存在或已禁用' + if app.AuthRequired != 1: + return False, '该应用无需企业授权,可直接启用' + ua, created = UserApp.objects.get_or_create( + UserUUID=user_bytes, + AppUUID=app.AppUUID, + defaults={'UserAppUUID': uuid.uuid4().bytes}, + ) + if ua.ApplyStatus == APPLY_APPROVED: + return False, '该应用已授权,可直接启用' + ua.ApplyStatus = APPLY_PENDING + ua.ApplyTime = datetime.datetime.utcnow() + ua.save(update_fields=['ApplyStatus', 'ApplyTime']) + return True, '' + + @staticmethod + def ApproveUserApp(OperatorUUID, UserAppUUID, Approved=True): + """租户管理员审批个人授权申请。 + + Returns: + (ok, err, flat) + """ + ua = UserApp.objects.filter(UserAppUUID=_ToBytes(UserAppUUID)).first() + if ua is None: + return False, '申请记录不存在' + if ua.ApplyStatus != APPLY_PENDING: + return False, '该申请已处理(非待审批状态)' + ua.ApplyStatus = APPLY_APPROVED if Approved else APPLY_DENIED + ua.UseStatus = 1 if Approved else 0 + ua.ApproveBy = _ToBytes(OperatorUUID) + ua.ApproveTime = datetime.datetime.utcnow() + ua.save(update_fields=['ApplyStatus', 'UseStatus', 'ApproveBy', 'ApproveTime']) + app = AppManager.GetApp(ua.AppUUID) + return True, '', _AppFlat(app, user_record=ua) + + @staticmethod + def ListPendingApplications(TenantUUID): + """租户内待审批的个人授权申请(供租户管理员审批台)""" + tenant_bytes = _ToBytes(TenantUUID) + rows = [] + for ua in UserApp.objects.filter(ApplyStatus=APPLY_PENDING).order_by('-ApplyTime'): + app = AppManager.GetApp(ua.AppUUID) + if app is None: + continue + rows.append({ + 'UserAppUUID': UUIDObj(ua.UserAppUUID).str, + 'UserUUID': UUIDObj(ua.UserUUID).str, + 'AppUUID': UUIDObj(ua.AppUUID).str, + 'AppName': app.AppName, + 'AppCode': app.AppCode, + 'AppIcon': app.AppIcon or '', + 'ApplyTime': ua.ApplyTime.isoformat() if ua.ApplyTime else None, + }) + return rows + + # ==================== 访问校验(菜单注入用) ==================== + + @staticmethod + def CheckAccess(TenantUUID, UserUUID, AppCode): + """校验用户当前是否可用某应用(供菜单注入/接口鉴权)。 + + Returns: + App 实例或 None + """ + app = AppManager.GetAppByCode(AppCode) + if app is None or app.AppStatus != 1: + return None + tenant_bytes = _ToBytes(TenantUUID) + user_bytes = _ToBytes(UserUUID) + if app.AppType == TYPE_ENTERPRISE: + ta = TenantApp.objects.filter( + TenantUUID=tenant_bytes, AppUUID=app.AppUUID, InstallStatus=1, + ).first() + return app if ta else None + if app.AppType == TYPE_PERSONAL: + ua = UserApp.objects.filter( + UserUUID=user_bytes, AppUUID=app.AppUUID, UseStatus=1, + ).first() + return app if ua else None + # both + ta = TenantApp.objects.filter(TenantUUID=tenant_bytes, AppUUID=app.AppUUID, InstallStatus=1).first() + if ta: + return app + ua = UserApp.objects.filter(UserUUID=user_bytes, AppUUID=app.AppUUID, UseStatus=1).first() + return app if ua else None diff --git a/gvsds/apps/gerp/migrations/0023_app_tenantapp_userapp.py b/gvsds/apps/gerp/migrations/0023_app_tenantapp_userapp.py new file mode 100644 index 00000000..c933d1c9 --- /dev/null +++ b/gvsds/apps/gerp/migrations/0023_app_tenantapp_userapp.py @@ -0,0 +1,82 @@ +"""Add App, TenantApp, UserApp + +功能/应用体系——企业功能(租户安装)与个人应用(个人启用)的统一实体: +- App:应用注册表(AppType: enterprise/personal/both,AuthRequired 企业授权标记) +- TenantApp:企业安装/授权(InstallStatus/AuthStatus) +- UserApp:个人启用/申请(UseStatus/ApplyStatus 审批状态机) +""" +import datetime +from django.db import migrations, models +import gvsds.apps.core.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0022_user_gitea_email'), + ] + + operations = [ + migrations.CreateModel( + name='App', + fields=[ + ('app_uuid', gvsds.apps.core.models.UUIDField(db_column='AppUUID', primary_key=True, serialize=False)), + ('app_code', models.CharField(db_column='AppCode', max_length=64, unique=True)), + ('app_name', models.CharField(db_column='AppName', max_length=128)), + ('app_icon', models.CharField(db_column='AppIcon', default='', max_length=256)), + ('app_desc', models.TextField(db_column='AppDesc', default='')), + ('app_type', models.CharField(db_column='AppType', default='both', max_length=16)), + ('auth_required', models.SmallIntegerField(db_column='AuthRequired', default=0)), + ('menu_root_uuid', gvsds.apps.core.models.UUIDField(blank=True, db_column='MenuRootUUID', null=True)), + ('default_perms', models.TextField(db_column='DefaultPerms', default='')), + ('app_sort', models.IntegerField(db_column='AppSort', default=0)), + ('app_status', models.SmallIntegerField(db_column='AppStatus', default=1)), + ('create_time', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow)), + ], + options={ + 'verbose_name': '应用/功能', + 'verbose_name_plural': '应用/功能', + 'db_table': 'App', + 'ordering': ['AppSort', 'CreateTime'], + }, + ), + migrations.CreateModel( + name='TenantApp', + fields=[ + ('tenant_app_uuid', gvsds.apps.core.models.UUIDField(db_column='TenantAppUUID', primary_key=True, serialize=False)), + ('tenant_uuid', gvsds.apps.core.models.UUIDField(db_column='TenantUUID')), + ('app_uuid', gvsds.apps.core.models.UUIDField(db_column='AppUUID')), + ('install_status', models.SmallIntegerField(db_column='InstallStatus', default=1)), + ('auth_status', models.SmallIntegerField(db_column='AuthStatus', default=0)), + ('install_time', models.DateTimeField(db_column='InstallTime', default=datetime.datetime.utcnow)), + ('auth_by', gvsds.apps.core.models.UUIDField(blank=True, db_column='AuthBy', null=True)), + ('auth_time', models.DateTimeField(blank=True, db_column='AuthTime', null=True)), + ], + options={ + 'verbose_name': '企业应用安装', + 'verbose_name_plural': '企业应用安装', + 'db_table': 'TenantApp', + 'unique_together': {('tenant_uuid', 'app_uuid')}, + }, + ), + migrations.CreateModel( + name='UserApp', + fields=[ + ('user_app_uuid', gvsds.apps.core.models.UUIDField(db_column='UserAppUUID', primary_key=True, serialize=False)), + ('user_uuid', gvsds.apps.core.models.UUIDField(db_column='UserUUID')), + ('app_uuid', gvsds.apps.core.models.UUIDField(db_column='AppUUID')), + ('use_status', models.SmallIntegerField(db_column='UseStatus', default=1)), + ('apply_status', models.SmallIntegerField(db_column='ApplyStatus', default=0)), + ('apply_time', models.DateTimeField(blank=True, db_column='ApplyTime', null=True)), + ('approve_by', gvsds.apps.core.models.UUIDField(blank=True, db_column='ApproveBy', null=True)), + ('approve_time', models.DateTimeField(blank=True, db_column='ApproveTime', null=True)), + ('create_time', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow)), + ], + options={ + 'verbose_name': '个人应用', + 'verbose_name_plural': '个人应用', + 'db_table': 'UserApp', + 'unique_together': {('user_uuid', 'app_uuid')}, + }, + ), + ] diff --git a/gvsds/apps/gerp/models.py b/gvsds/apps/gerp/models.py index e8cae6d9..d8d42ee7 100644 --- a/gvsds/apps/gerp/models.py +++ b/gvsds/apps/gerp/models.py @@ -772,6 +772,90 @@ class UserGiteaAccount(QModel): return self.GiteaLogin +class App(QModel): + """应用/功能注册表——企业功能与个人应用的统一实体 + + 企业视角叫"功能"(租户安装后全员可用),个人视角叫"应用"(个人启用后自用)。 + 投放形态 AppType: + enterprise —— 企业功能:仅租户管理员安装后该租户可见 + personal —— 个人应用:个人启用后仅本人可见 + both —— 混合:企业装=全员,个人装=自用 + 企业授权 AuthRequired=1:个人使用须经租户管理员审批授权(UserApp.ApplyStatus 状态机)。 + """ + + AppUUID = UUIDField(primary_key=True, verbose_name='应用UUID') + AppCode = models.CharField(max_length=64, unique=True, verbose_name='应用编码') + AppName = models.CharField(max_length=128, verbose_name='应用名称') + AppIcon = models.CharField(max_length=256, default='', verbose_name='应用图标') + AppDesc = models.TextField(default='', verbose_name='应用描述') + AppType = models.CharField(max_length=16, default='both', verbose_name='投放形态(enterprise/personal/both)') + AuthRequired = models.SmallIntegerField(default=0, verbose_name='是否需企业授权(0/1)') + MenuRootUUID = UUIDField(null=True, blank=True, verbose_name='关联菜单根UUID(安装后注入)') + DefaultPerms = models.TextField(default='', verbose_name='默认权限码集(;分隔)') + 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='创建时间') + + class Meta: + db_table = 'App' + app_label = 'gerp' + ordering = ['AppSort', 'CreateTime'] + verbose_name = '应用/功能' + verbose_name_plural = '应用/功能' + + def __str__(self): + return self.AppName + + +class TenantApp(QModel): + """企业安装/授权——租户与应用的关系 + + InstallStatus: 1=已安装(启用) / 0=已停用 + AuthStatus: 0=未授权 / 1=已授权 / 2=已拒绝(AuthRequired 应用的企业级开通状态) + """ + + TenantAppUUID = UUIDField(primary_key=True, verbose_name='企业安装记录UUID') + TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID') + AppUUID = UUIDField(db_column='AppUUID', verbose_name='应用UUID') + InstallStatus = models.SmallIntegerField(default=1, verbose_name='安装状态(1启用/0停用)') + AuthStatus = models.SmallIntegerField(default=0, verbose_name='企业授权状态(0/1/2)') + InstallTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='安装时间') + AuthBy = UUIDField(null=True, blank=True, verbose_name='企业授权人UUID') + AuthTime = models.DateTimeField(null=True, blank=True, verbose_name='企业授权时间') + + class Meta: + db_table = 'TenantApp' + app_label = 'gerp' + unique_together = (('tenant_uuid', 'app_uuid'),) + verbose_name = '企业应用安装' + verbose_name_plural = '企业应用安装' + + +class UserApp(QModel): + """个人应用启用/申请——用户与应用的关系 + + UseStatus: 1=已启用 / 0=已停用 + ApplyStatus: 0=未申请 / 1=申请中 / 2=已授权 / 3=已拒绝(AuthRequired 应用的审批状态机) + """ + + UserAppUUID = UUIDField(primary_key=True, verbose_name='个人应用记录UUID') + UserUUID = UUIDField(db_column='UserUUID', verbose_name='用户UUID') + AppUUID = UUIDField(db_column='AppUUID', verbose_name='应用UUID') + UseStatus = models.SmallIntegerField(default=1, verbose_name='使用状态(1启用/0停用)') + ApplyStatus = models.SmallIntegerField(default=0, verbose_name='授权申请状态(0/1/2/3)') + ApplyTime = models.DateTimeField(null=True, blank=True, verbose_name='申请时间') + ApproveBy = UUIDField(null=True, blank=True, verbose_name='审批人UUID') + ApproveTime = models.DateTimeField(null=True, blank=True, verbose_name='审批时间') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + + class Meta: + db_table = 'UserApp' + app_label = 'gerp' + unique_together = (('user_uuid', 'app_uuid'),) + verbose_name = '个人应用' + verbose_name_plural = '个人应用' + + # ==================== 从 gvsdsdk 导入重复模型 ==================== # 以下模型原在 gerp/models.py 定义,现统一依赖 gvsdsdk.models(映射 SAAS 主库) # 注意:gvsdsdk 模型用 UUIDField(非 ForeignKey),不支持 select_related 和关联属性访问 diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py index 6a6b7c82..e9d82831 100644 --- a/gvsds/apps/gerp/views/__init__.py +++ b/gvsds/apps/gerp/views/__init__.py @@ -144,3 +144,14 @@ from .gitea import ( GiteaAdminListView, GiteaDisableView, ) +from .app import ( + AppMarketView, + AppMyAppsView, + AppInstallView, + AppUninstallView, + AppUserEnableView, + AppUserDisableView, + AppApplyView, + AppApproveView, + AppPendingView, +) diff --git a/gvsds/apps/gerp/views/app.py b/gvsds/apps/gerp/views/app.py new file mode 100644 index 00000000..2e880469 --- /dev/null +++ b/gvsds/apps/gerp/views/app.py @@ -0,0 +1,206 @@ +"""模块:GERP 应用/功能视图(应用市场与安装授权) + +提供应用市场的浏览、企业功能安装/停用、个人应用启用/停用、 +企业授权申请/审批,以及"我的应用"查询。 + +URL 路径最后一段作为 action 名称: + + GET /API/App/Market - 应用市场列表(含租户安装/个人启用/申请状态) + GET /API/App/MyApps - 我的应用(当前用户可用) + POST /API/App/Install - 租户管理员:安装企业功能(enterprise/both) + POST /API/App/Uninstall - 租户管理员:停用企业功能 + POST /API/App/Enable - 个人:启用应用(AuthRequired=1 需先获授权) + POST /API/App/Disable - 个人:停用应用 + POST /API/App/Apply - 个人:申请企业授权 + POST /API/App/Approve - 租户管理员:审批个人授权申请 + GET /API/App/Pending - 租户管理员:待审批申请列表 +""" +import logging + +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from gvsdsdk.auth.permissions import IsAdminUser +from gvsdsdk.model_utils import UUIDObj + +from . import ELTAuthenticatedAPIView +from ..app_manager import AppManager + +logger = logging.getLogger(__name__) + + +def _GetUserBytes(request): + """当前用户 UUID bytes(兼容 core User)""" + user = request.user + if not user: + return None + uu = getattr(user, 'UserUUID', None) + if uu is None: + return None + if hasattr(uu, 'bytes'): + return uu.bytes + try: + return UUIDObj(str(uu)).bytes + except Exception: + return None + + +def _GetTenantBytes(request): + """当前角色租户 UUID bytes(CurrentRoleMiddleware/ResolveRoleContext 注入)""" + tu = getattr(request, 'current_tenant_uuid', None) + if tu is None: + return None + if hasattr(tu, 'bytes'): + return tu.bytes + try: + return UUIDObj(str(tu)).bytes + except Exception: + return None + + +class AppMarketView(ELTAuthenticatedAPIView): + """应用市场——全部启用应用,附租户安装/个人启用/申请状态。""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + data = AppManager.ListMarket( + TenantUUID=_GetTenantBytes(request), + UserUUID=_GetUserBytes(request), + ) + return Response({'code': 0, 'data': {'list': data, 'total': len(data)}}) + + +class AppMyAppsView(ELTAuthenticatedAPIView): + """我的应用——当前用户可用应用列表。""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + data = AppManager.ListMyApps( + TenantUUID=_GetTenantBytes(request), + UserUUID=_GetUserBytes(request), + ) + return Response({'code': 0, 'data': {'list': data, 'total': len(data)}}) + + +class AppInstallView(ELTAuthenticatedAPIView): + """租户管理员:安装企业功能。""" + + 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) + tenant_bytes = _GetTenantBytes(request) + if not tenant_bytes: + return Response({'code': 1, 'msg': '无法确定当前租户'}, status=400) + ok, err, flat = AppManager.InstallTenantApp(tenant_bytes, app_uuid) + if not ok: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': '安装成功', 'data': flat}) + + +class AppUninstallView(ELTAuthenticatedAPIView): + """租户管理员:停用企业功能。""" + + 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) + tenant_bytes = _GetTenantBytes(request) + if not tenant_bytes: + return Response({'code': 1, 'msg': '无法确定当前租户'}, status=400) + ok, err = AppManager.UninstallTenantApp(tenant_bytes, app_uuid) + if not ok: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': '已停用'}) + + +class AppUserEnableView(ELTAuthenticatedAPIView): + """个人:启用应用(AuthRequired=1 需先获企业授权)。""" + + permission_classes = [IsAuthenticated] + + def post(self, request): + app_uuid = (request.data.get('AppUUID') or '').strip() + if not app_uuid: + return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400) + user_bytes = _GetUserBytes(request) + if not user_bytes: + return Response({'code': 1, 'msg': '无法识别当前用户'}, status=401) + ok, err, flat = AppManager.EnableUserApp(user_bytes, app_uuid) + if not ok: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': '已启用', 'data': flat}) + + +class AppUserDisableView(ELTAuthenticatedAPIView): + """个人:停用应用。""" + + permission_classes = [IsAuthenticated] + + def post(self, request): + app_uuid = (request.data.get('AppUUID') or '').strip() + if not app_uuid: + return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400) + user_bytes = _GetUserBytes(request) + if not user_bytes: + return Response({'code': 1, 'msg': '无法识别当前用户'}, status=401) + ok, err = AppManager.DisableUserApp(user_bytes, app_uuid) + if not ok: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': '已停用'}) + + +class AppApplyView(ELTAuthenticatedAPIView): + """个人:申请企业授权(AuthRequired=1 的应用)。""" + + permission_classes = [IsAuthenticated] + + def post(self, request): + app_uuid = (request.data.get('AppUUID') or '').strip() + if not app_uuid: + return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400) + user_bytes = _GetUserBytes(request) + if not user_bytes: + return Response({'code': 1, 'msg': '无法识别当前用户'}, status=401) + ok, err = AppManager.ApplyUserApp(user_bytes, app_uuid) + if not ok: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': '申请已提交,等待租户管理员审批'}) + + +class AppApproveView(ELTAuthenticatedAPIView): + """租户管理员:审批个人授权申请。""" + + permission_classes = [IsAuthenticated, IsAdminUser] + + def post(self, request): + user_app_uuid = (request.data.get('UserAppUUID') or '').strip() + if not user_app_uuid: + return Response({'code': 1, 'msg': '缺少申请记录 UUID'}, status=400) + approved = request.data.get('Approved', True) + if isinstance(approved, str): + approved = approved.lower() in ('1', 'true', 'yes') + user_bytes = _GetUserBytes(request) + ok, err, flat = AppManager.ApproveUserApp(user_bytes, user_app_uuid, Approved=bool(approved)) + if not ok: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': '已通过' if approved else '已拒绝', 'data': flat}) + + +class AppPendingView(ELTAuthenticatedAPIView): + """租户管理员:待审批的个人授权申请列表。""" + + permission_classes = [IsAuthenticated, IsAdminUser] + + def get(self, request): + tenant_bytes = _GetTenantBytes(request) + if not tenant_bytes: + return Response({'code': 0, 'data': {'list': [], 'total': 0}}) + rows = AppManager.ListPendingApplications(tenant_bytes) + return Response({'code': 0, 'data': {'list': rows, 'total': len(rows)}}) diff --git a/gvsds/apps/gerp/views/menu.py b/gvsds/apps/gerp/views/menu.py index f18ec7a8..4532f35a 100644 --- a/gvsds/apps/gerp/views/menu.py +++ b/gvsds/apps/gerp/views/menu.py @@ -146,6 +146,39 @@ 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) 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 应用菜单过滤失败') + # 构建树 root_menus = [] for menu_uuid_str, item in menu_map.items(): diff --git a/gvsds/urls.py b/gvsds/urls.py index 1372c7c4..27b7024b 100644 --- a/gvsds/urls.py +++ b/gvsds/urls.py @@ -146,6 +146,17 @@ urlpatterns = [ path('API/Tenant/Approve/', gerp_views.TenantApproveView.as_view(), name='tenant_approve'), path('API/Tenant/Reject/', gerp_views.TenantRejectView.as_view(), name='tenant_reject'), + # 应用/功能市场与安装授权 + path('API/App/Market', gerp_views.AppMarketView.as_view(), name='app_market'), + path('API/App/MyApps', gerp_views.AppMyAppsView.as_view(), name='app_my_apps'), + path('API/App/Install', gerp_views.AppInstallView.as_view(), name='app_install'), + path('API/App/Uninstall', gerp_views.AppUninstallView.as_view(), name='app_uninstall'), + path('API/App/Enable', gerp_views.AppUserEnableView.as_view(), name='app_enable'), + path('API/App/Disable', gerp_views.AppUserDisableView.as_view(), name='app_disable'), + 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/Role/PermissionsTree', gerp_views.RolePermissionsTreeView.as_view(), name='role_permissions_tree'), path('API/Role/SavePermissions', gerp_views.RoleSavePermissionsView.as_view(), name='role_save_permissions'),