Merge branch 'staging'
This commit is contained in:
commit
27c58f6aea
80 changed files with 2086 additions and 172 deletions
|
|
@ -14,7 +14,7 @@ import os, sys, importlib, inspect, json
|
|||
from .exceptions import *
|
||||
from .utils.jinja import get_jenv, get_template, render_template, get_email_from_template
|
||||
|
||||
__version__ = '8.7.11'
|
||||
__version__ = '8.8.0'
|
||||
__title__ = "Frappe Framework"
|
||||
|
||||
local = Local()
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@ from frappe.modules.patch_handler import check_session_stopped
|
|||
from frappe.translate import get_lang_code
|
||||
from frappe.utils.password import check_password
|
||||
from frappe.core.doctype.authentication_log.authentication_log import add_authentication_log
|
||||
from frappe.utils.background_jobs import enqueue
|
||||
from twofactor import (should_run_2fa, authenticate_for_2factor,
|
||||
confirm_otp_token, get_cached_user_pass)
|
||||
|
||||
from six.moves.urllib.parse import quote
|
||||
|
||||
import pyotp, base64, os
|
||||
|
||||
class HTTPRequest:
|
||||
def __init__(self):
|
||||
# Get Environment variables
|
||||
|
|
@ -62,6 +67,7 @@ class HTTPRequest:
|
|||
|
||||
def validate_csrf_token(self):
|
||||
if frappe.local.request and frappe.local.request.method=="POST":
|
||||
if not frappe.local.session: return
|
||||
if not frappe.local.session.data.csrf_token \
|
||||
or frappe.local.session.data.device=="mobile" \
|
||||
or frappe.conf.get('ignore_csrf', None):
|
||||
|
|
@ -88,7 +94,7 @@ class HTTPRequest:
|
|||
def connect(self, ac_name = None):
|
||||
"""connect to db, from ac_name or db_name"""
|
||||
frappe.local.db = frappe.database.Database(user = self.get_db_name(), \
|
||||
password = getattr(conf,'db_password', ''))
|
||||
password = getattr(conf, 'db_password', ''))
|
||||
|
||||
class LoginManager:
|
||||
def __init__(self):
|
||||
|
|
@ -98,7 +104,7 @@ class LoginManager:
|
|||
self.user_type = None
|
||||
|
||||
if frappe.local.form_dict.get('cmd')=='login' or frappe.local.request.path=="/api/method/login":
|
||||
self.login()
|
||||
if self.login()==False: return
|
||||
self.resume = False
|
||||
|
||||
# run login triggers
|
||||
|
|
@ -116,7 +122,12 @@ class LoginManager:
|
|||
def login(self):
|
||||
# clear cache
|
||||
frappe.clear_cache(user = frappe.form_dict.get('usr'))
|
||||
self.authenticate()
|
||||
user, pwd = get_cached_user_pass()
|
||||
self.authenticate(user=user, pwd=pwd)
|
||||
if should_run_2fa(self.user):
|
||||
authenticate_for_2factor(self.user)
|
||||
if not confirm_otp_token(self):
|
||||
return False
|
||||
self.post_login()
|
||||
|
||||
def post_login(self):
|
||||
|
|
@ -183,7 +194,7 @@ class LoginManager:
|
|||
if not (user and pwd):
|
||||
user, pwd = frappe.form_dict.get('usr'), frappe.form_dict.get('pwd')
|
||||
if not (user and pwd):
|
||||
self.fail('Incomplete login details', user=user)
|
||||
self.fail(_('Incomplete login details'), user=user)
|
||||
|
||||
if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")):
|
||||
user = frappe.db.get_value("User", filters={"mobile_no": user}, fieldname="name") or user
|
||||
|
|
@ -205,7 +216,9 @@ class LoginManager:
|
|||
except frappe.AuthenticationError:
|
||||
self.fail('Incorrect password', user=user)
|
||||
|
||||
def fail(self, message, user="NA"):
|
||||
def fail(self, message, user=None):
|
||||
if not user:
|
||||
user = _('Unknown User')
|
||||
frappe.local.response['message'] = message
|
||||
add_authentication_log(message, user, status="Failed")
|
||||
frappe.db.commit()
|
||||
|
|
@ -302,6 +315,7 @@ class CookieManager:
|
|||
for key in set(self.to_delete):
|
||||
response.set_cookie(key, "", expires=expires)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_logged_user():
|
||||
return frappe.session.user
|
||||
|
|
@ -317,4 +331,4 @@ def get_website_user_home_page(user):
|
|||
home_page = frappe.get_attr(home_page_method[-1])(user)
|
||||
return '/' + home_page.strip('/')
|
||||
else:
|
||||
return '/me'
|
||||
return '/me'
|
||||
|
|
@ -10,6 +10,7 @@ const path_join = path.resolve;
|
|||
const app = require('express')();
|
||||
const http = require('http').Server(app);
|
||||
const io = require('socket.io')(http);
|
||||
const touch = require("touch");
|
||||
|
||||
// basic setup
|
||||
const sites_path = path_join(__dirname, '..', '..', '..', 'sites');
|
||||
|
|
@ -42,6 +43,7 @@ function build(minify) {
|
|||
for (const output_path in build_map) {
|
||||
pack(output_path, build_map[output_path], minify);
|
||||
}
|
||||
touch(path_join(sites_path, '.build'), {force:true});
|
||||
}
|
||||
|
||||
let socket_connection = false;
|
||||
|
|
@ -228,7 +230,7 @@ function watch_less(ondirty) {
|
|||
const less_paths = app_paths.map(path => path_join(path, 'public', 'less'));
|
||||
|
||||
const to_watch = filter_valid_paths(less_paths);
|
||||
chokidar.watch(to_watch).on('change', (filename, stats) => {
|
||||
chokidar.watch(to_watch).on('change', (filename) => {
|
||||
console.log(filename, 'dirty');
|
||||
var last_index = filename.lastIndexOf('/');
|
||||
const less_path = filename.slice(0, last_index);
|
||||
|
|
@ -236,17 +238,18 @@ function watch_less(ondirty) {
|
|||
filename = filename.split('/').pop();
|
||||
|
||||
compile_less_file(filename, less_path, public_path)
|
||||
.then(css_file_path => {
|
||||
// build the target css file for which this css file is input
|
||||
for (const target in build_map) {
|
||||
const sources = build_map[target];
|
||||
if (sources.includes(css_file_path)) {
|
||||
pack(target, sources);
|
||||
ondirty && ondirty(target);
|
||||
break;
|
||||
.then(css_file_path => {
|
||||
// build the target css file for which this css file is input
|
||||
for (const target in build_map) {
|
||||
const sources = build_map[target];
|
||||
if (sources.includes(css_file_path)) {
|
||||
pack(target, sources);
|
||||
ondirty && ondirty(target);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
touch(path_join(sites_path, '.build'), {force:true});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -265,6 +268,7 @@ function watch_js(ondirty) {
|
|||
// break;
|
||||
}
|
||||
}
|
||||
touch(path_join(sites_path, '.build'), {force:true});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
2
frappe/change_log/v8/v8_8_0.md
Normal file
2
frappe/change_log/v8/v8_8_0.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
### Two Factor Authentication
|
||||
- Now you can authenticate user with two factor authentication. You can enable the Two Factor Authentication from System Settings.
|
||||
|
|
@ -296,3 +296,8 @@ def get_js(items):
|
|||
out.append(code)
|
||||
|
||||
return out
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_time_zone():
|
||||
'''Returns default time zone'''
|
||||
return {"time_zone": frappe.defaults.get_defaults().get("time_zone")}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ frappe.ui.form.on("Address", {
|
|||
}
|
||||
}
|
||||
});
|
||||
frm.refresh_field("links");
|
||||
},
|
||||
validate: function(frm) {
|
||||
// clear linked customer / supplier / sales partner on saving...
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ QUnit.test("test: {doctype}", function (assert) {{
|
|||
// number of asserts
|
||||
assert.expect(1);
|
||||
|
||||
frappe.run_serially('{doctype}', [
|
||||
frappe.run_serially([
|
||||
// insert a new {doctype}
|
||||
() => frappe.tests.make([
|
||||
() => frappe.tests.make('{doctype}', [
|
||||
// values to be set
|
||||
{{key: 'value'}}
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -105,6 +105,37 @@
|
|||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"default": "0",
|
||||
"fieldname": "two_factor_auth",
|
||||
"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": "Two Factor Authentication",
|
||||
"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,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
|
|
@ -148,7 +179,7 @@
|
|||
"issingle": 0,
|
||||
"istable": 0,
|
||||
"max_attachments": 0,
|
||||
"modified": "2017-05-04 11:03:41.533058",
|
||||
"modified": "2017-07-06 12:42:57.097914",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Role",
|
||||
|
|
|
|||
1
frappe/core/doctype/sms_parameter/README.md
Normal file
1
frappe/core/doctype/sms_parameter/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
SMS query parameter for SMS Settings.
|
||||
1
frappe/core/doctype/sms_parameter/__init__.py
Executable file
1
frappe/core/doctype/sms_parameter/__init__.py
Executable file
|
|
@ -0,0 +1 @@
|
|||
from __future__ import unicode_literals
|
||||
98
frappe/core/doctype/sms_parameter/sms_parameter.json
Executable file
98
frappe/core/doctype/sms_parameter/sms_parameter.json
Executable file
|
|
@ -0,0 +1,98 @@
|
|||
{
|
||||
"allow_copy": 0,
|
||||
"allow_guest_to_view": 0,
|
||||
"allow_import": 0,
|
||||
"allow_rename": 0,
|
||||
"beta": 0,
|
||||
"creation": "2013-02-22 01:27:58",
|
||||
"custom": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"fields": [
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "parameter",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Parameter",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": "150px",
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0,
|
||||
"width": "150px"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "value",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Value",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": "150px",
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0,
|
||||
"width": "150px"
|
||||
}
|
||||
],
|
||||
"has_web_view": 0,
|
||||
"hide_heading": 0,
|
||||
"hide_toolbar": 0,
|
||||
"idx": 1,
|
||||
"image_view": 0,
|
||||
"in_create": 0,
|
||||
"is_submittable": 0,
|
||||
"issingle": 0,
|
||||
"istable": 1,
|
||||
"max_attachments": 0,
|
||||
"modified": "2017-07-22 22:52:53.309396",
|
||||
"modified_by": "chude.osiegbu@manqala.com",
|
||||
"module": "Core",
|
||||
"name": "SMS Parameter",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 0,
|
||||
"read_only": 0,
|
||||
"read_only_onload": 0,
|
||||
"show_name_in_global_search": 0,
|
||||
"track_changes": 0,
|
||||
"track_seen": 0
|
||||
}
|
||||
10
frappe/core/doctype/sms_parameter/sms_parameter.py
Normal file
10
frappe/core/doctype/sms_parameter/sms_parameter.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
|
||||
from frappe.model.document import Document
|
||||
|
||||
class SMSParameter(Document):
|
||||
pass
|
||||
1
frappe/core/doctype/sms_settings/README.md
Normal file
1
frappe/core/doctype/sms_settings/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Settings for automatically sending SMS from the system.
|
||||
1
frappe/core/doctype/sms_settings/__init__.py
Executable file
1
frappe/core/doctype/sms_settings/__init__.py
Executable file
|
|
@ -0,0 +1 @@
|
|||
from __future__ import unicode_literals
|
||||
0
frappe/core/doctype/sms_settings/sms_settings.js
Normal file
0
frappe/core/doctype/sms_settings/sms_settings.js
Normal file
267
frappe/core/doctype/sms_settings/sms_settings.json
Executable file
267
frappe/core/doctype/sms_settings/sms_settings.json
Executable file
|
|
@ -0,0 +1,267 @@
|
|||
{
|
||||
"allow_copy": 1,
|
||||
"allow_guest_to_view": 0,
|
||||
"allow_import": 0,
|
||||
"allow_rename": 0,
|
||||
"beta": 0,
|
||||
"creation": "2013-01-10 16:34:24",
|
||||
"custom": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 0,
|
||||
"fields": [
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "column_break0",
|
||||
"fieldtype": "Column Break",
|
||||
"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,
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"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,
|
||||
"unique": 0,
|
||||
"width": "50%"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"description": "Eg. smsgateway.com/api/send_sms.cgi",
|
||||
"fieldname": "sms_gateway_url",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "SMS Gateway URL",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"description": "Enter url parameter for message",
|
||||
"fieldname": "message_parameter",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Message Parameter",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"description": "Enter url parameter for receiver nos",
|
||||
"fieldname": "receiver_parameter",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Receiver Parameter",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "sms_sender_name",
|
||||
"fieldtype": "Data",
|
||||
"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": "SMS Sender Name",
|
||||
"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,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "static_parameters_section",
|
||||
"fieldtype": "Column Break",
|
||||
"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,
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"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,
|
||||
"unique": 0,
|
||||
"width": "50%"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"description": "Enter static url parameters here (Eg. sender=ERPNext, username=ERPNext, password=1234 etc.)",
|
||||
"fieldname": "parameters",
|
||||
"fieldtype": "Table",
|
||||
"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": "Static Parameters",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "SMS Parameter",
|
||||
"permlevel": 0,
|
||||
"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,
|
||||
"unique": 0
|
||||
}
|
||||
],
|
||||
"has_web_view": 0,
|
||||
"hide_heading": 0,
|
||||
"hide_toolbar": 0,
|
||||
"icon": "fa fa-cog",
|
||||
"idx": 1,
|
||||
"image_view": 0,
|
||||
"in_create": 0,
|
||||
"is_submittable": 0,
|
||||
"issingle": 1,
|
||||
"istable": 0,
|
||||
"max_attachments": 0,
|
||||
"modified": "2017-07-22 22:52:16.066981",
|
||||
"modified_by": "chude.osiegbu@manqala.com",
|
||||
"module": "Core",
|
||||
"name": "SMS Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"amend": 0,
|
||||
"apply_user_permissions": 0,
|
||||
"cancel": 0,
|
||||
"create": 1,
|
||||
"delete": 0,
|
||||
"email": 0,
|
||||
"export": 0,
|
||||
"if_owner": 0,
|
||||
"import": 0,
|
||||
"permlevel": 0,
|
||||
"print": 0,
|
||||
"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,
|
||||
"track_changes": 0,
|
||||
"track_seen": 0
|
||||
}
|
||||
117
frappe/core/doctype/sms_settings/sms_settings.py
Normal file
117
frappe/core/doctype/sms_settings/sms_settings.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
|
||||
from frappe import _, throw, msgprint
|
||||
from frappe.utils import nowdate
|
||||
|
||||
from frappe.model.document import Document
|
||||
|
||||
class SMSSettings(Document):
|
||||
pass
|
||||
|
||||
def validate_receiver_nos(receiver_list):
|
||||
validated_receiver_list = []
|
||||
for d in receiver_list:
|
||||
# remove invalid character
|
||||
for x in [' ', '+', '-', '(', ')']:
|
||||
d = d.replace(x, '')
|
||||
|
||||
validated_receiver_list.append(d)
|
||||
|
||||
if not validated_receiver_list:
|
||||
throw(_("Please enter valid mobile nos"))
|
||||
|
||||
return validated_receiver_list
|
||||
|
||||
|
||||
def get_sender_name():
|
||||
"returns name as SMS sender"
|
||||
sender_name = frappe.db.get_single_value('SMS Settings', 'sms_sender_name') or \
|
||||
'ERPNXT'
|
||||
if len(sender_name) > 6 and \
|
||||
frappe.db.get_default("country") == "India":
|
||||
throw("""As per TRAI rule, sender name must be exactly 6 characters.
|
||||
Kindly change sender name in Setup --> Global Defaults.
|
||||
Note: Hyphen, space, numeric digit, special characters are not allowed.""")
|
||||
return sender_name
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_contact_number(contact_name, ref_doctype, ref_name):
|
||||
"returns mobile number of the contact"
|
||||
number = frappe.db.sql("""select mobile_no, phone from tabContact
|
||||
where name=%s
|
||||
and exists(
|
||||
select name from `tabDynamic Link` where link_doctype=%s and link_name=%s
|
||||
)
|
||||
""", (contact_name, ref_doctype, ref_name))
|
||||
|
||||
return number and (number[0][0] or number[0][1]) or ''
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_sms(receiver_list, msg, sender_name = '', success_msg = True):
|
||||
|
||||
import json
|
||||
if isinstance(receiver_list, basestring):
|
||||
receiver_list = json.loads(receiver_list)
|
||||
if not isinstance(receiver_list, list):
|
||||
receiver_list = [receiver_list]
|
||||
|
||||
receiver_list = validate_receiver_nos(receiver_list)
|
||||
|
||||
arg = {
|
||||
'receiver_list' : receiver_list,
|
||||
'message' : unicode(msg).encode('utf-8'),
|
||||
'sender_name' : sender_name or get_sender_name(),
|
||||
'success_msg' : success_msg
|
||||
}
|
||||
|
||||
if frappe.db.get_value('SMS Settings', None, 'sms_gateway_url'):
|
||||
send_via_gateway(arg)
|
||||
else:
|
||||
msgprint(_("Please Update SMS Settings"))
|
||||
|
||||
def send_via_gateway(arg):
|
||||
ss = frappe.get_doc('SMS Settings', 'SMS Settings')
|
||||
args = {ss.message_parameter: arg.get('message')}
|
||||
for d in ss.get("parameters"):
|
||||
args[d.parameter] = d.value
|
||||
|
||||
success_list = []
|
||||
for d in arg.get('receiver_list'):
|
||||
args[ss.receiver_parameter] = d
|
||||
status = send_request(ss.sms_gateway_url, args)
|
||||
|
||||
if 200 <= status < 300:
|
||||
success_list.append(d)
|
||||
|
||||
if len(success_list) > 0:
|
||||
args.update(arg)
|
||||
create_sms_log(args, success_list)
|
||||
if arg.get('success_msg'):
|
||||
frappe.msgprint(_("SMS sent to following numbers: {0}").format("\n" + "\n".join(success_list)))
|
||||
|
||||
|
||||
def send_request(gateway_url, params):
|
||||
import requests
|
||||
response = requests.get(gateway_url, params = params, headers={'Accept': "text/plain, text/html, */*"})
|
||||
response.raise_for_status()
|
||||
return response.status_code
|
||||
|
||||
|
||||
# Create SMS Log
|
||||
# =========================================================
|
||||
def create_sms_log(args, sent_to):
|
||||
sl = frappe.new_doc('SMS Log')
|
||||
sl.sender_name = args['sender_name']
|
||||
sl.sent_on = nowdate()
|
||||
sl.message = args['message'].decode('utf-8')
|
||||
sl.no_of_requested_sms = len(args['receiver_list'])
|
||||
sl.requested_numbers = "\n".join(args['receiver_list'])
|
||||
sl.no_of_sent_sms = len(sent_to)
|
||||
sl.sent_to = "\n".join(sent_to)
|
||||
sl.flags.ignore_permissions = True
|
||||
sl.save()
|
||||
23
frappe/core/doctype/sms_settings/test_sms_settings.js
Normal file
23
frappe/core/doctype/sms_settings/test_sms_settings.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/* eslint-disable */
|
||||
// rename this file from _test_[name] to test_[name] to activate
|
||||
// and remove above this line
|
||||
|
||||
QUnit.test("test: SMS Settings", function (assert) {
|
||||
let done = assert.async();
|
||||
|
||||
// number of asserts
|
||||
assert.expect(1);
|
||||
|
||||
frappe.run_serially('SMS Settings', [
|
||||
// insert a new SMS Settings
|
||||
() => frappe.tests.make([
|
||||
// values to be set
|
||||
{key: 'value'}
|
||||
]),
|
||||
() => {
|
||||
assert.equal(cur_frm.doc.key, 'value');
|
||||
},
|
||||
() => done()
|
||||
]);
|
||||
|
||||
});
|
||||
|
|
@ -895,6 +895,165 @@
|
|||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 1,
|
||||
"columns": 0,
|
||||
"fieldname": "two_factor_authentication",
|
||||
"fieldtype": "Section Break",
|
||||
"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": "Two Factor Authentication",
|
||||
"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,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "enable_two_factor_auth",
|
||||
"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": "Enable Two Factor Auth",
|
||||
"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,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"default": "OTP App",
|
||||
"depends_on": "",
|
||||
"description": "Choose authentication method to be used by all users",
|
||||
"fieldname": "two_factor_method",
|
||||
"fieldtype": "Select",
|
||||
"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": "Two Factor Authentication method",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "OTP App\nSMS\nEmail",
|
||||
"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,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"depends_on": "eval:doc.two_factor_method == \"OTP App\"",
|
||||
"description": "Time in seconds to retain QR code image on server. Min:<strong>240</strong>",
|
||||
"fieldname": "lifespan_qrcode_image",
|
||||
"fieldtype": "Int",
|
||||
"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": "Expiry time of QR Code Image Page",
|
||||
"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,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"default": "Frappe Framework",
|
||||
"depends_on": "enable_two_factor_auth",
|
||||
"fieldname": "otp_issuer_name",
|
||||
"fieldtype": "Data",
|
||||
"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": "OTP Issuer Name",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "",
|
||||
"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,
|
||||
"unique": 0
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
|
|
@ -1027,7 +1186,7 @@
|
|||
"issingle": 1,
|
||||
"istable": 0,
|
||||
"max_attachments": 0,
|
||||
"modified": "2017-07-20 22:57:56.466867",
|
||||
"modified": "2017-08-07 23:29:18.858797",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "System Settings",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from frappe.model import no_value_fields
|
|||
from frappe.translate import set_default_language
|
||||
from frappe.utils import cint
|
||||
from frappe.utils.momentjs import get_all_timezones
|
||||
from frappe.twofactor import toggle_two_factor_auth
|
||||
|
||||
class SystemSettings(Document):
|
||||
def validate(self):
|
||||
|
|
@ -25,6 +26,12 @@ class SystemSettings(Document):
|
|||
if len(parts)!=2 or not (cint(parts[0]) or cint(parts[1])):
|
||||
frappe.throw(_("Session Expiry must be in format {0}").format("hh:mm"))
|
||||
|
||||
if self.enable_two_factor_auth:
|
||||
if self.two_factor_method=='SMS':
|
||||
if not frappe.db.get_value('SMS Settings', None, 'sms_gateway_url'):
|
||||
frappe.throw(_('Please setup SMS before setting it as an authentication method, via SMS Settings'))
|
||||
toggle_two_factor_auth(True, roles=['All'])
|
||||
|
||||
def on_update(self):
|
||||
for df in self.meta.get("fields"):
|
||||
if df.fieldtype not in no_value_fields:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ frappe.ui.form.on('Test Runner', {
|
|||
|
||||
},
|
||||
run_tests: function(frm, files) {
|
||||
frappe.flags.in_test = true;
|
||||
let require_list = [
|
||||
"assets/frappe/js/lib/jquery/qunit.js",
|
||||
"assets/frappe/js/lib/jquery/qunit.css"
|
||||
|
|
|
|||
|
|
@ -78,6 +78,15 @@ frappe.ui.form.on('User', {
|
|||
})
|
||||
})
|
||||
|
||||
frm.add_custom_button(__("Reset OTP Secret"), function() {
|
||||
frappe.call({
|
||||
method: "frappe.core.doctype.user.user.reset_otp_secret",
|
||||
args: {
|
||||
"user": frm.doc.name
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
frm.trigger('enabled');
|
||||
|
||||
frm.roles_editor && frm.roles_editor.show();
|
||||
|
|
@ -111,6 +120,7 @@ frappe.ui.form.on('User', {
|
|||
}
|
||||
cur_frm.dirty();
|
||||
}
|
||||
|
||||
},
|
||||
validate: function(frm) {
|
||||
if(frm.roles_editor) {
|
||||
|
|
|
|||
|
|
@ -1971,7 +1971,7 @@
|
|||
"istable": 0,
|
||||
"max_attachments": 5,
|
||||
"menu_index": 0,
|
||||
"modified": "2017-07-12 19:24:00.824902",
|
||||
"modified": "2017-07-07 17:18:14.047969",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "User",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import frappe.share
|
|||
import re
|
||||
from frappe.limits import get_limits
|
||||
from frappe.website.utils import is_signup_enabled
|
||||
from frappe.utils.background_jobs import enqueue
|
||||
|
||||
STANDARD_USERS = ("Guest", "Administrator")
|
||||
|
||||
|
|
@ -586,8 +587,8 @@ def get_email_awaiting(user):
|
|||
return waiting
|
||||
else:
|
||||
frappe.db.sql("""update `tabUser Email`
|
||||
set awaiting_password =0
|
||||
where parent = %(user)s""",{"user":user})
|
||||
set awaiting_password =0
|
||||
where parent = %(user)s""",{"user":user})
|
||||
return False
|
||||
|
||||
@frappe.whitelist(allow_guest=False)
|
||||
|
|
@ -675,7 +676,7 @@ def ask_pass_update():
|
|||
from frappe.utils import set_default
|
||||
|
||||
users = frappe.db.sql("""SELECT DISTINCT(parent) as user FROM `tabUser Email`
|
||||
WHERE awaiting_password = 1""", as_dict=True)
|
||||
WHERE awaiting_password = 1""", as_dict=True)
|
||||
|
||||
password_list = [ user.get("user") for user in users ]
|
||||
set_default("email_user_password", u','.join(password_list))
|
||||
|
|
@ -888,4 +889,84 @@ def handle_password_test_fail(result):
|
|||
def update_gravatar(name):
|
||||
gravatar = has_gravatar(name)
|
||||
if gravatar:
|
||||
frappe.db.set_value('User', name, 'user_image', gravatar)
|
||||
frappe.db.set_value('User', name, 'user_image', gravatar)
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def send_token_via_sms(tmp_id,phone_no=None,user=None):
|
||||
try:
|
||||
from frappe.core.doctype.sms_settings.sms_settings import send_request
|
||||
except:
|
||||
return False
|
||||
|
||||
if not frappe.cache().ttl(tmp_id + '_token'):
|
||||
return False
|
||||
ss = frappe.get_doc('SMS Settings', 'SMS Settings')
|
||||
if not ss.sms_gateway_url:
|
||||
return False
|
||||
|
||||
token = frappe.cache().get(tmp_id + '_token')
|
||||
args = {ss.message_parameter: 'verification code is {}'.format(token)}
|
||||
|
||||
for d in ss.get("parameters"):
|
||||
args[d.parameter] = d.value
|
||||
|
||||
if user:
|
||||
user_phone = frappe.db.get_value('User', user, ['phone','mobile_no'], as_dict=1)
|
||||
usr_phone = user_phone.mobile_no or user_phone.phone
|
||||
if not usr_phone:
|
||||
return False
|
||||
else:
|
||||
if phone_no:
|
||||
usr_phone = phone_no
|
||||
else:
|
||||
return False
|
||||
|
||||
args[ss.receiver_parameter] = usr_phone
|
||||
status = send_request(ss.sms_gateway_url, args)
|
||||
|
||||
if 200 <= status < 300:
|
||||
frappe.cache().delete(tmp_id + '_token')
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def send_token_via_email(tmp_id,token=None):
|
||||
import pyotp
|
||||
|
||||
user = frappe.cache().get(tmp_id + '_user')
|
||||
count = token or frappe.cache().get(tmp_id + '_token')
|
||||
|
||||
if ((not user) or (user == 'None') or (not count)):
|
||||
return False
|
||||
user_email = frappe.db.get_value('User',user, 'email')
|
||||
if not user_email:
|
||||
return False
|
||||
|
||||
otpsecret = frappe.cache().get(tmp_id + '_otp_secret')
|
||||
hotp = pyotp.HOTP(otpsecret)
|
||||
|
||||
frappe.sendmail(
|
||||
recipients=user_email, sender=None, subject='Verification Code',
|
||||
message='<p>Your verification code is {0}</p>'.format(hotp.at(int(count))),
|
||||
delayed=False, retry=3)
|
||||
|
||||
return True
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def reset_otp_secret(user):
|
||||
otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name')
|
||||
user_email = frappe.db.get_value('User',user, 'email')
|
||||
if frappe.session.user in ["Administrator", user] :
|
||||
frappe.defaults.clear_default(user + '_otplogin')
|
||||
frappe.defaults.clear_default(user + '_otpsecret')
|
||||
email_args = {
|
||||
'recipients':user_email, 'sender':None, 'subject':'OTP Secret Reset - {}'.format(otp_issuer or "Frappe Framework"),
|
||||
'message':'<p>Your OTP secret on {} has been reset. If you did not perform this reset and did not request it, please contact your System Administrator immediately.</p>'.format(otp_issuer or "Frappe Framework"),
|
||||
'delayed':False,
|
||||
'retry':3
|
||||
}
|
||||
enqueue(method=frappe.sendmail, queue='short', timeout=300, event=None, async=True, job_name=None, now=False, **email_args)
|
||||
return frappe.msgprint(_("OTP Secret has been reset. Re-registration will be required on next login."))
|
||||
else:
|
||||
return frappe.throw(_("OTP secret can only be reset by the Administrator."))
|
||||
|
|
@ -14,12 +14,13 @@ class TestVersion(unittest.TestCase):
|
|||
new_doc = copy.deepcopy(old_doc)
|
||||
|
||||
old_doc.color = None
|
||||
new_doc.color = '#fafafa'
|
||||
|
||||
diff = get_diff(old_doc, new_doc)['changed']
|
||||
|
||||
self.assertEquals(get_fieldnames(diff)[0], 'color')
|
||||
self.assertTrue(get_old_values(diff)[0] is None)
|
||||
self.assertEquals(get_new_values(diff)[0], 'blue')
|
||||
self.assertEquals(get_new_values(diff)[0], '#fafafa')
|
||||
|
||||
new_doc.starts_on = "2017-07-20"
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ QUnit.test("test customize form", function(assert) {
|
|||
let done = assert.async();
|
||||
frappe.run_serially([
|
||||
() => frappe.set_route('Form', 'Customize Form'),
|
||||
() => frappe.timeout(2),
|
||||
() => frappe.timeout(1),
|
||||
() => cur_frm.set_value('doc_type', 'ToDo'),
|
||||
() => frappe.timeout(2),
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -312,9 +312,9 @@
|
|||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"default": "blue",
|
||||
"default": "",
|
||||
"fieldname": "color",
|
||||
"fieldtype": "Select",
|
||||
"fieldtype": "Color",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
|
|
@ -325,7 +325,7 @@
|
|||
"label": "Color",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "red\ngreen\nblue\nyellow\nskyblue\norange",
|
||||
"options": "",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
|
|
@ -895,8 +895,8 @@
|
|||
"issingle": 0,
|
||||
"istable": 0,
|
||||
"max_attachments": 0,
|
||||
"modified": "2017-07-06 12:37:44.036819",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2017-08-03 16:34:54.657796",
|
||||
"modified_by": "faris@erpnext.com",
|
||||
"module": "Desk",
|
||||
"name": "Event",
|
||||
"owner": "Administrator",
|
||||
|
|
|
|||
42
frappe/desk/doctype/event/test_event.js
Normal file
42
frappe/desk/doctype/event/test_event.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
|
||||
QUnit.test("test: Event", function (assert) {
|
||||
let done = assert.async();
|
||||
|
||||
// number of asserts
|
||||
assert.expect(4);
|
||||
|
||||
const subject = '_Test Event 1';
|
||||
const datetime = frappe.datetime.now_datetime();
|
||||
const hex = '#6be273';
|
||||
const rgb = 'rgb(107, 226, 115)';
|
||||
|
||||
frappe.run_serially([
|
||||
// insert a new Event
|
||||
() => frappe.tests.make('Event', [
|
||||
// values to be set
|
||||
{subject: subject},
|
||||
{starts_on: datetime},
|
||||
{color: hex},
|
||||
{event_type: 'Private'}
|
||||
]),
|
||||
() => {
|
||||
assert.equal(cur_frm.doc.subject, subject, 'Subject correctly set');
|
||||
assert.equal(cur_frm.doc.starts_on, datetime, 'Date correctly set');
|
||||
assert.equal(cur_frm.doc.color, hex, 'Color correctly set');
|
||||
|
||||
// set filters explicitly for list view
|
||||
frappe.route_options = {
|
||||
event_type: 'Private'
|
||||
};
|
||||
},
|
||||
() => frappe.set_route('List', 'Event', 'Calendar'),
|
||||
() => frappe.timeout(2),
|
||||
() => {
|
||||
const bg_color = $(`.result-list:visible .fc-day-grid-event:contains("${subject}")`)
|
||||
.css('background-color');
|
||||
assert.equal(bg_color, rgb, 'Event background color is set correctly');
|
||||
},
|
||||
() => done()
|
||||
]);
|
||||
|
||||
});
|
||||
|
|
@ -10,7 +10,7 @@ frappe.ui.form.on("Note", {
|
|||
// toggle edit
|
||||
frm.add_custom_button("Edit", function() {
|
||||
frm.events.set_editable(frm, !frm.is_note_editable);
|
||||
})
|
||||
});
|
||||
frm.events.set_editable(frm, false);
|
||||
}
|
||||
},
|
||||
|
|
@ -24,12 +24,12 @@ frappe.ui.form.on("Note", {
|
|||
frm.set_df_property("content", "read_only", editable ? 0: 1);
|
||||
|
||||
// hide all other fields
|
||||
$.each(frm.fields_dict, function(fieldname, field) {
|
||||
$.each(frm.fields_dict, function(fieldname) {
|
||||
|
||||
if(fieldname !== "content") {
|
||||
frm.set_df_property(fieldname, "hidden", editable ? 0: 1);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// no label, description for content either
|
||||
frm.get_field("content").toggle_label(editable);
|
||||
|
|
|
|||
|
|
@ -561,7 +561,7 @@ var frappe_slides = [
|
|||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
var utils = {
|
||||
|
|
|
|||
|
|
@ -267,3 +267,10 @@ def email_setup_wizard_exception(traceback, args):
|
|||
|
||||
def get_language_code(lang):
|
||||
return frappe.db.get_value('Language', {'language_name':lang})
|
||||
|
||||
|
||||
def enable_twofactor_all_roles():
|
||||
all_role = frappe.get_doc('Role',{'role_name':'All'})
|
||||
all_role.two_factor_auth = True
|
||||
all_role.save(ignore_permissions=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ def run(report_name, filters=None, user=None):
|
|||
frappe.msgprint(_("Must have report permission to access this report."),
|
||||
raise_exception=True)
|
||||
|
||||
columns, result, message, chart = [], [], None, None
|
||||
columns, result, message, chart, data_to_be_printed = [], [], None, None, None
|
||||
if report.report_type=="Query Report":
|
||||
if not report.query:
|
||||
frappe.msgprint(_("Must specify a Query to run"), raise_exception=True)
|
||||
|
|
@ -99,6 +99,8 @@ def run(report_name, filters=None, user=None):
|
|||
message = res[2]
|
||||
if len(res) > 3:
|
||||
chart = res[3]
|
||||
if len(res) > 4:
|
||||
data_to_be_printed = res[4]
|
||||
|
||||
if report.apply_user_permissions and result:
|
||||
result = get_filtered_data(report.ref_doctype, columns, result, user)
|
||||
|
|
@ -110,7 +112,8 @@ def run(report_name, filters=None, user=None):
|
|||
"result": result,
|
||||
"columns": columns,
|
||||
"message": message,
|
||||
"chart": chart
|
||||
"chart": chart,
|
||||
"data_to_be_printed": data_to_be_printed
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from frappe.utils.scheduler import log
|
|||
from frappe.email.queue import send
|
||||
from frappe.email.doctype.email_group.email_group import add_subscribers
|
||||
from frappe.utils import parse_addr
|
||||
from frappe.utils import validate_email_add
|
||||
|
||||
|
||||
class Newsletter(Document):
|
||||
|
|
@ -23,6 +24,10 @@ class Newsletter(Document):
|
|||
from `tabEmail Queue` where reference_doctype=%s and reference_name=%s
|
||||
group by status""", (self.doctype, self.name))) or None
|
||||
|
||||
def validate(self):
|
||||
if self.send_from:
|
||||
validate_email_add(self.send_from, True)
|
||||
|
||||
def test_send(self, doctype="Lead"):
|
||||
self.recipients = frappe.utils.split_emails(self.test_email_id)
|
||||
self.queue_all()
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ class SessionStopped(Exception):
|
|||
class UnsupportedMediaType(Exception):
|
||||
http_status_code = 415
|
||||
|
||||
class RequestToken(Exception):
|
||||
http_status_code = 200
|
||||
|
||||
class Redirect(Exception):
|
||||
http_status_code = 301
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,8 @@ scheduler_events = {
|
|||
"frappe.email.doctype.email_account.email_account.pull",
|
||||
"frappe.email.doctype.email_account.email_account.notify_unreplied",
|
||||
"frappe.oauth.delete_oauth2_data",
|
||||
"frappe.integrations.doctype.razorpay_settings.razorpay_settings.capture_payment"
|
||||
"frappe.integrations.doctype.razorpay_settings.razorpay_settings.capture_payment",
|
||||
"frappe.twofactor.delete_all_barcodes_for_users"
|
||||
],
|
||||
"hourly": [
|
||||
"frappe.model.utils.link_count.update_link_count",
|
||||
|
|
@ -189,3 +190,5 @@ bot_parsers = [
|
|||
|
||||
setup_wizard_exception = "frappe.desk.page.setup_wizard.setup_wizard.email_setup_wizard_exception"
|
||||
before_write_file = "frappe.limits.validate_space_limit"
|
||||
|
||||
otp_methods = ['OTP App','Email','SMS']
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@
|
|||
"public/js/frappe/ui/messages.js",
|
||||
"public/js/frappe/ui/keyboard.js",
|
||||
"public/js/frappe/ui/emoji.js",
|
||||
"public/js/frappe/ui/colors.js",
|
||||
|
||||
"public/js/frappe/request.js",
|
||||
"public/js/frappe/socketio_client.js",
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ th.fc-day-header {
|
|||
background: #cfdce5 !important;
|
||||
}
|
||||
.fc-day-grid-event {
|
||||
background-color: rgba(94, 100, 255, 0.2) !important;
|
||||
border: none !important;
|
||||
margin: 5px 4px 0 !important;
|
||||
padding: 1px 5px !important;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ hr {
|
|||
border-top: none;
|
||||
}
|
||||
.email-footer-container {
|
||||
margin-top: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.email-footer-container > div:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
|
|
|
|||
|
|
@ -299,17 +299,32 @@ h6.uppercase,
|
|||
.timeline-item.user-content .action-btns {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
padding: 5px 15px 2px 5px;
|
||||
padding: 8px 15px 0 5px;
|
||||
}
|
||||
.timeline-item.user-content .action-btns .edit-btn-container {
|
||||
margin-right: 13px;
|
||||
}
|
||||
.timeline-item.user-content .comment-header {
|
||||
background-color: #fafbfc;
|
||||
padding: 10px 15px 10px 13px;
|
||||
padding: 10px 15px 8px 13px;
|
||||
margin: 0px;
|
||||
color: #8D99A6;
|
||||
border-bottom: 1px solid #EBEFF2;
|
||||
}
|
||||
.timeline-item.user-content .comment-header.links-active {
|
||||
padding-right: 60px;
|
||||
padding-right: 77px;
|
||||
}
|
||||
.timeline-item.user-content .comment-header .asset-details {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
.timeline-item.user-content .comment-header .asset-details .btn-link {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.timeline-item.user-content .comment-header .asset-details .btn-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.timeline-item.user-content .comment-header .commented-on-small {
|
||||
display: none;
|
||||
|
|
@ -334,7 +349,8 @@ h6.uppercase,
|
|||
.timeline-item.user-content .close-btn-container .close {
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
padding: 0 0 0 10px;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.timeline-item.user-content .edit-btn-container {
|
||||
padding: 0;
|
||||
|
|
@ -409,7 +425,8 @@ h6.uppercase,
|
|||
top: 5px;
|
||||
}
|
||||
.timeline-item .reply-link {
|
||||
padding-left: 7px;
|
||||
margin-left: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.timeline-head {
|
||||
background-color: white;
|
||||
|
|
|
|||
|
|
@ -183,6 +183,25 @@
|
|||
.listview-main-section .octicon-heart {
|
||||
cursor: pointer;
|
||||
}
|
||||
.listview-main-section .page-form {
|
||||
padding-left: 17px;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.listview-main-section .page-form {
|
||||
padding-left: 25px;
|
||||
}
|
||||
}
|
||||
.listview-main-section .page-form .octicon-search {
|
||||
float: left;
|
||||
padding-top: 7px;
|
||||
margin-left: -4px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.listview-main-section .page-form .octicon-search {
|
||||
margin-left: -12px;
|
||||
}
|
||||
}
|
||||
.like-action.octicon-heart {
|
||||
color: #ff5858;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ body {
|
|||
body[data-route^="Form"] .page-title h1 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
body[data-route^="Form"] .page-title h1.editable-title {
|
||||
padding-right: 80px;
|
||||
}
|
||||
body[data-route^="Form"] .page-title .indicator {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
|
|
@ -197,7 +200,7 @@ body {
|
|||
}
|
||||
body[data-route^="Form"] .page-title .title-text {
|
||||
font-size: 16px;
|
||||
width: calc(100% - 30px);
|
||||
width: calc(100% - 90px);
|
||||
}
|
||||
body[data-route^="Form"] .page-title .indicator {
|
||||
float: left;
|
||||
|
|
@ -356,7 +359,10 @@ body {
|
|||
content: none;
|
||||
}
|
||||
.timeline .timeline-item.user-content .action-btns {
|
||||
padding: 5px 10px 2px 5px;
|
||||
padding: 7px 10px 2px 5px;
|
||||
}
|
||||
.timeline .timeline-item.user-content .action-btns .edit-btn-container {
|
||||
margin-right: 0;
|
||||
}
|
||||
.timeline .timeline-item.user-content .comment-header {
|
||||
padding: 7px 10px;
|
||||
|
|
@ -364,6 +370,12 @@ body {
|
|||
.timeline .timeline-item.user-content .comment-header .links-active {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.timeline .timeline-item.user-content .comment-header .reply-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
.timeline .timeline-item.user-content .comment-header .asset-details {
|
||||
width: calc(100% - 30px);
|
||||
}
|
||||
.timeline .timeline-item.user-content .avatar-medium {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@
|
|||
vertical-align: middle;
|
||||
}
|
||||
.page-title .title-image {
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 0;
|
||||
padding: 23px 0;
|
||||
|
|
@ -56,6 +55,7 @@
|
|||
text-align: center;
|
||||
line-height: 0;
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.editable-title .title-text {
|
||||
cursor: pointer;
|
||||
|
|
|
|||
|
|
@ -507,6 +507,7 @@ li {
|
|||
border-top: 1px solid #EBEFF2;
|
||||
}
|
||||
.page_content {
|
||||
padding-top: 30px;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
.carousel-control .icon {
|
||||
|
|
@ -554,6 +555,9 @@ li {
|
|||
.panel-body {
|
||||
padding-left: 15px;
|
||||
}
|
||||
.page-head {
|
||||
margin-bottom: -30px;
|
||||
}
|
||||
.page-head h1,
|
||||
.page-head h2 {
|
||||
margin-top: 0px;
|
||||
|
|
@ -588,9 +592,14 @@ fieldset {
|
|||
width: 100%;
|
||||
}
|
||||
.page-container {
|
||||
padding: 0px;
|
||||
display: flex;
|
||||
max-width: 970px;
|
||||
margin: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.page-container {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
||||
.page-max-width {
|
||||
max-width: 800px;
|
||||
|
|
@ -603,30 +612,28 @@ fieldset {
|
|||
.web-sidebar {
|
||||
position: relative;
|
||||
}
|
||||
.web-sidebar .sidebar-item {
|
||||
.web-sidebar .sidebar-item:not(:last-child) {
|
||||
margin: 0px;
|
||||
padding-bottom: 12px;
|
||||
border: none;
|
||||
color: #8D99A6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.web-sidebar .sidebar-item .badge {
|
||||
.web-sidebar .sidebar-item:not(:last-child) .badge {
|
||||
font-weight: normal;
|
||||
}
|
||||
.web-sidebar .sidebar-item a {
|
||||
color: #36414C !important;
|
||||
color: #8D99A6;
|
||||
}
|
||||
.web-sidebar .sidebar-item a.active {
|
||||
color: #36414C !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
.web-sidebar .sidebar-items {
|
||||
margin-bottom: 30px;
|
||||
color: #36414C;
|
||||
}
|
||||
.web-sidebar .sidebar-items .title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.web-sidebar .sidebar-items ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.page-footer {
|
||||
padding: 15px 0px;
|
||||
border-top: 1px solid #EBEFF2;
|
||||
|
|
@ -712,11 +719,6 @@ textarea {
|
|||
.sidebar-navbar-items a:visited {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.visible-xs {
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
.more-block {
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
|
@ -790,16 +792,49 @@ a.active {
|
|||
.btn-next-wrapper {
|
||||
margin-top: 60px;
|
||||
}
|
||||
.sidebar-block,
|
||||
.sidebar-block {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
border-right: 1px solid #d1d8dd;
|
||||
padding: 30px;
|
||||
padding-left: 0px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.sidebar-block {
|
||||
font-size: 14px;
|
||||
border-right: none;
|
||||
border-top: 1px solid #d1d8dd;
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
.page-content {
|
||||
flex: 6;
|
||||
}
|
||||
.page-content h1:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.page-content.with-sidebar {
|
||||
padding: 30px;
|
||||
padding-left: 40px;
|
||||
}
|
||||
.page-content.without-sidebar {
|
||||
padding-top: 30px;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
.your-account-info {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.page-content.with-sidebar {
|
||||
padding-left: 50px;
|
||||
@media (max-width: 767px) {
|
||||
.visible-xs {
|
||||
display: inline-block !important;
|
||||
}
|
||||
.sidebar-block {
|
||||
width: 100%;
|
||||
}
|
||||
.page-content.with-sidebar {
|
||||
width: 100%;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 480px) {
|
||||
.page-content {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ frappe.Application = Class.extend({
|
|||
this.make_nav_bar();
|
||||
this.set_favicon();
|
||||
this.setup_analytics();
|
||||
this.setup_beforeunload();
|
||||
frappe.ui.keys.setup();
|
||||
this.set_rtl();
|
||||
|
||||
|
|
@ -480,6 +481,23 @@ frappe.Application = Class.extend({
|
|||
}
|
||||
},
|
||||
|
||||
setup_beforeunload: function() {
|
||||
if (frappe.defaults.get_default('in_selenium')) {
|
||||
return;
|
||||
}
|
||||
window.onbeforeunload = function () {
|
||||
if (frappe.flags.in_test) return null;
|
||||
var unsaved_docs = [];
|
||||
for (doctype in locals) {
|
||||
for (name in locals[doctype]) {
|
||||
var doc = locals[doctype][name];
|
||||
if(doc.__unsaved) { unsaved_docs.push(doc.name); }
|
||||
}
|
||||
}
|
||||
return unsaved_docs.length ? true : null;
|
||||
};
|
||||
},
|
||||
|
||||
show_notes: function() {
|
||||
var me = this;
|
||||
if(frappe.boot.notes.length) {
|
||||
|
|
|
|||
|
|
@ -688,6 +688,8 @@ frappe.ui.form.ControlColor = frappe.ui.form.ControlData.extend({
|
|||
},
|
||||
set_formatted_input: function(value) {
|
||||
this._super(value);
|
||||
|
||||
if(!value) value = '#ffffff';
|
||||
this.$input.css({
|
||||
"background-color": value
|
||||
});
|
||||
|
|
@ -721,6 +723,9 @@ frappe.ui.form.ControlColor = frappe.ui.form.ControlData.extend({
|
|||
});
|
||||
},
|
||||
validate: function (value) {
|
||||
if(value === '') {
|
||||
return '';
|
||||
}
|
||||
var is_valid = /^#[0-9A-F]{6}$/i.test(value);
|
||||
if(is_valid) {
|
||||
return value;
|
||||
|
|
|
|||
|
|
@ -159,12 +159,12 @@ frappe.ui.form.Timeline = Class.extend({
|
|||
this.prepare_timeline_item(c);
|
||||
var $timeline_item = $(frappe.render_template("timeline_item", {data:c, frm:this.frm}))
|
||||
.appendTo(me.list)
|
||||
.on("click", ".close", function() {
|
||||
.on("click", ".delete-comment", function() {
|
||||
var name = $timeline_item.data('name');
|
||||
me.delete_comment(name);
|
||||
return false;
|
||||
})
|
||||
.on('click', '.edit', function(e) {
|
||||
.on('click', '.edit-comment', function(e) {
|
||||
e.preventDefault();
|
||||
var name = $timeline_item.data('name');
|
||||
|
||||
|
|
@ -176,6 +176,7 @@ frappe.ui.form.Timeline = Class.extend({
|
|||
var content = $timeline_item.find('.timeline-item-content').html();
|
||||
|
||||
$edit_btn
|
||||
.text("Save")
|
||||
.find('i')
|
||||
.removeClass('octicon-pencil')
|
||||
.addClass('octicon-check');
|
||||
|
|
@ -232,6 +233,7 @@ frappe.ui.form.Timeline = Class.extend({
|
|||
new frappe.views.CommunicationComposer({
|
||||
doc: me.frm.doc,
|
||||
txt: "",
|
||||
title: __('Reply'),
|
||||
frm: me.frm,
|
||||
last_email: last_email
|
||||
});
|
||||
|
|
@ -251,11 +253,11 @@ frappe.ui.form.Timeline = Class.extend({
|
|||
c["edit"] = "";
|
||||
if(c.communication_type=="Comment" && (c.comment_type || "Comment") === "Comment") {
|
||||
if(frappe.model.can_delete("Communication")) {
|
||||
c["delete"] = '<a class="close" title="Delete" href="#"><i class="octicon octicon-x"></i></a>';
|
||||
c["delete"] = '<a class="close delete-comment" title="Delete" href="#"><i class="octicon octicon-x"></i></a>';
|
||||
}
|
||||
|
||||
if(frappe.user.name == c.sender || (frappe.user.name == 'Administrator')) {
|
||||
c["edit"] = '<a class="edit" title="Edit" href="#"><i class="octicon octicon-pencil"></i></a>';
|
||||
c["edit"] = '<a class="edit-comment text-muted" title="Edit" href="#">Edit</a>';
|
||||
}
|
||||
}
|
||||
c.comment_on_small = comment_when(c.creation, true);
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
{% if (data.communication_medium === "Email"
|
||||
&& data.sender !== frappe.session.user_email) { %}
|
||||
<a class="text-muted reply-link pull-right timeline-content-show"
|
||||
data-name="{%= data.name %}" title="{%= __("Reply") %}"><i class="octicon octicon-mail-reply"></i></a>
|
||||
data-name="{%= data.name %}" title="{%= __("Reply") %}">{%= __("Reply") %}</a>
|
||||
{% } %}
|
||||
{% } %}
|
||||
<span class="text-muted commented-on hidden-xs">
|
||||
|
|
|
|||
|
|
@ -132,11 +132,14 @@ frappe.ui.form.PrintPreview = Class.extend({
|
|||
show_footer: function() {
|
||||
// footer is hidden by default as reqd by pdf generation
|
||||
// simple hack to show it in print preview
|
||||
this.wrapper.find('.print-format').css('position', 'relative');
|
||||
this.wrapper.find('.page-break').css({
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column'
|
||||
});
|
||||
this.wrapper.find('#footer-html').attr('style', `
|
||||
display: block !important;
|
||||
position: absolute;
|
||||
bottom: 0.75in;
|
||||
order: 1;
|
||||
margin-top: 20px;
|
||||
`);
|
||||
},
|
||||
printit: function () {
|
||||
|
|
|
|||
|
|
@ -197,14 +197,14 @@ frappe.ui.BaseList = Class.extend({
|
|||
onchange: () => { me.refresh(true); }
|
||||
});
|
||||
|
||||
this.meta.fields.forEach(function(df) {
|
||||
this.meta.fields.forEach(function(df, i) {
|
||||
if(df.in_standard_filter && !frappe.model.no_value_type.includes(df.fieldtype)) {
|
||||
let options = df.options;
|
||||
let condition = '=';
|
||||
let fieldtype = df.fieldtype;
|
||||
if (['Text', 'Small Text', 'Text Editor', 'Data'].includes(fieldtype)) {
|
||||
fieldtype = 'Data',
|
||||
condition = 'like'
|
||||
fieldtype = 'Data';
|
||||
condition = 'like';
|
||||
}
|
||||
if(df.fieldtype == "Select" && df.options) {
|
||||
options = df.options.split("\n");
|
||||
|
|
@ -213,7 +213,7 @@ frappe.ui.BaseList = Class.extend({
|
|||
options = options.join("\n");
|
||||
}
|
||||
}
|
||||
me.page.add_field({
|
||||
let f = me.page.add_field({
|
||||
fieldtype: fieldtype,
|
||||
label: __(df.label),
|
||||
options: options,
|
||||
|
|
@ -221,6 +221,13 @@ frappe.ui.BaseList = Class.extend({
|
|||
condition: condition,
|
||||
onchange: () => {me.refresh(true);}
|
||||
});
|
||||
filter_count ++;
|
||||
if (filter_count > 3) {
|
||||
$(f.wrapper).addClass('hidden-sm').addClass('hidden-xs');
|
||||
}
|
||||
if (filter_count > 5) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
121
frappe/public/js/frappe/ui/colors.js
Normal file
121
frappe/public/js/frappe/ui/colors.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// MIT License. See license.txt
|
||||
|
||||
frappe.provide("frappe.ui");
|
||||
|
||||
frappe.ui.color_map = {
|
||||
red: ["#ffc4c4", "#ff8989", "#ff4d4d", "#a83333"],
|
||||
brown: ["#ffe8cd", "#ffd19c", "#ffb868", "#a87945"],
|
||||
orange: ["#ffd2c2", "#ffa685", "#ff7846", "#a85b5b"],
|
||||
peach: ["#ffd7d7", "#ffb1b1", "#ff8989", "#a84f2e"],
|
||||
yellow: ["#fffacd", "#fff168", "#fff69c", "#a89f45"],
|
||||
yellowgreen: ["#ebf8cc", "#d9f399", "#c5ec63", "#7b933d"],
|
||||
green: ["#cef6d1", "#9deca2", "#6be273", "#428b46"],
|
||||
cyan: ["#d2f8ed", "#a4f3dd", "#77ecca", "#49937e"],
|
||||
skyblue: ["#d2f1ff", "#a6e4ff", "#78d6ff", "#4f8ea8"],
|
||||
blue: ["#d2d2ff", "#a3a3ff", "#7575ff", "#4d4da8"],
|
||||
purple: ["#dac7ff", "#b592ff", "#8e58ff", "#5e3aa8"],
|
||||
pink: ["#f8d4f8", "#f3aaf0", "#ec7dea", "#934f92"]
|
||||
};
|
||||
|
||||
frappe.ui.color = {
|
||||
get: function(color_name, shade) {
|
||||
if(color_name && shade) return this.get_color_shade(color_name, shade);
|
||||
if(color_name) return this.get_color_shade(color_name, 'default');
|
||||
return frappe.ui.color_map;
|
||||
},
|
||||
get_color: function(color_name) {
|
||||
const color_names = Object.keys(frappe.ui.color_map);
|
||||
if(color_names.includes(color_name)) {
|
||||
return frappe.ui.color_map[color_name];
|
||||
} else {
|
||||
throw new RangeError(`${color_name} can be one of ${color_names}`);
|
||||
}
|
||||
},
|
||||
get_color_shade: function(color_name, shade) {
|
||||
const shades = {
|
||||
'default': 2,
|
||||
'light': 1,
|
||||
'extra-light': 0,
|
||||
'dark': 3
|
||||
};
|
||||
|
||||
if(Object.keys(shades).includes(shade)) {
|
||||
return frappe.ui.color_map[color_name][shades[shade]];
|
||||
} else {
|
||||
throw new RangeError(`${shade} can be one of ${Object.keys(shades)}`);
|
||||
}
|
||||
},
|
||||
all: function() {
|
||||
return Object.values(frappe.ui.color_map)
|
||||
.reduce((acc, curr) => acc.concat(curr) , []);
|
||||
},
|
||||
names: function() {
|
||||
return Object.keys(frappe.ui.color_map);
|
||||
},
|
||||
validate: function(color_name) {
|
||||
if(!color_name) return false;
|
||||
if(color_name.startsWith('#')) {
|
||||
return this.all().includes(color_name);
|
||||
}
|
||||
return this.names().includes(color_name);
|
||||
},
|
||||
get_color_name: function(hex) {
|
||||
for (const key in frappe.ui.color_map) {
|
||||
const colors = frappe.ui.color_map[key];
|
||||
if (colors.includes(hex)) return key;
|
||||
}
|
||||
},
|
||||
get_contrast_color: function(hex) {
|
||||
if(!this.validate(hex)) {
|
||||
const brightness = this.brightness(hex);
|
||||
if(brightness < 128) {
|
||||
return this.lighten(hex, 0.5);
|
||||
}
|
||||
return this.lighten(hex, -0.5);
|
||||
}
|
||||
|
||||
const color_name = this.get_color_name(hex);
|
||||
const colors = this.get_color(color_name);
|
||||
const shade_value = colors.indexOf(hex);
|
||||
if(shade_value <= 1) {
|
||||
return this.get(color_name, 'dark');
|
||||
}
|
||||
return this.get(color_name, 'extra-light');
|
||||
},
|
||||
|
||||
lighten(color, percent) {
|
||||
// https://stackoverflow.com/a/13542669/5353542
|
||||
var f = parseInt(color.slice(1), 16),
|
||||
t = percent < 0 ? 0 : 255,
|
||||
p = percent < 0 ? percent * -1 : percent,
|
||||
R = f >> 16,
|
||||
G = f >> 8 & 0x00FF,
|
||||
B = f & 0x0000FF;
|
||||
return "#" +
|
||||
(0x1000000 +
|
||||
(Math.round((t - R) * p) + R) *
|
||||
0x10000 +
|
||||
(Math.round((t - G) * p) + G) *
|
||||
0x100 + (Math.round((t - B) * p) + B)
|
||||
).toString(16).slice(1);
|
||||
},
|
||||
|
||||
hex_to_rgb(hex) {
|
||||
if(hex.startsWith('#')) {
|
||||
hex = hex.substring(1);
|
||||
}
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
return {r, g, b};
|
||||
},
|
||||
|
||||
brightness(hex) {
|
||||
const rgb = this.hex_to_rgb(hex);
|
||||
// https://www.w3.org/TR/AERT#color-contrast
|
||||
// 255 - brightest (#fff)
|
||||
// 0 - darkest (#000)
|
||||
return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
|
||||
}
|
||||
};
|
||||
|
|
@ -401,8 +401,13 @@ frappe.ui.Page = Class.extend({
|
|||
.addClass('col-md-2')
|
||||
.attr("title", __(df.label)).tooltip();
|
||||
|
||||
// html fields in toolbar are only for display
|
||||
if (df.fieldtype=='HTML') {
|
||||
return;
|
||||
}
|
||||
|
||||
// hidden fields dont have $input
|
||||
if(!f.$input) f.make_input();
|
||||
if (!f.$input) f.make_input();
|
||||
|
||||
f.$input.addClass("input-sm").attr("placeholder", __(df.label));
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,8 @@ frappe.views.Calendar = Class.extend({
|
|||
color_map: {
|
||||
"danger": "red",
|
||||
"success": "green",
|
||||
"warning": "orange"
|
||||
"warning": "orange",
|
||||
"default": "blue"
|
||||
},
|
||||
get_system_datetime: function(date) {
|
||||
date._offset = moment.user_utc_offset;
|
||||
|
|
@ -232,25 +233,28 @@ frappe.views.Calendar = Class.extend({
|
|||
d.end = frappe.datetime.convert_to_user_tz(d.end);
|
||||
|
||||
me.fix_end_date_for_event_render(d);
|
||||
|
||||
let color;
|
||||
if(me.get_css_class) {
|
||||
color = me.color_map[me.get_css_class(d)];
|
||||
// if invalid, fallback to blue color
|
||||
if(!Object.values(me.color_map).includes(color)) {
|
||||
color = "blue";
|
||||
}
|
||||
} else {
|
||||
// color field can be set in {doctype}_calendar.js
|
||||
// see event_calendar.js
|
||||
color = d.color;
|
||||
}
|
||||
|
||||
if(!color) color = "blue";
|
||||
d.className = "fc-bg-" + color;
|
||||
me.prepare_colors(d);
|
||||
return d;
|
||||
});
|
||||
},
|
||||
prepare_colors: function(d) {
|
||||
let color, color_name;
|
||||
if(this.get_css_class) {
|
||||
color_name = this.color_map[this.get_css_class(d)];
|
||||
color_name =
|
||||
frappe.ui.color.validate(color_name) ?
|
||||
color_name :
|
||||
'blue';
|
||||
d.backgroundColor = frappe.ui.color.get(color_name, 'extra-light');
|
||||
d.textColor = frappe.ui.color.get(color_name, 'dark');
|
||||
} else {
|
||||
color = d.color;
|
||||
if(!color) color = frappe.ui.color.get('blue', 'extra-light');
|
||||
d.backgroundColor = color;
|
||||
d.textColor = frappe.ui.color.get_contrast_color(color);
|
||||
}
|
||||
return d;
|
||||
},
|
||||
update_event: function(event, revertFunc) {
|
||||
var me = this;
|
||||
frappe.model.remove_from_locals(me.doctype, event.name);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
make: function() {
|
||||
var me = this;
|
||||
this.dialog = new frappe.ui.Dialog({
|
||||
title: (this.subject || ""),
|
||||
title: (this.title || this.subject || __("New Email")),
|
||||
no_submit_on_enter: true,
|
||||
fields: this.get_fields(),
|
||||
primary_action_label: __("Send"),
|
||||
|
|
@ -49,12 +49,12 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
var fields= [
|
||||
{label:__("To"), fieldtype:"Data", reqd: 0, fieldname:"recipients",length:524288},
|
||||
{fieldtype: "Section Break", collapsible: 1, label: "CC & Standard Reply"},
|
||||
{label:__("CC"), fieldtype:"Data", fieldname:"cc",length:524288},
|
||||
{label:__("CC"), fieldtype:"Data", fieldname:"cc", length:524288},
|
||||
{label:__("Standard Reply"), fieldtype:"Link", options:"Standard Reply",
|
||||
fieldname:"standard_reply"},
|
||||
{fieldtype: "Section Break"},
|
||||
{label:__("Subject"), fieldtype:"Data", reqd: 1,
|
||||
fieldname:"subject",length:524288},
|
||||
fieldname:"subject", length:524288},
|
||||
{fieldtype: "Section Break"},
|
||||
{label:__("Message"), fieldtype:"Text Editor", reqd: 1,
|
||||
fieldname:"content"},
|
||||
|
|
@ -444,6 +444,7 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
|
||||
send_email: function(btn, form_values, selected_attachments, print_html, print_format) {
|
||||
var me = this;
|
||||
me.dialog.hide();
|
||||
|
||||
if((form_values.send_email || form_values.communication_medium === "Email") && !form_values.recipients) {
|
||||
frappe.msgprint(__("Enter Email Recipient(s)"));
|
||||
|
|
@ -496,8 +497,6 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
[ frappe.utils.escape_html(r.message["emails_not_sent_to"]) ]) );
|
||||
}
|
||||
|
||||
me.dialog.hide();
|
||||
|
||||
if ((frappe.last_edited_communication[me.doc] || {})[me.key]) {
|
||||
delete frappe.last_edited_communication[me.doc][me.key];
|
||||
}
|
||||
|
|
@ -506,7 +505,7 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
cur_frm.timeline.input && cur_frm.timeline.input.val("");
|
||||
cur_frm.reload_doc();
|
||||
}
|
||||
|
||||
|
||||
// try the success callback if it exists
|
||||
if (me.success) {
|
||||
try {
|
||||
|
|
@ -515,10 +514,10 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
frappe.msgprint(__("There were errors while sending email. Please try again."));
|
||||
|
||||
|
||||
// try the error callback if it exists
|
||||
if (me.error) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -184,12 +184,12 @@ frappe.views.QueryReport = Class.extend({
|
|||
frappe.msgprint(__("You are not allowed to print this report"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if(this.html_format) {
|
||||
var content = frappe.render(this.html_format, {
|
||||
data: frappe.slickgrid_tools.get_filtered_items(this.dataView),
|
||||
filters: this.get_values(),
|
||||
report: this
|
||||
report: this,
|
||||
data_to_be_printed: this.data_to_be_printed
|
||||
});
|
||||
|
||||
frappe.render_grid({
|
||||
|
|
@ -223,7 +223,8 @@ frappe.views.QueryReport = Class.extend({
|
|||
var content = frappe.render(this.html_format, {
|
||||
data: frappe.slickgrid_tools.get_filtered_items(this.dataView),
|
||||
filters:this.get_values(),
|
||||
report:this
|
||||
report:this,
|
||||
data_to_be_printed: this.data_to_be_printed
|
||||
});
|
||||
|
||||
//Render Report in HTML
|
||||
|
|
@ -487,6 +488,7 @@ frappe.views.QueryReport = Class.extend({
|
|||
|
||||
this.set_message(res.message);
|
||||
this.setup_chart(res);
|
||||
this.set_print_data(res.data_to_be_printed);
|
||||
|
||||
this.toggle_expand_collapse_buttons(this.is_tree_report);
|
||||
},
|
||||
|
|
@ -897,5 +899,9 @@ frappe.views.QueryReport = Class.extend({
|
|||
if(this.chart && opts.data && opts.data.rows && opts.data.rows.length) {
|
||||
this.chart_area.toggle(true);
|
||||
}
|
||||
},
|
||||
|
||||
set_print_data: function(data_to_be_printed) {
|
||||
this.data_to_be_printed = data_to_be_printed;
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -335,7 +335,7 @@ _f.Frm.prototype.refresh_header = function(is_a_different_doc) {
|
|||
! this.is_dirty() &&
|
||||
! this.is_new() &&
|
||||
this.doc.docstatus===0) {
|
||||
this.dashboard.add_comment(__('Submit this document to confirm'), 'alert-warning', true);
|
||||
this.dashboard.add_comment(__('Submit this document to confirm'), 'orange', true);
|
||||
}
|
||||
|
||||
this.clear_custom_buttons();
|
||||
|
|
@ -459,6 +459,7 @@ _f.Frm.prototype.refresh = function(docname) {
|
|||
_f.Frm.prototype.show_if_needs_refresh = function() {
|
||||
if(this.doc.__needs_refresh) {
|
||||
if(this.doc.__unsaved) {
|
||||
this.dashboard.clear_headline();
|
||||
this.dashboard.set_headline_alert(__("This form has been modified after you have loaded it")
|
||||
+ '<a class="btn btn-xs btn-primary pull-right" onclick="cur_frm.reload_doc()">'
|
||||
+ __("Refresh") + '</a>', "alert-warning");
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ th.fc-widget-header {
|
|||
|
||||
.fc-unthemed .fc-today {
|
||||
background-color: #FFF !important;
|
||||
|
||||
|
||||
.fc-day-number {
|
||||
background-color: @brand-primary;
|
||||
min-width: 20px;
|
||||
|
|
@ -90,7 +90,6 @@ th.fc-day-header {
|
|||
}
|
||||
|
||||
.fc-day-grid-event {
|
||||
background-color: rgba(94, 100, 255, 0.2) !important;
|
||||
border: none !important;
|
||||
margin: 5px 4px 0 !important;
|
||||
padding: 1px 5px !important;
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ hr {
|
|||
}
|
||||
|
||||
.email-footer-container {
|
||||
margin-top: 10px;
|
||||
margin-top: 30px;
|
||||
|
||||
& > div:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
|
|
|
|||
|
|
@ -391,17 +391,32 @@ h6.uppercase, .h6.uppercase {
|
|||
.action-btns {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
padding: 5px 15px 2px 5px;
|
||||
padding: 8px 15px 0 5px;
|
||||
.edit-btn-container {
|
||||
margin-right: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
background-color: @light-bg;
|
||||
padding: 10px 15px 10px 13px;
|
||||
padding: 10px 15px 8px 13px;
|
||||
margin: 0px;
|
||||
color: @text-muted;
|
||||
border-bottom: 1px solid @light-border-color;
|
||||
&.links-active {
|
||||
padding-right: 60px;
|
||||
padding-right: 77px;
|
||||
}
|
||||
.asset-details {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
.btn-link {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.commented-on-small {
|
||||
display: none;
|
||||
|
|
@ -434,7 +449,8 @@ h6.uppercase, .h6.uppercase {
|
|||
.close {
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
padding: 0 0 0 10px;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -530,7 +546,8 @@ h6.uppercase, .h6.uppercase {
|
|||
}
|
||||
|
||||
.timeline-item .reply-link {
|
||||
padding-left: 7px;
|
||||
margin-left: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timeline-head {
|
||||
|
|
|
|||
|
|
@ -226,8 +226,27 @@
|
|||
padding: 5px 15px;
|
||||
}
|
||||
|
||||
.listview-main-section .octicon-heart {
|
||||
cursor: pointer;
|
||||
.listview-main-section {
|
||||
.octicon-heart {
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-form {
|
||||
padding-left: 17px;
|
||||
|
||||
@media (max-width: @screen-sm) {
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
.octicon-search {
|
||||
float: left;
|
||||
padding-top: 7px;
|
||||
margin-left: -4px;
|
||||
margin-right: -4px;
|
||||
@media (max-width: @screen-sm) {
|
||||
margin-left: -12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.like-action.octicon-heart {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ body {
|
|||
body[data-route^="Form"] {
|
||||
.page-title h1 {
|
||||
margin-top: 12px;
|
||||
&.editable-title {
|
||||
padding-right: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title .indicator {
|
||||
|
|
@ -230,7 +233,7 @@ body {
|
|||
.page-title {
|
||||
.title-text {
|
||||
font-size: 16px;
|
||||
width: calc(~"100% - 30px");
|
||||
width: calc(~"100% - 90px");
|
||||
}
|
||||
.indicator {
|
||||
float: left;
|
||||
|
|
@ -432,13 +435,22 @@ body {
|
|||
}
|
||||
}
|
||||
.action-btns {
|
||||
padding: 5px 10px 2px 5px;
|
||||
padding: 7px 10px 2px 5px;
|
||||
.edit-btn-container {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.comment-header{
|
||||
padding: 7px 10px;
|
||||
.links-active {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.reply-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
.asset-details {
|
||||
width: calc(~"100% - 30px")
|
||||
}
|
||||
}
|
||||
.avatar-medium {
|
||||
margin-right: 10px;
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@
|
|||
}
|
||||
|
||||
.title-image {
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 0;
|
||||
padding: 23px 0;
|
||||
|
|
@ -66,6 +65,7 @@
|
|||
text-align: center;
|
||||
line-height: 0;
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ li {
|
|||
}
|
||||
|
||||
.page_content {
|
||||
padding-top: 30px;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
||||
|
|
@ -181,6 +182,7 @@ li {
|
|||
}
|
||||
|
||||
.page-head {
|
||||
margin-bottom: -30px;
|
||||
h1, h2 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
|
@ -221,9 +223,13 @@ fieldset {
|
|||
}
|
||||
|
||||
.page-container {
|
||||
padding: 0px;
|
||||
display: flex;
|
||||
max-width: 970px;
|
||||
margin: auto;
|
||||
margin: 0 auto;
|
||||
|
||||
@media(max-width: @screen-xs) {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.page-max-width {
|
||||
|
|
@ -241,12 +247,11 @@ fieldset {
|
|||
.web-sidebar {
|
||||
position: relative;
|
||||
|
||||
.sidebar-item {
|
||||
.sidebar-item:not(:last-child) {
|
||||
margin: 0px;
|
||||
padding-bottom: 12px;
|
||||
border: none;
|
||||
color: @text-muted;
|
||||
font-size: 12px;
|
||||
|
||||
.badge {
|
||||
font-weight: normal;
|
||||
|
|
@ -255,21 +260,22 @@ fieldset {
|
|||
}
|
||||
|
||||
.sidebar-item a {
|
||||
color: @text-color !important;
|
||||
}
|
||||
color: @text-muted;
|
||||
|
||||
.sidebar-item a.active {
|
||||
color: @text-color !important;
|
||||
font-weight: 500 !important;
|
||||
&.active {
|
||||
color: @text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-items {
|
||||
// margin-top:30px;
|
||||
margin-bottom:30px;
|
||||
.title{
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -378,11 +384,6 @@ textarea {
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.visible-xs {
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
|
||||
.more-block {
|
||||
padding-bottom: 30px;
|
||||
|
|
@ -477,16 +478,54 @@ a.active {
|
|||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.sidebar-block, .page-content {
|
||||
padding-top: 30px;
|
||||
padding-bottom: 50px;
|
||||
.sidebar-block {
|
||||
flex: 1;
|
||||
font-size: @text-medium;
|
||||
border-right: 1px solid @border-color;
|
||||
padding: 30px;
|
||||
padding-left: 0px;
|
||||
|
||||
@media(max-width: @screen-xs) {
|
||||
font-size: @text-regular;
|
||||
border-right: none;
|
||||
border-top: 1px solid @border-color;
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 6;
|
||||
|
||||
h1:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-content.with-sidebar {
|
||||
padding: 30px;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.page-content.without-sidebar {
|
||||
padding-top: 30px;
|
||||
}
|
||||
|
||||
.your-account-info {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.page-content.with-sidebar {
|
||||
padding-left: 50px;
|
||||
@media (max-width: 767px) {
|
||||
.visible-xs {
|
||||
display: inline-block !important;
|
||||
}
|
||||
.sidebar-block {
|
||||
width: 100%;
|
||||
}
|
||||
.page-content.with-sidebar {
|
||||
width: 100%;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
<div class="more-block {% if not show_more -%} hide {%- endif %}">
|
||||
<button class="btn btn-default btn-more">{{ _("More") }}</button>
|
||||
<button class="btn btn-default btn-more btn-sm">{{ _("More") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{%- endif %}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@ window.disable_signup = {{ disable_signup and "true" or "false" }};
|
|||
|
||||
window.login = {};
|
||||
|
||||
window.verify = {};
|
||||
|
||||
login.bind_events = function() {
|
||||
$(window).on("hashchange", function() {
|
||||
login.route();
|
||||
});
|
||||
|
||||
|
||||
$(".form-login").on("submit", function(event) {
|
||||
event.preventDefault();
|
||||
var args = {};
|
||||
|
|
@ -92,6 +95,11 @@ login.login = function() {
|
|||
$(".for-login").toggle(true);
|
||||
}
|
||||
|
||||
login.steptwo = function() {
|
||||
login.reset_sections();
|
||||
$(".for-login").toggle(true);
|
||||
}
|
||||
|
||||
login.forgot = function() {
|
||||
login.reset_sections();
|
||||
$(".for-forgot").toggle(true);
|
||||
|
|
@ -150,7 +158,7 @@ login.login_handlers = (function() {
|
|||
|
||||
var login_handlers = {
|
||||
200: function(data) {
|
||||
if(data.message=="Logged In") {
|
||||
if(data.message == 'Logged In'){
|
||||
login.set_indicator("{{ _("Success") }}", 'green');
|
||||
window.location.href = get_url_arg("redirect-to") || data.home_page;
|
||||
} else if(data.message=="No App") {
|
||||
|
|
@ -190,15 +198,31 @@ login.login_handlers = (function() {
|
|||
}
|
||||
//login.set_indicator(__(data.message), 'green');
|
||||
}
|
||||
|
||||
//OTP verification
|
||||
if(data.verification && data.message != 'Logged In') {
|
||||
login.set_indicator("{{ _("Success") }}", 'green');
|
||||
|
||||
document.cookie = "tmp_id="+data.tmp_id;
|
||||
|
||||
if (data.verification.method == 'OTP App'){
|
||||
continue_otp_app(data.verification.setup, data.verification.qrcode);
|
||||
} else if (data.verification.method == 'SMS'){
|
||||
continue_sms(data.verification.setup, data.verification.prompt);
|
||||
} else if (data.verification.method == 'Email'){
|
||||
continue_email(data.verification.setup, data.verification.prompt);
|
||||
}
|
||||
}
|
||||
},
|
||||
401: get_error_handler("{{ _("Invalid Login. Try again.") }}"),
|
||||
417: get_error_handler("{{ _("Oops! Something went wrong") }}")
|
||||
};
|
||||
|
||||
return login_handlers;
|
||||
})();
|
||||
} )();
|
||||
|
||||
frappe.ready(function() {
|
||||
|
||||
login.bind_events();
|
||||
|
||||
if (!window.location.hash) {
|
||||
|
|
@ -210,3 +234,76 @@ frappe.ready(function() {
|
|||
$(".form-signup, .form-forgot").removeClass("hide");
|
||||
$(document).trigger('login_rendered');
|
||||
});
|
||||
|
||||
var verify_token = function(event) {
|
||||
$(".form-verify").on("submit", function(eventx) {
|
||||
eventx.preventDefault();
|
||||
var args = {};
|
||||
args.cmd = "login";
|
||||
args.otp = $("#login_token").val();
|
||||
args.tmp_id = frappe.get_cookie('tmp_id');
|
||||
if(!args.otp) {
|
||||
frappe.msgprint('{{ _("Login token required") }}');
|
||||
return false;
|
||||
}
|
||||
login.call(args);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
var request_otp = function(r){
|
||||
$('.login-content').empty().append($('<div>').attr({'id':'twofactor_div'}).html(
|
||||
'<form class="form-verify">\
|
||||
<div class="page-card-head">\
|
||||
<span class="indicator blue" data-text="Verification">Verification</span>\
|
||||
</div>\
|
||||
<div id="otp_div"></div>\
|
||||
<input type="text" id="login_token" autocomplete="off" class="form-control" placeholder="Verification Code" required="" autofocus="">\
|
||||
<button class="btn btn-sm btn-primary btn-block" id="verify_token">Verify</button>\
|
||||
</form>'));
|
||||
// add event handler for submit button
|
||||
verify_token();
|
||||
}
|
||||
|
||||
var continue_otp_app = function(setup, qrcode){
|
||||
request_otp();
|
||||
var qrcode_div = $('<div class="text-muted" style="padding-bottom: 15px;"></div>');
|
||||
|
||||
if (setup){
|
||||
direction = $('<div>').attr('id','qr_info').text('Enter Code displayed in OTP App.');
|
||||
qrcode_div.append(direction);
|
||||
$('#otp_div').prepend(qrcode_div);
|
||||
} else {
|
||||
direction = $('<div>').attr('id','qr_info').text('OTP setup using OTP App was not completed. Please contact Administrator.');
|
||||
qrcode_div.append(direction);
|
||||
$('#otp_div').prepend(qrcode_div);
|
||||
}
|
||||
}
|
||||
|
||||
var continue_sms = function(setup, prompt){
|
||||
request_otp();
|
||||
var sms_div = $('<div class="text-muted" style="padding-bottom: 15px;"></div>');
|
||||
|
||||
if (setup){
|
||||
sms_div.append(prompt)
|
||||
$('#otp_div').prepend(sms_div);
|
||||
} else {
|
||||
direction = $('<div>').attr('id','qr_info').text(prompt || 'SMS was not sent. Please contact Administrator.');
|
||||
sms_div.append(direction);
|
||||
$('#otp_div').prepend(sms_div)
|
||||
}
|
||||
}
|
||||
|
||||
var continue_email = function(setup, prompt){
|
||||
request_otp();
|
||||
var email_div = $('<div class="text-muted" style="padding-bottom: 15px;"></div>');
|
||||
|
||||
if (setup){
|
||||
email_div.append(prompt)
|
||||
$('#otp_div').prepend(email_div);
|
||||
} else {
|
||||
var direction = $('<div>').attr('id','qr_info').text(prompt || 'Verification code email not sent. Please contact Administrator.');
|
||||
email_div.append(direction);
|
||||
$('#otp_div').prepend(email_div);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,13 +6,12 @@
|
|||
data-path="{{ pathname }}"
|
||||
{%- if page_or_generator=="Generator" %}
|
||||
data-doctype="{{ doctype }}"{% endif %}>
|
||||
<div class="row {% if show_sidebar %}vert-line{% endif %}">
|
||||
{% if show_sidebar %}
|
||||
<div class="col-sm-3 sidebar-block hidden-xs">
|
||||
<div class="sidebar-block">
|
||||
{% include "templates/includes/web_sidebar.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="{% if show_sidebar %}page-content with-sidebar col-sm-9{% else %} page-content col-sm-12 {% endif %}">
|
||||
<div class="{% if show_sidebar %}page-content with-sidebar{% else %}page-content without-sidebar{% endif %}">
|
||||
<div class="page-content-wrapper">
|
||||
<div class="row page-head">
|
||||
<div class='col-sm-12'>
|
||||
|
|
@ -48,7 +47,7 @@
|
|||
{%- block page_content -%}{%- endblock -%}
|
||||
</div>
|
||||
</div>
|
||||
<!-- sidebar ends -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
132
frappe/tests/test_twofactor.py
Normal file
132
frappe/tests/test_twofactor.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import unittest, frappe, pyotp
|
||||
from werkzeug.wrappers import Request
|
||||
from werkzeug.test import EnvironBuilder
|
||||
from frappe.auth import HTTPRequest
|
||||
from frappe.twofactor import (should_run_2fa, authenticate_for_2factor, get_cached_user_pass,
|
||||
two_factor_is_enabled_for_, confirm_otp_token, get_otpsecret_for_, get_verification_obj,
|
||||
render_string_template)
|
||||
|
||||
import time
|
||||
|
||||
class TestTwoFactor(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.http_requests = create_http_request()
|
||||
self.login_manager = frappe.local.login_manager
|
||||
self.user = self.login_manager.user
|
||||
|
||||
def tearDown(self):
|
||||
frappe.local.response['verification'] = None
|
||||
frappe.local.response['tmp_id'] = None
|
||||
disable_2fa()
|
||||
frappe.clear_cache(user=self.user)
|
||||
|
||||
def test_should_run_2fa(self):
|
||||
'''Should return true if enabled.'''
|
||||
toggle_2fa_all_role(state=True)
|
||||
self.assertTrue(should_run_2fa(self.user))
|
||||
toggle_2fa_all_role(state=False)
|
||||
self.assertFalse(should_run_2fa(self.user))
|
||||
|
||||
def test_get_cached_user_pass(self):
|
||||
'''Cached data should not contain user and pass before 2fa.'''
|
||||
user,pwd = get_cached_user_pass()
|
||||
self.assertTrue(all([not user, not pwd]))
|
||||
|
||||
def test_authenticate_for_2factor(self):
|
||||
'''Verification obj and tmp_id should be set in frappe.local.'''
|
||||
authenticate_for_2factor(self.user)
|
||||
verification_obj = frappe.local.response['verification']
|
||||
tmp_id = frappe.local.response['tmp_id']
|
||||
self.assertTrue(verification_obj)
|
||||
self.assertTrue(tmp_id)
|
||||
for k in ['_usr','_pwd','_otp_secret']:
|
||||
self.assertTrue(frappe.cache().get('{0}{1}'.format(tmp_id,k)),
|
||||
'{} not available'.format(k))
|
||||
|
||||
def test_two_factor_is_enabled_for_user(self):
|
||||
'''Should return true if enabled for user.'''
|
||||
toggle_2fa_all_role(state=True)
|
||||
self.assertTrue(two_factor_is_enabled_for_(self.user))
|
||||
toggle_2fa_all_role(state=False)
|
||||
self.assertFalse(two_factor_is_enabled_for_(self.user))
|
||||
|
||||
def test_get_otpsecret_for_user(self):
|
||||
'''OTP secret should be set for user.'''
|
||||
self.assertTrue(get_otpsecret_for_(self.user))
|
||||
self.assertTrue(frappe.db.get_default(self.user + '_otpsecret'))
|
||||
|
||||
def test_confirm_otp_token(self):
|
||||
'''Ensure otp is confirmed'''
|
||||
authenticate_for_2factor(self.user)
|
||||
tmp_id = frappe.local.response['tmp_id']
|
||||
otp = 'wrongotp'
|
||||
with self.assertRaises(frappe.AuthenticationError):
|
||||
confirm_otp_token(self.login_manager,otp=otp,tmp_id=tmp_id)
|
||||
otp = get_otp(self.user)
|
||||
self.assertTrue(confirm_otp_token(self.login_manager,otp=otp,tmp_id=tmp_id))
|
||||
if frappe.flags.tests_verbose:
|
||||
print('Sleeping for 30secs to confirm token expires..')
|
||||
time.sleep(30)
|
||||
with self.assertRaises(frappe.AuthenticationError):
|
||||
confirm_otp_token(self.login_manager,otp=otp,tmp_id=tmp_id)
|
||||
|
||||
def test_get_verification_obj(self):
|
||||
'''Confirm verification object is returned.'''
|
||||
otp_secret = get_otpsecret_for_(self.user)
|
||||
token = int(pyotp.TOTP(otp_secret).now())
|
||||
self.assertTrue(get_verification_obj(self.user,token,otp_secret))
|
||||
|
||||
def test_render_string_template(self):
|
||||
'''String template renders as expected with variables.'''
|
||||
args = {'issuer_name':'Frappe Technologies'}
|
||||
_str = 'Verification Code from {{issuer_name}}'
|
||||
_str = render_string_template(_str,args)
|
||||
self.assertEqual(_str,'Verification Code from Frappe Technologies')
|
||||
|
||||
|
||||
def set_request(**kwargs):
|
||||
builder = EnvironBuilder(**kwargs)
|
||||
frappe.local.request = Request(builder.get_environ())
|
||||
|
||||
def create_http_request():
|
||||
'''Get http request object.'''
|
||||
set_request(method='POST', path='login')
|
||||
enable_2fa()
|
||||
frappe.form_dict['usr'] = 'test@erpnext.com'
|
||||
frappe.form_dict['pwd'] = 'test'
|
||||
frappe.local.form_dict['cmd'] = 'login'
|
||||
http_requests = HTTPRequest()
|
||||
return http_requests
|
||||
|
||||
def enable_2fa():
|
||||
'''Enable Two factor in system settings.'''
|
||||
system_settings = frappe.get_doc('System Settings')
|
||||
system_settings.enable_two_factor_auth = 1
|
||||
system_settings.two_factor_method = 'OTP App'
|
||||
system_settings.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
def disable_2fa():
|
||||
system_settings = frappe.get_doc('System Settings')
|
||||
system_settings.enable_two_factor_auth = 0
|
||||
system_settings.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
def toggle_2fa_all_role(state=None):
|
||||
'''Enable or disable 2fa for 'all' role on the system.'''
|
||||
all_role = frappe.get_doc('Role','All')
|
||||
if state == None:
|
||||
state = False if all_role.two_factor_auth == True else False
|
||||
if state not in [True,False]:return
|
||||
all_role.two_factor_auth = state
|
||||
all_role.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
def get_otp(user):
|
||||
otp_secret = get_otpsecret_for_(user)
|
||||
otp = pyotp.TOTP(otp_secret)
|
||||
return otp.now()
|
||||
|
|
@ -6,6 +6,7 @@ class TestTestRunner(unittest.TestCase):
|
|||
def test_test_runner(self):
|
||||
driver = TestDriver()
|
||||
driver.login()
|
||||
frappe.db.set_default('in_selenium', '1')
|
||||
for test in get_tests():
|
||||
if test.startswith('#'):
|
||||
continue
|
||||
|
|
@ -33,6 +34,7 @@ class TestTestRunner(unittest.TestCase):
|
|||
print('Checking if passed "{0}"'.format(test))
|
||||
self.assertTrue('Tests Passed' in console)
|
||||
time.sleep(1)
|
||||
frappe.db.set_default('in_selenium', None)
|
||||
driver.close()
|
||||
|
||||
def get_tests():
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ frappe/tests/ui/test_kanban/test_kanban_filters.js
|
|||
frappe/tests/ui/test_kanban/test_kanban_column.js
|
||||
frappe/core/doctype/report/test_query_report.js
|
||||
frappe/tests/ui/test_linked_with.js
|
||||
frappe/custom/doctype/customize_form/test_customize_form.js
|
||||
frappe/custom/doctype/customize_form/test_customize_form.js
|
||||
frappe/desk/doctype/event/test_event.js
|
||||
|
|
|
|||
369
frappe/twofactor.py
Normal file
369
frappe/twofactor.py
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
import pyotp, os
|
||||
from frappe.utils.background_jobs import enqueue
|
||||
from jinja2 import Template
|
||||
from pyqrcode import create as qrcreate
|
||||
from StringIO import StringIO
|
||||
from base64 import b64encode, b32encode
|
||||
from frappe.utils import get_url, get_datetime, time_diff_in_seconds
|
||||
|
||||
class ExpiredLoginException(Exception): pass
|
||||
|
||||
def toggle_two_factor_auth(state, roles=[]):
|
||||
'''Enable or disable 2FA in site_config and roles'''
|
||||
for role in roles:
|
||||
role = frappe.get_doc('Role', {'role_name': role})
|
||||
role.two_factor_auth = state
|
||||
role.save(ignore_permissions=True)
|
||||
|
||||
def two_factor_is_enabled(user=None):
|
||||
'''Returns True if 2FA is enabled.'''
|
||||
enabled = int(frappe.db.get_value('System Settings', None, 'enable_two_factor_auth') or 0)
|
||||
if not user or not enabled:
|
||||
return enabled
|
||||
return two_factor_is_enabled_for_(user)
|
||||
|
||||
def should_run_2fa(user):
|
||||
'''Check if 2fa should run.'''
|
||||
return two_factor_is_enabled(user=user)
|
||||
|
||||
def get_cached_user_pass():
|
||||
'''Get user and password if set.'''
|
||||
user = pwd = None
|
||||
tmp_id = frappe.form_dict.get('tmp_id')
|
||||
if tmp_id:
|
||||
user = frappe.cache().get(tmp_id+'_usr')
|
||||
pwd = frappe.cache().get(tmp_id+'_pwd')
|
||||
return (user, pwd)
|
||||
|
||||
def authenticate_for_2factor(user):
|
||||
'''Authenticate two factor for enabled user before login.'''
|
||||
if frappe.form_dict.get('otp'):
|
||||
return
|
||||
otp_secret = get_otpsecret_for_(user)
|
||||
token = int(pyotp.TOTP(otp_secret).now())
|
||||
tmp_id = frappe.generate_hash(length=8)
|
||||
cache_2fa_data(user, token, otp_secret, tmp_id)
|
||||
verification_obj = get_verification_obj(user, token, otp_secret)
|
||||
# Save data in local
|
||||
frappe.local.response['verification'] = verification_obj
|
||||
frappe.local.response['tmp_id'] = tmp_id
|
||||
|
||||
def cache_2fa_data(user, token, otp_secret, tmp_id):
|
||||
'''Cache and set expiry for data.'''
|
||||
pwd = frappe.form_dict.get('pwd')
|
||||
verification_method = get_verification_method()
|
||||
|
||||
# set increased expiry time for SMS and Email
|
||||
if verification_method in ['SMS', 'Email']:
|
||||
expiry_time = 300
|
||||
frappe.cache().set(tmp_id + '_token', token)
|
||||
frappe.cache().expire(tmp_id + '_token', expiry_time)
|
||||
else:
|
||||
expiry_time = 180
|
||||
for k, v in {'_usr': user, '_pwd': pwd, '_otp_secret': otp_secret}.iteritems():
|
||||
frappe.cache().set("{0}{1}".format(tmp_id, k), v)
|
||||
frappe.cache().expire("{0}{1}".format(tmp_id, k), expiry_time)
|
||||
|
||||
def two_factor_is_enabled_for_(user):
|
||||
'''Check if 2factor is enabled for user.'''
|
||||
if isinstance(user, basestring):
|
||||
user = frappe.get_doc('User', user)
|
||||
|
||||
roles = [frappe.db.escape(d.role) for d in user.roles or []]
|
||||
roles.append('All')
|
||||
|
||||
query = """select name from `tabRole` where two_factor_auth=1
|
||||
and name in ({0}) limit 1""".format(', '.join('\"{}\"'.format(i) for \
|
||||
i in roles))
|
||||
if len(frappe.db.sql(query)) > 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_otpsecret_for_(user):
|
||||
'''Set OTP Secret for user even if not set.'''
|
||||
otp_secret = frappe.db.get_default(user + '_otpsecret')
|
||||
if not otp_secret:
|
||||
otp_secret = b32encode(os.urandom(10)).decode('utf-8')
|
||||
frappe.db.set_default(user + '_otpsecret', otp_secret)
|
||||
frappe.db.commit()
|
||||
return otp_secret
|
||||
|
||||
def get_verification_method():
|
||||
return frappe.db.get_value('System Settings', None, 'two_factor_method')
|
||||
|
||||
def confirm_otp_token(login_manager, otp=None, tmp_id=None):
|
||||
'''Confirm otp matches.'''
|
||||
if not otp:
|
||||
otp = frappe.form_dict.get('otp')
|
||||
if not otp:
|
||||
if two_factor_is_enabled_for_(login_manager.user):
|
||||
return False
|
||||
return True
|
||||
if not tmp_id:
|
||||
tmp_id = frappe.form_dict.get('tmp_id')
|
||||
hotp_token = frappe.cache().get(tmp_id + '_token')
|
||||
otp_secret = frappe.cache().get(tmp_id + '_otp_secret')
|
||||
if not otp_secret:
|
||||
raise ExpiredLoginException(_('Login session expired, refresh page to retry'))
|
||||
hotp = pyotp.HOTP(otp_secret)
|
||||
if hotp_token:
|
||||
if hotp.verify(otp, int(hotp_token)):
|
||||
frappe.cache().delete(tmp_id + '_token')
|
||||
return True
|
||||
else:
|
||||
login_manager.fail(_('Incorrect Verification code'), login_manager.user)
|
||||
|
||||
totp = pyotp.TOTP(otp_secret)
|
||||
if totp.verify(otp):
|
||||
# show qr code only once
|
||||
if not frappe.db.get_default(login_manager.user + '_otplogin'):
|
||||
frappe.db.set_default(login_manager.user + '_otplogin', 1)
|
||||
delete_qrimage(login_manager.user)
|
||||
return True
|
||||
else:
|
||||
login_manager.fail(_('Incorrect Verification code'), login_manager.user)
|
||||
|
||||
|
||||
def get_verification_obj(user, token, otp_secret):
|
||||
otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name')
|
||||
verification_method = get_verification_method()
|
||||
verification_obj = None
|
||||
if verification_method == 'SMS':
|
||||
verification_obj = process_2fa_for_sms(user, token, otp_secret)
|
||||
elif verification_method == 'OTP App':
|
||||
#check if this if the first time that the user is trying to login. If so, send an email
|
||||
if not frappe.db.get_default(user + '_otplogin'):
|
||||
verification_obj = process_2fa_for_email(user, token, otp_secret, otp_issuer, method='OTP App')
|
||||
else:
|
||||
verification_obj = process_2fa_for_otp_app(user, otp_secret, otp_issuer)
|
||||
elif verification_method == 'Email':
|
||||
verification_obj = process_2fa_for_email(user, token, otp_secret, otp_issuer)
|
||||
return verification_obj
|
||||
|
||||
|
||||
def process_2fa_for_sms(user, token, otp_secret):
|
||||
'''Process sms method for 2fa.'''
|
||||
phone = frappe.db.get_value('User', user, ['phone', 'mobile_no'], as_dict=1)
|
||||
phone = phone.mobile_no or phone.phone
|
||||
status = send_token_via_sms(otp_secret, token=token, phone_no=phone)
|
||||
verification_obj = {
|
||||
'token_delivery': status,
|
||||
'prompt': status and 'Enter verification code sent to {}'.format(phone[:4] + '******' + phone[-3:]),
|
||||
'method': 'SMS',
|
||||
'setup': status
|
||||
}
|
||||
return verification_obj
|
||||
|
||||
def process_2fa_for_otp_app(user, otp_secret, otp_issuer):
|
||||
'''Process OTP App method for 2fa.'''
|
||||
totp_uri = pyotp.TOTP(otp_secret).provisioning_uri(user, issuer_name=otp_issuer)
|
||||
if frappe.db.get_default(user + '_otplogin'):
|
||||
otp_setup_completed = True
|
||||
else:
|
||||
otp_setup_completed = False
|
||||
|
||||
verification_obj = {
|
||||
'totp_uri': totp_uri,
|
||||
'method': 'OTP App',
|
||||
'qrcode': get_qr_svg_code(totp_uri),
|
||||
'setup': otp_setup_completed
|
||||
}
|
||||
return verification_obj
|
||||
|
||||
def process_2fa_for_email(user, token, otp_secret, otp_issuer, method='Email'):
|
||||
'''Process Email method for 2fa.'''
|
||||
subject = None
|
||||
message = None
|
||||
status = True
|
||||
prompt = ''
|
||||
if method == 'OTP App' and not frappe.db.get_default(user + '_otplogin'):
|
||||
'''Sending one-time email for OTP App'''
|
||||
totp_uri = pyotp.TOTP(otp_secret).provisioning_uri(user, issuer_name=otp_issuer)
|
||||
qrcode_link = get_link_for_qrcode(user, totp_uri)
|
||||
message = get_email_body_for_qr_code({'qrcode_link': qrcode_link})
|
||||
subject = get_email_subject_for_qr_code({'qrcode_link': qrcode_link})
|
||||
prompt = _('Please check your registered email address for instructions on how to proceed. Do not close this window as you will have to return to it.')
|
||||
else:
|
||||
'''Sending email verification'''
|
||||
prompt = _('Verification code has been sent to your registered email address.')
|
||||
status = send_token_via_email(user, token, otp_secret, otp_issuer, subject=subject, message=message)
|
||||
verification_obj = {
|
||||
'token_delivery': status,
|
||||
'prompt': status and prompt,
|
||||
'method': 'Email',
|
||||
'setup': status
|
||||
}
|
||||
return verification_obj
|
||||
|
||||
def get_email_subject_for_2fa(kwargs_dict):
|
||||
'''Get email subject for 2fa.'''
|
||||
subject_template = _('Login Verification Code from {}').format(frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name'))
|
||||
subject = render_string_template(subject_template, kwargs_dict)
|
||||
return subject
|
||||
|
||||
def get_email_body_for_2fa(kwargs_dict):
|
||||
'''Get email body for 2fa.'''
|
||||
body_template = 'Enter this code to complete your login:<br><br> <b>{{otp}}</b>'
|
||||
body = render_string_template(body_template, kwargs_dict)
|
||||
return body
|
||||
|
||||
def get_email_subject_for_qr_code(kwargs_dict):
|
||||
'''Get QRCode email subject.'''
|
||||
subject_template = _('One Time Password (OTP) Registration Code from {}').format(frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name'))
|
||||
subject = render_string_template(subject_template, kwargs_dict)
|
||||
return subject
|
||||
|
||||
def get_email_body_for_qr_code(kwargs_dict):
|
||||
'''Get QRCode email body.'''
|
||||
body_template = 'Please click on the following link and follow the instructions on the page.<br><br> {{qrcode_link}}'
|
||||
body = render_string_template(body_template, kwargs_dict)
|
||||
return body
|
||||
|
||||
def render_string_template(_str, kwargs_dict):
|
||||
'''Render string with jinja.'''
|
||||
s = Template(_str)
|
||||
s = s.render(**kwargs_dict)
|
||||
return s
|
||||
|
||||
def get_link_for_qrcode(user, totp_uri):
|
||||
'''Get link to temporary page showing QRCode.'''
|
||||
key = frappe.generate_hash(length=20)
|
||||
key_user = "{}_user".format(key)
|
||||
key_uri = "{}_uri".format(key)
|
||||
lifespan = int(frappe.db.get_value('System Settings', 'System Settings', 'lifespan_qrcode_image'))
|
||||
if lifespan<=0:
|
||||
lifespan = 240
|
||||
frappe.cache().set_value(key_uri, totp_uri, expires_in_sec=lifespan)
|
||||
frappe.cache().set_value(key_user, user, expires_in_sec=lifespan)
|
||||
return get_url('/qrcode?k={}'.format(key))
|
||||
|
||||
def send_token_via_sms(otpsecret, token=None, phone_no=None):
|
||||
'''Send token as sms to user.'''
|
||||
otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name')
|
||||
try:
|
||||
from frappe.core.doctype.sms_settings.sms_settings import send_request
|
||||
except:
|
||||
return False
|
||||
|
||||
if not phone_no:
|
||||
return False
|
||||
|
||||
ss = frappe.get_doc('SMS Settings', 'SMS Settings')
|
||||
if not ss.sms_gateway_url:
|
||||
return False
|
||||
|
||||
hotp = pyotp.HOTP(otpsecret)
|
||||
args = {ss.message_parameter: 'Your verification code is {}'.format(hotp.at(int(token))), ss.sms_sender_name: otp_issuer}
|
||||
for d in ss.get("parameters"):
|
||||
args[d.parameter] = d.value
|
||||
|
||||
args[ss.receiver_parameter] = phone_no
|
||||
|
||||
sms_args = {'gateway_url': ss.sms_gateway_url, 'params': args}
|
||||
enqueue(method=send_request, queue='short', timeout=300, event=None, async=True, job_name=None, now=False, **sms_args)
|
||||
return True
|
||||
|
||||
def send_token_via_email(user, token, otp_secret, otp_issuer, subject=None, message=None):
|
||||
'''Send token to user as email.'''
|
||||
user_email = frappe.db.get_value('User', user, 'email')
|
||||
if not user_email:
|
||||
return False
|
||||
hotp = pyotp.HOTP(otp_secret)
|
||||
otp = hotp.at(int(token))
|
||||
template_args = {'otp': otp, 'otp_issuer': otp_issuer}
|
||||
if not subject:
|
||||
subject = get_email_subject_for_2fa(template_args)
|
||||
if not message:
|
||||
message = get_email_body_for_2fa(template_args)
|
||||
|
||||
email_args = {
|
||||
'recipients': user_email,
|
||||
'sender': None,
|
||||
'subject': subject,
|
||||
'message': message,
|
||||
'header': [_('Verfication Code'), 'blue'],
|
||||
'delayed': False,
|
||||
'retry':3
|
||||
}
|
||||
|
||||
enqueue(method=frappe.sendmail, queue='short',
|
||||
timeout=300, event=None, async=True, job_name=None, now=False, **email_args)
|
||||
return True
|
||||
|
||||
def get_qr_svg_code(totp_uri):
|
||||
'''Get SVG code to display Qrcode for OTP.'''
|
||||
url = qrcreate(totp_uri)
|
||||
svg = ''
|
||||
stream = StringIO()
|
||||
try:
|
||||
url.svg(stream, scale=4, background="#eee", module_color="#222")
|
||||
svg = stream.getvalue().replace('\n', '')
|
||||
svg = b64encode(bytes(svg))
|
||||
finally:
|
||||
stream.close()
|
||||
return svg
|
||||
|
||||
def qrcode_as_png(user, totp_uri):
|
||||
'''Save temporary Qrcode to server.'''
|
||||
from frappe.utils.file_manager import save_file
|
||||
folder = create_barcode_folder()
|
||||
png_file_name = '{}.png'.format(frappe.generate_hash(length=20))
|
||||
file_obj = save_file(png_file_name, png_file_name, 'User', user, folder=folder)
|
||||
frappe.db.commit()
|
||||
file_url = get_url(file_obj.file_url)
|
||||
file_path = os.path.join(frappe.get_site_path('public', 'files'), file_obj.file_name)
|
||||
url = qrcreate(totp_uri)
|
||||
with open(file_path, 'w') as png_file:
|
||||
url.png(png_file, scale=8, module_color=[0, 0, 0, 180], background=[0xff, 0xff, 0xcc])
|
||||
return file_url
|
||||
|
||||
def create_barcode_folder():
|
||||
'''Get Barcodes folder.'''
|
||||
folder_name = 'Barcodes'
|
||||
folder = frappe.db.exists('File', {'file_name': folder_name})
|
||||
if folder:
|
||||
return folder
|
||||
folder = frappe.get_doc({
|
||||
'doctype': 'File',
|
||||
'file_name': folder_name,
|
||||
'is_folder':1,
|
||||
'folder': 'Home'
|
||||
})
|
||||
folder.insert(ignore_permissions=True)
|
||||
return folder.name
|
||||
|
||||
def delete_qrimage(user, check_expiry=False):
|
||||
'''Delete Qrimage when user logs in.'''
|
||||
user_barcodes = frappe.get_all('File', {'attached_to_doctype': 'User',
|
||||
'attached_to_name': user, 'folder': 'Home/Barcodes'})
|
||||
for barcode in user_barcodes:
|
||||
if check_expiry and not should_remove_barcode_image(barcode): continue
|
||||
barcode = frappe.get_doc('File', barcode.name)
|
||||
frappe.delete_doc('File', barcode.name, ignore_permissions=True)
|
||||
|
||||
def delete_all_barcodes_for_users():
|
||||
'''Task to delete all barcodes for user.'''
|
||||
users = frappe.get_all('User', {'enabled':1})
|
||||
for user in users:
|
||||
delete_qrimage(user.name, check_expiry=True)
|
||||
|
||||
def should_remove_barcode_image(barcode):
|
||||
'''Check if it's time to delete barcode image from server. '''
|
||||
if isinstance(barcode, basestring):
|
||||
barcode = frappe.get_doc('File', barcode)
|
||||
lifespan = frappe.db.get_value('System Settings', 'System Settings', 'lifespan_qrcode_image')
|
||||
if time_diff_in_seconds(get_datetime(), barcode.creation) > int(lifespan):
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable():
|
||||
frappe.db.set_value('System Settings', None, 'enable_two_factor_auth', 0)
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
import redis, frappe, re
|
||||
import cPickle as pickle
|
||||
from six.moves import cPickle as pickle
|
||||
from frappe.utils import cstr
|
||||
from six import iteritems
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ def get_page_context(path):
|
|||
page_context = make_page_context(path)
|
||||
if can_cache(page_context.no_cache):
|
||||
page_context_cache[frappe.local.lang] = page_context
|
||||
|
||||
frappe.cache().hset("page_context", path, page_context_cache)
|
||||
|
||||
return page_context
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ def find_first_image(html):
|
|||
return None
|
||||
|
||||
def can_cache(no_cache=False):
|
||||
return not (frappe.conf.disable_website_cache or getattr(frappe.local, "no_cache", False) or no_cache)
|
||||
if frappe.conf.disable_website_cache or frappe.conf.developer_mode:
|
||||
return False
|
||||
if getattr(frappe.local, "no_cache", False):
|
||||
return False
|
||||
return not no_cache
|
||||
|
||||
def get_comment_list(doctype, name):
|
||||
return frappe.db.sql("""select
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- Chrome, Firefox OS and Opera -->
|
||||
<meta name="theme-color" content="#7575ff">
|
||||
<!-- Windows Phone -->
|
||||
<meta name="msapplication-navbutton-color" content="#7575ff">
|
||||
<!-- iOS Safari -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="#7575ff"> <meta charset="utf-8">
|
||||
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="utf-8" http-equiv="encoding">
|
||||
<meta name="author" content="">
|
||||
|
|
@ -15,7 +20,7 @@
|
|||
<link rel="icon"
|
||||
href="{{ favicon or "/assets/frappe/images/favicon.png" }}" type="image/x-icon">
|
||||
{% for include in include_css -%}
|
||||
<link type="text/css" rel="stylesheet" href="{{ include }}">
|
||||
<link type="text/css" rel="stylesheet" href="{{ include }}?ver={{ build_version }}">
|
||||
{%- endfor -%}
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -50,7 +55,7 @@
|
|||
</script>
|
||||
|
||||
{% for include in include_js %}
|
||||
<script type="text/javascript" src="{{ include }}"></script>
|
||||
<script type="text/javascript" src="{{ include }}?ver={{ build_version }}"></script>
|
||||
{% endfor %}
|
||||
{% include "templates/includes/app_analytics/google_analytics.html" %}
|
||||
{% include "templates/includes/app_analytics/mixpanel_analytics.html" %}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ def get_context(context):
|
|||
# remove script tags from boot
|
||||
boot_json = re.sub("\<script\>[^<]*\</script\>", "", boot_json)
|
||||
|
||||
return {
|
||||
context.update({
|
||||
"no_cache": 1,
|
||||
"build_version": get_build_version(),
|
||||
"include_js": hooks["app_include_js"],
|
||||
"include_css": hooks["app_include_css"],
|
||||
|
|
@ -46,7 +47,7 @@ def get_context(context):
|
|||
(boot.user.background_image or boot.default_background_image) or None),
|
||||
"google_analytics_id": frappe.conf.get("google_analytics_id"),
|
||||
"mixpanel_id": frappe.conf.get("mixpanel_id")
|
||||
}
|
||||
})
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_desk_assets(build_version):
|
||||
|
|
@ -64,7 +65,7 @@ def get_desk_assets(build_version):
|
|||
try:
|
||||
with open(os.path.join(frappe.local.sites_path, path) ,"r") as f:
|
||||
assets[0]["data"] = assets[0]["data"] + "\n" + text_type(f.read(), "utf-8")
|
||||
except IOError as e:
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
for path in data["include_css"]:
|
||||
|
|
@ -78,5 +79,4 @@ def get_desk_assets(build_version):
|
|||
}
|
||||
|
||||
def get_build_version():
|
||||
return str(os.path.getmtime(os.path.join(frappe.local.sites_path, "assets", "js",
|
||||
"desk.min.js")))
|
||||
return str(os.path.getmtime(os.path.join(frappe.local.sites_path, '.build')))
|
||||
|
|
|
|||
|
|
@ -9,16 +9,16 @@
|
|||
{% block page_content %}
|
||||
<!-- {{ for_test }} -->
|
||||
<section class='for-login'>
|
||||
<div class="login-content page-card" style="margin-top: 20px;">
|
||||
<div class="login-content page-card" style="margin-top: 30px;">
|
||||
<form class="form-signin form-login" role="form">
|
||||
<div class="page-card-head">
|
||||
<span class="indicator blue" data-text="{{ _("Sign In") }}"></span>
|
||||
</div>
|
||||
|
||||
<input type="text" id="login_email"
|
||||
class="form-control" placeholder="{{
|
||||
_('Email address or Mobile number')
|
||||
if frappe.utils.cint(frappe.db.get_value('System Settings', 'System Settings', 'allow_login_using_mobile_number'))
|
||||
class="form-control" placeholder="{{
|
||||
_('Email address or Mobile number')
|
||||
if frappe.utils.cint(frappe.db.get_value('System Settings', 'System Settings', 'allow_login_using_mobile_number'))
|
||||
else _('Email address') }}"
|
||||
required autofocus>
|
||||
|
||||
|
|
|
|||
|
|
@ -68,4 +68,3 @@ def login_via_token(login_token):
|
|||
frappe.local.login_manager = LoginManager()
|
||||
|
||||
redirect_post_login(desk_user = frappe.db.get_value("User", frappe.session.user, "user_type")=="System User")
|
||||
|
||||
|
|
|
|||
27
frappe/www/qrcode.html
Normal file
27
frappe/www/qrcode.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{% extends "templates/web.html" %}
|
||||
|
||||
{% block title %}{{ _("QR Code") }}{% endblock %}
|
||||
|
||||
{% block page_content %}
|
||||
<h1>{{ _("QR Code for Login Verification") }}</h1>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<p>{{ _("Hi {0}").format(qr_code_user.first_name) }},</p>
|
||||
|
||||
<p>{{ _("Steps to verify your login") }}:</p>
|
||||
<ol>
|
||||
<li> {{ _("Open your authentication app on your mobile phone.") }}
|
||||
<li> {{ _("Scan the QR Code and enter the resulting code displayed.") }}
|
||||
<li> {{ _("Return to the Verification screen and enter the code displayed by your authentication app") }}
|
||||
</ol>
|
||||
</p>
|
||||
<br>
|
||||
<p class='text-muted small'>{{ _("Authentication Apps you can use are: ") }}
|
||||
Google Authenticator, Lastpass Authenticator, Authy and Duo Mobile.
|
||||
</p>
|
||||
</div>
|
||||
<div class='col-sm-6' style='padding-top: 15px;'>
|
||||
<img src="data:image/svg+xml;base64,{{qrcode_svg}}">
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
37
frappe/www/qrcode.py
Normal file
37
frappe/www/qrcode.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from urlparse import parse_qs
|
||||
from frappe.twofactor import get_qr_svg_code
|
||||
|
||||
def get_context(context):
|
||||
context.no_cache = 1
|
||||
context.qr_code_user,context.qrcode_svg = get_user_svg_from_cache()
|
||||
|
||||
def get_query_key():
|
||||
'''Return query string arg.'''
|
||||
query_string = frappe.local.request.query_string
|
||||
query = parse_qs(query_string)
|
||||
if not 'k' in query.keys():
|
||||
frappe.throw(_('Not Permitted'),frappe.PermissionError)
|
||||
query = (query['k'][0]).strip()
|
||||
if False in [i.isalpha() or i.isdigit() for i in query]:
|
||||
frappe.throw(_('Not Permitted'),frappe.PermissionError)
|
||||
return query
|
||||
|
||||
def get_user_svg_from_cache():
|
||||
'''Get User and SVG code from cache.'''
|
||||
key = get_query_key()
|
||||
totp_uri = frappe.cache().get_value("{}_uri".format(key))
|
||||
user = frappe.cache().get_value("{}_user".format(key))
|
||||
if not totp_uri or not user:
|
||||
frappe.throw(_('Page has expired!'),frappe.PermissionError)
|
||||
if not frappe.db.exists('User',user):
|
||||
frappe.throw(_('Not Permitted'), frappe.PermissionError)
|
||||
user = frappe.get_doc('User',user)
|
||||
svg = get_qr_svg_code(totp_uri)
|
||||
return (user,svg)
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
"nightwatch": "^0.9.16",
|
||||
"redis": "^2.7.1",
|
||||
"socket.io": "^2.0.1",
|
||||
"superagent": "^3.5.2"
|
||||
"superagent": "^3.5.2",
|
||||
"touch": "^3.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,4 +41,8 @@ oauthlib
|
|||
PyJWT
|
||||
pypdf
|
||||
openpyxl
|
||||
pyotp
|
||||
pyqrcode
|
||||
pypng
|
||||
premailer
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue