perf: 优化发送结果

pull/14517/head
ibuler 2024-11-18 11:22:46 +08:00
parent e58054c441
commit ca7d2130a5
25 changed files with 437 additions and 117 deletions

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
#
from django.db.models import Q, Count
from django.http import HttpResponse
from rest_framework.decorators import action
from accounts import serializers
@ -28,8 +29,9 @@ class CheckAccountExecutionViewSet(AutomationExecutionViewSet):
("list", "accounts.view_checkaccountexecution"),
("retrieve", "accounts.view_checkaccountsexecution"),
("create", "accounts.add_checkaccountexecution"),
("report", "accounts.view_checkaccountsexecution"),
)
ordering = ('-date_created',)
tp = AutomationTypes.check_account
def get_queryset(self):
@ -37,6 +39,12 @@ class CheckAccountExecutionViewSet(AutomationExecutionViewSet):
queryset = queryset.filter(automation__type=self.tp)
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):
model = AccountRisk

View File

@ -1,3 +1,5 @@
from django.template.loader import render_to_string
from accounts.automations.methods import platform_automation_methods
from assets.automations.base.manager import BasePlaybookManager
from common.utils import get_logger
@ -6,7 +8,16 @@ logger = get_logger(__name__)
class AccountBasePlaybookManager(BasePlaybookManager):
template_path = ''
@property
def platform_automation_methods(self):
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)

View File

@ -15,7 +15,6 @@ from assets.const import HostTypes
from common.utils import get_logger
from common.utils.file import encrypt_and_compress_zip_file
from common.utils.timezone import local_now_filename
from users.models import User
from ..base.manager import AccountBasePlaybookManager
from ...utils import SecretGenerator
@ -247,7 +246,6 @@ class ChangeSecretManager(AccountBasePlaybookManager):
]
recipients = self.execution.recipients
recipients = User.objects.filter(id__in=list(recipients.keys()))
if not recipients:
return

View File

@ -2,9 +2,14 @@ import re
import time
from collections import defaultdict
from django.template.loader import render_to_string
from django.utils import timezone
from premailer import transform
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):
@ -30,7 +35,6 @@ def is_weak_password(password):
or not re.search(r'[0-9]', password)
or not re.search(r'[\W_]', password)):
return True
return False
@ -38,27 +42,34 @@ def check_account_secrets(accounts, assets):
now = timezone.now().isoformat()
risks = []
tmpl = "Check account %s: %s"
RED = "\033[31m"
GREEN = "\033[32m"
RESET = "\033[0m" # 还原默认颜色
summary = defaultdict(int)
result = defaultdict(list)
summary['accounts'] = len(accounts)
summary['assets'] = len(assets)
for account in accounts:
result_item = {
'asset': str(account.asset),
'username': account.username,
}
if not account.secret:
print(tmpl % (account, "no secret"))
summary['no_secret'] += 1
result['no_secret'].append(result_item)
continue
if is_weak_password(account.secret):
print(tmpl % (account, f"{RED}weak{RESET}"))
summary['weak'] += 1
print(tmpl % (account, color_fmt("weak", "red")))
summary['weak_password'] += 1
result['weak_password'].append(result_item)
risks.append({
'account': account,
'risk': 'weak_password',
})
else:
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_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'],
details=[{'datetime': now}],
)
return summary
return summary, result
class CheckAccountManager:
@ -90,9 +101,12 @@ class CheckAccountManager:
self.timedelta = 0
self.assets = []
self.summary = {}
self.result = defaultdict(list)
def pre_run(self):
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):
for engine in self.execution.snapshot.get('engines', []):
@ -104,18 +118,57 @@ class CheckAccountManager:
for i in range(0, len(self.assets), batch_size):
_assets = self.assets[i:i + batch_size]
accounts = Account.objects.filter(asset__in=_assets)
summary = handle(accounts, _assets)
self.summary.update(summary)
summary, result = handle(accounts, _assets)
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.time_end = time.time()
self.timedelta = self.time_end - self.time_start
tmpl = "\n-\nSummary: ok: %s, weak: %s, no_secret: %s, using time: %ss" % (
self.summary['ok'], self.summary['weak'], self.summary['no_secret'], self.timedelta
self.duration = self.time_end - self.time_start
self.execution.date_finished = timezone.now()
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)
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,):
self.pre_run()
self.batch_run()

View File

