feat(assets): add user custom favorite folders (#16949)

* feat(assets): add user custom favorite folders

- Add FavoriteFolder model (per-user, nested via self parent FK)
- Add nullable folder FK on FavoriteAsset, allow one asset in multiple folders
- Add FavoriteFolderViewSet and folder filter on FavoriteAssetViewSet
- Serializer outputs asset_info for building connectable tree nodes
- Register favorite-folders route; exclude favoritefolder from rbac/audit

* feat(assets): register favorite-folders route and add migration

- Register favorite-folders router endpoint
- Add migration 0020 creating FavoriteFolder and folder FK on FavoriteAsset

* feat(rbac): exclude favoritefolder from permission management

Keep favoritefolder consistent with favoriteasset (user-owned, not RBAC managed)

* feat(audits): exclude FavoriteFolder from operate log
This commit is contained in:
LDX
2026-06-26 16:29:25 +08:00
committed by GitHub
parent 10b0585199
commit ec9e76e405
7 changed files with 136 additions and 18 deletions

View File

@@ -4,16 +4,15 @@ from rest_framework_bulk.generics import BulkModelViewSet
from common.permissions import IsValidUser
from orgs.utils import tmp_to_root_org
from ..models import FavoriteAsset
from ..serializers import FavoriteAssetSerializer
from ..models import FavoriteAsset, FavoriteFolder
from ..serializers import FavoriteAssetSerializer, FavoriteFolderSerializer
__all__ = ['FavoriteAssetViewSet']
__all__ = ['FavoriteAssetViewSet', 'FavoriteFolderViewSet']
class FavoriteAssetViewSet(BulkModelViewSet):
serializer_class = FavoriteAssetSerializer
class FavoriteFolderViewSet(BulkModelViewSet):
serializer_class = FavoriteFolderSerializer
permission_classes = (IsValidUser,)
filterset_fields = ['asset']
page_no_limit = True
def dispatch(self, request, *args, **kwargs):
@@ -21,7 +20,23 @@ class FavoriteAssetViewSet(BulkModelViewSet):
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
queryset = FavoriteAsset.objects.filter(user=self.request.user)
return FavoriteFolder.objects.filter(user=self.request.user)
class FavoriteAssetViewSet(BulkModelViewSet):
serializer_class = FavoriteAssetSerializer
permission_classes = (IsValidUser,)
filterset_fields = ['asset', 'folder']
page_no_limit = True
def dispatch(self, request, *args, **kwargs):
with tmp_to_root_org():
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
queryset = FavoriteAsset.objects.filter(
user=self.request.user
).select_related('asset', 'asset__platform')
return queryset
def allow_bulk_destroy(self, qs, filtered):

View File

@@ -0,0 +1,44 @@
# Generated for user custom favorite folders
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('assets', '0019_alter_asset_connectivity'),
]
operations = [
migrations.CreateModel(
name='FavoriteFolder',
fields=[
('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')),
('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')),
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(max_length=128, verbose_name='Name')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='assets.favoritefolder')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Favorite folder',
'unique_together': {('user', 'name', 'parent')},
},
),
migrations.AddField(
model_name='favoriteasset',
name='folder',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='assets.favoritefolder'),
),
migrations.AlterUniqueTogether(
name='favoriteasset',
unique_together={('user', 'asset', 'folder')},
),
]

View File

@@ -5,15 +5,36 @@ from django.utils.translation import gettext_lazy as _
from common.db.models import JMSBaseModel
__all__ = ['FavoriteAsset']
__all__ = ['FavoriteAsset', 'FavoriteFolder']
class FavoriteFolder(JMSBaseModel):
"""User custom favorite folder, owned by a user, visible across orgs, supports nesting"""
user = models.ForeignKey('users.User', on_delete=models.CASCADE)
name = models.CharField(max_length=128, verbose_name=_("Name"))
parent = models.ForeignKey(
'self', on_delete=models.CASCADE,
null=True, blank=True, related_name='children'
)
class Meta:
unique_together = ('user', 'name', 'parent')
verbose_name = _("Favorite folder")
def __str__(self):
return self.name
class FavoriteAsset(JMSBaseModel):
user = models.ForeignKey('users.User', on_delete=models.CASCADE)
asset = models.ForeignKey('assets.Asset', on_delete=models.CASCADE)
folder = models.ForeignKey(
'assets.FavoriteFolder', on_delete=models.CASCADE,
null=True, blank=True, related_name='assets'
)
class Meta:
unique_together = ('user', 'asset')
unique_together = ('user', 'asset', 'folder')
verbose_name = _("Favorite asset")
@classmethod

View File

@@ -4,16 +4,57 @@
from rest_framework import serializers
from common.serializers import BulkSerializerMixin
from ..models import FavoriteAsset
from ..models import FavoriteAsset, FavoriteFolder
__all__ = ['FavoriteAssetSerializer']
__all__ = ['FavoriteAssetSerializer', 'FavoriteFolderSerializer']
class FavoriteFolderSerializer(BulkSerializerMixin, serializers.ModelSerializer):
user = serializers.HiddenField(
default=serializers.CurrentUserDefault()
)
class Meta:
model = FavoriteFolder
fields = ['id', 'user', 'name', 'parent', 'date_created']
read_only_fields = ['id', 'date_created']
class FavoriteAssetSerializer(BulkSerializerMixin, serializers.ModelSerializer):
user = serializers.HiddenField(
default=serializers.CurrentUserDefault()
)
asset_info = serializers.SerializerMethodField()
class Meta:
model = FavoriteAsset
fields = ['user', 'asset']
fields = ['user', 'asset', 'folder', 'asset_info']
@staticmethod
def _get_icon(asset, platform):
from assets.const import AllTypes
support_types = AllTypes.get_types_values(exclude_custom=True)
if asset.category == 'device':
return 'switch'
if asset.type in support_types:
return asset.type
return 'file'
def get_asset_info(self, obj):
asset = obj.asset
platform = asset.platform
return {
'id': str(asset.id),
'name': asset.name,
'iconSkin': self._get_icon(asset, platform),
'chkDisabled': not asset.is_active,
'meta': {
'type': 'asset',
'data': {
'platform_type': platform.type,
'org_name': asset.org_name,
'name': asset.name,
'address': asset.address,
},
},
}

View File

@@ -23,6 +23,7 @@ router.register(r'nodes', api.NodeViewSet, 'node')
router.register(r'zones', api.ZoneViewSet, 'zone')
router.register(r'gateways', api.GatewayViewSet, 'gateway')
router.register(r'favorite-assets', api.FavoriteAssetViewSet, 'favorite-asset')
router.register(r'favorite-folders', api.FavoriteFolderViewSet, 'favorite-folder')
router.register(r'protocol-settings', api.PlatformProtocolViewSet, 'protocol-setting')
router.register(r'labels', LabelViewSet, 'label')
router.register(r'my-asset', api.MyAssetViewSet, 'my-asset')

View File

@@ -80,18 +80,14 @@ def signal_of_operate_log_whether_continue(
condition = False
if instance and getattr(instance, OP_LOG_SKIP_SIGNAL, False):
condition = False
# 不记录组件的操作日志
user = current_request.user if current_request else None
if not user or getattr(user, 'is_service_account', False):
condition = False
# 终端模型的 create 事件由系统产生,不记录
if instance._meta.object_name == 'Terminal' and created:
condition = False
# last_login 改变是最后登录日期, 每次登录都会改变
if instance._meta.object_name == 'User' and \
update_fields and 'last_login' in update_fields:
condition = False
# 不在记录白名单中,跳过
if sender._meta.object_name not in MODELS_NEED_RECORD:
condition = False
return condition
@@ -108,7 +104,6 @@ def on_object_pre_create_or_update(
return
with translation.override('en'):
# users.PrivateToken Model 没有 id 有 pk字段
instance_id = getattr(instance, 'id', getattr(instance, 'pk', None))
instance_before_data = {'id': instance_id}
raw_instance = type(instance).objects.filter(pk=instance_id).first()
@@ -188,7 +183,7 @@ def on_django_start_set_operate_log_monitor_models(sender, **kwargs):
'PermedAsset', 'PermedAccount', 'MenuPermission',
'Permission', 'TicketSession', 'ApplyLoginTicket',
'ApplyCommandTicket', 'ApplyLoginAssetTicket',
'FavoriteAsset', 'ChangeSecretRecord', 'AppProvider', 'Variable', 'LeakPasswords'
'FavoriteAsset', 'FavoriteFolder', 'ChangeSecretRecord', 'AppProvider', 'Variable', 'LeakPasswords'
}
include_models = {'UserSession'}
for i, app in enumerate(apps.get_models(), 1):

View File

@@ -35,6 +35,7 @@ exclude_permissions = (
('assets', 'cluster', '*', '*'),
('assets', 'systemuser', '*', '*'),
('assets', 'favoriteasset', '*', '*'),
('assets', 'favoritefolder', '*', '*'),
('assets', 'assetuser', '*', '*'),
('assets', 'web', '*', '*'),
('assets', 'host', '*', '*'),