diff --git a/apps/audits/api.py b/apps/audits/api.py index 7397b3596..0233bc1b2 100644 --- a/apps/audits/api.py +++ b/apps/audits/api.py @@ -43,7 +43,7 @@ class UserLoginLogViewSet(ListModelMixin, CommonGenericViewSet): @staticmethod def get_org_members(): - users = current_org.get_org_members().values_list('username', flat=True) + users = current_org.get_members().values_list('username', flat=True) return users def get_queryset(self): @@ -79,7 +79,7 @@ class PasswordChangeLogViewSet(ListModelMixin, CommonGenericViewSet): ordering = ['-datetime'] def get_queryset(self): - users = current_org.get_org_members() + users = current_org.get_members() queryset = super().get_queryset().filter( user__in=[user.__str__() for user in users] ) diff --git a/apps/audits/filters.py b/apps/audits/filters.py index 6db2d9b21..470c2c4b5 100644 --- a/apps/audits/filters.py +++ b/apps/audits/filters.py @@ -20,7 +20,7 @@ class CurrentOrgMembersFilter(filters.BaseFilterBackend): ] def _get_user_list(self): - users = current_org.get_org_members(exclude=('Auditor',)) + users = current_org.get_members(exclude=('Auditor',)) return users def filter_queryset(self, request, queryset, view): diff --git a/apps/audits/models.py b/apps/audits/models.py index 38a41554f..97aef40ce 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -124,7 +124,7 @@ class UserLoginLog(models.Model): Q(username__contains=keyword) ) if not current_org.is_root(): - username_list = current_org.get_org_members().values_list('username', flat=True) + username_list = current_org.get_members().values_list('username', flat=True) login_logs = login_logs.filter(username__in=username_list) return login_logs diff --git a/apps/common/const/choices.py b/apps/common/const/choices.py new file mode 100644 index 000000000..8de0c5fc8 --- /dev/null +++ b/apps/common/const/choices.py @@ -0,0 +1,8 @@ +from django.utils.translation import ugettext_lazy as _ + +from common.db.models import ChoiceSet + + +ADMIN = 'Admin' +USER = 'User' +AUDITOR = 'Auditor' diff --git a/apps/common/db/aggregates.py b/apps/common/db/aggregates.py index 081c1fea8..e04390299 100644 --- a/apps/common/db/aggregates.py +++ b/apps/common/db/aggregates.py @@ -3,10 +3,10 @@ from django.db.models import Aggregate class GroupConcat(Aggregate): function = 'GROUP_CONCAT' - template = '%(function)s(%(distinct)s %(expressions)s %(order_by)s %(separator))' + template = '%(function)s(%(expressions)s %(order_by)s %(separator)s)' allow_distinct = False - def __init__(self, expression, distinct=False, order_by=None, separator=',', **extra): + def __init__(self, expression, order_by=None, separator=',', **extra): order_by_clause = '' if order_by is not None: order = 'ASC' @@ -21,8 +21,7 @@ class GroupConcat(Aggregate): super().__init__( expression, - distinct='DISTINCT' if distinct else '', order_by=order_by_clause, - separator=f'SEPARATOR {separator}', + separator=f"SEPARATOR '{separator}'", **extra ) diff --git a/apps/common/db/models.py b/apps/common/db/models.py new file mode 100644 index 000000000..04d501b8f --- /dev/null +++ b/apps/common/db/models.py @@ -0,0 +1,48 @@ +from functools import partial + + +class Choice(str): + def __new__(cls, value, label): + self = super().__new__(cls, value) + self.label = label + return self + + +class ChoiceSetType(type): + def __new__(cls, name, bases, attrs): + _choices = [] + collected = set() + new_attrs = {} + for k, v in attrs.items(): + if isinstance(v, tuple): + v = Choice(*v) + assert v not in collected, 'Cannot be defined repeatedly' + _choices.append(v) + collected.add(v) + new_attrs[k] = v + for base in bases: + if hasattr(base, '_choices'): + for c in base._choices: + if c not in collected: + _choices.append(c) + collected.add(c) + new_attrs['_choices'] = _choices + new_attrs['_choices_dict'] = {c: c.label for c in _choices} + return type.__new__(cls, name, bases, new_attrs) + + def __contains__(self, item): + return self._choices_dict.__contains__(item) + + def __getitem__(self, item): + return self._choices_dict.__getitem__(item) + + def get(self, item, default=None): + return self._choices_dict.get(item, default=None) + + @property + def choices(self): + return [(c, c.label) for c in self._choices] + + +class ChoiceSet(metaclass=ChoiceSetType): + choices = None # 用于 Django Model 中的 choices 配置, 为了代码提示在此声明 diff --git a/apps/common/drf/fields.py b/apps/common/drf/fields.py new file mode 100644 index 000000000..e3b333d56 --- /dev/null +++ b/apps/common/drf/fields.py @@ -0,0 +1,43 @@ +from uuid import UUID + +from rest_framework.fields import get_attribute +from rest_framework.relations import ManyRelatedField, PrimaryKeyRelatedField, MANY_RELATION_KWARGS + + +class GroupConcatedManyRelatedField(ManyRelatedField): + def get_attribute(self, instance): + if hasattr(instance, 'pk') and instance.pk is None: + return [] + + attr = self.source_attrs[-1] + + # `gc` 是 `GroupConcat` 的缩写 + gc_attr = f'gc_{attr}' + if hasattr(instance, gc_attr): + gc_value = getattr(instance, gc_attr) + if isinstance(gc_value, str): + return [UUID(pk) for pk in set(gc_value.split(','))] + else: + return '' + + relationship = get_attribute(instance, self.source_attrs) + return relationship.all() if hasattr(relationship, 'all') else relationship + + +class GroupConcatedPrimaryKeyRelatedField(PrimaryKeyRelatedField): + @classmethod + def many_init(cls, *args, **kwargs): + list_kwargs = {'child_relation': cls(*args, **kwargs)} + for key in kwargs: + if key in MANY_RELATION_KWARGS: + list_kwargs[key] = kwargs[key] + return GroupConcatedManyRelatedField(**list_kwargs) + + def to_representation(self, value): + if self.pk_field is not None: + return self.pk_field.to_representation(value.pk) + + if hasattr(value, 'pk'): + return value.pk + else: + return value diff --git a/apps/jumpserver/api.py b/apps/jumpserver/api.py index 9558a92df..026a90b9a 100644 --- a/apps/jumpserver/api.py +++ b/apps/jumpserver/api.py @@ -128,7 +128,7 @@ class DatesLoginMetricMixin: @lazyproperty def dates_total_count_inactive_users(self): - total = current_org.get_org_members().count() + total = current_org.get_members().count() active = self.dates_total_count_active_users count = total - active if count < 0: @@ -137,7 +137,7 @@ class DatesLoginMetricMixin: @lazyproperty def dates_total_count_disabled_users(self): - return current_org.get_org_members().filter(is_active=False).count() + return current_org.get_members().filter(is_active=False).count() @lazyproperty def dates_total_count_active_assets(self): @@ -207,7 +207,7 @@ class DatesLoginMetricMixin: class TotalCountMixin: @staticmethod def get_total_count_users(): - return current_org.get_org_members().count() + return current_org.get_members().count() @staticmethod def get_total_count_assets(): diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 8fb1a75b4..f72e19c12 100644 Binary files a/apps/locale/zh/LC_MESSAGES/django.mo and b/apps/locale/zh/LC_MESSAGES/django.mo differ diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index b819ee494..dd64657a7 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-07-21 16:29+0800\n" +"POT-Creation-Date: 2020-07-27 19:01+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -25,10 +25,10 @@ msgstr "自定义" #: assets/models/asset.py:145 assets/models/base.py:232 #: assets/models/cluster.py:18 assets/models/cmd_filter.py:21 #: assets/models/domain.py:20 assets/models/group.py:20 -#: assets/models/label.py:18 ops/mixin.py:24 orgs/models.py:12 +#: assets/models/label.py:18 ops/mixin.py:24 orgs/models.py:22 #: perms/models/base.py:48 settings/models.py:27 terminal/models.py:26 #: terminal/models.py:342 terminal/models.py:374 terminal/models.py:411 -#: users/forms/profile.py:20 users/models/group.py:15 users/models/user.py:467 +#: users/forms/profile.py:20 users/models/group.py:15 users/models/user.py:473 #: users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 #: users/templates/users/user_asset_permission.html:154 @@ -75,9 +75,9 @@ msgstr "数据库" #: assets/models/cmd_filter.py:23 assets/models/cmd_filter.py:57 #: assets/models/domain.py:21 assets/models/domain.py:54 #: assets/models/group.py:23 assets/models/label.py:23 ops/models/adhoc.py:37 -#: orgs/models.py:18 perms/models/base.py:56 settings/models.py:32 +#: orgs/models.py:25 perms/models/base.py:56 settings/models.py:32 #: terminal/models.py:36 terminal/models.py:381 terminal/models.py:418 -#: users/models/group.py:16 users/models/user.py:500 +#: users/models/group.py:16 users/models/user.py:506 #: users/templates/users/user_detail.html:115 #: users/templates/users/user_granted_database_app.html:38 #: users/templates/users/user_granted_remote_app.html:37 @@ -131,8 +131,8 @@ msgstr "参数" #: applications/models/remote_app.py:39 assets/models/asset.py:224 #: assets/models/base.py:240 assets/models/cluster.py:28 #: assets/models/cmd_filter.py:26 assets/models/cmd_filter.py:60 -#: assets/models/group.py:21 common/mixins/models.py:49 orgs/models.py:16 -#: perms/models/base.py:54 users/models/user.py:508 +#: assets/models/group.py:21 common/mixins/models.py:49 orgs/models.py:23 +#: orgs/models.py:316 perms/models/base.py:54 users/models/user.py:514 #: users/serializers/group.py:35 users/templates/users/user_detail.html:97 #: xpack/plugins/change_auth_plan/models.py:81 xpack/plugins/cloud/models.py:56 #: xpack/plugins/cloud/models.py:146 xpack/plugins/gathered_user/models.py:30 @@ -146,8 +146,8 @@ msgstr "创建者" #: assets/models/domain.py:23 assets/models/gathered_user.py:19 #: assets/models/group.py:22 assets/models/label.py:25 #: common/mixins/models.py:50 ops/models/adhoc.py:38 ops/models/command.py:27 -#: orgs/models.py:17 perms/models/base.py:55 users/models/group.py:18 -#: users/templates/users/user_group_detail.html:58 +#: orgs/models.py:24 orgs/models.py:314 perms/models/base.py:55 +#: users/models/group.py:18 users/templates/users/user_group_detail.html:58 #: xpack/plugins/cloud/models.py:59 xpack/plugins/cloud/models.py:149 msgid "Date created" msgstr "创建日期" @@ -336,10 +336,10 @@ msgid "AuthBook" msgstr "" #: assets/models/base.py:233 assets/models/gathered_user.py:15 -#: audits/models.py:99 authentication/forms.py:10 +#: audits/models.py:99 authentication/forms.py:11 #: authentication/templates/authentication/login.html:21 -#: authentication/templates/authentication/xpack_login.html:93 -#: ops/models/adhoc.py:148 users/forms/profile.py:19 users/models/user.py:465 +#: authentication/templates/authentication/xpack_login.html:101 +#: ops/models/adhoc.py:148 users/forms/profile.py:19 users/models/user.py:471 #: users/templates/users/_select_user_modal.html:14 #: users/templates/users/user_detail.html:53 #: users/templates/users/user_list.html:15 @@ -350,9 +350,9 @@ msgid "Username" msgstr "用户名" #: assets/models/base.py:234 assets/serializers/asset_user.py:71 -#: authentication/forms.py:12 +#: authentication/forms.py:13 #: authentication/templates/authentication/login.html:29 -#: authentication/templates/authentication/xpack_login.html:101 +#: authentication/templates/authentication/xpack_login.html:109 #: users/forms/user.py:22 users/forms/user.py:193 #: users/templates/users/user_otp_check_password.html:13 #: users/templates/users/user_password_update.html:43 @@ -379,7 +379,7 @@ msgid "SSH public key" msgstr "SSH公钥" #: assets/models/base.py:239 assets/models/gathered_user.py:20 -#: common/mixins/models.py:51 ops/models/adhoc.py:39 +#: common/mixins/models.py:51 ops/models/adhoc.py:39 orgs/models.py:315 msgid "Date updated" msgstr "更新日期" @@ -391,7 +391,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:486 +#: assets/models/cluster.py:22 users/models/user.py:492 #: users/templates/users/user_detail.html:62 msgid "Phone" msgstr "手机" @@ -417,7 +417,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:627 +#: users/models/user.py:635 msgid "System" msgstr "系统" @@ -535,14 +535,15 @@ msgstr "默认资产组" #: assets/models/label.py:15 audits/models.py:36 audits/models.py:56 #: audits/models.py:69 audits/serializers.py:77 authentication/models.py:43 -#: perms/forms/asset_permission.py:83 perms/forms/database_app_permission.py:38 +#: orgs/models.py:16 orgs/models.py:312 perms/forms/asset_permission.py:83 +#: perms/forms/database_app_permission.py:38 #: perms/forms/remote_app_permission.py:40 perms/models/base.py:49 #: templates/index.html:78 terminal/backends/command/models.py:18 #: terminal/backends/command/serializers.py:12 terminal/models.py:185 #: tickets/models/ticket.py:35 tickets/models/ticket.py:130 #: tickets/serializers/request_asset_perm.py:55 #: tickets/serializers/ticket.py:27 users/forms/group.py:15 -#: users/models/user.py:160 users/models/user.py:176 users/models/user.py:615 +#: users/models/user.py:157 users/models/user.py:623 #: users/serializers/group.py:20 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -707,14 +708,14 @@ msgid "Backend" msgstr "后端" #: assets/serializers/asset_user.py:75 users/forms/profile.py:148 -#: users/models/user.py:497 users/templates/users/user_password_update.html:48 +#: users/models/user.py:503 users/templates/users/user_password_update.html:48 #: users/templates/users/user_profile.html:69 #: users/templates/users/user_profile_update.html:46 #: users/templates/users/user_pubkey_update.html:46 msgid "Public key" msgstr "SSH公钥" -#: assets/serializers/asset_user.py:79 users/models/user.py:494 +#: assets/serializers/asset_user.py:79 users/models/user.py:500 msgid "Private key" msgstr "ssh私钥" @@ -999,8 +1000,8 @@ msgstr "Agent" #: audits/models.py:104 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: authentication/templates/authentication/login_otp.html:6 -#: users/forms/profile.py:52 users/models/user.py:489 -#: users/serializers/user.py:220 users/templates/users/user_detail.html:77 +#: users/forms/profile.py:52 users/models/user.py:495 +#: users/serializers/user.py:224 users/templates/users/user_detail.html:77 #: users/templates/users/user_profile.html:87 msgid "MFA" msgstr "多因子认证" @@ -1169,7 +1170,10 @@ msgstr "等待登录复核处理" msgid "Login confirm ticket was {}" msgstr "登录复核 {}" -#: authentication/forms.py:29 users/forms/user.py:199 +#: authentication/forms.py:26 authentication/forms.py:34 +#: authentication/templates/authentication/login.html:38 +#: authentication/templates/authentication/xpack_login.html:118 +#: users/forms/user.py:199 msgid "MFA code" msgstr "多因子认证验证码" @@ -1224,7 +1228,7 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: users/models/user.py:387 users/serializers/user.py:217 +#: users/models/user.py:393 users/serializers/user.py:221 #: users/templates/users/user_profile.html:94 #: users/templates/users/user_profile.html:163 #: users/templates/users/user_profile.html:166 @@ -1233,7 +1237,7 @@ msgid "Disable" msgstr "禁用" #: authentication/templates/authentication/_access_key_modal.html:67 -#: users/models/user.py:388 users/serializers/user.py:218 +#: users/models/user.py:394 users/serializers/user.py:222 #: users/templates/users/user_profile.html:92 #: users/templates/users/user_profile.html:170 msgid "Enable" @@ -1274,29 +1278,29 @@ msgid "Code error" msgstr "代码错误" #: authentication/templates/authentication/login.html:6 -#: authentication/templates/authentication/login.html:39 -#: authentication/templates/authentication/xpack_login.html:112 +#: authentication/templates/authentication/login.html:49 +#: authentication/templates/authentication/xpack_login.html:130 #: templates/_base_only_msg_content.html:51 templates/_header_bar.html:83 msgid "Login" msgstr "登录" #: authentication/templates/authentication/login.html:17 -#: authentication/templates/authentication/xpack_login.html:87 +#: authentication/templates/authentication/xpack_login.html:95 msgid "Captcha invalid" msgstr "验证码错误" -#: authentication/templates/authentication/login.html:50 -#: authentication/templates/authentication/xpack_login.html:116 +#: authentication/templates/authentication/login.html:60 +#: authentication/templates/authentication/xpack_login.html:134 #: users/templates/users/forgot_password.html:7 #: users/templates/users/forgot_password.html:8 msgid "Forgot password" msgstr "忘记密码" -#: authentication/templates/authentication/login.html:57 +#: authentication/templates/authentication/login.html:67 msgid "More login options" msgstr "更多登录方式" -#: authentication/templates/authentication/login.html:61 +#: authentication/templates/authentication/login.html:71 msgid "OpenID" msgstr "OpenID" @@ -1337,11 +1341,11 @@ msgstr "返回" msgid "Copy success" msgstr "复制成功" -#: authentication/templates/authentication/xpack_login.html:74 +#: authentication/templates/authentication/xpack_login.html:78 msgid "Welcome back, please enter username and password to login" msgstr "欢迎回来,请输入用户名和密码登录" -#: authentication/views/login.py:83 +#: authentication/views/login.py:82 msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" @@ -1602,11 +1606,11 @@ msgstr "命令 `{}` 不允许被执行 ......." msgid "Task end" msgstr "任务结束" -#: ops/tasks.py:68 +#: ops/tasks.py:71 msgid "Clean task history period" msgstr "定期清除任务历史" -#: ops/tasks.py:81 +#: ops/tasks.py:84 msgid "Clean celery log period" msgstr "定期清除Celery日志" @@ -1622,18 +1626,35 @@ msgstr "更新任务内容: {}" msgid "Disk used more than 80%: {} => {}" msgstr "磁盘使用率超过 80%: {} => {}" -#: orgs/api.py:57 +#: orgs/api.py:54 msgid "Organization contains undeleted resources" msgstr "组织内包含未删除的资源" -#: orgs/api.py:61 +#: orgs/api.py:58 msgid "The current organization cannot be deleted" msgstr "当能删除当前所在组织" -#: orgs/mixins/models.py:56 orgs/mixins/serializers.py:26 orgs/models.py:31 +#: orgs/mixins/models.py:56 orgs/mixins/serializers.py:26 orgs/models.py:40 +#: orgs/models.py:311 msgid "Organization" msgstr "组织" +#: orgs/models.py:15 +msgid "Organization administrator" +msgstr "组织管理员" + +#: orgs/models.py:17 +msgid "Organization auditor" +msgstr "组织审计员" + +#: orgs/models.py:313 users/forms/user.py:27 users/models/user.py:483 +#: users/templates/users/_select_user_modal.html:15 +#: users/templates/users/user_detail.html:73 +#: users/templates/users/user_list.html:16 +#: users/templates/users/user_profile.html:55 +msgid "Role" +msgstr "角色" + #: perms/const.py:7 msgid "Ungrouped" msgstr "未分组" @@ -1651,7 +1672,8 @@ msgstr "提示:RDP 协议不支持单独控制上传或下载文件" #: perms/forms/asset_permission.py:86 perms/forms/database_app_permission.py:41 #: perms/forms/remote_app_permission.py:43 perms/models/base.py:50 #: templates/_nav.html:21 users/forms/user.py:168 users/models/group.py:31 -#: users/models/user.py:473 users/templates/users/_select_user_modal.html:16 +#: users/models/user.py:479 users/serializers/user.py:43 +#: users/templates/users/_select_user_modal.html:16 #: users/templates/users/user_asset_permission.html:39 #: users/templates/users/user_asset_permission.html:67 #: users/templates/users/user_database_app_permission.html:38 @@ -1717,7 +1739,7 @@ msgid "Asset permission" msgstr "资产授权" #: perms/models/base.py:53 tickets/serializers/request_asset_perm.py:20 -#: users/models/user.py:505 users/templates/users/user_detail.html:93 +#: users/models/user.py:511 users/templates/users/user_detail.html:93 #: users/templates/users/user_profile.html:120 msgid "Date expired" msgstr "失效日期" @@ -2634,7 +2656,7 @@ msgstr "" " \n" " " -#: users/api/user.py:119 +#: users/api/user.py:126 msgid "Could not reset self otp, use profile reset instead" msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置" @@ -2680,7 +2702,7 @@ msgstr "确认密码" msgid "Password does not match" msgstr "密码不一致" -#: users/forms/profile.py:89 users/models/user.py:469 +#: users/forms/profile.py:89 users/models/user.py:475 #: users/templates/users/user_detail.html:57 #: users/templates/users/user_profile.html:59 msgid "Email" @@ -2716,20 +2738,12 @@ msgid "Public key should not be the same as your old one." msgstr "不能和原来的密钥相同" #: users/forms/profile.py:137 users/forms/user.py:90 -#: users/serializers/user.py:181 users/serializers/user.py:262 -#: users/serializers/user.py:320 +#: users/serializers/user.py:185 users/serializers/user.py:266 +#: users/serializers/user.py:324 msgid "Not a valid ssh public key" msgstr "SSH密钥不合法" -#: users/forms/user.py:27 users/models/user.py:477 -#: users/templates/users/_select_user_modal.html:15 -#: users/templates/users/user_detail.html:73 -#: users/templates/users/user_list.html:16 -#: users/templates/users/user_profile.html:55 -msgid "Role" -msgstr "角色" - -#: users/forms/user.py:31 users/models/user.py:512 +#: users/forms/user.py:31 users/models/user.py:518 #: users/templates/users/user_detail.html:89 #: users/templates/users/user_list.html:18 #: users/templates/users/user_profile.html:102 @@ -2749,105 +2763,105 @@ msgstr "添加到用户组" msgid "* Your password does not meet the requirements" msgstr "* 您的密码不符合要求" -#: users/forms/user.py:124 users/serializers/user.py:30 +#: users/forms/user.py:124 users/serializers/user.py:31 msgid "Reset link will be generated and sent to the user" msgstr "生成重置密码链接,通过邮件发送给用户" -#: users/forms/user.py:125 users/serializers/user.py:31 +#: users/forms/user.py:125 users/serializers/user.py:32 msgid "Set password" msgstr "设置密码" -#: users/forms/user.py:132 users/serializers/user.py:38 +#: users/forms/user.py:132 users/serializers/user.py:39 #: xpack/plugins/change_auth_plan/models.py:61 #: xpack/plugins/change_auth_plan/serializers.py:30 msgid "Password strategy" msgstr "密码策略" -#: users/models/user.py:159 users/models/user.py:623 -msgid "Administrator" -msgstr "管理员" +#: users/models/user.py:156 +msgid "Super administrator" +msgstr "超级管理员" -#: users/models/user.py:161 +#: users/models/user.py:158 +msgid "Super auditor" +msgstr "超级审计员" + +#: users/models/user.py:159 msgid "Application" msgstr "应用程序" -#: users/models/user.py:162 -msgid "Auditor" -msgstr "审计员" - -#: users/models/user.py:172 -msgid "Org admin" -msgstr "组织管理员" - -#: users/models/user.py:174 -msgid "Org auditor" -msgstr "组织审计员" - -#: users/models/user.py:389 users/templates/users/user_profile.html:90 +#: users/models/user.py:395 users/templates/users/user_profile.html:90 msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:456 +#: users/models/user.py:462 msgid "Local" msgstr "数据库" -#: users/models/user.py:480 +#: users/models/user.py:486 msgid "Avatar" msgstr "头像" -#: users/models/user.py:483 users/templates/users/user_detail.html:68 +#: users/models/user.py:489 users/templates/users/user_detail.html:68 msgid "Wechat" msgstr "微信" -#: users/models/user.py:516 +#: users/models/user.py:522 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:626 +#: users/models/user.py:631 +msgid "Administrator" +msgstr "管理员" + +#: users/models/user.py:634 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" -#: users/serializers/user.py:69 users/serializers/user.py:233 +#: users/serializers/user.py:72 users/serializers/user.py:237 msgid "Is first login" msgstr "首次登录" -#: users/serializers/user.py:70 +#: users/serializers/user.py:73 msgid "Is valid" msgstr "账户是否有效" -#: users/serializers/user.py:71 +#: users/serializers/user.py:74 msgid "Is expired" msgstr " 是否过期" -#: users/serializers/user.py:72 +#: users/serializers/user.py:75 msgid "Avatar url" msgstr "头像路径" -#: users/serializers/user.py:76 +#: users/serializers/user.py:79 msgid "Groups name" msgstr "用户组名" -#: users/serializers/user.py:77 +#: users/serializers/user.py:80 msgid "Source name" msgstr "用户来源名" -#: users/serializers/user.py:78 -msgid "Role name" -msgstr "角色名" +#: users/serializers/user.py:81 +msgid "Organization role name" +msgstr "组织角色名称" -#: users/serializers/user.py:101 +#: users/serializers/user.py:82 +msgid "Super role name" +msgstr "超级角色名称" + +#: users/serializers/user.py:105 msgid "Role limit to {}" msgstr "角色只能为 {}" -#: users/serializers/user.py:113 users/serializers/user.py:286 +#: users/serializers/user.py:117 users/serializers/user.py:290 msgid "Password does not match security rules" msgstr "密码不满足安全规则" -#: users/serializers/user.py:278 +#: users/serializers/user.py:282 msgid "The old password is incorrect" msgstr "旧密码错误" -#: users/serializers/user.py:292 +#: users/serializers/user.py:296 msgid "The newly set password is inconsistent" msgstr "两次密码不一致" @@ -3969,6 +3983,15 @@ msgstr "企业版" msgid "Ultimate edition" msgstr "旗舰版" +#~ msgid "Auditor" +#~ msgstr "审计员" + +#~ msgid "Org admin" +#~ msgstr "组织管理员" + +#~ msgid "Role name" +#~ msgstr "角色名" + #~ msgid "GUI copy" #~ msgstr "GUI 复制" @@ -5508,9 +5531,6 @@ msgstr "旗舰版" #~ msgid "Admin" #~ msgstr "管理员" -#~ msgid "Organizations" -#~ msgstr "组织管理" - #~ msgid "Org detail" #~ msgstr "组织详情" diff --git a/apps/orgs/api.py b/apps/orgs/api.py index d8ba32635..e29a14e22 100644 --- a/apps/orgs/api.py +++ b/apps/orgs/api.py @@ -1,23 +1,20 @@ # -*- coding: utf-8 -*- # -from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext as _ from rest_framework import status, generics from rest_framework.views import Response from rest_framework_bulk import BulkModelViewSet from common.permissions import IsSuperUserOrAppUser -from .models import Organization +from .models import Organization, ROLE from .serializers import OrgSerializer, OrgReadSerializer, \ - OrgMembershipUserSerializer, OrgMembershipAdminSerializer, \ OrgAllUserSerializer, OrgRetrieveSerializer from users.models import User, UserGroup from assets.models import Asset, Domain, AdminUser, SystemUser, Label from perms.models import AssetPermission from orgs.utils import current_org from common.utils import get_logger -from .mixins.api import OrgMembershipModelViewSetMixin logger = get_logger(__file__) @@ -39,7 +36,7 @@ class OrgViewSet(BulkModelViewSet): def get_data_from_model(self, model): if model == User: - data = model.objects.filter(related_user_orgs__id=self.org.id) + data = model.objects.filter(orgs__id=self.org.id, m2m_org_members__role=ROLE.USER) else: data = model.objects.filter(org_id=self.org.id) return data @@ -64,18 +61,6 @@ class OrgViewSet(BulkModelViewSet): return Response({'msg': True}, status=status.HTTP_200_OK) -class OrgMembershipAdminsViewSet(OrgMembershipModelViewSetMixin, BulkModelViewSet): - serializer_class = OrgMembershipAdminSerializer - membership_class = Organization.admins.through - permission_classes = (IsSuperUserOrAppUser, ) - - -class OrgMembershipUsersViewSet(OrgMembershipModelViewSetMixin, BulkModelViewSet): - serializer_class = OrgMembershipUserSerializer - membership_class = Organization.users.through - permission_classes = (IsSuperUserOrAppUser, ) - - class OrgAllUserListApi(generics.ListAPIView): permission_classes = (IsSuperUserOrAppUser,) serializer_class = OrgAllUserSerializer @@ -84,6 +69,7 @@ class OrgAllUserListApi(generics.ListAPIView): def get_queryset(self): pk = self.kwargs.get("pk") - org = get_object_or_404(Organization, pk=pk) - users = org.get_org_users().only(*self.serializer_class.Meta.only_fields) + users = User.objects.filter( + orgs=pk, m2m_org_members__role=ROLE.USER + ).only(*self.serializer_class.Meta.only_fields) return users diff --git a/apps/orgs/migrations/0004_organizationmember.py b/apps/orgs/migrations/0004_organizationmember.py new file mode 100644 index 000000000..6b43a5850 --- /dev/null +++ b/apps/orgs/migrations/0004_organizationmember.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.10 on 2020-07-21 11:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('orgs', '0003_auto_20190916_1057'), + ] + + operations = [ + migrations.CreateModel( + name='OrganizationMember', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('role', models.CharField(choices=[('Admin', 'Administrator'), ('User', 'User'), ('Auditor', 'Auditor')], default='User', max_length=16, verbose_name='Role')), + ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')), + ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m2m_org_members', to='orgs.Organization', verbose_name='Organization')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m2m_org_members', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'db_table': 'orgs_organization_members', + 'unique_together': {('org', 'user', 'role')}, + }, + ), + ] diff --git a/apps/orgs/migrations/0005_auto_20200721_1937.py b/apps/orgs/migrations/0005_auto_20200721_1937.py new file mode 100644 index 000000000..ac0ec8a08 --- /dev/null +++ b/apps/orgs/migrations/0005_auto_20200721_1937.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.10 on 2020-07-21 11:37 + +from django.db import migrations + + +def migrate_old_organization_members(apps, schema_editor): + org_model = apps.get_model("orgs", "Organization") + org_member_model = apps.get_model('orgs', 'OrganizationMember') + orgs = org_model.objects.all() + + roles = ['User', 'Auditor', 'Admin'] + + for org in orgs: + users = org.users.all().only('id') + auditors = org.auditors.all().only('id') + admins = org.admins.all().only('id') + total_members = zip([users, auditors, admins], roles) + + org_members = [] + for members, role in total_members: + for user in members: + org_user = org_member_model(user=user, org=org, role=role) + org_members.append(org_user) + org_member_model.objects.bulk_create(org_members) + + +class Migration(migrations.Migration): + + dependencies = [ + ('orgs', '0004_organizationmember'), + ] + + operations = [ + migrations.RunPython(migrate_old_organization_members) + ] diff --git a/apps/orgs/migrations/0006_auto_20200721_1937.py b/apps/orgs/migrations/0006_auto_20200721_1937.py new file mode 100644 index 000000000..fe0b1f477 --- /dev/null +++ b/apps/orgs/migrations/0006_auto_20200721_1937.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.10 on 2020-07-21 11:37 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('orgs', '0005_auto_20200721_1937'), + ] + + operations = [ + migrations.RemoveField( + model_name='organization', + name='admins', + ), + migrations.RemoveField( + model_name='organization', + name='auditors', + ), + migrations.RemoveField( + model_name='organization', + name='users', + ), + migrations.AddField( + model_name='organization', + name='members', + field=models.ManyToManyField(related_name='orgs', through='orgs.OrganizationMember', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/orgs/models.py b/apps/orgs/models.py index 6e6e6dfd6..effabf50f 100644 --- a/apps/orgs/models.py +++ b/apps/orgs/models.py @@ -1,21 +1,30 @@ import uuid -from django.conf import settings +from functools import partial from django.db import models +from django.db.models import signals +from django.db.models import Q from django.utils.translation import ugettext_lazy as _ -from common.utils import is_uuid, lazyproperty +from common.utils import is_uuid +from common.const import choices +from common.db.models import ChoiceSet + + +class ROLE(ChoiceSet): + ADMIN = choices.ADMIN, _('Organization administrator') + USER = choices.USER, _('User') + AUDITOR = choices.AUDITOR, _("Organization auditor") class Organization(models.Model): id = models.UUIDField(default=uuid.uuid4, primary_key=True) name = models.CharField(max_length=128, unique=True, verbose_name=_("Name")) - users = models.ManyToManyField('users.User', related_name='related_user_orgs', blank=True) - admins = models.ManyToManyField('users.User', related_name='related_admin_orgs', blank=True) - auditors = models.ManyToManyField('users.User', related_name='related_audit_orgs', blank=True) created_by = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by')) date_created = models.DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created')) comment = models.TextField(max_length=128, default='', blank=True, verbose_name=_('Comment')) + members = models.ManyToManyField('users.User', related_name='orgs', through='orgs.OrganizationMember', + through_fields=('org', 'user')) orgs = None CACHE_PREFIX = 'JMS_ORG_{}' @@ -72,29 +81,24 @@ class Organization(models.Model): org = cls.default() if default else None return org - # @lazyproperty - # lazyproperty 导致用户列表中角色显示出现不稳定的情况, 如果不加会导致数据库操作次数太多 - def org_users(self): + def get_org_members_by_role(self, role): from users.models import User if self.is_real(): - return self.users.all() - users = User.objects.filter(role=User.ROLE_USER) - if self.is_default() and not settings.DEFAULT_ORG_SHOW_ALL_USERS: - users = users.filter(related_user_orgs__isnull=True) + return self.members.filter(m2m_org_members__role=role) + users = User.objects.filter(role=role) return users - def get_org_users(self): - return self.org_users() + @property + def users(self): + return self.get_org_members_by_role(ROLE.USER) - # @lazyproperty - def org_admins(self): - from users.models import User - if self.is_real(): - return self.admins.all() - return User.objects.filter(role=User.ROLE_ADMIN) + @property + def admins(self): + return self.get_org_members_by_role(ROLE.ADMIN) - def get_org_admins(self): - return self.org_admins() + @property + def auditors(self): + return self.get_org_members_by_role(ROLE.AUDITOR) def org_id(self): if self.is_real(): @@ -104,87 +108,76 @@ class Organization(models.Model): else: return '' - # @lazyproperty - def org_auditors(self): + def get_members(self, exclude=()): from users.models import User if self.is_real(): - return self.auditors.all() - return User.objects.filter(role=User.ROLE_AUDITOR) + members = self.members.exclude(m2m_org_members__role__in=exclude) + else: + members = User.objects.exclude(role__in=exclude) - def get_org_auditors(self): - return self.org_auditors() - - def get_org_members(self, exclude=()): - from users.models import User - members = User.objects.none() - if 'Admin' not in exclude: - members |= self.get_org_admins() - if 'User' not in exclude: - members |= self.get_org_users() - if 'Auditor' not in exclude: - members |= self.get_org_auditors() - return members.exclude(role=User.ROLE_APP).distinct() + return members.exclude(role=User.ROLE.APP).distinct() def can_admin_by(self, user): if user.is_superuser: return True - if self.get_org_admins().filter(id=user.id): + if self.admins.filter(id=user.id).exists(): return True return False def can_audit_by(self, user): if user.is_super_auditor: return True - if self.get_org_auditors().filter(id=user.id): + if self.auditors.filter(id=user.id).exists(): return True return False def can_user_by(self, user): - if self.get_org_users().filter(id=user.id): + if self.users.filter(id=user.id).exists(): return True return False def is_real(self): return self.id not in (self.DEFAULT_NAME, self.ROOT_ID, self.SYSTEM_ID) + @classmethod + def get_user_orgs_by_role(cls, user, role): + if not isinstance(role, (tuple, list)): + role = (role, ) + + return cls.objects.filter( + m2m_org_members__role__in=role, + m2m_org_members__user_id=user.id + ).distinct() + @classmethod def get_user_admin_orgs(cls, user): - admin_orgs = [] if user.is_anonymous: - return admin_orgs - elif user.is_superuser: - admin_orgs = list(cls.objects.all()) - admin_orgs.append(cls.default()) - elif user.is_org_admin: - admin_orgs = user.related_admin_orgs.all() - return admin_orgs + return cls.objects.none() + if user.is_superuser: + return [*cls.objects.all(), cls.default()] + return cls.get_user_orgs_by_role(user, ROLE.ADMIN) @classmethod def get_user_user_orgs(cls, user): - user_orgs = [] if user.is_anonymous: - return user_orgs - user_orgs = user.related_user_orgs.all() - return user_orgs + return cls.objects.none() + return cls.get_user_orgs_by_role(user, ROLE.USER) @classmethod def get_user_audit_orgs(cls, user): - audit_orgs = [] if user.is_anonymous: - return audit_orgs - elif user.is_super_auditor: - audit_orgs = list(cls.objects.all()) - audit_orgs.append(cls.default()) - elif user.is_org_auditor: - audit_orgs = user.related_audit_orgs.all() - return audit_orgs + return cls.objects.none() + if user.is_super_auditor: + return [*cls.objects.all(), cls.default()] + return cls.get_user_orgs_by_role(user, ROLE.AUDITOR) @classmethod - def get_user_admin_or_audit_orgs(self, user): - admin_orgs = self.get_user_admin_orgs(user) - audit_orgs = self.get_user_audit_orgs(user) - orgs = set(admin_orgs) | set(audit_orgs) - return orgs + def get_user_admin_or_audit_orgs(cls, user): + if user.is_anonymous: + return cls.objects.none() + if user.is_superuser or user.is_super_auditor: + return [*cls.objects.all(), cls.default()] + return cls.get_user_orgs_by_role(user, (ROLE.AUDITOR, ROLE.ADMIN)) @classmethod def default(cls): @@ -211,8 +204,122 @@ class Organization(models.Model): from .utils import set_current_org set_current_org(self) - @classmethod - def all_orgs(cls): - orgs = list(cls.objects.all()) - orgs.append(cls.default()) - return orgs + +def _convert_to_uuid_set(users): + rst = set() + for user in users: + if isinstance(user, models.Model): + rst.add(user.id) + elif not isinstance(user, uuid.UUID): + rst.add(uuid.UUID(user)) + return rst + + +def _none2list(*args): + return ([] if v is None else v for v in args) + + +class OrgMemeberManager(models.Manager): + + def remove_users_by_role(self, org, users=None, admins=None, auditors=None): + if not any((users, admins, auditors)): + return + users, admins, auditors = _none2list(users, admins, auditors) + + send = partial(signals.m2m_changed.send, sender=self.model, instance=org, reverse=False, + model=Organization, pk_set=[*users, *admins, *auditors], using=self.db) + + send(action="pre_remove") + self.filter(org_id=org.id).filter( + Q(user__in=users, role=ROLE.USER) | + Q(user__in=admins, role=ROLE.ADMIN) | + Q(user__in=auditors, role=ROLE.AUDITOR) + ).delete() + send(action="post_remove") + + def add_users_by_role(self, org, users=None, admins=None, auditors=None): + if not any((users, admins, auditors)): + return + users, admins, auditors = _none2list(users, admins, auditors) + + add_mapper = ( + (users, ROLE.USER), + (admins, ROLE.ADMIN), + (auditors, ROLE.AUDITOR) + ) + + oms_add = [] + for users, role in add_mapper: + for user in users: + if isinstance(user, models.Model): + user = user.id + oms_add.append(self.model(org_id=org.id, user_id=user, role=role)) + + send = partial(signals.m2m_changed.send, sender=self.model, instance=org, reverse=False, + model=Organization, pk_set=[*users, *admins, *auditors], using=self.db) + + send(action='pre_add') + self.bulk_create(oms_add) + send(action='post_add') + + def _get_remove_add_set(self, new_users, old_users): + if new_users is None: + return None, None + new_users = _convert_to_uuid_set(new_users) + return (old_users - new_users), (new_users - old_users) + + def set_users_by_role(self, org, users=None, admins=None, auditors=None): + oms = self.filter(org_id=org.id).values_list('role', 'user_id') + + old_users, old_admins, old_auditors = set(), set(), set() + + mapper = { + ROLE.USER: old_users, + ROLE.ADMIN: old_admins, + ROLE.AUDITOR: old_auditors + } + + for role, user_id in oms: + if role in mapper: + mapper[role].add(user_id) + + users_remove, users_add = self._get_remove_add_set(users, old_users) + admins_remove, admins_add = self._get_remove_add_set(admins, old_admins) + auditors_remove, auditors_add = self._get_remove_add_set(auditors, old_auditors) + + self.remove_users_by_role( + org, + users_remove, + admins_remove, + auditors_remove + ) + + self.add_users_by_role( + org, + users_add, + admins_add, + auditors_add + ) + + +class OrganizationMember(models.Model): + """ + 注意:直接调用该 `Model.delete` `Model.objects.delete` 不会触发清理该用户的信号 + """ + + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + org = models.ForeignKey(Organization, related_name='m2m_org_members', on_delete=models.CASCADE, verbose_name=_('Organization')) + user = models.ForeignKey('users.User', related_name='m2m_org_members', on_delete=models.CASCADE, verbose_name=_('User')) + role = models.CharField(max_length=16, choices=ROLE.choices, default=ROLE.USER, verbose_name=_("Role")) + date_created = models.DateTimeField(auto_now_add=True, verbose_name=_("Date created")) + date_updated = models.DateTimeField(auto_now=True, verbose_name=_("Date updated")) + created_by = models.CharField(max_length=128, null=True, verbose_name=_('Created by')) + + objects = OrgMemeberManager() + + class Meta: + unique_together = [('org', 'user', 'role')] + db_table = 'orgs_organization_members' + + def __str__(self): + return '{} is {}: {}'.format(self.user.name, self.org.name, self.role) diff --git a/apps/orgs/serializers.py b/apps/orgs/serializers.py index ae98420e0..4cf54f92a 100644 --- a/apps/orgs/serializers.py +++ b/apps/orgs/serializers.py @@ -1,16 +1,18 @@ from rest_framework.serializers import ModelSerializer from rest_framework import serializers -from users.models import UserGroup -from assets.models import Asset, Domain, AdminUser, SystemUser, Label -from perms.models import AssetPermission + +from users.models.user import User from common.serializers import AdaptedBulkListSerializer -from .utils import set_current_org, get_current_org -from .models import Organization +from .models import Organization, OrganizationMember from .mixins.serializers import OrgMembershipSerializerMixin class OrgSerializer(ModelSerializer): + users = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True) + admins = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True) + auditors = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True) + class Meta: model = Organization list_serializer_class = AdaptedBulkListSerializer @@ -21,11 +23,27 @@ class OrgSerializer(ModelSerializer): fields_m2m = ['users', 'admins', 'auditors'] fields = fields_small + fields_m2m read_only_fields = ['created_by', 'date_created'] - extra_kwargs = { - 'admins': {'write_only': True}, - 'users': {'write_only': True}, - 'auditors': {'write_only': True}, - } + + def create(self, validated_data): + members = self._pop_memebers(validated_data) + instance = Organization.objects.create(**validated_data) + OrganizationMember.objects.add_users_by_role(instance, *members) + return instance + + def _pop_memebers(self, validated_data): + return ( + validated_data.pop('users', None), + validated_data.pop('admins', None), + validated_data.pop('auditors', None) + ) + + def update(self, instance, validated_data): + members = self._pop_memebers(validated_data) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + OrganizationMember.objects.set_users_by_role(instance, *members) + return instance class OrgReadSerializer(OrgSerializer): @@ -34,14 +52,14 @@ class OrgReadSerializer(OrgSerializer): class OrgMembershipAdminSerializer(OrgMembershipSerializerMixin, ModelSerializer): class Meta: - model = Organization.admins.through + model = Organization.members.through list_serializer_class = AdaptedBulkListSerializer fields = '__all__' class OrgMembershipUserSerializer(OrgMembershipSerializerMixin, ModelSerializer): class Meta: - model = Organization.users.through + model = Organization.members.through list_serializer_class = AdaptedBulkListSerializer fields = '__all__' diff --git a/apps/orgs/signals_handler.py b/apps/orgs/signals_handler.py index 17e3d525e..eb7df9741 100644 --- a/apps/orgs/signals_handler.py +++ b/apps/orgs/signals_handler.py @@ -5,7 +5,7 @@ from django.db.models.signals import m2m_changed from django.db.models.signals import post_save from django.dispatch import receiver -from .models import Organization +from .models import Organization, OrganizationMember from .hands import set_current_org, current_org, Node, get_current_org from perms.models import AssetPermission from users.models import UserGroup @@ -26,23 +26,31 @@ def on_org_create_or_update(sender, instance=None, created=False, **kwargs): instance.expire_cache() -@receiver(m2m_changed, sender=Organization.users.through) -def on_org_user_changed(sender, instance=None, **kwargs): - if isinstance(instance, Organization): - old_org = current_org - set_current_org(instance) - if kwargs['action'] == 'pre_remove': - users = kwargs['model'].objects.filter(pk__in=kwargs['pk_set']) - for user in users: - perms = AssetPermission.objects.filter(users=user) - user_groups = UserGroup.objects.filter(users=user) - for perm in perms: - perm.users.remove(user) - for user_group in user_groups: - user_group.users.remove(user) - set_current_org(old_org) +def _remove_users(model, users, org, reverse=False): + if not isinstance(users, (tuple, list, set)): + users = (users, ) + + m2m_model = model.users.through + if reverse: + m2m_field_name = model.users.field.m2m_reverse_field_name() + else: + m2m_field_name = model.users.field.m2m_field_name() + m2m_model.objects.filter(**{'user__in': users, f'{m2m_field_name}__org_id': org.id}).delete() -@receiver(m2m_changed, sender=Organization.admins.through) -def on_org_admin_change(sender, **kwargs): - Organization._user_admin_orgs = None +def _clear_users_from_org(org, users): + if not users: + return + + old_org = current_org + set_current_org(org) + _remove_users(AssetPermission, users, org) + _remove_users(UserGroup, users, org, reverse=True) + set_current_org(old_org) + + +@receiver(m2m_changed, sender=OrganizationMember) +def on_org_user_changed(sender, instance=None, action=None, pk_set=None, **kwargs): + if action == 'post_remove': + leaved_users = set(pk_set) - set(instance.members.values_list('id', flat=True)) + _clear_users_from_org(instance, leaved_users) diff --git a/apps/orgs/tests.py b/apps/orgs/tests.py index 7ce503c2d..1b007fe3d 100644 --- a/apps/orgs/tests.py +++ b/apps/orgs/tests.py @@ -1,3 +1,16 @@ -from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status + +from users.models.user import User + + +class OrgTests(APITestCase): + def test_create(self): + print(User.objects.all()) + reverse('api-orgs:org-list') + + + +{"name":"a-07","admins":["138167d2-6843-4e25-b838-59657157c6c6"],"auditors":["8d4b3ec4-8339-4a2c-b33c-c2633da62c84"],"users":["ea60e8ce-876d-493b-a641-ff836258629c"]} -# Create your tests here. diff --git a/apps/orgs/urls/api_urls.py b/apps/orgs/urls/api_urls.py index 17f8c3c8a..e14435868 100644 --- a/apps/orgs/urls/api_urls.py +++ b/apps/orgs/urls/api_urls.py @@ -11,12 +11,6 @@ from .. import api app_name = 'orgs' router = DefaultRouter() -# 将会删除 -router.register(r'orgs/(?P[0-9a-zA-Z\-]{36})/membership/admins', - api.OrgMembershipAdminsViewSet, 'membership-admins') -router.register(r'orgs/(?P[0-9a-zA-Z\-]{36})/membership/users', - api.OrgMembershipUsersViewSet, 'membership-users'), - router.register(r'orgs', api.OrgViewSet, 'org') old_version_urlpatterns = [ diff --git a/apps/users/api/mixins.py b/apps/users/api/mixins.py index 117c2c28a..0e81bd8d2 100644 --- a/apps/users/api/mixins.py +++ b/apps/users/api/mixins.py @@ -9,7 +9,7 @@ from orgs.utils import current_org class UserQuerysetMixin: def get_queryset(self): if self.request.query_params.get('all') or not current_org.is_real(): - queryset = User.objects.exclude(role=User.ROLE_APP) + queryset = User.objects.exclude(role=User.ROLE.APP) else: queryset = utils.get_current_org_members() return queryset diff --git a/apps/users/api/user.py b/apps/users/api/user.py index 3ad748316..b1c039318 100644 --- a/apps/users/api/user.py +++ b/apps/users/api/user.py @@ -1,11 +1,13 @@ # ~*~ coding: utf-8 ~*~ from django.core.cache import cache +from django.db.models import CharField from django.utils.translation import ugettext as _ from rest_framework import generics from rest_framework.response import Response from rest_framework_bulk import BulkModelViewSet +from common.db.aggregates import GroupConcat from common.permissions import ( IsOrgAdmin, IsOrgAdminOrAppUser, CanUpdateDeleteUser, IsSuperUser @@ -13,6 +15,7 @@ from common.permissions import ( from common.mixins import CommonApiMixin from common.utils import get_logger from orgs.utils import current_org +from orgs.models import ROLE as ORG_ROLE, OrganizationMember from .. import serializers from ..serializers import UserSerializer, UserRetrieveSerializer from .mixins import UserQuerysetMixin @@ -39,7 +42,11 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet): extra_filter_backends = [OrgRoleUserFilterBackend] def get_queryset(self): - return super().get_queryset().prefetch_related('groups') + return super().get_queryset().annotate( + gc_m2m_org_members__role=GroupConcat('m2m_org_members__role'), + gc_groups__name=GroupConcat('groups__name'), + gc_groups=GroupConcat('groups__id', output_field=CharField()) + ) def send_created_signal(self, users): if not isinstance(users, list): @@ -48,11 +55,32 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet): post_user_create.send(self.__class__, user=user) def perform_create(self, serializer): + validated_data = serializer.validated_data + if isinstance(validated_data, list): + org_roles = [item.pop('org_role', None) for item in validated_data] + else: + org_roles = [validated_data.pop('org_role', None)] + users = serializer.save() if isinstance(users, User): users = [users] if current_org and current_org.is_real(): - current_org.users.add(*users) + mapper = { + ORG_ROLE.USER: [], + ORG_ROLE.ADMIN: [], + ORG_ROLE.AUDITOR: [] + } + + for user, role in zip(users, org_roles): + if role in mapper: + mapper[role].append(user) + else: + mapper[ORG_ROLE.USER].append(user) + OrganizationMember.objects.set_users_by_role( + current_org, users=mapper[ORG_ROLE.USER], + admins=mapper[ORG_ROLE.ADMIN], + auditors=mapper[ORG_ROLE.AUDITOR] + ) self.send_created_signal(users) def get_permissions(self): @@ -78,7 +106,7 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet): users_ids = [ d.get("id") or d.get("pk") for d in serializer.validated_data ] - users = current_org.get_org_members().filter(id__in=users_ids) + users = current_org.get_members().filter(id__in=users_ids) for user in users: self.check_object_permissions(self.request, user) return super().perform_bulk_update(serializer) diff --git a/apps/users/filters.py b/apps/users/filters.py index d12d3234e..0aa46609e 100644 --- a/apps/users/filters.py +++ b/apps/users/filters.py @@ -12,13 +12,13 @@ class OrgRoleUserFilterBackend(filters.BaseFilterBackend): return queryset if org_role == 'admins': - return queryset & (current_org.get_org_admins() | User.objects.filter(role=User.ROLE_ADMIN)) + return queryset & (current_org.admins | User.objects.filter(role=User.ROLE_ADMIN)) elif org_role == 'auditors': - return queryset & current_org.get_org_auditors() + return queryset & current_org.auditors elif org_role == 'users': - return queryset & current_org.get_org_users() + return queryset & current_org.users elif org_role == 'members': - return queryset & current_org.get_org_members() + return queryset & current_org.get_members() def get_schema_fields(self, view): return [ diff --git a/apps/users/forms/user.py b/apps/users/forms/user.py index a58e1fef1..e02aaf17e 100644 --- a/apps/users/forms/user.py +++ b/apps/users/forms/user.py @@ -17,14 +17,14 @@ __all__ = [ class UserCreateUpdateFormMixin(OrgModelForm): - role_choices = ((i, n) for i, n in User.ROLE_CHOICES if i != User.ROLE_APP) + role_choices = ((i, n) for i, n in User.ROLE.choices if i != User.ROLE.APP) password = forms.CharField( label=_('Password'), widget=forms.PasswordInput, max_length=128, strip=False, required=False, ) role = forms.ChoiceField( choices=role_choices, required=True, - initial=User.ROLE_USER, label=_("Role") + initial=User.ROLE.USER, label=_("Role") ) source = forms.ChoiceField( choices=get_source_choices, required=True, diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 89bfe4254..456cd1fbc 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -18,8 +18,10 @@ from django.shortcuts import reverse from common.local import LOCAL_DYNAMIC_SETTINGS from orgs.utils import current_org -from common.utils import signer, date_expired_default, get_logger, lazyproperty +from common.utils import date_expired_default, get_logger, lazyproperty from common import fields +from common.const import choices +from common.db.models import ChoiceSet from ..signals import post_user_change_password @@ -150,45 +152,58 @@ class AuthMixin: class RoleMixin: - ROLE_ADMIN = 'Admin' - ROLE_USER = 'User' - ROLE_APP = 'App' - ROLE_AUDITOR = 'Auditor' + class ROLE(ChoiceSet): + ADMIN = choices.ADMIN, _('Super administrator') + USER = choices.USER, _('User') + AUDITOR = choices.AUDITOR, _('Super auditor') + APP = 'App', _('Application') - ROLE_CHOICES = ( - (ROLE_ADMIN, _('Administrator')), - (ROLE_USER, _('User')), - (ROLE_APP, _('Application')), - (ROLE_AUDITOR, _("Auditor")) - ) - role = ROLE_USER + role = ROLE.USER @property - def role_display(self): + def super_role_display(self): + return self.get_role_display() + + @property + def org_role_display(self): + from orgs.models import ROLE as ORG_ROLE + if not current_org.is_real(): - return self.get_role_display() - roles = [] - if self in current_org.get_org_admins(): - roles.append(str(_('Org admin'))) - if self in current_org.get_org_auditors(): - roles.append(str(_('Org auditor'))) - if self in current_org.get_org_users(): - roles.append(str(_('User'))) - return " | ".join(roles) + if self.is_superuser: + return ORG_ROLE.ADMIN.label + else: + return ORG_ROLE.USER.label + + if hasattr(self, 'gc_m2m_org_members__role'): + names = self.gc_m2m_org_members__role + if isinstance(names, str): + roles = set(self.gc_m2m_org_members__role.split(',')) + else: + roles = set() + else: + roles = set(self.m2m_org_members.filter( + org_id=current_org.id + ).values_list('role', flat=True)) + + return ' | '.join([str(ORG_ROLE[role]) for role in roles if role in ORG_ROLE]) def current_org_roles(self): - roles = [] - if self.can_admin_current_org: - roles.append('Admin') - if self.can_audit_current_org: - roles.append('Auditor') - else: - roles.append('User') + from orgs.models import OrganizationMember, ROLE as ORG_ROLE + if not current_org.is_real(): + if self.is_superuser: + return [ORG_ROLE.ADMIN] + else: + return [ORG_ROLE.USER] + + roles = list(set(OrganizationMember.objects.filter( + org_id=current_org.id, user=self + ).values_list('role', flat=True))) + return roles @property def is_superuser(self): - if self.role == 'Admin': + if self.role == self.ROLE.ADMIN: return True else: return False @@ -196,13 +211,13 @@ class RoleMixin: @is_superuser.setter def is_superuser(self, value): if value is True: - self.role = 'Admin' + self.role = self.ROLE.ADMIN else: - self.role = 'User' + self.role = self.ROLE.USER @property def is_super_auditor(self): - return self.role == 'Auditor' + return self.role == self.ROLE.AUDITOR @property def is_common_user(self): @@ -216,7 +231,7 @@ class RoleMixin: @property def is_app(self): - return self.role == 'App' + return self.role == self.ROLE.APP @lazyproperty def user_orgs(self): @@ -240,14 +255,16 @@ class RoleMixin: @lazyproperty def is_org_admin(self): - if self.is_superuser or self.related_admin_orgs.exists(): + from orgs.models import ROLE as ORG_ROLE + if self.is_superuser or self.m2m_org_members.filter(role=ORG_ROLE.ADMIN).exists(): return True else: return False @lazyproperty def is_org_auditor(self): - if self.is_super_auditor or self.related_audit_orgs.exists(): + from orgs.models import ROLE as ORG_ROLE + if self.is_super_auditor or self.m2m_org_members.filter(role=ORG_ROLE.AUDITOR).exists(): return True else: return False @@ -283,7 +300,7 @@ class RoleMixin: def create_app_user(cls, name, comment): app = cls.objects.create( username=name, name=name, email='{}@local.domain'.format(name), - is_active=False, role='App', comment=comment, + is_active=False, role=cls.ROLE.APP, comment=comment, is_first_login=False, created_by='System' ) access_key = app.create_access_key() @@ -473,7 +490,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): blank=True, verbose_name=_('User group') ) role = models.CharField( - choices=RoleMixin.ROLE_CHOICES, default='User', max_length=10, + choices=RoleMixin.ROLE.choices, default='User', max_length=10, blank=True, verbose_name=_('Role') ) avatar = models.ImageField( @@ -526,6 +543,12 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): @property def groups_display(self): + if hasattr(self, 'gc_groups__name'): + names = self.gc_groups__name + if isinstance(names, str): + return ' '.join(set(self.gc_groups__name.split(','))) + else: + return '' return ' '.join([group.name for group in self.groups.all()]) @property @@ -646,7 +669,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): email=forgery_py.internet.email_address(), name=forgery_py.name.full_name(), password=make_password(forgery_py.lorem_ipsum.word()), - role=choice(list(dict(User.ROLE_CHOICES).keys())), + role=choice(list(dict(User.ROLE.choices).keys())), wechat=forgery_py.internet.user_name(True), comment=forgery_py.lorem_ipsum.sentence(), created_by=choice(cls.objects.all()).username) diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index bebc5042b..76485eb93 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -9,7 +9,8 @@ from common.utils import validate_ssh_public_key from common.mixins import CommonBulkSerializerMixin from common.serializers import AdaptedBulkListSerializer from common.permissions import CanUpdateDeleteUser -from ..models import User +from common.drf.fields import GroupConcatedPrimaryKeyRelatedField +from ..models import User, UserGroup __all__ = [ @@ -38,10 +39,16 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): label=_('Password strategy'), write_only=True ) mfa_level_display = serializers.ReadOnlyField(source='get_mfa_level_display') + groups = GroupConcatedPrimaryKeyRelatedField( + label=_('User group'), many=True, queryset=UserGroup.objects.all(), required=False + ) login_blocked = serializers.SerializerMethodField() can_update = serializers.SerializerMethodField() can_delete = serializers.SerializerMethodField() - + org_role = serializers.CharField( + label=_('Organization role name'), write_only=True, + allow_null=True, required=False, allow_blank=True + ) key_prefix_block = "_LOGIN_BLOCK_{}" class Meta: @@ -52,7 +59,7 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): # small 指的是 不需要计算的直接能从一张表中获取到的数据 fields_small = fields_mini + [ 'password', 'email', 'public_key', 'wechat', 'phone', 'mfa_level', 'mfa_enabled', - 'mfa_level_display', 'mfa_force_enabled', + 'mfa_level_display', 'mfa_force_enabled', 'super_role_display', 'comment', 'source', 'is_valid', 'is_expired', 'is_active', 'created_by', 'is_first_login', 'password_strategy', 'date_password_last_updated', 'date_expired', @@ -60,7 +67,7 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): ] fields = fields_small + [ 'groups', 'role', 'groups_display', 'role_display', - 'can_update', 'can_delete', 'login_blocked', + 'can_update', 'can_delete', 'login_blocked', 'org_role' ] extra_kwargs = { @@ -75,7 +82,8 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): 'can_delete': {'read_only': True}, 'groups_display': {'label': _('Groups name')}, 'source_display': {'label': _('Source name')}, - 'role_display': {'label': _('Role name')}, + 'role_display': {'label': _('Organization role name'), 'source': 'org_role_display'}, + 'super_role_display': {'label': _('Super role name')}, } def __init__(self, *args, **kwargs): @@ -87,17 +95,17 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): if not role: return choices = role._choices - choices.pop(User.ROLE_APP, None) + choices.pop(User.ROLE.APP, None) request = self.context.get('request') if request and hasattr(request, 'user') and not request.user.is_superuser: - choices.pop(User.ROLE_ADMIN, None) - choices.pop(User.ROLE_AUDITOR, None) + choices.pop(User.ROLE.ADMIN, None) + choices.pop(User.ROLE.AUDITOR, None) role._choices = choices def validate_role(self, value): request = self.context.get('request') - if not request.user.is_superuser and value != User.ROLE_USER: - role_display = dict(User.ROLE_CHOICES)[User.ROLE_USER] + if not request.user.is_superuser and value != User.ROLE.USER: + role_display = User.ROLE.USER.label msg = _("Role limit to {}".format(role_display)) raise serializers.ValidationError(msg) return value @@ -121,7 +129,7 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): role = self.initial_data.get('role') if self.instance: role = role or self.instance.role - if role == User.ROLE_AUDITOR: + if role == User.ROLE.AUDITOR: return [] return groups diff --git a/apps/users/templates/users/user_detail.html b/apps/users/templates/users/user_detail.html index e9bdf8b92..0b624b78a 100644 --- a/apps/users/templates/users/user_detail.html +++ b/apps/users/templates/users/user_detail.html @@ -71,7 +71,7 @@ {% endif %} {% trans 'Role' %}: - {{ object.role_display }} + {{ object.org_role_display }} {% trans 'MFA' %}: diff --git a/apps/users/utils.py b/apps/users/utils.py index af6e0197b..cf6be4c67 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -315,7 +315,7 @@ def construct_user_email(username, email): def get_current_org_members(exclude=()): from orgs.utils import current_org - return current_org.get_org_members(exclude=exclude) + return current_org.get_members(exclude=exclude) def get_source_choices():