mirror of https://github.com/jumpserver/jumpserver
commit
0763404235
56
README.md
56
README.md
|
@ -25,7 +25,8 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||||
- 无插件: 仅需浏览器,极致的 Web Terminal 使用体验;
|
- 无插件: 仅需浏览器,极致的 Web Terminal 使用体验;
|
||||||
- 多云支持: 一套系统,同时管理不同云上面的资产;
|
- 多云支持: 一套系统,同时管理不同云上面的资产;
|
||||||
- 云端存储: 审计录像云端存储,永不丢失;
|
- 云端存储: 审计录像云端存储,永不丢失;
|
||||||
- 多租户: 一套系统,多个子公司和部门同时使用。
|
- 多租户: 一套系统,多个子公司和部门同时使用;
|
||||||
|
- 多应用支持: 数据库,Windows远程应用,Kubernetes。
|
||||||
|
|
||||||
## 版本说明
|
## 版本说明
|
||||||
|
|
||||||
|
@ -198,6 +199,54 @@ v2.1.0 是 v2.0.0 之后的功能版本。
|
||||||
<td>文件传输</td>
|
<td>文件传输</td>
|
||||||
<td>可对文件的上传、下载记录进行审计</td>
|
<td>可对文件的上传、下载记录进行审计</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td rowspan="20">数据库审计<br>Database</td>
|
||||||
|
<td rowspan="2">连接方式</td>
|
||||||
|
<td>命令方式</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Web UI方式 (X-PACK)</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td rowspan="4">支持的数据库</td>
|
||||||
|
<td>MySQL</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Oracle (X-PACK)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>MariaDB (X-PACK)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>PostgreSQL (X-PACK)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td rowspan="6">功能亮点</td>
|
||||||
|
<td>语法高亮</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SQL格式化</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>支持快捷键</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>支持选中执行</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SQL历史查询</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>支持页面创建 DB, TABLE</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td rowspan="2">会话审计</td>
|
||||||
|
<td>命令记录</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>录像回放</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
@ -212,6 +261,11 @@ v2.1.0 是 v2.0.0 之后的功能版本。
|
||||||
- [Koko](https://github.com/jumpserver/koko) JumpServer 字符协议 Connector 项目,替代原来 Python 版本的 [Coco](https://github.com/jumpserver/coco)
|
- [Koko](https://github.com/jumpserver/koko) JumpServer 字符协议 Connector 项目,替代原来 Python 版本的 [Coco](https://github.com/jumpserver/coco)
|
||||||
- [Guacamole](https://github.com/jumpserver/docker-guacamole) JumpServer 图形协议 Connector 项目,依赖 [Apache Guacamole](https://guacamole.apache.org/)
|
- [Guacamole](https://github.com/jumpserver/docker-guacamole) JumpServer 图形协议 Connector 项目,依赖 [Apache Guacamole](https://guacamole.apache.org/)
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
- [Apache Guacamole](https://guacamole.apache.org/) Web页面连接 RDP, SSH, VNC协议设备,JumpServer 图形化连接依赖
|
||||||
|
- [OmniDB](https://omnidb.org/) Web页面连接使用数据库,JumpServer Web数据库依赖
|
||||||
|
|
||||||
|
|
||||||
## JumpServer 企业版
|
## JumpServer 企业版
|
||||||
- [申请企业版试用](https://jinshuju.net/f/kyOYpi)
|
- [申请企业版试用](https://jinshuju.net/f/kyOYpi)
|
||||||
> 注:企业版支持离线安装,申请通过后会提供高速下载链接。
|
> 注:企业版支持离线安装,申请通过后会提供高速下载链接。
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 2.2.13 on 2020-11-16 09:57
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('assets', '0060_node_full_value'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='node',
|
||||||
|
options={'ordering': ['value'], 'verbose_name': 'Node'},
|
||||||
|
),
|
||||||
|
]
|
|
@ -158,9 +158,11 @@ class AuthMixin:
|
||||||
if update_fields:
|
if update_fields:
|
||||||
self.save(update_fields=update_fields)
|
self.save(update_fields=update_fields)
|
||||||
|
|
||||||
def has_special_auth(self, asset=None):
|
def has_special_auth(self, asset=None, username=None):
|
||||||
from .authbook import AuthBook
|
from .authbook import AuthBook
|
||||||
queryset = AuthBook.objects.filter(username=self.username)
|
if username is None:
|
||||||
|
username = self.username
|
||||||
|
queryset = AuthBook.objects.filter(username=username)
|
||||||
if asset:
|
if asset:
|
||||||
queryset = queryset.filter(asset=asset)
|
queryset = queryset.filter(asset=asset)
|
||||||
return queryset.exists()
|
return queryset.exists()
|
||||||
|
|
|
@ -210,6 +210,7 @@ class FamilyMixin:
|
||||||
if not full_value:
|
if not full_value:
|
||||||
return []
|
return []
|
||||||
nodes_family = full_value.split('/')
|
nodes_family = full_value.split('/')
|
||||||
|
nodes_family = [v for v in nodes_family if v]
|
||||||
org_root = cls.org_root()
|
org_root = cls.org_root()
|
||||||
if nodes_family[0] == org_root.value:
|
if nodes_family[0] == org_root.value:
|
||||||
nodes_family = nodes_family[1:]
|
nodes_family = nodes_family[1:]
|
||||||
|
@ -217,6 +218,7 @@ class FamilyMixin:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_nodes_recurse(cls, values, parent=None):
|
def create_nodes_recurse(cls, values, parent=None):
|
||||||
|
values = [v for v in values if v]
|
||||||
if not values:
|
if not values:
|
||||||
return None
|
return None
|
||||||
if parent is None:
|
if parent is None:
|
||||||
|
@ -407,7 +409,7 @@ class Node(OrgModelMixin, SomeNodesMixin, FamilyMixin, NodeAssetsMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Node")
|
verbose_name = _("Node")
|
||||||
ordering = ['key']
|
ordering = ['value']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.full_value
|
return self.full_value
|
||||||
|
|
|
@ -165,6 +165,11 @@ class SystemUser(BaseUser):
|
||||||
def is_need_test_asset_connective(self):
|
def is_need_test_asset_connective(self):
|
||||||
return self.protocol not in self.application_category_protocols
|
return self.protocol not in self.application_category_protocols
|
||||||
|
|
||||||
|
def has_special_auth(self, asset=None, username=None):
|
||||||
|
if username is None and self.username_same_with_user:
|
||||||
|
raise TypeError('System user is dynamic, username should be pass')
|
||||||
|
return super().has_special_auth(asset=asset, username=username)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_perm_to_asset(self):
|
def can_perm_to_asset(self):
|
||||||
return self.protocol not in self.application_category_protocols
|
return self.protocol not in self.application_category_protocols
|
||||||
|
|
|
@ -139,6 +139,7 @@ def get_push_windows_system_user_tasks(system_user, username=None):
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
if not password:
|
if not password:
|
||||||
|
logger.error("Error: no password found")
|
||||||
return tasks
|
return tasks
|
||||||
task = {
|
task = {
|
||||||
'name': 'Add user {}'.format(username),
|
'name': 'Add user {}'.format(username),
|
||||||
|
@ -214,14 +215,15 @@ def push_system_user_util(system_user, assets, task_name, username=None):
|
||||||
print(_("Start push system user for platform: [{}]").format(platform))
|
print(_("Start push system user for platform: [{}]").format(platform))
|
||||||
print(_("Hosts count: {}").format(len(_hosts)))
|
print(_("Hosts count: {}").format(len(_hosts)))
|
||||||
|
|
||||||
if not system_user.has_special_auth():
|
# 如果没有特殊密码设置,就不需要单独推送某台机器了
|
||||||
|
if not system_user.has_special_auth(username=username):
|
||||||
logger.debug("System user not has special auth")
|
logger.debug("System user not has special auth")
|
||||||
tasks = get_push_system_user_tasks(system_user, platform, username=username)
|
tasks = get_push_system_user_tasks(system_user, platform, username=username)
|
||||||
run_task(tasks, _hosts)
|
run_task(tasks, _hosts)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for _host in _hosts:
|
for _host in _hosts:
|
||||||
system_user.load_asset_special_auth(_host)
|
system_user.load_asset_special_auth(_host, username=username)
|
||||||
tasks = get_push_system_user_tasks(system_user, platform, username=username)
|
tasks = get_push_system_user_tasks(system_user, platform, username=username)
|
||||||
run_task(tasks, [_host])
|
run_task(tasks, [_host])
|
||||||
|
|
||||||
|
|
|
@ -189,7 +189,7 @@ class Crypto:
|
||||||
if origin_text:
|
if origin_text:
|
||||||
# 有时不同算法解密不报错,但是返回空字符串
|
# 有时不同算法解密不报错,但是返回空字符串
|
||||||
return origin_text
|
return origin_text
|
||||||
except (TypeError, ValueError, UnicodeDecodeError):
|
except (TypeError, ValueError, UnicodeDecodeError, IndexError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -69,9 +69,9 @@ urlpatterns = [
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \
|
||||||
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
|
||||||
js_i18n_patterns = i18n_patterns(
|
js_i18n_patterns = [
|
||||||
path('jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'),
|
path('core/jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'),
|
||||||
)
|
]
|
||||||
urlpatterns += js_i18n_patterns
|
urlpatterns += js_i18n_patterns
|
||||||
|
|
||||||
handler404 = 'jumpserver.views.handler404'
|
handler404 = 'jumpserver.views.handler404'
|
||||||
|
|
|
@ -65,6 +65,8 @@ class AdHocResultCallback(CallbackMixin, CallbackModule, CMDCallBackModule):
|
||||||
"""
|
"""
|
||||||
Task result Callback
|
Task result Callback
|
||||||
"""
|
"""
|
||||||
|
context = None
|
||||||
|
|
||||||
def clean_result(self, t, host, task_name, task_result):
|
def clean_result(self, t, host, task_name, task_result):
|
||||||
contacted = self.results_summary["contacted"]
|
contacted = self.results_summary["contacted"]
|
||||||
dark = self.results_summary["dark"]
|
dark = self.results_summary["dark"]
|
||||||
|
@ -133,7 +135,11 @@ class AdHocResultCallback(CallbackMixin, CallbackModule, CMDCallBackModule):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def set_play_context(self, context):
|
def set_play_context(self, context):
|
||||||
context.ssh_args = '-C -o ControlMaster=no'
|
# for k, v in context._attributes.items():
|
||||||
|
# print("{} ==> {}".format(k, v))
|
||||||
|
if self.context and isinstance(self.context, dict):
|
||||||
|
for k, v in self.context.items():
|
||||||
|
setattr(context, k, v)
|
||||||
|
|
||||||
|
|
||||||
class CommandResultCallback(AdHocResultCallback):
|
class CommandResultCallback(AdHocResultCallback):
|
||||||
|
|
|
@ -182,6 +182,13 @@ class AdHocRunner:
|
||||||
_options.update(options)
|
_options.update(options)
|
||||||
return _options
|
return _options
|
||||||
|
|
||||||
|
def set_control_master_if_need(self, cleaned_tasks):
|
||||||
|
modules = [task.get('action', {}).get('module') for task in cleaned_tasks]
|
||||||
|
if {'ping', 'win_ping'} & set(modules):
|
||||||
|
self.results_callback.context = {
|
||||||
|
'ssh_args': '-C -o ControlMaster=no'
|
||||||
|
}
|
||||||
|
|
||||||
def run(self, tasks, pattern, play_name='Ansible Ad-hoc', gather_facts='no'):
|
def run(self, tasks, pattern, play_name='Ansible Ad-hoc', gather_facts='no'):
|
||||||
"""
|
"""
|
||||||
:param tasks: [{'action': {'module': 'shell', 'args': 'ls'}, ...}, ]
|
:param tasks: [{'action': {'module': 'shell', 'args': 'ls'}, ...}, ]
|
||||||
|
@ -193,6 +200,7 @@ class AdHocRunner:
|
||||||
self.check_pattern(pattern)
|
self.check_pattern(pattern)
|
||||||
self.results_callback = self.get_result_callback()
|
self.results_callback = self.get_result_callback()
|
||||||
cleaned_tasks = self.clean_tasks(tasks)
|
cleaned_tasks = self.clean_tasks(tasks)
|
||||||
|
self.set_control_master_if_need(cleaned_tasks)
|
||||||
context.CLIARGS = ImmutableDict(self.options)
|
context.CLIARGS = ImmutableDict(self.options)
|
||||||
|
|
||||||
play_source = dict(
|
play_source = dict(
|
||||||
|
|
|
@ -76,7 +76,7 @@ class JMSInventory(JMSBaseInventory):
|
||||||
write you own inventory, construct you inventory,
|
write you own inventory, construct you inventory,
|
||||||
user_info is obtained from admin_user or asset_user
|
user_info is obtained from admin_user or asset_user
|
||||||
"""
|
"""
|
||||||
def __init__(self, assets, run_as_admin=False, run_as=None, become_info=None):
|
def __init__(self, assets, run_as_admin=False, run_as=None, become_info=None, system_user=None):
|
||||||
"""
|
"""
|
||||||
:param assets: assets
|
:param assets: assets
|
||||||
:param run_as_admin: True 是否使用管理用户去执行, 每台服务器的管理用户可能不同
|
:param run_as_admin: True 是否使用管理用户去执行, 每台服务器的管理用户可能不同
|
||||||
|
@ -86,6 +86,7 @@ class JMSInventory(JMSBaseInventory):
|
||||||
self.assets = assets
|
self.assets = assets
|
||||||
self.using_admin = run_as_admin
|
self.using_admin = run_as_admin
|
||||||
self.run_as = run_as
|
self.run_as = run_as
|
||||||
|
self.system_user = system_user
|
||||||
self.become_info = become_info
|
self.become_info = become_info
|
||||||
|
|
||||||
host_list = []
|
host_list = []
|
||||||
|
@ -104,18 +105,25 @@ class JMSInventory(JMSBaseInventory):
|
||||||
def get_run_user_info(self, host):
|
def get_run_user_info(self, host):
|
||||||
from assets.backends import AssetUserManager
|
from assets.backends import AssetUserManager
|
||||||
|
|
||||||
if self.run_as is None:
|
if not self.run_as and not self.system_user:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
asset_id = host.get('id', '')
|
||||||
|
asset = self.assets.filter(id=asset_id).first()
|
||||||
|
if not asset:
|
||||||
|
logger.error('Host not found: ', asset_id)
|
||||||
|
|
||||||
|
if self.system_user:
|
||||||
|
self.system_user.load_asset_special_auth(asset=asset, username=self.run_as)
|
||||||
|
return self.system_user._to_secret_json()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asset = self.assets.get(id=host.get('id'))
|
|
||||||
manager = AssetUserManager()
|
manager = AssetUserManager()
|
||||||
run_user = manager.get_latest(username=self.run_as, asset=asset)
|
run_user = manager.get_latest(username=self.run_as, asset=asset, prefer='system_user')
|
||||||
|
return run_user._to_secret_json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e, exc_info=True)
|
logger.error(e, exc_info=True)
|
||||||
return {}
|
return {}
|
||||||
else:
|
|
||||||
return run_user._to_secret_json()
|
|
||||||
|
|
||||||
|
|
||||||
class JMSCustomInventory(JMSBaseInventory):
|
class JMSCustomInventory(JMSBaseInventory):
|
||||||
|
|
|
@ -184,7 +184,7 @@ class AdHoc(OrgModelMixin):
|
||||||
hid = str(uuid.uuid4())
|
hid = str(uuid.uuid4())
|
||||||
execution = AdHocExecution(
|
execution = AdHocExecution(
|
||||||
id=hid, adhoc=self, task=self.task,
|
id=hid, adhoc=self, task=self.task,
|
||||||
task_display=str(self.task),
|
task_display=str(self.task)[:128],
|
||||||
date_start=timezone.now(),
|
date_start=timezone.now(),
|
||||||
hosts_amount=self.hosts.count(),
|
hosts_amount=self.hosts.count(),
|
||||||
)
|
)
|
||||||
|
|
|
@ -37,7 +37,7 @@ class CommandExecution(OrgModelMixin):
|
||||||
username = self.user.username
|
username = self.user.username
|
||||||
else:
|
else:
|
||||||
username = self.run_as.username
|
username = self.run_as.username
|
||||||
inv = JMSInventory(self.hosts.all(), run_as=username)
|
inv = JMSInventory(self.hosts.all(), run_as=username, system_user=self.run_as)
|
||||||
return inv
|
return inv
|
||||||
|
|
||||||
@lazyproperty
|
@lazyproperty
|
||||||
|
@ -78,7 +78,7 @@ class CommandExecution(OrgModelMixin):
|
||||||
runner = CommandRunner(self.inventory)
|
runner = CommandRunner(self.inventory)
|
||||||
try:
|
try:
|
||||||
host = self.hosts.first()
|
host = self.hosts.first()
|
||||||
if host.is_windows():
|
if host and host.is_windows():
|
||||||
shell = 'win_shell'
|
shell = 'win_shell'
|
||||||
else:
|
else:
|
||||||
shell = 'shell'
|
shell = 'shell'
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
{% include '_head_css_js.html' %}
|
{% include '_head_css_js.html' %}
|
||||||
<link href="{% static "css/jumpserver.css" %}" rel="stylesheet">
|
<link href="{% static "css/jumpserver.css" %}" rel="stylesheet">
|
||||||
<script src="{% url 'javascript-catalog' %}"></script>
|
|
||||||
<script src="{% static "js/jumpserver.js" %}"></script>
|
<script src="{% static "js/jumpserver.js" %}"></script>
|
||||||
<style>
|
<style>
|
||||||
.passwordBox {
|
.passwordBox {
|
||||||
|
|
|
@ -13,5 +13,7 @@
|
||||||
<script src="{% static 'js/plugins/sweetalert/sweetalert.min.js' %}"></script>
|
<script src="{% static 'js/plugins/sweetalert/sweetalert.min.js' %}"></script>
|
||||||
<script src="{% static 'js/bootstrap.min.js' %}"></script>
|
<script src="{% static 'js/bootstrap.min.js' %}"></script>
|
||||||
<script src="{% static 'js/plugins/datatables/datatables.min.js' %}"></script>
|
<script src="{% static 'js/plugins/datatables/datatables.min.js' %}"></script>
|
||||||
|
<script src="{% url 'javascript-catalog' %}"></script>
|
||||||
<link href="{% static 'css/plugins/select2/select2.min.css' %}" rel="stylesheet">
|
<link href="{% static 'css/plugins/select2/select2.min.css' %}" rel="stylesheet">
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 2.2.13 on 2020-11-16 09:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('terminal', '0028_auto_20201110_1918'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='commandstorage',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=128, unique=True, verbose_name='Name'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='replaystorage',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=128, unique=True, verbose_name='Name'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -401,7 +401,7 @@ class CommandStorage(CommonModelMixin):
|
||||||
TYPE_DEFAULTS = dict(const.REPLAY_STORAGE_TYPE_CHOICES_DEFAULT).keys()
|
TYPE_DEFAULTS = dict(const.REPLAY_STORAGE_TYPE_CHOICES_DEFAULT).keys()
|
||||||
TYPE_SERVER = const.COMMAND_STORAGE_TYPE_SERVER
|
TYPE_SERVER = const.COMMAND_STORAGE_TYPE_SERVER
|
||||||
|
|
||||||
name = models.CharField(max_length=32, verbose_name=_("Name"), unique=True)
|
name = models.CharField(max_length=128, verbose_name=_("Name"), unique=True)
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
max_length=16, choices=TYPE_CHOICES, verbose_name=_('Type'),
|
max_length=16, choices=TYPE_CHOICES, verbose_name=_('Type'),
|
||||||
default=TYPE_SERVER
|
default=TYPE_SERVER
|
||||||
|
@ -438,7 +438,7 @@ class ReplayStorage(CommonModelMixin):
|
||||||
TYPE_SERVER = const.REPLAY_STORAGE_TYPE_SERVER
|
TYPE_SERVER = const.REPLAY_STORAGE_TYPE_SERVER
|
||||||
TYPE_DEFAULTS = dict(const.REPLAY_STORAGE_TYPE_CHOICES_DEFAULT).keys()
|
TYPE_DEFAULTS = dict(const.REPLAY_STORAGE_TYPE_CHOICES_DEFAULT).keys()
|
||||||
|
|
||||||
name = models.CharField(max_length=32, verbose_name=_("Name"), unique=True)
|
name = models.CharField(max_length=128, verbose_name=_("Name"), unique=True)
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
max_length=16, choices=TYPE_CHOICES, verbose_name=_('Type'),
|
max_length=16, choices=TYPE_CHOICES, verbose_name=_('Type'),
|
||||||
default=TYPE_SERVER
|
default=TYPE_SERVER
|
||||||
|
|
Loading…
Reference in New Issue