Merge branch 'develop' into no-ammend

This commit is contained in:
Suraj Shetty 2020-06-10 12:19:54 +05:30 committed by GitHub
commit d8810e5cff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
139 changed files with 3825 additions and 1074 deletions

View file

@ -47,23 +47,27 @@ matrix:
script: bench --site test_site run-ui-tests frappe --headless
before_install:
# do we really want to run travis?
# do we really want to run travis? check which files are changed and if git doesnt face any fatal errors
- |
ONLY_DOCS_CHANGES=$(git diff --name-only $TRAVIS_COMMIT_RANGE | grep -qvE '\.(md|png|jpg|jpeg)$|^.github|LICENSE' ; echo $?)
ONLY_JS_CHANGES=$(git diff --name-only $TRAVIS_COMMIT_RANGE | grep -qvE '\.js$' ; echo $?)
ONLY_PY_CHANGES=$(git diff --name-only $TRAVIS_COMMIT_RANGE | grep -qvE '\.py$' ; echo $?)
FILES_CHANGED=$( git diff --name-only $TRAVIS_COMMIT_RANGE 2>&1 )
if [[ $ONLY_DOCS_CHANGES == "1" ]]; then
echo "Only docs were updated, stopping build process.";
exit;
fi
if [[ $ONLY_JS_CHANGES == "1" && $TYPE == "server" ]]; then
echo "Only JavaScript code was updated; Stopping Python build process.";
exit;
fi
if [[ $ONLY_PY_CHANGES == "1" && $TYPE == "ui" ]]; then
echo "Only Python code was updated, stopping Cypress build process.";
exit;
if [[ $FILES_CHANGED != *"fatal"* ]]; then
ONLY_DOCS_CHANGES=$( echo $FILES_CHANGED | grep -qvE '\.(md|png|jpg|jpeg)$|^.github|LICENSE' ; echo $? )
ONLY_JS_CHANGES=$( echo $FILES_CHANGED | grep -qvE '\.js$' ; echo $? )
ONLY_PY_CHANGES=$( echo $FILES_CHANGED | grep -qvE '\.py$' ; echo $? )
if [[ $ONLY_DOCS_CHANGES == "1" ]]; then
echo "Only docs were updated, stopping build process.";
exit;
fi
if [[ $ONLY_JS_CHANGES == "1" && $TYPE == "server" ]]; then
echo "Only JavaScript code was updated; Stopping Python build process.";
exit;
fi
if [[ $ONLY_PY_CHANGES == "1" && $TYPE == "ui" ]]; then
echo "Only Python code was updated, stopping Cypress build process.";
exit;
fi
fi
# install wkhtmltopdf

View file

