mirror of https://github.com/jumpserver/jumpserver
perf: 优化发送结果
parent
e58054c441
commit
ca7d2130a5
|
@ -1,6 +1,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
from django.db.models import Q, Count
|
from django.db.models import Q, Count
|
||||||
|
from django.http import HttpResponse
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
|
||||||
from accounts import serializers
|
from accounts import serializers
|
||||||
|
@ -28,8 +29,9 @@ class CheckAccountExecutionViewSet(AutomationExecutionViewSet):
|
||||||
("list", "accounts.view_checkaccountexecution"),
|
("list", "accounts.view_checkaccountexecution"),
|
||||||
("retrieve", "accounts.view_checkaccountsexecution"),
|
("retrieve", "accounts.view_checkaccountsexecution"),
|
||||||
("create", "accounts.add_checkaccountexecution"),
|
("create", "accounts.add_checkaccountexecution"),
|
||||||
|
("report", "accounts.view_checkaccountsexecution"),
|
||||||
)
|
)
|
||||||
|
ordering = ('-date_created',)
|
||||||
tp = AutomationTypes.check_account
|
tp = AutomationTypes.check_account
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
@ -37,6 +39,12 @@ class CheckAccountExecutionViewSet(AutomationExecutionViewSet):
|
||||||
queryset = queryset.filter(automation__type=self.tp)
|
queryset = queryset.filter(automation__type=self.tp)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
@action(methods=['get'], detail=True, url_path='report')
|
||||||
|
def report(self, request, *args, **kwargs):
|
||||||
|
execution = self.get_object()
|
||||||
|
report = execution.manager.gen_report()
|
||||||
|
return HttpResponse(report)
|
||||||
|
|
||||||
|
|
||||||
class AccountRiskViewSet(OrgBulkModelViewSet):
|
class AccountRiskViewSet(OrgBulkModelViewSet):
|
||||||
model = AccountRisk
|
model = AccountRisk
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
from accounts.automations.methods import platform_automation_methods
|
from accounts.automations.methods import platform_automation_methods
|
||||||
from assets.automations.base.manager import BasePlaybookManager
|
from assets.automations.base.manager import BasePlaybookManager
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
|
@ -6,7 +8,16 @@ logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AccountBasePlaybookManager(BasePlaybookManager):
|
class AccountBasePlaybookManager(BasePlaybookManager):
|
||||||
|
template_path = ''
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def platform_automation_methods(self):
|
def platform_automation_methods(self):
|
||||||
return platform_automation_methods
|
return platform_automation_methods
|
||||||
|
|
||||||
|
def gen_report(self):
|
||||||
|
context = {
|
||||||
|
'execution': self.execution,
|
||||||
|
'summary': self.execution.summary,
|
||||||
|
'result': self.execution.result
|
||||||
|
}
|
||||||
|
return render_to_string(self.template_path, context)
|
||||||
|
|
|
@ -15,7 +15,6 @@ from assets.const import HostTypes
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from common.utils.file import encrypt_and_compress_zip_file
|
from common.utils.file import encrypt_and_compress_zip_file
|
||||||
from common.utils.timezone import local_now_filename
|
from common.utils.timezone import local_now_filename
|
||||||
from users.models import User
|
|
||||||
from ..base.manager import AccountBasePlaybookManager
|
from ..base.manager import AccountBasePlaybookManager
|
||||||
from ...utils import SecretGenerator
|
from ...utils import SecretGenerator
|
||||||
|
|
||||||
|
@ -247,7 +246,6 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
||||||
]
|
]
|
||||||
|
|
||||||
recipients = self.execution.recipients
|
recipients = self.execution.recipients
|
||||||
recipients = User.objects.filter(id__in=list(recipients.keys()))
|
|
||||||
if not recipients:
|
if not recipients:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
@ -2,9 +2,14 @@ import re
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from django.template.loader import render_to_string
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from premailer import transform
|
||||||
|
|
||||||
from accounts.models import Account, AccountRisk
|
from accounts.models import Account, AccountRisk
|
||||||
|
from common.db.utils import safe_db_connection
|
||||||
|
from common.tasks import send_mail_async
|
||||||
|
from common.utils.strings import color_fmt
|
||||||
|
|
||||||
|
|
||||||
def is_weak_password(password):
|
def is_weak_password(password):
|
||||||
|
@ -30,7 +35,6 @@ def is_weak_password(password):
|
||||||
or not re.search(r'[0-9]', password)
|
or not re.search(r'[0-9]', password)
|
||||||
or not re.search(r'[\W_]', password)):
|
or not re.search(r'[\W_]', password)):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@ -38,27 +42,34 @@ def check_account_secrets(accounts, assets):
|
||||||
now = timezone.now().isoformat()
|
now = timezone.now().isoformat()
|
||||||
risks = []
|
risks = []
|
||||||
tmpl = "Check account %s: %s"
|
tmpl = "Check account %s: %s"
|
||||||
RED = "\033[31m"
|
|
||||||
GREEN = "\033[32m"
|
|
||||||
RESET = "\033[0m" # 还原默认颜色
|
|
||||||
|
|
||||||
summary = defaultdict(int)
|
summary = defaultdict(int)
|
||||||
|
result = defaultdict(list)
|
||||||
|
summary['accounts'] = len(accounts)
|
||||||
|
summary['assets'] = len(assets)
|
||||||
|
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
|
result_item = {
|
||||||
|
'asset': str(account.asset),
|
||||||
|
'username': account.username,
|
||||||
|
}
|
||||||
if not account.secret:
|
if not account.secret:
|
||||||
print(tmpl % (account, "no secret"))
|
print(tmpl % (account, "no secret"))
|
||||||
summary['no_secret'] += 1
|
summary['no_secret'] += 1
|
||||||
|
result['no_secret'].append(result_item)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if is_weak_password(account.secret):
|
if is_weak_password(account.secret):
|
||||||
print(tmpl % (account, f"{RED}weak{RESET}"))
|
print(tmpl % (account, color_fmt("weak", "red")))
|
||||||
summary['weak'] += 1
|
summary['weak_password'] += 1
|
||||||
|
result['weak_password'].append(result_item)
|
||||||
risks.append({
|
risks.append({
|
||||||
'account': account,
|
'account': account,
|
||||||
'risk': 'weak_password',
|
'risk': 'weak_password',
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
summary['ok'] += 1
|
summary['ok'] += 1
|
||||||
print(tmpl % (account, f"{GREEN}ok{RESET}"))
|
result['ok'].append(result_item)
|
||||||
|
print(tmpl % (account, color_fmt("ok", "green")))
|
||||||
|
|
||||||
origin_risks = AccountRisk.objects.filter(asset__in=assets)
|
origin_risks = AccountRisk.objects.filter(asset__in=assets)
|
||||||
origin_risks_dict = {f'{r.asset_id}_{r.username}_{r.risk}': r for r in origin_risks}
|
origin_risks_dict = {f'{r.asset_id}_{r.username}_{r.risk}': r for r in origin_risks}
|
||||||
|
@ -77,7 +88,7 @@ def check_account_secrets(accounts, assets):
|
||||||
risk=d['risk'],
|
risk=d['risk'],
|
||||||
details=[{'datetime': now}],
|
details=[{'datetime': now}],
|
||||||
)
|
)
|
||||||
return summary
|
return summary, result
|
||||||
|
|
||||||
|
|
||||||
class CheckAccountManager:
|
class CheckAccountManager:
|
||||||
|
@ -90,9 +101,12 @@ class CheckAccountManager:
|
||||||
self.timedelta = 0
|
self.timedelta = 0
|
||||||
self.assets = []
|
self.assets = []
|
||||||
self.summary = {}
|
self.summary = {}
|
||||||
|
self.result = defaultdict(list)
|
||||||
|
|
||||||
def pre_run(self):
|
def pre_run(self):
|
||||||
self.assets = self.execution.get_all_assets()
|
self.assets = self.execution.get_all_assets()
|
||||||
|
self.execution.date_start = timezone.now()
|
||||||
|
self.execution.save(update_fields=['date_start'])
|
||||||
|
|
||||||
def batch_run(self, batch_size=100):
|
def batch_run(self, batch_size=100):
|
||||||
for engine in self.execution.snapshot.get('engines', []):
|
for engine in self.execution.snapshot.get('engines', []):
|
||||||
|
@ -104,18 +118,57 @@ class CheckAccountManager:
|
||||||
for i in range(0, len(self.assets), batch_size):
|
for i in range(0, len(self.assets), batch_size):
|
||||||
_assets = self.assets[i:i + batch_size]
|
_assets = self.assets[i:i + batch_size]
|
||||||
accounts = Account.objects.filter(asset__in=_assets)
|
accounts = Account.objects.filter(asset__in=_assets)
|
||||||
summary = handle(accounts, _assets)
|
summary, result = handle(accounts, _assets)
|
||||||
self.summary.update(summary)
|
|
||||||
|
|
||||||
def after_run(self):
|
for k, v in summary.items():
|
||||||
|
self.summary[k] = self.summary.get(k, 0) + v
|
||||||
|
for k, v in result.items():
|
||||||
|
self.result[k].extend(v)
|
||||||
|
|
||||||
|
def _update_execution_and_summery(self):
|
||||||
self.date_end = timezone.now()
|
self.date_end = timezone.now()
|
||||||
self.time_end = time.time()
|
self.time_end = time.time()
|
||||||
self.timedelta = self.time_end - self.time_start
|
self.duration = self.time_end - self.time_start
|
||||||
tmpl = "\n-\nSummary: ok: %s, weak: %s, no_secret: %s, using time: %ss" % (
|
self.execution.date_finished = timezone.now()
|
||||||
self.summary['ok'], self.summary['weak'], self.summary['no_secret'], self.timedelta
|
self.execution.status = 'success'
|
||||||
|
self.execution.summary = self.summary
|
||||||
|
self.execution.result = self.result
|
||||||
|
|
||||||
|
with safe_db_connection():
|
||||||
|
self.execution.save(update_fields=['date_finished', 'status', 'summary', 'result'])
|
||||||
|
|
||||||
|
def after_run(self):
|
||||||
|
self._update_execution_and_summery()
|
||||||
|
self._send_report()
|
||||||
|
|
||||||
|
tmpl = "\n---\nSummary: \nok: %s, weak password: %s, no secret: %s, using time: %ss" % (
|
||||||
|
self.summary['ok'], self.summary['weak_password'], self.summary['no_secret'], int(self.timedelta)
|
||||||
)
|
)
|
||||||
print(tmpl)
|
print(tmpl)
|
||||||
|
|
||||||
|
def gen_report(self):
|
||||||
|
template_path = 'accounts/check_account_report.html'
|
||||||
|
context = {
|
||||||
|
'execution': self.execution,
|
||||||
|
'summary': self.execution.summary,
|
||||||
|
'result': self.execution.result
|
||||||
|
}
|
||||||
|
data = render_to_string(template_path, context)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _send_report(self):
|
||||||
|
recipients = self.execution.recipients
|
||||||
|
if not recipients:
|
||||||
|
return
|
||||||
|
|
||||||
|
report = self.gen_report()
|
||||||
|
report = transform(report)
|
||||||
|
print("Send resport to: {}".format([str(r) for r in recipients]))
|
||||||
|
subject = f'Check account automation {self.execution.id} finished'
|
||||||
|
emails = [r.email for r in recipients if r.email]
|
||||||
|
|
||||||
|
send_mail_async(subject, report, emails, html_message=report)
|
||||||
|
|
||||||
def run(self,):
|
def run(self,):
|
||||||
self.pre_run()
|
self.pre_run()
|
||||||
self.batch_run()
|
self.batch_run()
|
||||||
|
|
|
@ -18,7 +18,6 @@ class ExecutionManager:
|
||||||
AutomationTypes.gather_accounts: GatherAccountsManager,
|
AutomationTypes.gather_accounts: GatherAccountsManager,
|
||||||
AutomationTypes.verify_gateway_account: VerifyGatewayAccountManager,
|
AutomationTypes.verify_gateway_account: VerifyGatewayAccountManager,
|
||||||
AutomationTypes.check_account: CheckAccountManager,
|
AutomationTypes.check_account: CheckAccountManager,
|
||||||
# TODO 后期迁移到自动化策略中
|
|
||||||
'backup_account': AccountBackupManager,
|
'backup_account': AccountBackupManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,3 +27,6 @@ class ExecutionManager:
|
||||||
|
|
||||||
def run(self, *args, **kwargs):
|
def run(self, *args, **kwargs):
|
||||||
return self._runner.run(*args, **kwargs)
|
return self._runner.run(*args, **kwargs)
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
return getattr(self._runner, item)
|
||||||
|
|
|
@ -9,7 +9,6 @@ from common.const import ConfirmOrIgnore
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from common.utils.strings import get_text_diff
|
from common.utils.strings import get_text_diff
|
||||||
from orgs.utils import tmp_to_org
|
from orgs.utils import tmp_to_org
|
||||||
from users.models import User
|
|
||||||
from .filter import GatherAccountsFilter
|
from .filter import GatherAccountsFilter
|
||||||
from ..base.manager import AccountBasePlaybookManager
|
from ..base.manager import AccountBasePlaybookManager
|
||||||
from ...notifications import GatherAccountChangeMsg
|
from ...notifications import GatherAccountChangeMsg
|
||||||
|
@ -313,10 +312,7 @@ class GatherAccountsManager(AccountBasePlaybookManager):
|
||||||
if not self.asset_usernames_mapper or not recipients:
|
if not self.asset_usernames_mapper or not recipients:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
users = User.objects.filter(id__in=recipients)
|
users = recipients
|
||||||
if not users.exists():
|
|
||||||
return users, None
|
|
||||||
|
|
||||||
asset_ids = self.asset_usernames_mapper.keys()
|
asset_ids = self.asset_usernames_mapper.keys()
|
||||||
assets = Asset.objects.filter(id__in=asset_ids).prefetch_related('accounts')
|
assets = Asset.objects.filter(id__in=asset_ids).prefetch_related('accounts')
|
||||||
gather_accounts = GatheredAccount.objects.filter(asset_id__in=asset_ids, remote_present=True)
|
gather_accounts = GatheredAccount.objects.filter(asset_id__in=asset_ids, remote_present=True)
|
||||||
|
|
|
@ -7,68 +7,6 @@ logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
|
class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def method_type(cls):
|
def method_type(cls):
|
||||||
return AutomationTypes.push_account
|
return AutomationTypes.push_account
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def trigger_by_asset_create(cls, asset):
|
|
||||||
# automations = PushAccountAutomation.objects.filter(
|
|
||||||
# triggers__contains=TriggerChoice.on_asset_create
|
|
||||||
# )
|
|
||||||
# account_automation_map = {auto.username: auto for auto in automations}
|
|
||||||
#
|
|
||||||
# util = AssetPermissionUtil()
|
|
||||||
# permissions = util.get_permissions_for_assets([asset], with_node=True)
|
|
||||||
# account_permission_map = defaultdict(list)
|
|
||||||
# for permission in permissions:
|
|
||||||
# for account in permission.accounts:
|
|
||||||
# account_permission_map[account].append(permission)
|
|
||||||
#
|
|
||||||
# username_automation_map = {}
|
|
||||||
# for username, automation in account_automation_map.items():
|
|
||||||
# if username != '@USER':
|
|
||||||
# username_automation_map[username] = automation
|
|
||||||
# continue
|
|
||||||
#
|
|
||||||
# asset_permissions = account_permission_map.get(username)
|
|
||||||
# if not asset_permissions:
|
|
||||||
# continue
|
|
||||||
# asset_permissions = util.get_permissions([p.id for p in asset_permissions])
|
|
||||||
# usernames = asset_permissions.values_list('users__username', flat=True).distinct()
|
|
||||||
# for _username in usernames:
|
|
||||||
# username_automation_map[_username] = automation
|
|
||||||
#
|
|
||||||
# asset_usernames_exists = asset.accounts.values_list('username', flat=True)
|
|
||||||
# accounts_to_create = []
|
|
||||||
# accounts_to_push = []
|
|
||||||
# for username, automation in username_automation_map.items():
|
|
||||||
# if username in asset_usernames_exists:
|
|
||||||
# continue
|
|
||||||
#
|
|
||||||
# if automation.secret_strategy != SecretStrategy.custom:
|
|
||||||
# secret_generator = SecretGenerator(
|
|
||||||
# automation.secret_strategy, automation.secret_type,
|
|
||||||
# automation.password_rules
|
|
||||||
# )
|
|
||||||
# secret = secret_generator.get_secret()
|
|
||||||
# else:
|
|
||||||
# secret = automation.secret
|
|
||||||
#
|
|
||||||
# account = Account(
|
|
||||||
# username=username, secret=secret,
|
|
||||||
# asset=asset, secret_type=automation.secret_type,
|
|
||||||
# comment='Create by account creation {}'.format(automation.name),
|
|
||||||
# )
|
|
||||||
# accounts_to_create.append(account)
|
|
||||||
# if automation.action == 'create_and_push':
|
|
||||||
# accounts_to_push.append(account)
|
|
||||||
# else:
|
|
||||||
# accounts_to_create.append(account)
|
|
||||||
#
|
|
||||||
# logger.debug(f'Create account {account} for asset {asset}')
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def trigger_by_permission_accounts_change(cls):
|
|
||||||
# pass
|
|
||||||
|
|
|
@ -48,13 +48,13 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='changesecretautomation',
|
model_name='changesecretautomation',
|
||||||
name='check_conn_after_change',
|
name='check_conn_after_change',
|
||||||
field=models.BooleanField(default=True, verbose_name='Check connection after change'),
|
field=models.BooleanField(default=True, verbose_name='Check connection after change'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='pushaccountautomation',
|
model_name='pushaccountautomation',
|
||||||
name='check_conn_after_change',
|
name='check_conn_after_change',
|
||||||
field=models.BooleanField(default=True, verbose_name='Check connection after change'),
|
field=models.BooleanField(default=True, verbose_name='Check connection after change'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,7 +5,6 @@ import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def init_account_check_engine(apps, schema_editor):
|
def init_account_check_engine(apps, schema_editor):
|
||||||
data = [
|
data = [
|
||||||
{
|
{
|
||||||
|
@ -26,7 +25,6 @@ def init_account_check_engine(apps, schema_editor):
|
||||||
model_cls.objects.create(**item)
|
model_cls.objects.create(**item)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 4.1.13 on 2024-11-15 03:00
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("accounts", "0012_accountcheckengine_accountcheckautomation_engines"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="checkaccountautomation",
|
||||||
|
name="recipients",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, to=settings.AUTH_USER_MODEL, verbose_name="Recipient"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -42,10 +42,11 @@ class AutomationExecution(AssetAutomationExecution):
|
||||||
('add_pushaccountexecution', _('Can add push account execution')),
|
('add_pushaccountexecution', _('Can add push account execution')),
|
||||||
]
|
]
|
||||||
|
|
||||||
def start(self):
|
@property
|
||||||
|
def manager(self):
|
||||||
from accounts.automations.endpoint import ExecutionManager
|
from accounts.automations.endpoint import ExecutionManager
|
||||||
manager = ExecutionManager(execution=self)
|
manager = ExecutionManager(execution=self)
|
||||||
return manager.run()
|
return manager
|
||||||
|
|
||||||
|
|
||||||
class ChangeSecretMixin(SecretWithRandomMixin):
|
class ChangeSecretMixin(SecretWithRandomMixin):
|
||||||
|
|
|
@ -24,10 +24,7 @@ class ChangeSecretAutomation(ChangeSecretMixin, AccountBaseAutomation):
|
||||||
def to_attr_json(self):
|
def to_attr_json(self):
|
||||||
attr_json = super().to_attr_json()
|
attr_json = super().to_attr_json()
|
||||||
attr_json.update({
|
attr_json.update({
|
||||||
'recipients': {
|
'recipients': [str(r.id) for r in self.recipients.all()]
|
||||||
str(recipient.id): (str(recipient), bool(recipient.secret_key))
|
|
||||||
for recipient in self.recipients.all()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
return attr_json
|
return attr_json
|
||||||
|
|
||||||
|
|
|
@ -15,11 +15,16 @@ __all__ = ['CheckAccountAutomation', 'AccountRisk', 'RiskChoice', 'CheckAccountE
|
||||||
|
|
||||||
class CheckAccountAutomation(AccountBaseAutomation):
|
class CheckAccountAutomation(AccountBaseAutomation):
|
||||||
engines = models.ManyToManyField('CheckAccountEngine', related_name='check_automations', verbose_name=_('Engines'))
|
engines = models.ManyToManyField('CheckAccountEngine', related_name='check_automations', verbose_name=_('Engines'))
|
||||||
|
recipients = models.ManyToManyField('users.User', verbose_name=_("Recipient"), blank=True)
|
||||||
|
|
||||||
|
def get_report_template(self):
|
||||||
|
return 'accounts/check_account_report.html'
|
||||||
|
|
||||||
def to_attr_json(self):
|
def to_attr_json(self):
|
||||||
attr_json = super().to_attr_json()
|
attr_json = super().to_attr_json()
|
||||||
attr_json.update({
|
attr_json.update({
|
||||||
'engines': [engine.slug for engine in self.engines.all()],
|
'engines': [engine.slug for engine in self.engines.all()],
|
||||||
|
'recipients': [str(user.id) for user in self.recipients.all()]
|
||||||
})
|
})
|
||||||
return attr_json
|
return attr_json
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ from common.utils.timezone import is_date_more_than
|
||||||
from orgs.mixins.models import JMSOrgBaseModel
|
from orgs.mixins.models import JMSOrgBaseModel
|
||||||
from .base import AccountBaseAutomation
|
from .base import AccountBaseAutomation
|
||||||
|
|
||||||
__all__ = ['GatherAccountsAutomation', 'GatheredAccount', ]
|
__all__ = ['GatherAccountsAutomation', 'GatheredAccount',]
|
||||||
|
|
||||||
|
|
||||||
class GatheredAccount(JMSOrgBaseModel):
|
class GatheredAccount(JMSOrgBaseModel):
|
||||||
|
|
|
@ -58,7 +58,7 @@ class CheckAccountAutomationSerializer(BaseAutomationSerializer):
|
||||||
model = CheckAccountAutomation
|
model = CheckAccountAutomation
|
||||||
read_only_fields = BaseAutomationSerializer.Meta.read_only_fields
|
read_only_fields = BaseAutomationSerializer.Meta.read_only_fields
|
||||||
fields = BaseAutomationSerializer.Meta.fields \
|
fields = BaseAutomationSerializer.Meta.fields \
|
||||||
+ ['engines'] + read_only_fields
|
+ ['engines', 'recipients'] + read_only_fields
|
||||||
extra_kwargs = BaseAutomationSerializer.Meta.extra_kwargs
|
extra_kwargs = BaseAutomationSerializer.Meta.extra_kwargs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -0,0 +1,108 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<div class='summary'>
|
||||||
|
<p>{% trans 'The following is a summary of the account check tasks. Please review and handle them' %}</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan='2'>任务汇总: </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans 'Task name' %}: </td>
|
||||||
|
<td>{{ execution.automation.name }} </td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans 'Date start' %}: </td>
|
||||||
|
<td>{{ execution.date_start }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans 'Date end' %}: </td>
|
||||||
|
<td>{{ execution.date_finished }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans 'Time using' %}: </td>
|
||||||
|
<td>{{ execution.duration }}s</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans 'Assets count' %}: </td>
|
||||||
|
<td>{{ summary.assets }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans 'Account count' %}: </td>
|
||||||
|
<td>{{ summary.accounts }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans 'Week password count' %}:</td>
|
||||||
|
<td> <span> {{ summary.weak_password }}</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans 'Ok count' %}: </td>
|
||||||
|
<td>{{ summary.ok }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans 'No password count' %}: </td>
|
||||||
|
<td>{{ summary.no_secret }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='result'>
|
||||||
|
<p>{% trans 'Account check details' %}:</p>
|
||||||
|
<table style="">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans 'No.' %}</th>
|
||||||
|
<th>{% trans 'Asset' %}</th>
|
||||||
|
<th>{% trans 'Username' %}</th>
|
||||||
|
<th>{% trans 'Result' %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for account in result.weak_password %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ forloop.counter }}</td>
|
||||||
|
<td>{{ account.asset }}</td>
|
||||||
|
<td>{{ account.username }}</td>
|
||||||
|
<td>{% trans 'Week password' %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f2f2f2;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr :first-child {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result tr :first-child {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</style>
|
|
@ -1,6 +1,6 @@
|
||||||
|
from .gather_facts.manager import GatherFactsManager
|
||||||
from .ping.manager import PingManager
|
from .ping.manager import PingManager
|
||||||
from .ping_gateway.manager import PingGatewayManager
|
from .ping_gateway.manager import PingGatewayManager
|
||||||
from .gather_facts.manager import GatherFactsManager
|
|
||||||
from ..const import AutomationTypes
|
from ..const import AutomationTypes
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,3 +17,6 @@ class ExecutionManager:
|
||||||
|
|
||||||
def run(self, *args, **kwargs):
|
def run(self, *args, **kwargs):
|
||||||
return self._runner.run(*args, **kwargs)
|
return self._runner.run(*args, **kwargs)
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
return getattr(self._runner, item)
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 4.1.13 on 2024-11-15 10:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("assets", "0007_baseautomation_date_last_run_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="automationexecution",
|
||||||
|
name="result",
|
||||||
|
field=models.JSONField(default=dict, verbose_name="Result"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="automationexecution",
|
||||||
|
name="summary",
|
||||||
|
field=models.JSONField(default=dict, verbose_name="Summary"),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.1.13 on 2024-11-15 10:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("assets", "0008_automationexecution_result_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="automationexecution",
|
||||||
|
name="duration",
|
||||||
|
field=models.FloatField(default=0, verbose_name="Duration"),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.1.13 on 2024-11-18 02:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("assets", "0009_automationexecution_duration"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="automationexecution",
|
||||||
|
name="duration",
|
||||||
|
field=models.IntegerField(default=0, verbose_name="Duration"),
|
||||||
|
),
|
||||||
|
]
|
|
@ -11,6 +11,7 @@ from common.const.choices import Trigger
|
||||||
from common.db.fields import EncryptJsonDictTextField
|
from common.db.fields import EncryptJsonDictTextField
|
||||||
from ops.mixin import PeriodTaskModelMixin
|
from ops.mixin import PeriodTaskModelMixin
|
||||||
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel
|
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
|
||||||
class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
||||||
|
@ -21,6 +22,9 @@ class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
||||||
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
|
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
|
||||||
params = models.JSONField(default=dict, verbose_name=_("Parameters"))
|
params = models.JSONField(default=dict, verbose_name=_("Parameters"))
|
||||||
|
|
||||||
|
def get_report_template(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name + '@' + str(self.created_by)
|
return self.name + '@' + str(self.created_by)
|
||||||
|
|
||||||
|
@ -114,6 +118,7 @@ class AutomationExecution(OrgModelMixin):
|
||||||
date_created = models.DateTimeField(auto_now_add=True, verbose_name=_('Date created'))
|
date_created = models.DateTimeField(auto_now_add=True, verbose_name=_('Date created'))
|
||||||
date_start = models.DateTimeField(null=True, verbose_name=_('Date start'), db_index=True)
|
date_start = models.DateTimeField(null=True, verbose_name=_('Date start'), db_index=True)
|
||||||
date_finished = models.DateTimeField(null=True, verbose_name=_("Date finished"))
|
date_finished = models.DateTimeField(null=True, verbose_name=_("Date finished"))
|
||||||
|
duration = models.IntegerField(default=0, verbose_name=_('Duration'))
|
||||||
snapshot = EncryptJsonDictTextField(
|
snapshot = EncryptJsonDictTextField(
|
||||||
default=dict, blank=True, null=True, verbose_name=_('Automation snapshot')
|
default=dict, blank=True, null=True, verbose_name=_('Automation snapshot')
|
||||||
)
|
)
|
||||||
|
@ -121,6 +126,8 @@ class AutomationExecution(OrgModelMixin):
|
||||||
max_length=128, default=Trigger.manual, choices=Trigger.choices,
|
max_length=128, default=Trigger.manual, choices=Trigger.choices,
|
||||||
verbose_name=_('Trigger mode')
|
verbose_name=_('Trigger mode')
|
||||||
)
|
)
|
||||||
|
summary = models.JSONField(default=dict, verbose_name=_('Summary'))
|
||||||
|
result = models.JSONField(default=dict, verbose_name=_('Result'))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('org_id', '-date_start',)
|
ordering = ('org_id', '-date_start',)
|
||||||
|
@ -150,10 +157,14 @@ class AutomationExecution(OrgModelMixin):
|
||||||
def recipients(self):
|
def recipients(self):
|
||||||
recipients = self.snapshot.get('recipients')
|
recipients = self.snapshot.get('recipients')
|
||||||
if not recipients:
|
if not recipients:
|
||||||
return {}
|
return []
|
||||||
return recipients
|
users = User.objects.filter(id__in=recipients)
|
||||||
|
return users
|
||||||
|
|
||||||
|
@property
|
||||||
|
def manager(self):
|
||||||
|
from assets.automations.endpoint import ExecutionManager
|
||||||
|
return ExecutionManager(execution=self)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
from assets.automations.endpoint import ExecutionManager
|
return self.manager.run()
|
||||||
manager = ExecutionManager(execution=self)
|
|
||||||
return manager.run()
|
|
||||||
|
|
|
@ -15,3 +15,30 @@ def get_text_diff(old_text, new_text):
|
||||||
old_text.splitlines(), new_text.splitlines(), lineterm=""
|
old_text.splitlines(), new_text.splitlines(), lineterm=""
|
||||||
)
|
)
|
||||||
return "\n".join(diff)
|
return "\n".join(diff)
|
||||||
|
|
||||||
|
|
||||||
|
def color_fmt(msg, color=None):
|
||||||
|
# ANSI 颜色代码
|
||||||
|
colors = {
|
||||||
|
'red': '\033[91m',
|
||||||
|
'green': '\033[92m',
|
||||||
|
'yellow': '\033[93m',
|
||||||
|
'blue': '\033[94m',
|
||||||
|
'purple': '\033[95m',
|
||||||
|
'cyan': '\033[96m',
|
||||||
|
'default': '\033[0m' # 结束颜色的默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取颜色代码,如果没有指定颜色或颜色不支持,使用默认颜色
|
||||||
|
color_code = colors.get(color, colors['default'])
|
||||||
|
# 打印带颜色的消息
|
||||||
|
return f"{color_code}{msg}{colors['default']}" # 确保在消息结束后重置颜色
|
||||||
|
|
||||||
|
|
||||||
|
def color_print(msg, color=None):
|
||||||
|
print(color_fmt(msg, color))
|
||||||
|
|
||||||
|
|
||||||
|
def color_fill_print(tmp, msg, color=None):
|
||||||
|
text = tmp.format(color_fmt(msg, color))
|
||||||
|
print(text)
|
||||||
|
|
|
@ -2,17 +2,17 @@
|
||||||
#
|
#
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import redis_lock
|
|
||||||
import redis
|
import redis
|
||||||
|
import redis_lock
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.timezone import get_current_timezone
|
|
||||||
from django.db.utils import ProgrammingError, OperationalError
|
from django.db.utils import ProgrammingError, OperationalError
|
||||||
|
from django.utils.timezone import get_current_timezone
|
||||||
from django_celery_beat.models import (
|
from django_celery_beat.models import (
|
||||||
PeriodicTask, IntervalSchedule, CrontabSchedule, PeriodicTasks
|
PeriodicTask, IntervalSchedule, CrontabSchedule, PeriodicTasks
|
||||||
)
|
)
|
||||||
|
|
||||||
from common.utils.timezone import local_now
|
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
|
from common.utils.timezone import local_now
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
@ -67,7 +67,7 @@ def create_or_update_celery_periodic_tasks(tasks):
|
||||||
if crontab is None:
|
if crontab is None:
|
||||||
crontab = CrontabSchedule.objects.create(**kwargs)
|
crontab = CrontabSchedule.objects.create(**kwargs)
|
||||||
else:
|
else:
|
||||||
logger.error("Schedule is not valid")
|
logger.warning("Schedule is not valid: %s" % name)
|
||||||
return
|
return
|
||||||
|
|
||||||
defaults = dict(
|
defaults = dict(
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adal"
|
name = "adal"
|
||||||
|
@ -1613,6 +1613,45 @@ type = "legacy"
|
||||||
url = "https://mirrors.aliyun.com/pypi/simple"
|
url = "https://mirrors.aliyun.com/pypi/simple"
|
||||||
reference = "aliyun"
|
reference = "aliyun"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssselect"
|
||||||
|
version = "1.2.0"
|
||||||
|
description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
files = [
|
||||||
|
{file = "cssselect-1.2.0-py2.py3-none-any.whl", hash = "sha256:da1885f0c10b60c03ed5eccbb6b68d6eff248d91976fcde348f395d54c9fd35e"},
|
||||||
|
{file = "cssselect-1.2.0.tar.gz", hash = "sha256:666b19839cfaddb9ce9d36bfe4c969132c647b92fc9088c4e23f786b30f1b3dc"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://mirrors.aliyun.com/pypi/simple"
|
||||||
|
reference = "aliyun"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssutils"
|
||||||
|
version = "2.11.1"
|
||||||
|
description = "A CSS Cascading Style Sheets library for Python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
files = [
|
||||||
|
{file = "cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1"},
|
||||||
|
{file = "cssutils-2.11.1.tar.gz", hash = "sha256:0563a76513b6af6eebbe788c3bf3d01c920e46b3f90c8416738c5cfc773ff8e2"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
more-itertools = "*"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
||||||
|
test = ["cssselect", "importlib-resources", "jaraco.test (>=5.1)", "lxml", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://mirrors.aliyun.com/pypi/simple"
|
||||||
|
reference = "aliyun"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "daphne"
|
name = "daphne"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
|
@ -3899,6 +3938,22 @@ type = "legacy"
|
||||||
url = "https://mirrors.aliyun.com/pypi/simple"
|
url = "https://mirrors.aliyun.com/pypi/simple"
|
||||||
reference = "aliyun"
|
reference = "aliyun"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "more-itertools"
|
||||||
|
version = "10.5.0"
|
||||||
|
description = "More routines for operating on iterables, beyond itertools"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
files = [
|
||||||
|
{file = "more-itertools-10.5.0.tar.gz", hash = "sha256:5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6"},
|
||||||
|
{file = "more_itertools-10.5.0-py3-none-any.whl", hash = "sha256:037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://mirrors.aliyun.com/pypi/simple"
|
||||||
|
reference = "aliyun"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "msal"
|
name = "msal"
|
||||||
version = "1.29.0"
|
version = "1.29.0"
|
||||||
|
@ -4762,6 +4817,33 @@ type = "legacy"
|
||||||
url = "https://mirrors.aliyun.com/pypi/simple"
|
url = "https://mirrors.aliyun.com/pypi/simple"
|
||||||
reference = "aliyun"
|
reference = "aliyun"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "premailer"
|
||||||
|
version = "3.10.0"
|
||||||
|
description = "Turns CSS blocks into style attributes"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
files = [
|
||||||
|
{file = "premailer-3.10.0-py2.py3-none-any.whl", hash = "sha256:021b8196364d7df96d04f9ade51b794d0b77bcc19e998321c515633a2273be1a"},
|
||||||
|
{file = "premailer-3.10.0.tar.gz", hash = "sha256:d1875a8411f5dc92b53ef9f193db6c0f879dc378d618e0ad292723e388bfe4c2"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
cachetools = "*"
|
||||||
|
cssselect = "*"
|
||||||
|
cssutils = "*"
|
||||||
|
lxml = "*"
|
||||||
|
requests = "*"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["black", "flake8", "therapist", "tox", "twine", "wheel"]
|
||||||
|
test = ["mock", "nose"]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://mirrors.aliyun.com/pypi/simple"
|
||||||
|
reference = "aliyun"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettytable"
|
name = "prettytable"
|
||||||
version = "3.10.0"
|
version = "3.10.0"
|
||||||
|
@ -7670,4 +7752,4 @@ reference = "aliyun"
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.11"
|
python-versions = "^3.11"
|
||||||
content-hash = "9acfafd75bf7dbb7e0dffb54b7f11f6b09aa4ceff769d193a3906d03ae796ccc"
|
content-hash = "184c3ae62b74c9af2a61c7a1e955666da7099bd832ad3c16504b1b3012ff93bb"
|
||||||
|
|
|
@ -165,6 +165,7 @@ polib = "^1.2.0"
|
||||||
# psycopg2 = "2.9.6"
|
# psycopg2 = "2.9.6"
|
||||||
psycopg2-binary = "2.9.6"
|
psycopg2-binary = "2.9.6"
|
||||||
pycountry = "^24.6.1"
|
pycountry = "^24.6.1"
|
||||||
|
premailer = "^3.10.0"
|
||||||
|
|
||||||
[tool.poetry.group.xpack]
|
[tool.poetry.group.xpack]
|
||||||
optional = true
|
optional = true
|
||||||
|
|
Loading…
Reference in New Issue