diff --git a/__init__.py b/__init__.py index e05e393..c6ef030 100644 --- a/__init__.py +++ b/__init__.py @@ -301,6 +301,16 @@ _LAZY_SYMBOLS = { 'PositionTemplatePermission': ('models', 'PositionTemplatePermission'), 'Menu': ('models', 'Menu'), 'WorkOrder': ('models', 'WorkOrder'), + 'AuditLog': ('models', 'AuditLog'), + 'UserRoleDepartment': ('models', 'UserRoleDepartment'), + 'UserRoleWorkgroup': ('models', 'UserRoleWorkgroup'), + 'Message': ('models', 'Message'), + 'UserIDCard': ('models', 'UserIDCard'), + 'PersonalPage': ('models', 'PersonalPage'), + 'UserSettings': ('models', 'UserSettings'), + 'Tenant': ('models', 'Tenant'), + 'Company': ('models', 'Company'), + 'Department': ('models', 'Department'), 'FixedBinaryField': ('models', 'FixedBinaryField'), 'QModel': ('model_base', 'QModel'), 'QModelBase': ('model_base', 'QModelBase'), diff --git a/auth/managers.py b/auth/managers.py index 66bc364..a051cb6 100644 --- a/auth/managers.py +++ b/auth/managers.py @@ -2,10 +2,13 @@ import uuid from django.db.models import Q from django.utils import timezone as _timezone -from gvsdsdk.models import Role, UserRole, RolePermission, Permission, Position, PositionTemplate, PositionTemplatePermission, Shop, RoleShop, User2FA, UserAvatar, Password, UserPending2FA, AccountActivation, Tenant, Company, Department, UserRoleDepartment, Message, Menu, WorkOrder +from gvsdsdk.models import Role, UserRole, UserRoleDepartment, UserRoleWorkgroup, RolePermission, Permission, Position, PositionTemplate, PositionTemplatePermission, Shop, RoleShop, User2FA, UserAvatar, Password, UserPending2FA, AccountActivation, Tenant, Company, Department, Message, Menu, WorkOrder, AuditLog from gvsdsdk.auth.crypto import HashPassword, VerifyPassword from gvsdsdk.model_utils import _GetUserModel, UUIDObj +# 系统公司 UUID(00000000-0000-0000-0000-000000000001),全局唯一 +_SYSTEM_COMPANY_UUID = uuid.UUID('00000000-0000-0000-0000-000000000001').bytes + # NOTE: 类型注解暂缓——RoleManager/PositionManager 静态方法较多,全量补充注解工作量较大且易引入回归,后续单独迭代处理。 @@ -176,6 +179,68 @@ class RoleManager: qs = qs.filter(CompanyUUID=CompanyUUID) return list(qs) + @staticmethod + def GetUserRoleRelation(UserUUID, RoleUUID, CompanyUUID=None): + """按 (用户, 角色[, 公司]) 查询 UserRole 关联(不存在返回 None)""" + qs = UserRole.objects.filter( + UserUUID=UUIDObj(UserUUID).bytes, + RoleUUID=UUIDObj(RoleUUID).bytes, + ) + if CompanyUUID is not None: + try: + qs = qs.filter(CompanyUUID=UUIDObj(CompanyUUID).bytes) + except Exception: + qs = qs.filter(CompanyUUID=CompanyUUID) + return qs.first() + + @staticmethod + def GetUserRoleDepartment(UserRoleUUID): + """按 UserRoleUUID 查询部门归属(不存在返回 None)""" + return UserRoleDepartment.objects.filter( + UserRoleUUID=UUIDObj(UserRoleUUID).bytes, + ).first() + + @staticmethod + def GetUserRoleWorkgroup(UserRoleUUID): + """按 UserRoleUUID 查询工作组归属(不存在返回 None)""" + return UserRoleWorkgroup.objects.filter( + UserRoleUUID=UUIDObj(UserRoleUUID).bytes, + ).first() + + @staticmethod + def GetScopeMembers(ScopeType, UUID): + """获取作用域成员的用户 UUID 列表(去重) + + Args: + ScopeType: 'dept_manager' | 'workgroup_lead' + UUID: 部门或工作组 UUID(bytes/str/UUIDObj 均可) + + Returns: + list[bytes]:成员用户 UUID;无成员返回 [];未知类型或异常返回 None + """ + try: + uuid_bytes = UUIDObj(UUID).bytes + except Exception: + return None + try: + if ScopeType == 'dept_manager': + ur_uuids = list(UserRoleDepartment.objects.filter( + DeptUUID=uuid_bytes, + ).values_list('UserRoleUUID', flat=True)) + elif ScopeType == 'workgroup_lead': + ur_uuids = list(UserRoleWorkgroup.objects.filter( + WorkgroupUUID=uuid_bytes, + ).values_list('UserRoleUUID', flat=True)) + else: + return None + if not ur_uuids: + return [] + # UserRoleUUID → UserUUID(关联表存 UserRoleUUID,用户 UUID 从 UserRole 表取) + qs = UserRole.objects.filter(UserRoleUUID__in=ur_uuids) + return list(set(qs.values_list('UserUUID', flat=True))) + except Exception: + return None + @staticmethod def HasRolePermission(RoleUUID, PermUUID, CompanyUUID=None): """检查角色是否已拥有指定权限(同公司下防重复绑定)""" @@ -456,6 +521,103 @@ class RoleManager: return None, False return False, True + @staticmethod + def EnsureDefaultRoleForUser(UserUUID, CompanyUUID, AdminUserUUID=None): + """为新用户关联全局默认角色("默认角色"在系统租户下唯一) + + 租户解析顺序:系统公司所属租户 → 操作管理员角色租户 → 系统首个租户。 + + Args: + UserUUID: 新用户 UUID(bytes/str/UUIDObj 均可) + CompanyUUID: 公司 UUID(通常为系统公司) + AdminUserUUID: 可选,操作管理员 UUID(租户回退用) + + Returns: + (UserRole 实例 or None, 错误消息 or None) + """ + company_bytes = UUIDObj(CompanyUUID).bytes + # 角色租户优先取自系统公司所属租户 + tenant_uuid = None + company = Company.objects.filter(CompanyUUID=company_bytes).first() + if company is not None: + tenant_uuid = company.TenantUUID + # 回退:当前操作管理员的首个角色租户 + if not tenant_uuid and AdminUserUUID: + try: + admin_ur = UserRole.objects.filter( + UserUUID=UUIDObj(AdminUserUUID).bytes, + ).first() + if admin_ur and admin_ur.RoleUUID: + role = Role.objects.filter(RoleUUID=admin_ur.RoleUUID).first() + tenant_uuid = role.TenantUUID if role else None + except Exception: + tenant_uuid = None + # 回退:系统首个租户 + if not tenant_uuid: + tenant = Tenant.objects.first() + tenant_uuid = tenant.TenantUUID if tenant else None + if not tenant_uuid: + return None, '无法确定租户' + + # 查找或创建全局唯一的"默认角色" + default_role = Role.objects.filter( + TenantUUID=tenant_uuid, + RoleName='默认角色', + ).first() + if not default_role: + default_role = Role.objects.create( + RoleUUID=uuid.uuid4().bytes, + TenantUUID=tenant_uuid, + RoleName='默认角色', + RoleStatus=1, + AssignScope=0, + RoleDesc='系统默认角色,所有新用户自动关联', + CreateTime=_timezone.now(), + ) + + # 避免重复关联 + if RoleManager.HasRole(UserUUID, default_role.RoleUUID, CompanyUUID): + return None, None + ur = UserRole.objects.create( + UserRoleUUID=uuid.uuid4().bytes, + UserUUID=UUIDObj(UserUUID).bytes, + RoleUUID=default_role.RoleUUID, + CompanyUUID=company_bytes, + ) + return ur, None + + @staticmethod + def EnsureTenantAdminRole(TenantUUID): + """为租户(集团)创建/复用"集团超级管理员"角色并分配 admin:tenant 权限 + + Args: + TenantUUID: 租户 UUID(bytes/str/UUIDObj 均可) + + Returns: + Role 实例(已创建或已存在) + """ + tenant_bytes = UUIDObj(TenantUUID).bytes + role = Role.objects.filter( + TenantUUID=tenant_bytes, + RoleName='集团超级管理员', + ).first() + if role: + return role + + role = RoleManager.CreateRole( + '集团超级管理员', tenant_bytes, + AssignScope=0, + RoleDesc='集团超级管理员,管理本租户下所有事务', + ) + + # 分配 admin:tenant 权限(租户内管理员:关闭公司隔离,保留租户隔离) + admin_perm = RoleManager.GetPermissionByCode('admin:tenant') + if admin_perm: + RoleManager.AssignPermission( + role.RoleUUID, admin_perm.PermUUID, _SYSTEM_COMPANY_UUID, + ) + return role + class PositionManager: """职务管理器——提供人事职务标签创建、岗位模板管理""" @@ -1022,6 +1184,56 @@ class AuthManager: class AdminUserManager: """管理端用户管理器——用户搜索、密码重置、账户状态与邮箱管理""" + @staticmethod + def DeleteUser(UserUUID): + """删除用户并级联清理关联数据(UserRole / 专属默认角色 / UserAvatar / User2FA / UserPending2FA) + + 与 gvsds FormService.delete_record 用户分支零行为差异: + 仅当角色名为 "{用户名}的默认角色" 且无其他用户持有时才删除角色本身。 + + Args: + UserUUID: 用户 UUID(bytes/str/UUIDObj 均可) + + Returns: + (成功?, 错误消息 or None) + """ + User = _GetUserModel() + try: + user_bytes = UUIDObj(UserUUID).bytes + except Exception: + return False, '无效的 UserUUID' + user = User.objects.filter(UserUUID=user_bytes).first() + if user is None: + return False, '用户不存在' + + from django.db import transaction + with transaction.atomic(): + user_name = getattr(user, 'UserName', '') or '' + # 查找该用户的所有 UserRole 关联 + user_roles = list(UserRole.objects.filter(UserUUID=user_bytes)) + # 识别默认角色:名称匹配 "{用户名}的默认角色" 且仅此用户一人 + default_role_ids = [] + for ur in user_roles: + role = Role.objects.filter(RoleUUID=ur.RoleUUID).first() + if role and getattr(role, 'RoleName', '') == f'{user_name}的默认角色': + other_count = UserRole.objects.filter( + RoleUUID=role.RoleUUID, + ).exclude(UserUUID=user_bytes).count() + if other_count == 0: + default_role_ids.append(role.RoleUUID) + # 删除所有 UserRole 关联 + UserRole.objects.filter(UserUUID=user_bytes).delete() + # 删除默认角色(RolePermission 会被 cascade 删除) + if default_role_ids: + Role.objects.filter(RoleUUID__in=default_role_ids).delete() + # 清理 UserAvatar / User2FA / UserPending2FA(这些表用 UUIDField 而非 ForeignKey,不会自动级联) + UserAvatar.objects.filter(UserUUID=user_bytes).delete() + User2FA.objects.filter(UserUUID=user_bytes).delete() + UserPending2FA.objects.filter(UserUUID=user_bytes).delete() + # 删除用户本身 + user.delete() + return True, None + @staticmethod def SearchUsers(Keyword='', Offset=0, PageSize=20): """搜索用户(按用户名模糊匹配,分页) @@ -1771,3 +1983,48 @@ class WorkOrderManager: wo.save() return WorkOrderManager._ToFlat(wo) + +class AuditManager: + """审计日志管理器——敏感表写操作快照记录(失败静默,不阻断业务)""" + + @staticmethod + def Log(TenantUUID=None, CompanyUUID=None, UserUUID=None, TableName='', + Action='', TargetUUID=None, IPAddress='', OldData=None, NewData=None): + """写入一条审计日志 + + Args: + TenantUUID/CompanyUUID/UserUUID: 归属与操作人 UUID(bytes/str/UUIDObj 均可,无效自动置空) + TableName: 表名 + Action: 操作类型(add/modify/delete/xxx_status) + TargetUUID: 目标记录 UUID + IPAddress: 操作 IP + OldData/NewData: 变更前后数据快照 + + Returns: + True + """ + def _ToBytes(value): + if value is None: + return None + try: + return UUIDObj(value).bytes + except Exception: + return None + + try: + AuditLog.objects.create( + AuditLogUUID=uuid.uuid4().bytes, + TenantUUID=_ToBytes(TenantUUID), + CompanyUUID=_ToBytes(CompanyUUID), + UserUUID=_ToBytes(UserUUID), + TableName=TableName or '', + Action=Action or '', + TargetUUID=_ToBytes(TargetUUID), + IPAddress=IPAddress or '', + OldData=OldData, + NewData=NewData, + ) + except Exception: + pass + return True + diff --git a/models.py b/models.py index a08d34c..99a60bb 100644 --- a/models.py +++ b/models.py @@ -537,6 +537,26 @@ class UserRoleDepartment(QModel): verbose_name_plural = '用户-角色部门关联' +# === 用户-角色工作组关联表 === + +class UserRoleWorkgroup(QModel): + """用户-角色对 与 工作组 的多对多关联表(映射 SAAS 主库 UserRoleWorkgroup 表)""" + + UserRoleWorkgroupUUID = UUIDField(primary_key=True, db_column='UserRoleWorkgroupUUID', verbose_name='关联UUID') + UserRoleUUID = UUIDField(db_column='UserRoleUUID', verbose_name='用户-角色关联UUID') + WorkgroupUUID = UUIDField(db_column='WorkgroupUUID', verbose_name='工作组UUID') + CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID') + RoleType = models.SmallIntegerField(default=0, db_column='RoleType', verbose_name='角色类型') + CreateTime = models.DateTimeField(default=_UtcNow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = False + app_label = 'gvsdsdk' + db_table = 'UserRoleWorkgroup' + verbose_name = '用户-角色工作组关联' + verbose_name_plural = '用户-角色工作组关联' + + # === 消息表 === class Message(QModel): @@ -1050,3 +1070,32 @@ class WorkOrder(QModel): def __str__(self): return self.Title + + +# === 操作审计日志表 === + +class AuditLog(QModel): + """操作审计日志表——记录敏感表写操作快照(映射 SAAS 主库 AuditLog 表)""" + + AuditLogUUID = UUIDField(primary_key=True, db_column='AuditLogUUID', verbose_name='日志UUID') + TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID') + CompanyUUID = UUIDField(null=True, blank=True, db_column='CompanyUUID', verbose_name='公司UUID') + UserUUID = UUIDField(null=True, blank=True, db_column='UserUUID', verbose_name='操作人UUID') + TableName = models.CharField(max_length=64, db_index=True, db_column='TableName', verbose_name='表名') + Action = models.CharField(max_length=16, db_column='Action', verbose_name='操作类型') + TargetUUID = UUIDField(null=True, blank=True, db_column='TargetUUID', verbose_name='目标记录UUID') + IPAddress = models.CharField(max_length=64, default='', db_column='IPAddress', verbose_name='IP地址') + OldData = models.JSONField(null=True, blank=True, db_column='OldData', verbose_name='变更前数据') + NewData = models.JSONField(null=True, blank=True, db_column='NewData', verbose_name='变更后数据') + CreateTime = models.DateTimeField(default=_UtcNow, db_index=True, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = False + app_label = 'gvsdsdk' + db_table = 'AuditLog' + ordering = ['-CreateTime'] + verbose_name = '操作审计日志' + verbose_name_plural = '操作审计日志' + + def __str__(self): + return f'{self.TableName}/{self.Action}' diff --git a/stats/manager.py b/stats/manager.py index 144c2af..7f7adad 100644 --- a/stats/manager.py +++ b/stats/manager.py @@ -7,7 +7,7 @@ from django.db import transaction, connection from django.db.models import F, Sum, Count -from gvsdsdk.stats.models import StatMetric, StatRecord +from gvsdsdk.stats.models import StatMetric, StatRecord, StatDimension, StatConfig class StatsManager: @@ -313,3 +313,27 @@ class StatsManager: ) count, _ = qs.delete() return count + + @staticmethod + def ListMetrics(tenant_uuid, status=None): + """列出租户下的统计指标(可按状态过滤,保持数据库顺序)""" + qs = StatMetric.objects.filter(TenantUUID=tenant_uuid) + if status is not None: + qs = qs.filter(MetricStatus=status) + return qs + + @staticmethod + def ListDimensions(tenant_uuid, status=None): + """列出租户下的统计维度(可按状态过滤,保持数据库顺序)""" + qs = StatDimension.objects.filter(TenantUUID=tenant_uuid) + if status is not None: + qs = qs.filter(DimensionStatus=status) + return qs + + @staticmethod + def ListConfigs(tenant_uuid, status=None): + """列出租户下的统计配置(可按状态过滤,保持数据库顺序)""" + qs = StatConfig.objects.filter(TenantUUID=tenant_uuid) + if status is not None: + qs = qs.filter(ConfigStatus=status) + return qs