Merge branch 'develop' into add-login-fields
This commit is contained in:
commit
f4ff67dacd
114 changed files with 3880 additions and 845 deletions
38
.github/helper/semgrep_rules/README.md
vendored
38
.github/helper/semgrep_rules/README.md
vendored
|
|
@ -1,38 +0,0 @@
|
|||
# Semgrep linting
|
||||
|
||||
## What is semgrep?
|
||||
Semgrep or "semantic grep" is language agnostic static analysis tool. In simple terms semgrep is syntax-aware `grep`, so unlike regex it doesn't get confused by different ways of writing same thing or whitespaces or code split in multiple lines etc.
|
||||
|
||||
Example:
|
||||
|
||||
To check if a translate function is using f-string or not the regex would be `r"_\(\s*f[\"']"` while equivalent rule in semgrep would be `_(f"...")`. As semgrep knows grammer of language it takes care of unnecessary whitespace, type of quotation marks etc.
|
||||
|
||||
You can read more such examples in `.github/helper/semgrep_rules` directory.
|
||||
|
||||
# Why/when to use this?
|
||||
We want to maintain quality of contributions, at the same time remembering all the good practices can be pain to deal with while evaluating contributions. Using semgrep if you can translate "best practice" into a rule then it can automate the task for us.
|
||||
|
||||
## Running locally
|
||||
|
||||
Install semgrep using homebrew `brew install semgrep` or pip `pip install semgrep`.
|
||||
|
||||
To run locally use following command:
|
||||
|
||||
`semgrep --config=.github/helper/semgrep_rules [file/folder names]`
|
||||
|
||||
## Testing
|
||||
semgrep allows testing the tests. Refer to this page: https://semgrep.dev/docs/writing-rules/testing-rules/
|
||||
|
||||
When writing new rules you should write few positive and few negative cases as shown in the guide and current tests.
|
||||
|
||||
To run current tests: `semgrep --test --test-ignore-todo .github/helper/semgrep_rules`
|
||||
|
||||
|
||||
## Reference
|
||||
|
||||
If you are new to Semgrep read following pages to get started on writing/modifying rules:
|
||||
|
||||
- https://semgrep.dev/docs/getting-started/
|
||||
- https://semgrep.dev/docs/writing-rules/rule-syntax
|
||||
- https://semgrep.dev/docs/writing-rules/pattern-examples/
|
||||
- https://semgrep.dev/docs/writing-rules/rule-ideas/#common-use-cases
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import frappe
|
||||
from frappe import _, flt
|
||||
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
# ruleid: frappe-modifying-but-not-comitting
|
||||
def on_submit(self):
|
||||
if self.value_of_goods == 0:
|
||||
frappe.throw(_('Value of goods cannot be 0'))
|
||||
self.status = 'Submitted'
|
||||
|
||||
|
||||
# ok: frappe-modifying-but-not-comitting
|
||||
def on_submit(self):
|
||||
if self.value_of_goods == 0:
|
||||
frappe.throw(_('Value of goods cannot be 0'))
|
||||
self.status = 'Submitted'
|
||||
self.db_set('status', 'Submitted')
|
||||
|
||||
# ok: frappe-modifying-but-not-comitting
|
||||
def on_submit(self):
|
||||
if self.value_of_goods == 0:
|
||||
frappe.throw(_('Value of goods cannot be 0'))
|
||||
x = "y"
|
||||
self.status = x
|
||||
self.db_set('status', x)
|
||||
|
||||
|
||||
# ok: frappe-modifying-but-not-comitting
|
||||
def on_submit(self):
|
||||
x = "y"
|
||||
self.status = x
|
||||
self.save()
|
||||
|
||||
# ruleid: frappe-modifying-but-not-comitting-other-method
|
||||
class DoctypeClass(Document):
|
||||
def on_submit(self):
|
||||
self.good_method()
|
||||
self.tainted_method()
|
||||
|
||||
def tainted_method(self):
|
||||
self.status = "uptate"
|
||||
|
||||
|
||||
# ok: frappe-modifying-but-not-comitting-other-method
|
||||
class DoctypeClass(Document):
|
||||
def on_submit(self):
|
||||
self.good_method()
|
||||
self.tainted_method()
|
||||
|
||||
def tainted_method(self):
|
||||
self.status = "update"
|
||||
self.db_set("status", "update")
|
||||
|
||||
# ok: frappe-modifying-but-not-comitting-other-method
|
||||
class DoctypeClass(Document):
|
||||
def on_submit(self):
|
||||
self.good_method()
|
||||
self.tainted_method()
|
||||
self.save()
|
||||
|
||||
def tainted_method(self):
|
||||
self.status = "uptate"
|
||||
146
.github/helper/semgrep_rules/frappe_correctness.yml
vendored
146
.github/helper/semgrep_rules/frappe_correctness.yml
vendored
|
|
@ -1,146 +0,0 @@
|
|||
# This file specifies rules for correctness according to how frappe doctype data model works.
|
||||
|
||||
rules:
|
||||
- id: frappe-modifying-but-not-comitting
|
||||
patterns:
|
||||
- pattern: |
|
||||
def $METHOD(self, ...):
|
||||
...
|
||||
self.$ATTR = ...
|
||||
- pattern-not: |
|
||||
def $METHOD(self, ...):
|
||||
...
|
||||
self.$ATTR = ...
|
||||
...
|
||||
self.db_set(..., self.$ATTR, ...)
|
||||
- pattern-not: |
|
||||
def $METHOD(self, ...):
|
||||
...
|
||||
self.$ATTR = $SOME_VAR
|
||||
...
|
||||
self.db_set(..., $SOME_VAR, ...)
|
||||
- pattern-not: |
|
||||
def $METHOD(self, ...):
|
||||
...
|
||||
self.$ATTR = $SOME_VAR
|
||||
...
|
||||
self.save()
|
||||
- metavariable-regex:
|
||||
metavariable: '$ATTR'
|
||||
# this is negative look-ahead, add more attrs to ignore like (ignore|ignore_this_too|ignore_me)
|
||||
regex: '^(?!ignore_linked_doctypes|status_updater)(.*)$'
|
||||
- metavariable-regex:
|
||||
metavariable: "$METHOD"
|
||||
regex: "(on_submit|on_cancel)"
|
||||
message: |
|
||||
DocType modified in self.$METHOD. Please check if modification of self.$ATTR is commited to database.
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
|
||||
- id: frappe-modifying-but-not-comitting-other-method
|
||||
patterns:
|
||||
- pattern: |
|
||||
class $DOCTYPE(...):
|
||||
def $METHOD(self, ...):
|
||||
...
|
||||
self.$ANOTHER_METHOD()
|
||||
...
|
||||
|
||||
def $ANOTHER_METHOD(self, ...):
|
||||
...
|
||||
self.$ATTR = ...
|
||||
- pattern-not: |
|
||||
class $DOCTYPE(...):
|
||||
def $METHOD(self, ...):
|
||||
...
|
||||
self.$ANOTHER_METHOD()
|
||||
...
|
||||
|
||||
def $ANOTHER_METHOD(self, ...):
|
||||
...
|
||||
self.$ATTR = ...
|
||||
...
|
||||
self.db_set(..., self.$ATTR, ...)
|
||||
- pattern-not: |
|
||||
class $DOCTYPE(...):
|
||||
def $METHOD(self, ...):
|
||||
...
|
||||
self.$ANOTHER_METHOD()
|
||||
...
|
||||
|
||||
def $ANOTHER_METHOD(self, ...):
|
||||
...
|
||||
self.$ATTR = $SOME_VAR
|
||||
...
|
||||
self.db_set(..., $SOME_VAR, ...)
|
||||
- pattern-not: |
|
||||
class $DOCTYPE(...):
|
||||
def $METHOD(self, ...):
|
||||
...
|
||||
self.$ANOTHER_METHOD()
|
||||
...
|
||||
self.save()
|
||||
def $ANOTHER_METHOD(self, ...):
|
||||
...
|
||||
self.$ATTR = ...
|
||||
- metavariable-regex:
|
||||
metavariable: "$METHOD"
|
||||
regex: "(on_submit|on_cancel)"
|
||||
message: |
|
||||
self.$ANOTHER_METHOD is called from self.$METHOD, check if changes to self.$ATTR are commited to database.
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
|
||||
- id: frappe-print-function-in-doctypes
|
||||
pattern: print(...)
|
||||
message: |
|
||||
Did you mean to leave this print statement in? Consider using msgprint or logger instead of print statement.
|
||||
languages: [python]
|
||||
severity: WARNING
|
||||
paths:
|
||||
include:
|
||||
- "*/**/doctype/*"
|
||||
|
||||
- id: frappe-modifying-child-tables-while-iterating
|
||||
pattern-either:
|
||||
- pattern: |
|
||||
for $ROW in self.$TABLE:
|
||||
...
|
||||
self.remove(...)
|
||||
- pattern: |
|
||||
for $ROW in self.$TABLE:
|
||||
...
|
||||
self.append(...)
|
||||
message: |
|
||||
Child table being modified while iterating on it.
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
paths:
|
||||
include:
|
||||
- "*/**/doctype/*"
|
||||
|
||||
- id: frappe-same-key-assigned-twice
|
||||
pattern-either:
|
||||
- pattern: |
|
||||
{..., $X: $A, ..., $X: $B, ...}
|
||||
- pattern: |
|
||||
dict(..., ($X, $A), ..., ($X, $B), ...)
|
||||
- pattern: |
|
||||
_dict(..., ($X, $A), ..., ($X, $B), ...)
|
||||
message: |
|
||||
key `$X` is uselessly assigned twice. This could be a potential bug.
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
|
||||
- id: frappe-using-db-sql
|
||||
pattern-either:
|
||||
- pattern: frappe.db.sql(...)
|
||||
- pattern: frappe.db.sql_ddl(...)
|
||||
- pattern: frappe.db.sql_list(...)
|
||||
paths:
|
||||
exclude:
|
||||
- "test_*.py"
|
||||
message: |
|
||||
The PR contains a SQL query that may be re-written with frappe.qb (https://frappeframework.com/docs/user/en/api/query-builder) or the Database API (https://frappeframework.com/docs/user/en/api/database)
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
6
.github/helper/semgrep_rules/security.py
vendored
6
.github/helper/semgrep_rules/security.py
vendored
|
|
@ -1,6 +0,0 @@
|
|||
def function_name(input):
|
||||
# ruleid: frappe-codeinjection-eval
|
||||
eval(input)
|
||||
|
||||
# ok: frappe-codeinjection-eval
|
||||
eval("1 + 1")
|
||||
25
.github/helper/semgrep_rules/security.yml
vendored
25
.github/helper/semgrep_rules/security.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
rules:
|
||||
- id: frappe-codeinjection-eval
|
||||
patterns:
|
||||
- pattern-not: eval("...")
|
||||
- pattern: eval(...)
|
||||
message: |
|
||||
Detected the use of eval(). eval() can be dangerous if used to evaluate
|
||||
dynamic content. Avoid it or use safe_eval().
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
|
||||
- id: frappe-sqli-format-strings
|
||||
patterns:
|
||||
- pattern-inside: |
|
||||
@frappe.whitelist()
|
||||
def $FUNC(...):
|
||||
...
|
||||
- pattern-either:
|
||||
- pattern: frappe.db.sql("..." % ...)
|
||||
- pattern: frappe.db.sql(f"...", ...)
|
||||
- pattern: frappe.db.sql("...".format(...), ...)
|
||||
message: |
|
||||
Detected use of raw string formatting for SQL queries. This can lead to sql injection vulnerabilities. Refer security guidelines - https://github.com/frappe/erpnext/wiki/Code-Security-Guidelines
|
||||
languages: [python]
|
||||
severity: WARNING
|
||||
44
.github/helper/semgrep_rules/translate.js
vendored
44
.github/helper/semgrep_rules/translate.js
vendored
|
|
@ -1,44 +0,0 @@
|
|||
// ruleid: frappe-translation-empty-string
|
||||
__("")
|
||||
// ruleid: frappe-translation-empty-string
|
||||
__('')
|
||||
|
||||
// ok: frappe-translation-js-formatting
|
||||
__('Welcome {0}, get started with ERPNext in just a few clicks.', [full_name]);
|
||||
|
||||
// ruleid: frappe-translation-js-formatting
|
||||
__(`Welcome ${full_name}, get started with ERPNext in just a few clicks.`);
|
||||
|
||||
// ok: frappe-translation-js-formatting
|
||||
__('This is fine');
|
||||
|
||||
|
||||
// ok: frappe-translation-trailing-spaces
|
||||
__('This is fine');
|
||||
|
||||
// ruleid: frappe-translation-trailing-spaces
|
||||
__(' this is not ok ');
|
||||
// ruleid: frappe-translation-trailing-spaces
|
||||
__('this is not ok ');
|
||||
// ruleid: frappe-translation-trailing-spaces
|
||||
__(' this is not ok');
|
||||
|
||||
// ok: frappe-translation-js-splitting
|
||||
__('You have {0} subscribers in your mailing list.', [subscribers.length])
|
||||
|
||||
// todoruleid: frappe-translation-js-splitting
|
||||
__('You have') + subscribers.length + __('subscribers in your mailing list.')
|
||||
|
||||
// ruleid: frappe-translation-js-splitting
|
||||
__('You have' + 'subscribers in your mailing list.')
|
||||
|
||||
// ruleid: frappe-translation-js-splitting
|
||||
__('You have {0} subscribers' +
|
||||
'in your mailing list', [subscribers.length])
|
||||
|
||||
// ok: frappe-translation-js-splitting
|
||||
__("Ctrl+Enter to add comment")
|
||||
|
||||
// ruleid: frappe-translation-js-splitting
|
||||
__('You have {0} subscribers \
|
||||
in your mailing list', [subscribers.length])
|
||||
61
.github/helper/semgrep_rules/translate.py
vendored
61
.github/helper/semgrep_rules/translate.py
vendored
|
|
@ -1,61 +0,0 @@
|
|||
# Examples taken from https://frappeframework.com/docs/user/en/translations
|
||||
# This file is used for testing the tests.
|
||||
|
||||
from frappe import _
|
||||
|
||||
full_name = "Jon Doe"
|
||||
# ok: frappe-translation-python-formatting
|
||||
_('Welcome {0}, get started with ERPNext in just a few clicks.').format(full_name)
|
||||
|
||||
# ruleid: frappe-translation-python-formatting
|
||||
_('Welcome %s, get started with ERPNext in just a few clicks.' % full_name)
|
||||
# ruleid: frappe-translation-python-formatting
|
||||
_('Welcome %(name)s, get started with ERPNext in just a few clicks.' % {'name': full_name})
|
||||
|
||||
# ruleid: frappe-translation-python-formatting
|
||||
_('Welcome {0}, get started with ERPNext in just a few clicks.'.format(full_name))
|
||||
|
||||
|
||||
subscribers = ["Jon", "Doe"]
|
||||
# ok: frappe-translation-python-formatting
|
||||
_('You have {0} subscribers in your mailing list.').format(len(subscribers))
|
||||
|
||||
# ruleid: frappe-translation-python-splitting
|
||||
_('You have') + len(subscribers) + _('subscribers in your mailing list.')
|
||||
|
||||
# ruleid: frappe-translation-python-splitting
|
||||
_('You have {0} subscribers \
|
||||
in your mailing list').format(len(subscribers))
|
||||
|
||||
# ok: frappe-translation-python-splitting
|
||||
_('You have {0} subscribers') \
|
||||
+ 'in your mailing list'
|
||||
|
||||
# ruleid: frappe-translation-trailing-spaces
|
||||
msg = _(" You have {0} pending invoice ")
|
||||
# ruleid: frappe-translation-trailing-spaces
|
||||
msg = _("You have {0} pending invoice ")
|
||||
# ruleid: frappe-translation-trailing-spaces
|
||||
msg = _(" You have {0} pending invoice")
|
||||
|
||||
# ok: frappe-translation-trailing-spaces
|
||||
msg = ' ' + _("You have {0} pending invoices") + ' '
|
||||
|
||||
# ruleid: frappe-translation-python-formatting
|
||||
_(f"can not format like this - {subscribers}")
|
||||
# ruleid: frappe-translation-python-splitting
|
||||
_(f"what" + f"this is also not cool")
|
||||
|
||||
|
||||
# ruleid: frappe-translation-empty-string
|
||||
_("")
|
||||
# ruleid: frappe-translation-empty-string
|
||||
_('')
|
||||
|
||||
|
||||
class Test:
|
||||
# ok: frappe-translation-python-splitting
|
||||
def __init__(
|
||||
args
|
||||
):
|
||||
pass
|
||||
64
.github/helper/semgrep_rules/translate.yml
vendored
64
.github/helper/semgrep_rules/translate.yml
vendored
|
|
@ -1,64 +0,0 @@
|
|||
rules:
|
||||
- id: frappe-translation-empty-string
|
||||
pattern-either:
|
||||
- pattern: _("")
|
||||
- pattern: __("")
|
||||
message: |
|
||||
Empty string is useless for translation.
|
||||
Please refer: https://frappeframework.com/docs/user/en/translations
|
||||
languages: [python, javascript, json]
|
||||
severity: ERROR
|
||||
|
||||
- id: frappe-translation-trailing-spaces
|
||||
pattern-either:
|
||||
- pattern: _("=~/(^[ \t]+|[ \t]+$)/")
|
||||
- pattern: __("=~/(^[ \t]+|[ \t]+$)/")
|
||||
message: |
|
||||
Trailing or leading whitespace not allowed in translate strings.
|
||||
Please refer: https://frappeframework.com/docs/user/en/translations
|
||||
languages: [python, javascript, json]
|
||||
severity: ERROR
|
||||
|
||||
- id: frappe-translation-python-formatting
|
||||
pattern-either:
|
||||
- pattern: _("..." % ...)
|
||||
- pattern: _("...".format(...))
|
||||
- pattern: _(f"...")
|
||||
message: |
|
||||
Only positional formatters are allowed and formatting should not be done before translating.
|
||||
Please refer: https://frappeframework.com/docs/user/en/translations
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
|
||||
- id: frappe-translation-js-formatting
|
||||
patterns:
|
||||
- pattern: __(`...`)
|
||||
- pattern-not: __("...")
|
||||
message: |
|
||||
Template strings are not allowed for text formatting.
|
||||
Please refer: https://frappeframework.com/docs/user/en/translations
|
||||
languages: [javascript, json]
|
||||
severity: ERROR
|
||||
|
||||
- id: frappe-translation-python-splitting
|
||||
pattern-either:
|
||||
- pattern: _(...) + _(...)
|
||||
- pattern: _("..." + "...")
|
||||
- pattern-regex: '[\s\.]_\([^\)]*\\\s*' # lines broken by `\`
|
||||
- pattern-regex: '[\s\.]_\(\s*\n' # line breaks allowed by python for using ( )
|
||||
message: |
|
||||
Do not split strings inside translate function. Do not concatenate using translate functions.
|
||||
Please refer: https://frappeframework.com/docs/user/en/translations
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
|
||||
- id: frappe-translation-js-splitting
|
||||
pattern-either:
|
||||
- pattern-regex: '__\([^\)]*[\\]\s+'
|
||||
- pattern: __('...' + '...', ...)
|
||||
- pattern: __('...') + __('...')
|
||||
message: |
|
||||
Do not split strings inside translate function. Do not concatenate using translate functions.
|
||||
Please refer: https://frappeframework.com/docs/user/en/translations
|
||||
languages: [javascript, json]
|
||||
severity: ERROR
|
||||
9
.github/helper/semgrep_rules/ux.js
vendored
9
.github/helper/semgrep_rules/ux.js
vendored
|
|
@ -1,9 +0,0 @@
|
|||
|
||||
// ok: frappe-missing-translate-function-js
|
||||
frappe.msgprint('{{ _("Both login and password required") }}');
|
||||
|
||||
// ruleid: frappe-missing-translate-function-js
|
||||
frappe.msgprint('What');
|
||||
|
||||
// ok: frappe-missing-translate-function-js
|
||||
frappe.throw(' {{ _("Both login and password required") }}. ');
|
||||
31
.github/helper/semgrep_rules/ux.py
vendored
31
.github/helper/semgrep_rules/ux.py
vendored
|
|
@ -1,31 +0,0 @@
|
|||
import frappe
|
||||
from frappe import msgprint, throw, _
|
||||
|
||||
|
||||
# ruleid: frappe-missing-translate-function-python
|
||||
throw("Error Occured")
|
||||
|
||||
# ruleid: frappe-missing-translate-function-python
|
||||
frappe.throw("Error Occured")
|
||||
|
||||
# ruleid: frappe-missing-translate-function-python
|
||||
frappe.msgprint("Useful message")
|
||||
|
||||
# ruleid: frappe-missing-translate-function-python
|
||||
msgprint("Useful message")
|
||||
|
||||
|
||||
# ok: frappe-missing-translate-function-python
|
||||
translatedmessage = _("Hello")
|
||||
|
||||
# ok: frappe-missing-translate-function-python
|
||||
throw(translatedmessage)
|
||||
|
||||
# ok: frappe-missing-translate-function-python
|
||||
msgprint(translatedmessage)
|
||||
|
||||
# ok: frappe-missing-translate-function-python
|
||||
msgprint(_("Helpful message"))
|
||||
|
||||
# ok: frappe-missing-translate-function-python
|
||||
frappe.throw(_("Error occured"))
|
||||
30
.github/helper/semgrep_rules/ux.yml
vendored
30
.github/helper/semgrep_rules/ux.yml
vendored
|
|
@ -1,30 +0,0 @@
|
|||
rules:
|
||||
- id: frappe-missing-translate-function-python
|
||||
pattern-either:
|
||||
- patterns:
|
||||
- pattern: frappe.msgprint("...", ...)
|
||||
- pattern-not: frappe.msgprint(_("..."), ...)
|
||||
- patterns:
|
||||
- pattern: frappe.throw("...", ...)
|
||||
- pattern-not: frappe.throw(_("..."), ...)
|
||||
message: |
|
||||
All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
|
||||
- id: frappe-missing-translate-function-js
|
||||
pattern-either:
|
||||
- patterns:
|
||||
- pattern: frappe.msgprint("...", ...)
|
||||
- pattern-not: frappe.msgprint(__("..."), ...)
|
||||
# ignore microtemplating e.g. msgprint("{{ _("server side translation") }}")
|
||||
- pattern-not: frappe.msgprint("=~/\{\{.*\_.*\}\}/i", ...)
|
||||
- patterns:
|
||||
- pattern: frappe.throw("...", ...)
|
||||
- pattern-not: frappe.throw(__("..."), ...)
|
||||
# ignore microtemplating
|
||||
- pattern-not: frappe.throw("=~/\{\{.*\_.*\}\}/i", ...)
|
||||
message: |
|
||||
All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations
|
||||
languages: [javascript]
|
||||
severity: ERROR
|
||||
6
.github/workflows/semgrep.yml
vendored
6
.github/workflows/semgrep.yml
vendored
|
|
@ -9,10 +9,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Download Semgrep rules
|
||||
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
|
||||
|
||||
- uses: returntocorp/semgrep-action@v1
|
||||
env:
|
||||
SEMGREP_TIMEOUT: 120
|
||||
with:
|
||||
config: >-
|
||||
r/python.lang.correctness
|
||||
.github/helper/semgrep_rules
|
||||
./frappe-semgrep-rules/rules
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ context('Awesome Bar', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
cy.get('.navbar .navbar-home').click();
|
||||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)').clear();
|
||||
});
|
||||
|
||||
it('navigates to doctype list', () => {
|
||||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 200 });
|
||||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 700 });
|
||||
cy.get('.awesomplete').findByRole('listbox').should('be.visible');
|
||||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 100 });
|
||||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 700 });
|
||||
|
||||
cy.get('.title-text').should('contain', 'To Do');
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ context('Awesome Bar', () => {
|
|||
|
||||
it('find text in doctype list', () => {
|
||||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)')
|
||||
.type('test in todo{downarrow}{enter}', { delay: 200 });
|
||||
.type('test in todo{downarrow}{enter}', { delay: 700 });
|
||||
|
||||
cy.get('.title-text').should('contain', 'To Do');
|
||||
|
||||
|
|
@ -31,14 +32,14 @@ context('Awesome Bar', () => {
|
|||
|
||||
it('navigates to new form', () => {
|
||||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)')
|
||||
.type('new blog post{downarrow}{enter}', { delay: 200 });
|
||||
.type('new blog post{downarrow}{enter}', { delay: 700 });
|
||||
|
||||
cy.get('.title-text:visible').should('have.text', 'New Blog Post');
|
||||
});
|
||||
|
||||
it('calculates math expressions', () => {
|
||||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)')
|
||||
.type('55 + 32{downarrow}{enter}', { delay: 200 });
|
||||
.type('55 + 32{downarrow}{enter}', { delay: 700 });
|
||||
|
||||
cy.get('.modal-title').should('contain', 'Result');
|
||||
cy.get('.msgprint').should('contain', '55 + 32 = 87');
|
||||
|
|
|
|||
|
|
@ -49,19 +49,19 @@ context('Control Link', () => {
|
|||
it('should unset invalid value', () => {
|
||||
get_dialog_with_link().as('dialog');
|
||||
|
||||
cy.intercept('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link');
|
||||
cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value');
|
||||
|
||||
cy.get('.frappe-control[data-fieldname=link] input')
|
||||
.type('invalid value', { delay: 100 })
|
||||
.blur();
|
||||
cy.wait('@validate_link');
|
||||
cy.wait('@get_value');
|
||||
cy.get('.frappe-control[data-fieldname=link] input').should('have.value', '');
|
||||
});
|
||||
|
||||
it('should route to form on arrow click', () => {
|
||||
get_dialog_with_link().as('dialog');
|
||||
|
||||
cy.intercept('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link');
|
||||
cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value');
|
||||
cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link');
|
||||
|
||||
cy.get('@todos').then(todos => {
|
||||
|
|
@ -69,7 +69,7 @@ context('Control Link', () => {
|
|||
cy.get('@input').focus();
|
||||
cy.wait('@search_link');
|
||||
cy.get('@input').type(todos[0]).blur();
|
||||
cy.wait('@validate_link');
|
||||
cy.wait('@get_value');
|
||||
cy.get('@input').focus();
|
||||
cy.findByTitle('Open Link')
|
||||
.should('be.visible')
|
||||
|
|
@ -77,4 +77,19 @@ context('Control Link', () => {
|
|||
cy.location('pathname').should('eq', `/app/todo/${todos[0]}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch valid value', () => {
|
||||
cy.get('@todos').then(todos => {
|
||||
cy.visit(`/app/todo/${todos[0]}`);
|
||||
cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value');
|
||||
|
||||
cy.get('.frappe-control[data-fieldname=assigned_by] input').focus().as('input');
|
||||
cy.get('@input').type('Administrator', {delay: 100}).blur();
|
||||
cy.wait('@get_value');
|
||||
cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should(
|
||||
'contain', 'Administrator'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,13 +13,6 @@ context('Recorder', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('Navigate to Recorder', () => {
|
||||
cy.visit('/app');
|
||||
cy.awesomebar('recorder');
|
||||
cy.findByTitle('Recorder').should('exist');
|
||||
cy.url().should('include', '/recorder/detail');
|
||||
});
|
||||
|
||||
it('Recorder Empty State', () => {
|
||||
cy.findByTitle('Recorder').should('exist');
|
||||
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ Cypress.Commands.add('get_table_field', (tablefieldname, row_idx, fieldname, fie
|
|||
});
|
||||
|
||||
Cypress.Commands.add('awesomebar', text => {
|
||||
cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 100});
|
||||
cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 700});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('new_form', doctype => {
|
||||
|
|
@ -354,4 +354,4 @@ Cypress.Commands.add('click_listview_primary_button', (btn_name) => {
|
|||
|
||||
Cypress.Commands.add('click_timeline_action_btn', (btn_name) => {
|
||||
cy.get('.timeline-message-box .custom-actions > .btn').contains(btn_name).click();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ let argv = yargs
|
|||
})
|
||||
.option("live-reload", {
|
||||
type: "boolean",
|
||||
description: `Automatically reload web pages when assets are rebuilt.
|
||||
description: `Automatically reload Desk when assets are rebuilt.
|
||||
Can only be used with the --watch flag.`
|
||||
})
|
||||
.option("production", {
|
||||
|
|
@ -288,10 +288,24 @@ function get_watch_config() {
|
|||
assets_json,
|
||||
prev_assets_json
|
||||
} = await write_assets_json(result.metafile);
|
||||
|
||||
let changed_files;
|
||||
if (prev_assets_json) {
|
||||
log_rebuilt_assets(prev_assets_json, assets_json);
|
||||
changed_files = get_rebuilt_assets(
|
||||
prev_assets_json,
|
||||
assets_json
|
||||
);
|
||||
|
||||
let timestamp = new Date().toLocaleTimeString();
|
||||
let message = `${timestamp}: Compiled ${changed_files.length} files...`;
|
||||
log(chalk.yellow(message));
|
||||
for (let filepath of changed_files) {
|
||||
let filename = path.basename(filepath);
|
||||
log(" " + filename);
|
||||
}
|
||||
log();
|
||||
}
|
||||
notify_redis({ success: true });
|
||||
notify_redis({ success: true, changed_files });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -461,7 +475,7 @@ function run_build_command_for_apps(apps) {
|
|||
process.chdir(cwd);
|
||||
}
|
||||
|
||||
async function notify_redis({ error, success }) {
|
||||
async function notify_redis({ error, success, changed_files }) {
|
||||
// notify redis which in turns tells socketio to publish this to browser
|
||||
let subscriber = get_redis_subscriber("redis_socketio");
|
||||
subscriber.on("error", _ => {
|
||||
|
|
@ -484,6 +498,7 @@ async function notify_redis({ error, success }) {
|
|||
if (success) {
|
||||
payload = {
|
||||
success: true,
|
||||
changed_files,
|
||||
live_reload: argv["live-reload"]
|
||||
};
|
||||
}
|
||||
|
|
@ -514,7 +529,7 @@ function open_in_editor() {
|
|||
subscriber.subscribe("open_in_editor");
|
||||
}
|
||||
|
||||
function log_rebuilt_assets(prev_assets, new_assets) {
|
||||
function get_rebuilt_assets(prev_assets, new_assets) {
|
||||
let added_files = [];
|
||||
let old_files = Object.values(prev_assets);
|
||||
let new_files = Object.values(new_assets);
|
||||
|
|
@ -524,17 +539,5 @@ function log_rebuilt_assets(prev_assets, new_assets) {
|
|||
added_files.push(filepath);
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
chalk.yellow(
|
||||
`${new Date().toLocaleTimeString()}: Compiled ${
|
||||
added_files.length
|
||||
} files...`
|
||||
)
|
||||
);
|
||||
for (let filepath of added_files) {
|
||||
let filename = path.basename(filepath);
|
||||
log(" " + filename);
|
||||
}
|
||||
log();
|
||||
return added_files;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,6 +258,12 @@ def set_default(key, value, parent=None):
|
|||
frappe.db.set_default(key, value, parent or frappe.session.user)
|
||||
frappe.clear_cache(user=frappe.session.user)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_default(key, parent=None):
|
||||
"""set a user default value"""
|
||||
return frappe.db.get_default(key, parent)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=['POST', 'PUT'])
|
||||
def make_width_property_setter(doc):
|
||||
'''Set width Property Setter
|
||||
|
|
@ -276,18 +282,17 @@ def bulk_update(docs):
|
|||
docs = json.loads(docs)
|
||||
failed_docs = []
|
||||
for doc in docs:
|
||||
doc.pop("flags", None)
|
||||
try:
|
||||
ddoc = {key: val for key, val in doc.items() if key not in ['doctype', 'docname']}
|
||||
doctype = doc['doctype']
|
||||
docname = doc['docname']
|
||||
doc = frappe.get_doc(doctype, docname)
|
||||
doc.update(ddoc)
|
||||
doc.save()
|
||||
except:
|
||||
existing_doc = frappe.get_doc(doc["doctype"], doc["docname"])
|
||||
existing_doc.update(doc)
|
||||
existing_doc.save()
|
||||
except Exception:
|
||||
failed_docs.append({
|
||||
'doc': doc,
|
||||
'exc': frappe.utils.get_traceback()
|
||||
})
|
||||
|
||||
return {'failed_docs': failed_docs}
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ from frappe.utils import get_fullname, now
|
|||
from frappe.model.document import Document
|
||||
from frappe.core.utils import set_timeline_doc
|
||||
import frappe
|
||||
from frappe.query_builder import DocType, Interval
|
||||
from frappe.query_builder.functions import Now
|
||||
from pypika.terms import PseudoColumn
|
||||
|
||||
class ActivityLog(Document):
|
||||
def before_insert(self):
|
||||
|
|
@ -44,6 +47,7 @@ def clear_activity_logs(days=None):
|
|||
|
||||
if not days:
|
||||
days = 90
|
||||
|
||||
frappe.db.sql("""delete from `tabActivity Log` where \
|
||||
creation< (NOW() - INTERVAL '{0}' DAY)""".format(days))
|
||||
doctype = DocType("Activity Log")
|
||||
frappe.db.delete(doctype, filters=(
|
||||
doctype.creation < PseudoColumn(f"({Now() - Interval(days=days)})")
|
||||
))
|
||||
|
|
@ -87,10 +87,6 @@ class DocType(Document):
|
|||
if self.default_print_format and not self.custom:
|
||||
frappe.throw(_('Standard DocType cannot have default print format, use Customize Form'))
|
||||
|
||||
if frappe.conf.get('developer_mode'):
|
||||
self.owner = 'Administrator'
|
||||
self.modified_by = 'Administrator'
|
||||
|
||||
def validate_field_name_conflicts(self):
|
||||
"""Check if field names dont conflict with controller properties and methods"""
|
||||
core_doctypes = [
|
||||
|
|
@ -177,7 +173,6 @@ class DocType(Document):
|
|||
if self.is_virtual and self.custom:
|
||||
frappe.throw(_("Not allowed to create custom Virtual DocType."), CannotCreateStandardDoctypeError)
|
||||
|
||||
|
||||
if frappe.conf.get('developer_mode'):
|
||||
self.owner = 'Administrator'
|
||||
self.modified_by = 'Administrator'
|
||||
|
|
@ -315,9 +310,7 @@ class DocType(Document):
|
|||
if allow_doctype_export:
|
||||
self.export_doc()
|
||||
self.make_controller_template()
|
||||
|
||||
if self.has_web_view:
|
||||
self.set_base_class_for_controller()
|
||||
self.set_base_class_for_controller()
|
||||
|
||||
# update index
|
||||
if not self.custom:
|
||||
|
|
@ -355,23 +348,49 @@ class DocType(Document):
|
|||
now=now, doctype=self.name)
|
||||
|
||||
def set_base_class_for_controller(self):
|
||||
'''Updates the controller class to subclass from `WebsiteGenertor`,
|
||||
if it is a subclass of `Document`'''
|
||||
controller_path = frappe.get_module_path(frappe.scrub(self.module),
|
||||
'doctype', frappe.scrub(self.name), frappe.scrub(self.name) + '.py')
|
||||
"""If DocType.has_web_view has been changed, updates the controller class and import
|
||||
from `WebsiteGenertor` to `Document` or viceversa"""
|
||||
|
||||
with open(controller_path, 'r') as f:
|
||||
if not self.has_value_changed("has_web_view"):
|
||||
return
|
||||
|
||||
despaced_name = self.name.replace(" ", "_")
|
||||
scrubbed_name = frappe.scrub(self.name)
|
||||
scrubbed_module = frappe.scrub(self.module)
|
||||
controller_path = frappe.get_module_path(
|
||||
scrubbed_module, "doctype", scrubbed_name, f"{scrubbed_name}.py"
|
||||
)
|
||||
|
||||
document_cls_tag = f"class {despaced_name}(Document)"
|
||||
document_import_tag = "from frappe.model.document import Document"
|
||||
website_generator_cls_tag = f"class {despaced_name}(WebsiteGenerator)"
|
||||
website_generator_import_tag = "from frappe.website.generators.website_generator import WebsiteGenerator"
|
||||
|
||||
with open(controller_path) as f:
|
||||
code = f.read()
|
||||
updated_code = code
|
||||
|
||||
class_string = '\nclass {0}(Document)'.format(self.name.replace(' ', ''))
|
||||
if '\nfrom frappe.model.document import Document' in code and class_string in code:
|
||||
code = code.replace('from frappe.model.document import Document',
|
||||
'from frappe.website.website_generator import WebsiteGenerator')
|
||||
code = code.replace('class {0}(Document)'.format(self.name.replace(' ', '')),
|
||||
'class {0}(WebsiteGenerator)'.format(self.name.replace(' ', '')))
|
||||
is_website_generator_class = all([
|
||||
website_generator_cls_tag in code,
|
||||
website_generator_import_tag in code
|
||||
])
|
||||
|
||||
with open(controller_path, 'w') as f:
|
||||
f.write(code)
|
||||
if self.has_web_view and not is_website_generator_class:
|
||||
updated_code = updated_code.replace(
|
||||
document_import_tag, website_generator_import_tag
|
||||
).replace(
|
||||
document_cls_tag, website_generator_cls_tag
|
||||
)
|
||||
elif not self.has_web_view and is_website_generator_class:
|
||||
updated_code = updated_code.replace(
|
||||
website_generator_import_tag, document_import_tag
|
||||
).replace(
|
||||
website_generator_cls_tag, document_cls_tag
|
||||
)
|
||||
|
||||
if updated_code != code:
|
||||
with open(controller_path, "w") as f:
|
||||
f.write(updated_code)
|
||||
|
||||
def run_module_method(self, method):
|
||||
from frappe.modules import load_doctype_module
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
"label": "Clear Activity Log After"
|
||||
},
|
||||
{
|
||||
"default": "90",
|
||||
"default": "30",
|
||||
"description": "In Days",
|
||||
"fieldname": "clear_email_queue_after",
|
||||
"fieldtype": "Int",
|
||||
|
|
@ -80,4 +80,4 @@
|
|||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import DocType, Interval
|
||||
from frappe.query_builder.functions import Now
|
||||
from pypika.terms import PseudoColumn
|
||||
|
||||
|
||||
class LogSettings(Document):
|
||||
def clear_logs(self):
|
||||
|
|
@ -13,9 +17,10 @@ class LogSettings(Document):
|
|||
self.clear_email_queue()
|
||||
|
||||
def clear_error_logs(self):
|
||||
frappe.db.sql(""" DELETE FROM `tabError Log`
|
||||
WHERE `creation` < (NOW() - INTERVAL '{0}' DAY)
|
||||
""".format(self.clear_error_log_after))
|
||||
table = DocType("Error Log")
|
||||
frappe.db.delete(table, filters=(
|
||||
table.creation < PseudoColumn(f"({Now() - Interval(days=self.clear_error_log_after)})")
|
||||
))
|
||||
|
||||
def clear_activity_logs(self):
|
||||
from frappe.core.doctype.activity_log.activity_log import clear_activity_logs
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class Report(Document):
|
|||
if not self.query.lower().startswith("select"):
|
||||
frappe.throw(_("Query must be a SELECT"), title=_('Report Document Error'))
|
||||
|
||||
result = [list(t) for t in frappe.db.sql(self.query, filters, debug=True)]
|
||||
result = [list(t) for t in frappe.db.sql(self.query, filters)]
|
||||
columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()]
|
||||
|
||||
return [columns, result]
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ class TestServerScript(unittest.TestCase):
|
|||
self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello')
|
||||
|
||||
def test_permission_query(self):
|
||||
self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', return_query=1))
|
||||
self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', run=False))
|
||||
self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list))
|
||||
|
||||
def test_attribute_error(self):
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from frappe.utils.user import get_system_managers
|
|||
from frappe.website.utils import is_signup_disabled
|
||||
from frappe.rate_limiter import rate_limit
|
||||
from frappe.core.doctype.user_type.user_type import user_linked_with_permission_on_doctype
|
||||
from frappe.query_builder import DocType
|
||||
|
||||
|
||||
STANDARD_USERS = ("Guest", "Administrator")
|
||||
|
|
@ -366,15 +367,21 @@ class User(Document):
|
|||
# delete shares
|
||||
frappe.db.delete("DocShare", {"user": self.name})
|
||||
# delete messages
|
||||
frappe.db.sql("""delete from `tabCommunication`
|
||||
where communication_type in ('Chat', 'Notification')
|
||||
and reference_doctype='User'
|
||||
and (reference_name=%s or owner=%s)""", (self.name, self.name))
|
||||
|
||||
table = DocType("Communication")
|
||||
frappe.db.delete(
|
||||
table,
|
||||
filters=(
|
||||
(table.communication_type.isin(["Chat", "Notification"]))
|
||||
& (table.reference_doctype == "User")
|
||||
& ((table.reference_name == self.name) | table.owner == self.name)
|
||||
),
|
||||
run=False,
|
||||
)
|
||||
# unlink contact
|
||||
frappe.db.sql("""update `tabContact`
|
||||
set `user`=null
|
||||
where `user`=%s""", (self.name))
|
||||
table = DocType("Contact")
|
||||
frappe.qb.update(table).where(
|
||||
table.user == self.name
|
||||
).set(table.user, None).run()
|
||||
|
||||
# delete notification settings
|
||||
frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True)
|
||||
|
|
@ -421,9 +428,10 @@ class User(Document):
|
|||
frappe.rename_doc("Notification Settings", old_name, new_name, force=True, show_alert=False)
|
||||
|
||||
# set email
|
||||
frappe.db.sql("""UPDATE `tabUser`
|
||||
SET email = %s
|
||||
WHERE name = %s""", (new_name, new_name))
|
||||
table = DocType("User")
|
||||
frappe.qb.update(table).where(
|
||||
table.name == new_name
|
||||
).set("email", new_name).run()
|
||||
|
||||
def append_roles(self, *roles):
|
||||
"""Add roles to user"""
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ def get_user_linked_doctypes(doctype, txt, searchfield, start, page_len, filters
|
|||
['DocType', 'read_only', '=', 0], ['DocType', 'name', 'like', '%{0}%'.format(txt)]]
|
||||
|
||||
doctypes = frappe.get_all('DocType', fields = ['`tabDocType`.`name`'], filters=filters,
|
||||
order_by = '`tabDocType`.`idx` desc', limit_start=start, limit_page_length=page_len, as_list=1, debug=1)
|
||||
order_by = '`tabDocType`.`idx` desc', limit_start=start, limit_page_length=page_len, as_list=1)
|
||||
|
||||
custom_dt_filters = [['Custom Field', 'dt', 'like', '%{0}%'.format(txt)],
|
||||
['Custom Field', 'options', '=', 'User'], ['Custom Field', 'fieldtype', '=', 'Link']]
|
||||
|
|
|
|||
56
frappe/data/google_fonts.json
Normal file
56
frappe/data/google_fonts.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
[
|
||||
"Alegreya Sans",
|
||||
"Alegreya",
|
||||
"Andada Pro",
|
||||
"Anton",
|
||||
"Archivo Narrow",
|
||||
"Archivo",
|
||||
"BioRhyme",
|
||||
"Cardo",
|
||||
"Chivo",
|
||||
"Cormorant",
|
||||
"Crimson Text",
|
||||
"DM Sans",
|
||||
"Eczar",
|
||||
"Encode Sans",
|
||||
"Epilogue ",
|
||||
"Fira Sans",
|
||||
"Hahmlet",
|
||||
"IBM Plex Sans",
|
||||
"Inconsolata",
|
||||
"Inknut Antiqua",
|
||||
"Inter",
|
||||
"JetBrains Mono",
|
||||
"Karla",
|
||||
"Lato",
|
||||
"Libre Baskerville",
|
||||
"Libre Franklin",
|
||||
"Lora",
|
||||
"Manrope",
|
||||
"Merriweather",
|
||||
"Montserrat",
|
||||
"Neuton",
|
||||
"Nunito",
|
||||
"Old Standard TT",
|
||||
"Open Sans",
|
||||
"Oswald",
|
||||
"Oxygen",
|
||||
"Playfair Display",
|
||||
"Poppins",
|
||||
"Proza Libre",
|
||||
"PT Sans",
|
||||
"PT Serif",
|
||||
"Raleway",
|
||||
"Roboto Slab",
|
||||
"Roboto",
|
||||
"Rubik",
|
||||
"Sora",
|
||||
"Source Sans Pro",
|
||||
"Source Serif Pro",
|
||||
"Space Grotesk",
|
||||
"Space Mono",
|
||||
"Spectral",
|
||||
"Syne",
|
||||
"Work Sans"
|
||||
]
|
||||
|
||||
|
|
@ -37,6 +37,7 @@ class Database(object):
|
|||
STANDARD_VARCHAR_COLUMNS = ('name', 'owner', 'modified_by', 'parent', 'parentfield', 'parenttype')
|
||||
DEFAULT_COLUMNS = ['name', 'creation', 'modified', 'modified_by', 'owner', 'docstatus', 'parent',
|
||||
'parentfield', 'parenttype', 'idx']
|
||||
MAX_WRITES_PER_TRANSACTION = 200_000
|
||||
|
||||
class InvalidColumnName(frappe.ValidationError): pass
|
||||
|
||||
|
|
@ -83,7 +84,8 @@ class Database(object):
|
|||
pass
|
||||
|
||||
def sql(self, query, values=(), as_dict = 0, as_list = 0, formatted = 0,
|
||||
debug=0, ignore_ddl=0, as_utf8=0, auto_commit=0, update=None, explain=False, run=True):
|
||||
debug=0, ignore_ddl=0, as_utf8=0, auto_commit=0, update=None,
|
||||
explain=False, run=True, pluck=False):
|
||||
"""Execute a SQL query and fetch all rows.
|
||||
|
||||
:param query: SQL query.
|
||||
|
|
@ -178,6 +180,9 @@ class Database(object):
|
|||
if not self._cursor.description:
|
||||
return ()
|
||||
|
||||
if pluck:
|
||||
return [r[0] for r in self._cursor.fetchall()]
|
||||
|
||||
# scrub output if required
|
||||
if as_dict:
|
||||
ret = self.fetch_as_dict(formatted, as_utf8)
|
||||
|
|
@ -233,7 +238,7 @@ class Database(object):
|
|||
except Exception:
|
||||
frappe.errprint("error in query explain")
|
||||
|
||||
def sql_list(self, query, values=(), debug=False):
|
||||
def sql_list(self, query, values=(), debug=False, **kwargs):
|
||||
"""Return data as list of single elements (first column).
|
||||
|
||||
Example:
|
||||
|
|
@ -241,7 +246,7 @@ class Database(object):
|
|||
# doctypes = ["DocType", "DocField", "User", ...]
|
||||
doctypes = frappe.db.sql_list("select name from DocType")
|
||||
"""
|
||||
return [r[0] for r in self.sql(query, values, debug=debug)]
|
||||
return [r[0] for r in self.sql(query, values, **kwargs, debug=debug)]
|
||||
|
||||
def sql_ddl(self, query, values=(), debug=False):
|
||||
"""Commit and execute a query. DDL (Data Definition Language) queries that alter schema
|
||||
|
|
@ -262,7 +267,7 @@ class Database(object):
|
|||
|
||||
if query[:6].lower() in ('update', 'insert', 'delete'):
|
||||
self.transaction_writes += 1
|
||||
if self.transaction_writes > 200000:
|
||||
if self.transaction_writes > self.MAX_WRITES_PER_TRANSACTION:
|
||||
if self.auto_commit_on_many_writes:
|
||||
self.commit()
|
||||
else:
|
||||
|
|
@ -324,7 +329,7 @@ class Database(object):
|
|||
return self.get_value(doctype, filters, "*", as_dict=as_dict, cache=cache)
|
||||
|
||||
def get_value(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False,
|
||||
debug=False, order_by=None, cache=False, for_update=False):
|
||||
debug=False, order_by=None, cache=False, for_update=False, run=True):
|
||||
"""Returns a document property or list of properties.
|
||||
|
||||
:param doctype: DocType name.
|
||||
|
|
@ -351,12 +356,15 @@ class Database(object):
|
|||
"""
|
||||
|
||||
ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug,
|
||||
order_by, cache=cache, for_update=for_update)
|
||||
order_by, cache=cache, for_update=for_update, run=run)
|
||||
|
||||
if not run:
|
||||
return ret
|
||||
|
||||
return ((len(ret[0]) > 1 or as_dict) and ret[0] or ret[0][0]) if ret else None
|
||||
|
||||
def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False,
|
||||
debug=False, order_by=None, update=None, cache=False, for_update=False):
|
||||
debug=False, order_by=None, update=None, cache=False, for_update=False, run=True):
|
||||
"""Returns multiple document properties.
|
||||
|
||||
:param doctype: DocType name.
|
||||
|
|
@ -382,7 +390,7 @@ class Database(object):
|
|||
|
||||
if isinstance(filters, list):
|
||||
order_by = order_by or "modified_desc"
|
||||
out = self._get_value_for_many_names(doctype, filters, fieldname, debug=debug)
|
||||
out = self._get_value_for_many_names(doctype, filters, fieldname, debug=debug, run=run)
|
||||
|
||||
else:
|
||||
fields = fieldname
|
||||
|
|
@ -395,26 +403,28 @@ class Database(object):
|
|||
if (filters is not None) and (filters!=doctype or doctype=="DocType"):
|
||||
try:
|
||||
order_by = order_by or "modified"
|
||||
out = self._get_values_from_table(fields, filters, doctype, as_dict, debug, order_by, update, for_update=for_update)
|
||||
out = self._get_values_from_table(
|
||||
fields, filters, doctype, as_dict, debug, order_by, update, for_update=for_update, run=run
|
||||
)
|
||||
except Exception as e:
|
||||
if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)):
|
||||
# table or column not found, return None
|
||||
out = None
|
||||
elif (not ignore) and frappe.db.is_table_missing(e):
|
||||
# table not found, look in singles
|
||||
out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update)
|
||||
out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run)
|
||||
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update)
|
||||
out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run)
|
||||
|
||||
if cache and isinstance(filters, str):
|
||||
self.value_cache[(doctype, filters, fieldname)] = out
|
||||
|
||||
return out
|
||||
|
||||
def get_values_from_single(self, fields, filters, doctype, as_dict=False, debug=False, update=None):
|
||||
def get_values_from_single(self, fields, filters, doctype, as_dict=False, debug=False, update=None, run=True):
|
||||
"""Get values from `tabSingles` (Single DocTypes) (internal).
|
||||
|
||||
:param fields: List of fields,
|
||||
|
|
@ -443,8 +453,9 @@ class Database(object):
|
|||
r = self.sql("""select field, value
|
||||
from `tabSingles` where field in (%s) and doctype=%s"""
|
||||
% (', '.join(['%s'] * len(fields)), '%s'),
|
||||
tuple(fields) + (doctype,), as_dict=False, debug=debug)
|
||||
|
||||
tuple(fields) + (doctype,), as_dict=False, debug=debug, run=run)
|
||||
if not run:
|
||||
return r
|
||||
if as_dict:
|
||||
if r:
|
||||
r = frappe._dict(r)
|
||||
|
|
@ -522,7 +533,8 @@ class Database(object):
|
|||
"""Alias for get_single_value"""
|
||||
return self.get_single_value(*args, **kwargs)
|
||||
|
||||
def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None, update=None, for_update=False):
|
||||
def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None,
|
||||
update=None, for_update=False, run=True):
|
||||
field_objects = []
|
||||
|
||||
for field in fields:
|
||||
|
|
@ -531,7 +543,9 @@ class Database(object):
|
|||
else:
|
||||
field_objects.append(field)
|
||||
|
||||
criterion = self.query.build_conditions(table=doctype, filters=filters, orderby=order_by, for_update=for_update)
|
||||
criterion = self.query.build_conditions(
|
||||
table=doctype, filters=filters, orderby=order_by, for_update=for_update
|
||||
)
|
||||
|
||||
if isinstance(fields, (list, tuple)):
|
||||
query = criterion.select(*field_objects)
|
||||
|
|
@ -539,18 +553,17 @@ class Database(object):
|
|||
if fields=="*":
|
||||
query = criterion.select(fields)
|
||||
as_dict = True
|
||||
r = self.sql(query, as_dict=as_dict, debug=debug, update=update)
|
||||
|
||||
r = self.sql(query, as_dict=as_dict, debug=debug, update=update, run=run)
|
||||
return r
|
||||
|
||||
def _get_value_for_many_names(self, doctype, names, field, debug=False):
|
||||
def _get_value_for_many_names(self, doctype, names, field, debug=False, run=True):
|
||||
names = list(filter(None, names))
|
||||
|
||||
if names:
|
||||
return self.get_all(doctype,
|
||||
fields=['name', field],
|
||||
filters=[['name', 'in', names]],
|
||||
debug=debug, as_list=1)
|
||||
debug=debug, as_list=1, run=run)
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
|
@ -595,7 +608,7 @@ class Database(object):
|
|||
for key in to_update:
|
||||
set_values.append('`{0}`=%({0})s'.format(key))
|
||||
|
||||
for name in self.get_values(dt, dn, 'name', for_update=for_update):
|
||||
for name in self.get_values(dt, dn, 'name', for_update=for_update, debug=debug):
|
||||
values = dict(name=name[0])
|
||||
values.update(to_update)
|
||||
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ class Query:
|
|||
Returns:
|
||||
frappe.qb: condition object
|
||||
"""
|
||||
condition = self.get_condition(table, **kwargs)
|
||||
condition = self.add_conditions(self.get_condition(table, **kwargs), **kwargs)
|
||||
return condition.where(criterion)
|
||||
|
||||
def add_conditions(self, conditions: frappe.qb, **kwargs):
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import frappe
|
||||
from frappe.desk.notifications import clear_notifications
|
||||
from frappe.cache_manager import clear_defaults_cache, common_default_keys
|
||||
from frappe.query_builder import DocType
|
||||
|
||||
# Note: DefaultValue records are identified by parenttype
|
||||
# __default, __global or 'User Permission'
|
||||
|
|
@ -116,14 +117,11 @@ def set_default(key, value, parent, parenttype="__default"):
|
|||
:param value: Default value.
|
||||
:param parent: Usually, **User** to whom the default belongs.
|
||||
:param parenttype: [optional] default is `__default`."""
|
||||
if frappe.db.sql('''
|
||||
select
|
||||
defkey
|
||||
from
|
||||
`tabDefaultValue`
|
||||
where
|
||||
defkey=%s and parent=%s
|
||||
for update''', (key, parent)):
|
||||
table = DocType("DefaultValue")
|
||||
key_exists = frappe.qb.from_(table).where(
|
||||
(table.defkey == key) & (table.parent == parent)
|
||||
).select(table.defkey).for_update().run()
|
||||
if key_exists:
|
||||
frappe.db.delete("DefaultValue", {
|
||||
"defkey": key,
|
||||
"parent": parent
|
||||
|
|
@ -191,8 +189,12 @@ def get_defaults_for(parent="__default"):
|
|||
|
||||
if defaults==None:
|
||||
# sort descending because first default must get precedence
|
||||
res = frappe.db.sql("""select defkey, defvalue from `tabDefaultValue`
|
||||
where parent = %s order by creation""", (parent,), as_dict=1)
|
||||
table = DocType("DefaultValue")
|
||||
res = frappe.qb.from_(table).where(
|
||||
table.parent == parent
|
||||
).select(
|
||||
table.defkey, table.defvalue
|
||||
).orderby("creation").run(as_dict=True)
|
||||
|
||||
defaults = frappe._dict({})
|
||||
for d in res:
|
||||
|
|
|
|||
|
|
@ -16,44 +16,6 @@ def remove_attach():
|
|||
file_name = frappe.form_dict.get('file_name')
|
||||
frappe.delete_doc('File', fid)
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_link():
|
||||
"""validate link when updated by user"""
|
||||
import frappe
|
||||
import frappe.utils
|
||||
|
||||
value, options, fetch = frappe.form_dict.get('value'), frappe.form_dict.get('options'), frappe.form_dict.get('fetch')
|
||||
|
||||
# no options, don't validate
|
||||
if not options or options=='null' or options=='undefined':
|
||||
frappe.response['message'] = 'Ok'
|
||||
return
|
||||
|
||||
valid_value = frappe.db.get_all(options, filters=dict(name=value), as_list=1, limit=1)
|
||||
|
||||
if valid_value:
|
||||
valid_value = valid_value[0][0]
|
||||
|
||||
# get fetch values
|
||||
if fetch:
|
||||
# escape with "`"
|
||||
fetch = ", ".join(("`{0}`".format(f.strip()) for f in fetch.split(",")))
|
||||
fetch_value = None
|
||||
try:
|
||||
fetch_value = frappe.db.sql("select %s from `tab%s` where name=%s"
|
||||
% (fetch, options, '%s'), (value,))[0]
|
||||
except Exception as e:
|
||||
error_message = str(e).split("Unknown column '")
|
||||
fieldname = None if len(error_message)<=1 else error_message[1].split("'")[0]
|
||||
frappe.msgprint(_("Wrong fieldname <b>{0}</b> in add_fetch configuration of custom client script").format(fieldname))
|
||||
frappe.errprint(frappe.get_traceback())
|
||||
|
||||
if fetch_value:
|
||||
frappe.response['fetch_values'] = [frappe.utils.parse_val(c) for c in fetch_value]
|
||||
|
||||
frappe.response['valid_value'] = valid_value
|
||||
frappe.response['message'] = 'Ok'
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_comment(reference_doctype, reference_name, content, comment_email, comment_by):
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ def get_group_by_count(doctype, current_filters, field):
|
|||
current_filters = frappe.parse_json(current_filters)
|
||||
subquery_condition = ''
|
||||
|
||||
subquery = frappe.get_all(doctype, filters=current_filters, return_query = True)
|
||||
subquery = frappe.get_all(doctype, filters=current_filters, run=False)
|
||||
if field == 'assigned_to':
|
||||
subquery_condition = ' and `tabToDo`.reference_name in ({subquery})'.format(subquery = subquery)
|
||||
return frappe.db.sql("""select `tabToDo`.owner as name, count(*) as count
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class DatabaseQuery(object):
|
|||
join='left join', distinct=False, start=None, page_length=None, limit=None,
|
||||
ignore_ifnull=False, save_user_settings=False, save_user_settings_fields=False,
|
||||
update=None, add_total_row=None, user_settings=None, reference_doctype=None,
|
||||
return_query=False, strict=True, pluck=None, ignore_ddl=False) -> List:
|
||||
run=True, strict=True, pluck=None, ignore_ddl=False) -> List:
|
||||
if not ignore_permissions and \
|
||||
not frappe.has_permission(self.doctype, "select", user=user) and \
|
||||
not frappe.has_permission(self.doctype, "read", user=user):
|
||||
|
|
@ -87,7 +87,7 @@ class DatabaseQuery(object):
|
|||
self.user = user or frappe.session.user
|
||||
self.update = update
|
||||
self.user_settings_fields = copy.deepcopy(self.fields)
|
||||
self.return_query = return_query
|
||||
self.run = run
|
||||
self.strict = strict
|
||||
self.ignore_ddl = ignore_ddl
|
||||
|
||||
|
|
@ -104,8 +104,6 @@ class DatabaseQuery(object):
|
|||
if not self.columns: return []
|
||||
|
||||
result = self.build_and_run()
|
||||
if return_query:
|
||||
return result
|
||||
|
||||
if with_comment_count and not as_list and self.doctype:
|
||||
self.add_comment_count(result)
|
||||
|
|
@ -137,11 +135,8 @@ class DatabaseQuery(object):
|
|||
%(order_by)s
|
||||
%(limit)s""" % args
|
||||
|
||||
if self.return_query:
|
||||
return query
|
||||
else:
|
||||
return frappe.db.sql(query, as_dict=not self.as_list, debug=self.debug,
|
||||
update=self.update, ignore_ddl=self.ignore_ddl)
|
||||
return frappe.db.sql(query, as_dict=not self.as_list, debug=self.debug,
|
||||
update=self.update, ignore_ddl=self.ignore_ddl, run=self.run)
|
||||
|
||||
def prepare_args(self):
|
||||
self.parse_args()
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def write_document_file(doc, record_module=None, create_init=True, folder_name=N
|
|||
doc_export = doc.as_dict(no_nulls=True)
|
||||
doc.run_method("before_export", doc_export)
|
||||
|
||||
strip_default_fields(doc, doc_export)
|
||||
doc_export = strip_default_fields(doc, doc_export)
|
||||
module = record_module or get_module_name(doc)
|
||||
|
||||
# create folder
|
||||
|
|
@ -42,12 +42,17 @@ def write_document_file(doc, record_module=None, create_init=True, folder_name=N
|
|||
|
||||
def strip_default_fields(doc, doc_export):
|
||||
# strip out default fields from children
|
||||
if doc.doctype == "DocType" and doc.migration_hash:
|
||||
del doc_export["migration_hash"]
|
||||
|
||||
for df in doc.meta.get_table_fields():
|
||||
for d in doc_export.get(df.fieldname):
|
||||
for fieldname in frappe.model.default_fields:
|
||||
if fieldname in d:
|
||||
del d[fieldname]
|
||||
|
||||
return doc_export
|
||||
|
||||
def write_code_files(folder, fname, doc, doc_export):
|
||||
'''Export code files and strip from values'''
|
||||
if hasattr(doc, 'get_code_fields'):
|
||||
|
|
@ -59,8 +64,6 @@ def write_code_files(folder, fname, doc, doc_export):
|
|||
# remove from exporting
|
||||
del doc_export[key]
|
||||
|
||||
|
||||
|
||||
def get_module_name(doc):
|
||||
if doc.doctype == 'Module Def':
|
||||
module = doc.name
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import frappe
|
||||
from frappe.desk.form.linked_with import get_linked_doctypes
|
||||
from frappe.patches.v11_0.replicate_old_user_permissions import get_doctypes_to_skip
|
||||
from frappe.query_builder import Field
|
||||
|
||||
# `skip_for_doctype` was a un-normalized way of storing for which
|
||||
# doctypes the user permission was applicable.
|
||||
|
|
@ -72,16 +73,12 @@ def execute():
|
|||
frappe.db.set_value('User Permission', user_permission.name, 'apply_to_all_doctypes', 1)
|
||||
|
||||
if new_user_permissions_list:
|
||||
frappe.db.sql('''
|
||||
INSERT INTO `tabUser Permission`
|
||||
(`name`, `user`, `allow`, `for_value`, `applicable_for`, `apply_to_all_doctypes`, `creation`, `modified`)
|
||||
VALUES {}
|
||||
'''.format( # nosec
|
||||
', '.join(['%s'] * len(new_user_permissions_list))
|
||||
), tuple(new_user_permissions_list))
|
||||
frappe.qb.into("User Permission").columns(
|
||||
"name", "user", "allow", "for_value", "applicable_for", "apply_to_all_doctypes", "creation", "modified"
|
||||
).insert(*new_user_permissions_list).run()
|
||||
|
||||
if user_permissions_to_delete:
|
||||
frappe.db.sql('DELETE FROM `tabUser Permission` WHERE `name` in ({})' # nosec
|
||||
.format(','.join(['%s'] * len(user_permissions_to_delete))),
|
||||
tuple(user_permissions_to_delete)
|
||||
frappe.db.delete(
|
||||
"User Permission",
|
||||
filters=(Field("name").isin(tuple(user_permissions_to_delete)))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import frappe
|
|||
import frappe.share
|
||||
from frappe import _, msgprint
|
||||
from frappe.utils import cint
|
||||
|
||||
from frappe.query_builder import DocType
|
||||
|
||||
rights = ("select", "read", "write", "create", "delete", "submit", "cancel", "amend",
|
||||
"print", "email", "report", "import", "export", "set_user_permissions", "share")
|
||||
|
|
@ -330,8 +330,7 @@ def get_all_perms(role):
|
|||
'''Returns valid permissions for a given role'''
|
||||
perms = frappe.get_all('DocPerm', fields='*', filters=dict(role=role))
|
||||
custom_perms = frappe.get_all('Custom DocPerm', fields='*', filters=dict(role=role))
|
||||
doctypes_with_custom_perms = frappe.db.sql_list("""select distinct parent
|
||||
from `tabCustom DocPerm`""")
|
||||
doctypes_with_custom_perms = frappe.get_all("Custom DocPerm", pluck="parent", distinct=True)
|
||||
|
||||
for p in perms:
|
||||
if p.parent not in doctypes_with_custom_perms:
|
||||
|
|
@ -348,10 +347,13 @@ def get_roles(user=None, with_standard=True):
|
|||
|
||||
def get():
|
||||
if user == 'Administrator':
|
||||
return [r[0] for r in frappe.db.sql("select name from `tabRole`")] # return all available roles
|
||||
return frappe.get_all("Role", pluck="name") # return all available roles
|
||||
else:
|
||||
return [r[0] for r in frappe.db.sql("""select role from `tabHas Role`
|
||||
where parent=%s and role not in ('All', 'Guest')""", (user,))] + ['All', 'Guest']
|
||||
table = DocType("Has Role")
|
||||
roles = frappe.qb.from_(table).where(
|
||||
(table.parent == user) & (table.role.notin(["All", "Guest"]))
|
||||
).select(table.role).run(pluck=True)
|
||||
return roles + ['All', 'Guest']
|
||||
|
||||
roles = frappe.cache().hget("roles", user, get)
|
||||
|
||||
|
|
@ -460,10 +462,9 @@ def update_permission_property(doctype, role, permlevel, ptype, value=None, vali
|
|||
|
||||
name = frappe.get_value('Custom DocPerm', dict(parent=doctype, role=role,
|
||||
permlevel=permlevel))
|
||||
table = DocType("Custom DocPerm")
|
||||
frappe.qb.update(table).set(ptype, value).where(table.name == name).run()
|
||||
|
||||
frappe.db.sql("""
|
||||
update `tabCustom DocPerm`
|
||||
set `{0}`=%s where name=%s""".format(ptype), (value, name))
|
||||
if validate:
|
||||
validate_permissions_for_doctype(doctype)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:letter_head_name",
|
||||
"creation": "2012-11-22 17:45:46",
|
||||
|
|
@ -13,6 +14,9 @@
|
|||
"is_default",
|
||||
"letter_head_image_section",
|
||||
"image",
|
||||
"image_height",
|
||||
"image_width",
|
||||
"align",
|
||||
"header_section",
|
||||
"content",
|
||||
"footer_section",
|
||||
|
|
@ -100,15 +104,34 @@
|
|||
"fieldname": "footer",
|
||||
"fieldtype": "HTML Editor",
|
||||
"label": "Footer HTML"
|
||||
},
|
||||
{
|
||||
"default": "Left",
|
||||
"fieldname": "align",
|
||||
"fieldtype": "Select",
|
||||
"label": "Align",
|
||||
"options": "Left\nRight\nCenter"
|
||||
},
|
||||
{
|
||||
"fieldname": "image_height",
|
||||
"fieldtype": "Float",
|
||||
"label": "Image Height"
|
||||
},
|
||||
{
|
||||
"fieldname": "image_width",
|
||||
"fieldtype": "Float",
|
||||
"label": "Image Width"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-font",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"max_attachments": 3,
|
||||
"modified": "2019-11-11 18:46:43.375120",
|
||||
"modified": "2021-10-03 14:37:58.314696",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Printing",
|
||||
"name": "Letter Head",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# License: MIT. See LICENSE
|
||||
|
||||
import frappe
|
||||
from frappe.utils import is_image
|
||||
from frappe.utils import is_image, flt
|
||||
from frappe.model.document import Document
|
||||
from frappe import _
|
||||
|
||||
|
|
@ -26,7 +26,15 @@ class LetterHead(Document):
|
|||
def set_image(self):
|
||||
if self.source=='Image':
|
||||
if self.image and is_image(self.image):
|
||||
self.content = '<img src="{}">'.format(self.image)
|
||||
self.image_width = flt(self.image_width)
|
||||
self.image_height = flt(self.image_height)
|
||||
dimension = 'width' if self.image_width > self.image_height else 'height'
|
||||
dimension_value = self.get('image_' + dimension)
|
||||
self.content = f'''
|
||||
<div style="text-align: {self.align.lower()};">
|
||||
<img src="{self.image}" alt="{self.name}" {dimension}="{dimension_value}" style="{dimension}: {dimension_value}px;">
|
||||
</div>
|
||||
'''
|
||||
frappe.msgprint(frappe._('Header HTML set from attachment {0}').format(self.image), alert = True)
|
||||
else:
|
||||
frappe.msgprint(frappe._('Please attach an image file to set HTML'), alert = True, indicator = 'orange')
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ frappe.ui.form.on("Print Format", {
|
|||
frappe.msgprint(__("Please select DocType first"));
|
||||
return;
|
||||
}
|
||||
frappe.set_route("print-format-builder", frm.doc.name);
|
||||
if (frm.doc.print_format_builder_beta) {
|
||||
frappe.set_route("print-format-builder-beta", frm.doc.name);
|
||||
} else {
|
||||
frappe.set_route("print-format-builder", frm.doc.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (frm.doc.custom_format && !frm.doc.raw_printing) {
|
||||
|
|
|
|||
|
|
@ -19,19 +19,26 @@
|
|||
"html",
|
||||
"raw_commands",
|
||||
"section_break_9",
|
||||
"margin_top",
|
||||
"margin_bottom",
|
||||
"margin_left",
|
||||
"margin_right",
|
||||
"align_labels_right",
|
||||
"show_section_headings",
|
||||
"line_breaks",
|
||||
"absolute_value",
|
||||
"column_break_11",
|
||||
"font_size",
|
||||
"font",
|
||||
"page_number",
|
||||
"css_section",
|
||||
"css",
|
||||
"custom_html_help",
|
||||
"section_break_13",
|
||||
"print_format_help",
|
||||
"format_data",
|
||||
"print_format_builder"
|
||||
"print_format_builder",
|
||||
"print_format_builder_beta"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
|
|
@ -149,12 +156,10 @@
|
|||
"options": "Language"
|
||||
},
|
||||
{
|
||||
"default": "Default",
|
||||
"depends_on": "eval:!doc.custom_format",
|
||||
"fieldname": "font",
|
||||
"fieldtype": "Select",
|
||||
"label": "Font",
|
||||
"options": "Default\nHelvetica Neue\nArial\nHelvetica\nVerdana\nMonospace"
|
||||
"fieldtype": "Data",
|
||||
"label": "Google Font"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.raw_printing",
|
||||
|
|
@ -205,16 +210,60 @@
|
|||
"fieldname": "absolute_value",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Absolute Values"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "print_format_builder_beta",
|
||||
"fieldtype": "Check",
|
||||
"label": "Print Format Builder Beta"
|
||||
},
|
||||
{
|
||||
"default": "15",
|
||||
"fieldname": "margin_top",
|
||||
"fieldtype": "Float",
|
||||
"label": "Margin Top"
|
||||
},
|
||||
{
|
||||
"default": "15",
|
||||
"fieldname": "margin_bottom",
|
||||
"fieldtype": "Float",
|
||||
"label": "Margin Bottom"
|
||||
},
|
||||
{
|
||||
"default": "15",
|
||||
"fieldname": "margin_left",
|
||||
"fieldtype": "Float",
|
||||
"label": "Margin Left"
|
||||
},
|
||||
{
|
||||
"default": "15",
|
||||
"fieldname": "margin_right",
|
||||
"fieldtype": "Float",
|
||||
"label": "Margin Right"
|
||||
},
|
||||
{
|
||||
"default": "14",
|
||||
"fieldname": "font_size",
|
||||
"fieldtype": "Int",
|
||||
"label": "Font Size"
|
||||
},
|
||||
{
|
||||
"default": "Hide",
|
||||
"fieldname": "page_number",
|
||||
"fieldtype": "Select",
|
||||
"label": "Page Number",
|
||||
"options": "Hide\nTop Left\nTop Center\nTop Right\nBottom Left\nBottom Center\nBottom Right"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-print",
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2021-03-01 15:25:46.578863",
|
||||
"modified": "2021-10-12 17:52:41.167107",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Printing",
|
||||
"name": "Print Format",
|
||||
"naming_rule": "Set by user",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,10 +7,24 @@ import frappe.utils
|
|||
import json
|
||||
from frappe import _
|
||||
from frappe.utils.jinja import validate_template
|
||||
|
||||
from frappe.utils.weasyprint import get_html, download_pdf
|
||||
from frappe.model.document import Document
|
||||
|
||||
class PrintFormat(Document):
|
||||
def onload(self):
|
||||
templates = frappe.db.get_all(
|
||||
"Print Format Field Template",
|
||||
fields=["template", "field", "name"],
|
||||
filters={"document_type": self.doc_type},
|
||||
)
|
||||
self.set_onload("print_templates", templates)
|
||||
|
||||
def get_html(self, docname, letterhead=None):
|
||||
return get_html(self.doc_type, docname, self.name, letterhead)
|
||||
|
||||
def download_pdf(self, docname, letterhead=None):
|
||||
return download_pdf(self.doc_type, docname, self.name, letterhead)
|
||||
|
||||
def validate(self):
|
||||
if (self.standard=="Yes"
|
||||
and not frappe.local.conf.get("developer_mode")
|
||||
|
|
@ -38,6 +52,10 @@ class PrintFormat(Document):
|
|||
|
||||
def extract_images(self):
|
||||
from frappe.core.doctype.file.file import extract_images_from_html
|
||||
|
||||
if self.print_format_builder_beta:
|
||||
return
|
||||
|
||||
if self.format_data:
|
||||
data = json.loads(self.format_data)
|
||||
for df in data:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright (c) 2021, Frappe Technologies and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on('Print Format Field Template', {
|
||||
// refresh: function(frm) {
|
||||
|
||||
// }
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "Prompt",
|
||||
"creation": "2021-10-05 14:23:56.508499",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"document_type",
|
||||
"field",
|
||||
"template_file",
|
||||
"column_break_3",
|
||||
"module",
|
||||
"standard",
|
||||
"section_break_5",
|
||||
"template"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"depends_on": "eval:!doc.multiple",
|
||||
"fieldname": "document_type",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Document Type",
|
||||
"mandatory_depends_on": "eval:!doc.multiple",
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "field",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Default Template For Field"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.standard",
|
||||
"fieldname": "template",
|
||||
"fieldtype": "Code",
|
||||
"label": "Template",
|
||||
"mandatory_depends_on": "eval:!doc.standard",
|
||||
"options": "HTML"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_3",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_5",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "standard",
|
||||
"fieldname": "module",
|
||||
"fieldtype": "Link",
|
||||
"label": "Module",
|
||||
"options": "Module Def"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "standard",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Standard"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.standard",
|
||||
"fieldname": "template_file",
|
||||
"fieldtype": "Data",
|
||||
"label": "Template File",
|
||||
"mandatory_depends_on": "eval:doc.standard"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2021-10-19 17:47:59.577949",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Printing",
|
||||
"name": "Print Format Field Template",
|
||||
"naming_rule": "Set by user",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# Copyright (c) 2021, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe import _
|
||||
|
||||
|
||||
class PrintFormatFieldTemplate(Document):
|
||||
def validate(self):
|
||||
if self.standard and not (frappe.conf.developer_mode or frappe.flags.in_patch):
|
||||
frappe.throw(_("Enable developer mode to create a standard Print Template"))
|
||||
|
||||
def before_insert(self):
|
||||
self.validate_duplicate()
|
||||
|
||||
def on_update(self):
|
||||
self.validate_duplicate()
|
||||
self.export_doc()
|
||||
|
||||
def validate_duplicate(self):
|
||||
if not self.standard:
|
||||
return
|
||||
if not self.field:
|
||||
return
|
||||
|
||||
filters = {"document_type": self.document_type, "field": self.field}
|
||||
if not self.is_new():
|
||||
filters.update({"name": ("!=", self.name)})
|
||||
result = frappe.db.get_all("Print Format Field Template", filters=filters, limit=1)
|
||||
if result:
|
||||
frappe.throw(
|
||||
_("A template already exists for field {0} of {1}").format(
|
||||
frappe.bold(self.field), frappe.bold(self.document_type)
|
||||
),
|
||||
frappe.DuplicateEntryError,
|
||||
title=_("Duplicate Entry"),
|
||||
)
|
||||
|
||||
def export_doc(self):
|
||||
from frappe.modules.utils import export_module_json
|
||||
|
||||
export_module_json(self, self.standard, self.module)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Copyright (c) 2021, Frappe Technologies and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
class TestPrintFormatFieldTemplate(unittest.TestCase):
|
||||
pass
|
||||
|
|
@ -41,7 +41,11 @@ frappe.ui.form.PrintView = class {
|
|||
</iframe>
|
||||
</div>
|
||||
<div class="page-break-message text-muted text-center text-medium margin-top"></div>
|
||||
</div>`
|
||||
</div>
|
||||
<div class="preview-beta-wrapper">
|
||||
<iframe width="100%" height="0" frameBorder="0"></iframe>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
this.print_settings = frappe.model.get_doc(
|
||||
|
|
@ -72,7 +76,7 @@ frappe.ui.form.PrintView = class {
|
|||
|
||||
this.page.add_button(
|
||||
__('PDF'),
|
||||
() => this.render_page('/api/method/frappe.utils.print_format.download_pdf?'),
|
||||
() => this.render_pdf(),
|
||||
{ icon: 'small-file' }
|
||||
);
|
||||
|
||||
|
|
@ -190,6 +194,13 @@ frappe.ui.form.PrintView = class {
|
|||
this.set_breadcrumbs();
|
||||
this.setup_customize_dialog();
|
||||
|
||||
// print format builder beta
|
||||
this.page.add_inner_message(`
|
||||
<a style="line-height: 2.4" href="/app/print-format-builder-beta?doctype=${this.frm.doctype}">
|
||||
${__('Try the new Print Format Builder')}
|
||||
</a>
|
||||
`);
|
||||
|
||||
let tasks = [
|
||||
this.refresh_print_options,
|
||||
this.set_default_print_language,
|
||||
|
|
@ -233,7 +244,7 @@ frappe.ui.form.PrintView = class {
|
|||
let print_format = this.get_print_format();
|
||||
let is_custom_format =
|
||||
print_format.name &&
|
||||
print_format.print_format_builder &&
|
||||
(print_format.print_format_builder || print_format.print_format_builder_beta) &&
|
||||
print_format.standard === 'No';
|
||||
let is_standard_but_editable =
|
||||
print_format.name && print_format.custom_format;
|
||||
|
|
@ -243,7 +254,11 @@ frappe.ui.form.PrintView = class {
|
|||
return;
|
||||
}
|
||||
if (is_custom_format) {
|
||||
frappe.set_route('print-format-builder', print_format.name);
|
||||
if (print_format.print_format_builder_beta) {
|
||||
frappe.set_route('print-format-builder-beta', print_format.name);
|
||||
} else {
|
||||
frappe.set_route('print-format-builder', print_format.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// start a new print format
|
||||
|
|
@ -261,6 +276,11 @@ frappe.ui.form.PrintView = class {
|
|||
fieldtype: 'Read Only',
|
||||
default: print_format.name || 'Standard',
|
||||
},
|
||||
{
|
||||
label: __('Use the new Print Format Builder'),
|
||||
fieldname: 'beta',
|
||||
fieldtype: 'Check'
|
||||
},
|
||||
],
|
||||
(data) => {
|
||||
frappe.route_options = {
|
||||
|
|
@ -268,6 +288,7 @@ frappe.ui.form.PrintView = class {
|
|||
doctype: this.frm.doctype,
|
||||
name: data.print_format_name,
|
||||
based_on: data.based_on,
|
||||
beta: data.beta
|
||||
};
|
||||
frappe.set_route('print-format-builder');
|
||||
this.print_sel.val(data.print_format_name);
|
||||
|
|
@ -380,6 +401,17 @@ frappe.ui.form.PrintView = class {
|
|||
}
|
||||
|
||||
preview() {
|
||||
let print_format = this.get_print_format();
|
||||
if (print_format.print_format_builder_beta) {
|
||||
this.print_wrapper.find('.print-preview-wrapper').hide();
|
||||
this.print_wrapper.find('.preview-beta-wrapper').show();
|
||||
this.preview_beta();
|
||||
return;
|
||||
}
|
||||
|
||||
this.print_wrapper.find('.preview-beta-wrapper').hide();
|
||||
this.print_wrapper.find('.print-preview-wrapper').show();
|
||||
|
||||
const $print_format = this.print_wrapper.find('iframe');
|
||||
this.$print_format_body = $print_format.contents();
|
||||
this.get_print_html((out) => {
|
||||
|
|
@ -403,6 +435,21 @@ frappe.ui.form.PrintView = class {
|
|||
});
|
||||
}
|
||||
|
||||
preview_beta() {
|
||||
let print_format = this.get_print_format();
|
||||
const iframe = this.print_wrapper.find('.preview-beta-wrapper iframe');
|
||||
let params = new URLSearchParams({
|
||||
doctype: this.frm.doc.doctype,
|
||||
name: this.frm.doc.name,
|
||||
print_format: print_format.name
|
||||
});
|
||||
let letterhead = this.get_letterhead();
|
||||
if (letterhead) {
|
||||
params.append("letterhead", letterhead);
|
||||
}
|
||||
iframe.prop('src', `/printpreview?${params.toString()}`);
|
||||
}
|
||||
|
||||
setup_print_format_dom(out, $print_format) {
|
||||
this.print_wrapper.find('.print-format-skeleton').remove();
|
||||
let base_url = frappe.urllib.get_base_url();
|
||||
|
|
@ -565,6 +612,26 @@ frappe.ui.form.PrintView = class {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
render_pdf() {
|
||||
let print_format = this.get_print_format();
|
||||
if (print_format.print_format_builder_beta) {
|
||||
let params = new URLSearchParams({
|
||||
doctype: this.frm.doc.doctype,
|
||||
name: this.frm.doc.name,
|
||||
print_format: print_format.name,
|
||||
letterhead: this.get_letterhead()
|
||||
});
|
||||
let w = window.open(`/api/method/frappe.utils.weasyprint.download_pdf?${params}`);
|
||||
if (!w) {
|
||||
frappe.msgprint(__('Please enable pop-ups'));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.render_page('/api/method/frappe.utils.print_format.download_pdf?');
|
||||
}
|
||||
}
|
||||
|
||||
render_page(method, printit = false) {
|
||||
let w = window.open(
|
||||
frappe.urllib.get_full_url(
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ frappe.pages['print-format-builder'].on_page_show = function(wrapper) {
|
|||
});
|
||||
} else if(frappe.route_options) {
|
||||
if(frappe.route_options.make_new) {
|
||||
let { doctype, name, based_on } = frappe.route_options;
|
||||
let { doctype, name, based_on, beta } = frappe.route_options;
|
||||
frappe.route_options = null;
|
||||
frappe.print_format_builder.setup_new_print_format(doctype, name, based_on);
|
||||
frappe.print_format_builder.setup_new_print_format(doctype, name, based_on, beta);
|
||||
} else {
|
||||
frappe.print_format_builder.print_format = frappe.route_options.doc;
|
||||
frappe.route_options = null;
|
||||
|
|
@ -126,18 +126,22 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder {
|
|||
|
||||
});
|
||||
}
|
||||
setup_new_print_format(doctype, name, based_on) {
|
||||
setup_new_print_format(doctype, name, based_on, beta) {
|
||||
frappe.call({
|
||||
method: 'frappe.printing.page.print_format_builder.print_format_builder.create_custom_format',
|
||||
args: {
|
||||
doctype: doctype,
|
||||
name: name,
|
||||
based_on: based_on
|
||||
based_on: based_on,
|
||||
beta: Boolean(beta)
|
||||
},
|
||||
callback: (r) => {
|
||||
if(!r.exc) {
|
||||
if(r.message) {
|
||||
this.print_format = r.message;
|
||||
if (r.message) {
|
||||
let print_format = r.message;
|
||||
if (print_format.print_format_builder_beta) {
|
||||
frappe.set_route('print-format-builder-beta', print_format.name);
|
||||
} else {
|
||||
this.print_format = print_format;
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import frappe
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_custom_format(doctype, name, based_on='Standard'):
|
||||
def create_custom_format(doctype, name, based_on='Standard', beta=False):
|
||||
doc = frappe.new_doc('Print Format')
|
||||
doc.doc_type = doctype
|
||||
doc.name = name
|
||||
doc.print_format_builder = 1
|
||||
beta = frappe.parse_json(beta)
|
||||
|
||||
if beta:
|
||||
doc.print_format_builder_beta = 1
|
||||
else:
|
||||
doc.print_format_builder = 1
|
||||
doc.format_data = frappe.db.get_value('Print Format', based_on, 'format_data') \
|
||||
if based_on != 'Standard' else None
|
||||
doc.insert()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.layout-main-section-wrapper {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
frappe.pages["print-format-builder-beta"].on_page_load = function(wrapper) {
|
||||
frappe.ui.make_app_page({
|
||||
parent: wrapper,
|
||||
title: __("Print Format Builder"),
|
||||
single_column: true
|
||||
});
|
||||
|
||||
// hot reload in development
|
||||
if (frappe.boot.developer_mode) {
|
||||
frappe.hot_update = frappe.hot_update || [];
|
||||
frappe.hot_update.push(() => load_print_format_builder_beta(wrapper));
|
||||
}
|
||||
};
|
||||
|
||||
frappe.pages["print-format-builder-beta"].on_page_show = function(wrapper) {
|
||||
load_print_format_builder_beta(wrapper);
|
||||
};
|
||||
|
||||
function load_print_format_builder_beta(wrapper) {
|
||||
let route = frappe.get_route();
|
||||
let $parent = $(wrapper).find(".layout-main-section");
|
||||
$parent.empty();
|
||||
|
||||
if (route.length > 1) {
|
||||
frappe.require("print_format_builder.bundle.js").then(() => {
|
||||
frappe.print_format_builder = new frappe.ui.PrintFormatBuilder({
|
||||
wrapper: $parent,
|
||||
page: wrapper.page,
|
||||
print_format: route[1]
|
||||
});
|
||||
});
|
||||
} else {
|
||||
let d = new frappe.ui.Dialog({
|
||||
title: __("Create or Edit Print Format"),
|
||||
fields: [
|
||||
{
|
||||
label: __("Action"),
|
||||
fieldname: "action",
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
{ label: __("Create New"), value: "Create" },
|
||||
{ label: __("Edit Existing"), value: "Edit" }
|
||||
],
|
||||
change() {
|
||||
let action = d.get_value("action");
|
||||
d.get_primary_btn().text(
|
||||
action === "Create" ? __("Create") : __("Edit")
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: __("Select Document Type"),
|
||||
fieldname: "doctype",
|
||||
fieldtype: "Link",
|
||||
options: "DocType",
|
||||
filters: {
|
||||
istable: 0
|
||||
},
|
||||
reqd: 1,
|
||||
default: frappe.route_options
|
||||
? frappe.route_options.doctype
|
||||
: null
|
||||
},
|
||||
{
|
||||
label: __("Print Format Name"),
|
||||
fieldname: "print_format_name",
|
||||
fieldtype: "Data",
|
||||
depends_on: doc => doc.action === "Create",
|
||||
mandatory_depends_on: doc => doc.action === "Create"
|
||||
},
|
||||
{
|
||||
label: __("Select Print Format"),
|
||||
fieldname: "print_format",
|
||||
fieldtype: "Link",
|
||||
options: "Print Format",
|
||||
only_select: 1,
|
||||
depends_on: doc => doc.action === "Edit",
|
||||
get_query() {
|
||||
return {
|
||||
filters: {
|
||||
doc_type: d.get_value("doctype"),
|
||||
print_format_builder_beta: 1
|
||||
}
|
||||
};
|
||||
},
|
||||
mandatory_depends_on: doc => doc.action === "Edit"
|
||||
}
|
||||
],
|
||||
primary_action_label: __("Edit"),
|
||||
primary_action({
|
||||
action,
|
||||
doctype,
|
||||
print_format,
|
||||
print_format_name
|
||||
}) {
|
||||
if (action === "Edit") {
|
||||
frappe.set_route("print-format-builder-beta", print_format);
|
||||
} else if (action === "Create") {
|
||||
d.get_primary_btn().prop("disabled", true);
|
||||
frappe.db
|
||||
.insert({
|
||||
doctype: "Print Format",
|
||||
name: print_format_name,
|
||||
doc_type: doctype,
|
||||
print_format_builder_beta: 1
|
||||
})
|
||||
.then(doc => {
|
||||
frappe.set_route(
|
||||
"print-format-builder-beta",
|
||||
doc.name
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
d.get_primary_btn().prop("disabled", false);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
d.set_value("action", "Create");
|
||||
d.show();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"content": null,
|
||||
"creation": "2021-07-10 12:22:16.138485",
|
||||
"docstatus": 0,
|
||||
"doctype": "Page",
|
||||
"idx": 0,
|
||||
"modified": "2021-07-10 12:22:16.138485",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Printing",
|
||||
"name": "print-format-builder-beta",
|
||||
"owner": "Administrator",
|
||||
"page_name": "Print Format Builder Beta",
|
||||
"roles": [
|
||||
{
|
||||
"role": "System Manager"
|
||||
}
|
||||
],
|
||||
"script": null,
|
||||
"standard": "Yes",
|
||||
"style": null,
|
||||
"system_page": 0
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
import functools
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_google_fonts():
|
||||
return _get_google_fonts()
|
||||
|
||||
|
||||
@functools.lru_cache()
|
||||
def _get_google_fonts():
|
||||
file_path = frappe.get_app_path("frappe", "data", "google_fonts.json")
|
||||
return frappe.parse_json(frappe.read_file(file_path))
|
||||
|
|
@ -7,6 +7,34 @@ let error = null;
|
|||
|
||||
frappe.realtime.on("build_event", data => {
|
||||
if (data.success) {
|
||||
// remove executed cache for rebuilt files
|
||||
let changed_files = data.changed_files;
|
||||
if (Array.isArray(changed_files)) {
|
||||
for (let file of changed_files) {
|
||||
if (file.includes(".bundle.")) {
|
||||
let parts = file.split(".bundle.");
|
||||
if (parts.length === 2) {
|
||||
let filename = parts[0].split("/").slice(-1)[0];
|
||||
|
||||
frappe.assets.executed_ = frappe.assets.executed_.filter(
|
||||
asset => !asset.includes(`${filename}.bundle`)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// update assets json
|
||||
frappe.call("frappe.sessions.get_boot_assets_json").then(r => {
|
||||
if (r.message) {
|
||||
frappe.boot.assets_json = r.message;
|
||||
|
||||
if (frappe.hot_update) {
|
||||
frappe.hot_update.forEach(callback => {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
show_build_success(data);
|
||||
} else if (data.error) {
|
||||
show_build_error(data);
|
||||
|
|
|
|||
|
|
@ -56,9 +56,6 @@ frappe.ui.form.ControlBarcode = class ControlBarcode extends frappe.ui.form.Cont
|
|||
get_options(value) {
|
||||
// get JsBarcode options
|
||||
let options = {};
|
||||
options.background = "var(--control-bg)";
|
||||
options.lineColor = "var(--text-color)";
|
||||
options.font = "var(--font-stack)";
|
||||
options.fontSize = "16";
|
||||
options.width = "3";
|
||||
options.height = "50";
|
||||
|
|
|
|||
|
|
@ -451,51 +451,55 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
return this.validate_link_and_fetch(this.df, this.get_options(),
|
||||
this.docname, value);
|
||||
}
|
||||
validate_link_and_fetch(df, doctype, docname, value) {
|
||||
if(value) {
|
||||
return new Promise((resolve) => {
|
||||
var fetch = '';
|
||||
if(this.frm && this.frm.fetch_dict[df.fieldname]) {
|
||||
fetch = this.frm.fetch_dict[df.fieldname].columns.join(', ');
|
||||
}
|
||||
// if default and no fetch, no need to validate
|
||||
if (!fetch && df.__default_value && df.__default_value===value) {
|
||||
resolve(value);
|
||||
}
|
||||
validate_link_and_fetch(df, options, docname, value) {
|
||||
if (!value) return;
|
||||
|
||||
this.fetch_and_validate_link(resolve, df, doctype, docname, value, fetch);
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const fetch_map = this.fetch_map;
|
||||
|
||||
fetch_and_validate_link(resolve, df, doctype, docname, value, fetch) {
|
||||
frappe.call({
|
||||
method: 'frappe.desk.form.utils.validate_link',
|
||||
type: "GET",
|
||||
args: {
|
||||
'value': value,
|
||||
'options': doctype,
|
||||
'fetch': fetch
|
||||
},
|
||||
no_spinner: true,
|
||||
callback: (r) => {
|
||||
if (r.message=='Ok') {
|
||||
if (r.fetch_values && docname) {
|
||||
this.set_fetch_values(df, docname, r.fetch_values);
|
||||
}
|
||||
resolve(r.valid_value);
|
||||
} else {
|
||||
resolve("");
|
||||
}
|
||||
// if default and no fetch, no need to validate
|
||||
if ($.isEmptyObject(fetch_map) && df.__default_value === value) {
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
frappe.db.get_value(
|
||||
options,
|
||||
value,
|
||||
["name", ...Object.values(fetch_map)],
|
||||
(response) => {
|
||||
if (!response.name) {
|
||||
return resolve("");
|
||||
}
|
||||
|
||||
if (docname) {
|
||||
for (const [target_field, source_field] of Object.entries(fetch_map)) {
|
||||
frappe.model.set_value(
|
||||
df.parent,
|
||||
docname,
|
||||
target_field,
|
||||
response[source_field],
|
||||
df.fieldtype,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(response.name);
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
set_fetch_values(df, docname, fetch_values) {
|
||||
var fl = this.frm.fetch_dict[df.fieldname].fields;
|
||||
for(var i=0; i < fl.length; i++) {
|
||||
frappe.model.set_value(df.parent, docname, fl[i], fetch_values[i], df.fieldtype);
|
||||
get fetch_map() {
|
||||
const fetch_map = {};
|
||||
if (!this.frm) return fetch_map;
|
||||
|
||||
for (const key of ["*", this.df.parent]) {
|
||||
if (this.frm.fetch_dict[key] && this.frm.fetch_dict[key][this.df.fieldname]) {
|
||||
Object.assign(fetch_map, this.frm.fetch_dict[key][this.df.fieldname]);
|
||||
}
|
||||
}
|
||||
|
||||
return fetch_map;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1112,12 +1112,24 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
}
|
||||
|
||||
// UTILITIES
|
||||
add_fetch(link_field, src_field, tar_field) {
|
||||
if(!this.fetch_dict[link_field]) {
|
||||
this.fetch_dict[link_field] = {'columns':[], 'fields':[]};
|
||||
}
|
||||
this.fetch_dict[link_field].columns.push(src_field);
|
||||
this.fetch_dict[link_field].fields.push(tar_field);
|
||||
add_fetch(link_field, source_field, target_field, target_doctype) {
|
||||
/*
|
||||
Example fetch dict to get sender_email from email_id field in sender:
|
||||
{
|
||||
"Notification": {
|
||||
"sender": {
|
||||
"sender_email": "email_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
if (!target_doctype) target_doctype = "*";
|
||||
|
||||
// Target field kept as key because source field could be non-unique
|
||||
this.fetch_dict
|
||||
.setDefault(target_doctype, {})
|
||||
.setDefault(link_field, {})[target_field] = source_field;
|
||||
}
|
||||
|
||||
has_perm(ptype) {
|
||||
|
|
|
|||
|
|
@ -320,7 +320,7 @@ export default class GridRow {
|
|||
</div>
|
||||
<p class='help-box small text-muted hidden-xs'>
|
||||
<a class='add-new-fields text-muted'>
|
||||
+ Add / Remove Columns
|
||||
+ ${__('Add / Remove Columns')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -397,7 +397,7 @@ export default class GridRow {
|
|||
<a style='cursor: grabbing;'>${frappe.utils.icon('drag', 'xs')}</a>
|
||||
</div>
|
||||
<div class='col-md-7' style='padding-left:0px;'>
|
||||
${docfield.label}
|
||||
${__(docfield.label)}
|
||||
</div>
|
||||
<div class='col-md-3' style='padding-left:0px;margin-top:-2px;' title='${__('Columns')}'>
|
||||
<input class='form-control column-width input-xs text-right'
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ frappe.ui.form.ScriptManager = class ScriptManager {
|
|||
'Text Editor', 'Code', 'Link', 'Float', 'Int', 'Date', 'Select', 'Duration'].includes(df.fieldtype) || df.read_only==1)
|
||||
&& df.fetch_from && df.fetch_from.indexOf(".")!=-1) {
|
||||
var parts = df.fetch_from.split(".");
|
||||
me.frm.add_fetch(parts[0], parts[1], df.fieldname);
|
||||
me.frm.add_fetch(parts[0], parts[1], df.fieldname, df.parent);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ $('body').on('click', 'a', function(e) {
|
|||
|
||||
if (frappe.router.is_app_route(e.currentTarget.pathname)) {
|
||||
// target has "/app, this is a v2 style route.
|
||||
|
||||
frappe.route_options = {};
|
||||
let params = new URLSearchParams(e.currentTarget.search);
|
||||
for (const [key, value] of params) {
|
||||
frappe.route_options[key] = value;
|
||||
}
|
||||
return override(e.currentTarget.pathname + e.currentTarget.hash);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,38 +50,31 @@ frappe.search.AwesomeBar = class AwesomeBar {
|
|||
|
||||
this.awesomplete = awesomplete;
|
||||
|
||||
$input.on("input", function(e) {
|
||||
$input.on("input", frappe.utils.debounce(function(e) {
|
||||
var value = e.target.value;
|
||||
var txt = value.trim().replace(/\s\s+/g, ' ');
|
||||
var last_space = txt.lastIndexOf(' ');
|
||||
me.global_results = [];
|
||||
// if(txt && txt.length > 1) {
|
||||
// me.global.get_awesome_bar_options(txt.toLowerCase(), me);
|
||||
// }
|
||||
|
||||
var $this = $(this);
|
||||
clearTimeout($this.data('timeout'));
|
||||
me.options = [];
|
||||
|
||||
$this.data('timeout', setTimeout(function(){
|
||||
me.options = [];
|
||||
if(txt && txt.length > 1) {
|
||||
if(last_space !== -1) {
|
||||
me.set_specifics(txt.slice(0,last_space), txt.slice(last_space+1));
|
||||
}
|
||||
me.add_defaults(txt);
|
||||
me.options = me.options.concat(me.build_options(txt));
|
||||
me.options = me.options.concat(me.global_results);
|
||||
} else {
|
||||
me.options = me.options.concat(
|
||||
me.deduplicate(frappe.search.utils.get_recent_pages(txt || "")));
|
||||
me.options = me.options.concat(frappe.search.utils.get_frequent_links());
|
||||
if (txt && txt.length > 1) {
|
||||
if (last_space !== -1) {
|
||||
me.set_specifics(txt.slice(0, last_space), txt.slice(last_space+1));
|
||||
}
|
||||
me.add_help();
|
||||
me.add_defaults(txt);
|
||||
me.options = me.options.concat(me.build_options(txt));
|
||||
me.options = me.options.concat(me.global_results);
|
||||
} else {
|
||||
me.options = me.options.concat(
|
||||
me.deduplicate(frappe.search.utils.get_recent_pages(txt || "")));
|
||||
me.options = me.options.concat(frappe.search.utils.get_frequent_links());
|
||||
}
|
||||
me.add_help();
|
||||
|
||||
awesomplete.list = me.deduplicate(me.options);
|
||||
}, 100));
|
||||
awesomplete.list = me.deduplicate(me.options);
|
||||
|
||||
});
|
||||
}, 500));
|
||||
|
||||
var open_recent = function() {
|
||||
if (!this.autocomplete_open) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ if (!Array.prototype.uniqBy) {
|
|||
});
|
||||
}
|
||||
|
||||
// Python's dict.setdefault ported for JS objects
|
||||
Object.defineProperty(Object.prototype, "setDefault", {
|
||||
value: function(key, default_value) {
|
||||
if (!(key in this)) this[key] = default_value;
|
||||
return this[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Pluralize
|
||||
String.prototype.plural = function(revert) {
|
||||
const plural = {
|
||||
|
|
|
|||
111
frappe/public/js/print_format_builder/ConfigureColumns.vue
Normal file
111
frappe/public/js/print_format_builder/ConfigureColumns.vue
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<div>
|
||||
<p class="mb-3 text-muted">
|
||||
{{ help_message }}
|
||||
</p>
|
||||
<div class="row font-weight-bold">
|
||||
<div class="col-8">
|
||||
{{ __("Column") }}
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{{ __("Width") }}
|
||||
({{ __("Total:") }} {{ total_width }})
|
||||
</div>
|
||||
</div>
|
||||
<draggable
|
||||
:list="df.table_columns"
|
||||
:animation="200"
|
||||
:group="df.fieldname"
|
||||
handle=".icon-drag"
|
||||
>
|
||||
<div
|
||||
class="mt-2 row align-center column-row"
|
||||
v-for="column in df.table_columns"
|
||||
>
|
||||
<div class="col-8">
|
||||
<div class="column-label d-flex align-center">
|
||||
<div class="px-2 icon-drag ml-n2">
|
||||
<svg class="icon icon-xs">
|
||||
<use xlink:href="#icon-drag"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-1 ml-1">
|
||||
<input
|
||||
class="input-column-label"
|
||||
:class="{ 'text-danger': column.invalid_width }"
|
||||
type="text"
|
||||
v-model="column.label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4 d-flex align-items-center">
|
||||
<input
|
||||
type="number"
|
||||
class="text-right form-control"
|
||||
:class="{ 'text-danger is-invalid': column.invalid_width }"
|
||||
v-model.number="column.width"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
/>
|
||||
<button
|
||||
class="ml-2 btn btn-xs btn-icon"
|
||||
@click="remove_column(column)"
|
||||
>
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-close"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import draggable from "vuedraggable";
|
||||
export default {
|
||||
name: "ConfigureColumns",
|
||||
props: ["df"],
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
methods: {
|
||||
remove_column(column) {
|
||||
this.$set(
|
||||
this.df,
|
||||
"table_columns",
|
||||
this.df.table_columns.filter(_column => _column !== column)
|
||||
);
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
help_message() {
|
||||
// prettier-ignore
|
||||
return __("Drag columns to set order. Column width is set in percentage. The total width should not be more than 100. Columns marked in red will be removed.");
|
||||
},
|
||||
total_width() {
|
||||
return this.df.table_columns.reduce((total, tf) => total + tf.width, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.icon-drag {
|
||||
cursor: grab;
|
||||
}
|
||||
.input-column-label {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
.input-column-label:focus {
|
||||
border-color: var(--border-color);
|
||||
outline: none;
|
||||
background-color: var(--control-bg);
|
||||
}
|
||||
.input-column-label::placeholder {
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
360
frappe/public/js/print_format_builder/Field.vue
Normal file
360
frappe/public/js/print_format_builder/Field.vue
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
<template>
|
||||
<div class="field" :title="df.fieldname" @click="editing = true">
|
||||
<div class="field-controls">
|
||||
<div>
|
||||
<div
|
||||
class="custom-html"
|
||||
v-if="df.fieldtype == 'HTML' && df.html"
|
||||
v-html="df.html"
|
||||
></div>
|
||||
<div
|
||||
class="custom-html"
|
||||
v-if="df.fieldtype == 'Field Template'"
|
||||
>
|
||||
{{ df.label }}
|
||||
</div>
|
||||
<input
|
||||
v-else-if="editing && df.fieldtype != 'HTML'"
|
||||
ref="label-input"
|
||||
class="label-input"
|
||||
type="text"
|
||||
:placeholder="__('Label')"
|
||||
v-model="df.label"
|
||||
@keydown.enter="editing = false"
|
||||
@blur="editing = false"
|
||||
/>
|
||||
<span v-else-if="df.label">{{ df.label }}</span>
|
||||
<i class="text-muted" v-else>
|
||||
{{ __("No Label") }} ({{ df.fieldname }})
|
||||
</i>
|
||||
</div>
|
||||
<div class="field-actions">
|
||||
<button
|
||||
v-if="df.fieldtype == 'HTML'"
|
||||
class="btn btn-xs btn-icon"
|
||||
@click="edit_html"
|
||||
>
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-edit"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="df.fieldtype == 'Table'"
|
||||
class="btn btn-xs btn-default"
|
||||
@click="configure_columns"
|
||||
>
|
||||
Configure columns
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-icon"
|
||||
@click="$set(df, 'remove', true)"
|
||||
>
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-close"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="df.fieldtype == 'Table'"
|
||||
class="table-controls row no-gutters"
|
||||
:style="{ opacity: 1 }"
|
||||
>
|
||||
<div
|
||||
class="table-column"
|
||||
:style="{ width: tf.width + '%' }"
|
||||
v-for="(tf, i) in df.table_columns"
|
||||
:key="tf.fieldname"
|
||||
>
|
||||
<div class="table-field">
|
||||
{{ tf.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import draggable from "vuedraggable";
|
||||
import ConfigureColumnsVue from "./ConfigureColumns.vue";
|
||||
import { storeMixin } from "./store";
|
||||
|
||||
export default {
|
||||
name: "Field",
|
||||
mixins: [storeMixin],
|
||||
props: ["df"],
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editing: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
editing(value) {
|
||||
if (value) {
|
||||
this.$nextTick(() => this.$refs["label-input"].focus());
|
||||
}
|
||||
},
|
||||
"df.table_columns": {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.validate_table_columns();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
edit_html() {
|
||||
let d = new frappe.ui.Dialog({
|
||||
title: __("Edit HTML"),
|
||||
fields: [
|
||||
{
|
||||
label: __("HTML"),
|
||||
fieldname: "html",
|
||||
fieldtype: "Code",
|
||||
options: "HTML"
|
||||
}
|
||||
],
|
||||
primary_action: ({ html }) => {
|
||||
html = frappe.dom.remove_script_and_style(html);
|
||||
this.$set(this.df, "html", html);
|
||||
d.hide();
|
||||
}
|
||||
});
|
||||
d.set_value("html", this.df.html);
|
||||
d.show();
|
||||
},
|
||||
configure_columns() {
|
||||
let dialog = new frappe.ui.Dialog({
|
||||
title: __("Configure columns for {0}", [this.df.label]),
|
||||
fields: [
|
||||
{
|
||||
fieldtype: "HTML",
|
||||
fieldname: "columns_area"
|
||||
},
|
||||
{
|
||||
label: "",
|
||||
fieldtype: "Autocomplete",
|
||||
placeholder: __("Add Column"),
|
||||
fieldname: "add_column",
|
||||
options: this.get_all_columns(),
|
||||
onchange: () => {
|
||||
let fieldname = dialog.get_value("add_column");
|
||||
if (fieldname) {
|
||||
let column = this.get_column_to_add(fieldname);
|
||||
if (column) {
|
||||
this.df.table_columns.push(column);
|
||||
this.$set(
|
||||
this.df,
|
||||
"table_columns",
|
||||
this.df.table_columns
|
||||
);
|
||||
dialog.set_value("add_column", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
on_page_show: () => {
|
||||
new Vue({
|
||||
el: dialog.get_field("columns_area").$wrapper.get(0),
|
||||
render: h =>
|
||||
h(ConfigureColumnsVue, {
|
||||
props: {
|
||||
df: this.df
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
on_hide: () => {
|
||||
this.$set(
|
||||
this.df,
|
||||
"table_columns",
|
||||
this.df.table_columns.filter(col => !col.invalid_width)
|
||||
);
|
||||
}
|
||||
});
|
||||
dialog.show();
|
||||
},
|
||||
get_all_columns() {
|
||||
let meta = frappe.get_meta(this.df.options);
|
||||
let more_columns = [
|
||||
{
|
||||
label: __("Sr No."),
|
||||
value: "idx"
|
||||
}
|
||||
];
|
||||
return more_columns.concat(
|
||||
meta.fields
|
||||
.map(tf => {
|
||||
if (frappe.model.no_value_type.includes(tf.fieldtype)) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
label: tf.label,
|
||||
value: tf.fieldname
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
},
|
||||
get_column_to_add(fieldname) {
|
||||
let standard_columns = {
|
||||
idx: {
|
||||
label: __("Sr No."),
|
||||
fieldtype: "Data",
|
||||
fieldname: "idx",
|
||||
width: 10
|
||||
}
|
||||
};
|
||||
|
||||
if (fieldname in standard_columns) {
|
||||
return standard_columns[fieldname];
|
||||
}
|
||||
|
||||
return {
|
||||
...frappe.meta.get_docfield(this.df.options, fieldname),
|
||||
width: 10
|
||||
};
|
||||
},
|
||||
validate_table_columns() {
|
||||
if (this.df.fieldtype != "Table") return;
|
||||
|
||||
let columns = this.df.table_columns;
|
||||
let total_width = 0;
|
||||
for (let column of columns) {
|
||||
if (!column.width) {
|
||||
column.width = 10;
|
||||
}
|
||||
total_width += column.width;
|
||||
if (total_width > 100) {
|
||||
column.invalid_width = true;
|
||||
} else {
|
||||
column.invalid_width = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.field {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
background-color: var(--bg-light-gray);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px dashed var(--gray-400);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.field-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field:not(:first-child) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-html {
|
||||
padding-right: var(--padding-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.label-input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.label-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field:focus-within {
|
||||
border-style: solid;
|
||||
border-color: var(--gray-600);
|
||||
}
|
||||
|
||||
.field-actions {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.field-actions .btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.field-actions .btn-icon {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.field:hover .btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.table-controls {
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.table-column {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-field {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px dashed var(--gray-400);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: var(--text-sm);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 6px;
|
||||
border-radius: 2px;
|
||||
height: 80%;
|
||||
background-color: var(--gray-600);
|
||||
transform: translate(50%, 10%);
|
||||
z-index: 999;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.column-resize-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.column-resize-actions .btn-icon {
|
||||
background: white;
|
||||
}
|
||||
.column-resize-actions .btn-icon:hover {
|
||||
background: var(--bg-light-gray);
|
||||
}
|
||||
|
||||
.columns-input {
|
||||
padding: var(--padding-sm);
|
||||
}
|
||||
</style>
|
||||
68
frappe/public/js/print_format_builder/HTMLEditor.vue
Normal file
68
frappe/public/js/print_format_builder/HTMLEditor.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div class="html-editor">
|
||||
<div class="d-flex justify-content-end">
|
||||
<button
|
||||
class="btn btn-default btn-xs btn-edit"
|
||||
@click="toggle_edit"
|
||||
>
|
||||
{{ !editing ? buttonLabel : __("Done") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!editing" v-html="value"></div>
|
||||
<div v-show="editing" ref="editor"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: "HTMLEditor",
|
||||
props: ["value", "button-label"],
|
||||
data() {
|
||||
return {
|
||||
editing: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggle_edit() {
|
||||
if (this.editing) {
|
||||
this.$emit("change", this.get_value());
|
||||
this.editing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.editing = true;
|
||||
if (!this.control) {
|
||||
this.control = frappe.ui.form.make_control({
|
||||
parent: this.$refs.editor,
|
||||
df: {
|
||||
fieldname: "editor",
|
||||
fieldtype: "HTML Editor",
|
||||
min_lines: 10,
|
||||
max_lines: 30,
|
||||
change: () => {
|
||||
this.$emit("change", this.get_value());
|
||||
}
|
||||
},
|
||||
render_input: true
|
||||
});
|
||||
}
|
||||
this.control.set_value(this.value);
|
||||
},
|
||||
get_value() {
|
||||
return frappe.dom.remove_script_and_style(this.control.get_value());
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.html-editor {
|
||||
position: relative;
|
||||
border: 1px solid var(--dark-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.html-editor:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
341
frappe/public/js/print_format_builder/LetterHeadEditor.vue
Normal file
341
frappe/public/js/print_format_builder/LetterHeadEditor.vue
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
<template>
|
||||
<div class="letterhead">
|
||||
<div class="mb-4 d-flex justify-content-between">
|
||||
<div class="d-flex align-items-center">
|
||||
<div
|
||||
v-if="letterhead && $store.edit_letterhead"
|
||||
class="btn-group"
|
||||
role="group"
|
||||
aria-label="Align Letterhead"
|
||||
>
|
||||
<button
|
||||
v-for="direction in ['Left', 'Center', 'Right']"
|
||||
type="button"
|
||||
class="btn btn-xs"
|
||||
@click="letterhead.align = direction"
|
||||
:class="
|
||||
letterhead.align == direction
|
||||
? 'btn-secondary'
|
||||
: 'btn-default'
|
||||
"
|
||||
>
|
||||
{{ direction }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
class="ml-4 custom-range"
|
||||
v-if="letterhead && $store.edit_letterhead"
|
||||
type="range"
|
||||
name="image-resize"
|
||||
min="20"
|
||||
:max="range_input_field === 'image_width' ? 700 : 500"
|
||||
:value="letterhead[range_input_field]"
|
||||
@input="
|
||||
e =>
|
||||
(letterhead[range_input_field] = parseFloat(
|
||||
e.target.value
|
||||
))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="ml-2 btn btn-default btn-xs"
|
||||
v-if="letterhead && $store.edit_letterhead"
|
||||
@click="upload_image"
|
||||
>
|
||||
{{ __("Change Image") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="letterhead && $store.edit_letterhead"
|
||||
class="ml-2 btn btn-default btn-xs btn-change-letterhead"
|
||||
@click="change_letterhead"
|
||||
>
|
||||
{{ __("Change Letter Head") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="letterhead"
|
||||
class="ml-2 btn btn-default btn-xs btn-edit"
|
||||
@click="toggle_edit_letterhead"
|
||||
>
|
||||
{{
|
||||
!$store.edit_letterhead
|
||||
? __("Edit Letter Head")
|
||||
: __("Done")
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
v-if="!letterhead"
|
||||
class="ml-2 btn btn-default btn-xs btn-edit"
|
||||
@click="create_letterhead"
|
||||
>
|
||||
{{ __("Create Letter Head") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="letterhead && !$store.edit_letterhead"
|
||||
v-html="letterhead.content"
|
||||
></div>
|
||||
<!-- <div v-show="letterhead && $store.edit_letterhead" ref="editor"></div> -->
|
||||
<div
|
||||
class="edit-letterhead"
|
||||
v-if="letterhead && $store.edit_letterhead"
|
||||
:style="{
|
||||
justifyContent: {
|
||||
Left: 'flex-start',
|
||||
Center: 'center',
|
||||
Right: 'flex-end'
|
||||
}[letterhead.align]
|
||||
}"
|
||||
>
|
||||
<div class="edit-image">
|
||||
<div v-if="letterhead.image">
|
||||
<img
|
||||
:src="letterhead.image"
|
||||
:style="{
|
||||
width:
|
||||
range_input_field === 'image_width'
|
||||
? letterhead.image_width + 'px'
|
||||
: null,
|
||||
height:
|
||||
range_input_field === 'image_height'
|
||||
? letterhead.image_height + 'px'
|
||||
: null
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<button v-else class="btn btn-default" @click="upload_image">
|
||||
{{ __("Upload Image") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { storeMixin } from "./store";
|
||||
import { get_image_dimensions } from "./utils";
|
||||
export default {
|
||||
name: "LetterHeadEditor",
|
||||
mixins: [storeMixin],
|
||||
data() {
|
||||
return {
|
||||
range_input_field: null,
|
||||
aspect_ratio: null
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
letterhead: {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
handler(letterhead) {
|
||||
if (!letterhead) return;
|
||||
if (letterhead.image_width && letterhead.image_height) {
|
||||
let dimension =
|
||||
letterhead.image_width > letterhead.image_height
|
||||
? "width"
|
||||
: "height";
|
||||
let dimension_value = letterhead["image_" + dimension];
|
||||
letterhead.content = `
|
||||
<div style="text-align: ${letterhead.align.toLowerCase()};">
|
||||
<img
|
||||
src="${letterhead.image}"
|
||||
alt="${letterhead.name}"
|
||||
${dimension}="${dimension_value}"
|
||||
style="${dimension}: ${dimension_value}px;">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (!this.letterhead) {
|
||||
frappe
|
||||
.call("frappe.client.get_default", { key: "letter_head" })
|
||||
.then(r => {
|
||||
if (r.message) {
|
||||
this.set_letterhead(r.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.$watch(
|
||||
function() {
|
||||
return this.letterhead
|
||||
? this.letterhead[this.range_input_field]
|
||||
: null;
|
||||
},
|
||||
function() {
|
||||
if (this.aspect_ratio === null) return;
|
||||
|
||||
let update_field =
|
||||
this.range_input_field == "image_width"
|
||||
? "image_height"
|
||||
: "image_width";
|
||||
this.letterhead[update_field] =
|
||||
update_field == "image_width"
|
||||
? this.aspect_ratio * this.letterhead.image_height
|
||||
: this.letterhead.image_width / this.aspect_ratio;
|
||||
}
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
toggle_edit_letterhead() {
|
||||
if (this.$store.edit_letterhead) {
|
||||
this.$store.edit_letterhead = false;
|
||||
return;
|
||||
}
|
||||
this.$store.edit_letterhead = true;
|
||||
if (!this.control) {
|
||||
this.control = frappe.ui.form.make_control({
|
||||
parent: this.$refs.editor,
|
||||
df: {
|
||||
fieldname: "letterhead",
|
||||
fieldtype: "Comment",
|
||||
change: () => {
|
||||
this.letterhead._dirty = true;
|
||||
this.letterhead.content = this.control.get_value();
|
||||
}
|
||||
},
|
||||
render_input: true,
|
||||
only_input: true,
|
||||
no_wrapper: true
|
||||
});
|
||||
}
|
||||
this.control.set_value(this.letterhead.content);
|
||||
},
|
||||
change_letterhead() {
|
||||
let d = new frappe.ui.Dialog({
|
||||
title: __("Change Letter Head"),
|
||||
fields: [
|
||||
{
|
||||
label: __("Letter Head"),
|
||||
fieldname: "letterhead",
|
||||
fieldtype: "Link",
|
||||
options: "Letter Head"
|
||||
}
|
||||
],
|
||||
primary_action: ({ letterhead }) => {
|
||||
if (letterhead) {
|
||||
this.set_letterhead(letterhead);
|
||||
}
|
||||
d.hide();
|
||||
}
|
||||
});
|
||||
d.show();
|
||||
},
|
||||
upload_image() {
|
||||
new frappe.ui.FileUploader({
|
||||
folder: "Home/Attachments",
|
||||
on_success: file_doc => {
|
||||
get_image_dimensions(file_doc.file_url).then(
|
||||
({ width, height }) => {
|
||||
this.$set(
|
||||
this.letterhead,
|
||||
"image",
|
||||
file_doc.file_url
|
||||
);
|
||||
let new_width = width;
|
||||
let new_height = height;
|
||||
this.aspect_ratio = width / height;
|
||||
this.range_input_field =
|
||||
this.aspect_ratio > 1
|
||||
? "image_width"
|
||||
: "image_height";
|
||||
|
||||
if (width > 200) {
|
||||
new_width = 200;
|
||||
new_height = new_width / aspect_ratio;
|
||||
}
|
||||
if (height > 80) {
|
||||
new_height = 80;
|
||||
new_width = aspect_ratio * new_height;
|
||||
}
|
||||
|
||||
this.$set(
|
||||
this.letterhead,
|
||||
"image_height",
|
||||
new_height
|
||||
);
|
||||
this.$set(
|
||||
this.letterhead,
|
||||
"image_width",
|
||||
new_width
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
set_letterhead(letterhead) {
|
||||
this.$store.change_letterhead(letterhead).then(() => {
|
||||
get_image_dimensions(this.letterhead.image).then(
|
||||
({ width, height }) => {
|
||||
this.aspect_ratio = width / height;
|
||||
this.range_input_field =
|
||||
this.aspect_ratio > 1
|
||||
? "image_width"
|
||||
: "image_height";
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
create_letterhead() {
|
||||
let d = new frappe.ui.Dialog({
|
||||
title: __("Create Letter Head"),
|
||||
fields: [
|
||||
{
|
||||
label: __("Letter Head Name"),
|
||||
fieldname: "name",
|
||||
fieldtype: "Data"
|
||||
}
|
||||
],
|
||||
primary_action: ({ name }) => {
|
||||
return frappe.db
|
||||
.insert({
|
||||
doctype: "Letter Head",
|
||||
letter_head_name: name,
|
||||
source: "Image"
|
||||
})
|
||||
.then(doc => {
|
||||
d.hide();
|
||||
this.$store.change_letterhead(doc.name).then(() => {
|
||||
this.toggle_edit_letterhead();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
d.show();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.letterhead {
|
||||
position: relative;
|
||||
border: 1px solid var(--dark-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.edit-letterhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.edit-image {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.edit-image img {
|
||||
height: 100%;
|
||||
}
|
||||
.edit-title {
|
||||
margin-left: 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
132
frappe/public/js/print_format_builder/Preview.vue
Normal file
132
frappe/public/js/print_format_builder/Preview.vue
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<template>
|
||||
<div class="h-100">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="preview-control" ref="doc-select"></div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="preview-control" ref="preview-type"></div>
|
||||
</div>
|
||||
<div class="col d-flex">
|
||||
<a
|
||||
v-if="url"
|
||||
class="btn btn-default btn-sm btn-new-tab"
|
||||
target="_blank"
|
||||
:href="url"
|
||||
>
|
||||
{{ __("Open in a new tab") }}
|
||||
</a>
|
||||
<button
|
||||
v-if="url"
|
||||
class="ml-3 btn btn-default btn-sm btn-new-tab"
|
||||
@click="refresh"
|
||||
>
|
||||
{{ __("Refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="url && !preview_loaded">Generating preview...</div>
|
||||
<iframe
|
||||
ref="iframe"
|
||||
:src="url"
|
||||
v-if="url"
|
||||
v-show="preview_loaded"
|
||||
class="preview-iframe"
|
||||
@load="preview_loaded = true"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { storeMixin } from "./store";
|
||||
export default {
|
||||
name: "Preview",
|
||||
mixins: [storeMixin],
|
||||
data() {
|
||||
return {
|
||||
type: "PDF",
|
||||
docname: null,
|
||||
preview_loaded: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.doc_select = frappe.ui.form.make_control({
|
||||
parent: this.$refs["doc-select"],
|
||||
df: {
|
||||
label: __("Select {0}", [__(this.doctype)]),
|
||||
fieldname: "docname",
|
||||
fieldtype: "Link",
|
||||
options: this.doctype,
|
||||
change: () => {
|
||||
this.docname = this.doc_select.get_value();
|
||||
}
|
||||
},
|
||||
render_input: true
|
||||
});
|
||||
this.preview_type = frappe.ui.form.make_control({
|
||||
parent: this.$refs["preview-type"],
|
||||
df: {
|
||||
label: __("Preview type"),
|
||||
fieldname: "docname",
|
||||
fieldtype: "Select",
|
||||
options: ["PDF", "HTML"],
|
||||
change: () => {
|
||||
this.type = this.preview_type.get_value();
|
||||
}
|
||||
},
|
||||
render_input: true
|
||||
});
|
||||
this.preview_type.set_value(this.type);
|
||||
this.get_default_docname().then(
|
||||
docname => docname && this.doc_select.set_value(docname)
|
||||
);
|
||||
this.$store.$on("after_save", () => {
|
||||
this.refresh();
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
refresh() {
|
||||
this.$refs.iframe.contentWindow.location.reload();
|
||||
},
|
||||
get_default_docname() {
|
||||
return frappe.db.get_list(this.doctype, { limit: 1 }).then(doc => {
|
||||
return doc.length > 0 ? doc[0].name : null;
|
||||
});
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
doctype() {
|
||||
return this.print_format.doc_type;
|
||||
},
|
||||
url() {
|
||||
if (!this.docname) return null;
|
||||
let params = new URLSearchParams();
|
||||
params.append("doctype", this.doctype);
|
||||
params.append("name", this.docname);
|
||||
params.append("print_format", this.print_format.name);
|
||||
if (this.$store.letterhead) {
|
||||
params.append("letterhead", this.$store.letterhead.name);
|
||||
}
|
||||
let url =
|
||||
this.type == "PDF"
|
||||
? `/api/method/frappe.utils.weasyprint.download_pdf`
|
||||
: "/printpreview";
|
||||
return `${url}?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
height: 96%;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
.btn-new-tab {
|
||||
margin-top: auto;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.preview-control >>> .form-control {
|
||||
background: var(--control-bg-on-gray);
|
||||
}
|
||||
</style>
|
||||
136
frappe/public/js/print_format_builder/PrintFormat.vue
Normal file
136
frappe/public/js/print_format_builder/PrintFormat.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<template>
|
||||
<div class="print-format-main" :style="rootStyles">
|
||||
<div :style="page_number_style">{{ __("1 of 2") }}</div>
|
||||
|
||||
<LetterHeadEditor type="Header" />
|
||||
<HTMLEditor
|
||||
:value="layout.header"
|
||||
@change="$set(layout, 'header', $event)"
|
||||
:button-label="__('Edit Header')"
|
||||
/>
|
||||
<draggable
|
||||
class="mb-4"
|
||||
v-model="layout.sections"
|
||||
group="sections"
|
||||
filter=".section-columns, .column, .field"
|
||||
:animation="200"
|
||||
>
|
||||
<PrintFormatSection
|
||||
v-for="(section, i) in layout.sections"
|
||||
:key="i"
|
||||
:section="section"
|
||||
@add_section_above="add_section_above(section)"
|
||||
/>
|
||||
</draggable>
|
||||
<HTMLEditor
|
||||
:value="layout.footer"
|
||||
@change="$set(layout, 'footer', $event)"
|
||||
:button-label="__('Edit Footer')"
|
||||
/>
|
||||
<HTMLEditor
|
||||
v-if="letterhead"
|
||||
:value="letterhead.footer"
|
||||
@change="update_letterhead_footer"
|
||||
:button-label="__('Edit Letter Head Footer')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from "vuedraggable";
|
||||
import HTMLEditor from "./HTMLEditor.vue";
|
||||
import LetterHeadEditor from "./LetterHeadEditor.vue";
|
||||
import PrintFormatSection from "./PrintFormatSection.vue";
|
||||
import { storeMixin } from "./store";
|
||||
|
||||
export default {
|
||||
name: "PrintFormat",
|
||||
mixins: [storeMixin],
|
||||
components: {
|
||||
draggable,
|
||||
PrintFormatSection,
|
||||
LetterHeadEditor,
|
||||
HTMLEditor
|
||||
},
|
||||
computed: {
|
||||
rootStyles() {
|
||||
let {
|
||||
margin_top = 0,
|
||||
margin_bottom = 0,
|
||||
margin_left = 0,
|
||||
margin_right = 0
|
||||
} = this.print_format;
|
||||
return {
|
||||
padding: `${margin_top}mm ${margin_right}mm ${margin_bottom}mm ${margin_left}mm`,
|
||||
width: "210mm",
|
||||
minHeight: "297mm"
|
||||
};
|
||||
},
|
||||
page_number_style() {
|
||||
let style = {
|
||||
position: "absolute",
|
||||
background: "white",
|
||||
padding: "4px",
|
||||
borderRadius: "var(--border-radius)",
|
||||
border: "1px solid var(--border-color)"
|
||||
};
|
||||
if (this.print_format.page_number.includes("Top")) {
|
||||
style.top = this.print_format.margin_top / 2 + "mm";
|
||||
style.transform = "translateY(-50%)";
|
||||
}
|
||||
if (this.print_format.page_number.includes("Left")) {
|
||||
style.left = this.print_format.margin_left + "mm";
|
||||
}
|
||||
if (this.print_format.page_number.includes("Right")) {
|
||||
style.right = this.print_format.margin_right + "mm";
|
||||
}
|
||||
if (this.print_format.page_number.includes("Bottom")) {
|
||||
style.bottom = this.print_format.margin_bottom / 2 + "mm";
|
||||
style.transform = "translateY(50%)";
|
||||
}
|
||||
if (this.print_format.page_number.includes("Center")) {
|
||||
style.left = "50%";
|
||||
style.transform += " translateX(-50%)";
|
||||
}
|
||||
if (this.print_format.page_number.includes("Hide")) {
|
||||
style.display = "none";
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
add_section_above(section) {
|
||||
let sections = [];
|
||||
for (let _section of this.layout.sections) {
|
||||
if (_section === section) {
|
||||
sections.push({
|
||||
label: "",
|
||||
columns: [
|
||||
{ label: "", fields: [] },
|
||||
{ label: "", fields: [] }
|
||||
]
|
||||
});
|
||||
}
|
||||
sections.push(_section);
|
||||
}
|
||||
this.$set(this.layout, "sections", sections);
|
||||
},
|
||||
update_letterhead_footer(val) {
|
||||
this.letterhead.footer = val;
|
||||
this.letterhead._dirty = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.print-format-main {
|
||||
position: relative;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
background-color: white;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
74
frappe/public/js/print_format_builder/PrintFormatBuilder.vue
Normal file
74
frappe/public/js/print_format_builder/PrintFormatBuilder.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<div class="layout-main-section row" v-if="shouldRender">
|
||||
<div class="col-3">
|
||||
<PrintFormatControls />
|
||||
</div>
|
||||
<div class="print-format-container col-9">
|
||||
<keep-alive>
|
||||
<Preview v-if="show_preview" />
|
||||
<PrintFormat v-else />
|
||||
</keep-alive>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PrintFormat from "./PrintFormat.vue";
|
||||
import Preview from "./Preview.vue";
|
||||
import PrintFormatControls from "./PrintFormatControls.vue";
|
||||
import { getStore } from "./store";
|
||||
|
||||
export default {
|
||||
name: "PrintFormatBuilder",
|
||||
props: ["print_format_name"],
|
||||
components: {
|
||||
PrintFormat,
|
||||
PrintFormatControls,
|
||||
Preview
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show_preview: false
|
||||
};
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
$store: this.$store
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$store.fetch().then(() => {
|
||||
if (!this.$store.layout) {
|
||||
this.$store.layout = this.$store.get_default_layout();
|
||||
this.$store.save_changes();
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
toggle_preview() {
|
||||
this.show_preview = !this.show_preview;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
$store() {
|
||||
return getStore(this.print_format_name);
|
||||
},
|
||||
shouldRender() {
|
||||
return Boolean(
|
||||
this.$store.print_format &&
|
||||
this.$store.meta &&
|
||||
this.$store.layout
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.print-format-container {
|
||||
height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
</style>
|
||||
336
frappe/public/js/print_format_builder/PrintFormatControls.vue
Normal file
336
frappe/public/js/print_format_builder/PrintFormatControls.vue
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
<template>
|
||||
<div class="layout-side-section">
|
||||
<div class="form-sidebar">
|
||||
<div class="sidebar-menu">
|
||||
<div class="sidebar-label">{{ __("Page Margins") }}</div>
|
||||
<div class="margin-controls">
|
||||
<div
|
||||
class="form-group"
|
||||
v-for="df in margins"
|
||||
:key="df.fieldname"
|
||||
>
|
||||
<div class="clearfix">
|
||||
<label class="control-label">
|
||||
{{ df.label }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-input-wrapper">
|
||||
<div class="control-input">
|
||||
<input
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
:value="print_format[df.fieldname]"
|
||||
min="0"
|
||||
@change="
|
||||
e =>
|
||||
update_margin(
|
||||
df.fieldname,
|
||||
e.target.value
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-menu">
|
||||
<div class="sidebar-label">{{ __("Google Font") }}</div>
|
||||
<div class="form-group">
|
||||
<div class="control-input-wrapper">
|
||||
<div class="control-input">
|
||||
<select
|
||||
class="form-control form-control-sm"
|
||||
v-model="print_format.font"
|
||||
>
|
||||
<option
|
||||
v-for="font in google_fonts"
|
||||
:value="font"
|
||||
>
|
||||
{{ font }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-menu">
|
||||
<div class="sidebar-label">{{ __("Font Size") }}</div>
|
||||
<div class="form-group">
|
||||
<div class="control-input-wrapper">
|
||||
<div class="control-input">
|
||||
<input
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="12, 13, 14"
|
||||
:value="print_format.font_size"
|
||||
@change="
|
||||
e =>
|
||||
(print_format.font_size = parseFloat(
|
||||
e.target.value
|
||||
))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-menu">
|
||||
<div class="sidebar-label">{{ __("Page Number") }}</div>
|
||||
<div class="form-group">
|
||||
<div class="control-input-wrapper">
|
||||
<div class="control-input">
|
||||
<select
|
||||
class="form-control form-control-sm"
|
||||
v-model="print_format.page_number"
|
||||
>
|
||||
<option
|
||||
v-for="position in page_number_positions"
|
||||
:value="position.value"
|
||||
>
|
||||
{{ position.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-menu">
|
||||
<div class="sidebar-label">{{ __("Fields") }}</div>
|
||||
<input
|
||||
class="mb-2 form-control form-control-sm"
|
||||
type="text"
|
||||
:placeholder="__('Search fields')"
|
||||
v-model="search_text"
|
||||
/>
|
||||
<draggable
|
||||
class="fields-container"
|
||||
:list="fields"
|
||||
:group="{ name: 'fields', pull: 'clone', put: false }"
|
||||
:sort="false"
|
||||
:clone="clone_field"
|
||||
>
|
||||
<div
|
||||
class="field"
|
||||
v-for="df in fields"
|
||||
:key="df.fieldname"
|
||||
:title="df.fieldname"
|
||||
>
|
||||
{{ df.label }}
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from "vuedraggable";
|
||||
import { get_table_columns, pluck } from "./utils";
|
||||
import { storeMixin } from "./store";
|
||||
|
||||
export default {
|
||||
name: "PrintFormatControls",
|
||||
mixins: [storeMixin],
|
||||
data() {
|
||||
return {
|
||||
search_text: "",
|
||||
google_fonts: []
|
||||
};
|
||||
},
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
mounted() {
|
||||
let method =
|
||||
"frappe.printing.page.print_format_builder_beta.print_format_builder_beta.get_google_fonts";
|
||||
frappe.call(method).then(r => {
|
||||
this.google_fonts = r.message || [];
|
||||
if (!this.google_fonts.includes(this.print_format.font)) {
|
||||
this.google_fonts.push(this.print_format.font);
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
update_margin(fieldname, value) {
|
||||
value = parseFloat(value);
|
||||
if (value < 0) {
|
||||
value = 0;
|
||||
}
|
||||
this.$store.print_format[fieldname] = value;
|
||||
},
|
||||
clone_field(df) {
|
||||
let cloned = pluck(df, [
|
||||
"label",
|
||||
"fieldname",
|
||||
"fieldtype",
|
||||
"options",
|
||||
"table_columns",
|
||||
"html",
|
||||
"field_template"
|
||||
]);
|
||||
if (cloned.custom) {
|
||||
// generate unique fieldnames for custom blocks
|
||||
cloned.fieldname += "_" + frappe.utils.get_random(8);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
margins() {
|
||||
return [
|
||||
{ label: __("Top"), fieldname: "margin_top" },
|
||||
{ label: __("Bottom"), fieldname: "margin_bottom" },
|
||||
{ label: __("Left"), fieldname: "margin_left" },
|
||||
{ label: __("Right"), fieldname: "margin_right" }
|
||||
];
|
||||
},
|
||||
fields() {
|
||||
let fields = this.meta.fields
|
||||
.filter(df => {
|
||||
if (
|
||||
["Section Break", "Column Break"].includes(df.fieldtype)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (this.search_text) {
|
||||
if (df.fieldname.includes(this.search_text)) {
|
||||
return true;
|
||||
}
|
||||
if (df.label && df.label.includes(this.search_text)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.map(df => {
|
||||
let out = {
|
||||
label: df.label,
|
||||
fieldname: df.fieldname,
|
||||
fieldtype: df.fieldtype,
|
||||
options: df.options
|
||||
};
|
||||
if (df.fieldtype == "Table") {
|
||||
out.table_columns = get_table_columns(df);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
label: __("Custom HTML"),
|
||||
fieldname: "custom_html",
|
||||
fieldtype: "HTML",
|
||||
html: "",
|
||||
custom: 1
|
||||
},
|
||||
{
|
||||
label: __("ID (name)"),
|
||||
fieldname: "name",
|
||||
fieldtype: "Data"
|
||||
},
|
||||
{
|
||||
label: __("Spacer"),
|
||||
fieldname: "spacer",
|
||||
fieldtype: "Spacer",
|
||||
custom: 1
|
||||
},
|
||||
{
|
||||
label: __("Divider"),
|
||||
fieldname: "divider",
|
||||
fieldtype: "Divider",
|
||||
custom: 1
|
||||
},
|
||||
...this.print_templates,
|
||||
...fields
|
||||
];
|
||||
},
|
||||
print_templates() {
|
||||
let templates = this.print_format.__onload.print_templates || {};
|
||||
let out = [];
|
||||
for (let template of templates) {
|
||||
let df;
|
||||
if (template.field) {
|
||||
df = frappe.meta.get_docfield(
|
||||
this.meta.name,
|
||||
template.field
|
||||
);
|
||||
} else {
|
||||
df = {
|
||||
label: template.name,
|
||||
fieldname: frappe.scrub(template.name)
|
||||
};
|
||||
}
|
||||
out.push({
|
||||
label: `${__(df.label)} (${__("Field Template")})`,
|
||||
fieldname: df.fieldname + "_template",
|
||||
fieldtype: "Field Template",
|
||||
field_template: template.name
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
page_number_positions() {
|
||||
return [
|
||||
{ label: __("Hide"), value: "Hide" },
|
||||
{ label: __("Top Left"), value: "Top Left" },
|
||||
{ label: __("Top Center"), value: "Top Center" },
|
||||
{ label: __("Top Right"), value: "Top Right" },
|
||||
{ label: __("Bottom Left"), value: "Bottom Left" },
|
||||
{ label: __("Bottom Center"), value: "Bottom Center" },
|
||||
{ label: __("Bottom Right"), value: "Bottom Right" }
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.margin-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background: var(--control-bg-on-gray);
|
||||
}
|
||||
|
||||
.margin-controls > .form-group + .form-group {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.margin-controls > .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fields-container {
|
||||
max-height: calc(100vh - 34rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background-color: var(--bg-light-gray);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px dashed var(--gray-400);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field:not(:first-child) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-menu:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.control-font >>> .frappe-control[data-fieldname="font"] label {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
245
frappe/public/js/print_format_builder/PrintFormatSection.vue
Normal file
245
frappe/public/js/print_format_builder/PrintFormatSection.vue
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<template>
|
||||
<div class="print-format-section-container" v-if="!section.remove">
|
||||
<div class="print-format-section">
|
||||
<div class="section-header">
|
||||
<input
|
||||
class="input-section-label w-50"
|
||||
type="text"
|
||||
:placeholder="__('Section Title')"
|
||||
v-model="section.label"
|
||||
/>
|
||||
<div class="d-flex align-items-center">
|
||||
<div
|
||||
class="mr-2 text-small text-muted d-flex"
|
||||
v-if="section.field_orientation == 'left-right'"
|
||||
:title="
|
||||
// prettier-ignore
|
||||
__('Render labels to the left and values to the right in this section')
|
||||
"
|
||||
>
|
||||
Label → Value
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-xs btn-section dropdown-button"
|
||||
data-toggle="dropdown"
|
||||
>
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-dot-horizontal"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="dropdown-menu dropdown-menu-right"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
v-for="option in section_options"
|
||||
class="dropdown-item"
|
||||
@click="option.action"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row section-columns">
|
||||
<div
|
||||
class="column col"
|
||||
v-for="(column, i) in section.columns"
|
||||
:key="i"
|
||||
>
|
||||
<draggable
|
||||
class="drag-container"
|
||||
:style="{
|
||||
backgroundColor: column.fields.length
|
||||
? null
|
||||
: 'var(--gray-50)'
|
||||
}"
|
||||
v-model="column.fields"
|
||||
group="fields"
|
||||
:animation="150"
|
||||
>
|
||||
<Field
|
||||
v-for="df in get_fields(column)"
|
||||
:key="df.fieldname"
|
||||
:df="df"
|
||||
/>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="my-4 text-center text-muted font-italic"
|
||||
v-if="section.page_break"
|
||||
>
|
||||
{{ __("Page Break") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from "vuedraggable";
|
||||
import Field from "./Field.vue";
|
||||
import { storeMixin } from "./store";
|
||||
|
||||
export default {
|
||||
name: "PrintFormatSection",
|
||||
mixins: [storeMixin],
|
||||
props: ["section"],
|
||||
components: {
|
||||
draggable,
|
||||
Field
|
||||
},
|
||||
methods: {
|
||||
add_column() {
|
||||
if (this.section.columns.length < 4) {
|
||||
this.section.columns.push({
|
||||
label: "",
|
||||
fields: []
|
||||
});
|
||||
}
|
||||
},
|
||||
remove_column() {
|
||||
if (this.section.columns.length <= 1) return;
|
||||
|
||||
let columns = this.section.columns.slice();
|
||||
let last_column_fields = columns.slice(-1)[0].fields.slice();
|
||||
let index = columns.length - 1;
|
||||
columns = columns.slice(0, index);
|
||||
let last_column = columns[index - 1];
|
||||
last_column.fields = [...last_column.fields, ...last_column_fields];
|
||||
|
||||
this.$set(this.section, "columns", columns);
|
||||
},
|
||||
add_page_break() {
|
||||
this.$set(this.section, "page_break", true);
|
||||
},
|
||||
remove_page_break() {
|
||||
this.$set(this.section, "page_break", false);
|
||||
},
|
||||
get_fields(column) {
|
||||
return column.fields.filter(df => !df.remove);
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
section_options() {
|
||||
return [
|
||||
{
|
||||
label: __("Add section above"),
|
||||
action: () => this.$emit("add_section_above")
|
||||
},
|
||||
{
|
||||
label: __("Add column"),
|
||||
action: this.add_column,
|
||||
condition: () => this.section.columns.length < 4
|
||||
},
|
||||
{
|
||||
label: __("Remove column"),
|
||||
action: this.remove_column,
|
||||
condition: () => this.section.columns.length > 1
|
||||
},
|
||||
{
|
||||
label: __("Add page break"),
|
||||
action: this.add_page_break,
|
||||
condition: () => !this.section.page_break
|
||||
},
|
||||
{
|
||||
label: __("Remove page break"),
|
||||
action: this.remove_page_break,
|
||||
condition: () => this.section.page_break
|
||||
},
|
||||
{
|
||||
label: __("Remove section"),
|
||||
action: () => this.$set(this.section, "remove", true)
|
||||
},
|
||||
{
|
||||
label: __("Field Orientation (Left-Right)"),
|
||||
condition: () => !this.section.field_orientation,
|
||||
action: () =>
|
||||
this.$set(
|
||||
this.section,
|
||||
"field_orientation",
|
||||
"left-right"
|
||||
)
|
||||
},
|
||||
{
|
||||
label: __("Field Orientation (Top-Down)"),
|
||||
condition: () =>
|
||||
this.section.field_orientation == "left-right",
|
||||
action: () =>
|
||||
this.$set(this.section, "field_orientation", "")
|
||||
}
|
||||
].filter(option => (option.condition ? option.condition() : true));
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.print-format-section-container:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.print-format-section {
|
||||
background-color: white;
|
||||
border: 1px solid var(--dark-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.input-section-label {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-section-label:focus {
|
||||
border-color: var(--border-color);
|
||||
outline: none;
|
||||
background-color: var(--control-bg);
|
||||
}
|
||||
|
||||
.input-section-label::placeholder {
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.btn-section {
|
||||
padding: var(--padding-xs);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-section:hover {
|
||||
background-color: var(--bg-light-gray);
|
||||
}
|
||||
|
||||
.print-format-section:not(:first-child) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.section-columns {
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
.column {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.drag-container {
|
||||
height: 100%;
|
||||
min-height: 2rem;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import PrintFormatBuilderComponent from "./PrintFormatBuilder.vue";
|
||||
import { getStore } from "./store";
|
||||
|
||||
class PrintFormatBuilder {
|
||||
constructor({ wrapper, page, print_format }) {
|
||||
this.$wrapper = $(wrapper);
|
||||
this.page = page;
|
||||
this.print_format = print_format;
|
||||
|
||||
this.page.clear_actions();
|
||||
this.page.clear_icons();
|
||||
this.page.clear_custom_actions();
|
||||
|
||||
this.page.set_title(__("Editing {0}", [this.print_format]));
|
||||
this.page.set_primary_action(__("Save"), () => {
|
||||
this.$component.$store.save_changes();
|
||||
});
|
||||
let $toggle_preview_btn = this.page.add_button(
|
||||
__("Show Preview"),
|
||||
() => {
|
||||
this.$component.toggle_preview();
|
||||
}
|
||||
);
|
||||
this.page.add_button(__("Reset Changes"), () =>
|
||||
this.$component.$store.reset_changes()
|
||||
);
|
||||
this.page.add_menu_item(__("Edit Print Format"), () => {
|
||||
frappe.set_route("Form", "Print Format", this.print_format);
|
||||
});
|
||||
this.page.add_menu_item(__("Change Print Format"), () => {
|
||||
frappe.set_route("print-format-builder-beta");
|
||||
});
|
||||
|
||||
let $vm = new Vue({
|
||||
el: this.$wrapper.get(0),
|
||||
render: h =>
|
||||
h(PrintFormatBuilderComponent, {
|
||||
props: {
|
||||
print_format_name: print_format
|
||||
}
|
||||
})
|
||||
});
|
||||
this.$component = $vm.$children[0];
|
||||
let store = getStore(print_format);
|
||||
store.$watch("dirty", value => {
|
||||
if (value) {
|
||||
this.page.set_indicator("Not Saved", "orange");
|
||||
$toggle_preview_btn.hide();
|
||||
} else {
|
||||
this.page.clear_indicator();
|
||||
$toggle_preview_btn.show();
|
||||
}
|
||||
});
|
||||
this.$component.$watch("show_preview", value => {
|
||||
$toggle_preview_btn.text(
|
||||
value ? __("Hide Preview") : __("Show Preview")
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
frappe.provide("frappe.ui");
|
||||
frappe.ui.PrintFormatBuilder = PrintFormatBuilder;
|
||||
export default PrintFormatBuilder;
|
||||
177
frappe/public/js/print_format_builder/store.js
Normal file
177
frappe/public/js/print_format_builder/store.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { create_default_layout, pluck } from "./utils";
|
||||
|
||||
let stores = {};
|
||||
|
||||
export function getStore(print_format_name) {
|
||||
if (stores[print_format_name]) {
|
||||
return stores[print_format_name];
|
||||
}
|
||||
|
||||
let options = {
|
||||
data() {
|
||||
return {
|
||||
print_format_name,
|
||||
letterhead_name: null,
|
||||
print_format: null,
|
||||
letterhead: null,
|
||||
doctype: null,
|
||||
meta: null,
|
||||
layout: null,
|
||||
dirty: false,
|
||||
edit_letterhead: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
layout: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.dirty = true;
|
||||
}
|
||||
},
|
||||
print_format: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetch() {
|
||||
return new Promise(resolve => {
|
||||
frappe.model.clear_doc(
|
||||
"Print Format",
|
||||
this.print_format_name
|
||||
);
|
||||
frappe.model.with_doc(
|
||||
"Print Format",
|
||||
this.print_format_name,
|
||||
() => {
|
||||
let print_format = frappe.get_doc(
|
||||
"Print Format",
|
||||
this.print_format_name
|
||||
);
|
||||
frappe.model.with_doctype(
|
||||
print_format.doc_type,
|
||||
() => {
|
||||
this.meta = frappe.get_meta(
|
||||
print_format.doc_type
|
||||
);
|
||||
this.print_format = print_format;
|
||||
this.layout = this.get_layout();
|
||||
this.$nextTick(() => (this.dirty = false));
|
||||
this.edit_letterhead = false;
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
update({ fieldname, value }) {
|
||||
this.$set(this.print_format, fieldname, value);
|
||||
},
|
||||
save_changes() {
|
||||
frappe.dom.freeze(__("Saving..."));
|
||||
|
||||
this.layout.sections = this.layout.sections
|
||||
.filter(section => !section.remove)
|
||||
.map(section => {
|
||||
section.columns = section.columns.map(column => {
|
||||
column.fields = column.fields
|
||||
.filter(df => !df.remove)
|
||||
.map(df => {
|
||||
if (df.table_columns) {
|
||||
df.table_columns = df.table_columns.map(
|
||||
tf => {
|
||||
return pluck(tf, [
|
||||
"label",
|
||||
"fieldname",
|
||||
"fieldtype",
|
||||
"options",
|
||||
"width",
|
||||
"field_template"
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
return pluck(df, [
|
||||
"label",
|
||||
"fieldname",
|
||||
"fieldtype",
|
||||
"options",
|
||||
"table_columns",
|
||||
"html",
|
||||
"field_template"
|
||||
]);
|
||||
});
|
||||
return column;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
|
||||
this.print_format.format_data = JSON.stringify(this.layout);
|
||||
|
||||
frappe
|
||||
.call("frappe.client.save", {
|
||||
doc: this.print_format
|
||||
})
|
||||
.then(() => {
|
||||
if (this.letterhead && this.letterhead._dirty) {
|
||||
return frappe
|
||||
.call("frappe.client.save", {
|
||||
doc: this.letterhead
|
||||
})
|
||||
.then(r => (this.letterhead = r.message));
|
||||
}
|
||||
})
|
||||
.then(() => this.fetch())
|
||||
.always(() => {
|
||||
frappe.dom.unfreeze();
|
||||
this.$emit("after_save");
|
||||
});
|
||||
},
|
||||
reset_changes() {
|
||||
this.fetch();
|
||||
},
|
||||
get_layout() {
|
||||
if (this.print_format) {
|
||||
if (typeof this.print_format.format_data == "string") {
|
||||
return JSON.parse(this.print_format.format_data);
|
||||
}
|
||||
return this.print_format.format_data;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
get_default_layout() {
|
||||
return create_default_layout(this.meta, this.print_format);
|
||||
},
|
||||
change_letterhead(letterhead) {
|
||||
return frappe.db
|
||||
.get_doc("Letter Head", letterhead)
|
||||
.then(doc => {
|
||||
this.letterhead = doc;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
stores[print_format_name] = new Vue(options);
|
||||
return stores[print_format_name];
|
||||
}
|
||||
|
||||
export let storeMixin = {
|
||||
inject: ["$store"],
|
||||
computed: {
|
||||
print_format() {
|
||||
return this.$store.print_format;
|
||||
},
|
||||
layout() {
|
||||
return this.$store.layout;
|
||||
},
|
||||
letterhead() {
|
||||
return this.$store.letterhead;
|
||||
},
|
||||
meta() {
|
||||
return this.$store.meta;
|
||||
}
|
||||
}
|
||||
};
|
||||
159
frappe/public/js/print_format_builder/utils.js
Normal file
159
frappe/public/js/print_format_builder/utils.js
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
export function create_default_layout(meta, print_format) {
|
||||
let layout = {
|
||||
header: get_default_header(meta),
|
||||
sections: []
|
||||
};
|
||||
|
||||
let section = null,
|
||||
column = null;
|
||||
|
||||
function set_column(df) {
|
||||
if (!section) {
|
||||
set_section();
|
||||
}
|
||||
column = get_new_column(df);
|
||||
section.columns.push(column);
|
||||
}
|
||||
|
||||
function set_section(df) {
|
||||
section = get_new_section(df);
|
||||
column = null;
|
||||
layout.sections.push(section);
|
||||
}
|
||||
|
||||
function get_new_section(df) {
|
||||
if (!df) {
|
||||
df = { label: "" };
|
||||
}
|
||||
return {
|
||||
label: df.label || "",
|
||||
columns: []
|
||||
};
|
||||
}
|
||||
|
||||
function get_new_column(df) {
|
||||
if (!df) {
|
||||
df = { label: "" };
|
||||
}
|
||||
return {
|
||||
label: df.label || "",
|
||||
fields: []
|
||||
};
|
||||
}
|
||||
|
||||
for (let df of meta.fields) {
|
||||
if (df.fieldname) {
|
||||
// make a copy to avoid mutation bugs
|
||||
df = JSON.parse(JSON.stringify(df));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (df.fieldtype === "Section Break") {
|
||||
set_section(df);
|
||||
} else if (df.fieldtype === "Column Break") {
|
||||
set_column(df);
|
||||
} else if (df.label) {
|
||||
if (!column) set_column();
|
||||
|
||||
if (!df.print_hide) {
|
||||
let field = {
|
||||
label: df.label,
|
||||
fieldname: df.fieldname,
|
||||
fieldtype: df.fieldtype,
|
||||
options: df.options
|
||||
};
|
||||
|
||||
let field_template = get_field_template(
|
||||
print_format,
|
||||
df.fieldname
|
||||
);
|
||||
if (field_template) {
|
||||
field.label = `${__(df.label)} (${__("Field Template")})`;
|
||||
field.fieldtype = "Field Template";
|
||||
field.field_template = field_template.name;
|
||||
field.fieldname = df.fieldname = "_template";
|
||||
}
|
||||
|
||||
if (df.fieldtype === "Table") {
|
||||
field.table_columns = get_table_columns(df);
|
||||
}
|
||||
|
||||
column.fields.push(field);
|
||||
section.has_fields = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove empty sections
|
||||
layout.sections = layout.sections.filter(section => section.has_fields);
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
export function get_table_columns(df) {
|
||||
let table_columns = [];
|
||||
let table_fields = frappe.get_meta(df.options).fields;
|
||||
let total_width = 0;
|
||||
for (let tf of table_fields) {
|
||||
if (
|
||||
!in_list(["Section Break", "Column Break"], tf.fieldtype) &&
|
||||
!tf.print_hide &&
|
||||
df.label &&
|
||||
total_width < 100
|
||||
) {
|
||||
let width =
|
||||
typeof tf.width == "number" && tf.width < 100
|
||||
? tf.width
|
||||
: tf.width
|
||||
? 20
|
||||
: 10;
|
||||
table_columns.push({
|
||||
label: tf.label,
|
||||
fieldname: tf.fieldname,
|
||||
fieldtype: tf.fieldtype,
|
||||
options: tf.options,
|
||||
width
|
||||
});
|
||||
total_width += width;
|
||||
}
|
||||
}
|
||||
return table_columns;
|
||||
}
|
||||
|
||||
function get_field_template(print_format, fieldname) {
|
||||
let templates = print_format.__onload.print_templates || {};
|
||||
for (let template of templates) {
|
||||
if (template.field === fieldname) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function get_default_header(meta) {
|
||||
return `<div class="document-header">
|
||||
<h3>${meta.name}</h3>
|
||||
<p>{{ doc.name }}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function pluck(object, keys) {
|
||||
let out = {};
|
||||
for (let key of keys) {
|
||||
if (key in object) {
|
||||
out[key] = object[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function get_image_dimensions(src) {
|
||||
return new Promise(resolve => {
|
||||
let img = new Image();
|
||||
img.onload = function() {
|
||||
resolve({ width: this.width, height: this.height });
|
||||
};
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
|
@ -231,6 +231,13 @@ textarea.form-control {
|
|||
background-color: var(--control-bg);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--padding-md);
|
||||
|
||||
svg > rect {
|
||||
fill: var(--control-bg) !important;
|
||||
}
|
||||
svg > g {
|
||||
fill: var(--text-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.preview-beta-wrapper {
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.print-toolbar {
|
||||
margin: 0px;
|
||||
padding: var(--padding-md) 0;
|
||||
|
|
|
|||
5
frappe/public/scss/print_format.bundle.scss
Normal file
5
frappe/public/scss/print_format.bundle.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@import "./desk/variables.scss";
|
||||
@import "./common/mixins.scss";
|
||||
@import "./common/global.scss";
|
||||
@import "./common/icons.scss";
|
||||
@import "~bootstrap/scss/bootstrap";
|
||||
|
|
@ -7,7 +7,7 @@ from frappe.utils import update_progress_bar
|
|||
from whoosh.index import create_in, open_dir, EmptyIndexError
|
||||
from whoosh.fields import TEXT, ID, Schema
|
||||
from whoosh.qparser import MultifieldParser, FieldsPlugin, WildcardPlugin
|
||||
from whoosh.query import Prefix
|
||||
from whoosh.query import Prefix, FuzzyTerm
|
||||
from whoosh.writing import AsyncWriter
|
||||
|
||||
|
||||
|
|
@ -23,6 +23,9 @@ class FullTextSearch:
|
|||
def get_schema(self):
|
||||
return Schema(name=ID(stored=True), content=TEXT(stored=True))
|
||||
|
||||
def get_fields_to_search(self):
|
||||
return ["name", "content"]
|
||||
|
||||
def get_id(self):
|
||||
return "name"
|
||||
|
||||
|
|
@ -120,8 +123,15 @@ class FullTextSearch:
|
|||
results = None
|
||||
out = []
|
||||
|
||||
search_fields = self.get_fields_to_search()
|
||||
fieldboosts = {}
|
||||
|
||||
# apply reducing boost on fields based on order. 1.0, 0.5, 0.33 and so on
|
||||
for idx, field in enumerate(search_fields, start=1):
|
||||
fieldboosts[field] = 1.0 / idx
|
||||
|
||||
with ix.searcher() as searcher:
|
||||
parser = MultifieldParser(["title", "content"], ix.schema)
|
||||
parser = MultifieldParser(search_fields, ix.schema, termclass=FuzzyTermExtended, fieldboosts=fieldboosts)
|
||||
parser.remove_plugin_class(FieldsPlugin)
|
||||
parser.remove_plugin_class(WildcardPlugin)
|
||||
query = parser.parse(text)
|
||||
|
|
@ -136,5 +146,13 @@ class FullTextSearch:
|
|||
|
||||
return out
|
||||
|
||||
|
||||
class FuzzyTermExtended(FuzzyTerm):
|
||||
def __init__(self, fieldname, text, boost=1.0, maxdist=2, prefixlength=1,
|
||||
constantscore=True):
|
||||
super().__init__(fieldname, text, boost=boost, maxdist=maxdist,
|
||||
prefixlength=prefixlength, constantscore=constantscore)
|
||||
|
||||
|
||||
def get_index_path(index_name):
|
||||
return frappe.get_site_path("indexes", index_name)
|
||||
|
|
|
|||
|
|
@ -125,4 +125,4 @@ def get_documents():
|
|||
deploy business applications with Rich Admin Interface. CommonSearchTerm"""
|
||||
})
|
||||
|
||||
return docs
|
||||
return docs
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ class WebsiteSearch(FullTextSearch):
|
|||
title=TEXT(stored=True), path=ID(stored=True), content=TEXT(stored=True)
|
||||
)
|
||||
|
||||
def get_fields_to_search(self):
|
||||
return ["title", "content"]
|
||||
|
||||
def get_id(self):
|
||||
return "path"
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import frappe.translate
|
|||
import redis
|
||||
from urllib.parse import unquote
|
||||
from frappe.cache_manager import clear_user_cache
|
||||
from frappe.query_builder import Order, DocType
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def clear():
|
||||
|
|
@ -61,18 +63,14 @@ def get_sessions_to_clear(user=None, keep_current=False, device=None):
|
|||
simultaneous_sessions = frappe.db.get_value('User', user, 'simultaneous_sessions') or 1
|
||||
offset = simultaneous_sessions - 1
|
||||
|
||||
condition = ''
|
||||
session = DocType("Sessions")
|
||||
session_id = frappe.qb.from_(session).where((session.user == user) & (session.device.isin(device)))
|
||||
if keep_current:
|
||||
condition = ' AND sid != {0}'.format(frappe.db.escape(frappe.session.sid))
|
||||
session_id = session_id.where(session.sid != frappe.db.escape(frappe.session.sid))
|
||||
|
||||
return frappe.db.sql_list("""
|
||||
SELECT `sid` FROM `tabSessions`
|
||||
WHERE `tabSessions`.user=%(user)s
|
||||
AND device in %(device)s
|
||||
{condition}
|
||||
ORDER BY `lastupdate` DESC
|
||||
LIMIT 100 OFFSET {offset}""".format(condition=condition, offset=offset),
|
||||
{"user": user, "device": device})
|
||||
query = session_id.select(session.sid).offset(offset).limit(100).orderby(session.lastupdate, order=Order.desc)
|
||||
|
||||
return query.run(pluck=True)
|
||||
|
||||
def delete_session(sid=None, user=None, reason="Session Expired"):
|
||||
from frappe.core.doctype.activity_log.feed import logout_feed
|
||||
|
|
@ -80,7 +78,10 @@ def delete_session(sid=None, user=None, reason="Session Expired"):
|
|||
frappe.cache().hdel("session", sid)
|
||||
frappe.cache().hdel("last_db_session_update", sid)
|
||||
if sid and not user:
|
||||
user_details = frappe.db.sql("""select user from tabSessions where sid=%s""", sid, as_dict=True)
|
||||
table = DocType("Sessions")
|
||||
user_details = frappe.qb.from_(table).where(
|
||||
table.sid == sid
|
||||
).select(table.user).run(as_dict=True)
|
||||
if user_details: user = user_details[0].get("user")
|
||||
|
||||
logout_feed(user, reason)
|
||||
|
|
@ -91,7 +92,7 @@ def clear_all_sessions(reason=None):
|
|||
"""This effectively logs out all users"""
|
||||
frappe.only_for("Administrator")
|
||||
if not reason: reason = "Deleted All Active Session"
|
||||
for sid in frappe.db.sql_list("select sid from `tabSessions`"):
|
||||
for sid in frappe.qb.from_("Sessions").select("sid").run(pluck=True):
|
||||
delete_session(sid, reason=reason)
|
||||
|
||||
def get_expired_sessions():
|
||||
|
|
@ -159,6 +160,10 @@ def get():
|
|||
|
||||
return bootinfo
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_boot_assets_json():
|
||||
return get_assets_json()
|
||||
|
||||
def get_csrf_token():
|
||||
if not frappe.local.session.data.csrf_token:
|
||||
generate_csrf_token()
|
||||
|
|
|
|||
|
|
@ -128,8 +128,11 @@ def get_shared_doctypes(user=None):
|
|||
"""Return list of doctypes in which documents are shared for the given user."""
|
||||
if not user:
|
||||
user = frappe.session.user
|
||||
|
||||
return frappe.db.sql_list("select distinct share_doctype from tabDocShare where (user=%s or everyone=1)", user)
|
||||
table = frappe.qb.DocType("DocShare")
|
||||
query = frappe.qb.from_(table).where(
|
||||
(table.user == user) | (table.everyone == 1)
|
||||
).select(table.share_doctype).distinct()
|
||||
return query.run(pluck=True)
|
||||
|
||||
def get_share_name(doctype, name, user, everyone):
|
||||
if cint(everyone):
|
||||
|
|
|
|||
13
frappe/templates/print_format/macros.html
Normal file
13
frappe/templates/print_format/macros.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% macro render_field(df, doc) %}
|
||||
{%- set value = doc.get(df.fieldname) -%}
|
||||
{% include ['templates/print_format/macros/' + df.renderer + '.html', 'templates/print_format/macros/Data.html'] ignore missing %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro field_attributes(df) %}
|
||||
{%- if df.fieldname -%}
|
||||
data-fieldname="{{ df.fieldname }}"
|
||||
{%- endif %}
|
||||
{% if df.fieldtype -%}
|
||||
data-fieldtype="{{ df.fieldtype }}"
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
7
frappe/templates/print_format/macros/Attach.html
Normal file
7
frappe/templates/print_format/macros/Attach.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "templates/print_format/macros/Data.html" %}
|
||||
|
||||
{%- block value -%}
|
||||
<div class="value">
|
||||
<a href="{{ value }}">{{ value.rsplit('/', 1)[1] }}</a>
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
7
frappe/templates/print_format/macros/AttachImage.html
Normal file
7
frappe/templates/print_format/macros/AttachImage.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "templates/print_format/macros/Data.html" %}
|
||||
|
||||
{%- block value -%}
|
||||
<div class="value">
|
||||
<img class="w-100" src="{{ value }}" alt="{{ df.label }}">
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
9
frappe/templates/print_format/macros/Check.html
Normal file
9
frappe/templates/print_format/macros/Check.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{% extends "templates/print_format/macros/Data.html" %}
|
||||
|
||||
{%- block value -%}
|
||||
<div class="value">
|
||||
<svg viewBox="0 0 16 16" fill="transparent" stroke="#1F272E" stroke-width="2" xmlns="http://www.w3.org/2000/svg" id="icon-tick">
|
||||
<path d="M2 9.66667L5.33333 13L14 3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
7
frappe/templates/print_format/macros/Code.html
Normal file
7
frappe/templates/print_format/macros/Code.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "templates/print_format/macros/Data.html" %}
|
||||
|
||||
{%- block value -%}
|
||||
<div class="value">
|
||||
<pre><code>{{ value }}</code></pre>
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
8
frappe/templates/print_format/macros/Color.html
Normal file
8
frappe/templates/print_format/macros/Color.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{% extends "templates/print_format/macros/Data.html" %}
|
||||
|
||||
{%- block value -%}
|
||||
<div class="value">
|
||||
<div class="color-square" style="background-color: {{ value }};"></div>
|
||||
{{ value }}
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
10
frappe/templates/print_format/macros/Data.html
Normal file
10
frappe/templates/print_format/macros/Data.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{% if value %}
|
||||
<div class="field {{ df.section.field_orientation or '' }}" {{ field_attributes(df) }}>
|
||||
{%- block label -%}
|
||||
<div class="label">{{ df.label }}</div>
|
||||
{%- endblock -%}
|
||||
{%- block value -%}
|
||||
<div class="value">{{ doc.get_formatted(df.fieldname) }}</div>
|
||||
{%- endblock -%}
|
||||
</div>
|
||||
{% endif %}
|
||||
2
frappe/templates/print_format/macros/Divider.html
Normal file
2
frappe/templates/print_format/macros/Divider.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<div style="height: 1px; margin: 0.5rem 0; border-bottom: 1px solid; border-bottom-color: var(--dark-border-color);">
|
||||
</div>
|
||||
4
frappe/templates/print_format/macros/FieldTemplate.html
Normal file
4
frappe/templates/print_format/macros/FieldTemplate.html
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<div class="field-template" {{ field_attributes(df) }}>
|
||||
{% set template = frappe.db.get_value('Print Format Field Template', df.field_template, ['template', 'template_file', 'standard'], as_dict=1) %}
|
||||
{{ frappe.render_template(template.template_file if template.standard else template.template, {'doc': doc}) }}
|
||||
</div>
|
||||
3
frappe/templates/print_format/macros/HTML.html
Normal file
3
frappe/templates/print_format/macros/HTML.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<div class="custom-html" {{ field_attributes(df) }}>
|
||||
{{ frappe.render_template(df.html, {'doc': doc}) }}
|
||||
</div>
|
||||
9
frappe/templates/print_format/macros/Markdown.html
Normal file
9
frappe/templates/print_format/macros/Markdown.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{% extends "templates/print_format/macros/Data.html" %}
|
||||
|
||||
{%- block value -%}
|
||||
<div class="value">
|
||||
{{ frappe.utils.md_to_html(doc.get(df.fieldname)) }}
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
|
||||
|
||||
22
frappe/templates/print_format/macros/Rating.html
Normal file
22
frappe/templates/print_format/macros/Rating.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{% extends "templates/print_format/macros/Data.html" %}
|
||||
|
||||
{% macro star(is_active=false) %}
|
||||
<svg id="icon-star" class="rating-star {{ is_active and 'active' or '' }}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{%- set color = '#f6c35e' if is_active else '#dce0e3' -%}
|
||||
<path
|
||||
fill="{{ color }}" stroke="{{ color }}"
|
||||
d="M11.5516 2.90849C11.735 2.53687 12.265 2.53687 12.4484 2.90849L14.8226 7.71919C14.8954 7.86677 15.0362 7.96905 15.1991 7.99271L20.508 8.76415C20.9181 8.82374 21.0818 9.32772 20.7851 9.61699L16.9435 13.3616C16.8257 13.4765 16.7719 13.642 16.7997 13.8042L17.7066 19.0916C17.7766 19.5001 17.3479 19.8116 16.9811 19.6187L12.2327 17.1223C12.087 17.0457 11.913 17.0457 11.7673 17.1223L7.01888 19.6187C6.65207 19.8116 6.22335 19.5001 6.29341 19.0916L7.20028 13.8042C7.2281 13.642 7.17433 13.4765 7.05648 13.3616L3.21491 9.61699C2.91815 9.32772 3.08191 8.82374 3.49202 8.76415L8.80094 7.99271C8.9638 7.96905 9.10458 7.86677 9.17741 7.71919L11.5516 2.90849Z"
|
||||
/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{%- block value -%}
|
||||
<div class="value">
|
||||
{%- for i in range(value) -%}
|
||||
{{ star(true) }}
|
||||
{%- endfor -%}
|
||||
{%- for i in range(5 - value) -%}
|
||||
{{ star() }}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
7
frappe/templates/print_format/macros/Signature.html
Normal file
7
frappe/templates/print_format/macros/Signature.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "templates/print_format/macros/Data.html" %}
|
||||
|
||||
{%- block value -%}
|
||||
<div class="value">
|
||||
<img src="{{ value }}" alt="{{ df.label }}">
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
2
frappe/templates/print_format/macros/Spacer.html
Normal file
2
frappe/templates/print_format/macros/Spacer.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<div style="height: 1rem">
|
||||
</div>
|
||||
30
frappe/templates/print_format/macros/Table.html
Normal file
30
frappe/templates/print_format/macros/Table.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{% if doc.get(df.fieldname) %}
|
||||
<div class="child-table" {{ field_attributes(df) }}>
|
||||
<div class="label">
|
||||
{{ df.label }}
|
||||
</div>
|
||||
<table class="table table-bordered">
|
||||
{% set columns = df.table_columns %}
|
||||
<thead>
|
||||
<tr class="table-row">
|
||||
{% for column in columns %}
|
||||
<th class="column-header" width="{{ column.width }}%" {{ field_attributes(column) }}>
|
||||
{{ column.label }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in doc.get(df.fieldname) %}
|
||||
<tr class="table-row {{ loop.cycle('odd', 'even') }}" data-idx="{{ row.idx }}">
|
||||
{% for column in columns %}
|
||||
<td class="column-value" width="{{ column.width }}%" {{ field_attributes(column) }}>
|
||||
{{ row.get_formatted(column.fieldname) }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
24
frappe/templates/print_format/print_footer.html
Normal file
24
frappe/templates/print_format/print_footer.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<style>
|
||||
{% include "templates/print_format/print_format_font.css" %}
|
||||
|
||||
@media print {
|
||||
footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding-bottom: {{ print_format.margin_bottom | int }}mm;
|
||||
padding-left: {{ print_format.margin_left | int }}mm;
|
||||
padding-right: {{ print_format.margin_right | int }}mm;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<footer>
|
||||
{%- if layout.footer -%}
|
||||
{{ frappe.render_template(layout.footer, {'doc': doc}) }}
|
||||
{%- endif -%}
|
||||
|
||||
{%- if letterhead -%}
|
||||
{{ frappe.render_template(letterhead.footer, {'doc': doc}) }}
|
||||
{%- endif -%}
|
||||
</footer>
|
||||
131
frappe/templates/print_format/print_format.css
Normal file
131
frappe/templates/print_format/print_format.css
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
{% include "templates/print_format/print_format_font.css" %}
|
||||
|
||||
{% macro render_margin_text(position, content) %}
|
||||
@{{ position.replace('_', '-') }} {
|
||||
content: {{ content }}
|
||||
}
|
||||
{% endmacro %}
|
||||
|
||||
@page {
|
||||
size: {{ print_settings.pdf_page_size or 'A4' }} portrait;
|
||||
margin-top: {{ print_format.margin_top | int }}mm;
|
||||
margin-bottom: {{ print_format.margin_bottom | int }}mm;
|
||||
margin-left: {{ print_format.margin_left | int }}mm;
|
||||
margin-right: {{ print_format.margin_right | int }}mm;
|
||||
padding-top: {{ (header_height or 0) + 8 }}px;
|
||||
padding-bottom: {{ (footer_height or 0) + 8 }}px;
|
||||
|
||||
/* page number */
|
||||
{% set page_number_position = print_format.page_number.lower().replace(' ', '_') %}
|
||||
{% if page_number_position in ['top_left', 'top_center', 'top_right', 'bottom_left', 'bottom_center', 'bottom_right'] %}
|
||||
{{ render_margin_text(page_number_position, 'counter(page) " of " counter(pages)') }}
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-size: {{ print_format.font_size }}px;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: {{ body_width | int }}mm !important;
|
||||
max-width: {{ body_width | int }}mm !important;
|
||||
}
|
||||
|
||||
/* CSS rules to fix bootstrap column rendering in PDF
|
||||
https://github.com/Kozea/WeasyPrint/issues/697#issuecomment-542338732
|
||||
*/
|
||||
@media print {
|
||||
.col, *[class^="col-"] {
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen {
|
||||
html {
|
||||
background-color: var(--gray-200);
|
||||
}
|
||||
body {
|
||||
background-color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
margin: 2rem auto;
|
||||
min-height: 297mm;
|
||||
height: min-content;
|
||||
min-width: {{ body_width | int }}mm !important;
|
||||
max-width: {{ body_width | int }}mm !important;
|
||||
padding-top: {{ print_format.margin_top | int }}mm;
|
||||
padding-right: {{ print_format.margin_right | int }}mm;
|
||||
padding-left: {{ print_format.margin_left | int }}mm;
|
||||
padding-bottom: {{ print_format.margin_bottom | int }}mm;
|
||||
}
|
||||
}
|
||||
|
||||
.section:not(:first-child) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field + .field {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.field .label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.field.left-right {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.field.left-right .label {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.field.left-right .value {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.child-table [data-fieldtype="Currency"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.page-break {
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.document-header {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-style: solid;
|
||||
border-bottom-color: var(--gray-300);
|
||||
}
|
||||
|
||||
.field[data-fieldtype="Rating"] .rating-star {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.field[data-fieldtype="Long Text"] .value, .field[data-fieldtype="Text"] .value {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.field[data-fieldtype="Color"] .value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field[data-fieldtype="Color"] .color-square {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.3rem;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.field[data-fieldtype="Check"] #icon-tick {
|
||||
width: 1rem;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue