浏览代码

Merge branch 'develop' into refactor-db-exists

version-14
Raffael Meyer 3 年前
committed by GitHub
父节点
当前提交
621a473614
找不到此签名对应的密钥 GPG 密钥 ID: 4AEE18F83AFDEB23
共有 18 个文件被更改,包括 213 次插入192 次删除
  1. +9
    -7
      frappe/commands/site.py
  2. +39
    -0
      frappe/core/doctype/communication/communication.py
  3. +100
    -22
      frappe/core/doctype/communication/email.py
  4. +1
    -2
      frappe/core/doctype/user/user.json
  5. +25
    -16
      frappe/desk/form/linked_with.py
  6. +1
    -2
      frappe/email/doctype/newsletter/newsletter.json
  7. +4
    -3
      frappe/email/doctype/notification/notification.py
  8. +1
    -7
      frappe/email/email_body.py
  9. +2
    -2
      frappe/model/rename_doc.py
  10. +9
    -98
      frappe/public/js/frappe/form/linked_with.js
  11. +12
    -4
      frappe/public/js/frappe/list/base_list.js
  12. +2
    -19
      frappe/public/js/frappe/list/list_view.js
  13. +4
    -3
      frappe/public/js/frappe/views/reports/report_view.js
  14. +0
    -1
      frappe/templates/emails/standard.html
  15. +1
    -2
      frappe/website/doctype/blog_post/blog_post.json
  16. +1
    -2
      frappe/website/doctype/web_page/web_page.json
  17. +1
    -2
      frappe/website/doctype/website_settings/website_settings.json
  18. +1
    -0
      requirements.txt

+ 9
- 7
frappe/commands/site.py 查看文件

@@ -1,7 +1,7 @@
# imports - standard imports
import os
import sys
import shutil
import sys