@ -18,7 +18,6 @@ class ExecutionManager:
AutomationTypes.gather_accounts: GatherAccountsManager,
AutomationTypes.verify_gateway_account: VerifyGatewayAccountManager,
AutomationTypes.check_account: CheckAccountManager,
# TODO 后期迁移到自动化策略中
'backup_account': AccountBackupManager,
}
@ -28,3 +27,6 @@ class ExecutionManager:
def run(self, *args, **kwargs):
return self._runner.run(*args, **kwargs)
def __getattr__(self, item):
return getattr(self._runner, item)

View File

@ -9,7 +9,6 @@ from common.const import ConfirmOrIgnore
from common.utils import get_logger
from common.utils.strings import get_text_diff
from orgs.utils import tmp_to_org
from users.models import User
from .filter import GatherAccountsFilter
from ..base.manager import AccountBasePlaybookManager
from ...notifications import GatherAccountChangeMsg
@ -313,10 +312,7 @@ class GatherAccountsManager(AccountBasePlaybookManager):
if not self.asset_usernames_mapper or not recipients:
return None, None
users = User.objects.filter(id__in=recipients)
if not users.exists():
return users, None
users = recipients
asset_ids = self.asset_usernames_mapper.keys()
assets = Asset.objects.filter(id__in=asset_ids).prefetch_related('accounts')
gather_accounts = GatheredAccount.objects.filter(asset_id__in=asset_ids, remote_present=True)

View File

@ -7,68 +7,6 @@ logger = get_logger(__name__)
class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
@classmethod
def method_type(cls):
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

View File

@ -48,13 +48,13 @@ class Migration(migrations.Migration):
),
),
migrations.AddField(
model_name='changesecretautomation',
name='check_conn_after_change',
field=models.BooleanField(default=True, verbose_name='Check connection after change'),
),
migrations.AddField(
model_name='pushaccountautomation',
name='check_conn_after_change',
field=models.BooleanField(default=True, verbose_name='Check connection after change'),
),
model_name='changesecretautomation',
name='check_conn_after_change',
field=models.BooleanField(default=True, verbose_name='Check connection after change'),
),
migrations.AddField(
model_name='pushaccountautomation',
name='check_conn_after_change',
field=models.BooleanField(default=True, verbose_name='Check connection after change'),
),
]

View File

@ -5,7 +5,6 @@ import django.db.models.deletion
import uuid
def init_account_check_engine(apps, schema_editor):
data = [
{
@ -26,7 +25,6 @@ def init_account_check_engine(apps, schema_editor):
model_cls.objects.create(**item)
class Migration(migrations.Migration):
dependencies = [

View File

@ -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"
),
),
]

View File

@ -42,10 +42,11 @@ class AutomationExecution(AssetAutomationExecution):
('add_pushaccountexecution', _('Can add push account execution')),
]
def start(self):
@property
def manager(self):
from accounts.automations.endpoint import ExecutionManager
manager = ExecutionManager(execution=self)
return manager.run()
return manager
class ChangeSecretMixin(SecretWithRandomMixin):

View File

@ -24,10 +24,7 @@ class ChangeSecretAutomation(ChangeSecretMixin, AccountBaseAutomation):
def to_attr_json(self):
attr_json = super().to_attr_json()
attr_json.update({
'recipients': {
str(recipient.id): (str(recipient), bool(recipient.secret_key))
for recipient in self.recipients.all()
}
'recipients': [str(r.id) for r in self.recipients.all()]
})
return attr_json

View File

@ -15,11 +15,16 @@ __all__ = ['CheckAccountAutomation', 'AccountRisk', 'RiskChoice', 'CheckAccountE
class CheckAccountAutomation(AccountBaseAutomation):
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):
attr_json = super().to_attr_json()
attr_json.update({
'engines': [engine.slug for engine in self.engines.all()],
'recipients': [str(user.id) for user in self.recipients.all()]
})
return attr_json

View File

@ -9,7 +9,7 @@ from common.utils.timezone import is_date_more_than
from orgs.mixins.models import JMSOrgBaseModel
from .base import AccountBaseAutomation
__all__ = ['GatherAccountsAutomation', 'GatheredAccount', ]
__all__ = ['GatherAccountsAutomation', 'GatheredAccount',]
class GatheredAccount(JMSOrgBaseModel):

View File

@ -58,7 +58,7 @@ class CheckAccountAutomationSerializer(BaseAutomationSerializer):
model = CheckAccountAutomation
read_only_fields = BaseAutomationSerializer.Meta.read_only_fields
fields = BaseAutomationSerializer.Meta.fields \
+ ['engines'] + read_only_fields
+ ['engines', 'recipients'] + read_only_fields
extra_kwargs = BaseAutomationSerializer.Meta.extra_kwargs
@property

View File

@ -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>

View File

@ -1,6 +1,6 @@
from .gather_facts.manager import GatherFactsManager
from .ping.manager import PingManager
from .ping_gateway.manager import PingGatewayManager
from .gather_facts.manager import GatherFactsManager
from ..const import AutomationTypes
@ -17,3 +17,6 @@ class ExecutionManager:
def run(self, *args, **kwargs):
return self._runner.run(*args, **kwargs)
def __getattr__(self, item):
return getattr(self._runner, item)

View File

@ -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"),
),
]

View File

@ -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"),
),
]

View File

@ -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"),
),
]

View File

@ -11,6 +11,7 @@ from common.const.choices import Trigger
from common.db.fields import EncryptJsonDictTextField
from ops.mixin import PeriodTaskModelMixin
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel
from users.models import User
class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
@ -21,6 +22,9 @@ class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
params = models.JSONField(default=dict, verbose_name=_("Parameters"))
def get_report_template(self):
raise NotImplementedError
def __str__(self):
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_start = models.DateTimeField(null=True, verbose_name=_('Date start'), db_index=True)
date_finished = models.DateTimeField(null=True, verbose_name=_("Date finished"))
duration = models.IntegerField(default=0, verbose_name=_('Duration'))
snapshot = EncryptJsonDictTextField(
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,
verbose_name=_('Trigger mode')
)
summary = models.JSONField(default=dict, verbose_name=_('Summary'))
result = models.JSONField(default=dict, verbose_name=_('Result'))
class Meta:
ordering = ('org_id', '-date_start',)
@ -150,10 +157,14 @@ class AutomationExecution(OrgModelMixin):
def recipients(self):
recipients = self.snapshot.get('recipients')
if not recipients:
return {}
return recipients
return []
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):
from assets.automations.endpoint import ExecutionManager
manager = ExecutionManager(execution=self)
return manager.run()
return self.manager.run()

View File

@ -15,3 +15,30 @@ def get_text_diff(old_text, new_text):
old_text.splitlines(), new_text.splitlines(), lineterm=""
)
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)

View File

@ -2,17 +2,17 @@
#
import json
import redis_lock
import redis
import redis_lock
from django.conf import settings
from django.utils.timezone import get_current_timezone
from django.db.utils import ProgrammingError, OperationalError
from django.utils.timezone import get_current_timezone
from django_celery_beat.models import (
PeriodicTask, IntervalSchedule, CrontabSchedule, PeriodicTasks
)
from common.utils.timezone import local_now
from common.utils import get_logger
from common.utils.timezone import local_now
logger = get_logger(__name__)
@ -67,7 +67,7 @@ def create_or_update_celery_periodic_tasks(tasks):
if crontab is None:
crontab = CrontabSchedule.objects.create(**kwargs)
else:
logger.error("Schedule is not valid")
logger.warning("Schedule is not valid: %s" % name)
return
defaults = dict(

86
poetry.lock generated
View File

@ -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]]
name = "adal"
@ -1613,6 +1613,45 @@ type = "legacy"
url = "https://mirrors.aliyun.com/pypi/simple"
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]]
name = "daphne"
version = "4.0.0"
@ -3899,6 +3938,22 @@ type = "legacy"
url = "https://mirrors.aliyun.com/pypi/simple"
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]]
name = "msal"
version = "1.29.0"
@ -4762,6 +4817,33 @@ type = "legacy"
url = "https://mirrors.aliyun.com/pypi/simple"
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]]
name = "prettytable"
version = "3.10.0"
@ -7670,4 +7752,4 @@ reference = "aliyun"
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "9acfafd75bf7dbb7e0dffb54b7f11f6b09aa4ceff769d193a3906d03ae796ccc"
content-hash = "184c3ae62b74c9af2a61c7a1e955666da7099bd832ad3c16504b1b3012ff93bb"

View File

@ -165,6 +165,7 @@ polib = "^1.2.0"
# psycopg2 = "2.9.6"
psycopg2-binary = "2.9.6"
pycountry = "^24.6.1"
premailer = "^3.10.0"
[tool.poetry.group.xpack]
optional = true