diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3ab78cc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.js linguist-language=python +*.css linguist-language=python +*.html linguist-language=python diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/.idea/dictionaries/RobbieHan.xml b/.idea/dictionaries/RobbieHan.xml deleted file mode 100644 index 2b2e610..0000000 --- a/.idea/dictionaries/RobbieHan.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index eb73b7d..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 1d4b809..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/sandboxMP.iml b/.idea/sandboxMP.iml deleted file mode 100644 index 6b57a36..0000000 --- a/.idea/sandboxMP.iml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/apps/__pycache__/custom.cpython-36.pyc b/apps/__pycache__/custom.cpython-36.pyc index 458ff71..65e77f7 100644 Binary files a/apps/__pycache__/custom.cpython-36.pyc and b/apps/__pycache__/custom.cpython-36.pyc differ diff --git a/apps/cmdb/__init__.py b/apps/cmdb/__init__.py new file mode 100644 index 0000000..4490fa6 --- /dev/null +++ b/apps/cmdb/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cmdb.apps.CmdbConfig' \ No newline at end of file diff --git a/apps/cmdb/admin.py b/apps/cmdb/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/cmdb/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/cmdb/apps.py b/apps/cmdb/apps.py new file mode 100644 index 0000000..a9135fa --- /dev/null +++ b/apps/cmdb/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class CmdbConfig(AppConfig): + name = 'cmdb' + + def ready(self): + from .signals import auto_delete_file \ No newline at end of file diff --git a/apps/cmdb/forms.py b/apps/cmdb/forms.py new file mode 100644 index 0000000..4f44c9b --- /dev/null +++ b/apps/cmdb/forms.py @@ -0,0 +1,46 @@ +# @Time : 2018/12/19 16:13 +# @Author : RobbieHan +# @File : forms.py + +from django import forms + +from .models import Code + + +class CodeCreateForm(forms.ModelForm): + class Meta: + model = Code + fields = '__all__' + + error_messages = { + 'key': {'required': 'key不能为空'}, + 'value': {'required': 'value不能为空'} + } + + def clean(self): + cleaned_data = super(CodeCreateForm, self).clean() + key = cleaned_data.get('key') + value = cleaned_data.get('value') + + if Code.objects.filter(key=key).count(): + raise forms.ValidationError('key:{}已存在'.format(key)) + + if Code.objects.filter(value=value).count(): + raise forms.ValidationError('value: {}已存在'.format(value)) + + +class CodeUpdateForm(CodeCreateForm): + + def clean(self): + cleaned_data = self.cleaned_data + key = cleaned_data.get('key') + value = cleaned_data.get('value') + + if self.instance: + matching_code = Code.objects.exclude(pk=self.instance.pk) + if matching_code.filter(key=key).exists(): + msg = 'key:{} 已经存在'.format(key) + raise forms.ValidationError(msg) + if matching_code.filter(value=value).exists(): + msg = 'value:{} 已经存在'.format(value) + raise forms.ValidationError(msg) \ No newline at end of file diff --git a/apps/cmdb/migrations/__init__.py b/apps/cmdb/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/cmdb/models.py b/apps/cmdb/models.py new file mode 100644 index 0000000..1151a50 --- /dev/null +++ b/apps/cmdb/models.py @@ -0,0 +1,122 @@ +from datetime import datetime + +from django.db import models +from django.contrib.auth import get_user_model + +from simple_history.models import HistoricalRecords + +User = get_user_model() + + +class AbstractMode(models.Model): + parent = models.ForeignKey( + 'self', blank=True, null=True, on_delete=models.SET_NULL, related_name='child' + ) + + class Meta: + abstract = True + + +class Code(AbstractMode): + key = models.CharField(max_length=80, verbose_name='键') + value = models.CharField(max_length=80, verbose_name='值') + desc = models.BooleanField(default=True, verbose_name='备注') + + class Meta: + verbose_name = '字典' + verbose_name_plural = verbose_name + + +class TimeAbstract(models.Model): + add_time = models.DateTimeField(auto_now_add=True, verbose_name="添加时间") + modify_time = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + abstract = True + + +class ConnectionAbstract(models.Model): + auth_method_choices = ( + ('private_key', '密钥认证'), + ('password', '密码认证') + ) + hostname = models.CharField(max_length=50, verbose_name='设备地址(IP或域名)') + port = models.IntegerField(default=22, verbose_name='SSH端口') + username = models.CharField(max_length=15, blank=True, default='', verbose_name='SSH用户名') + password = models.CharField(max_length=80, blank=True, default='', verbose_name='SSH密码') + private_key = models.CharField(max_length=100, blank=True, default='', verbose_name='密钥路径') + auth_type = models.CharField(max_length=30, choices=auth_method_choices, default='') + status = models.CharField(max_length=10, blank=True, default='') + + class Meta: + abstract = True + + +class DeviceAbstract(models.Model): + sys_hostname = models.CharField(max_length=50, blank=True, default='', verbose_name='主机名') + mac_address = models.CharField(max_length=50, blank=True, default='', verbose_name='MAC地址') + sn_number = models.CharField(max_length=50, blank=True, default='', verbose_name='SN号码') + os_type = models.CharField(max_length=50, blank=True, default='', verbose_name='系统类型') + device_type = models.CharField(max_length=50, blank=True, default='', verbose_name='设备类型') + + class Meta: + abstract = True + + +class DeviceScanInfo(ConnectionAbstract, DeviceAbstract, TimeAbstract): + error_message = models.CharField(max_length=80, blank=True, default='', verbose_name='错误信息') + + class Meta: + verbose_name = '扫描信息' + verbose_name_plural = verbose_name + + +class ConnectionInfo(ConnectionAbstract, TimeAbstract): + + class Meta: + verbose_name = 'SSH连接信息' + verbose_name_plural = verbose_name + + +class Cabinet(models.Model): + number = models.CharField(max_length=50, verbose_name='机柜编号') + position = models.CharField(max_length=80, verbose_name='机柜位置') + desc = models.TextField(blank=True, default='', verbose_name='备注信息') + + class Meta: + verbose_name = '机柜信息' + verbose_name_plural = verbose_name + + +class DeviceInfo(AbstractMode, DeviceAbstract, TimeAbstract): + hostname = models.CharField(max_length=50, verbose_name='设备地址(IP或域名)') + network_type = models.IntegerField(blank=True, null=True, verbose_name='网络类型') + service_type = models.IntegerField(blank=True, null=True, verbose_name='服务类型') + operation_type = models.IntegerField(blank=True, null=True, verbose_name='业务类型') + leader = models.IntegerField(blank=True, null=True, verbose_name='责任人') + dev_cabinet = models.IntegerField(blank=True, null=True, verbose_name='机柜信息') + dev_connection = models.IntegerField(blank=True, null=True, verbose_name='连接信息') + buyDate = models.DateField(default=datetime.now, verbose_name="购买日期") + warrantyDate = models.DateField(default=datetime.now, verbose_name="到保日期") + desc = models.TextField(blank=True, default='', verbose_name='备注信息') + changed_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) + history = HistoricalRecords(excluded_fields=['add_time', 'modify_time']) + + class Meta: + verbose_name = '设备信息' + verbose_name_plural = verbose_name + + @property + def _history_user(self): + return self.changed_by + + @_history_user.setter + def _history_user(self, value): + self.changed_by = value + + +class DeviceFile(TimeAbstract): + device = models.ForeignKey('DeviceInfo', blank=True, null=True, on_delete=models.SET_NULL, verbose_name='设备') + file_content = models.FileField(upload_to="asset_file/%Y/%m", null=True, blank=True, verbose_name="资产文件") + upload_user = models.CharField(max_length=20, verbose_name="上传人") + diff --git a/apps/cmdb/signals.py b/apps/cmdb/signals.py new file mode 100644 index 0000000..d66e53a --- /dev/null +++ b/apps/cmdb/signals.py @@ -0,0 +1,13 @@ +import os + +from django.dispatch import receiver +from django.db.models.signals import post_delete + +from .models import DeviceFile + + +@receiver(post_delete, sender=DeviceFile) +def auto_delete_file(sender, instance, **kwargs): + if instance.file_content: + if os.path.isfile(instance.file_content.path): + os.remove(instance.file_content.path) \ No newline at end of file diff --git a/apps/cmdb/tasks.py b/apps/cmdb/tasks.py new file mode 100644 index 0000000..febdee3 --- /dev/null +++ b/apps/cmdb/tasks.py @@ -0,0 +1,56 @@ +import time +import logging + +from celery import shared_task +from celery_once import QueueOnce + +from utils.sandbox_utils import SandboxScan, LoginExecution +from .models import DeviceScanInfo + +info_logger = logging.getLogger('sandbox_info') + + +@shared_task(base=QueueOnce) +def scan_execution(): + scan = SandboxScan() + execution = LoginExecution() + scan_type = execution.get_scan_type() + auth_type = execution.get_auth_type() + start_time = time.time() + if scan_type == 'basic_scan': + hosts = scan.basic_scan() + for host in hosts: + DeviceScanInfo.objects.update_or_create( + hostname=host, + ) + else: + hosts = scan.os_scan() + login_hosts = [host for host in hosts if host['os'] in ['Linux', 'embedded']] + nologin_hosts = [host for host in hosts if host not in login_hosts] + for host in nologin_hosts: + DeviceScanInfo.objects.update_or_create( + hostname=host['host'], + defaults={ + 'os_type': host['os'] + } + ) + for host in login_hosts: + kwargs = { + 'hostname': host['host'], + 'username': execution.get_ssh_username(), + 'port': execution.get_ssh_port(), + 'password': execution.get_ssh_password(), + 'private_key': execution.get_ssh_private_key() + } + defaults = execution.login_execution(auth_type=auth_type, **kwargs) + DeviceScanInfo.objects.update_or_create( + hostname=host['host'], + defaults=defaults + ) + end_time = time.time() + msg = 'Scan task has been completed, execution time: %(time)s, %(num)s hosts are up.' % { + 'time': end_time - start_time, + 'num': len(hosts) + } + info_logger.info(msg) + return msg \ No newline at end of file diff --git a/apps/cmdb/tests.py b/apps/cmdb/tests.py new file mode 100644 index 0000000..f8783cd --- /dev/null +++ b/apps/cmdb/tests.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +# Create your tests here. +from django.views.generic.base import View +from django.shortcuts import HttpResponse +import logging +from .models import Code + +info_logger = logging.getLogger('sandbox_info') +error_logger = logging.getLogger('sandbox_error') + + +class TestLoggingView(View): + + def get(self, request): + print('a') + info_logger.info('The system print a letter "a" ') + try: + Code.objects.get(id=100) + except Exception as e: + error_logger.error(e) + return HttpResponse("OK!") \ No newline at end of file diff --git a/apps/cmdb/urls.py b/apps/cmdb/urls.py new file mode 100644 index 0000000..f58f0a7 --- /dev/null +++ b/apps/cmdb/urls.py @@ -0,0 +1,30 @@ +from django.urls import path + +from .views import CmdbView +from . import views_code, views_scan, views_eam + +app_name = 'cmdb' + +urlpatterns = [ + path('', CmdbView.as_view(), name='index'), + path('portal/code/', views_code.CodeView.as_view(), name='portal-code'), + path('portal/code/create/', views_code.CodeCreateView.as_view(), name='portal-code-create'), + path('portal/code/list/', views_code.CodeListView.as_view(), name='portal-code-list'), + path('portal/code/update/', views_code.CodeUpdateView.as_view(), name='portal-code-update'), + path('portal/code/delete/', views_code.CodeDeleteView.as_view(), name='portal-code-delete'), + + path('portal/scan_config/', views_scan.ScanConfigView.as_view(), name='portal-scan_config'), + path('portal/device_scan/', views_scan.DeviceScanView.as_view(), name='portal-device_scan'), + path('portal/device_scan/list/', views_scan.DeviceScanListView.as_view(), name='portal-device_scan-list'), + path('portal/device_scan/detail/', views_scan.DeviceScanDetailView.as_view(), name='portal-device_scan-detail'), + path('portal/device_scan/delete/', views_scan.DeviceScanDeleteView.as_view(), name='portal-device_scan-delete'), + path('portal/device_scan/exec/', views_scan.DeviceScanExecView.as_view(), name='portal-device_scan-exec'), + path('portal/device_scan/inbound/', views_scan.DeviceScanInboundView.as_view(), name='portal-device_scan-inbound'), + + path('eam/cabinet/', views_eam.CabinetView.as_view(), name='eam-cabinet'), + path('eam/cabinet/create/', views_eam.CabinetCreateView.as_view(), name='eam-cabinet-create'), + path('eam/cabinet/update/', views_eam.CabinetUpdateView.as_view(), name='eam-cabinet-update'), + path('eam/cabinet/list/', views_eam.CabinetListView.as_view(), name='eam-cabinet-list'), + path('eam/cabinet/delete/', views_eam.CabinetDeleteView.as_view(), name='eam-cabinet-delete'), + +] diff --git a/apps/cmdb/views.py b/apps/cmdb/views.py new file mode 100644 index 0000000..ac3e598 --- /dev/null +++ b/apps/cmdb/views.py @@ -0,0 +1,9 @@ +from django.views.generic import TemplateView + +from system.mixin import LoginRequiredMixin +from custom import BreadcrumbMixin + + +class CmdbView(LoginRequiredMixin, BreadcrumbMixin, TemplateView): + + template_name = 'cmdb/cmdb_index.html' diff --git a/apps/cmdb/views_code.py b/apps/cmdb/views_code.py new file mode 100644 index 0000000..ca45aa4 --- /dev/null +++ b/apps/cmdb/views_code.py @@ -0,0 +1,53 @@ +# @Time : 2018/12/19 13:31 +# @Author : RobbieHan +# @File : views_code.py.py + +from django.views.generic import TemplateView + +from system.mixin import LoginRequiredMixin +from custom import (BreadcrumbMixin, SandboxCreateView, + SandboxListView, SandboxUpdateView, SandboxDeleteView) +from .models import Code +from .forms import CodeCreateForm, CodeUpdateForm + + +class CodeView(LoginRequiredMixin, BreadcrumbMixin, TemplateView): + template_name = 'cmdb/code.html' + + def get_context_data(self): + self.kwargs['code_parent'] = Code.objects.filter(parent=None) + return super().get_context_data(**self.kwargs) + + +class CodeCreateView(SandboxCreateView): + model = Code + form_class = CodeCreateForm + template_name_suffix = '_create' + + def get_context_data(self, **kwargs): + kwargs['code_parent'] = Code.objects.filter(parent=None) + return super().get_context_data(**kwargs) + + +class CodeListView(SandboxListView): + model = Code + fields = ['id', 'key', 'value', 'parent__value'] + + def get(self, request): + if 'parent' in request.GET and request.GET['parent']: + self.filters = dict(parent__key=request.GET['parent']) + return super().get(request) + + +class CodeUpdateView(SandboxUpdateView): + model = Code + form_class = CodeUpdateForm + template_name_suffix = '_update' + + def get_context_data(self, **kwargs): + kwargs['code_parent'] = Code.objects.filter(parent=None) + return super().get_context_data(**kwargs) + + +class CodeDeleteView(SandboxDeleteView): + model = Code diff --git a/apps/cmdb/views_eam.py b/apps/cmdb/views_eam.py new file mode 100644 index 0000000..68d9018 --- /dev/null +++ b/apps/cmdb/views_eam.py @@ -0,0 +1,38 @@ +from django.views.generic import TemplateView + +from system.mixin import LoginRequiredMixin +from custom import (BreadcrumbMixin, SandboxDeleteView, + SandboxListView, SandboxUpdateView, SandboxCreateView) +from .models import Cabinet + + +class CabinetView(LoginRequiredMixin, BreadcrumbMixin, TemplateView): + template_name = 'cmdb/cabinet.html' + + +class CabinetCreateView(SandboxCreateView): + model = Cabinet + fields = '__all__' + + +class CabinetUpdateView(SandboxUpdateView): + model = Cabinet + fields = '__all__' + + +class CabinetListView(SandboxListView): + model = Cabinet + fields = ['id', 'number', 'position', 'desc'] + + def get_filters(self): + data = self.request.GET + filters = {} + if 'number' in data and data['number']: + filters['number__icontains'] = data['number'] + if 'position' in data and data['position']: + filters['position__icontains'] = data['position'] + return filters + + +class CabinetDeleteView(SandboxDeleteView): + model = Cabinet diff --git a/apps/cmdb/views_scan.py b/apps/cmdb/views_scan.py new file mode 100644 index 0000000..543dd06 --- /dev/null +++ b/apps/cmdb/views_scan.py @@ -0,0 +1,118 @@ +# @Time : 2018/12/29 19:25 +# @Author : RobbieHan +# @File : views_scan.py + +import ast +import logging +from ruamel import yaml + +from django.views.generic import View, TemplateView +from django.http import JsonResponse +from django.shortcuts import render, get_object_or_404 + +from celery_once import AlreadyQueued + +from system.mixin import LoginRequiredMixin +from custom import BreadcrumbMixin, SandboxListView, SandboxDeleteView +from utils.sandbox_utils import ConfigFileMixin +from system.models import Menu +from .models import (DeviceScanInfo, ConnectionInfo, DeviceInfo, + ConnectionAbstract, DeviceAbstract) +from .tasks import scan_execution + +error_logger = logging.getLogger('sandbox_error') + + +class ScanConfigView(LoginRequiredMixin, BreadcrumbMixin, ConfigFileMixin, View): + + def get(self, request): + menu = Menu.get_menu_by_request_url(request.path_info) + template_name = 'cmdb/scan_config.html' + context = self.get_conf_content() + context.update(menu) + return render(request, template_name, context) + + def post(self, request): + ret = dict(result=False) + config = dict() + hosts = request.POST + try: + config['net_address'] = ast.literal_eval(hosts['net_address']) + config['ssh_username'] = hosts['ssh_username'] + config['ssh_port'] = hosts['ssh_port'] + config['ssh_password'] = hosts['ssh_password'] + config['ssh_private_key'] = hosts['ssh_private_key'] + config['commands'] = ast.literal_eval(hosts['commands']) + config['auth_type'] = hosts['auth_type'] + config['scan_type'] = hosts['scan_type'] + config['email'] = hosts['email'] + config['send_email'] = hosts['send_email'] + data = dict(hosts=config) + config_file = self.get_config_file() + with open(config_file, 'w', encoding='utf-8') as f: + yaml.dump(data, f, Dumper=yaml.RoundTripDumper, indent=4) + ret['result'] = True + except Exception as e: + error_logger.error(e) + + return JsonResponse(ret) + + +class DeviceScanView(LoginRequiredMixin, BreadcrumbMixin, TemplateView): + template_name = 'cmdb/device_scan.html' + + +class DeviceScanListView(SandboxListView): + model = DeviceScanInfo + fields = ['id', 'sys_hostname', 'hostname', 'mac_address', 'auth_type', 'status', 'os_type', 'device_type'] + + +class DeviceScanDetailView(LoginRequiredMixin, View): + + def get(self, request): + ret = Menu.get_menu_by_request_url(request.path_info) + if 'id' in request.GET and request.GET['id']: + device = get_object_or_404(DeviceScanInfo, pk=int(request.GET['id'])) + ret['device'] = device + return render(request, 'cmdb/device_scan_detail.html', ret) + + +class DeviceScanDeleteView(SandboxDeleteView): + model = DeviceScanInfo + + +class DeviceScanExecView(LoginRequiredMixin, View): + + def get(self, request): + ret = dict(status='fail') + try: + scan_execution.delay() + ret['status'] = 'success' + except AlreadyQueued: + ret['status'] = 'already_queued' + return JsonResponse(ret) + + +class DeviceScanInboundView(LoginRequiredMixin, View): + def post(self, request): + ret = dict(result=False) + login_succeed = list(DeviceScanInfo.objects.filter(status='succeed').values()) + connection_fields = [field.name for field in ConnectionAbstract._meta.fields if field.name is not 'id'] + device_fields = [field.name for field in DeviceAbstract._meta.fields if field.name is not 'id'] + device_fields.append('hostname') + for host in login_succeed: + connection_defaults = {key: host[key] for key in host.keys() & connection_fields} + device_defaults = {key: host[key] for key in host.keys() & device_fields} + connection_info, _ = ConnectionInfo.objects.update_or_create( + hostname=host['hostname'], + defaults=connection_defaults + ) + connection_id = int(getattr(connection_info, 'id')) + device_defaults['dev_connection'] = connection_id + device_defaults['changed_by_id'] = request.user.id + DeviceInfo.objects.update_or_create( + hostname=host['hostname'], + defaults=device_defaults + ) + ret['result'] = True + return JsonResponse(ret) diff --git a/apps/custom.py b/apps/custom.py index 6c2e497..4490fd5 100644 --- a/apps/custom.py +++ b/apps/custom.py @@ -3,10 +3,13 @@ # @File : custom.py import json +import re -from django.views.generic import CreateView, UpdateView +from django.views.generic import CreateView, UpdateView, View from django.shortcuts import HttpResponse -from django.http import Http404 +from django.http import Http404, JsonResponse +from django.db.models.query import QuerySet +from django.core.exceptions import ImproperlyConfigured from system.mixin import LoginRequiredMixin from system.models import Menu @@ -42,6 +45,69 @@ class SandboxGetObjectMixin: return obj +class SandboxMultipleObjectMixin: + + filters = {} + fields = [] + queryset = None + model = None + + def get_queryset(self): + if self.queryset is not None: + queryset = self.queryset + if isinstance(queryset, QuerySet): + queryset = queryset.all() + elif self.model is not None: + queryset = self.model._default_manager.all() + else: + raise ImproperlyConfigured( + "%(cls)s is missing a QuerySet. Define " + "%(cls)s.model, %(cls)s.queryset." + % {'cls': self.__class__.__name__} + ) + return queryset + + def get_datatables_paginator(self, request): + datatables = request.GET + draw = int(datatables.get('draw')) + start = int(datatables.get('start')) + length = int(datatables.get('length')) + order_column = datatables.get('order[0][column]') + order_dir = datatables.get('order[0][dir]') + order_field = datatables.get('columns[{}][data]'.format(order_column)) + queryset = self.get_queryset() + if order_dir == 'asc': + queryset = queryset.order_by(order_field) + else: + queryset = queryset.order_by('-{0}'.format(order_field)) + record_total_count = queryset.count() + filters = self.get_filters() + fields = self.get_fields() + if filters: + queryset = queryset.filter(**filters) + if fields: + queryset = queryset.values(*fields) + + record_filter_count = queryset.count() + + object_list = queryset[start:(start + length)] + + data = list(object_list) + + return { + 'draw': draw, + 'recordsTotal': record_total_count, + 'recordsFiltered': record_filter_count, + 'data': data, + } + + def get_filters(self): + return self.filters + + def get_fields(self): + return self.fields + + class SandboxEditViewMixin: def post(self, request, *args, **kwargs): @@ -50,6 +116,11 @@ class SandboxEditViewMixin: if form.is_valid(): form.save() res['result'] = True + else: + pattern = '
  • .*?