@ -1,7 +1,7 @@
<div align="center">
<img src=".github/frappe-framework-logo.png" height="150">
<h1>
<a href="https://frappe.io">
<a href="https://frappeframework.com">
frappe
</a>
</h1>
@ -33,8 +33,8 @@
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com)
### Table of Contents
* [Installation](#installation)
* [Documentation](https://frappe.io/docs)
* [Installation](https://frappeframework.com/docs/user/en/installation)
* [Documentation](https://frappeframework.com/docs)
* [License](#license)
### Installation
@ -49,7 +49,7 @@ Full-stack web application framework that uses Python and MariaDB on the server
### Website
For details and documentation, see the website
[https://frappe.io](https://frappe.io)
[https://frappeframework.com](https://frappeframework.com)
### License
This repository has been released under the [MIT License](LICENSE).

View file

@ -4,14 +4,14 @@ context('Control Duration', () => {
cy.visit('/desk#workspace/Website');
});
function get_dialog_with_duration(show_days=1, show_seconds=1) {
function get_dialog_with_duration(hide_days=0, hide_seconds=0) {
return cy.dialog({
title: 'Duration',
fields: [{
'fieldname': 'duration',
'fieldtype': 'Duration',
'show_seconds': show_days,
'show_days': show_seconds
'hide_days': hide_days,
'hide_seconds': hide_seconds
}]
});
}
@ -37,7 +37,7 @@ context('Control Duration', () => {
});
it('should hide days or seconds according to duration options', () => {
get_dialog_with_duration(0, 0).as('dialog');
get_dialog_with_duration(1, 1).as('dialog');
cy.get('.frappe-control[data-fieldname=duration] input').first().click();
cy.get('.duration-input[data-duration=days]').should('not.be.visible');
cy.get('.duration-input[data-duration=seconds]').should('not.be.visible');

View file

@ -9,6 +9,7 @@ context('Form', () => {
it('create a new form', () => {
cy.visit('/desk#Form/ToDo/New ToDo 1');
cy.fill_field('description', 'this is a test todo', 'Text Editor').blur();
cy.wait(300);
cy.get('.page-title').should('contain', 'Not Saved');
cy.server();
cy.route({

View file

@ -40,12 +40,12 @@ context('Grid Pagination', () => {
cy.get('@table').find('.current-page-number').should('contain', '20');
cy.get('@table').find('.total-page-number').should('contain', '20');
});
it('deletes all rows', ()=> {
cy.visit('/desk#Form/Contact/Test Contact');
cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
cy.get('@table').find('.grid-heading-row .grid-row-check').click({force: true});
cy.get('@table').find('button.grid-remove-all-rows').click();
cy.get('.modal-dialog .btn-primary').contains('Yes').click();
cy.get('@table').find('.grid-body .grid-row').should('have.length', 0);
});
// it('deletes all rows', ()=> {
// cy.visit('/desk#Form/Contact/Test Contact');
// cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
// cy.get('@table').find('.grid-heading-row .grid-row-check').click({force: true});
// cy.get('@table').find('button.grid-remove-all-rows').click();
// cy.get('.modal-dialog .btn-primary').contains('Yes').click();
// cy.get('@table').find('.grid-body .grid-row').should('have.length', 0);
// });
});

View file

@ -9,14 +9,14 @@ context('Relative Timeframe', () => {
frappe.call("frappe.tests.ui_test_helpers.create_todo_records");
});
});
it('set relative filter for Previous and check list', () => {
it('sets relative timespan filter for last week and filters list', () => {
cy.visit('/desk#List/ToDo/List');
cy.get('.list-row:contains("this is fourth todo")').should('exist');
cy.get('.tag-filters-area .btn:contains("Add Filter")').click();
cy.get('.fieldname-select-area').should('exist');
cy.get('.fieldname-select-area input').type("Due Date{enter}", { delay: 100 });
cy.get('select.condition.form-control').select("Previous");
cy.get('.filter-field select.input-with-feedback.form-control').select("1 week");
cy.get('select.condition.form-control').select("Timespan");
cy.get('.filter-field select.input-with-feedback.form-control').select("last week");
cy.server();
cy.route('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
cy.get('.filter-box .btn:contains("Apply")').click();
@ -28,13 +28,13 @@ context('Relative Timeframe', () => {
cy.get('.remove-filter.btn').click();
cy.wait('@save_user_settings');
});
it('set relative filter for Next and check list', () => {
it('sets relative timespan filter for next week and filters list', () => {
cy.visit('/desk#List/ToDo/List');
cy.get('.list-row:contains("this is fourth todo")').should('exist');
cy.get('.tag-filters-area .btn:contains("Add Filter")').click();
cy.get('.fieldname-select-area input').type("Due Date{enter}", { delay: 100 });
cy.get('select.condition.form-control').select("Next");
cy.get('.filter-field select.input-with-feedback.form-control').select("1 week");
cy.get('select.condition.form-control').select("Timespan");
cy.get('.filter-field select.input-with-feedback.form-control').select("next week");
cy.server();
cy.route('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
cy.get('.filter-box .btn:contains("Apply")').click();

View file

@ -231,9 +231,8 @@ def get_site_config(sites_path=None, site_path=None):
if os.path.exists(site_config):
config.update(get_file_json(site_config))
elif local.site and not local.flags.new_site:
print("{0} does not exist".format(local.site))
print("Site {0} does not exist".format(local.site))
sys.exit(1)
#raise IncorrectSitePath, "{0} does not exist".format(site_config)
return _dict(config)
@ -1559,10 +1558,10 @@ def get_doctype_app(doctype):
loggers = {}
log_level = None
def logger(module=None, with_more_info=True):
def logger(module=None, with_more_info=False):
'''Returns a python logger that uses StreamHandler'''
from frappe.utils.logger import get_logger
return get_logger(module or 'default', with_more_info=with_more_info)
return get_logger(module=module, with_more_info=with_more_info)
def log_error(message=None, title=_("Error")):
'''Log error to Error Log'''

View file

@ -99,6 +99,16 @@ def application(request):
frappe.monitor.stop(response)
frappe.recorder.dump()
frappe.logger("web").info({
"site": get_site_name(request.host),
"remote_addr": getattr(request, "remote_addr", "NOTFOUND"),
"base_url": getattr(request, "base_url", "NOTFOUND"),
"full_path": getattr(request, "full_path", "NOTFOUND"),
"method": getattr(request, "method", "NOTFOUND"),
"scheme": getattr(request, "scheme", "NOTFOUND"),
"http_status_code": getattr(response, "status_code", "NOTFOUND")
})
if response and hasattr(frappe.local, 'rate_limiter'):
response.headers.extend(frappe.local.rate_limiter.headers())
@ -195,7 +205,6 @@ def handle_exception(e):
frappe.local.login_manager.clear_cookies()
if http_status_code >= 500:
frappe.logger().error('Request Error', exc_info=True)
make_error_snapshot(e)
if return_as_message:

View file

@ -85,6 +85,7 @@ def get_bootinfo():
bootinfo.points = get_energy_points(frappe.session.user)
bootinfo.frequently_visited_links = frequently_visited_links()
bootinfo.link_preview_doctypes = get_link_preview_doctypes()
bootinfo.additional_filters_config = get_additional_filters_from_hooks()
return bootinfo
@ -297,3 +298,11 @@ def get_link_preview_doctypes():
link_preview_doctypes.append(custom.doc_type)
return link_preview_doctypes
def get_additional_filters_from_hooks():
filter_config = frappe._dict()
filter_hooks = frappe.get_hooks('filters_config')
for hook in filter_hooks:
filter_config.update(frappe.get_attr(hook)())
return filter_config

View file

@ -22,7 +22,11 @@ def pass_context(f):
pr = cProfile.Profile()
pr.enable()
ret = f(frappe._dict(ctx.obj), *args, **kwargs)
try:
ret = f(frappe._dict(ctx.obj), *args, **kwargs)
except frappe.exceptions.SiteNotSpecifiedError as e:
click.secho(str(e), fg='yellow')
sys.exit(1)
if profile:
pr.disable()
@ -44,8 +48,7 @@ def get_site(context):
site = context.sites[0]
return site
except (IndexError, TypeError):
print('Please specify --site sitename')
sys.exit(1)
raise frappe.SiteNotSpecifiedError
def popen(command, *args, **kwargs):
output = kwargs.get('output', True)

View file

@ -4,6 +4,7 @@ import sys
import frappe
from frappe.utils import cint
from frappe.commands import pass_context, get_site
from frappe.exceptions import SiteNotSpecifiedError
def _is_scheduler_enabled():
enable_scheduler = False
@ -30,6 +31,8 @@ def trigger_scheduler_event(context, event):
frappe.utils.scheduler.trigger(site, event, now=True)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('enable-scheduler')
@pass_context
@ -45,6 +48,8 @@ def enable_scheduler(context):
print("Enabled for", site)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('disable-scheduler')
@pass_context
@ -60,7 +65,8 @@ def disable_scheduler(context):
print("Disabled for", site)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('scheduler')

View file

@ -15,6 +15,7 @@ import frappe
from frappe import _
from frappe.commands import get_site, pass_context
from frappe.commands.scheduler import _is_scheduler_enabled
from frappe.exceptions import SiteNotSpecifiedError
from frappe.installer import update_site_config
from frappe.utils import get_site_path, touch_file
@ -82,10 +83,6 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N
installing = touch_file(get_site_path('locks', 'installing.lock'))
if new_site:
# run cleanup only if new-site is called
atexit.register(_new_site_cleanup, site, mariadb_root_username, mariadb_root_password)
install_db(root_login=mariadb_root_username, root_password=mariadb_root_password, db_name=db_name,
admin_password=admin_password, verbose=verbose, source_sql=source_sql, force=force, reinstall=reinstall,
db_password=db_password, db_type=db_type, db_host=db_host, db_port=db_port, no_mariadb_socket=no_mariadb_socket)
@ -101,18 +98,6 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N
scheduler_status = "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled"
print("*** Scheduler is", scheduler_status, "***")
def _new_site_cleanup(site, mariadb_root_username, mariadb_root_password):
try:
installing = get_site_path('locks', 'installing.lock')
except AttributeError:
installing = os.path.join(site, 'locks', 'installing.lock')
if installing and os.path.exists(installing):
if mariadb_root_password:
_drop_site(site, mariadb_root_username, mariadb_root_password, force=True, no_backup=True)
shutil.rmtree(site)
frappe.destroy()
@click.command('restore')
@click.argument('sql-file-path')
@ -130,30 +115,47 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas
# Extract the gzip file if user has passed *.sql.gz file instead of *.sql file
if not os.path.exists(sql_file_path):
sql_file_path = '../' + sql_file_path
base_path = '..'
sql_file_path = os.path.join(base_path, sql_file_path)
if not os.path.exists(sql_file_path):
print('Invalid path {0}'.format(sql_file_path[3:]))
sys.exit(1)
elif sql_file_path.startswith(os.sep):
base_path = os.sep
else:
base_path = '.'
if sql_file_path.endswith('sql.gz'):
sql_file_path = extract_sql_gzip(os.path.abspath(sql_file_path))
decompressed_file_name = extract_sql_gzip(os.path.abspath(sql_file_path))
else:
decompressed_file_name = sql_file_path
site = get_site(context)
frappe.init(site=site)
_new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username,
mariadb_root_password=mariadb_root_password, admin_password=admin_password,
verbose=context.verbose, install_apps=install_app, source_sql=sql_file_path,
force=context.force)
verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name,
force=True)
# Extract public and/or private files to the restored site, if user has given the path
if with_public_files:
with_public_files = os.path.join(base_path, with_public_files)
public = extract_tar_files(site, with_public_files, 'public')
os.remove(public)
if with_private_files:
with_private_files = os.path.join(base_path, with_private_files)
private = extract_tar_files(site, with_private_files, 'private')
os.remove(private)
# Removing temporarily created file
if decompressed_file_name != sql_file_path:
os.remove(decompressed_file_name)
success_message = "Site {0} has been restored{1}".format(site, " with files" if (with_public_files or with_private_files) else "")
click.secho(success_message, fg="green")
@click.command('reinstall')
@click.option('--admin-password', help='Administrator Password for reinstalled site')
@click.option('--mariadb-root-username', help='Root username for MariaDB')
@ -200,6 +202,8 @@ def install_app(context, apps):
_install_app(app, verbose=context.verbose)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('list-apps')
@pass_context
@ -229,7 +233,8 @@ def add_system_manager(context, email, first_name, last_name, send_welcome_email
frappe.db.commit()
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('disable-user')
@click.argument('email')
@ -260,6 +265,8 @@ def migrate(context, rebuild_website=False, skip_failing=False):
migrate(context.verbose, rebuild_website=rebuild_website, skip_failing=skip_failing)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
print("Compiling Python Files...")
compileall.compile_dir('../apps', quiet=1, rx=re.compile('.*node_modules.*'))
@ -271,7 +278,12 @@ def migrate_to(context, frappe_provider):
"Migrates site to the specified provider"
from frappe.integrations.frappe_providers import migrate_to
for site in context.sites:
frappe.init(site=site)
frappe.connect()
migrate_to(site, frappe_provider)
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('run-patch')
@click.argument('module')
@ -286,6 +298,8 @@ def run_patch(context, module):
frappe.modules.patch_handler.run_single(module, force=context.force)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('reload-doc')
@click.argument('module')
@ -302,6 +316,8 @@ def reload_doc(context, module, doctype, docname):
frappe.db.commit()
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('reload-doctype')
@click.argument('doctype')
@ -316,6 +332,8 @@ def reload_doctype(context, doctype):
frappe.db.commit()
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('add-to-hosts')
@pass_context
@ -323,6 +341,8 @@ def add_to_hosts(context):
"Add site to hosts"
for site in context.sites:
frappe.commands.popen('echo 127.0.0.1\t{0} | sudo tee -a /etc/hosts'.format(site))
if not context.sites:
raise SiteNotSpecifiedError
@click.command('use')
@click.argument('site')
@ -336,7 +356,7 @@ def use(site, sites_path='.'):
sitefile.write(site)
print("Current Site set to {}".format(site))
else:
print("{} does not exist".format(site))
print("Site {} does not exist".format(site))
@click.command('backup')
@click.option('--with-files', default=False, is_flag=True, help="Take backup with files")
@ -369,6 +389,9 @@ def backup(context, with_files=False, backup_path_db=None, backup_path_files=Non
print("Private files: ", odb.backup_path_private_files)
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
sys.exit(exit_code)
@click.command('remove-from-installed-apps')
@ -384,6 +407,8 @@ def remove_from_installed_apps(context, app):
remove_from_installed_apps(app)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('uninstall-app')
@click.argument('app')
@ -400,6 +425,8 @@ def uninstall(context, app, dry_run=False, yes=False):
remove_app(app, dry_run, yes)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('drop-site')
@ -430,7 +457,7 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path=
else:
click.echo("="*80)
click.echo("Error: The operation has stopped because backup of {s}'s database failed.".format(s=site))
click.echo("Reason: {reason}{sep}".format(reason=err[1], sep="\n"))
click.echo("Reason: {reason}{sep}".format(reason=str(err), sep="\n"))
click.echo("Fix the issue and try again.")
click.echo(
"Hint: Use 'bench drop-site {s} --force' to force the removal of {s}".format(sep="\n", tab="\t", s=site)
@ -491,6 +518,8 @@ def set_admin_password(context, admin_password, logout_all_sessions=False):
admin_password = None
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('set-last-active-for-user')
@click.option('--user', help="Setup last active date for user")
@ -536,6 +565,8 @@ def publish_realtime(context, event, message, room, user, doctype, docname, afte
frappe.db.commit()
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('browse')
@click.argument('site', required=False)
@ -563,6 +594,8 @@ def start_recording(context):
for site in context.sites:
frappe.init(site=site)
frappe.recorder.start()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('stop-recording')
@ -571,6 +604,8 @@ def stop_recording(context):
for site in context.sites:
frappe.init(site=site)
frappe.recorder.stop()
if not context.sites:
raise SiteNotSpecifiedError
commands = [

View file

@ -1,6 +1,7 @@
from __future__ import unicode_literals, absolute_import, print_function
import click
from frappe.commands import pass_context, get_site
from frappe.exceptions import SiteNotSpecifiedError
# translation
@click.command('build-message-files')
@ -15,6 +16,8 @@ def build_message_files(context):
frappe.translate.rebuild_all_translation_files()
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('new-language') #, help="Create lang-code.csv for given app")
@pass_context

View file

@ -6,6 +6,7 @@ import json, os, sys, subprocess
from distutils.spawn import find_executable
import frappe
from frappe.commands import pass_context, get_site
from frappe.exceptions import SiteNotSpecifiedError
from frappe.utils import update_progress_bar, get_bench_path
from frappe.utils.response import json_handler
from coverage import Coverage
@ -51,7 +52,8 @@ def clear_cache(context):
frappe.website.render.clear_cache()
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('clear-website-cache')
@pass_context
@ -65,7 +67,8 @@ def clear_website_cache(context):
frappe.website.render.clear_cache()
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('destroy-all-sessions')
@click.option('--reason')
@ -81,7 +84,8 @@ def destroy_all_sessions(context, reason=None):
frappe.db.commit()
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('show-config')
@pass_context
@ -117,7 +121,8 @@ def reset_perms(context):
reset_perms(d)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('execute')
@click.argument('method')
@ -164,6 +169,9 @@ def execute(context, method, args=None, kwargs=None, profile=False):
if ret:
print(json.dumps(ret, default=json_handler))
if not context.sites:
raise SiteNotSpecifiedError
@click.command('add-to-email-queue')
@click.argument('email-path')
@ -197,7 +205,8 @@ def export_doc(context, doctype, docname):
frappe.modules.export_doc(doctype, docname)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('export-json')
@click.argument('doctype')
@ -214,7 +223,8 @@ def export_json(context, doctype, path, name=None):
data_import.export_json(doctype, path, name=name)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('export-csv')
@click.argument('doctype')
@ -230,7 +240,8 @@ def export_csv(context, doctype, path):
data_import.export_csv(doctype, path)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('export-fixtures')
@click.option('--app', default=None, help='Export fixtures of a specific app')
@ -245,7 +256,8 @@ def export_fixtures(context, app=None):
export_fixtures(app=app)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('import-doc')
@click.argument('path')
@ -267,7 +279,8 @@ def import_doc(context, path, force=False):
data_import.import_doc(path, overwrite=context.force)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('import-csv')
@click.argument('path')
@ -364,6 +377,8 @@ def mariadb(context):
import os
site = get_site(context)
if not site:
raise SiteNotSpecifiedError
frappe.init(site=site)
# This is assuming you're within the bench instance.
@ -487,7 +502,17 @@ def run_tests(context, app=None, module=None, doctype=None, test=(),
if coverage:
# Generate coverage report only for app that is being tested
source_path = os.path.join(get_bench_path(), 'apps', app or 'frappe')
cov = Coverage(source=[source_path], omit=['*.html', '*.js', '*.xml', '*.css', '*/doctype/*/*_dashboard.py', '*/patches/*'])
cov = Coverage(source=[source_path], omit=[
'*.html',
'*.js',
'*.xml',
'*.css',
'*.less',
'*.scss',
'*.vue',
'*/doctype/*/*_dashboard.py',
'*/patches/*'
])
cov.start()
ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests,
@ -577,7 +602,8 @@ def request(context, args=None, path=None):
print(frappe.response)
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('make-app')
@click.argument('destination')
@ -658,7 +684,8 @@ def rebuild_global_search(context, static_pages=False):
finally:
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@click.command('auto-deploy')
@click.argument('app')

View file

@ -158,11 +158,7 @@ class TestAccessLog(unittest.TestCase):
request = requests.post(private_file_link, headers=self.header)
last_doc = frappe.get_last_doc('Access Log')
if request.status_code == 403:
# if file is not accessible, access log wont be generated
pass
else:
if request.ok:
# check for the access log of downloaded file
self.assertEqual(new_private_file.doctype, last_doc.export_from)
self.assertEqual(new_private_file.name, last_doc.reference_document)

View file

@ -444,24 +444,48 @@ def update_parent_document_on_communication(doc):
status_field = parent.meta.get_field("status")
if status_field:
options = (status_field.options or '').splitlines()
options = (status_field.options or "").splitlines()
# if status has a "Replied" option, then update the status for received communication
if ('Replied' in options) and doc.sent_or_received=="Received":
if ("Replied" in options) and doc.sent_or_received == "Received":
parent.db_set("status", "Open")
parent.run_method("handle_hold_time", "Replied")
apply_assignment_rule(parent)
else:
# update the modified date for document
parent.update_modified()
update_mins_to_first_communication(parent, doc)
parent.run_method('notify_communication', doc)
set_avg_response_time(parent, doc)
parent.run_method("notify_communication", doc)
parent.notify_update()
def update_mins_to_first_communication(parent, communication):
if parent.meta.has_field('mins_to_first_response') and not parent.get('mins_to_first_response'):
if parent.meta.has_field("mins_to_first_response") and not parent.get("mins_to_first_response"):
if is_system_user(communication.sender):
first_responded_on = communication.creation
if parent.meta.has_field('first_responded_on') and communication.sent_or_received == "Sent":
parent.db_set('first_responded_on', first_responded_on)
parent.db_set('mins_to_first_response', round(time_diff_in_seconds(first_responded_on, parent.creation) / 60), 2)
if parent.meta.has_field("first_responded_on") and communication.sent_or_received == "Sent":
parent.db_set("first_responded_on", first_responded_on)
parent.db_set("mins_to_first_response", round(time_diff_in_seconds(first_responded_on, parent.creation) / 60), 2)
def set_avg_response_time(parent, communication):
if parent.meta.has_field("avg_response_time") and communication.sent_or_received == "Sent":
# avg response time for all the responses
communications = frappe.get_list("Communication", filters={
"reference_doctype": parent.doctype,
"reference_name": parent.name
},
fields=["sent_or_received", "name", "creation"],
order_by="creation"
)
if len(communications):
response_times = []
for i in range(len(communications)):
if communications[i].sent_or_received == "Sent" and communications[i-1].sent_or_received == "Received":
response_time = round(time_diff_in_seconds(communications[i].creation, communications[i-1].creation), 2)
if response_time > 0:
response_times.append(response_time)
if response_times:
avg_response_time = sum(response_times) / len(response_times)
parent.db_set("avg_response_time", avg_response_time)

View file

@ -13,8 +13,8 @@
"fieldname",
"precision",
"length",
"show_days",
"show_seconds",
"hide_days",
"hide_seconds",
"reqd",
"search_index",
"in_list_view",
@ -453,18 +453,18 @@
"fieldtype": "Column Break"
},
{
"default": "1",
"depends_on": "eval:doc.fieldtype === \"Duration\";",
"fieldname": "show_days",
"default": "0",
"depends_on": "eval:doc.fieldtype=='Duration'",
"fieldname": "hide_days",
"fieldtype": "Check",
"label": "Show Days"
"label": "Hide Days"
},
{
"default": "1",
"depends_on": "eval:doc.fieldtype === \"Duration\";",
"fieldname": "show_seconds",
"default": "0",
"depends_on": "eval:doc.fieldtype=='Duration'",
"fieldname": "hide_seconds",
"fieldtype": "Check",
"label": "Show Seconds"
"label": "Hide Seconds"
},
{
"default": "0",
@ -477,7 +477,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2020-05-15 09:06:25.224411",
"modified": "2020-02-06 09:06:25.224413",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",

View file

@ -688,6 +688,9 @@ def validate_fields(meta):
def check_link_table_options(docname, d):
if frappe.flags.in_patch: return
if frappe.flags.in_fixtures: return
if d.fieldtype in ("Link",) + table_fields:
if not d.options:
frappe.throw(_("{0}: Options required for Link or Table type field {1} in row {2}").format(docname, d.label, d.idx), DoctypeLinkError)
@ -908,6 +911,8 @@ def validate_fields(meta):
frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", raise_exception=True)
def check_child_table_option(docfield):
if frappe.flags.in_fixtures: return
if docfield.fieldtype not in ['Table MultiSelect', 'Table']: return
doctype = docfield.options

View file

@ -48,6 +48,8 @@ class File(Document):
def before_insert(self):
frappe.local.rollback_observers.append(self)
self.set_folder_name()
if self.file_name:
self.file_name = re.sub(r'/', '', self.file_name)
self.content = self.get("content", None)
self.decode = self.get("decode", False)
if self.content:
@ -192,6 +194,8 @@ class File(Document):
def set_file_name(self):
if not self.file_name and self.file_url:
self.file_name = self.file_url.split('/')[-1]
else:
self.file_name = re.sub(r'/', '', self.file_name)
def generate_content_hash(self):
if self.content_hash or not self.file_url or self.file_url.startswith('http'):
@ -405,6 +409,12 @@ class File(Document):
frappe.throw(_("URL must start with 'http://' or 'https://'"))
return
if not self.file_url.startswith(("http://", "https://")):
# local file
root_files_path = get_files_path(is_private=self.is_private)
if not os.path.commonpath([root_files_path]) == os.path.commonpath([root_files_path, self.get_full_path()]):
# basically the file url is skewed to not point to /files/ or /private/files
frappe.throw(_("{0} is not a valid file url").format(self.file_url))
self.file_url = unquote(self.file_url)
self.file_size = frappe.form_dict.file_size or self.file_size

View file

@ -84,7 +84,7 @@ class ScheduledJobType(Document):
def log_status(self, status):
# log file
frappe.logger(__name__).info('Scheduled Job {0}: {1} for {2}'.format(status, self.method, frappe.local.site))
frappe.logger("scheduler").info('Scheduled Job {0}: {1} for {2}'.format(status, self.method, frappe.local.site))
self.update_scheduler_log(status)
def update_scheduler_log(self, status):

View file

@ -16,8 +16,8 @@
"column_break_6",
"fieldtype",
"precision",
"show_seconds",
"show_days",
"hide_seconds",
"hide_days",
"options",
"fetch_from",
"fetch_if_empty",
@ -383,22 +383,18 @@
"label": "In Preview"
},
{
"default": "1",
"depends_on": "eval:doc.fieldtype === \"Duration\";",
"fieldname": "show_seconds",
"default": "0",
"depends_on": "eval:doc.fieldtype=='Duration'",
"fieldname": "hide_seconds",
"fieldtype": "Check",
"label": "Show Seconds",
"show_days": 1,
"show_seconds": 1
"label": "Hide Seconds"
},
{
"default": "1",
"depends_on": "eval:doc.fieldtype === \"Duration\";",
"fieldname": "show_days",
"default": "0",
"depends_on": "eval:doc.fieldtype=='Duration'",
"fieldname": "hide_days",
"fieldtype": "Check",
"label": "Show Days",
"show_days": 1,
"show_seconds": 1
"label": "Hide Days"
},
{
"default": "0",
@ -411,7 +407,7 @@
"icon": "fa fa-glass",
"idx": 1,
"links": [],
"modified": "2020-05-15 23:43:00.123572",
"modified": "2020-02-06 23:43:00.123575",
"modified_by": "Administrator",
"module": "Custom",
"name": "Custom Field",

View file

@ -77,7 +77,9 @@ docfield_properties = {
'allow_bulk_edit': 'Check',
'auto_repeat': 'Link',
'allow_in_quick_entry': 'Check',
'hide_border': 'Check'
'hide_border': 'Check',
'hide_days': 'Check',
'hide_seconds': 'Check'
}
allowed_fieldtype_change = (('Currency', 'Float', 'Percent'), ('Small Text', 'Data'),

View file

@ -11,8 +11,8 @@
"label",
"fieldtype",
"fieldname",
"show_seconds",
"show_days",
"hide_seconds",
"hide_days",
"reqd",
"unique",
"in_list_view",
@ -393,22 +393,18 @@
"label": "In Preview"
},
{
"default": "1",
"depends_on": "eval:doc.fieldtype === \"Duration\";",
"fieldname": "show_seconds",
"default": "0",
"depends_on": "eval:doc.fieldtype=='Duration'",
"fieldname": "hide_seconds",
"fieldtype": "Check",
"label": "Show Seconds",
"show_days": 1,
"show_seconds": 1
"label": "Hide Seconds"
},
{
"default": "1",
"depends_on": "eval:doc.fieldtype === \"Duration\";",
"fieldname": "show_days",
"default": "0",
"depends_on": "eval:doc.fieldtype=='Duration'",
"fieldname": "hide_days",
"fieldtype": "Check",
"label": "Show Days",
"show_days": 1,
"show_seconds": 1
"label": "Hide Days"
},
{
"default": "0",
@ -421,7 +417,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2020-05-15 23:45:46.810869",
"modified": "2020-06-02 23:45:46.810868",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form Field",

View file

@ -64,6 +64,8 @@ CREATE TABLE `tabDocField` (
`length` int(11) NOT NULL DEFAULT 0,
`translatable` int(1) NOT NULL DEFAULT 0,
`hide_border` int(1) NOT NULL DEFAULT 0,
`hide_days` int(1) NOT NULL DEFAULT 0,
`hide_seconds` int(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`name`),
KEY `parent` (`parent`),
KEY `label` (`label`),

View file

@ -64,6 +64,8 @@ CREATE TABLE "tabDocField" (
"length" bigint NOT NULL DEFAULT 0,
"translatable" smallint NOT NULL DEFAULT 0,
"hide_border" smallint NOT NULL DEFAULT 0,
"hide_days" smallint NOT NULL DEFAULT 0,
"hide_seconds" smallint NOT NULL DEFAULT 0,
PRIMARY KEY ("name")
) ;

View file

@ -8,12 +8,26 @@ from json import loads, dumps
from frappe import _, DoesNotExistError, ValidationError, _dict
from frappe.boot import get_allowed_pages, get_allowed_reports
from six import string_types
from functools import wraps
from frappe.cache_manager import (
build_domain_restriced_doctype_cache,
build_domain_restriced_page_cache,
build_table_count_cache
)
def handle_not_exist(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except DoesNotExistError:
if frappe.message_log:
frappe.message_log.pop()
return []
return wrapper
class Workspace:
def __init__(self, page_name):
self.page_name = page_name
@ -157,7 +171,7 @@ class Workspace:
'user_can_dismiss': self.onboarding_doc.user_can_dismiss,
'items': self.get_onboarding_steps()
}
@handle_not_exist
def get_cards(self):
cards = self.doc.cards
if not self.doc.hide_custom:
@ -169,8 +183,8 @@ class Workspace:
def _doctype_contains_a_record(name):
exists = self.table_counts.get(name, None)
if exists is None:
if not frappe.db.get_value('DocType', name, 'issingle', cache=True):
if not exists:
if not frappe.db.get_value('DocType', name, 'issingle'):
exists = frappe.db.count(name)
else:
exists = True
@ -227,6 +241,7 @@ class Workspace:
return new_data
@handle_not_exist
def get_charts(self):
all_charts = []
if frappe.has_permission("Dashboard Chart", throw=False):
@ -242,6 +257,7 @@ class Workspace:
return all_charts
@handle_not_exist
def get_shortcuts(self):
def _in_active_domains(item):
@ -272,6 +288,7 @@ class Workspace:
return items
@handle_not_exist
def get_onboarding_steps(self):
steps = []
for doc in self.onboarding_doc.get_steps():
@ -296,21 +313,15 @@ def get_desktop_page(page):
Returns:
dict: dictionary of cards, charts and shortcuts to be displayed on website
"""
try:
wspace = Workspace(page)
wspace.build_workspace()
return {
'charts': wspace.charts,
'shortcuts': wspace.shortcuts,
'cards': wspace.cards,
'onboarding': wspace.onboarding,
'allow_customization': not wspace.doc.disable_user_customization
}
except DoesNotExistError:
if frappe.message_log:
frappe.message_log.pop()
return None
wspace = Workspace(page)
wspace.build_workspace()
return {
'charts': wspace.charts,
'shortcuts': wspace.shortcuts,
'cards': wspace.cards,
'onboarding': wspace.onboarding,
'allow_customization': not wspace.doc.disable_user_customization
}
@frappe.whitelist()
def get_desk_sidebar_items(flatten=False):

View file

@ -93,10 +93,10 @@ class TestEvent(unittest.TestCase):
self.assertEqual(set(json.loads(ev._assign)), set(["test@example.com", self.test_user]))
# close an assignment
# Remove an assignment
todo = frappe.get_doc("ToDo", {"reference_type": ev.doctype, "reference_name": ev.name,
"owner": self.test_user})
todo.status = "Closed"
todo.status = "Cancelled"
todo.save()
ev = frappe.get_doc("Event", ev.name)

View file

@ -1,8 +0,0 @@
// Copyright (c) 2019, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on('List View Setting', {
// refresh: function(frm) {
// }
});

View file

@ -1,160 +0,0 @@
{
"allow_copy": 0,
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"autoname": "Prompt",
"beta": 0,
"creation": "2019-03-06 13:29:21.101860",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "disable_count",
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Disable Count",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "disable_sidebar_stats",
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Disable Sidebar Stats",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "disable_auto_refresh",
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Disable Auto Refresh",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2019-03-06 13:40:59.533586",
"modified_by": "Administrator",
"module": "Desk",
"name": "List View Setting",
"name_case": "",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 0,
"role": "System Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1
}
],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0,
"track_views": 0
}

View file

@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
class ListViewSetting(Document):
pass

View file

@ -0,0 +1,8 @@
// Copyright (c) 2020, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on('List View Settings', {
// refresh: function(frm) {
// }
});

View file

@ -0,0 +1,76 @@
{
"actions": [],
"autoname": "Prompt",
"creation": "2019-10-23 15:00:48.392374",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"disable_count",
"disable_sidebar_stats",
"disable_auto_refresh",
"total_fields",
"fields_html",
"fields"
],
"fields": [
{
"default": "0",
"fieldname": "disable_count",
"fieldtype": "Check",
"label": "Disable Count"
},
{
"default": "0",
"fieldname": "disable_sidebar_stats",
"fieldtype": "Check",
"label": "Disable Sidebar Stats"
},
{
"default": "0",
"fieldname": "disable_auto_refresh",
"fieldtype": "Check",
"label": "Disable Auto Refresh"
},
{
"fieldname": "total_fields",
"fieldtype": "Select",
"label": "Maximum Number of Fields",
"options": "\n4\n5\n6\n7\n8\n9\n10"
},
{
"fieldname": "fields_html",
"fieldtype": "HTML",
"label": "Fields"
},
{
"fieldname": "fields",
"fieldtype": "Code",
"hidden": 1,
"label": "Fields",
"read_only": 1
}
],
"links": [],
"modified": "2020-05-12 18:27:15.568199",
"modified_by": "Administrator",
"module": "Desk",
"name": "List View Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"read_only": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View file

@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
class ListViewSettings(Document):
def on_update(self):
frappe.clear_document_cache(self.doctype, self.name)
@frappe.whitelist()
def save_listview_settings(doctype, listview_settings, removed_listview_fields):
listview_settings = frappe.parse_json(listview_settings)
removed_listview_fields = frappe.parse_json(removed_listview_fields)
if frappe.get_all("List View Settings", filters={"name": doctype}):
doc = frappe.get_doc("List View Settings", doctype)
doc.update(listview_settings)
doc.save()
else:
doc = frappe.new_doc("List View Settings")
doc.name = doctype
doc.update(listview_settings)
doc.insert()
set_listview_fields(doctype, listview_settings.get("fields"), removed_listview_fields)
return {
"meta": frappe.get_meta(doctype, False),
"listview_settings": doc
}
def set_listview_fields(doctype, listview_fields, removed_listview_fields):
meta = frappe.get_meta(doctype)
listview_fields = [f.get("fieldname") for f in frappe.parse_json(listview_fields) if f.get("fieldname")]
for field in removed_listview_fields:
set_in_list_view_property(doctype, meta.get_field(field), "0")
for field in listview_fields:
set_in_list_view_property(doctype, meta.get_field(field), "1")
def set_in_list_view_property(doctype, field, value):
if not field or field.fieldname == "status_field":
return
property_setter = frappe.db.get_value("Property Setter", {"doc_type": doctype, "field_name": field.fieldname, "property": "in_list_view"})
if property_setter:
doc = frappe.get_doc("Property Setter", property_setter)
doc.value = value
doc.save()
else:
frappe.make_property_setter({
"doctype": doctype,
"doctype_or_field": "DocField",
"fieldname": field.fieldname,
"property": "in_list_view",
"value": value,
"property_type": "Check"
}, ignore_validate=True)
@frappe.whitelist()
def get_default_listview_fields(doctype):
meta = frappe.get_meta(doctype)
path = frappe.get_module_path(frappe.scrub(meta.module), "doctype", frappe.scrub(meta.name), frappe.scrub(meta.name) + ".json")
doctype_json = frappe.get_file_json(path)
fields = [f.get("fieldname") for f in doctype_json.get("fields") if f.get("in_list_view")]
if meta.title_field:
if not meta.title_field.strip() in fields:
fields.append(meta.title_field.strip())
return fields

View file

@ -3,7 +3,8 @@
# See license.txt
from __future__ import unicode_literals
# import frappe
import unittest
class TestListViewSetting(unittest.TestCase):
class TestListViewSettings(unittest.TestCase):
pass

View file

@ -3,10 +3,43 @@
frappe.ui.form.on('Notification Log', {
refresh: function(frm) {
let dt = frm.doc.document_type;
let dn = frm.doc.document_name;
frm.fields_dict.document_name.$input_wrapper
.find('.control-value')
.wrapInner(`<a href='#Form/${dt}/${dn}'></a>`);
if (frm.doc.attached_file) {
frm.trigger('set_attachment');
} else {
frm.get_field('attachment_link').$wrapper.empty();
}
},
open_reference_document: function(frm) {
const dt = frm.doc.document_type;
const dn = frm.doc.document_name;
frappe.set_route('Form', dt, dn);
},
set_attachment: function(frm) {
const attachment = JSON.parse(frm.doc.attached_file);
const $wrapper = frm.get_field('attachment_link').$wrapper;
$wrapper.html(`
<div class="attached-file text-medium">
<div class="ellipsis">
<i class="fa fa-paperclip"></i>
<a class="attached-file-link">${attachment.name}.pdf</a>
</div>
</div>
`);
$wrapper.find(".attached-file-link").click(() => {
const w = window.open(
frappe.urllib.get_full_url(`/api/method/frappe.utils.print_format.download_pdf?
doctype=${encodeURIComponent(attachment.doctype)}
&name=${encodeURIComponent(attachment.name)}
&format=${encodeURIComponent(attachment.print_format)}
&lang=${encodeURIComponent(attachment.lang)}`)
);
if (!w) {
frappe.msgprint(__("Please enable pop-ups"));
}
});
}
});

View file

@ -1,4 +1,5 @@
{
"actions": [],
"creation": "2019-08-26 13:37:34.165254",
"doctype": "DocType",
"editable_grid": 1,
@ -8,10 +9,12 @@
"for_user",
"type",
"email_content",
"column_break_4",
"document_type",
"read",
"document_name",
"attached_file",
"attachment_link",
"open_reference_document",
"from_user"
],
"fields": [
@ -20,57 +23,65 @@
"fieldtype": "Text",
"in_list_view": 1,
"label": "Subject",
"read_only": 1
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "for_user",
"fieldtype": "Link",
"hidden": 1,
"label": "For User",
"options": "User",
"read_only": 1
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "type",
"fieldtype": "Select",
"hidden": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Type",
"options": "Mention\nEnergy Point\nAssignment\nShare",
"read_only": 1,
"search_index": 1
"options": "Mention\nEnergy Point\nAssignment\nShare\nAlert",
"search_index": 1,
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "email_content",
"fieldtype": "Text",
"label": "Email Content",
"read_only": 1
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
"fieldtype": "Text Editor",
"label": "Message",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "document_type",
"fieldtype": "Link",
"hidden": 1,
"label": "Document Type",
"options": "DocType",
"read_only": 1,
"search_index": 1
"search_index": 1,
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "document_name",
"fieldtype": "Data",
"label": "Document Name",
"read_only": 1,
"search_index": 1
"hidden": 1,
"label": "Document Link",
"search_index": 1,
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "from_user",
"fieldtype": "Link",
"hidden": 1,
"label": "From User",
"options": "User",
"read_only": 1,
"search_index": 1
"search_index": 1,
"show_days": 1,
"show_seconds": 1
},
{
"default": "0",
@ -78,26 +89,51 @@
"fieldtype": "Check",
"hidden": 1,
"ignore_user_permissions": 1,
"label": "Read"
"label": "Read",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "open_reference_document",
"fieldtype": "Button",
"label": "Open Reference Document",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "attached_file",
"fieldtype": "Code",
"hidden": 1,
"label": "Attached File",
"options": "JSON",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "attachment_link",
"fieldtype": "HTML",
"label": "Attachment Link",
"show_days": 1,
"show_seconds": 1
}
],
"hide_toolbar": 1,
"in_create": 1,
"modified": "2019-11-12 15:22:35.283678",
"links": [],
"modified": "2020-05-31 22:31:12.886950",
"modified_by": "umair@erpnext.com",
"module": "Desk",
"name": "Notification Log",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"share": 1,
"write": 1
"share": 1
}
],
"sort_field": "modified",

View file

@ -48,6 +48,7 @@ def enqueue_create_notification(users, doc):
if isinstance(users, frappe.string_types):
users = [user.strip() for user in users.split(',') if user.strip()]
users = list(set(users))
frappe.enqueue(
'frappe.desk.doctype.notification_log.notification_log.make_notification_logs',
@ -58,6 +59,7 @@ def enqueue_create_notification(users, doc):
def make_notification_logs(doc, users):
from frappe.social.doctype.energy_point_settings.energy_point_settings import is_energy_point_enabled
for user in users:
if frappe.db.exists('User', user):
if is_notifications_enabled(user):
@ -68,7 +70,7 @@ def make_notification_logs(doc, users):
_doc.update(doc)
_doc.for_user = user
_doc.subject = _doc.subject.replace('<div>', '').replace('</div>', '')
if _doc.for_user != _doc.from_user or doc.type == 'Energy Point':
if _doc.for_user != _doc.from_user or doc.type == 'Energy Point' or doc.type == 'Alert':
_doc.insert(ignore_permissions=True)
def send_notification_email(doc):

View file

@ -1,4 +1,5 @@
{
"actions": [],
"autoname": "Prompt",
"creation": "2019-09-11 22:15:44.851526",
"doctype": "DocType",
@ -21,52 +22,68 @@
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
"label": "Enabled",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "subscribed_documents",
"fieldtype": "Table MultiSelect",
"label": "Subscribed Documents",
"options": "Notification Subscribed Document"
"options": "Notification Subscribed Document",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "column_break_3",
"fieldtype": "Section Break",
"label": "Email Settings"
"label": "Email Settings",
"show_days": 1,
"show_seconds": 1
},
{
"default": "1",
"fieldname": "enable_email_notifications",
"fieldtype": "Check",
"label": "Enable Email Notifications"
"label": "Enable Email Notifications",
"show_days": 1,
"show_seconds": 1
},
{
"default": "1",
"depends_on": "enable_email_notifications",
"fieldname": "enable_email_mention",
"fieldtype": "Check",
"label": "Mentions"
"label": "Mentions",
"show_days": 1,
"show_seconds": 1
},
{
"default": "1",
"depends_on": "enable_email_notifications",
"fieldname": "enable_email_assignment",
"fieldtype": "Check",
"label": "Assignments"
"label": "Assignments",
"show_days": 1,
"show_seconds": 1
},
{
"default": "1",
"depends_on": "enable_email_notifications",
"fieldname": "enable_email_energy_point",
"fieldtype": "Check",
"label": "Energy Points"
"label": "Energy Points",
"show_days": 1,
"show_seconds": 1
},
{
"default": "1",
"depends_on": "enable_email_notifications",
"fieldname": "enable_email_share",
"fieldtype": "Check",
"label": "Document Share"
"label": "Document Share",
"show_days": 1,
"show_seconds": 1
},
{
"default": "__user",
@ -75,18 +92,23 @@
"hidden": 1,
"label": "User",
"options": "User",
"read_only": 1
"read_only": 1,
"show_days": 1,
"show_seconds": 1
},
{
"default": "0",
"fieldname": "seen",
"fieldtype": "Check",
"hidden": 1,
"label": "Seen"
"label": "Seen",
"show_days": 1,
"show_seconds": 1
}
],
"in_create": 1,
"modified": "2019-11-19 12:57:59.356786",
"links": [],
"modified": "2020-05-31 22:16:40.798019",
"modified_by": "Administrator",
"module": "Desk",
"name": "Notification Settings",

View file

@ -28,6 +28,9 @@ def is_email_notifications_enabled_for_type(user, notification_type):
if not is_email_notifications_enabled(user):
return False
if notification_type == 'Alert':
return False
fieldname = 'enable_email_' + frappe.scrub(notification_type)
enabled = frappe.db.get_value('Notification Settings', user, fieldname)
if enabled is None:

View file

@ -64,7 +64,7 @@ class ToDo(Document):
filters={
"reference_type": self.reference_type,
"reference_name": self.reference_name,
"status": "Open"
"status": ("!=", "Cancelled")
},
fields=["owner"], as_list=True)]

View file

@ -7,17 +7,16 @@ import frappe
@frappe.whitelist()
def get_list_settings(doctype):
try:
return frappe.get_cached_doc("List View Setting", doctype)
return frappe.get_cached_doc("List View Settings", doctype)
except frappe.DoesNotExistError:
frappe.clear_messages()
@frappe.whitelist()
def set_list_settings(doctype, values):
try:
doc = frappe.get_doc("List View Setting", doctype)
doc = frappe.get_doc("List View Settings", doctype)
except frappe.DoesNotExistError:
doc = frappe.new_doc("List View Setting")
doc = frappe.new_doc("List View Settings")
doc.name = doctype
frappe.clear_messages()
doc.update(frappe.parse_json(values))

View file

@ -252,7 +252,7 @@ def get_open_count(doctype, name, items=[]):
continue
filters = get_filters_for(d)
fieldname = links.get("non_standard_fieldnames", {}).get(d, links.fieldname)
fieldname = links.get("non_standard_fieldnames", {}).get(d, links.get('fieldname'))
data = {"name": d}
if filters:
# get the fieldname for the current document

View file

@ -216,8 +216,10 @@ def send_daily():
elif auto_email_report.frequency == 'Weekly':
if auto_email_report.day_of_week != current_day:
continue
auto_email_report.send()
try:
auto_email_report.send()
except Exception as e:
frappe.log_error(e, _('Failed to send {0} Auto Email Report').format(auto_email_report.name))
def send_monthly():

View file

@ -80,7 +80,6 @@ frappe.ui.form.on("Notification", {
});
},
refresh: function(frm) {
frm.toggle_reqd("recipients", frm.doc.channel=="Email");
frappe.notification.setup_fieldname_select(frm);
frm.get_field("is_standard").toggle(frappe.boot.developer_mode);
frm.trigger('event');

View file

@ -1,4 +1,5 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "Prompt",
"creation": "2014-07-11 17:18:09.923399",
@ -22,6 +23,7 @@
"days_in_advance",
"value_changed",
"sender",
"send_system_notification",
"sender_email",
"section_break_9",
"condition",
@ -46,32 +48,43 @@
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
"label": "Enabled",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
"fieldtype": "Column Break",
"show_days": 1,
"show_seconds": 1
},
{
"default": "Email",
"depends_on": "eval: !doc.disable_channel",
"fieldname": "channel",
"fieldtype": "Select",
"label": "Channel",
"options": "Email\nSlack",
"options": "Email\nSlack\nSystem Notification",
"reqd": 1,
"set_only_once": 1
"show_days": 1,
"show_seconds": 1
},
{
"depends_on": "eval:doc.channel=='Slack'",
"fieldname": "slack_webhook_url",
"fieldtype": "Link",
"label": "Slack Channel",
"options": "Slack Webhook URL"
"mandatory_depends_on": "eval:doc.channel=='Slack'",
"options": "Slack Webhook URL",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "filters",
"fieldtype": "Section Break",
"label": "Filters"
"label": "Filters",
"show_days": 1,
"show_seconds": 1
},
{
"description": "To add dynamic subject, use jinja tags like\n\n<div><pre><code>{{ doc.name }} Delivered</code></pre></div>",
@ -80,7 +93,9 @@
"ignore_xss_filter": 1,
"in_list_view": 1,
"label": "Subject",
"reqd": 1
"reqd": 1,
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "document_type",
@ -90,13 +105,17 @@
"label": "Document Type",
"options": "DocType",
"reqd": 1,
"search_index": 1
"search_index": 1,
"show_days": 1,
"show_seconds": 1
},
{
"default": "0",
"fieldname": "is_standard",
"fieldtype": "Check",
"label": "Is Standard"
"label": "Is Standard",
"show_days": 1,
"show_seconds": 1
},
{
"depends_on": "is_standard",
@ -104,11 +123,15 @@
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Module",
"options": "Module Def"
"options": "Module Def",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "col_break_1",
"fieldtype": "Column Break"
"fieldtype": "Column Break",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "event",
@ -117,21 +140,27 @@
"label": "Send Alert On",
"options": "\nNew\nSave\nSubmit\nCancel\nDays After\nDays Before\nValue Change\nMethod\nCustom",
"reqd": 1,
"search_index": 1
"search_index": 1,
"show_days": 1,
"show_seconds": 1
},
{
"depends_on": "eval:doc.event=='Method'",
"description": "Trigger on valid methods like \"before_insert\", \"after_update\", etc (will depend on the DocType selected)",
"fieldname": "method",
"fieldtype": "Data",
"label": "Trigger Method"
"label": "Trigger Method",
"show_days": 1,
"show_seconds": 1
},
{
"depends_on": "eval:doc.event==\"Days After\" || doc.event==\"Days Before\"",
"description": "Send alert if date matches this field's value",
"fieldname": "date_changed",
"fieldtype": "Select",
"label": "Reference Date"
"label": "Reference Date",
"show_days": 1,
"show_seconds": 1
},
{
"default": "0",
@ -139,31 +168,41 @@
"description": "Send days before or after the reference date",
"fieldname": "days_in_advance",
"fieldtype": "Int",
"label": "Days Before or After"
"label": "Days Before or After",
"show_days": 1,
"show_seconds": 1
},
{
"depends_on": "eval:doc.event==\"Value Change\"",
"description": "Send alert if this field's value changes",
"fieldname": "value_changed",
"fieldtype": "Select",
"label": "Value Changed"
"label": "Value Changed",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "sender",
"fieldtype": "Link",
"label": "Sender",
"options": "Email Account"
"options": "Email Account",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "sender_email",
"fieldtype": "Data",
"label": "Sender Email",
"options": "Email",
"read_only": 1
"read_only": 1,
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "section_break_9",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"show_days": 1,
"show_seconds": 1
},
{
"description": "Optional: The alert will be sent if this expression is true",
@ -171,99 +210,143 @@
"fieldtype": "Code",
"ignore_xss_filter": 1,
"in_list_view": 1,
"label": "Condition"
"label": "Condition",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "column_break_6",
"fieldtype": "Column Break"
"fieldtype": "Column Break",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "html_7",
"fieldtype": "HTML",
"options": "<p><strong>Condition Examples:</strong></p>\n<pre>doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total &gt; 40000\n</pre>\n"
"options": "<p><strong>Condition Examples:</strong></p>\n<pre>doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total &gt; 40000\n</pre>\n",
"show_days": 1,
"show_seconds": 1
},
{
"collapsible": 1,
"fieldname": "property_section",
"fieldtype": "Section Break",
"label": "Set Property After Alert"
"label": "Set Property After Alert",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "set_property_after_alert",
"fieldtype": "Select",
"label": "Set Property After Alert"
"label": "Set Property After Alert",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "property_value",
"fieldtype": "Data",
"label": "Value To Be Set"
"label": "Value To Be Set",
"show_days": 1,
"show_seconds": 1
},
{
"depends_on": "eval:doc.channel=='Email'",
"depends_on": "eval:doc.channel!=='Slack'",
"fieldname": "column_break_5",
"fieldtype": "Section Break",
"label": "Recipients"
"label": "Recipients",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "recipients",
"fieldtype": "Table",
"label": "Recipients",
"options": "Notification Recipient"
"mandatory_depends_on": "eval:doc.channel!=='Slack'",
"options": "Notification Recipient",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "message_sb",
"fieldtype": "Section Break",
"label": "Message"
"label": "Message",
"show_days": 1,
"show_seconds": 1
},
{
"default": "Add your message here",
"fieldname": "message",
"fieldtype": "Code",
"ignore_xss_filter": 1,
"label": "Message"
"label": "Message",
"show_days": 1,
"show_seconds": 1
},
{
"depends_on": "eval:doc.channel=='Email'",
"fieldname": "message_examples",
"fieldtype": "HTML",
"label": "Message Examples",
"options": "<h5>Message Example</h5>\n\n<pre>&lt;h3&gt;Order Overdue&lt;/h3&gt;\n\n&lt;p&gt;Transaction {{ doc.name }} has exceeded Due Date. Please take necessary action.&lt;/p&gt;\n\n&lt;!-- show last comment --&gt;\n{% if comments %}\nLast comment: {{ comments[-1].comment }} by {{ comments[-1].by }}\n{% endif %}\n\n&lt;h4&gt;Details&lt;/h4&gt;\n\n&lt;ul&gt;\n&lt;li&gt;Customer: {{ doc.customer }}\n&lt;li&gt;Amount: {{ doc.grand_total }}\n&lt;/ul&gt;\n</pre>"
"options": "<h5>Message Example</h5>\n\n<pre>&lt;h3&gt;Order Overdue&lt;/h3&gt;\n\n&lt;p&gt;Transaction {{ doc.name }} has exceeded Due Date. Please take necessary action.&lt;/p&gt;\n\n&lt;!-- show last comment --&gt;\n{% if comments %}\nLast comment: {{ comments[-1].comment }} by {{ comments[-1].by }}\n{% endif %}\n\n&lt;h4&gt;Details&lt;/h4&gt;\n\n&lt;ul&gt;\n&lt;li&gt;Customer: {{ doc.customer }}\n&lt;li&gt;Amount: {{ doc.grand_total }}\n&lt;/ul&gt;\n</pre>",
"show_days": 1,
"show_seconds": 1
},
{
"depends_on": "eval:doc.channel=='Slack'",
"fieldname": "slack_message_examples",
"fieldtype": "HTML",
"label": "Message Examples",
"options": "<h5>Message Example</h5>\n\n<pre>*Order Overdue*\n\nTransaction {{ doc.name }} has exceeded Due Date. Please take necessary action.\n\n<!-- show last comment -->\n{% if comments %}\nLast comment: {{ comments[-1].comment }} by {{ comments[-1].by }}\n{% endif %}\n\n*Details*\n\n\u2022 Customer: {{ doc.customer }}\n\u2022 Amount: {{ doc.grand_total }}\n</pre>"
"options": "<h5>Message Example</h5>\n\n<pre>*Order Overdue*\n\nTransaction {{ doc.name }} has exceeded Due Date. Please take necessary action.\n\n<!-- show last comment -->\n{% if comments %}\nLast comment: {{ comments[-1].comment }} by {{ comments[-1].by }}\n{% endif %}\n\n*Details*\n\n\u2022 Customer: {{ doc.customer }}\n\u2022 Amount: {{ doc.grand_total }}\n</pre>",
"show_days": 1,
"show_seconds": 1
},
{
"fieldname": "view_properties",
"fieldtype": "Button",
"label": "View Properties (via Customize Form)"
"label": "View Properties (via Customize Form)",
"show_days": 1,
"show_seconds": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "attach_print",
"fieldname": "column_break_25",
"fieldtype": "Section Break",
"label": "Print Settings"
"label": "Print Settings",
"show_days": 1,
"show_seconds": 1
},
{
"default": "0",
"fieldname": "attach_print",
"fieldtype": "Check",
"label": "Attach Print"
"label": "Attach Print",
"show_days": 1,
"show_seconds": 1
},
{
"depends_on": "attach_print",
"fieldname": "print_format",
"fieldtype": "Link",
"label": "Print Format",
"options": "Print Format"
"options": "Print Format",
"show_days": 1,
"show_seconds": 1
},
{
"default": "0",
"depends_on": "eval: doc.channel !== 'System Notification'",
"description": "If enabled, the notification will show up in the notifications dropdown on the top right corner of the navigation bar.",
"fieldname": "send_system_notification",
"fieldtype": "Check",
"label": "Send System Notification",
"show_days": 1,
"show_seconds": 1
}
],
"icon": "fa fa-envelope",
"modified": "2019-07-15 13:17:02.585013",
"links": [],
"modified": "2020-05-29 16:03:10.914526",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification",

View file

@ -13,6 +13,7 @@ from frappe.utils.jinja import validate_template
from frappe.modules.utils import export_module_json, get_doc_module
from six import string_types
from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send_slack_message
from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification
class Notification(Document):
def onload(self):
@ -125,6 +126,9 @@ def get_context(context):
if self.channel == 'Slack':
self.send_a_slack_msg(doc, context)
if self.channel == 'System Notification' or self.send_system_notification:
self.create_system_notification(doc, context)
if self.set_property_after_alert:
allow_update = True
if doc.docstatus == 1 and not doc.meta.get_field(self.set_property_after_alert).allow_on_submit:
@ -143,6 +147,25 @@ def get_context(context):
except Exception:
frappe.log_error(title='Document update failed', message=frappe.get_traceback())
def create_system_notification(self, doc, context):
subject = self.subject
if "{" in subject:
subject = frappe.render_template(self.subject, context)
attachments = self.get_attachment(doc)
recipients, cc, bcc = self.get_list_of_recipients(doc, context)
users = recipients + cc + bcc
notification_doc = {
'type': 'Alert',
'document_type': doc.doctype,
'document_name': doc.name,
'subject': subject,
'email_content': frappe.render_template(self.message, context),
'attached_file': attachments and json.dumps(attachments[0])
}
enqueue_create_notification(users, notification_doc)
def send_an_email(self, doc, context):
from email.utils import formataddr
subject = self.subject
@ -228,8 +251,7 @@ def get_context(context):
# ignoring attachment as draft and cancelled documents are not allowed to print
status = "Draft" if doc.docstatus == 0 else "Cancelled"
frappe.throw(_("""Not allowed to attach {0} document,
please enable Allow Print For {0} in Print Settings""".format(status)),
frappe.throw(_("""Not allowed to attach {0} document, please enable Allow Print For {0} in Print Settings""").format(status),
title=_("Error in Notification"))
else:
return [{

View file

@ -13,6 +13,11 @@ if sys.version_info.major == 2:
else:
from builtins import FileNotFoundError
class SiteNotSpecifiedError(Exception):
def __init__(self, *args, **kwargs):
self.message = "Please specify --site sitename"
super(Exception, self).__init__(self.message)
class ValidationError(Exception):
http_status_code = 417

View file

@ -56,6 +56,8 @@ website_route_rules = [
{"from_route": "/profile", "to_route": "me"},
]
base_template = "templates/base.html"
write_file_keys = ["file_url", "file_name"]
notification_config = "frappe.core.notifications.get_notification_config"
@ -270,7 +272,10 @@ setup_wizard_exception = [
]
before_migrate = ['frappe.patches.v11_0.sync_user_permission_doctype_before_migrate.execute']
after_migrate = ['frappe.website.doctype.website_theme.website_theme.generate_theme_files_if_not_exist']
after_migrate = [
'frappe.website.doctype.website_theme.website_theme.generate_theme_files_if_not_exist',
'frappe.modules.full_text_search.build_index_for_all_routes'
]
otp_methods = ['OTP App','Email','SMS']
user_privacy_documents = [

View file

@ -269,6 +269,7 @@ def make_site_dirs():
os.path.join(site_private_path, 'backups'),
os.path.join(site_public_path, 'files'),
os.path.join(site_private_path, 'files'),
os.path.join(frappe.local.site_path, 'logs'),
os.path.join(frappe.local.site_path, 'task-logs')):
if not os.path.exists(dir_path):
os.makedirs(dir_path)
@ -298,7 +299,8 @@ def remove_missing_apps():
def extract_sql_gzip(sql_gz_path):
try:
subprocess.check_call(['gzip', '-d', '-v', '-f', sql_gz_path])
# kdvf - keep, decompress, verbose, force
subprocess.check_call(['gzip', '-kdvf', sql_gz_path])
except:
raise

View file

@ -56,7 +56,8 @@ def take_backup_to_dropbox(retry_count=0, upload_db_backup=True):
did_not_upload, error_log = backup_to_dropbox(upload_db_backup)
if did_not_upload: raise Exception
send_email(True, "Dropbox", "Dropbox Settings", "send_notifications_to")
if cint(frappe.db.get_value("Dropbox Settings", None, "send_email_for_successful_backup")):
send_email(True, "Dropbox", "Dropbox Settings", "send_notifications_to")
except JobTimeoutException:
if retry_count < 2:
args = {

View file

@ -64,6 +64,8 @@ from __future__ import unicode_literals
import frappe
from frappe import _
import json
import hmac
import hashlib
from six.moves.urllib.parse import urlencode
from frappe.model.document import Document
from frappe.utils import get_url, call_hook_method, cint, get_timestamp
@ -317,6 +319,20 @@ class RazorpaySettings(Document):
except Exception:
frappe.log_error(frappe.get_traceback())
def verify_signature(self, body, signature, key):
key = bytes(key, 'utf-8')
body = bytes(body, 'utf-8')
dig = hmac.new(key=key, msg=body, digestmod=hashlib.sha256)
generated_signature = dig.hexdigest()
result = hmac.compare_digest(generated_signature, signature)
if not result:
frappe.throw(_('Razorpay Signature Verification Failed'), exc=frappe.PermissionError)
return result
def capture_payment(is_sandbox=False, sanbox_response=None):
"""
Verifies the purchase as complete by the merchant.

View file

@ -7,7 +7,6 @@ from frappe.integrations.frappe_providers.frappecloud import frappecloud_migrato
def migrate_to(local_site, frappe_provider):
if frappe_provider in ("frappe.cloud", "frappecloud.com"):
frappe_provider = "frappecloud.com"
return frappecloud_migrator(local_site, frappe_provider)
else:
print("{} is not supported yet".format(frappe_provider))

View file

@ -13,7 +13,129 @@ import requests
import frappe
import frappe.utils.backups
from frappe.utils import get_installed_apps_info
from frappe.utils.commands import render_table, add_line_after
from frappe.utils.commands import render_table, add_line_after, add_line_before
# TODO: check upgrade compatibility
def render_actions_table():
actions_table = [["#", "Action"]]
actions = []
for n, action in enumerate(migrator_actions):
actions_table.append([n+1, action["title"]])
actions.append(action["fn"])
render_table(actions_table)
return actions
def render_site_table(sites_info):
sites_table = [["#", "Site Name", "Status"]]
available_sites = []
for n, site_data in enumerate(sites_info):
name, status = site_data["name"], site_data["status"]
if status in ("Active", "Broken"):
sites_table.append([n + 1, name, status])
available_sites.append(name)
render_table(sites_table)
return available_sites
def render_teams_table(teams):
teams_table = [["#", "Team"]]
for n, team in enumerate(teams):
teams_table.append([n+1, team])
render_table(teams_table)
def render_plan_table(plans_list):
plans_table = [["Plan", "CPU Time"]]
visible_headers = ["name", "cpu_time_per_day"]
for plan in plans_list:
plan, cpu_time = [plan[header] for header in visible_headers]
plans_table.append([plan, "{} hour{}/day".format(cpu_time, "" if cpu_time < 2 else "s")])
render_table(plans_table)
def render_group_table(app_groups):
# title row
app_groups_table = [["#", "App Group", "Apps"]]
# all rows
for idx, app_group in enumerate(app_groups):
apps_list = ", ".join(["{}:{}".format(app["scrubbed"], app["branch"]) for app in app_group["apps"]])
row = [idx + 1, app_group["name"], apps_list]
app_groups_table.append(row)
render_table(app_groups_table)
def handle_request_failure(request=None, message=None, traceback=True, exit_code=1):
message = message or "Request failed with error code {}".format(request.status_code)
response = html2text(request.text) if traceback else ""
print("{0}{1}".format(message, "\n" + response))
sys.exit(exit_code)
@add_line_after
def select_primary_action():
actions = render_actions_table()
idx = click.prompt("What do you want to do?", type=click.IntRange(1, len(actions))) - 1
return actions[idx]
@add_line_after
def select_site():
get_all_sites_request = session.post(all_site_url, headers={
"accept": "application/json",
"accept-encoding": "gzip, deflate, br",
"content-type": "application/json; charset=utf-8"
})
if get_all_sites_request.ok:
all_sites = get_all_sites_request.json()["message"]
available_sites = render_site_table(all_sites)
while True:
selected_site = click.prompt("Name of the site you want to restore to", type=str).strip()
if selected_site in available_sites:
return selected_site
else:
print("Site {} does not exist. Try again ❌".format(selected_site))
else:
print("Couldn't retrive sites list...Try again later")
sys.exit(1)
@add_line_before
def select_team(session):
# get team options
account_details_sc = session.post(account_details_url)
if account_details_sc.ok:
account_details = account_details_sc.json()["message"]
available_teams = account_details["teams"]
# ask if they want to select, go ahead with if only one exists
if len(available_teams) == 1:
team = available_teams[0]
else:
render_teams_table(available_teams)
idx = click.prompt("Select Team", type=click.IntRange(1, len(available_teams))) - 1
team = available_teams[idx]
print("Team '{}' set for current session".format(team))
return team
def get_new_site_options():
@ -46,21 +168,6 @@ def is_subdomain_available(subdomain):
return available
def render_plan_table(plans_list):
plans_table = []
# title row
visible_headers = ["name", "cpu_time_per_day"]
plans_table.append(["Plan", "CPU Time"])
# all rows
for plan in plans_list:
plan, cpu_time = [plan[header] for header in visible_headers]
plans_table.append([plan, "{} hour{}/day".format(cpu_time, "" if cpu_time < 2 else "s")])
render_table(plans_table)
@add_line_after
def choose_plan(plans_list):
print("{} plans available".format(len(plans_list)))
@ -113,19 +220,6 @@ def check_app_compat(available_group):
return is_compat, filtered_apps
def render_group_table(app_groups):
# title row
app_groups_table = [["#", "App Group", "Apps"]]
# all rows
for idx, app_group in enumerate(app_groups):
apps_list = ", ".join(["{}:{}".format(app["scrubbed"], app["branch"]) for app in app_group["apps"]])
row = [idx + 1, app_group["name"], apps_list]
app_groups_table.append(row)
render_table(app_groups_table)
@add_line_after
def filter_apps(app_groups):
render_group_table(app_groups)
@ -148,24 +242,6 @@ def filter_apps(app_groups):
return selected_group["name"], filtered_apps
@add_line_after
def create_session():
# take user input from STDIN
username = click.prompt("Username").strip()
password = getpass.unix_getpass()
auth_credentials = {"usr": username, "pwd": password}
session = requests.Session()
login_sc = session.post(login_url, auth_credentials)
if login_sc.ok:
print("Authorization Successful! ✅")
session.headers.update({"X-Press-Team": username})
return session
else:
print("Authorization Failed with Error Code {}".format(login_sc.status_code))
@add_line_after
def get_subdomain(domain):
@ -208,61 +284,114 @@ def upload_backup(local_site):
return files_uploaded
def frappecloud_migrator(local_site, remote_site):
global login_url, upload_url, files_url, options_url, site_exists_url, session
def new_site(local_site):
# get new site options
site_options = get_new_site_options()
# set preferences from site options
subdomain = get_subdomain(site_options["domain"])
plan = choose_plan(site_options["plans"])
app_groups = site_options["groups"]
selected_group, filtered_apps = filter_apps(app_groups)
files_uploaded = upload_backup(local_site)
# push to frappe_cloud
payload = json.dumps({
"site": {
"apps": filtered_apps,
"files": files_uploaded,
"group": selected_group,
"name": subdomain,
"plan": plan
}
})
session.headers.update({"Content-Type": "application/json; charset=utf-8"})
site_creation_request = session.post(upload_url, payload)
if site_creation_request.ok:
site_url = site_creation_request.json()["message"]
print("Your site {} is being migrated ✨".format(local_site))
print("View your site dashboard at {}/dashboard/#/sites/{}".format(remote_site, site_url))
print("Your site URL: {}".format(site_url))
else:
handle_request_failure(site_creation_request)
def restore_site(local_site):
# get list of existing sites they can restore
selected_site = select_site()
# TODO: check if they can restore it
click.confirm("This is an irreversible action. Are you sure you want to continue?", abort=True)
# backup site
files_uploaded = upload_backup(local_site)
# push to frappe_cloud
payload = json.dumps({
"name": selected_site,
"files": files_uploaded
})
headers = {"Content-Type": "application/json; charset=utf-8"}
site_restore_request = session.post(restore_site_url, payload, headers=headers)
if site_restore_request.ok:
print("Your site {0} is being restored on {1}".format(local_site, selected_site))
print("View your site dashboard at {}/dashboard/#/sites/{}".format(remote_site, selected_site))
print("Your site URL: {}".format(selected_site))
else:
handle_request_failure(site_restore_request)
@add_line_after
def create_session():
print("Frappe Cloud credentials @ {}".format(remote_site))
# take user input from STDIN
username = click.prompt("Username").strip()
password = getpass.unix_getpass()
auth_credentials = {"usr": username, "pwd": password}
session = requests.Session()
login_sc = session.post(login_url, auth_credentials)
if login_sc.ok:
print("Authorization Successful! ✅")
team = select_team(session)
session.headers.update({"X-Press-Team": team })
return session
else:
handle_request_failure(message="Authorization Failed with Error Code {}".format(login_sc.status_code), traceback=False)
def frappecloud_migrator(local_site, frappecloud_site):
global login_url, upload_url, files_url, options_url, site_exists_url, restore_site_url, account_details_url, all_site_url
global session, migrator_actions, remote_site
remote_site = frappe.conf.frappecloud_url or "frappecloud.com"
login_url = "https://{}/api/method/login".format(remote_site)
upload_url = "https://{}/api/method/press.api.site.new".format(remote_site)
files_url = "https://{}/api/method/upload_file".format(remote_site)
options_url = "https://{}/api/method/press.api.site.options_for_new".format(remote_site)
site_exists_url = "https://{}/api/method/press.api.site.exists".format(remote_site)
account_details_url = "https://{}/api/method/press.api.account.get".format(remote_site)
all_site_url = "https://{}/api/method/press.api.site.all".format(remote_site)
restore_site_url = "https://{}/api/method/press.api.site.restore".format(remote_site)
print("Frappe Cloud credentials @ {}".format(remote_site))
migrator_actions = [
{ "title": "Create a new site", "fn": new_site },
{ "title": "Restore to an existing site", "fn": restore_site }
]
# get credentials + auth user + start session
session = create_session()
if session:
# connect to site db
frappe.init(site=local_site)
frappe.connect()
# available actions defined in migrator_actions
primary_action = select_primary_action()
# get new site options
site_options = get_new_site_options()
# set preferences from site options
subdomain = get_subdomain(site_options["domain"])
plan = choose_plan(site_options["plans"])
app_groups = site_options["groups"]
selected_group, filtered_apps = filter_apps(app_groups)
files_uploaded = upload_backup(local_site)
# push to frappe_cloud
payload = json.dumps({
"site": {
"apps": filtered_apps,
"files": files_uploaded,
"group": selected_group,
"name": subdomain,
"plan": plan
}
})
session.headers.update({"Content-Type": "application/json; charset=utf-8"})
site_creation_request = session.post(upload_url, payload)
frappe.destroy()
if site_creation_request.ok:
site_url = site_creation_request.json()["message"]
print("Your site {} is being migrated ✨".format(local_site))
print("View your site dashboard at {}/dashboard/#/sites/{}".format(remote_site, site_url))
print("Your site URL: {}".format(site_url))
else:
print("Request failed with error code {}".format(site_creation_request.status_code))
reason = html2text(site_creation_request.text)
print(reason)
sys.exit(1)
else:
sys.exit(1)
primary_action(local_site)

View file

@ -16,7 +16,7 @@ import frappe, json, copy, re
from frappe.model import optional_fields
from frappe.client import check_parent_permission
from frappe.model.utils.user_settings import get_user_settings, update_user_settings
from frappe.utils import flt, cint, get_time, make_filter_tuple, get_filter, add_to_date, cstr, nowdate
from frappe.utils import flt, cint, get_time, make_filter_tuple, get_filter, add_to_date, cstr, get_timespan_date_range
from frappe.model.meta import get_table_columns
class DatabaseQuery(object):
@ -354,7 +354,9 @@ class DatabaseQuery(object):
ifnull(`tabDocType`.`fieldname`, fallback) operator "value"
"""
f = get_filter(self.doctype, f)
from frappe.boot import get_additional_filters_from_hooks
additional_filters_config = get_additional_filters_from_hooks()
f = get_filter(self.doctype, f, additional_filters_config)
tname = ('`tab' + f.doctype + '`')
if not tname in self.tables:
@ -368,6 +370,9 @@ class DatabaseQuery(object):
can_be_null = True
if f.operator.lower() in additional_filters_config:
f.update(get_additional_filter_field(additional_filters_config, f, f.value))
# prepare in condition
if f.operator.lower() in ('ancestors of', 'descendants of', 'not ancestors of', 'not descendants of'):
values = f.value or ''
@ -426,29 +431,8 @@ class DatabaseQuery(object):
if df and df.fieldtype in ("Check", "Float", "Int", "Currency", "Percent"):
can_be_null = False
if f.operator.lower() in ('previous', 'next'):
if f.operator.lower() == "previous":
if f.value == "1 week":
date_range = [add_to_date(nowdate(), days=-7), nowdate()]
elif f.value == "1 month":
date_range = [add_to_date(nowdate(), months=-1), nowdate()]
elif f.value == "3 months":
date_range = [add_to_date(nowdate(), months=-3), nowdate()]
elif f.value == "6 months":
date_range = [add_to_date(nowdate(), months=-6), nowdate()]
elif f.value == "1 year":
date_range = [add_to_date(nowdate(), years=-1), nowdate()]
elif f.operator.lower() == "next":
if f.value == "1 week":
date_range = [nowdate(), add_to_date(nowdate(), days=7)]
elif f.value == "1 month":
date_range = [nowdate(), add_to_date(nowdate(), months=1)]
elif f.value == "3 months":
date_range = [nowdate(), add_to_date(nowdate(), months=3)]
elif f.value == "6 months":
date_range = [nowdate(), add_to_date(nowdate(), months=6)]
elif f.value == "1 year":
date_range = [nowdate(), add_to_date(nowdate(), years=1)]
if f.operator.lower() in ('previous', 'next', 'timespan'):
date_range = get_date_range(f.operator.lower(), f.value)
f.operator = "Between"
f.value = date_range
fallback = "'0001-01-01 00:00:00'"
@ -843,4 +827,31 @@ def get_between_date_filter(value, df=None):
frappe.db.format_date(from_date),
frappe.db.format_date(to_date))
return data
return data
def get_additional_filter_field(additional_filters_config, f, value):
additional_filter = additional_filters_config[f.operator.lower()]
f = frappe._dict(frappe.get_attr(additional_filter['get_field'])())
if f.query_value:
for option in f.options:
option = frappe._dict(option)
if option.value == value:
f.value = option.query_value
return f
def get_date_range(operator, value):
timespan_map = {
'1 week': 'week',
'1 month': 'month',
'3 months': 'quarter',
'6 months': '6 months',
'1 year': 'year',
}
period_map = {
'previous': 'last',
'next': 'next',
}
timespan = period_map[operator] + ' ' + timespan_map[value] if operator != 'timespan' else value
return get_timespan_date_range(timespan)

View file

@ -483,6 +483,9 @@ class Meta(Document):
def get_row_template(self):
return self.get_web_template(suffix='_row')
def get_list_template(self):
return self.get_web_template(suffix='_list')
def get_web_template(self, suffix=''):
'''Returns the relative path of the row template for this doctype'''
module_name = frappe.scrub(self.module)

View file

@ -56,6 +56,8 @@ def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=F
if not merge:
rename_parent_and_child(doctype, old, new, meta)
else:
update_assignments(old, new, doctype)
# update link fields' values
link_fields = get_link_fields(doctype)
@ -104,6 +106,27 @@ def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=F
return new
def update_assignments(old, new, doctype):
old_assignments = frappe.parse_json(frappe.db.get_value(doctype, old, '_assign')) or []
new_assignments = frappe.parse_json(frappe.db.get_value(doctype, new, '_assign')) or []
common_assignments = list(set(old_assignments).intersection(new_assignments))
for user in common_assignments:
# delete todos linked to old doc
todos = frappe.db.get_all('ToDo',
{
'owner': user,
'reference_type': doctype,
'reference_name': old,
},
['name', 'description']
)
for todo in todos:
frappe.delete_doc('ToDo', todo.name)
unique_assignments = list(set(old_assignments + new_assignments))
frappe.db.set_value(doctype, new, '_assign', frappe.as_json(unique_assignments, indent=0))
def update_user_settings(old, new, link_fields):
'''

View file

@ -299,6 +299,7 @@ def set_workflow_state_on_action(doc, workflow_name, action):
return
action_map = {
'update_after_submit': '1',
'submit': '1',
'cancel': '2'
}

View file

@ -0,0 +1,106 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe
from whoosh.index import create_in, open_dir
from whoosh.fields import TEXT, ID, Schema
from whoosh.qparser import MultifieldParser, FieldsPlugin, WildcardPlugin
from whoosh.query import Prefix
from bs4 import BeautifulSoup
from frappe.website.render import render_page
from frappe.utils import set_request, cint
from frappe.utils.global_search import get_routes_to_index
def build_index_for_all_routes():
print("Building search index for all web routes...")
routes = get_routes_to_index()
documents = [get_document_to_index(route) for route in routes]
build_index("web_routes", documents)
@frappe.whitelist(allow_guest=True)
def web_search(index_name, query, scope=None, limit=20):
limit = cint(limit)
return search(index_name, query, scope, limit)
def get_document_to_index(route):
frappe.set_user("Guest")
frappe.local.no_cache = True
try:
set_request(method="GET", path=route)
content = render_page(route)
soup = BeautifulSoup(content, "html.parser")
page_content = soup.find(class_="page_content")
text_content = page_content.text if page_content else ""
title = soup.title.text.strip() if soup.title else route
frappe.set_user("Administrator")
return frappe._dict(title=title, content=text_content, path=route)
except (
frappe.PermissionError,
frappe.DoesNotExistError,
frappe.ValidationError,
Exception,
):
pass
def build_index(index_name, documents):
schema = Schema(
title=TEXT(stored=True), path=ID(stored=True), content=TEXT(stored=True)
)
index_dir = get_index_path(index_name)
frappe.create_folder(index_dir)
ix = create_in(index_dir, schema)
writer = ix.writer()
for document in documents:
if document:
writer.add_document(
title=document.title, path=document.path, content=document.content
)
writer.commit()
def search(index_name, text, scope=None, limit=20):
index_dir = get_index_path(index_name)
ix = open_dir(index_dir)
results = None
out = []
with ix.searcher() as searcher:
parser = MultifieldParser(["title", "content"], ix.schema)
parser.remove_plugin_class(FieldsPlugin)
parser.remove_plugin_class(WildcardPlugin)
query = parser.parse(text)
filter_scoped = None
if scope:
filter_scoped = Prefix("path", scope)
results = searcher.search(query, limit=limit, filter=filter_scoped)
for r in results:
title_highlights = r.highlights("title")
content_highlights = r.highlights("content")
out.append(
frappe._dict(
title=r["title"],
path=r["path"],
title_highlights=title_highlights,
content_highlights=content_highlights,
)
)
return out
def get_index_path(index_name):
return frappe.get_site_path("indexes", index_name)

View file

@ -278,6 +278,7 @@ frappe.patches.v13_0.set_path_for_homepage_in_web_page_view
frappe.patches.v13_0.migrate_translation_column_data
frappe.patches.v13_0.set_read_times
frappe.patches.v13_0.remove_web_view
frappe.patches.v13_0.site_wise_logging
frappe.patches.v13_0.set_unique_for_page_view
frappe.patches.v13_0.remove_tailwind_from_page_builder
frappe.patches.v13_0.rename_onboarding
@ -285,4 +286,6 @@ frappe.patches.v13_0.email_unsubscribe
execute:frappe.delete_doc("Web Template", "Section with Left Image", force=1)
execute:frappe.delete_doc("DocType", "Onboarding Slide")
execute:frappe.delete_doc("DocType", "Onboarding Slide Field")
execute:frappe.delete_doc("DocType", "Onboarding Slide Help Link")
execute:frappe.delete_doc("DocType", "Onboarding Slide Help Link")
frappe.patches.v13_0.update_date_filters_in_user_settings
frappe.patches.v13_0.update_duration_options

View file

@ -0,0 +1,10 @@
import os
import frappe
def execute():
site = frappe.local.site
log_folder = os.path.join(site, 'logs')
if not os.path.exists(log_folder):
os.mkdir(log_folder)

View file

@ -0,0 +1,54 @@
from __future__ import unicode_literals
import frappe, json
from frappe.model.utils.user_settings import update_user_settings, sync_user_settings
def execute():
users = frappe.db.sql("select distinct(user) from `__UserSettings`", as_dict=True)
for user in users:
user_settings = frappe.db.sql('''
select
* from `__UserSettings`
where
user="{user}"
'''.format(user = user.user), as_dict=True)
for setting in user_settings:
data = frappe.parse_json(setting.get('data'))
if data:
for key in data:
update_user_setting_filters(data, key, setting)
sync_user_settings()
def update_user_setting_filters(data, key, user_setting):
timespan_map = {
'1 week': 'week',
'1 month': 'month',
'3 months': 'quarter',
'6 months': '6 months',
'1 year': 'year',
}
period_map = {
'Previous': 'last',
'Next': 'next'
}
if data.get(key):
update = False
if isinstance(data.get(key), dict):
filters = data.get(key).get('filters')
if filters and isinstance(filters, list):
for f in filters:
if f[2] == 'Next' or f[2] == 'Previous':
update = True
f[3] = period_map[f[2]] + ' ' + timespan_map[f[3]]
f[2] = 'Timespan'
if update:
data[key]['filters'] = filters
update_user_settings(user_setting['doctype'], json.dumps(data), for_update=True)

View file

@ -0,0 +1,28 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe
def execute():
frappe.reload_doc('core', 'doctype', 'DocField')
if frappe.db.has_column('DocField', 'show_days'):
frappe.db.sql("""
UPDATE
tabDocField
SET
hide_days = 1 WHERE show_days = 0
""")
frappe.db.sql_ddl('alter table tabDocField drop column show_days')
if frappe.db.has_column('DocField', 'show_seconds'):
frappe.db.sql("""
UPDATE
tabDocField
SET
hide_seconds = 1 WHERE show_seconds = 0
""")
frappe.db.sql_ddl('alter table tabDocField drop column show_seconds')
frappe.clear_cache(doctype='DocField')

View file

@ -0,0 +1,183 @@
/*
Night Owl for highlight.js (c) Carl Baxter <carl@cbax.tech>
An adaptation of Sarah Drasner's Night Owl VS Code Theme
https://github.com/sdras/night-owl-vscode-theme
Copyright (c) 2018 Sarah Drasner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
.hljs {
display: block;
overflow-x: auto;
padding: 1rem 1.25rem;
background: #011627;
color: #d6deeb;
border-radius: 0.5rem;
}
/* General Purpose */
.hljs-keyword {
color: #c792ea;
font-style: italic;
}
.hljs-built_in {
color: #addb67;
font-style: italic;
}
.hljs-type {
color: #82aaff;
}
.hljs-literal {
color: #ff5874;
}
.hljs-number {
color: #F78C6C;
}
.hljs-regexp {
color: #5ca7e4;
}
.hljs-string {
color: #ecc48d;
}
.hljs-subst {
color: #d3423e;
}
.hljs-symbol {
color: #82aaff;
}
.hljs-class {
color: #ffcb8b;
}
.hljs-function {
color: #82AAFF;
}
.hljs-title {
color: #DCDCAA;
font-style: italic;
}
.hljs-params {
color: #7fdbca;
}
/* Meta */
.hljs-comment {
color: #637777;
font-style: italic;
}
.hljs-doctag {
color: #7fdbca;
}
.hljs-meta {
color: #82aaff;
}
.hljs-meta-keyword {
color: #82aaff;
}
.hljs-meta-string {
color: #ecc48d;
}
/* Tags, attributes, config */
.hljs-section {
color: #82b1ff;
}
.hljs-tag,
.hljs-name,
.hljs-builtin-name {
color: #7fdbca;
}
.hljs-attr {
color: #7fdbca;
}
.hljs-attribute {
color: #80cbc4;
}
.hljs-variable {
color: #addb67;
}
/* Markup */
.hljs-bullet {
color: #d9f5dd;
}
.hljs-code {
color: #80CBC4;
}
.hljs-emphasis {
color: #c792ea;
font-style: italic;
}
.hljs-strong {
color: #addb67;
font-weight: bold;
}
.hljs-formula {
color: #c792ea;
}
.hljs-link {
color: #ff869a;
}
.hljs-quote {
color: #697098;
font-style: italic;
}
/* CSS */
.hljs-selector-tag {
color: #ff6363;
}
.hljs-selector-id {
color: #fad430;
}
.hljs-selector-class {
color: #addb67;
font-style: italic;
}
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #c792ea;
font-style: italic;
}
/* Templates */
.hljs-template-tag {
color: #c792ea;
}
.hljs-template-variable {
color: #addb67;
}
/* diff */
.hljs-addition {
color: #addb67ff;
font-style: italic;
}
.hljs-deletion {
color: #EF535090;
font-style: italic;
}

View file

@ -13,10 +13,10 @@ frappe.ui.form.ControlDuration = frappe.ui.form.ControlData.extend({
</div>`
);
this.$wrapper.append(this.$picker);
this.build_numeric_input("days", !this.duration_options.show_days);
this.build_numeric_input("days", this.duration_options.hide_days);
this.build_numeric_input("hours", false);
this.build_numeric_input("minutes", false);
this.build_numeric_input("seconds", !this.duration_options.show_seconds);
this.build_numeric_input("seconds", this.duration_options.hide_seconds);
this.set_duration_picker_value(this.value);
this.$picker.hide();
this.bind_events();
@ -130,10 +130,10 @@ frappe.ui.form.ControlDuration = frappe.ui.form.ControlData.extend({
if (this.inputs) {
total_duration.minutes = parseInt(this.inputs.minutes.val());
total_duration.hours = parseInt(this.inputs.hours.val());
if (this.duration_options.show_days) {
if (!this.duration_options.hide_days) {
total_duration.days = parseInt(this.inputs.days.val());
}
if (this.duration_options.show_seconds) {
if (!this.duration_options.hide_seconds) {
total_duration.seconds = parseInt(this.inputs.seconds.val());
}
}

View file

@ -338,6 +338,11 @@ frappe.views.BaseList = class BaseList {
: [];
}
get_filter_value(fieldname) {
return this.get_filters_for_args().filter(f => f[1] == fieldname)[0] &&
this.get_filters_for_args().filter(f => f[1] == fieldname)[0][3];
}
get_args() {
return {
doctype: this.doctype,

View file

@ -0,0 +1,383 @@
export default class ListSettings {
constructor({ listview, doctype, meta, settings }) {
if (!doctype) {
frappe.throw(__('Doctype required'));
}
this.listview = listview;
this.doctype = doctype;
this.meta = meta;
this.settings = settings;
this.dialog = null;
this.fields = this.settings && this.settings.fields ? JSON.parse(this.settings.fields) : [];
this.subject_field = null;
frappe.model.with_doctype("List View Settings", () => {
this.make();
this.get_listview_fields(meta);
this.setup_fields();
this.setup_remove_fields();
this.add_new_fields();
this.show_dialog();
});
}
make() {
let me = this;
let list_view_settings = frappe.get_meta("List View Settings");
me.dialog = new frappe.ui.Dialog({
title: __("{0} Settings", [__(me.doctype)]),
fields: list_view_settings.fields
});
me.dialog.set_values(me.settings);
me.dialog.set_primary_action(__('Save'), () => {
let values = me.dialog.get_values();
frappe.show_alert({
message: __("Saving"),
indicator: "green"
});
frappe.call({
method: "frappe.desk.doctype.list_view_settings.list_view_settings.save_listview_settings",
args: {
doctype: me.doctype,
listview_settings: values,
removed_listview_fields: me.removed_fields || []
},
callback: function (r) {
me.listview.refresh_columns(r.message.meta, r.message.listview_settings);
me.dialog.hide();
}
});
});
me.dialog.fields_dict["total_fields"].df.onchange = () => me.refresh();
}
refresh() {
let me = this;
me.setup_fields();
me.add_new_fields();
me.setup_remove_fields();
}
show_dialog() {
let me = this;
if (!this.settings.fields) {
me.update_fields();
}
if (!me.dialog.get_value("total_fields")) {
let field_count = me.fields.length;
if (field_count < 4) {
field_count = 4;
} else if (field_count > 10) {
field_count = 4;
}
me.dialog.set_value("total_fields", field_count);
}
me.dialog.show();
}
setup_fields() {
function is_status_field(field) {
return field.fieldname === "status_field";
}
let me = this;
let fields_html = me.dialog.get_field("fields_html");
let wrapper = fields_html.$wrapper[0];
let fields = ``;
let total_fields = me.dialog.get_values().total_fields || me.settings.total_fields;
for (let idx in me.fields) {
if (idx == parseInt(total_fields)) {
break;
}
let is_sortable = (idx == 0) ? `` : `sortable`;
let show_sortable_handle = (idx == 0) ? `hide` : ``;
let can_remove = (idx == 0 || is_status_field(me.fields[idx])) ? `hide` : ``;
fields += `
<div class="control-input flex align-center form-control fields_order ${is_sortable}"
style="display: block; margin-bottom: 5px;" data-fieldname="${me.fields[idx].fieldname}"
data-label="${me.fields[idx].label}" data-type="${me.fields[idx].type}">
<div class="row">
<div class="col-md-1">
<i class="fa fa-bars text-muted sortable-handle ${show_sortable_handle}" aria-hidden="true"></i>
</div>
<div class="col-md-10" style="padding-left:0px;">
${me.fields[idx].label}
</div>
<div class="col-md-1 ${can_remove}">
<a class="text-muted remove-field" data-fieldname="${me.fields[idx].fieldname}">
<i class="fa fa-trash-o" aria-hidden="true"></i>
</a>
</div>
</div>
</div>`;
}
fields_html.html(`
<div class="form-group">
<div class="clearfix">
<label class="control-label" style="padding-right: 0px;">Fields</label>
</div>
<div class="control-input-wrapper">
${fields}
</div>
<p class="help-box small text-muted hidden-xs">
<a class="add-new-fields text-muted">
+ Add / Remove Fields
</a>
</p>
</div>
`);
new Sortable(wrapper.getElementsByClassName("control-input-wrapper")[0], {
handle: '.sortable-handle',
draggable: '.sortable',
onUpdate: () => {
me.update_fields();
me.refresh();
}
});
}
add_new_fields() {
let me = this;
let fields_html = me.dialog.get_field("fields_html");
let add_new_fields = fields_html.$wrapper[0].getElementsByClassName("add-new-fields")[0];
add_new_fields.onclick = () => me.column_selector();
}
setup_remove_fields() {
let me = this;
let fields_html = me.dialog.get_field("fields_html");
let remove_fields = fields_html.$wrapper[0].getElementsByClassName("remove-field");
for (let idx = 0; idx < remove_fields.length; idx++) {
remove_fields.item(idx).onclick = () => me.remove_fields(remove_fields.item(idx).getAttribute("data-fieldname"));
}
}
remove_fields(fieldname) {
let me = this;
let existing_fields = me.fields.map(f => f.fieldname);
for (let idx in me.fields) {
let field = me.fields[idx];
if (field.fieldname == fieldname) {
me.fields.splice(idx, 1);
break;
}
}
me.set_removed_fields(me.get_removed_listview_fields(me.fields.map(f => f.fieldname), existing_fields));
me.refresh();
me.update_fields();
}
update_fields() {
let me = this;
let fields_html = me.dialog.get_field("fields_html");
let wrapper = fields_html.$wrapper[0];
let fields_order = wrapper.getElementsByClassName("fields_order");
me.fields = [];
for (let idx = 0; idx < fields_order.length; idx++) {
me.fields.push({
fieldname: fields_order.item(idx).getAttribute("data-fieldname"),
label: fields_order.item(idx).getAttribute("data-label")
});
}
me.dialog.set_value("fields", JSON.stringify(me.fields));
me.dialog.get_value("fields");
}
column_selector() {
let me = this;
let d = new frappe.ui.Dialog({
title: __("{0} Fields", [__(me.doctype)]),
fields: [
{
label: __("Reset Fields"),
fieldtype: "Button",
fieldname: "reset_fields",
click: () => me.reset_listview_fields(d)
},
{
label: __("Select Fields"),
fieldtype: "MultiCheck",
fieldname: "fields",
options: me.get_doctype_fields(me.meta, me.fields.map(f => f.fieldname)),
columns: 2
}
]
});
d.set_primary_action(__('Save'), () => {
let values = d.get_values().fields;
me.set_removed_fields(me.get_removed_listview_fields(values, me.fields.map(f => f.fieldname)));
me.fields = [];
me.set_subject_field(me.meta);
me.set_status_field();
for (let idx in values) {
let value = values[idx];
if (me.fields.length === parseInt(me.dialog.get_values().total_fields)) {
break;
} else if (value != me.subject_field.fieldname) {
let field = frappe.meta.get_docfield(me.doctype, value);
if (field) {
me.fields.push({
label: field.label,
fieldname: field.fieldname
});
}
}
}
me.refresh();
me.dialog.set_value("fields", JSON.stringify(me.fields));
d.hide();
});
d.show();
}
reset_listview_fields(dialog) {
let me = this;
frappe.xcall("frappe.desk.doctype.list_view_settings.list_view_settings.get_default_listview_fields", {
doctype: me.doctype
}).then((fields) => {
let field = dialog.get_field("fields");
field.df.options = me.get_doctype_fields(me.meta, fields);
dialog.refresh();
});
}
get_listview_fields(meta) {
let me = this;
if (!me.settings.fields) {
me.set_list_view_fields(meta);
} else {
me.fields = JSON.parse(this.settings.fields);
}
me.fields.uniqBy(f => f.fieldname);
}
set_list_view_fields(meta) {
let me = this;
me.set_subject_field(meta);
me.set_status_field();
meta.fields.forEach(field => {
if (field.in_list_view && !in_list(frappe.model.no_value_type, field.fieldtype) &&
me.subject_field.fieldname != field.fieldname) {
me.fields.push({
label: field.label,
fieldname: field.fieldname
});
}
});
}
set_subject_field(meta) {
let me = this;
me.subject_field = {
label: "Name",
fieldname: "name"
};
if (meta.title_field) {
let field = frappe.meta.get_docfield(me.doctype, meta.title_field.trim());
me.subject_field = {
label: field.label,
fieldname: field.fieldname
};
}
me.fields.push(me.subject_field);
}
set_status_field() {
let me = this;
if (frappe.has_indicator(me.doctype)) {
me.fields.push({
type: "Status",
label: "Status",
fieldname: "status_field"
});
}
}
get_doctype_fields(meta, fields) {
let multiselect_fields = [];
meta.fields.forEach(field => {
if (!in_list(frappe.model.no_value_type, field.fieldtype)) {
multiselect_fields.push({
label: field.label,
value: field.fieldname,
checked: in_list(fields, field.fieldname)
});
}
});
return multiselect_fields;
}
get_removed_listview_fields(new_fields, existing_fields) {
let me = this;
let removed_fields = [];
if (frappe.has_indicator(me.doctype)) {
new_fields.push("status_field");
}
existing_fields.forEach(column => {
if (!in_list(new_fields, column)) {
removed_fields.push(column);
}
});
return removed_fields;
}
set_removed_fields(fields) {
let me = this;
if (me.removed_fields) {
me.removed_fields.concat(fields);
} else {
me.removed_fields = fields;
}
}
}

View file

@ -1,4 +1,5 @@
import BulkOperations from "./bulk_operations";
import ListSettings from "./list_settings";
frappe.provide('frappe.views');
@ -231,6 +232,20 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
}
refresh_columns(meta, list_view_settings) {
this.meta = meta;
this.list_view_settings = list_view_settings;
this.setup_columns();
this.refresh(true);
}
refresh(refresh_header=false) {
super.refresh().then(() => {
this.render_header(refresh_header);
});
}
setup_freeze_area() {
this.$freeze =
$(`<div class="freeze flex justify-center align-center text-muted">${__('Loading')}...</div>`)
@ -287,19 +302,49 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
}))
);
// limit max to 8 columns
if (this.list_view_settings.fields) {
this.columns = this.reorder_listview_fields();
}
// limit max to 8 columns if no total_fields is set in List View Settings
// Screen with low density no of columns 4
// Screen with medium density no of columns 6
// Screen with high density no of columns 8
let column_count = 6;
let total_fields = 6;
if (window.innerWidth <= 1200) {
column_count = 4;
} else if (window.innerWidth > 1440) {
column_count = 8;
if (window.innerWidth <= 1366) {
total_fields = 4;
} else if (window.innerWidth >= 1920) {
total_fields = 8;
}
this.columns = this.columns.slice(0, column_count);
this.columns = this.columns.slice(0, this.list_view_settings.total_fields || total_fields);
}
reorder_listview_fields() {
let fields_order = [];
let fields = JSON.parse(this.list_view_settings.fields);
//title_field is fixed
fields_order.push(this.columns[0]);
this.columns.splice(0, 1);
for (let fld in fields) {
for (let col in this.columns) {
let field = fields[fld];
let column = this.columns[col];
if (column.type == "Status" && field.fieldname == "status_field") {
fields_order.push(column);
break;
} else if (column.type == "Field" && field.fieldname === column.df.fieldname) {
fields_order.push(column);
break;
}
}
}
return fields_order;
}
get_documentation_link() {
@ -386,7 +431,11 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
}
}
render_header() {
render_header(refresh_header=false) {
if (refresh_header) {
this.$result.find('.list-row-head').remove();
}
if (this.$result.find('.list-row-head').length === 0) {
// append header once
this.$result.prepend(this.get_header_html());
@ -1284,18 +1333,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
}
show_list_settings() {
frappe.model.with_doctype("List View Setting", () => {
let d = new frappe.ui.Dialog({
title: __("Settings"),
fields: frappe.get_meta("List View Setting").fields
});
d.set_values(this.list_view_settings);
d.show();
d.set_primary_action(__('Save'), () => {
let values = d.get_values();
frappe.call("frappe.desk.listview.set_list_settings", {doctype: this.doctype, values: values});
Object.assign(this.list_view_settings, values);
d.hide();
frappe.model.with_doctype(this.doctype, () => {
new ListSettings({
listview: this,
doctype: this.doctype,
settings: this.list_view_settings,
meta: frappe.get_meta(this.doctype)
});
});
}

View file

@ -287,7 +287,8 @@ frappe.socketio.SocketIOUploader = class SocketIOUploader {
}
function fallback_required() {
return !frappe.boot.sysdefaults.use_socketio_to_upload_file || !frappe.socketio.socket.connected;
return !frappe.socketio.socket.connected
|| !( !frappe.boot.sysdefaults || frappe.boot.sysdefaults.use_socketio_to_upload_file );
}
if (fallback_required()) {

View file

@ -42,6 +42,8 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
this.body = this.$body.get(0);
this.$message = $('<div class="hide modal-message"></div>').appendTo(this.modal_body);
this.header = this.$wrapper.find(".modal-header");
this.buttons = this.header.find('.buttons');
this.set_indicator();
// make fields (if any)
super.make();
@ -164,6 +166,11 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
set_title(t) {
this.$wrapper.find(".modal-title").html(t);
}
set_indicator() {
if (this.indicator) {
this.header.find('.indicator').removeClass().addClass('indicator ' + this.indicator);
}
}
show() {
// show it
if ( this.animate ) {

View file

@ -119,7 +119,14 @@ frappe.ui.FieldSelect = Class.extend({
// child tables
$.each(me.table_fields, function(i, table_df) {
if(table_df.options) {
var child_table_fields = [].concat(frappe.meta.docfield_list[table_df.options]);
let child_table_fields = [].concat(frappe.meta.docfield_list[table_df.options]);
if (table_df.fieldtype === "Table MultiSelect") {
const link_field = frappe.meta.get_docfields(table_df.options)
.find(df => df.fieldtype === 'Link');
child_table_fields = link_field ? [link_field] : [];
}
$.each(frappe.utils.sort(child_table_fields, "label", "string"), function(i, df) {
// show fields where user has read access and if report hide flag is not set
if(frappe.perm.has_perm(me.doctype, df.permlevel, "read"))
@ -130,15 +137,22 @@ frappe.ui.FieldSelect = Class.extend({
},
add_field_option(df) {
if (df.fieldname == 'docstatus' && !frappe.model.is_submittable(this.doctype))
let me = this;
if (df.fieldname == 'docstatus' && !frappe.model.is_submittable(me.doctype))
return;
var me = this;
var label, table;
if (frappe.model.table_fields.includes(df.fieldtype)) {
me.table_fields.push(df);
return;
}
let label = null;
let table = null;
if(me.doctype && df.parent==me.doctype) {
label = __(df.label);
table = me.doctype;
if(frappe.model.table_fields.includes(df.fieldtype)) me.table_fields.push(df);
} else {
label = __(df.label) + ' (' + __(df.parent) + ')';
table = df.parent;

View file

@ -6,6 +6,12 @@ frappe.ui.Filter = class {
}
this.utils = frappe.ui.filter_utils;
this.set_conditions();
this.set_conditions_from_config();
this.make();
}
set_conditions() {
this.conditions = [
["=", __("Equals")],
["!=", __("Not Equals")],
@ -19,8 +25,7 @@ frappe.ui.Filter = class {
[">=", ">="],
["<=", "<="],
["Between", __("Between")],
["Previous", __("Previous")],
["Next", __("Next")]
["Timespan", __("Timespan")],
];
this.nested_set_conditions = [
@ -35,17 +40,28 @@ frappe.ui.Filter = class {
this.invalid_condition_map = {
Date: ['like', 'not like'],
Datetime: ['like', 'not like'],
Data: ['Between', 'Previous', 'Next'],
Select: ['like', 'not like', 'Between', 'Previous', 'Next'],
Link: ["Between", 'Previous', 'Next', '>', '<', '>=', '<='],
Currency: ["Between", 'Previous', 'Next'],
Color: ["Between", 'Previous', 'Next'],
Data: ['Between', 'Timespan'],
Select: ['like', 'not like', 'Between', 'Timespan'],
Link: ["Between", 'Timespan', '>', '<', '>=', '<='],
Currency: ["Between", 'Timespan'],
Color: ["Between", 'Timespan'],
Check: this.conditions.map(c => c[0]).filter(c => c !== '=')
};
this.make();
this.make_select();
this.set_events();
this.setup();
}
set_conditions_from_config() {
if (frappe.boot.additional_filters_config) {
this.filters_config = frappe.boot.additional_filters_config;
for (let key of Object.keys(this.filters_config)) {
const filter = this.filters_config[key];
this.conditions.push([key, __(`{0}`, [filter.label])]);
for (let fieldtype of Object.keys(this.invalid_condition_map)) {
if (!filter.valid_for_fieldtypes.includes(fieldtype)) {
this.invalid_condition_map[fieldtype].push(filter.label);
}
}
}
}
}
make() {
@ -53,6 +69,10 @@ frappe.ui.Filter = class {
conditions: this.conditions
}))
.appendTo(this.parent.find('.filter-edit-area'));
this.make_select();
this.set_events();
this.setup();
}
make_select() {
@ -203,33 +223,23 @@ frappe.ui.Filter = class {
this.fieldselect.selected_doctype = doctype;
this.fieldselect.selected_fieldname = fieldname;
if(["Previous", "Next"].includes(condition) && ['Date', 'Datetime', 'DateRange', 'Select'].includes(this.field.df.fieldtype)) {
df.fieldtype = 'Select';
df.options = [
{
label: __('1 week'),
value: '1 week'
},
{
label: __('1 month'),
value: '1 month'
},
{
label: __('3 months'),
value: '3 months'
},
{
label: __('6 months'),
value: '6 months'
},
{
label: __('1 year'),
value: '1 year'
}
];
if (this.filters_config && this.filters_config[condition]
&& this.filters_config[condition].valid_for_fieldtypes.includes(df.fieldtype)) {
let args = {};
if (this.filters_config[condition].depends_on) {
const field_name = this.filters_config[condition].depends_on;
const filter_value = this.base_list.get_filter_value(field_name);
args[field_name] = filter_value;
}
frappe.xcall(this.filters_config[condition].get_field, args).then(field => {
df.fieldtype = field.fieldtype;
df.options = field.options;
df.fieldname = fieldname;
this.make_field(df, cur.fieldtype);
});
} else {
this.make_field(df, cur.fieldtype);
}
this.make_field(df, cur.fieldtype);
}
make_field(df, old_fieldtype) {
@ -440,6 +450,10 @@ frappe.ui.filter_utils = {
if(condition == "Between" && (df.fieldtype == 'Date' || df.fieldtype == 'Datetime')){
df.fieldtype = 'DateRange';
}
if (condition == 'Timespan' && ['Date', 'Datetime', 'DateRange', 'Select'].includes(df.fieldtype)) {
df.fieldtype = 'Select';
df.options = this.get_timespan_options(['Last', 'Today', 'This', 'Next']);
}
if (condition === 'is') {
df.fieldtype = 'Select';
df.options = [
@ -447,5 +461,32 @@ frappe.ui.filter_utils = {
{ label: __('Not Set'), value: 'not set' },
];
}
return;
},
get_timespan_options(periods) {
const period_map = {
'Last': ['Week', 'Month', 'Quarter', '6 months', 'Year'],
'Today': null,
'This': ['Week', 'Month', 'Quarter', 'Year'],
'Next': ['Week', 'Month', 'Quarter', '6 months', 'Year']
};
let options = [];
periods.forEach(period => {
if (period_map[period]) {
period_map[period].forEach(p => {
options.push({
label: __(`{0} {1}`, [period, p]),
value: `${period.toLowerCase()} ${p.toLowerCase()}`,
});
});
} else {
options.push({
label: __(`{0}`, [period]),
value: `${period.toLowerCase()}`,
});
}
});
return options;
}
};

View file

@ -103,7 +103,8 @@ frappe.ui.FilterGroup = class {
},
filter_items: (doctype, fieldname) => {
return !this.filter_exists([doctype, fieldname]);
}
},
base_list: this.base_list
};
let filter = new frappe.ui.Filter(args);
this.filters.push(filter);
@ -132,7 +133,6 @@ frappe.ui.FilterGroup = class {
get_filters() {
return this.filters.filter(f => f.field).map(f => {
f.freeze();
return f.get_value();
});
// {}: this.list.update_standard_filters(values);

View file

@ -202,8 +202,8 @@ frappe.ui.FilterList = Class.extend({
value = {0:"No", 1:"Yes"}[cint(value)];
} else if (field.df.original_type === "Duration") {
let duration_options = {
show_days: field.df.show_days,
show_seconds: field.df.show_seconds
hide_days: field.df.hide_days,
hide_seconds: field.df.hide_seconds
};
value = frappe.utils.get_formatted_duration(value, duration_options);
}

View file

@ -53,6 +53,33 @@ frappe.confirm = function(message, ifyes, ifno) {
return d;
}
frappe.warn = function(title, message_html, proceed_action, primary_label) {
const d = new frappe.ui.Dialog({
title: title,
indicator: 'red',
fields: [
{
fieldtype: 'HTML',
fieldname: 'warning_message',
options: `<div class="frappe-warning-message">${message_html}</div>`
}
],
primary_action_label: primary_label,
primary_action: () => {
if (proceed_action) proceed_action();
d.hide();
},
secondary_action_label: __("Cancel"),
});
d.buttons.find('.btn-primary').removeClass('btn-primary').addClass('btn-danger');
const modal_footer = $(`<div class="modal-footer"></div>`).insertAfter($(d.modal_body));
modal_footer.html(d.buttons);
d.show();
return d;
};
frappe.prompt = function(fields, callback, title, primary_label) {
if (typeof fields === "string") {
fields = [{

View file

@ -153,12 +153,12 @@ frappe.ui.Notifications = class Notifications {
let title = target ? `title="${__('Your Target')}"` : '';
let $list_item = !target
? $(`<li><a class="badge-hover" data-action="route_to_document_type" data-doctype="${name}" ${title}>
${label}
${__(label)}
<span class="badge pull-right">${value}</span>
</a></li>`)
: $(`<li><a class="progress-small" data-action="route_to_document_type" ${title}
data-doctype="${doc_dt}" data-docname="${name}">
<span class="dropdown-item-label">${label}<span>
<span class="dropdown-item-label">${__(label)}<span>
<div class="progress-chart">
<div class="progress">
<div class="progress-bar" style="width: ${value}%"></div>
@ -304,10 +304,7 @@ frappe.ui.Notifications = class Notifications {
}
get_dropdown_item_html(field) {
let doc_link = frappe.utils.get_form_link(
field.document_type,
field.document_name
);
let doc_link = this.get_item_link(field);
let read_class = field.read ? '' : 'unread';
let mark_read_action = field.read ? '': 'data-action="mark_as_read"';
let message = field.subject;
@ -336,6 +333,17 @@ frappe.ui.Notifications = class Notifications {
return item_html;
}
get_item_link(notification_doc) {
const link_doctype =
notification_doc.type == 'Alert' ? 'Notification Log': notification_doc.document_type;
const link_docname =
notification_doc.type == 'Alert' ? notification_doc.name: notification_doc.document_name;
return frappe.utils.get_form_link(
link_doctype,
link_docname
);
}
render_dropdown_headers() {
this.categories = [
{

View file

@ -292,6 +292,25 @@ Object.assign(frappe.utils, {
return frappe.utils.guess_style(text, null, true);
},
get_indicator_color: function(state) {
return frappe.db.get_list('Workflow State', {filters: {name: state}, fields: ['name', 'style']}).then(res => {
const state = res[0];
if (!state.style) {
return frappe.utils.guess_colour(state.name);
}
const style = state.style;
const colour_map = {
"Success": "green",
"Warning": "orange",
"Danger": "red",
"Primary": "blue",
};
return colour_map[style];
});
},
sort: function(list, key, compare_type, reverse) {
if(!list || list.length < 2)
return list || [];
@ -837,7 +856,7 @@ Object.assign(frappe.utils, {
minutes: Math.floor(secs % 3600 / 60),
seconds: Math.floor(secs % 60)
};
if (!duration_options.show_days) {
if (duration_options.hide_days) {
total_duration.hours = Math.floor(secs / 3600);
total_duration.days = 0;
}
@ -863,8 +882,8 @@ Object.assign(frappe.utils, {
get_duration_options: function(docfield) {
let duration_options = {
show_days: docfield.show_days,
show_seconds: docfield.show_seconds
hide_days: docfield.hide_days,
hide_seconds: docfield.hide_seconds
};
return duration_options;
}

View file

@ -1,38 +0,0 @@
frappe.ui.form.on("Web Page Block", {
edit_values(frm, cdt, cdn) {
let row = frm.selected_doc;
frappe.model.with_doc("Web Template", row.web_template).then((doc) => {
let d = new frappe.ui.Dialog({
title: __("Edit Values"),
fields: doc.fields.map((df) => {
if (df.fieldtype == "Section Break") {
df.collapsible = 1;
}
return df;
}),
primary_action(values) {
frappe.model.set_value(
cdt,
cdn,
"web_template_values",
JSON.stringify(values)
);
d.hide();
},
});
let values = JSON.parse(row.web_template_values || "{}");
d.set_values(values);
d.show();
d.sections.forEach((sect) => {
let fields_with_value = sect.fields_list.filter(
(field) => values[field.df.fieldname]
);
if (fields_with_value.length) {
sect.collapse(false);
}
});
});
},
});

View file

@ -26,14 +26,25 @@ export default class Desktop {
}
make_container() {
this.container = $(`<div class="desk-container row">
this.container = $(`
<div class="desk-container row">
<div class="desk-sidebar"></div>
<div class="desk-body"></div>
<div class="desk-body">
<div class="page-switcher">
<div class="current-title"></div>
<i class="fa fa-chevron-down text-muted"></i>
</div>
<div class="mobile-list">
</div>
</div>
</div>`);
this.container.appendTo(this.wrapper);
this.sidebar = this.container.find(".desk-sidebar");
this.body = this.container.find(".desk-body");
this.current_title = this.container.find(".current-title");
this.mobile_list = this.container.find(".mobile-list");
this.page_switcher = this.container.find(".page-switcher");
}
fetch_desktop_settings() {
@ -73,7 +84,9 @@ export default class Desktop {
this.current_page = item.name;
}
let $item = get_sidebar_item(item);
$item.appendTo(this.sidebar);
$item.appendTo(this.mobile_list);
$item.clone().appendTo(this.sidebar);
this.sidebar_items[item.name] = $item;
};
@ -84,6 +97,7 @@ export default class Desktop {
`<div class="sidebar-group-title h6 uppercase">${__(name)}</div>`
);
$title.appendTo(this.sidebar);
$title.clone().appendTo(this.mobile_list);
};
this.sidebar_categories.forEach(category => {
@ -94,6 +108,11 @@ export default class Desktop {
});
}
});
if (frappe.is_mobile) {
this.page_switcher.on('click', () => {
this.mobile_list.toggle();
});
}
}
show_page(page) {
@ -106,6 +125,8 @@ export default class Desktop {
this.sidebar_items[page].addClass("selected");
}
this.current_page = page;
this.mobile_list.hide();
this.current_title.empty().append(this.current_page);
localStorage.current_desk_page = page;
this.pages[page] ? this.pages[page].show() : this.make_page(page);
}

View file

@ -10,6 +10,10 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
return 'Report';
}
render_header() {
// Override List View Header
}
setup_defaults() {
super.setup_defaults();
this.page_title = __('Report:') + ' ' + this.page_title;

View file

@ -95,6 +95,11 @@ frappe.ready(function() {
};
df.fields = form_data[df.fieldname];
$.each(df.fields || [], function(_i, field) {
if (field.fieldtype === "Link") {
field.only_select = true;
}
});
if (df.fieldtype === "Attach") {
df.is_private = true;

View file

@ -293,6 +293,10 @@ export default class OnboardingWidget extends Widget {
});
};
} else {
frappe.msgprint({
message: __("You may continue with onboarding"),
title: __("Looks Great")
});
this.mark_complete(step);
}
},

View file

@ -128,7 +128,7 @@ function go_to_list_with_filters(doctype, filters) {
}
function shorten_number(number, country) {
country = country || '';
country = (country == 'India') ? country : '';
const number_system = get_number_system(country);
let x = Math.abs(Math.round(number));
for (const map of number_system) {

View file

@ -3,6 +3,40 @@
.desk-container {
margin-top: 20px;
.page-switcher {
border-radius: 5px;
display: none;
border: 1px solid @border-color;
background-color: @panel-bg;
padding: 8px 15px;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.mobile-list {
display: none;
border-radius: 5px;
padding: 8px 15px;
border: 1px solid @border-color;
.sidebar-item {
font-size: 12px;
font-weight: bold;
margin-bottom: 1px;
display: flex;
padding: 10px 15px;
border-radius: 4px;
text-decoration: none;
cursor: pointer;
text-rendering: optimizelegibility;
&.selected {
background-color: @panel-bg;
}
}
}
.desk-sidebar {
width: 20rem;
display: block;
@ -103,6 +137,9 @@
.desk-body {
padding-left: 15px !important;
}
.page-switcher {
display: flex;
}
}
}
@ -390,6 +427,10 @@
margin-top: 20px;
padding-right: 200px;
@media (max-width: 970px) {
padding-right: 0;
}
&.grid {
display: grid;
grid-template-columns: 1fr 1fr;
@ -410,6 +451,17 @@
&.grid-rows-5 {
grid-template-rows: repeat(5, 1fr);
}
@media (max-width: 768px) {
grid-template-columns: 1fr;
&.grid-rows-2,
&.grid-rows-3,
&.grid-rows-4,
&.grid-rows-5 {
grid-template-columns: 1fr;
grid-auto-flow: row;
}
}
}
.onboarding-step {

View file

@ -4,6 +4,7 @@ html {
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 16px;
color: $body-color;
}
@ -18,6 +19,7 @@ h1 {
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.025em;
margin-bottom: 1rem;
@include media-breakpoint-up(sm) {
line-height: 2.5rem;
@ -32,6 +34,7 @@ h1 {
h2 {
font-size: $font-size-xl;
font-weight: bold;
margin-bottom: 0.75rem;
@include media-breakpoint-up(sm) {
font-size: $font-size-2xl;

View file

@ -0,0 +1,94 @@
.blog-list {
display: flex;
flex-wrap: wrap;
margin-right: -15px;
margin-left: -15px;
&.result {
border-bottom: none;
}
}
.blog-card {
margin-bottom: 2rem;
position: relative;
width: 100%;
.card-body {
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card-img-top {
width: 100%;
overflow: hidden;
height: 12rem;
img {
width: 100%;
min-height: 100%;
}
.default-cover {
height: 100%;
width: 100%;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
background: $gray-200;
font-size: 1.2rem;
font-weight: 500;
color: $gray-600;
}
}
.blog-card-footer {
display: flex;
align-items: center;
margin-top: 0.5rem;
.avatar {
margin-right: 0.5rem;
border-radius: 50%;
}
}
}
.blog-container {
font-size: 1rem;
max-width: 800px;
margin: 0px auto;
.blog-title {
margin-top: 1rem;
@include media-breakpoint-up(xl) {
line-height: 1;
font-size: $font-size-4xl;
}
}
.blog-footer {
display: flex;
justify-content: space-between;
color: $text-muted;
margin-top: 3rem;
}
.blog-intro {
font-size: 1.125rem;
font-weight: 400;
}
.blog-content {
margin-bottom: 1rem;
.blog-header {
margin-bottom: 3rem;
margin-top: 3rem;
}
}
}

278
frappe/public/scss/doc.scss Normal file
View file

@ -0,0 +1,278 @@
$navbar-height: 7.625rem;
$navbar-height-lg: 4.5rem;
.doc-layout {
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: $navbar-height;
// border-bottom: 1px solid $gray-200;
@include media-breakpoint-up(lg) {
padding-top: $navbar-height-lg;
}
}
.sidebar-column {
display: none;
@include media-breakpoint-up(lg) {
display: block;
}
}
.doc-container {
max-width: 1280px;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.navbar-expand-lg .doc-container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.doc-navbar {
background-color: white;
padding-left: 0;
padding-right: 0;
.navbar-toggler {
margin-left: 0.75rem;
}
.web-sidebar {
display: block;
border-top: 1px solid $gray-200;
@include media-breakpoint-up(lg) {
display: none;
}
}
.navbar-collapse {
height: calc(100vh - #{$navbar-height-lg});
overflow: auto;
@include media-breakpoint-up(lg) {
height: auto;
overflow: initial;
}
}
.navbar-nav {
margin-left: -1rem;
margin-top: 0.75rem;
margin-bottom: 1.5rem;
@include media-breakpoint-up(lg) {
margin-top: 0;
margin-bottom: 0;
}
}
}
.doc-search-container {
display: flex;
margin-top: 0.75rem;
@include media-breakpoint-up(lg) {
margin-top: 0;
}
}
.doc-search {
position: relative;
width: 100%;
@include media-breakpoint-up(lg) {
padding-left: 4rem;
padding-right: 4rem;
}
.search-icon {
position: absolute;
left: 0;
top: 0;
width: 2.5rem;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
svg {
color: $gray-600;
}
input {
padding-left: 2.5rem;
}
.dropdown-menu {
.dropdown-item {
padding: 1rem 0.75rem;
}
.match {
background-color: $primary-light;
color: $primary;
font-weight: 500;
padding: 0 0.125rem;
}
}
}
.doc-sidebar {
position: sticky;
top: $navbar-height;
padding-bottom: 4rem;
height: 100vh;
overflow: hidden;
.web-sidebar {
height: 100%;
overflow: auto;
padding-top: 3rem;
padding-bottom: 4rem;
}
@include media-breakpoint-up(lg) {
top: $navbar-height-lg;
}
}
.doc-main .page-content-wrapper {
padding: 0 0 2rem 0;
@include media-breakpoint-up(lg) {
padding: 0rem 4rem 4rem 4rem;
}
}
.doc-sidebar-logo {
padding-top: 2.5rem;
padding-bottom: 2rem;
}
.page-toc {
font-size: $font-size-sm;
h5 {
font-size: $font-size-sm;
margin-bottom: 0.5rem;
color: $gray-500;
}
> div {
padding-top: 3rem;
padding-bottom: 4rem;
position: sticky;
top: $navbar-height;
@include media-breakpoint-up(lg) {
top: $navbar-height-lg;
}
}
ul {
padding-left: 0;
list-style-type: none;
}
li > ul {
padding-left: 0.5rem;
}
a {
display: block;
padding: 0.25rem 0;
color: $gray-600;
text-decoration: none;
font-weight: 500;
@include transition();
&:hover {
color: $gray-800;
}
}
}
// typography styles for documentation content
.doc-content .from-markdown {
> :first-child {
margin-top: 3rem;
}
h1 {
font-size: $font-size-3xl;
font-weight: 500;
}
h1 + p {
font-size: $font-size-lg;
}
h2 {
font-size: $font-size-2xl;
font-weight: 400;
}
h3 {
font-size: $font-size-xl;
font-weight: 500;
}
h1,
h2,
h3,
h4,
h5,
h6 {
&::before {
height: 6rem;
margin-top: -6rem;
content: '';
display: block;
visibility: hidden;
}
}
h4 {
font-size: $font-size-lg;
font-weight: 500;
}
strong {
font-weight: 600;
}
table {
border-color: $gray-200;
}
table thead {
background-color: $light;
}
.table-bordered,
.table-bordered th,
.table-bordered td {
border-left: none;
border-right: none;
border-color: $gray-200;
}
.table-bordered thead th,
.table-bordered thead td {
border-bottom-width: 1px;
}
}
// next links
.btn-next-wrapper {
border-top: 1px solid $gray-200;
margin-top: 2rem;
padding-top: 1rem;
text-align: right;
}

View file

@ -1,4 +1,5 @@
.from-markdown {
color: $gray-700;
line-height: 1.625;
> * + * {
@ -32,12 +33,11 @@
}
> blockquote {
padding: 0.75rem 1rem;
padding: 1.25rem 1rem;
font-size: $font-size-sm;
font-weight: 500;
color: $gray-900;
border-left: 4px solid $yellow;
background-color: lighten($yellow, 42%);
border: 1px solid $gray-200;
border-left: 3px solid $yellow;
border-top-left-radius: 0.1rem;
border-bottom-left-radius: 0.1rem;
border-top-right-radius: 0.375rem;
@ -49,11 +49,17 @@
margin-bottom: 0;
}
b, strong {
color: $gray-800;
}
h1, h2, h3, h4, h5, h6 {
color: $gray-900;
}
h1 + p {
max-width: 42rem;
margin-top: 0.75rem;
font-size: $font-size-base;
color: $gray-900;
@include media-breakpoint-up(sm) {
margin-top: 1.25rem;
@ -104,6 +110,7 @@
tr > td,
tr > th {
font-size: $font-size-sm;
padding: 0.5rem;
}
th:empty {
@ -114,11 +121,10 @@
border: 1px solid $gray-400;
border-radius: 0.375rem;
}
}
// apply margin on first h1 if container is full width without top margin
main:not(.my-5) .from-markdown {
h1:first-child {
margin-top: 5rem;
code:not(.hljs) {
padding: 0 0.25rem;
background: $light;
border-radius: 0.125rem;
}
}

View file

@ -1,13 +1,19 @@
.hero-subtitle {
@extend .lead;
font-weight: 400;
color: $gray-600;
max-width: 42rem;
font-size: 1rem;
@include media-breakpoint-up(sm) {
font-size: 1.25rem;
}
}
.section-description {
max-width: 56rem;
margin-top: 0.5rem;
font-size: $font-size-base;
color: $gray-900;
@include media-breakpoint-up(lg) {
font-size: $font-size-lg;
@ -88,16 +94,14 @@
}
.card {
.card-title {
color: $black;
}
.card-body {
color: $gray-900;
}
@include transition();
&:hover {
border-color: $gray-600;
border-color: $gray-500;
}
.card-title {
line-height: 1;
}
&.card-sm {
@ -156,12 +160,20 @@
}
.nav-tabs {
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
// 1 pixel bottom padding so that the 2px active border is visible
padding-bottom: 1px;
.nav-link {
color: $gray-700;
color: $gray-800;
font-weight: 500;
border: none;
padding: 1rem 0.5rem;
margin-right: 2rem;
white-space: nowrap;
@include transition();
&:hover {
color: $primary;
@ -171,7 +183,7 @@
.nav-link.active,
.nav-item.show .nav-link {
color: darken($primary, 5%);
background-color: #fff;
background-color: transparent;
border-bottom: 2px solid $primary;
}
}
@ -183,7 +195,7 @@
.section-cta {
padding: 3rem 2rem;
text-align: center;
background-color: lighten($primary, 42%);
background-color: $primary-light;
border-radius: 0.75rem;
@include media-breakpoint-up(sm) {
@ -210,7 +222,6 @@
margin: 0 auto;
margin-top: 0.5rem;
font-size: $font-size-base;
color: $gray-900;
@include media-breakpoint-up(md) {
font-size: $font-size-lg;
}
@ -220,7 +231,50 @@
margin: 0 auto;
margin-top: 0.5rem;
font-size: $font-size-xs;
}
}
.section-small-cta {
padding: 1.8rem;
background-color: lighten($primary, 42%);
border-radius: 0.75rem;
display: flex;
flex-direction: column;
text-align: center;
@include media-breakpoint-up(sm) {
flex-direction: column;
text-align: left;
}
@include media-breakpoint-up(md) {
flex-direction: row;
justify-content: space-between;
div {
align-self: center;
}
}
.title {
max-width: 36rem;
font-size: $font-size-xl;
font-weight: 800;
line-height: 1.25;
@include media-breakpoint-up(md) {
font-size: $font-size-2xl;
}
}
.subtitle {
max-width: 36rem;
font-size: $font-size-base;
color: $gray-900;
margin-bottom: 1.2rem;
@include media-breakpoint-up(md) {
font-size: $font-size-lg;
margin-bottom: 0px;
}
}
}
@ -266,19 +320,77 @@
margin-right: auto;
margin-top: 2rem;
max-width: 52rem;
font-size: $font-size-2xl;
font-size: $font-size-lg;
font-weight: 500;
@include media-breakpoint-up(lg) {
font-size: $font-size-2xl;
}
}
.testimonial-by {
font-size: $font-size-lg;
font-size: $font-size-base;
margin-top: 2rem;
&:before {
content: ''
}
@include media-breakpoint-up(lg) {
font-size: $font-size-lg;
}
}
.split-section-content {
margin-top: 2rem;
}
.section-image-grid {
display: flex;
flex-wrap: wrap;
width: 100%;
// Offset for padding
margin-right: -2px;
margin-left: -2px;
.image-container {
overflow: hidden;
border: 2px solid #fff;
border-radius: $border-radius;
width: 100%;
max-height: 8rem;
img {
width: 100%;
object-fit: cover;
}
@include media-breakpoint-up(sm) {
&.wide {
max-width: 75%;
width: 75%;
max-height: 15rem;
height: 15rem;
img {
width: 100%;
object-fit: cover;
}
}
&.narrow {
max-width: 25%;
width: 25%;
max-height: 15rem;
height: 15rem;
img {
height: 100%;
object-fit: cover;
}
}
}
}
}

View file

@ -6,13 +6,41 @@
.sidebar-item a {
display: block;
padding: 0.25rem 0;
padding: 0.25rem 0.5rem;
margin-top: 0.25rem;
border-radius: 0.375rem;
font-size: $font-size-sm;
color: $gray-700;
color: $gray-600;
text-decoration: none;
font-weight: 500;
@include transition();
&:hover {
color: $gray-900;
}
}
.sidebar-item a.active {
color: $primary;
background-color: $primary-light;
}
.sidebar-item-icon {
width: 24px;
height: 24px;
display: inline-block;
}
.sidebar-group {
margin-bottom: 1rem;
h6 {
font-size: $font-size-sm;
margin-bottom: 0.75rem;
}
> ul {
padding-left: 0.5rem;
margin-bottom: 2rem;
}
}

View file

@ -1,20 +1,23 @@
$gray-100: #fafbfc !default;
$gray-150: #f5f7fa !default;
$gray-200: #ebecf1 !default;
$gray-300: #d1d8dd !default;
$gray-400: #ced4da !default;
$gray-500: #adb5bd !default;
$gray-600: #8d99a6 !default;
$gray-700: #495057 !default;
$gray-800: #36414c !default;
$gray-900: #2e3338 !default;
$primary: #2490ef !default;
$gray-50: #F9FAFA !default;
$gray-100: #F4F5F6 !default;
$gray-200: #EEF0F2 !default;
$gray-300: #E2E6E9 !default;
$gray-400: #C8CFD5 !default;
$gray-500: #A6B1B9 !default;
$gray-600: #74808B !default;
$gray-700: #4C5A67 !default;
$gray-800: #313B44 !default;
$gray-900: #192734 !default;
$black: #000 !default;
$primary: #2490ef !default;
$primary-light: lighten($primary, 42%) !default;
$light: $gray-50 !default;
$body-color: $gray-800 !default;
$body-color: $gray-700 !default;
$text-muted: $gray-600 !default;
$border-color: $gray-300 !default;
$headings-color: $gray-900 !default;
$font-size-xs: 0.75rem !default;
$font-size-sm: 0.875rem !default;
@ -33,20 +36,32 @@ $btn-font-size-lg: 1.125rem !default;
$btn-line-height-lg: 1 !default;
$btn-border-radius-lg: 0.5rem !default;
$btn-border-radius: 0.375rem !default;
$btn-font-size: $font-size-sm;
$btn-font-size: $font-size-sm !default;
$btn-padding-x: 1rem !default;
$btn-padding-y: 0.5rem !default;
$btn-font-weight: 500 !default;
$navbar-nav-link-padding-x: 1rem !default;
$navbar-padding-y: 1rem;
$navbar-padding-y: 1rem !default;
$card-border-radius: 0.75rem !default;
$card-spacer-y: 1rem !default;
$card-spacer-y: 0.5rem !default;
$dropdown-font-size: $font-size-sm !default;
$dropdown-border-radius: 0.375rem !default;
$dropdown-item-padding-y: 0.5rem !default;
$dropdown-item-padding-x: 0.5rem !default;
$grid-breakpoints: (
xs: 0,
sm: 576px,
md: 768px,
lg: 992px,
xl: 1200px,
2xl: 1440px
) !default;
@import '~bootstrap/scss/functions';
@import '~bootstrap/scss/variables';
@import "~bootstrap/scss/mixins";
$code-color: $purple;

View file

@ -55,6 +55,12 @@ img:after {
width: 100%;
}
.website-image-extra-small {
@include website-image;
width: 2.5rem;
height: 2.5rem;
}
.website-image-small {
@include website-image;
width: 5rem;

View file

@ -5,8 +5,10 @@
@import 'multilevel-dropdown';
@import 'website-image';
@import 'page-builder';
@import 'blog';
@import 'markdown';
@import 'sidebar';
@import 'doc';
.container {
padding-left: 1.25rem;
@ -15,26 +17,26 @@
@include media-breakpoint-up(sm) {
.container {
padding-left: 1rem;
padding-right: 1rem;
}
}
@include media-breakpoint-up(md) {
.container {
padding-left: 1rem;
padding-right: 1rem;
padding-left: 0;
padding-right: 0;
}
}
@include media-breakpoint-up(lg) {
.container {
padding-left: 1rem;
padding-right: 1rem;
padding-left: 2.5rem;
padding-right: 2.5rem;
}
}
@include media-breakpoint-up(xl) {
.container {
padding-left: 5rem;
padding-right: 5rem;
}
}
@include media-breakpoint-up(2xl) {
.container {
padding-left: 1.5rem;
padding-right: 1.5rem;
@ -46,7 +48,7 @@
}
.navbar-light .navbar-nav .nav-link {
color: $gray-900;
color: $gray-700;
font-size: $font-size-sm;
font-weight: 500;
@ -145,11 +147,12 @@ a.card {
width: 5rem;
height: 2rem;
object-fit: contain;
object-position: left;
}
.footer-link, .footer-child-item a {
font-weight: 500;
color: $gray-900;
color: $gray-700;
&:hover {
color: $primary;
@ -158,8 +161,9 @@ a.card {
}
.footer-col-left, .footer-col-right {
padding-top: 1rem;
padding-top: 0.8rem;
padding-bottom: 1rem;
line-height: 2;
}
.footer-col-right {
@ -280,7 +284,6 @@ h5.modal-title {
}
.btn-primary-light {
$primary-light: lighten($primary, 42%);
@include button-variant(
$background: $primary-light,
$border: $primary-light,

187
frappe/templates/doc.html Normal file
View file

@ -0,0 +1,187 @@
{% extends "templates/base.html" %}
{%- from "templates/includes/navbar/navbar_items.html" import render_item -%}
{% macro page_content() %}
{%- block page_content -%}{%- endblock -%}
{% endmacro %}
{%- block head_include %}
<link rel="stylesheet" href="/assets/frappe/css/hljs-night-owl.css">
{% endblock -%}
{%- block navbar -%}
<nav class="navbar navbar-light navbar-expand-lg doc-navbar fixed-top">
<div class="container-fluid doc-container">
<div class="row no-gutters w-100">
<div class="col-12 col-lg-2">
<a class="navbar-brand" href="{{ url_prefix }}{{ home_page or "/" }}">
{%- if brand_html -%}
{{ brand_html }}
{%- elif banner_image -%}
<img src='{{ banner_image }}'>
{%- else -%}
<span>{{ (frappe.get_hooks("brand_html") or [_("Home")])[0] }}</span>
{%- endif -%}
</a>
</div>
<div class="col-12 col-lg-8">
<div class="doc-search-container">
<div class="doc-search">
<div class="dropdown">
<div class="search-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-search">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
<input type="search" class="form-control" placeholder="Search the docs (Press ? to focus)" />
<div class="overflow-hidden shadow dropdown-menu w-100">
</div>
</div>
</div>
<button class="navbar-toggler" type="button"
data-toggle="collapse"
data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="col-12 col-lg-2">
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav">
{%- set items = docs_navbar_items or [] -%}
{%- for item in items -%}
{{ render_item(item, parent=True) }}
{%- endfor -%}
</ul>
{% include "templates/includes/web_sidebar.html" %}
</div>
</div>
</div>
</div>
</nav>
{%- endblock -%}
{% block content %}
{% macro main_content() %}
<div class="page-content-wrapper">
{% block page_container %}
<main>
<div class="page_content page-content doc-content">
{{ page_content() }}
</div>
</main>
{% endblock %}
</div>
{% endmacro %}
{% macro container_attributes() -%}
id="page-{{ name or route | e }}" data-path="{{ pathname | e }}"
{%- if page_or_generator=="Generator" %}source-type="Generator" data-doctype="{{ doctype }}"{%- endif %}
{%- if source_content_type %}source-content-type="{{ source_content_type }}"{%- endif %}
{%- endmacro %}
<div class="container-fluid doc-layout doc-container">
<div class="row no-gutters" {{ container_attributes() }}>
<div class="sidebar-column col-sm-2">
<aside class="doc-sidebar">
{% block page_sidebar %}
{% include "templates/includes/web_sidebar.html" %}
{% endblock %}
</aside>
</div>
<div class="main-column doc-main col-12 col-lg-10 col-xl-8">
{{ main_content() }}
</div>
<div class="page-toc col-sm-2 d-none d-xl-block">
<div>
<h5>On this page</h5>
{{ page_toc_html }}
</div>
</div>
</div>
</div>
{% endblock %}
{%- block script -%}
<script>
frappe.ready(() => {
setup_search();
$('.web-footer .container')
.removeClass('container')
.addClass('container-fluid doc-container');
});
function setup_search() {
let $dropdown = $('.doc-search .dropdown');
let $dropdown_menu = $('.doc-search .dropdown-menu');
let $input = $('.doc-search input');
$(document).on('keypress', e => {
if (e.key === '/') {
e.preventDefault();
$input.focus();
}
});
$input.on('input', frappe.utils.debounce(() => {
if (!$input.val()) {
clear_dropdown();
return;
}
frappe.call({
method: 'frappe.modules.full_text_search.web_search',
args: {
index_name: 'web_routes',
scope: '{{ docs_search_scope or "" }}' || null,
query: $input.val(),
limit: 5
}
}).then(r => {
let results = r.message || [];
let dropdown_html;
if (results.length == 0) {
dropdown_html = `<div class="dropdown-item">No results found</div>`;
} else {
dropdown_html = results.map(r => {
return `<a class="dropdown-item" href="/${r.path}">
<h6>${r.title_highlights || r.title}</h6>
<div style="white-space: normal;">${r.content_highlights}</div>
</a>`
}).join('')
}
$dropdown_menu.html(dropdown_html);
$dropdown_menu.addClass('show');
});
}, 500));
$input.on('focus', () => {
if (!$input.val()) {
clear_dropdown();
}
});
$input.on('blur', () => {
setTimeout(() => {
clear_dropdown();
}, 300);
});
function clear_dropdown() {
$dropdown_menu.html('');
$dropdown_menu.removeClass('show');
}
}
</script>
{%- endblock -%}

View file

@ -1,19 +0,0 @@
{% extends "templates/web.html" %}
{% block title %}{{ blog_title or _("Blog") }}{% endblock %}
{% block header %}<h1>{{ blog_title or _("Blog") }}</h1>{% endblock %}
{% block hero %}{% endblock %}
{% block page_content %}
<!-- no-header -->
<!-- no-breadcrumbs -->
<div class="blog-list-content">
<div id="blog-list">
{% include "templates/includes/list/list.html" %}
</div>
</div>
{% endblock %}
{% block script %}
<script>{% include "templates/includes/list/list.js" %}</script>
{% endblock %}

View file

@ -1,7 +1,7 @@
{% from "frappe/templates/includes/macros.html" import square_image_with_fallback %}
<div class="media">
{{ square_image_with_fallback(src=blogger_info.avatar, size='72px', alt=blogger_info.full_name, class='align-self-start mr-3 rounded') }}
{{ square_image_with_fallback(src=blogger_info.avatar, size='small', alt=blogger_info.full_name, class='align-self-start mr-3 rounded') }}
<div class="media-body">
<h5 class="mt-0">
<a href="/blog?blogger={{ blogger_info.name }}" class="text-dark">{{ blogger_info.full_name }}</a>

View file

@ -1,4 +1,4 @@
{% if not no_breadcrumbs and parents %}
{%- if not no_breadcrumbs and parents -%}
<div class="container mt-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb" itemscope itemtype="http://schema.org/BreadcrumbList">
@ -17,4 +17,4 @@
</ol>
</nav>
</div>
{% endif %}
{%- endif -%}

View file

@ -1,7 +1,7 @@
{% from "frappe/templates/includes/macros.html" import square_image_with_fallback %}
<div class="comment-row media">
{{ square_image_with_fallback(src=frappe.get_gravatar(comment.comment_email or comment.sender), size='48px', alt=comment.sender_full_name, class='align-self-start mr-3') }}
{{ square_image_with_fallback(src=frappe.get_gravatar(comment.comment_email or comment.sender), size='extra-small', alt=comment.sender_full_name, class='align-self-start mr-3') }}
<div class="media-body">
<div class="d-flex justify-content-between align-items-start">
<span class="font-weight-bold text-muted">

View file

@ -1,18 +1,6 @@
{% macro square_image_with_fallback(src=None, size=None, alt=None, class="") %}
{% macro square_image_with_fallback(src=None, size='small', alt=None, class="") %}
{% if src %}
<img
{% if size %}
width="{{size}}"
height="{{size}}"
{% endif %}
{% if src %}
src="{{ src }}"
{% endif %}
class="{{ class }} "
alt="{{ alt or '' }}"
>
<img class="rounded-lg website-image-{{ size }} mr-2" src="{{ src }}">
{% else %}
<div class="no-image bg-light {{ class }} " {% if size %}style="width: {{size}}; height: {{size}};"{% endif %}></div>
{% endif %}

View file

@ -1,46 +1,82 @@
{% macro render_sidebar_item(item) %}
<li class="{{ 'sidebar-group' if item.group_title else 'sidebar-item' }}">
{%- if item.group_title -%}
<h6>{{ item.group_title }}</h6>
{{ render_sidebar_items(item.group_items) }}
{%- else -%}
{% if item.type != 'input' %}
{%- set item_route = item.route[1:] if item.route[0] == '/' else item.route -%}
<a href="{{ item.route }}" class="{{ 'active' if pathname == item_route else '' }}"
{% if item.target %}target="{{ item.target }}" {% endif %}>
{{ _(item.title or item.label) }}
</a>
{% else %}
<form action='{{ item.route }}' class="mr-3">
<input name='q' class='form-control' type='text' style="outline: none"
placeholder="{{ _(item.title or item.label) }}">
</form>
{% endif %}
{%- endif -%}
</li>
{% endmacro %}
{% macro render_sidebar_items(items) %}
{%- if items | len > 0 -%}
<ul class="list-unstyled">
{% for item in items -%}
{{ render_sidebar_item(item) }}
{%- endfor %}
</ul>
{%- endif -%}
{% endmacro %}
{% macro my_account() %}
{% if frappe.user != 'Guest' %}
<ul class="list-unstyled">
<li class="sidebar-item">
<a href="/me">{{ _("My Account") }}</a>
</li>
</ul>
{% endif %}
{% endmacro %}
<div class="web-sidebar">
{% if sidebar_title %}
<li class="title">
{{ sidebar_title }}
</li>
{% endif %}
<div class="sidebar-items">
<ul class="list-unstyled">
{% if sidebar_title %}
<li class="title">
{{ sidebar_title }}
</li>
{% endif %}
{% for item in sidebar_items -%}
<li class="sidebar-item">
{% if item.type != 'input' %}
{%- set item_route = item.route[1:] if item.route[0] == '/' else item.route -%}
<a href="{{ item.route }}" class="{{ 'active' if pathname == item_route else '' }}"
{% if item.target %}target="{{ item.target }}"{% endif %}>
{{ _(item.title or item.label) }}
</a>
{% else %}
<form action='{{ item.route }}' class="mr-3">
<input name='q' class='form-control' type='text' style="outline: none"
placeholder="{{ _(item.title or item.label) }}">
</form>
{% endif %}
</li>
{%- endfor %}
{% if frappe.user != 'Guest' %}
<li class="sidebar-item">
<a href="/me">{{ _("My Account") }}</a>
</li>
{% endif %}
</ul>
{{ render_sidebar_items(sidebar_items) }}
{{ my_account() }}
</div>
</div>
<script>
frappe.ready(function() {
$('.sidebar-item a').each(function(index) {
const active_class = 'active'
const non_active_class = ''
if(this.href.trim() == window.location) {
$(this).removeClass(non_active_class).addClass(active_class);
} else {
$(this).removeClass(active_class).addClass(non_active_class);
}
});
});
frappe.ready(function () {
$('.sidebar-item a').each(function (index) {
const active_class = 'active'
const non_active_class = ''
let page_href = window.location.href;
if (page_href.indexOf('#') !== -1) {
page_href = page_href.slice(0, page_href.indexOf('#'));
}
if (this.href.trim() == page_href) {
$(this).removeClass(non_active_class).addClass(active_class);
} else {
$(this).removeClass(active_class).addClass(non_active_class);
}
});
// scroll the active sidebar item into view
let active_sidebar_item = $('.sidebar-item a.active');
if (active_sidebar_item.length > 0) {
active_sidebar_item.get(0)
.scrollIntoView({behavior: "auto", block: "center", inline: "nearest"});
}
});
</script>

Some files were not shown because too many files have changed in this diff Show more