# imports - third party imports
import click
@@ -65,11 +65,11 @@ def restore(context, sql_file_path, encryption_key=None, db_root_username=None,
"Restore site database from an sql file"
from frappe.installer import (
_new_site,
extract_sql_from_archive,
extract_files,
extract_sql_from_archive,
is_downgrade,
is_partial,
validate_database_sql
validate_database_sql,
)
from frappe.utils.backups import Backup
if not os.path.exists(sql_file_path):
@@ -207,7 +207,7 @@ def restore(context, sql_file_path, encryption_key=None, db_root_username=None,
@click.option('--encryption-key', help='Backup encryption key')
@pass_context
def partial_restore(context, sql_file_path, verbose, encryption_key=None):
from frappe.installer import partial_restore, extract_sql_from_archive
from frappe.installer import extract_sql_from_archive, partial_restore
from frappe.utils.backups import Backup

if not os.path.exists(sql_file_path):
@@ -545,7 +545,7 @@ def _use(site, sites_path='.'):

def use(site, sites_path='.'):
if os.path.exists(os.path.join(sites_path, site)):
with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile:
with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile:
sitefile.write(site)
print("Current Site set to {}".format(site))
else:
@@ -751,6 +751,7 @@ def set_admin_password(context, admin_password=None, logout_all_sessions=False):

def set_user_password(site, user, password, logout_all_sessions=False):
import getpass

from frappe.utils.password import update_password

try:
@@ -881,15 +882,16 @@ def stop_recording(context):
raise SiteNotSpecifiedError

@click.command('ngrok')
@click.option('--bind-tls', is_flag=True, default=False, help='Returns a reference to the https tunnel.')
@pass_context
def start_ngrok(context):
def start_ngrok(context, bind_tls):
from pyngrok import ngrok

site = get_site(context)
frappe.init(site=site)

port = frappe.conf.http_port or frappe.conf.webserver_port
tunnel = ngrok.connect(addr=str(port), host_header=site)
tunnel = ngrok.connect(addr=str(port), host_header=site, bind_tls=bind_tls)
print(f'Public URL: {tunnel.public_url}')
print('Inspect logs at http://localhost:4040')



+ 39
- 0
frappe/core/doctype/communication/communication.py 查看文件

@@ -18,6 +18,7 @@ from urllib.parse import unquote
from frappe.utils.user import is_system_user
from frappe.contacts.doctype.contact.contact import get_contact_name
from frappe.automation.doctype.assignment_rule.assignment_rule import apply as apply_assignment_rule
from parse import compile

exclude_from_linked_with = True

@@ -114,6 +115,44 @@ class Communication(Document, CommunicationEmailMixin):
frappe.publish_realtime('new_message', self.as_dict(),
user=self.reference_name, after_commit=True)

def set_signature_in_email_content(self):
"""Set sender's User.email_signature or default outgoing's EmailAccount.signature to the email
"""
if not self.content:
return

quill_parser = compile('<div class="ql-editor read-mode">{}</div>')
email_body = quill_parser.parse(self.content)

if not email_body:
return

email_body = email_body[0]

user_email_signature = frappe.db.get_value(
"User",
self.sender,
"email_signature",
) if self.sender else None

signature = user_email_signature or frappe.db.get_value(
"Email Account",
{"default_outgoing": 1, "add_signature": 1},
"signature",
)

if not signature:
return

_signature = quill_parser.parse(signature)[0] if "ql-editor" in signature else None

if (_signature or signature) not in self.content:
self.content = f'{self.content}</p><br><p class="signature">{signature}'

def before_save(self):
if not self.flags.skip_add_signature:
self.set_signature_in_email_content()

def on_update(self):
# add to _comment property of the doctype, so it shows up in
# comments count for the list view


+ 100
- 22
frappe/core/doctype/communication/email.py 查看文件

@@ -22,12 +22,30 @@ OUTGOING_EMAIL_ACCOUNT_MISSING = _("""


@frappe.whitelist()
def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "Sent",
sender=None, sender_full_name=None, recipients=None, communication_medium="Email", send_email=False,
print_html=None, print_format=None, attachments='[]', send_me_a_copy=False, cc=None, bcc=None,
flags=None, read_receipt=None, print_letterhead=True, email_template=None, communication_type=None,
ignore_permissions=False) -> Dict[str, str]:
"""Make a new communication.
def make(
doctype=None,
name=None,
content=None,
subject=None,
sent_or_received="Sent",
sender=None,
sender_full_name=None,
recipients=None,
communication_medium="Email",
send_email=False,
print_html=None,
print_format=None,
attachments="[]",
send_me_a_copy=False,
cc=None,
bcc=None,
read_receipt=None,
print_letterhead=True,
email_template=None,
communication_type=None,
**kwargs,
) -> Dict[str, str]:
"""Make a new communication. Checks for email permissions for specified Document.

:param doctype: Reference DocType.
:param name: Reference Document name.
@@ -44,17 +62,71 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received =
:param send_me_a_copy: Send a copy to the sender (default **False**).
:param email_template: Template which is used to compose mail .
"""
is_error_report = (doctype=="User" and name==frappe.session.user and subject=="Error Report")
send_me_a_copy = cint(send_me_a_copy)
if kwargs:
from frappe.utils.commands import warn
warn(
f"Options {kwargs} used in frappe.core.doctype.communication.email.make "
"are deprecated or unsupported",
category=DeprecationWarning
)

if doctype and name and not frappe.has_permission(doctype=doctype, ptype="email", doc=name):
raise frappe.PermissionError(
f"You are not allowed to send emails related to: {doctype} {name}"
)

return _make(
doctype=doctype,
name=name,
content=content,
subject=subject,
sent_or_received=sent_or_received,
sender=sender,
sender_full_name=sender_full_name,
recipients=recipients,
communication_medium=communication_medium,
send_email=send_email,
print_html=print_html,
print_format=print_format,
attachments=attachments,
send_me_a_copy=cint(send_me_a_copy),
cc=cc,
bcc=bcc,
read_receipt=read_receipt,
print_letterhead=print_letterhead,
email_template=email_template,
communication_type=communication_type,
add_signature=False,
)

if not ignore_permissions:
if doctype and name and not is_error_report and not frappe.has_permission(doctype, "email", name) and not (flags or {}).get('ignore_doctype_permissions'):
raise frappe.PermissionError("You are not allowed to send emails related to: {doctype} {name}".format(
doctype=doctype, name=name))

if not sender:
sender = get_formatted_email(frappe.session.user)
def _make(
doctype=None,
name=None,
content=None,
subject=None,
sent_or_received="Sent",
sender=None,
sender_full_name=None,
recipients=None,
communication_medium="Email",
send_email=False,
print_html=None,
print_format=None,
attachments="[]",
send_me_a_copy=False,
cc=None,
bcc=None,
read_receipt=None,
print_letterhead=True,
email_template=None,
communication_type=None,
add_signature=True,
) -> Dict[str, str]:
"""Internal method to make a new communication that ignores Permission checks.
"""

sender = sender or get_formatted_email(frappe.session.user)
recipients = list_to_str(recipients) if isinstance(recipients, list) else recipients
cc = list_to_str(cc) if isinstance(cc, list) else cc
bcc = list_to_str(bcc) if isinstance(bcc, list) else bcc
@@ -77,7 +149,9 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received =
"read_receipt":read_receipt,
"has_attachment": 1 if attachments else 0,
"communication_type": communication_type,
}).insert(ignore_permissions=True)
})
comm.flags.skip_add_signature = not add_signature
comm.insert(ignore_permissions=True)

# if not committed, delayed task doesn't find the communication
if attachments:
@@ -87,17 +161,21 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received =

if cint(send_email):
if not comm.get_outgoing_email_account():
frappe.throw(msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError)
frappe.throw(
msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError
)

comm.send_email(print_html=print_html, print_format=print_format,
send_me_a_copy=send_me_a_copy, print_letterhead=print_letterhead)
comm.send_email(
print_html=print_html,
print_format=print_format,
send_me_a_copy=send_me_a_copy,
print_letterhead=print_letterhead,
)

emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy)

return {
"name": comm.name,
"emails_not_sent_to": ", ".join(emails_not_sent_to)
}
return {"name": comm.name, "emails_not_sent_to": ", ".join(emails_not_sent_to)}


def validate_email(doc: "Communication") -> None:
"""Validate Email Addresses of Recipients and CC"""


+ 1
- 2
frappe/core/doctype/user/user.json 查看文件

@@ -668,8 +668,7 @@
"link_fieldname": "user"
}
],
"max_attachments": 5,
"modified": "2022-01-03 11:53:25.250822",
"modified": "2022-03-09 01:47:56.745069",
"modified_by": "Administrator",
"module": "Core",
"name": "User",


+ 25
- 16
frappe/desk/form/linked_with.py 查看文件

@@ -1,9 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE

import json
from collections import defaultdict
import itertools
from typing import List
from typing import Dict, List, Optional

import frappe
import frappe.desk.form.load
@@ -367,7 +368,7 @@ def get_exempted_doctypes():


@frappe.whitelist()
def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
def get_linked_docs(doctype: str, name: str, linkinfo: Optional[Dict] = None) -> Dict[str, List]:
if isinstance(linkinfo, str):
# additional fields are added in linkinfo
linkinfo = json.loads(linkinfo)
@@ -377,23 +378,21 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
if not linkinfo:
return results

if for_doctype:
links = frappe.get_doc(doctype, name).get_link_filters(for_doctype)

if links:
linkinfo = links

if for_doctype in linkinfo:
# only get linked with for this particular doctype
linkinfo = { for_doctype: linkinfo.get(for_doctype) }
else:
return results

for dt, link in linkinfo.items():
filters = []
link["doctype"] = dt
link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt)
try:
link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt)
except Exception as e:
if isinstance(e, frappe.DoesNotExistError):
if frappe.local.message_log:
frappe.local.message_log.pop()
continue
linkmeta = link_meta_bundle[0]

if not linkmeta.has_permission():
continue

if not linkmeta.get("issingle"):
fields = [d.fieldname for d in linkmeta.get("fields", {
"in_list_view": 1,
@@ -456,6 +455,13 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):

return results


@frappe.whitelist()
def get(doctype, docname):
linked_doctypes = get_linked_doctypes(doctype=doctype)
return get_linked_docs(doctype=doctype, name=docname, linkinfo=linked_doctypes)


@frappe.whitelist()
def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
"""add list of doctypes this doctype is 'linked' with.
@@ -470,6 +476,7 @@ def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
else:
return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype))


def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
ret = {}
# find fields where this doctype is linked
@@ -499,6 +506,7 @@ def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False)

return ret


def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):

filters = [['fieldtype','=', 'Link'], ['options', '=', doctype]]
@@ -529,6 +537,7 @@ def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):

return ret


def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False):
ret = {}



+ 1
- 2
frappe/email/doctype/newsletter/newsletter.json 查看文件

@@ -236,8 +236,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"max_attachments": 3,
"modified": "2021-12-06 20:09:37.963141",
"modified": "2022-03-09 01:48:16.741603",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter",


+ 4
- 3
frappe/email/doctype/notification/notification.py 查看文件

@@ -186,7 +186,7 @@ def get_context(context):

def send_an_email(self, doc, context):
from email.utils import formataddr
from frappe.core.doctype.communication.email import make as make_communication
from frappe.core.doctype.communication.email import _make as make_communication
subject = self.subject
if "{" in subject:
subject = frappe.render_template(self.subject, context)
@@ -216,7 +216,8 @@ def get_context(context):
# Add mail notification to communication list
# No need to add if it is already a communication.
if doc.doctype != 'Communication':
make_communication(doctype=doc.doctype,
make_communication(
doctype=doc.doctype,
name=doc.name,
content=message,
subject=subject,
@@ -228,7 +229,7 @@ def get_context(context):
cc=cc,
bcc=bcc,
communication_type='Automated Message',
ignore_permissions=True)
)

def send_a_slack_msg(self, doc, context):
send_slack_message(


+ 1
- 7
frappe/email/email_body.py 查看文件

@@ -259,17 +259,12 @@ def get_formatted_html(subject, message, footer=None, print_html=None,

email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender)

signature = None
if "<!-- signature-included -->" not in message:
signature = get_signature(email_account)

rendered_email = frappe.get_template("templates/emails/standard.html").render({
"brand_logo": get_brand_logo(email_account) if with_container or header else None,
"with_container": with_container,
"site_url": get_url(),
"header": get_header(header),
"content": message,
"signature": signature,
"footer": get_footer(email_account, footer),
"title": subject,
"print_html": print_html,
@@ -281,8 +276,7 @@ def get_formatted_html(subject, message, footer=None, print_html=None,
if unsubscribe_link:
html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html)

html = inline_style_in_html(html)
return html
return inline_style_in_html(html)

@frappe.whitelist()
def get_email_html(template, args, subject, header=None, with_container=False):


+ 2
- 2
frappe/model/rename_doc.py 查看文件

@@ -43,8 +43,8 @@ def update_document_title(

title_field = doc.meta.get_title_field()

title_updated = (title_field != "name") and (updated_title != doc.get(title_field))
name_updated = updated_name != doc.name
title_updated = updated_title and (title_field != "name") and (updated_title != doc.get(title_field))
name_updated = updated_name and (updated_name != doc.name)

if name_updated:
docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge)


+ 9
- 98
frappe/public/js/frappe/form/linked_with.js 查看文件

@@ -1,9 +1,8 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See license.txt
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See LICENSE


frappe.ui.form.LinkedWith = class LinkedWith {

constructor(opts) {
$.extend(this, opts);
}
@@ -21,29 +20,23 @@ frappe.ui.form.LinkedWith = class LinkedWith {
}

make_dialog() {

this.dialog = new frappe.ui.Dialog({
title: __("Linked With")
});

this.dialog.on_page_show = () => {
// execute ajax calls sequentially
// 1. get linked doctypes
// 2. load all doctypes
// 3. load linked docs
this.get_linked_doctypes()
.then(() => this.load_doctypes())
.then(() => this.links_not_permitted_or_missing())
.then(() => this.get_linked_docs())
.then(() => this.make_html());
frappe.xcall(
"frappe.desk.form.linked_with.get",
{"doctype": cur_frm.doctype, "docname": cur_frm.docname},
).then(r => {
this.frm.__linked_docs = r;
}).then(() => this.make_html());
};
}

make_html() {
const linked_docs = this.frm.__linked_docs;

let html = '';
const linked_docs = this.frm.__linked_docs;
const linked_doctypes = Object.keys(linked_docs);

if (linked_doctypes.length === 0) {
@@ -63,88 +56,6 @@ frappe.ui.form.LinkedWith = class LinkedWith {
$(this.dialog.body).html(html);
}

load_doctypes() {
const already_loaded = Object.keys(locals.DocType);
let doctypes_to_load = [];

if (this.frm.__linked_doctypes) {
doctypes_to_load =
Object.keys(this.frm.__linked_doctypes)
.filter(doctype => !already_loaded.includes(doctype));
}

// load all doctypes asynchronously using with_doctype
const promises = doctypes_to_load.map(dt => {
return frappe.model.with_doctype(dt, () => {
if(frappe.listview_settings[dt]) {
// add additional fields to __linked_doctypes
this.frm.__linked_doctypes[dt].add_fields =
frappe.listview_settings[dt].add_fields;
}
});
});

return Promise.all(promises);
}

links_not_permitted_or_missing() {
let links = null;

if (this.frm.__linked_doctypes) {
links =
Object.keys(this.frm.__linked_doctypes)
.filter(frappe.model.can_get_report);
}

let flag;
if(!links) {
$(this.dialog.body).html(`${this.frm.__linked_doctypes
? __("Not enough permission to see links")
: __("Not Linked to any record")}`);
flag = true;
}
flag = false;

// reject Promise if not_permitted or missing
return new Promise(
(resolve, reject) => flag ? reject() : resolve()
);
}

get_linked_doctypes() {
return new Promise((resolve) => {
if (this.frm.__linked_doctypes) {
resolve();
}

frappe.call({
method: "frappe.desk.form.linked_with.get_linked_doctypes",
args: {
doctype: this.frm.doctype
},
callback: (r) => {
this.frm.__linked_doctypes = r.message;
resolve();
}
});
});
}

get_linked_docs() {
return frappe.call({
method: "frappe.desk.form.linked_with.get_linked_docs",
args: {
doctype: this.frm.doctype,
name: this.frm.docname,
linkinfo: this.frm.__linked_doctypes,
for_doctype: this.for_doctype
},
callback: (r) => {
this.frm.__linked_docs = r.message || {};
}
});
}

make_doc_head(heading) {
return `
<header class="level list-row list-row-head text-muted small">


+ 12
- 4
frappe/public/js/frappe/list/base_list.js 查看文件

@@ -760,6 +760,10 @@ class FilterArea {

const doctype_fields = this.list_view.meta.fields;
const title_field = this.list_view.meta.title_field;
const has_existing_filters = (
this.list_view.filters
&& this.list_view.filters.length > 0
);

fields = fields.concat(
doctype_fields
@@ -794,13 +798,17 @@ class FilterArea {
options = options.join("\n");
}
}
let default_value =
fieldtype === "Link"
? frappe.defaults.get_user_default(options)
: null;

let default_value;

if (fieldtype === "Link" && !has_existing_filters) {
default_value = frappe.defaults.get_user_default(options);
}

if (["__default", "__global"].includes(default_value)) {
default_value = null;
}

return {
fieldtype: fieldtype,
label: __(df.label),


+ 2
- 19
frappe/public/js/frappe/list/list_view.js 查看文件

@@ -83,32 +83,15 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
this.sort_by = this.view_user_settings.sort_by || "modified";
this.sort_order = this.view_user_settings.sort_order || "desc";

// set filters from user_settings or list_settings
if (
this.view_user_settings.filters &&
this.view_user_settings.filters.length
) {
// Priority 1: user_settings
const saved_filters = this.view_user_settings.filters;
this.filters = this.validate_filters(saved_filters);
} else {
// Priority 2: filters in listview_settings
this.filters = (this.settings.filters || []).map((f) => {
if (f.length === 3) {
f = [this.doctype, f[0], f[1], f[2]];
}
return f;
});
}

// build menu items
this.menu_items = this.menu_items.concat(this.get_menu_items());

// set filters from view_user_settings or list_settings
if (
this.view_user_settings.filters &&
this.view_user_settings.filters.length
) {
// Priority 1: saved filters
// Priority 1: view_user_settings
const saved_filters = this.view_user_settings.filters;
this.filters = this.validate_filters(saved_filters);
} else {


+ 4
- 3
frappe/public/js/frappe/views/reports/report_view.js 查看文件

@@ -125,11 +125,12 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
}

after_render() {
if (this.report_doc) {
this.set_dirty_state_for_custom_report();
} else {
if (!this.report_doc) {
this.save_report_settings();
} else if (!$.isEmptyObject(this.report_doc.json)) {
this.set_dirty_state_for_custom_report();
}

if (!this.group_by) {
this.init_chart();
}


+ 0
- 1
frappe/templates/emails/standard.html 查看文件

@@ -37,7 +37,6 @@
<tr>
<td valign="top">
<p>{{ content }}</p>
<p class="signature">{{ signature }}</p>
</td>
</tr>
</table>


+ 1
- 2
frappe/website/doctype/blog_post/blog_post.json 查看文件

@@ -213,8 +213,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"max_attachments": 5,
"modified": "2021-11-23 10:42:01.759723",
"modified": "2022-03-09 01:48:25.227295",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Post",


+ 1
- 2
frappe/website/doctype/web_page/web_page.json 查看文件

@@ -338,8 +338,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"max_attachments": 20,
"modified": "2022-01-03 13:01:48.182645",
"modified": "2022-03-09 01:45:28.548671",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Page",


+ 1
- 2
frappe/website/doctype/website_settings/website_settings.json 查看文件

@@ -420,8 +420,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"max_attachments": 10,
"modified": "2022-02-24 15:37:22.360138",
"modified": "2022-03-09 01:47:31.094462",
"modified_by": "Administrator",
"module": "Website",
"name": "Website Settings",


+ 1
- 0
requirements.txt 查看文件

@@ -29,6 +29,7 @@ maxminddb-geolite2==2018.703
num2words~=0.5.10
oauthlib~=3.1.0
openpyxl~=3.0.7
parse~=1.19.0
passlib~=1.7.4
paytmchecksum~=1.7.0
pdfkit~=0.6.1


正在加载...
取消
保存