Merge remote-tracking branch 'upstream/develop' into dev-import-file-error
This commit is contained in:
commit
e675043e9d
118 changed files with 1334 additions and 731 deletions
13
.github/workflows/semgrep.yml
vendored
Normal file
13
.github/workflows/semgrep.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
name: Semgrep
|
||||
|
||||
on:
|
||||
pull_request: {}
|
||||
|
||||
jobs:
|
||||
semgrep:
|
||||
name: Frappe Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: returntocorp/semgrep-action@v1
|
||||
|
||||
29
.semgrep.yml
Normal file
29
.semgrep.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#Reference: https://semgrep.dev/docs/writing-rules/rule-syntax/
|
||||
|
||||
rules:
|
||||
- id: eval
|
||||
patterns:
|
||||
- pattern-not: eval("...")
|
||||
- pattern: eval(...)
|
||||
message: |
|
||||
Detected the use of eval(). eval() can be dangerous if used to evaluate
|
||||
dynamic content. Avoid it or use safe_eval().
|
||||
languages:
|
||||
- python
|
||||
severity: ERROR
|
||||
|
||||
# translations
|
||||
- id: frappe-translation-syntax-python
|
||||
pattern-either:
|
||||
- pattern: _(f"...") # f-strings not allowed
|
||||
- pattern: _("..." + "...") # concatenation not allowed
|
||||
- pattern: _("") # empty string is meaningless
|
||||
- pattern: _("..." % ...) # Only positional formatters are allowed.
|
||||
- pattern: _("...".format(...)) # format should not be used before translating
|
||||
- pattern: _("...") + ... + _("...") # don't split strings
|
||||
message: |
|
||||
Incorrect use of translation function detected.
|
||||
Please refer: https://frappeframework.com/docs/user/en/translations
|
||||
languages:
|
||||
- python
|
||||
severity: ERROR
|
||||
|
|
@ -7,7 +7,7 @@ addons:
|
|||
- test_site_producer
|
||||
mariadb: 10.3
|
||||
postgresql: 9.5
|
||||
chrome: stable
|
||||
firefox: latest
|
||||
|
||||
services:
|
||||
- xvfb
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ website/ @prssanna
|
|||
web_form/ @prssanna
|
||||
templates/ @surajshetty3416
|
||||
www/ @surajshetty3416
|
||||
integrations/ @nextchamp-saqib
|
||||
integrations/ @leela
|
||||
patches/ @surajshetty3416
|
||||
dashboard/ @prssanna
|
||||
email/ @saurabh6790
|
||||
email/ @leela
|
||||
event_streaming/ @ruchamahabal
|
||||
data_import* @netchampfaris
|
||||
core/ @surajshetty3416
|
||||
|
|
|
|||
36
cypress/integration/control_select.js
Normal file
36
cypress/integration/control_select.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
context('Control Select', () => {
|
||||
before(() => {
|
||||
cy.login();
|
||||
cy.visit('/app/website');
|
||||
});
|
||||
|
||||
function get_dialog_with_select() {
|
||||
return cy.dialog({
|
||||
title: 'Select',
|
||||
fields: [{
|
||||
'fieldname': 'select_control',
|
||||
'fieldtype': 'Select',
|
||||
'placeholder': 'Select an Option',
|
||||
'options': ['', 'Option 1', 'Option 2', 'Option 2'],
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
it('toggles placholder on clicking an option', () => {
|
||||
get_dialog_with_select().as('dialog');
|
||||
|
||||
cy.get('.frappe-control[data-fieldname=select_control] .control-input').as('control');
|
||||
cy.get('.frappe-control[data-fieldname=select_control] .control-input select').as('select');
|
||||
cy.get('@control').get('.select-icon').should('exist');
|
||||
cy.get('@control').get('.placeholder').should('have.css', 'display', 'block');
|
||||
cy.get('@select').select('Option 1');
|
||||
cy.get('@control').get('.placeholder').should('have.css', 'display', 'none');
|
||||
cy.get('@select').invoke('val', '');
|
||||
cy.get('@control').get('.placeholder').should('have.css', 'display', 'block');
|
||||
|
||||
|
||||
cy.get('@dialog').then(dialog => {
|
||||
dialog.hide();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ context('Table MultiSelect', () => {
|
|||
it('select value from multiselect dropdown', () => {
|
||||
cy.new_form('Assignment Rule');
|
||||
cy.fill_field('__newname', name);
|
||||
cy.fill_field('document_type', 'ToDo');
|
||||
cy.fill_field('document_type', 'Blog Post');
|
||||
cy.fill_field('assign_condition', 'status=="Open"', 'Code');
|
||||
cy.get('input[data-fieldname="users"]').focus().as('input');
|
||||
cy.get('input[data-fieldname="users"] + ul').should('be.visible');
|
||||
|
|
|
|||
|
|
@ -17,11 +17,15 @@ from werkzeug.local import Local, release_local
|
|||
import os, sys, importlib, inspect, json
|
||||
from past.builtins import cmp
|
||||
import click
|
||||
from faker import Faker
|
||||
|
||||
# public
|
||||
# Local application imports
|
||||
from .exceptions import *
|
||||
from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader)
|
||||
from .utils.lazy_loader import lazy_import
|
||||
|
||||
# Lazy imports
|
||||
faker = lazy_import('faker')
|
||||
|
||||
|
||||
# Harmless for Python 3
|
||||
# For Python 2 set default encoding to utf-8
|
||||
|
|
@ -196,17 +200,20 @@ def init(site, sites_path=None, new_site=False):
|
|||
|
||||
local.initialised = True
|
||||
|
||||
def connect(site=None, db_name=None):
|
||||
def connect(site=None, db_name=None, set_admin_as_user=True):
|
||||
"""Connect to site database instance.
|
||||
|
||||
:param site: If site is given, calls `frappe.init`.
|
||||
:param db_name: Optional. Will use from `site_config.json`."""
|
||||
:param db_name: Optional. Will use from `site_config.json`.
|
||||
:param set_admin_as_user: Set Administrator as current user.
|
||||
"""
|
||||
from frappe.database import get_db
|
||||
if site:
|
||||
init(site)
|
||||
|
||||
local.db = get_db(user=db_name or local.conf.db_name)
|
||||
set_user("Administrator")
|
||||
if set_admin_as_user:
|
||||
set_user("Administrator")
|
||||
|
||||
def connect_replica():
|
||||
from frappe.database import get_db
|
||||
|
|
@ -468,8 +475,8 @@ def get_request_header(key, default=None):
|
|||
|
||||
def sendmail(recipients=[], sender="", subject="No Subject", message="No Message",
|
||||
as_markdown=False, delayed=True, reference_doctype=None, reference_name=None,
|
||||
unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None,
|
||||
attachments=None, content=None, doctype=None, name=None, reply_to=None,
|
||||
unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None, add_unsubscribe_link=1,
|
||||
attachments=None, content=None, doctype=None, name=None, reply_to=None, queue_separately=False,
|
||||
cc=[], bcc=[], message_id=None, in_reply_to=None, send_after=None, expose_recipients=None,
|
||||
send_priority=1, communication=None, retry=1, now=None, read_receipt=None, is_notification=False,
|
||||
inline_images=None, template=None, args=None, header=None, print_letterhead=False, with_container=False):
|
||||
|
|
@ -516,10 +523,10 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message
|
|||
from frappe.email import queue
|
||||
queue.send(recipients=recipients, sender=sender,
|
||||
subject=subject, message=message, text_content=text_content,
|
||||
reference_doctype = doctype or reference_doctype, reference_name = name or reference_name,
|
||||
reference_doctype = doctype or reference_doctype, reference_name = name or reference_name, add_unsubscribe_link=add_unsubscribe_link,
|
||||
unsubscribe_method=unsubscribe_method, unsubscribe_params=unsubscribe_params, unsubscribe_message=unsubscribe_message,
|
||||
attachments=attachments, reply_to=reply_to, cc=cc, bcc=bcc, message_id=message_id, in_reply_to=in_reply_to,
|
||||
send_after=send_after, expose_recipients=expose_recipients, send_priority=send_priority,
|
||||
send_after=send_after, expose_recipients=expose_recipients, send_priority=send_priority, queue_separately=queue_separately,
|
||||
communication=communication, now=now, read_receipt=read_receipt, is_notification=is_notification,
|
||||
inline_images=inline_images, header=header, print_letterhead=print_letterhead, with_container=with_container)
|
||||
|
||||
|
|
@ -1748,12 +1755,12 @@ def parse_json(val):
|
|||
|
||||
def mock(type, size=1, locale='en'):
|
||||
results = []
|
||||
faker = Faker(locale)
|
||||
if not type in dir(faker):
|
||||
fake = faker.Faker(locale)
|
||||
if type not in dir(fake):
|
||||
raise ValueError('Not a valid mock type.')
|
||||
else:
|
||||
for i in range(size):
|
||||
data = getattr(faker, type)()
|
||||
data = getattr(fake, type)()
|
||||
results.append(data)
|
||||
|
||||
from frappe.chat.util import squashify
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ def init_request(request):
|
|||
if frappe.local.conf.get('maintenance_mode'):
|
||||
frappe.connect()
|
||||
raise frappe.SessionStopped('Session Stopped')
|
||||
else:
|
||||
frappe.connect(set_admin_as_user=False)
|
||||
|
||||
make_form_dict(request)
|
||||
|
||||
|
|
@ -152,10 +154,10 @@ def process_response(response):
|
|||
|
||||
def set_cors_headers(response):
|
||||
origin = frappe.request.headers.get('Origin')
|
||||
if not origin:
|
||||
allow_cors = frappe.conf.allow_cors
|
||||
if not (origin and allow_cors):
|
||||
return
|
||||
|
||||
allow_cors = frappe.conf.allow_cors
|
||||
if allow_cors != "*":
|
||||
if not isinstance(allow_cors, list):
|
||||
allow_cors = [allow_cors]
|
||||
|
|
|
|||
175
frappe/auth.py
175
frappe/auth.py
|
|
@ -207,23 +207,44 @@ class LoginManager:
|
|||
if frappe.session.user != "Guest":
|
||||
clear_sessions(frappe.session.user, keep_current=True)
|
||||
|
||||
def authenticate(self, user=None, pwd=None):
|
||||
def authenticate(self, user: str = None, pwd: str = None):
|
||||
from frappe.core.doctype.user.user import User
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
# Ignore password check if tmp_id is set, 2FA takes care of authentication.
|
||||
validate_password = not bool(frappe.form_dict.get('tmp_id'))
|
||||
user = User.find_by_credentials(user, pwd, validate_password=validate_password)
|
||||
|
||||
if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_user_name")):
|
||||
user = frappe.db.get_value("User", filters={"username": user}, fieldname="name") or user
|
||||
if not user:
|
||||
self.fail('Invalid login credentials')
|
||||
|
||||
self.check_if_enabled(user)
|
||||
if not frappe.form_dict.get('tmp_id'):
|
||||
self.user = self.check_password(user, pwd)
|
||||
sys_settings = frappe.get_doc("System Settings")
|
||||
track_login_attempts = (sys_settings.allow_consecutive_login_attempts >0)
|
||||
|
||||
tracker_kwargs = {}
|
||||
if track_login_attempts:
|
||||
tracker_kwargs['lock_interval'] = sys_settings.allow_login_after_fail
|
||||
tracker_kwargs['max_consecutive_login_attempts'] = sys_settings.allow_consecutive_login_attempts
|
||||
|
||||
tracker = LoginAttemptTracker(user.name, **tracker_kwargs)
|
||||
|
||||
if track_login_attempts and not tracker.is_user_allowed():
|
||||
frappe.throw(_("Your account has been locked and will resume after {0} seconds")
|
||||
.format(sys_settings.allow_login_after_fail), frappe.SecurityException)
|
||||
|
||||
if not user.is_authenticated:
|
||||
tracker.add_failure_attempt()
|
||||
self.fail('Invalid login credentials', user=user.name)
|
||||
elif not (user.name == 'Administrator' or user.enabled):
|
||||
tracker.add_failure_attempt()
|
||||
self.fail('User disabled or missing', user=user.name)
|
||||
else:
|
||||
self.user = user
|
||||
tracker.add_success_attempt()
|
||||
self.user = user.name
|
||||
|
||||
def force_user_to_reset_password(self):
|
||||
if not self.user:
|
||||
|
|
@ -245,23 +266,12 @@ class LoginManager:
|
|||
if last_pwd_reset_days > reset_pwd_after_days:
|
||||
return True
|
||||
|
||||
def check_if_enabled(self, user):
|
||||
"""raise exception if user not enabled"""
|
||||
doc = frappe.get_doc("System Settings")
|
||||
if cint(doc.allow_consecutive_login_attempts) > 0:
|
||||
check_consecutive_login_attempts(user, doc)
|
||||
|
||||
if user=='Administrator': return
|
||||
if not cint(frappe.db.get_value('User', user, 'enabled')):
|
||||
self.fail('User disabled or missing', user=user)
|
||||
|
||||
def check_password(self, user, pwd):
|
||||
"""check password"""
|
||||
try:
|
||||
# returns user in correct case
|
||||
return check_password(user, pwd)
|
||||
except frappe.AuthenticationError:
|
||||
self.update_invalid_login(user)
|
||||
self.fail('Incorrect password', user=user)
|
||||
|
||||
def fail(self, message, user=None):
|
||||
|
|
@ -272,15 +282,6 @@ class LoginManager:
|
|||
frappe.db.commit()
|
||||
raise frappe.AuthenticationError
|
||||
|
||||
def update_invalid_login(self, user):
|
||||
last_login_tried = get_last_tried_login_data(user)
|
||||
|
||||
failed_count = 0
|
||||
if last_login_tried > get_datetime():
|
||||
failed_count = get_login_failed_count(user)
|
||||
|
||||
frappe.cache().hset('login_failed_count', user, failed_count + 1)
|
||||
|
||||
def run_trigger(self, event='on_login'):
|
||||
for method in frappe.get_hooks().get(event, []):
|
||||
frappe.call(frappe.get_attr(method), login_manager=self)
|
||||
|
|
@ -383,38 +384,6 @@ def clear_cookies():
|
|||
frappe.session.sid = ""
|
||||
frappe.local.cookie_manager.delete_cookie(["full_name", "user_id", "sid", "user_image", "system_user"])
|
||||
|
||||
def get_last_tried_login_data(user, get_last_login=False):
|
||||
locked_account_time = frappe.cache().hget('locked_account_time', user)
|
||||
if get_last_login and locked_account_time:
|
||||
return locked_account_time
|
||||
|
||||
last_login_tried = frappe.cache().hget('last_login_tried', user)
|
||||
if not last_login_tried or last_login_tried < get_datetime():
|
||||
last_login_tried = get_datetime() + datetime.timedelta(seconds=60)
|
||||
|
||||
frappe.cache().hset('last_login_tried', user, last_login_tried)
|
||||
|
||||
return last_login_tried
|
||||
|
||||
def get_login_failed_count(user):
|
||||
return cint(frappe.cache().hget('login_failed_count', user)) or 0
|
||||
|
||||
def check_consecutive_login_attempts(user, doc):
|
||||
login_failed_count = get_login_failed_count(user)
|
||||
last_login_tried = (get_last_tried_login_data(user, True)
|
||||
+ datetime.timedelta(seconds=doc.allow_login_after_fail))
|
||||
|
||||
if login_failed_count >= cint(doc.allow_consecutive_login_attempts):
|
||||
locked_account_time = frappe.cache().hget('locked_account_time', user)
|
||||
if not locked_account_time:
|
||||
frappe.cache().hset('locked_account_time', user, get_datetime())
|
||||
|
||||
if last_login_tried > get_datetime():
|
||||
frappe.throw(_("Your account has been locked and will resume after {0} seconds")
|
||||
.format(doc.allow_login_after_fail), frappe.SecurityException)
|
||||
else:
|
||||
delete_login_failed_cache(user)
|
||||
|
||||
def validate_ip_address(user):
|
||||
"""check if IP Address is valid"""
|
||||
user = frappe.get_cached_doc("User", user) if not frappe.flags.in_test else frappe.get_doc("User", user)
|
||||
|
|
@ -436,3 +405,87 @@ def validate_ip_address(user):
|
|||
return
|
||||
|
||||
frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError)
|
||||
|
||||
|
||||
class LoginAttemptTracker(object):
|
||||
"""Track login attemts of a user.
|
||||
|
||||
Lock the account for s number of seconds if there have been n consecutive unsuccessful attempts to log in.
|
||||
"""
|
||||
def __init__(self, user_name: str, max_consecutive_login_attempts: int=3, lock_interval:int = 5*60):
|
||||
""" Initialize the tracker.
|
||||
|
||||
:param user_name: Name of the loggedin user
|
||||
:param max_consecutive_login_attempts: Maximum allowed consecutive failed login attempts
|
||||
:param lock_interval: Locking interval incase of maximum failed attempts
|
||||
"""
|
||||
self.user_name = user_name
|
||||
self.lock_interval = datetime.timedelta(seconds=lock_interval)
|
||||
self.max_failed_logins = max_consecutive_login_attempts
|
||||
|
||||
@property
|
||||
def login_failed_count(self):
|
||||
return frappe.cache().hget('login_failed_count', self.user_name)
|
||||
|
||||
@login_failed_count.setter
|
||||
def login_failed_count(self, count):
|
||||
frappe.cache().hset('login_failed_count', self.user_name, count)
|
||||
|
||||
@login_failed_count.deleter
|
||||
def login_failed_count(self):
|
||||
frappe.cache().hdel('login_failed_count', self.user_name)
|
||||
|
||||
@property
|
||||
def login_failed_time(self):
|
||||
"""First failed login attempt time within lock interval.
|
||||
|
||||
For every user we track only First failed login attempt time within lock interval of time.
|
||||
"""
|
||||
return frappe.cache().hget('login_failed_time', self.user_name)
|
||||
|
||||
@login_failed_time.setter
|
||||
def login_failed_time(self, timestamp):
|
||||
frappe.cache().hset('login_failed_time', self.user_name, timestamp)
|
||||
|
||||
@login_failed_time.deleter
|
||||
def login_failed_time(self):
|
||||
frappe.cache().hdel('login_failed_time', self.user_name)
|
||||
|
||||
def add_failure_attempt(self):
|
||||
""" Log user failure attempts into the system.
|
||||
|
||||
Increase the failure count if new failure is with in current lock interval time period, if not reset the login failure count.
|
||||
"""
|
||||
login_failed_time = self.login_failed_time
|
||||
login_failed_count = self.login_failed_count # Consecutive login failure count
|
||||
current_time = get_datetime()
|
||||
|
||||
if not (login_failed_time and login_failed_count):
|
||||
login_failed_time, login_failed_count = current_time, 0
|
||||
|
||||
if login_failed_time + self.lock_interval > current_time:
|
||||
login_failed_count += 1
|
||||
else:
|
||||
login_failed_time, login_failed_count = current_time, 1
|
||||
|
||||
self.login_failed_time = login_failed_time
|
||||
self.login_failed_count = login_failed_count
|
||||
|
||||
def add_success_attempt(self):
|
||||
"""Reset login failures.
|
||||
"""
|
||||
del self.login_failed_count
|
||||
del self.login_failed_time
|
||||
|
||||
def is_user_allowed(self) -> bool:
|
||||
"""Is user allowed to login
|
||||
|
||||
User is not allowed to login if login failures are greater than threshold within in lock interval from first login failure.
|
||||
"""
|
||||
login_failed_time = self.login_failed_time
|
||||
login_failed_count = self.login_failed_count or 0
|
||||
current_time = get_datetime()
|
||||
|
||||
if login_failed_time and login_failed_time + self.lock_interval > current_time and login_failed_count > self.max_failed_logins:
|
||||
return False
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ frappe.ui.form.on('Assignment Rule', {
|
|||
frm.events.rule(frm);
|
||||
},
|
||||
|
||||
setup: function(frm) {
|
||||
frm.set_query("document_type", () => {
|
||||
return {
|
||||
filters: {
|
||||
name: ["!=", "ToDo"]
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
document_type: function(frm) {
|
||||
frm.trigger('set_options');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ class AssignmentRule(Document):
|
|||
if not len(set(assignment_days)) == len(assignment_days):
|
||||
repeated_days = get_repeated(assignment_days)
|
||||
frappe.throw(_("Assignment Day {0} has been repeated.").format(frappe.bold(repeated_days)))
|
||||
if self.document_type == 'ToDo':
|
||||
frappe.throw(_('Assignment Rule is not allowed on {0} document type').format(frappe.bold("ToDo")))
|
||||
|
||||
def on_update(self):
|
||||
clear_assignment_rule_cache(self)
|
||||
|
|
@ -298,4 +300,4 @@ def get_repeated(values):
|
|||
|
||||
def clear_assignment_rule_cache(rule):
|
||||
frappe.cache_manager.clear_doctype_map('Assignment Rule', rule.document_type)
|
||||
frappe.cache_manager.clear_doctype_map('Assignment Rule', 'due_date_rules_for_' + rule.document_type)
|
||||
frappe.cache_manager.clear_doctype_map('Assignment Rule', 'due_date_rules_for_' + rule.document_type)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import frappe
|
|||
from frappe.utils.minify import JavascriptMinify
|
||||
|
||||
import click
|
||||
from requests import get
|
||||
import psutil
|
||||
from six import iteritems, text_type
|
||||
from six.moves.urllib.parse import urlparse
|
||||
|
||||
|
|
@ -26,6 +26,8 @@ sites_path = os.path.abspath(os.getcwd())
|
|||
|
||||
|
||||
def download_file(url, prefix):
|
||||
from requests import get
|
||||
|
||||
filename = urlparse(url).path.split("/")[-1]
|
||||
local_filename = os.path.join(prefix, filename)
|
||||
with get(url, stream=True, allow_redirects=True) as r:
|
||||
|
|
@ -225,7 +227,7 @@ def bundle(no_compress, app=None, make_copy=False, restore=False, verbose=False,
|
|||
|
||||
frappe_app_path = os.path.abspath(os.path.join(app_paths[0], ".."))
|
||||
check_yarn()
|
||||
frappe.commands.popen(command, cwd=frappe_app_path)
|
||||
frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env())
|
||||
|
||||
|
||||
def watch(no_compress):
|
||||
|
|
@ -237,13 +239,32 @@ def watch(no_compress):
|
|||
frappe_app_path = os.path.abspath(os.path.join(app_paths[0], ".."))
|
||||
check_yarn()
|
||||
frappe_app_path = frappe.get_app_path("frappe", "..")
|
||||
frappe.commands.popen("{pacman} run watch".format(pacman=pacman), cwd=frappe_app_path)
|
||||
frappe.commands.popen("{pacman} run watch".format(pacman=pacman),
|
||||
cwd=frappe_app_path, env=get_node_env())
|
||||
|
||||
|
||||
def check_yarn():
|
||||
if not find_executable("yarn"):
|
||||
print("Please install yarn using below command and try again.\nnpm install -g yarn")
|
||||
|
||||
def get_node_env():
|
||||
node_env = {
|
||||
"NODE_OPTIONS": f"--max_old_space_size={get_safe_max_old_space_size()}"
|
||||
}
|
||||
return node_env
|
||||
|
||||
def get_safe_max_old_space_size():
|
||||
safe_max_old_space_size = 0
|
||||
try:
|
||||
total_memory = psutil.virtual_memory().total / (1024 * 1024)
|
||||
# reference for the safe limit assumption
|
||||
# https://nodejs.org/api/cli.html#cli_max_old_space_size_size_in_megabytes
|
||||
# set minimum value 1GB
|
||||
safe_max_old_space_size = max(1024, int(total_memory * 0.75))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return safe_max_old_space_size
|
||||
|
||||
def make_asset_dirs(make_copy=False, restore=False):
|
||||
# don't even think of making assets_path absolute - rm -rf ahead.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import frappe.utils
|
|||
import subprocess # nosec
|
||||
from functools import wraps
|
||||
from six import StringIO
|
||||
from os import environ
|
||||
|
||||
click.disable_unicode_literals_warning = True
|
||||
|
||||
|
|
@ -53,16 +54,20 @@ def get_site(context, raise_err=True):
|
|||
return None
|
||||
|
||||
def popen(command, *args, **kwargs):
|
||||
output = kwargs.get('output', True)
|
||||
cwd = kwargs.get('cwd')
|
||||
shell = kwargs.get('shell', True)
|
||||
output = kwargs.get('output', True)
|
||||
cwd = kwargs.get('cwd')
|
||||
shell = kwargs.get('shell', True)
|
||||
raise_err = kwargs.get('raise_err')
|
||||
env = kwargs.get('env')
|
||||
if env:
|
||||
env = dict(environ, **env)
|
||||
|
||||
proc = subprocess.Popen(command,
|
||||
stdout = None if output else subprocess.PIPE,
|
||||
stderr = None if output else subprocess.PIPE,
|
||||
shell = shell,
|
||||
cwd = cwd
|
||||
stdout=None if output else subprocess.PIPE,
|
||||
stderr=None if output else subprocess.PIPE,
|
||||
shell=shell,
|
||||
cwd=cwd,
|
||||
env=env
|
||||
)
|
||||
|
||||
return_ = proc.wait()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import click
|
|||
import frappe
|
||||
from frappe.commands import get_site, pass_context
|
||||
from frappe.exceptions import SiteNotSpecifiedError
|
||||
from frappe.installer import _new_site
|
||||
|
||||
|
||||
@click.command('new-site')
|
||||
|
|
@ -31,6 +30,8 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin
|
|||
verbose=False, install_apps=None, source_sql=None, force=None, no_mariadb_socket=False,
|
||||
install_app=None, db_name=None, db_password=None, db_type=None, db_host=None, db_port=None):
|
||||
"Create a new site"
|
||||
from frappe.installer import _new_site
|
||||
|
||||
frappe.init(site=site, new_site=True)
|
||||
|
||||
_new_site(db_name, site, mariadb_root_username=mariadb_root_username,
|
||||
|
|
@ -57,6 +58,7 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin
|
|||
def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None):
|
||||
"Restore site database from an sql file"
|
||||
from frappe.installer import (
|
||||
_new_site,
|
||||
extract_sql_from_archive,
|
||||
extract_files,
|
||||
is_downgrade,
|
||||
|
|
@ -145,6 +147,8 @@ def reinstall(context, admin_password=None, mariadb_root_username=None, mariadb_
|
|||
_reinstall(site, admin_password, mariadb_root_username, mariadb_root_password, yes, verbose=context.verbose)
|
||||
|
||||
def _reinstall(site, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False, verbose=False):
|
||||
from frappe.installer import _new_site
|
||||
|
||||
if not yes:
|
||||
click.confirm('This will wipe your database. Are you sure you want to reinstall?', abort=True)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -578,7 +578,7 @@ def run_ui_tests(context, app, headless=False):
|
|||
frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 --no-lockfile")
|
||||
|
||||
# run for headless mode
|
||||
run_or_open = 'run --browser chrome --record --key 4a48f41c-11b3-425b-aa88-c58048fa69eb' if headless else 'open'
|
||||
run_or_open = 'run --browser firefox --record --key 4a48f41c-11b3-425b-aa88-c58048fa69eb' if headless else 'open'
|
||||
command = '{site_env} {password_env} {cypress} {run_or_open}'
|
||||
formatted_command = command.format(site_env=site_env, password_env=password_env, cypress=cypress_path, run_or_open=run_or_open)
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,10 @@ class TestActivityLog(unittest.TestCase):
|
|||
self.assertRaises(frappe.AuthenticationError, LoginManager)
|
||||
self.assertRaises(frappe.AuthenticationError, LoginManager)
|
||||
self.assertRaises(frappe.AuthenticationError, LoginManager)
|
||||
|
||||
# REMOVE ME: current logic allows allow_consecutive_login_attempts+1 attempts
|
||||
# before raising security exception, remove below line when that is fixed.
|
||||
self.assertRaises(frappe.AuthenticationError, LoginManager)
|
||||
self.assertRaises(frappe.SecurityException, LoginManager)
|
||||
time.sleep(5)
|
||||
self.assertRaises(frappe.AuthenticationError, LoginManager)
|
||||
|
|
|
|||
|
|
@ -449,8 +449,8 @@ class ImportFile:
|
|||
data_without_first_row = data[1:]
|
||||
for row in data_without_first_row:
|
||||
row_values = row.get_values(parent_column_indexes)
|
||||
# if the row is blank, it's a child row doc
|
||||
if all([v in INVALID_VALUES for v in row_values]):
|
||||
# if the row is blank or same content as the previous parent row, it's a child row doc
|
||||
if all([v in INVALID_VALUES for v in row_values]) or row_values == parent_row_values:
|
||||
rows.append(row)
|
||||
continue
|
||||
# if we encounter a row which has values in parent columns,
|
||||
|
|
@ -472,32 +472,6 @@ class ImportFile:
|
|||
|
||||
doc = parent_doc
|
||||
|
||||
if self.import_type == INSERT:
|
||||
# check if there is atleast one row for mandatory table fields
|
||||
meta = frappe.get_meta(self.doctype)
|
||||
mandatory_table_fields = [
|
||||
df
|
||||
for df in meta.fields
|
||||
if df.fieldtype in table_fieldtypes
|
||||
and df.reqd
|
||||
and len(doc.get(df.fieldname, [])) == 0
|
||||
]
|
||||
if len(mandatory_table_fields) == 1:
|
||||
self.warnings.append(
|
||||
{
|
||||
"row": first_row.row_number,
|
||||
"message": _("There should be atleast one row for {0} table").format(
|
||||
frappe.bold(mandatory_table_fields[0].label)
|
||||
),
|
||||
}
|
||||
)
|
||||
elif mandatory_table_fields:
|
||||
fields_string = ", ".join([df.label for df in mandatory_table_fields])
|
||||
message = _("There should be atleast one row for the following tables: {0}").format(
|
||||
fields_string
|
||||
)
|
||||
self.warnings.append({"row": first_row.row_number, "message": message})
|
||||
|
||||
return doc, rows, data[len(rows) :]
|
||||
|
||||
def get_warnings(self):
|
||||
|
|
@ -626,7 +600,6 @@ class Row:
|
|||
new_doc.update(doc)
|
||||
doc = new_doc
|
||||
|
||||
self.check_mandatory_fields(doctype, doc, table_df)
|
||||
return doc
|
||||
|
||||
def validate_value(self, value, col):
|
||||
|
|
@ -727,66 +700,6 @@ class Row:
|
|||
pass
|
||||
return value
|
||||
|
||||
def check_mandatory_fields(self, doctype, doc, table_df=None):
|
||||
"""If import type is Insert:
|
||||
Check for mandatory fields (except table fields) in doc
|
||||
if import type is Update:
|
||||
Check for name field or autoname field in doc
|
||||
"""
|
||||
meta = frappe.get_meta(doctype)
|
||||
if self.import_type == UPDATE:
|
||||
if meta.istable:
|
||||
# when updating records with table rows,
|
||||
# there are two scenarios:
|
||||
# 1. if row 'name' is provided in the template
|
||||
# the table row will be updated
|
||||
# 2. if row 'name' is not provided
|
||||
# then a new row will be added
|
||||
# so we dont need to check for mandatory
|
||||
return
|
||||
|
||||
# for update, only ID (name) field is mandatory
|
||||
id_field = get_id_field(doctype)
|
||||
if doc.get(id_field.fieldname) in INVALID_VALUES:
|
||||
self.warnings.append(
|
||||
{
|
||||
"row": self.row_number,
|
||||
"message": _("{0} is a mandatory field").format(id_field.label),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
fields = [
|
||||
df
|
||||
for df in meta.fields
|
||||
if df.fieldtype not in table_fieldtypes
|
||||
and df.reqd
|
||||
and doc.get(df.fieldname) in INVALID_VALUES
|
||||
]
|
||||
|
||||
if not fields:
|
||||
return
|
||||
|
||||
def get_field_label(df):
|
||||
return "{0}{1}".format(df.label, " ({})".format(table_df.label) if table_df else "")
|
||||
|
||||
if len(fields) == 1:
|
||||
field_label = get_field_label(fields[0])
|
||||
self.warnings.append(
|
||||
{
|
||||
"row": self.row_number,
|
||||
"message": _("{0} is a mandatory field").format(frappe.bold(field_label)),
|
||||
}
|
||||
)
|
||||
else:
|
||||
fields_string = ", ".join([frappe.bold(get_field_label(df)) for df in fields])
|
||||
self.warnings.append(
|
||||
{
|
||||
"row": self.row_number,
|
||||
"message": _("{0} are mandatory fields").format(fields_string),
|
||||
}
|
||||
)
|
||||
|
||||
def get_values(self, indexes):
|
||||
return [self.data[i] for i in indexes]
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ doctype_name = 'DocType for Import'
|
|||
class TestImporter(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
create_doctype_if_not_exists(doctype_name)
|
||||
create_doctype_if_not_exists(doctype_name,)
|
||||
|
||||
def test_data_import_from_file(self):
|
||||
import_file = get_import_file('sample_import_file')
|
||||
|
|
@ -59,18 +59,18 @@ class TestImporter(unittest.TestCase):
|
|||
def test_data_import_without_mandatory_values(self):
|
||||
import_file = get_import_file('sample_import_file_without_mandatory')
|
||||
data_import = self.get_importer(doctype_name, import_file)
|
||||
frappe.local.message_log = []
|
||||
data_import.start_import()
|
||||
data_import.reload()
|
||||
warnings = frappe.parse_json(data_import.template_warnings)
|
||||
import_log = frappe.parse_json(data_import.import_log)
|
||||
self.assertEqual(import_log[0]['row_indexes'], [2,3])
|
||||
expected_error = "Error: <b>Child 1 of DocType for Import</b> Row #1: Value missing for: Child Title"
|
||||
self.assertEqual(frappe.parse_json(import_log[0]['messages'][0])['message'], expected_error)
|
||||
expected_error = "Error: <b>Child 1 of DocType for Import</b> Row #2: Value missing for: Child Title"
|
||||
self.assertEqual(frappe.parse_json(import_log[0]['messages'][1])['message'], expected_error)
|
||||
|
||||
self.assertEqual(warnings[0]['row'], 2)
|
||||
self.assertEqual(warnings[0]['message'], "<b>Child Title (Table Field 1)</b> is a mandatory field")
|
||||
|
||||
self.assertEqual(warnings[1]['row'], 3)
|
||||
self.assertEqual(warnings[1]['message'], "<b>Child Title (Table Field 1 Again)</b> is a mandatory field")
|
||||
|
||||
self.assertEqual(warnings[2]['row'], 4)
|
||||
self.assertEqual(warnings[2]['message'], "<b>Title</b> is a mandatory field")
|
||||
self.assertEqual(import_log[1]['row_indexes'], [4])
|
||||
self.assertEqual(frappe.parse_json(import_log[1]['messages'][0])['message'], "Title is required")
|
||||
|
||||
def test_data_import_update(self):
|
||||
existing_doc = frappe.get_doc(
|
||||
|
|
@ -104,6 +104,8 @@ class TestImporter(unittest.TestCase):
|
|||
data_import.reference_doctype = doctype
|
||||
data_import.import_file = import_file.file_url
|
||||
data_import.insert()
|
||||
# Commit so that the first import failure does not rollback the Data Import insert.
|
||||
frappe.db.commit()
|
||||
|
||||
return data_import
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ def execute_event(doc: str):
|
|||
frappe.only_for("System Manager")
|
||||
doc = json.loads(doc)
|
||||
frappe.get_doc("Scheduled Job Type", doc.get("name")).enqueue(force=True)
|
||||
return doc
|
||||
|
||||
|
||||
def run_scheduled_job(job_type: str):
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@
|
|||
"issingle": 1,
|
||||
"istable": 0,
|
||||
"max_attachments": 0,
|
||||
"modified": "2017-11-01 12:57:20.943845",
|
||||
"modified": "2021-03-02 18:06:00.868688",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "SMS Settings",
|
||||
|
|
@ -233,6 +233,6 @@
|
|||
"read_only": 0,
|
||||
"read_only_onload": 0,
|
||||
"show_name_in_global_search": 0,
|
||||
"track_changes": 0,
|
||||
"track_changes": 1,
|
||||
"track_seen": 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from frappe.utils import get_url
|
|||
from frappe.core.doctype.user.user import get_total_users
|
||||
from frappe.core.doctype.user.user import MaxUsersReachedError, test_password_strength
|
||||
from frappe.core.doctype.user.user import extract_mentions
|
||||
from frappe.frappeclient import FrappeClient
|
||||
|
||||
test_records = frappe.get_test_records('User')
|
||||
|
||||
|
|
@ -229,16 +230,22 @@ class TestUser(unittest.TestCase):
|
|||
self.assertEqual(extract_mentions(comment)[1], "test.again@example1.com")
|
||||
|
||||
def test_rate_limiting_for_reset_password(self):
|
||||
from frappe.utils.password import delete_password_reset_cache
|
||||
delete_password_reset_cache()
|
||||
|
||||
# Allow only one reset request for a day
|
||||
frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 1)
|
||||
frappe.db.commit()
|
||||
|
||||
user = frappe.get_doc("User", "testperm@example.com")
|
||||
link = user.reset_password()
|
||||
self.assertRegex(link, "\/update-password\?key=[A-Za-z0-9]*")
|
||||
url = get_url()
|
||||
data={'cmd': 'frappe.core.doctype.user.user.reset_password', 'user': 'test@test.com'}
|
||||
|
||||
self.assertRaises(frappe.ValidationError, user.reset_password, False)
|
||||
# Clear rate limit tracker to start fresh
|
||||
key = f"rl:{data['cmd']}:{data['user']}"
|
||||
frappe.cache().delete(key)
|
||||
|
||||
c = FrappeClient(url)
|
||||
res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
|
||||
res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
|
||||
self.assertEqual(res1.status_code, 200)
|
||||
self.assertEqual(res2.status_code, 417)
|
||||
|
||||
def test_user_rollback(self):
|
||||
""" """
|
||||
|
|
|
|||
|
|
@ -2,21 +2,25 @@
|
|||
# MIT License. See license.txt
|
||||
|
||||
from __future__ import unicode_literals, print_function
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
import frappe
|
||||
import frappe.share
|
||||
import frappe.defaults
|
||||
import frappe.permissions
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, flt, has_gravatar, escape_html, format_datetime, now_datetime, get_formatted_email, today
|
||||
from frappe import throw, msgprint, _
|
||||
from frappe.utils.password import update_password as _update_password
|
||||
from frappe.utils.password import update_password as _update_password, check_password, get_password_reset_limit
|
||||
from frappe.desk.notifications import clear_notifications
|
||||
from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings
|
||||
from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings, toggle_notifications
|
||||
from frappe.utils.user import get_system_managers
|
||||
from bs4 import BeautifulSoup
|
||||
import frappe.permissions
|
||||
import frappe.share
|
||||
import frappe.defaults
|
||||
from frappe.website.utils import is_signup_enabled
|
||||
from frappe.rate_limiter import rate_limit
|
||||
from frappe.utils.background_jobs import enqueue
|
||||
|
||||
|
||||
STANDARD_USERS = ("Guest", "Administrator")
|
||||
|
||||
|
||||
|
|
@ -146,6 +150,9 @@ class User(Document):
|
|||
if not cint(self.enabled) and getattr(frappe.local, "login_manager", None):
|
||||
frappe.local.login_manager.logout(user=self.name)
|
||||
|
||||
# toggle notifications based on the user's status
|
||||
toggle_notifications(self.name, enable=cint(self.enabled))
|
||||
|
||||
def add_system_manager_role(self):
|
||||
# if adding system manager, do nothing
|
||||
if not cint(self.enabled) or ("System Manager" in [user_role.role for user_role in
|
||||
|
|
@ -238,11 +245,6 @@ class User(Document):
|
|||
def reset_password(self, send_email=False, password_expired=False):
|
||||
from frappe.utils import random_string, get_url
|
||||
|
||||
rate_limit = frappe.db.get_single_value("System Settings", "password_reset_limit")
|
||||
|
||||
if rate_limit:
|
||||
check_password_reset_limit(self.name, rate_limit)
|
||||
|
||||
key = random_string(32)
|
||||
self.db_set("reset_password_key", key)
|
||||
|
||||
|
|
@ -254,7 +256,6 @@ class User(Document):
|
|||
if send_email:
|
||||
self.password_reset_mail(link)
|
||||
|
||||
update_password_reset_limit(self.name)
|
||||
return link
|
||||
|
||||
def get_other_system_managers(self):
|
||||
|
|
@ -358,6 +359,9 @@ class User(Document):
|
|||
set `user`=null
|
||||
where `user`=%s""", (self.name))
|
||||
|
||||
# delete notification settings
|
||||
frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True)
|
||||
|
||||
|
||||
def before_rename(self, old_name, new_name, merge=False):
|
||||
self.check_demo()
|
||||
|
|
@ -527,6 +531,27 @@ class User(Document):
|
|||
|
||||
return [i.strip() for i in self.restrict_ip.split(",")]
|
||||
|
||||
@classmethod
|
||||
def find_by_credentials(cls, user_name: str, password: str, validate_password: bool = True):
|
||||
"""Find the user by credentials.
|
||||
"""
|
||||
login_with_mobile = cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number"))
|
||||
filter = {"mobile_no": user_name} if login_with_mobile else {"name": user_name}
|
||||
|
||||
user = frappe.db.get_value("User", filters=filter, fieldname=['name', 'enabled'], as_dict=True) or {}
|
||||
if not user:
|
||||
return
|
||||
|
||||
user['is_authenticated'] = True
|
||||
if validate_password:
|
||||
try:
|
||||
check_password(user_name, password)
|
||||
except frappe.AuthenticationError:
|
||||
user['is_authenticated'] = False
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_timezones():
|
||||
import pytz
|
||||
|
|
@ -816,6 +841,7 @@ def sign_up(email, full_name, redirect_to):
|
|||
return 2, _("Please ask your administrator to verify your sign-up")
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@rate_limit(key='user', limit=get_password_reset_limit, seconds = 24*60*60, methods=['POST'])
|
||||
def reset_password(user):
|
||||
if user=="Administrator":
|
||||
return 'not allowed'
|
||||
|
|
@ -1147,16 +1173,3 @@ def generate_keys(user):
|
|||
def switch_theme(theme):
|
||||
if theme in ["Dark", "Light"]:
|
||||
frappe.db.set_value("User", frappe.session.user, "desk_theme", theme)
|
||||
|
||||
def update_password_reset_limit(user):
|
||||
generated_link_count = get_generated_link_count(user)
|
||||
generated_link_count += 1
|
||||
frappe.cache().hset("password_reset_link_count", user, generated_link_count)
|
||||
|
||||
def check_password_reset_limit(user, rate_limit):
|
||||
generated_link_count = get_generated_link_count(user)
|
||||
if generated_link_count >= rate_limit:
|
||||
frappe.throw(_("You have reached the hourly limit for generating password reset links. Please try again later."))
|
||||
|
||||
def get_generated_link_count(user):
|
||||
return cint(frappe.cache().hget("password_reset_link_count", user)) or 0
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ frappe.ui.form.on("Customize Form", {
|
|||
frm.trigger("setup_sortable");
|
||||
}
|
||||
}
|
||||
localStorage["customize_doctype"] = frm.doc.doc_type;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import frappe.model.meta
|
|||
from frappe import _
|
||||
from time import time
|
||||
from frappe.utils import now, getdate, cast_fieldtype, get_datetime
|
||||
from frappe.utils.background_jobs import execute_job, get_queue
|
||||
from frappe.model.utils.link_count import flush_local_link_count
|
||||
from frappe.utils import cint
|
||||
|
||||
|
|
@ -1032,6 +1031,8 @@ class Database(object):
|
|||
insert_list = []
|
||||
|
||||
def enqueue_jobs_after_commit():
|
||||
from frappe.utils.background_jobs import execute_job, get_queue
|
||||
|
||||
if frappe.flags.enqueue_after_commit and len(frappe.flags.enqueue_after_commit) > 0:
|
||||
for job in frappe.flags.enqueue_after_commit:
|
||||
q = get_queue(job.get("queue"), is_async=job.get("is_async"))
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ from pymysql.times import TimeDelta
|
|||
from pymysql.constants import ER, FIELD_TYPE
|
||||
from pymysql.converters import conversions
|
||||
|
||||
from frappe.utils import get_datetime, cstr
|
||||
from markdown2 import UnicodeWithAttrs
|
||||
from frappe.utils import get_datetime, cstr, UnicodeWithAttrs
|
||||
from frappe.database.database import Database
|
||||
from six import PY2, binary_type, text_type, string_types
|
||||
from frappe.database.mariadb.schema import MariaDBTable
|
||||
|
|
|
|||
|
|
@ -601,8 +601,8 @@ def merge_cards_based_on_label(cards):
|
|||
for card in cards:
|
||||
label = card.get('label')
|
||||
if label in cards_dict:
|
||||
links = loads(cards_dict[label].links) + loads(card.links)
|
||||
cards_dict[label].update(dict(links=dumps(links)))
|
||||
links = cards_dict[label].links + card.links
|
||||
cards_dict[label].update(dict(links=links))
|
||||
cards_dict[label] = cards_dict.pop(label)
|
||||
else:
|
||||
cards_dict[label] = card
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ def create_notification_settings(user):
|
|||
_doc.insert(ignore_permissions=True)
|
||||
|
||||
|
||||
def toggle_notifications(user, enable=False):
|
||||
if frappe.db.exists("Notification Settings", user):
|
||||
frappe.db.set_value("Notification Settings", user, 'enabled', enable)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_subscribed_documents():
|
||||
if not frappe.session.user:
|
||||
|
|
@ -75,4 +80,4 @@ def get_permission_query_conditions(user):
|
|||
|
||||
@frappe.whitelist()
|
||||
def set_seen_value(value, user):
|
||||
frappe.db.set_value('Notification Settings', user, 'seen', value, update_modified=False)
|
||||
frappe.db.set_value('Notification Settings', user, 'seen', value, update_modified=False)
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ def get_version(doctype, doc_name, frequency, user):
|
|||
return timeline
|
||||
|
||||
def get_comments(doctype, doc_name, frequency, user):
|
||||
from html2text import html2text
|
||||
|
||||
timeline = []
|
||||
filters = get_filters("reference_name", doc_name, frequency, user)
|
||||
comments = frappe.get_all("Comment",
|
||||
|
|
@ -166,7 +168,7 @@ def get_comments(doctype, doc_name, frequency, user):
|
|||
"time": comment.modified,
|
||||
"data": {
|
||||
"time": time,
|
||||
"comment": frappe.utils.html2text(str(comment.content)),
|
||||
"comment": html2text(str(comment.content)),
|
||||
"by": by
|
||||
},
|
||||
"doctype": doctype,
|
||||
|
|
@ -197,6 +199,8 @@ def get_follow_users(doctype, doc_name):
|
|||
)
|
||||
|
||||
def get_row_changed(row_changed, time, doctype, doc_name, v):
|
||||
from html2text import html2text
|
||||
|
||||
items = []
|
||||
for d in row_changed:
|
||||
d[2] = d[2] if d[2] else ' '
|
||||
|
|
@ -209,8 +213,8 @@ def get_row_changed(row_changed, time, doctype, doc_name, v):
|
|||
"table_field": d[0],
|
||||
"row": str(d[1]),
|
||||
"field": d[3][0][0],
|
||||
"from": frappe.utils.html2text(str(d[3][0][1])),
|
||||
"to": frappe.utils.html2text(str(d[3][0][2]))
|
||||
"from": html2text(str(d[3][0][1])),
|
||||
"to": html2text(str(d[3][0][2]))
|
||||
},
|
||||
"doctype": doctype,
|
||||
"doc_name": doc_name,
|
||||
|
|
@ -236,6 +240,8 @@ def get_added_row(added, time, doctype, doc_name, v):
|
|||
return items
|
||||
|
||||
def get_field_changed(changed, time, doctype, doc_name, v):
|
||||
from html2text import html2text
|
||||
|
||||
items = []
|
||||
for d in changed:
|
||||
d[1] = d[1] if d[1] else ' '
|
||||
|
|
@ -246,8 +252,8 @@ def get_field_changed(changed, time, doctype, doc_name, v):
|
|||
"data": {
|
||||
"time": time,
|
||||
"field": d[0],
|
||||
"from": frappe.utils.html2text(str(d[1])),
|
||||
"to": frappe.utils.html2text(str(d[2]))
|
||||
"from": html2text(str(d[1])),
|
||||
"to": html2text(str(d[2]))
|
||||
},
|
||||
"doctype": doctype,
|
||||
"doc_name": doc_name,
|
||||
|
|
|
|||
|
|
@ -16,8 +16,18 @@ def get_leaderboards():
|
|||
|
||||
@frappe.whitelist()
|
||||
def get_energy_point_leaderboard(date_range, company = None, field = None, limit = None):
|
||||
all_users = frappe.db.get_all('User',
|
||||
filters = {
|
||||
'name': ['not in', ['Administrator', 'Guest']],
|
||||
'enabled': 1,
|
||||
'user_type': ['!=', 'Website User']
|
||||
},
|
||||
order_by = 'name ASC')
|
||||
all_users_list = list(map(lambda x: x['name'], all_users))
|
||||
|
||||
filters = [
|
||||
['type', '!=', 'Review'],
|
||||
['user', 'in', all_users_list]
|
||||
]
|
||||
if date_range:
|
||||
date_range = frappe.parse_json(date_range)
|
||||
|
|
@ -28,15 +38,7 @@ def get_energy_point_leaderboard(date_range, company = None, field = None, limit
|
|||
group_by = 'user',
|
||||
order_by = 'value desc'
|
||||
)
|
||||
all_users = frappe.db.get_all('User',
|
||||
filters = {
|
||||
'name': ['not in', ['Administrator', 'Guest']],
|
||||
'enabled': 1,
|
||||
'user_type': ['!=', 'Website User']
|
||||
},
|
||||
order_by = 'name ASC')
|
||||
|
||||
all_users_list = list(map(lambda x: x['name'], all_users))
|
||||
energy_point_users_list = list(map(lambda x: x['name'], energy_point_users))
|
||||
for user in all_users_list:
|
||||
if user not in energy_point_users_list:
|
||||
|
|
|
|||
|
|
@ -90,6 +90,29 @@ class EmailAccount(Document):
|
|||
if self.append_to not in valid_doctypes:
|
||||
frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes)))
|
||||
|
||||
def before_save(self):
|
||||
messages = []
|
||||
as_list = 1
|
||||
if not self.enable_incoming and self.default_incoming:
|
||||
self.default_incoming = False
|
||||
messages.append(_("{} has been disabled. It can only be enabled if {} is checked.")
|
||||
.format(
|
||||
frappe.bold(_('Default Incoming')),
|
||||
frappe.bold(_('Enable Incoming'))
|
||||
)
|
||||
)
|
||||
if not self.enable_outgoing and self.default_outgoing:
|
||||
self.default_outgoing = False
|
||||
messages.append(_("{} has been disabled. It can only be enabled if {} is checked.")
|
||||
.format(
|
||||
frappe.bold(_('Default Outgoing')),
|
||||
frappe.bold(_('Enable Outgoing'))
|
||||
)
|
||||
)
|
||||
if messages:
|
||||
if len(messages) == 1: (as_list, messages) = (0, messages[0])
|
||||
frappe.msgprint(messages, as_list= as_list, indicator='orange', title=_("Defaults Updated"))
|
||||
|
||||
def on_update(self):
|
||||
"""Check there is only one default of each type."""
|
||||
from frappe.core.doctype.user.user import setup_user_email_inbox
|
||||
|
|
|
|||
|
|
@ -19,9 +19,12 @@
|
|||
"message",
|
||||
"message_md",
|
||||
"message_html",
|
||||
"section_break_13",
|
||||
"send_unsubscribe_link",
|
||||
"send_attachments",
|
||||
"column_break_9",
|
||||
"published",
|
||||
"send_webview_link",
|
||||
"route",
|
||||
"test_the_newsletter",
|
||||
"test_email_id",
|
||||
|
|
@ -160,6 +163,21 @@
|
|||
"fieldtype": "Check",
|
||||
"label": "Schedule Sending",
|
||||
"read_only_depends_on": "eval: doc.email_sent"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_9",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "published",
|
||||
"fieldname": "send_webview_link",
|
||||
"fieldtype": "Check",
|
||||
"label": "Send Web View Link"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_13",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"has_web_view": 1,
|
||||
|
|
@ -169,7 +187,7 @@
|
|||
"is_published_field": "published",
|
||||
"links": [],
|
||||
"max_attachments": 3,
|
||||
"modified": "2020-08-24 19:59:37.262500",
|
||||
"modified": "2021-02-22 14:33:56.095380",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Newsletter",
|
||||
|
|
|
|||
|
|
@ -68,13 +68,17 @@ class Newsletter(WebsiteGenerator):
|
|||
except IOError:
|
||||
frappe.throw(_("Unable to find attachment {0}").format(file.name))
|
||||
|
||||
send(recipients=self.recipients, sender=sender,
|
||||
subject=self.subject, message=self.get_message(),
|
||||
args = {
|
||||
"message": self.get_message(),
|
||||
"name": self.name
|
||||
}
|
||||
frappe.sendmail(recipients=self.recipients, sender=sender,
|
||||
subject=self.subject, message=self.get_message(), template="newsletter",
|
||||
reference_doctype=self.doctype, reference_name=self.name,
|
||||
add_unsubscribe_link=self.send_unsubscribe_link, attachments=attachments,
|
||||
unsubscribe_method="/unsubscribe",
|
||||
unsubscribe_params={"name": self.name},
|
||||
send_priority=0, queue_separately=True)
|
||||
send_priority=0, queue_separately=True, args=args)
|
||||
|
||||
if not frappe.flags.in_test:
|
||||
frappe.db.auto_commit_on_many_writes = False
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ class PermissionError(Exception):
|
|||
class DoesNotExistError(ValidationError):
|
||||
http_status_code = 404
|
||||
|
||||
class PageDoesNotExistError(ValidationError):
|
||||
http_status_code = 404
|
||||
|
||||
class NameError(Exception):
|
||||
http_status_code = 409
|
||||
|
||||
|
|
|
|||
|
|
@ -207,8 +207,7 @@ scheduler_events = {
|
|||
"frappe.deferred_insert.save_to_db",
|
||||
"frappe.desk.form.document_follow.send_hourly_updates",
|
||||
"frappe.integrations.doctype.google_calendar.google_calendar.sync",
|
||||
"frappe.email.doctype.newsletter.newsletter.send_scheduled_email",
|
||||
"frappe.utils.password.delete_password_reset_cache"
|
||||
"frappe.email.doctype.newsletter.newsletter.send_scheduled_email"
|
||||
],
|
||||
"daily": [
|
||||
"frappe.email.queue.set_expiry_for_email_queue",
|
||||
|
|
|
|||
|
|
@ -108,13 +108,8 @@ class TestConnectedApp(unittest.TestCase):
|
|||
|
||||
session = requests.Session()
|
||||
|
||||
# first login of a new user on a new site fails with "401 UNAUTHORIZED"
|
||||
# when anybody fixes that, the two lines below can be removed
|
||||
first_login = login()
|
||||
self.assertEqual(first_login.status_code, 401)
|
||||
|
||||
second_login = login()
|
||||
self.assertEqual(second_login.status_code, 200)
|
||||
self.assertEqual(first_login.status_code, 200)
|
||||
|
||||
authorization_url = self.connected_app.initiate_web_application_flow(user=self.user_name)
|
||||
|
||||
|
|
|
|||
|
|
@ -163,10 +163,13 @@ def openid_profile(*args, **kwargs):
|
|||
first_name, last_name, avatar, name = frappe.db.get_value("User", frappe.session.user, ["first_name", "last_name", "user_image", "name"])
|
||||
frappe_userid = frappe.db.get_value("User Social Login", {"parent":frappe.session.user, "provider": "frappe"}, "userid")
|
||||
request_url = urlparse(frappe.request.url)
|
||||
base_url = frappe.db.get_value("Social Login Key", "frappe", "base_url") or None
|
||||
|
||||
if avatar:
|
||||
if validate_url(avatar):
|
||||
picture = avatar
|
||||
elif base_url:
|
||||
picture = base_url + '/' + avatar
|
||||
else:
|
||||
picture = request_url.scheme + "://" + request_url.netloc + avatar
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,9 @@ from frappe.model.naming import set_new_name
|
|||
from frappe.model.utils.link_count import notify_link_count
|
||||
from frappe.modules import load_doctype_module
|
||||
from frappe.model import display_fieldtypes
|
||||
from frappe.utils.password import get_decrypted_password, set_encrypted_password
|
||||
from frappe.utils import (cint, flt, now, cstr, strip_html,
|
||||
sanitize_html, sanitize_email, cast_fieldtype)
|
||||
from frappe.utils.html_utils import unescape_html
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
max_positive_value = {
|
||||
'smallint': 2 ** 15,
|
||||
|
|
@ -419,25 +417,60 @@ class BaseDocument(object):
|
|||
doc.db_update()
|
||||
|
||||
def show_unique_validation_message(self, e):
|
||||
# TODO: Find a better way to extract fieldname
|
||||
if frappe.db.db_type != 'postgres':
|
||||
fieldname = str(e).split("'")[-2]
|
||||
label = None
|
||||
|
||||
# unique_first_fieldname_second_fieldname is the constraint name
|
||||
# created using frappe.db.add_unique
|
||||
if "unique_" in fieldname:
|
||||
fieldname = fieldname.split("_", 1)[1]
|
||||
# MariaDB gives key_name in error. Extracting fieldname from key name
|
||||
try:
|
||||
fieldname = self.get_field_name_by_key_name(fieldname)
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
df = self.meta.get_field(fieldname)
|
||||
if df:
|
||||
label = df.label
|
||||
label = self.get_label_from_fieldname(fieldname)
|
||||
|
||||
frappe.msgprint(_("{0} must be unique").format(label or fieldname))
|
||||
|
||||
# this is used to preserve traceback
|
||||
raise frappe.UniqueValidationError(self.doctype, self.name, e)
|
||||
|
||||
def get_field_name_by_key_name(self, key_name):
|
||||
"""MariaDB stores a mapping between `key_name` and `column_name`.
|
||||
This function returns the `column_name` associated with the `key_name` passed
|
||||
|
||||
Args:
|
||||
key_name (str): The name of the database index.
|
||||
|
||||
Raises:
|
||||
IndexError: If the key is not found in the table.
|
||||
|
||||
Returns:
|
||||
str: The column name associated with the key.
|
||||
"""
|
||||
return frappe.db.sql(f"""
|
||||
SHOW
|
||||
INDEX
|
||||
FROM
|
||||
`tab{self.doctype}`
|
||||
WHERE
|
||||
key_name=%s
|
||||
AND
|
||||
Non_unique=0
|
||||
""", key_name, as_dict=True)[0].get("Column_name")
|
||||
|
||||
def get_label_from_fieldname(self, fieldname):
|
||||
"""Returns the associated label for fieldname
|
||||
|
||||
Args:
|
||||
fieldname (str): The fieldname in the DocType to use to pull the label.
|
||||
|
||||
Returns:
|
||||
str: The label associated with the fieldname, if found, otherwise `None`.
|
||||
"""
|
||||
df = self.meta.get_field(fieldname)
|
||||
if df:
|
||||
return df.label
|
||||
|
||||
def update_modified(self):
|
||||
"""Update modified timestamp"""
|
||||
self.set("modified", now())
|
||||
|
|
@ -721,6 +754,8 @@ class BaseDocument(object):
|
|||
|
||||
- Ignore if 'Ignore XSS Filter' is checked or fieldtype is 'Code'
|
||||
"""
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
if frappe.flags.in_install:
|
||||
return
|
||||
|
||||
|
|
@ -757,6 +792,8 @@ class BaseDocument(object):
|
|||
|
||||
def _save_passwords(self):
|
||||
"""Save password field values in __Auth table"""
|
||||
from frappe.utils.password import set_encrypted_password
|
||||
|
||||
if self.flags.ignore_save_passwords is True:
|
||||
return
|
||||
|
||||
|
|
@ -771,6 +808,8 @@ class BaseDocument(object):
|
|||
self.set(df.fieldname, '*'*len(new_password))
|
||||
|
||||
def get_password(self, fieldname='password', raise_exception=True):
|
||||
from frappe.utils.password import get_decrypted_password
|
||||
|
||||
if self.get(fieldname) and not self.is_dummy_password(self.get(fieldname)):
|
||||
return self.get(fieldname)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import frappe
|
|||
import time
|
||||
from frappe import _, msgprint
|
||||
from frappe.utils import flt, cstr, now, get_datetime_str, file_lock, date_diff
|
||||
from frappe.utils.background_jobs import enqueue
|
||||
from frappe.model.base_document import BaseDocument, get_controller
|
||||
from frappe.model.naming import set_new_name
|
||||
from six import iteritems, string_types
|
||||
|
|
@ -1269,6 +1268,8 @@ class Document(BaseDocument):
|
|||
# call _submit instead of submit, so you can override submit to call
|
||||
# run_delayed based on some action
|
||||
# See: Stock Reconciliation
|
||||
from frappe.utils.background_jobs import enqueue
|
||||
|
||||
if hasattr(self, '_' + action):
|
||||
action = '_' + action
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ frappe.patches.v11_0.change_email_signature_fieldtype
|
|||
execute:frappe.reload_doc('core', 'doctype', 'activity_log')
|
||||
execute:frappe.reload_doc('core', 'doctype', 'deleted_document')
|
||||
execute:frappe.reload_doc('core', 'doctype', 'domain_settings')
|
||||
frappe.patches.v13_0.rename_custom_client_script
|
||||
frappe.patches.v8_0.rename_page_role_to_has_role #2017-03-16
|
||||
frappe.patches.v7_2.setup_custom_perms #2017-01-19
|
||||
frappe.patches.v8_0.set_user_permission_for_page_and_report #2017-03-20
|
||||
|
|
@ -330,4 +331,4 @@ execute:frappe.get_doc('Role', 'Guest').save() # remove desk access
|
|||
frappe.patches.v13_0.rename_desk_page_to_workspace # 02.02.2021
|
||||
frappe.patches.v13_0.delete_package_publish_tool
|
||||
frappe.patches.v13_0.rename_list_view_setting_to_list_view_settings
|
||||
frappe.patches.v13_0.rename_custom_client_script
|
||||
frappe.patches.v13_0.remove_twilio_settings
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ from __future__ import unicode_literals
|
|||
import frappe
|
||||
|
||||
def execute():
|
||||
"""Enable all the existing custom script"""
|
||||
frappe.reload_doc("Custom", "doctype", "Custom Script")
|
||||
"""Enable all the existing Client script"""
|
||||
|
||||
frappe.db.sql("""
|
||||
UPDATE `tabCustom Script` SET enabled=1
|
||||
UPDATE `tabClient Script` SET enabled=1
|
||||
""")
|
||||
20
frappe/patches/v13_0/remove_twilio_settings.py
Normal file
20
frappe/patches/v13_0/remove_twilio_settings.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""Add missing Twilio patch.
|
||||
|
||||
While making Twilio as a standaone app, we missed to delete Twilio records from DB through migration. Adding the missing patch.
|
||||
"""
|
||||
frappe.delete_doc_if_exists('DocType', 'Twilio Number Group')
|
||||
if twilio_settings_doctype_in_integrations():
|
||||
frappe.delete_doc_if_exists('DocType', 'Twilio Settings')
|
||||
frappe.db.sql("delete from `tabSingles` where `doctype`=%s", 'Twilio Settings')
|
||||
|
||||
def twilio_settings_doctype_in_integrations() -> bool:
|
||||
"""Check Twilio Settings doctype exists in integrations module or not.
|
||||
"""
|
||||
return frappe.db.exists("DocType", {'name': 'Twilio Settings', 'module': 'Integrations'})
|
||||
|
|
@ -171,7 +171,7 @@
|
|||
"fieldname": "custom_html_help",
|
||||
"fieldtype": "HTML",
|
||||
"label": "Custom HTML Help",
|
||||
"options": "<h3>Custom CSS Help</h3>\n\n<p>Notes:</p>\n\n<ol>\n<li>All field groups (label + value) are set attributes <code>data-fieldtype</code> and <code>data-fieldname</code></li>\n<li>All values are given class <code>value</code></li>\n<li>All Section Breaks are given class <code>section-break</code></li>\n<li>All Column Breaks are given class <code>column-break</code></li>\n</ol>\n\n<h4>Examples</h4>\n\n<p>1. Left align integers</p>\n\n<pre><code>[data-fieldtype=\"Int\"] .value { text-left: left; }</code></pre>\n\n<p>1. Add border to sections except the last section</p>\n\n<pre><code>.section-break { padding: 30px 0px; border-bottom: 1px solid #eee; }\n.section-break:last-child { padding-bottom: 0px; border-bottom: 0px; }</code></pre>\n"
|
||||
"options": "<h3>Custom CSS Help</h3>\n\n<p>Notes:</p>\n\n<ol>\n<li>All field groups (label + value) are set attributes <code>data-fieldtype</code> and <code>data-fieldname</code></li>\n<li>All values are given class <code>value</code></li>\n<li>All Section Breaks are given class <code>section-break</code></li>\n<li>All Column Breaks are given class <code>column-break</code></li>\n</ol>\n\n<h4>Examples</h4>\n\n<p>1. Left align integers</p>\n\n<pre><code>[data-fieldtype=\"Int\"] .value { text-align: left; }</code></pre>\n\n<p>1. Add border to sections except the last section</p>\n\n<pre><code>.section-break { padding: 30px 0px; border-bottom: 1px solid #eee; }\n.section-break:last-child { padding-bottom: 0px; border-bottom: 0px; }</code></pre>\n"
|
||||
},
|
||||
{
|
||||
"depends_on": "custom_format",
|
||||
|
|
@ -211,7 +211,7 @@
|
|||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2020-12-14 11:38:49.132061",
|
||||
"modified": "2021-03-01 15:25:46.578863",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Printing",
|
||||
"name": "Print Format",
|
||||
|
|
|
|||
|
|
@ -269,6 +269,7 @@ frappe.ui.form.PrintView = class {
|
|||
based_on: data.based_on,
|
||||
};
|
||||
frappe.set_route('print-format-builder');
|
||||
this.print_sel.val(data.print_format_name);
|
||||
},
|
||||
__('New Custom Print Format'),
|
||||
__('Start')
|
||||
|
|
@ -641,10 +642,13 @@ frappe.ui.form.PrintView = class {
|
|||
|
||||
refresh_print_options() {
|
||||
this.print_formats = frappe.meta.get_print_formats(this.frm.doctype);
|
||||
return this.print_sel.empty().add_options([
|
||||
const print_format_select_val = this.print_sel.val();
|
||||
this.print_sel.empty().add_options([
|
||||
this.get_default_option_for_select(__('Select Print Format')),
|
||||
...this.print_formats
|
||||
]);
|
||||
return this.print_formats.includes(print_format_select_val)
|
||||
&& this.print_sel.val(print_format_select_val);
|
||||
}
|
||||
|
||||
selected_format() {
|
||||
|
|
|
|||
|
|
@ -784,6 +784,7 @@ frappe.PrintFormatBuilder = Class.extend({
|
|||
btn: this.page.btn_primary,
|
||||
callback: function(r) {
|
||||
me.print_format = r.message;
|
||||
locals['Print Format'][me.print_format.name] = r.message;
|
||||
frappe.show_alert({message: __("Saved"), indicator: 'green'});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<span class="drag-handle">
|
||||
<svg class="icon icon-xs"><use xlink:href="#icon-drag"></use></svg>
|
||||
</span>
|
||||
{%= __(f.label) %}
|
||||
{%= __(f.label) || __(f.fieldname) %}
|
||||
</div>
|
||||
</div>
|
||||
{% } %}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="6" height="8" viewBox="0 0 6 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.25 7.5L4.75 4L1.25 0.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 206 B |
|
|
@ -128,7 +128,7 @@ frappe.Application = Class.extend({
|
|||
}
|
||||
|
||||
// REDESIGN-TODO: Fix preview popovers
|
||||
//this.link_preview = new frappe.ui.LinkPreview();
|
||||
this.link_preview = new frappe.ui.LinkPreview();
|
||||
|
||||
if (!frappe.boot.developer_mode) {
|
||||
setInterval(function() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
frappe.ui.form.ControlAttach = frappe.ui.form.ControlData.extend({
|
||||
make_input: function() {
|
||||
var me = this;
|
||||
let me = this;
|
||||
this.$input = $('<button class="btn btn-default btn-sm btn-attach">')
|
||||
.html(__("Attach"))
|
||||
.prependTo(me.input_area)
|
||||
|
|
@ -28,7 +28,7 @@ frappe.ui.form.ControlAttach = frappe.ui.form.ControlData.extend({
|
|||
this.toggle_reload_button();
|
||||
},
|
||||
clear_attachment: function() {
|
||||
var me = this;
|
||||
let me = this;
|
||||
if(this.frm) {
|
||||
me.parse_validate_and_set_in_model(null);
|
||||
me.refresh();
|
||||
|
|
@ -79,10 +79,13 @@ frappe.ui.form.ControlAttach = frappe.ui.form.ControlData.extend({
|
|||
this.value = value;
|
||||
if(this.value) {
|
||||
this.$input.toggle(false);
|
||||
if(this.value.indexOf(",")!==-1) {
|
||||
var parts = this.value.split(",");
|
||||
var filename = parts[0];
|
||||
dataurl = parts[1];
|
||||
// value can also be using this format: FILENAME,DATA_URL
|
||||
// Important: We have to be careful because normal filenames may also contain ","
|
||||
let file_url_parts = this.value.match(/^([^:]+),(.+):(.+)$/);
|
||||
let filename;
|
||||
if (file_url_parts) {
|
||||
filename = file_url_parts[1];
|
||||
dataurl = file_url_parts[2] + ':' + file_url_parts[3];
|
||||
}
|
||||
this.$value.toggle(true).find(".attached-file-link")
|
||||
.html(filename || this.value)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,41 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({
|
|||
html_element: 'select',
|
||||
make_input: function() {
|
||||
this._super();
|
||||
this.$wrapper.find('.control-input')
|
||||
.addClass('flex align-center')
|
||||
.append(frappe.utils.icon('select'));
|
||||
|
||||
const is_xs_input = this.df.input_class
|
||||
&& this.df.input_class.includes('input-xs');
|
||||
this.set_icon(is_xs_input);
|
||||
this.df.placeholder && this.set_placeholder(is_xs_input);
|
||||
|
||||
this.$input.addClass('ellipsis');
|
||||
this.set_options();
|
||||
},
|
||||
set_icon: function(is_xs_input) {
|
||||
const select_icon_html =
|
||||
`<div class="select-icon ${is_xs_input ? 'xs' : ''}">
|
||||
${frappe.utils.icon('select', is_xs_input ? 'xs' : 'sm')}
|
||||
</div>`;
|
||||
if (this.only_input) {
|
||||
this.$wrapper.append(select_icon_html);
|
||||
} else {
|
||||
this.$wrapper.find('.control-input')
|
||||
.addClass('flex align-center')
|
||||
.append(select_icon_html);
|
||||
}
|
||||
},
|
||||
set_placeholder: function(is_xs_input) {
|
||||
const placeholder_html =
|
||||
`<div class="placeholder ellipsis text-extra-muted ${is_xs_input ? 'xs' : ''}">
|
||||
<span>${this.df.placeholder}</span>
|
||||
</div>`;
|
||||
if (this.only_input) {
|
||||
this.$wrapper.append(placeholder_html);
|
||||
} else {
|
||||
this.$wrapper.find('.control-input').append(placeholder_html);
|
||||
}
|
||||
this.toggle_placeholder();
|
||||
this.$input && this.$input.on('select-change', () => this.toggle_placeholder());
|
||||
},
|
||||
set_formatted_input: function(value) {
|
||||
// refresh options first - (new ones??)
|
||||
if(value==null) value = '';
|
||||
|
|
@ -64,6 +94,10 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({
|
|||
this.set_description(__("Please attach a file first."));
|
||||
return [""];
|
||||
}
|
||||
},
|
||||
toggle_placeholder: function() {
|
||||
const input_set = Boolean(this.$input.val());
|
||||
this.$wrapper.find('.placeholder').toggle(!input_set);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -88,6 +122,7 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({
|
|||
label = is_label_null ? __(value) : __(v.label);
|
||||
}
|
||||
}
|
||||
|
||||
$('<option>').html(cstr(label))
|
||||
.attr('value', value)
|
||||
.prop('disabled', is_disabled)
|
||||
|
|
@ -95,6 +130,7 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({
|
|||
}
|
||||
// select the first option
|
||||
this.selectedIndex = 0;
|
||||
$(this).trigger('select-change');
|
||||
return $(this);
|
||||
};
|
||||
$.fn.set_working = function() {
|
||||
|
|
@ -103,4 +139,11 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({
|
|||
$.fn.done_working = function() {
|
||||
this.prop('disabled', false);
|
||||
};
|
||||
|
||||
let original_val = $.fn.val;
|
||||
$.fn.val = function() {
|
||||
let result = original_val.apply(this, arguments);
|
||||
if (arguments.length > 0) $(this).trigger('select-change');
|
||||
return result;
|
||||
};
|
||||
})(jQuery);
|
||||
|
|
|
|||
|
|
@ -358,10 +358,11 @@ class FormTimeline extends BaseTimeline {
|
|||
const args = {
|
||||
doc: this.frm.doc,
|
||||
frm: this.frm,
|
||||
recipients: communication_doc ? communication_doc.sender : this.get_recipient(),
|
||||
recipients: communication_doc && communication_doc.sender != frappe.session.user_email ? communication_doc.sender : this.get_recipient(),
|
||||
is_a_reply: Boolean(communication_doc),
|
||||
title: communication_doc ? __('Reply') : null,
|
||||
last_email: communication_doc
|
||||
last_email: communication_doc,
|
||||
subject: communication_doc && communication_doc.subject
|
||||
};
|
||||
|
||||
if (communication_doc && reply_all) {
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
}
|
||||
}
|
||||
if (action.action_type==='Server Action') {
|
||||
frappe.xcall(action.action, {doc: this.doc}).then((doc) => {
|
||||
frappe.xcall(action.action, {'doc': this.doc}).then((doc) => {
|
||||
if (doc.doctype) {
|
||||
// document is returned by the method,
|
||||
// apply the changes locally and refresh
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ frappe.form.formatters = {
|
|||
return repl('<a onclick="%(onclick)s">%(value)s</a>',
|
||||
{onclick: docfield.link_onclick.replace(/"/g, '"'), value:value});
|
||||
} else if(docfield && doctype) {
|
||||
if (!frappe.model.can_select(doctype) && frappe.model.can_read(doctype)) {
|
||||
if (frappe.model.can_read(doctype)) {
|
||||
return `<a
|
||||
href="/app/${encodeURIComponent(frappe.router.slug(doctype))}/${encodeURIComponent(original_value)}"
|
||||
data-doctype="${doctype}"
|
||||
|
|
|
|||
|
|
@ -649,7 +649,7 @@ export default class Grid {
|
|||
duplicate_row(d, copy_doc) {
|
||||
$.each(copy_doc, function (key, value) {
|
||||
if (!["creation", "modified", "modified_by", "idx", "owner",
|
||||
"parent", "doctype", "name", "parentield"].includes(key)) {
|
||||
"parent", "doctype", "name", "parentfield"].includes(key)) {
|
||||
d[key] = value;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ frappe.ui.form.Layout = Class.extend({
|
|||
},
|
||||
|
||||
refresh_section_collapse: function () {
|
||||
if (!this.doc) return;
|
||||
if (!(this.sections && this.sections.length)) return;
|
||||
|
||||
for (var i = 0; i < this.sections.length; i++) {
|
||||
var section = this.sections[i];
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@ frappe.ui.form.AssignTo = Class.extend({
|
|||
avatar_group.click(() => {
|
||||
new frappe.ui.form.AssignmentDialog({
|
||||
assignments: assigned_users,
|
||||
frm: this.frm,
|
||||
remove_action: this.remove.bind(this)
|
||||
frm: this.frm
|
||||
});
|
||||
});
|
||||
},
|
||||
|
|
@ -84,7 +83,7 @@ frappe.ui.form.AssignTo = Class.extend({
|
|||
|
||||
|
||||
frappe.ui.form.AssignToDialog = Class.extend({
|
||||
init: function(opts){
|
||||
init: function(opts) {
|
||||
$.extend(this, opts);
|
||||
|
||||
this.make();
|
||||
|
|
@ -214,15 +213,35 @@ frappe.ui.form.AssignmentDialog = class {
|
|||
constructor(opts) {
|
||||
this.frm = opts.frm;
|
||||
this.assignments = opts.assignments;
|
||||
this.remove_action = opts.remove_action;
|
||||
this.make();
|
||||
}
|
||||
|
||||
make() {
|
||||
this.dialog = new frappe.ui.Dialog({
|
||||
title: __('Assigned To'),
|
||||
title: __('Assignments'),
|
||||
size: 'small',
|
||||
no_focus: true,
|
||||
fields: [{
|
||||
'label': __('Assign a user'),
|
||||
'fieldname': 'user',
|
||||
'fieldtype': 'Link',
|
||||
'options': 'User',
|
||||
'change': () => {
|
||||
let value = this.dialog.get_value('user');
|
||||
if (value && !this.assigning) {
|
||||
this.assigning = true;
|
||||
this.dialog.set_df_property('user', 'read_only', 1);
|
||||
this.dialog.set_df_property('user', 'description', __('Assigning...'));
|
||||
this.add_assignment(value).then(() => {
|
||||
this.dialog.set_value('user', null);
|
||||
}).finally(() => {
|
||||
this.dialog.set_df_property('user', 'description', null);
|
||||
this.dialog.set_df_property('user', 'read_only', 0);
|
||||
this.assigning = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, {
|
||||
'fieldtype': 'HTML',
|
||||
'fieldname': 'assignment_list'
|
||||
}]
|
||||
|
|
@ -236,8 +255,31 @@ frappe.ui.form.AssignmentDialog = class {
|
|||
});
|
||||
this.dialog.show();
|
||||
}
|
||||
render(assignments) {
|
||||
this.frm && this.frm.assign_to.render(assignments);
|
||||
}
|
||||
add_assignment(assignment) {
|
||||
return frappe.xcall('frappe.desk.form.assign_to.add', {
|
||||
doctype: this.frm.doctype,
|
||||
name: this.frm.docname,
|
||||
assign_to: [assignment],
|
||||
}).then((assignments) => {
|
||||
this.update_assignment(assignment);
|
||||
this.render(assignments);
|
||||
});
|
||||
}
|
||||
remove_assignment(assignment) {
|
||||
return frappe.xcall('frappe.desk.form.assign_to.remove', {
|
||||
doctype: this.frm.doctype,
|
||||
name: this.frm.docname,
|
||||
assign_to: assignment,
|
||||
});
|
||||
}
|
||||
update_assignment(assignment) {
|
||||
this.assignment_list.append(this.get_assignment_row(assignment));
|
||||
const in_the_list = this.assignment_list.find(`[data-user="${assignment}"]`).length;
|
||||
if (!in_the_list) {
|
||||
this.assignment_list.append(this.get_assignment_row(assignment));
|
||||
}
|
||||
}
|
||||
get_assignment_row(assignment) {
|
||||
let row = $(`
|
||||
|
|
@ -256,10 +298,12 @@ frappe.ui.form.AssignmentDialog = class {
|
|||
</span>
|
||||
`);
|
||||
row.find('.remove-btn').click(() => {
|
||||
this.remove_action && this.remove_action(assignment);
|
||||
row.remove();
|
||||
this.remove_assignment(assignment).then((assignments) => {
|
||||
row.remove();
|
||||
this.render(assignments);
|
||||
});
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -957,6 +957,15 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
)}'></span>`;
|
||||
}
|
||||
|
||||
get_image_url(doc) {
|
||||
let url = doc.image ? doc.image : doc[this.meta.image_field];
|
||||
// absolute url for mobile
|
||||
if (window.cordova && !frappe.utils.is_url(url)) {
|
||||
url = frappe.base_url + url;
|
||||
}
|
||||
return url || null;
|
||||
}
|
||||
|
||||
setup_events() {
|
||||
this.setup_filterable();
|
||||
this.setup_list_click();
|
||||
|
|
|
|||
|
|
@ -156,10 +156,11 @@ frappe.views.ListViewSelect = class ListViewSelect {
|
|||
items.map(item => {
|
||||
if (item.name.toLowerCase() == page_name.toLowerCase()) {
|
||||
placeholder = item.name;
|
||||
} else {
|
||||
html += `<li><a class="dropdown-item" href="${item.route}">${
|
||||
item.name
|
||||
}</a></li>`;
|
||||
}
|
||||
html += `<li><a class="dropdown-item" href="${item.route}">${
|
||||
item.name
|
||||
}</a></li>`;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -621,6 +621,7 @@ $.extend(frappe.model, {
|
|||
r.message || args.new_name]);
|
||||
if(locals[doctype] && locals[doctype][docname])
|
||||
delete locals[doctype][docname];
|
||||
this.frm.reload_doc();
|
||||
d.hide();
|
||||
if(callback)
|
||||
callback(r.message);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
|
|||
// make fields (if any)
|
||||
super.make();
|
||||
|
||||
this.refresh_section_collapse();
|
||||
|
||||
// show footer
|
||||
this.action = this.action || { primary: { }, secondary: { } };
|
||||
if (this.primary_action || (this.action.primary && this.action.primary.onsubmit)) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ frappe.ui.LinkPreview = class {
|
|||
}
|
||||
});
|
||||
this.handle_popover_hide();
|
||||
|
||||
}
|
||||
|
||||
identify_doc() {
|
||||
|
|
@ -122,7 +121,7 @@ frappe.ui.LinkPreview = class {
|
|||
}
|
||||
});
|
||||
|
||||
$(window).on('hashchange', () => {
|
||||
frappe.router.on('change', () => {
|
||||
this.clear_all_popovers();
|
||||
});
|
||||
}
|
||||
|
|
@ -142,18 +141,22 @@ frappe.ui.LinkPreview = class {
|
|||
let popover_content = this.get_popover_html(preview_data);
|
||||
this.element.popover({
|
||||
container: 'body',
|
||||
template: `
|
||||
<div class="link-preview-popover popover">
|
||||
<div class="arrow"></div>
|
||||
<div class="popover-body popover-content">
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
html: true,
|
||||
sanitizeFn: (content) => content,
|
||||
content: popover_content,
|
||||
trigger: 'manual',
|
||||
placement: 'top auto',
|
||||
animation: false,
|
||||
placement: 'top',
|
||||
});
|
||||
|
||||
const $popover = this.element.data('bs.popover').tip();
|
||||
|
||||
$popover.addClass('link-preview-popover');
|
||||
const $popover = $(this.element.data('bs.popover').tip);
|
||||
$popover.toggleClass('control-field-popover', this.is_link);
|
||||
|
||||
this.popovers_list.push(this.element.data('bs.popover'));
|
||||
|
||||
}
|
||||
|
|
@ -167,53 +170,63 @@ frappe.ui.LinkPreview = class {
|
|||
this.href = this.href.replace(new RegExp(' ', 'g'), '%20');
|
||||
}
|
||||
|
||||
let image_html = '';
|
||||
let id_html = '';
|
||||
let content_html = '';
|
||||
|
||||
if (preview_data.preview_image) {
|
||||
let image_url = encodeURI(preview_data.preview_image);
|
||||
image_html = `
|
||||
let popover_content =`
|
||||
<div class="preview-popover-header">
|
||||
<div class="preview-header">
|
||||
<img src="${image_url}" onerror="this.src='/assets/frappe/images/fallback-thumbnail.jpg'" class="preview-image"></img>
|
||||
${this.get_image_html(preview_data)}
|
||||
<div class="preview-name">
|
||||
<a href=${this.href}>${__(preview_data.preview_title)}</a>
|
||||
</div>
|
||||
<div class="text-muted preview-title">${this.get_id_html(preview_data)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
</div>
|
||||
<hr>
|
||||
<div class="popover-body">
|
||||
${this.get_content_html(preview_data)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (preview_data.preview_title != preview_data.name) {
|
||||
return popover_content;
|
||||
}
|
||||
|
||||
get_id_html(preview_data) {
|
||||
let id_html = '';
|
||||
if (preview_data.preview_title !== preview_data.name) {
|
||||
id_html = `<a class="text-muted" href=${this.href}>${preview_data.name}</a>`;
|
||||
}
|
||||
|
||||
return id_html;
|
||||
}
|
||||
|
||||
get_image_html(preview_data) {
|
||||
let avatar_html = frappe.get_avatar(
|
||||
"avatar-medium",
|
||||
preview_data.preview_title,
|
||||
preview_data.preview_image
|
||||
);
|
||||
|
||||
return `<div class="preview-image">
|
||||
${avatar_html}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
get_content_html(preview_data) {
|
||||
let content_html = '';
|
||||
|
||||
Object.keys(preview_data).forEach(key => {
|
||||
if (!['preview_image', 'preview_title', 'name'].includes(key)) {
|
||||
let value = frappe.ellipsis(preview_data[key], 280);
|
||||
let label = key;
|
||||
content_html += `
|
||||
<div class="preview-field">
|
||||
<div class='small preview-label text-muted bold'>${__(label)}</div>
|
||||
<div class="small preview-value">${value}</div>
|
||||
<div class="preview-label text-muted">${__(label)}</div>
|
||||
<div class="preview-value">${value}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
content_html = `<div class="preview-table">${content_html}</div>`;
|
||||
|
||||
let popover_content =`
|
||||
<div class="preview-popover-header">${image_html}
|
||||
<div class="preview-header">
|
||||
<div class="preview-main">
|
||||
<a class="preview-name bold" href=${this.href}>${__(preview_data.preview_title)}</a>
|
||||
<span class="text-muted small">${__(this.doctype)} ${id_html}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="popover-body">
|
||||
${content_html}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return popover_content;
|
||||
return `<div class="preview-table">${content_html}</div>`;
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ frappe.msgprint = function(msg, title, is_minimizable) {
|
|||
}
|
||||
|
||||
frappe.msg_dialog.set_primary_action(
|
||||
__(data.primary_action.label || "Done"),
|
||||
__(data.primary_action.label || data.primary_action_label || "Done"),
|
||||
data.primary_action.action
|
||||
);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -771,6 +771,8 @@ frappe.ui.Page = Class.extend({
|
|||
df.placeholder = df.label;
|
||||
}
|
||||
|
||||
df.input_class = 'input-xs';
|
||||
|
||||
var f = frappe.ui.form.make_control({
|
||||
df: df,
|
||||
parent: parent || this.page_form,
|
||||
|
|
@ -792,7 +794,7 @@ frappe.ui.Page = Class.extend({
|
|||
// hidden fields dont have $input
|
||||
if (!f.$input) f.make_input();
|
||||
|
||||
f.$input.addClass("input-xs").attr("placeholder", __(df.label));
|
||||
f.$input.attr("placeholder", __(df.label));
|
||||
|
||||
if(df.fieldtype==="Check") {
|
||||
$(f.wrapper).find(":first-child")
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
|
|||
}
|
||||
|
||||
refresh() {
|
||||
this.current_theme = document.body.dataset.theme;
|
||||
this.current_theme = document.documentElement.getAttribute("data-theme") || "light";
|
||||
this.fetch_themes().then(() => {
|
||||
this.render();
|
||||
});
|
||||
|
|
@ -45,7 +45,6 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
get_preview_html(theme) {
|
||||
const preview = $(`<div class="${this.current_theme == theme.name ? "selected" : "" }">
|
||||
<div data-theme=${theme.name}>
|
||||
|
|
@ -69,15 +68,9 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
|
|||
</div>
|
||||
</div>`);
|
||||
|
||||
// preview.on('mouseover', () => {
|
||||
// this.toggle_theme(theme.name, true)
|
||||
// })
|
||||
|
||||
// preview.on('mouseleave', () => {
|
||||
// this.toggle_theme(this.current_theme, true)
|
||||
// })
|
||||
|
||||
preview.on('click', () => {
|
||||
if (this.current_theme === theme.name) return;
|
||||
|
||||
this.themes.forEach((th) => {
|
||||
th.$html.removeClass("selected");
|
||||
});
|
||||
|
|
@ -89,19 +82,15 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
|
|||
return preview;
|
||||
}
|
||||
|
||||
toggle_theme(theme, preview=false) {
|
||||
if (!preview) {
|
||||
document.body.dataset.theme = theme.toLowerCase();
|
||||
frappe.show_alert("Theme Changed", 3);
|
||||
toggle_theme(theme) {
|
||||
this.current_theme = theme.toLowerCase();
|
||||
document.documentElement.setAttribute("data-theme", this.current_theme);
|
||||
frappe.show_alert("Theme Changed", 3);
|
||||
|
||||
frappe.call('frappe.core.doctype.user.user.switch_theme', {
|
||||
theme: toTitle(theme)
|
||||
});
|
||||
} else {
|
||||
document.body.dataset.theme = theme.toLowerCase();
|
||||
}
|
||||
frappe.xcall("frappe.core.doctype.user.user.switch_theme", {
|
||||
theme: toTitle(theme)
|
||||
});
|
||||
}
|
||||
|
||||
show() {
|
||||
this.dialog.show();
|
||||
}
|
||||
|
|
@ -109,4 +98,4 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
|
|||
hide() {
|
||||
this.dialog.hide();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,27 +20,25 @@ frappe.avatar = function (user, css_class, title, image_url=null, remove_color=f
|
|||
title = user_info.fullname;
|
||||
}
|
||||
|
||||
return frappe.get_avatar(
|
||||
user, css_class, title, image_url || user_info.image, remove_color, filterable
|
||||
);
|
||||
};
|
||||
|
||||
frappe.get_avatar = function(user, css_class, title, image_url=null, remove_color, filterable) {
|
||||
let data_attr = '';
|
||||
|
||||
if (!css_class) {
|
||||
css_class = "avatar-small";
|
||||
}
|
||||
|
||||
if (filterable) {
|
||||
css_class += " filterable";
|
||||
data_attr = `data-filter="_assign,like,%${user}%"`;
|
||||
}
|
||||
|
||||
return frappe.get_avatar(
|
||||
css_class, title, image_url || user_info.image, remove_color, data_attr
|
||||
);
|
||||
};
|
||||
|
||||
frappe.get_avatar = function(css_class, title, image_url=null, remove_color, data_attributes) {
|
||||
if (!css_class) {
|
||||
css_class = "avatar-small";
|
||||
}
|
||||
|
||||
if (image_url) {
|
||||
const image = (window.cordova && image_url.indexOf('http') === -1) ? frappe.base_url + image_url : image_url;
|
||||
|
||||
return `<span class="avatar ${css_class}" title="${title}" ${data_attr}>
|
||||
return `<span class="avatar ${css_class}" title="${title}" ${data_attributes}>
|
||||
<span class="avatar-frame" style='background-image: url("${image}")'
|
||||
title="${title}"></span>
|
||||
</span>`;
|
||||
|
|
@ -55,7 +53,8 @@ frappe.get_avatar = function(user, css_class, title, image_url=null, remove_colo
|
|||
if (css_class === 'avatar-small' || css_class == 'avatar-xs') {
|
||||
abbr = abbr.substr(0, 1);
|
||||
}
|
||||
return `<span class="avatar ${css_class}" title="${title}" ${data_attr}>
|
||||
|
||||
return `<span class="avatar ${css_class}" title="${title}" ${data_attributes}>
|
||||
<div class="avatar-frame standard-image"
|
||||
style="${style}">
|
||||
${abbr}
|
||||
|
|
|
|||
|
|
@ -203,7 +203,9 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
if(this.dialog.fields_dict.sender) {
|
||||
this.dialog.fields_dict.sender.set_value(this.sender || '');
|
||||
}
|
||||
this.dialog.fields_dict.subject.set_value(this.subject || '');
|
||||
this.dialog.fields_dict.subject.set_value(
|
||||
frappe.utils.html2text(this.subject) || ''
|
||||
);
|
||||
|
||||
this.setup_earlier_reply();
|
||||
},
|
||||
|
|
@ -386,21 +388,18 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
},
|
||||
|
||||
setup_print_language: function() {
|
||||
var me = this;
|
||||
var doc = this.doc || cur_frm.doc;
|
||||
var fields = this.dialog.fields_dict;
|
||||
|
||||
//Load default print language from doctype
|
||||
this.lang_code = doc.language
|
||||
|
||||
if (this.get_print_format().default_print_language) {
|
||||
var default_print_language_code = this.get_print_format().default_print_language;
|
||||
me.lang_code = default_print_language_code;
|
||||
} else {
|
||||
var default_print_language_code = null;
|
||||
if (!this.lang_code && this.get_print_format().default_print_language) {
|
||||
this.lang_code = this.get_print_format().default_print_language;
|
||||
}
|
||||
|
||||
//On selection of language retrieve language code
|
||||
var me = this;
|
||||
$(fields.language_sel.input).change(function(){
|
||||
me.lang_code = this.value
|
||||
})
|
||||
|
|
@ -410,10 +409,8 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
.empty()
|
||||
.add_options(frappe.get_languages());
|
||||
|
||||
if (default_print_language_code) {
|
||||
$(fields.language_sel.input).val(default_print_language_code);
|
||||
} else {
|
||||
$(fields.language_sel.input).val(doc.language);
|
||||
if (this.lang_code) {
|
||||
$(fields.language_sel.input).val(this.lang_code);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -440,6 +437,7 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
}
|
||||
|
||||
},
|
||||
|
||||
setup_attach: function() {
|
||||
var fields = this.dialog.fields_dict;
|
||||
var attach = $(fields.select_attachments.wrapper);
|
||||
|
|
@ -606,7 +604,7 @@ frappe.views.CommunicationComposer = Class.extend({
|
|||
|
||||
delete_saved_draft() {
|
||||
if (this.dialog) {
|
||||
localforage.getItem(this.frm.doctype + this.frm.docname).catch(e => {
|
||||
localforage.removeItem(this.frm.doctype + this.frm.docname).catch(e => {
|
||||
if (e) {
|
||||
// silently fail
|
||||
console.log(e); // eslint-disable-line
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ frappe.views.FormFactory = class FormFactory extends frappe.views.Factory {
|
|||
if (name && name.substr(0, 3) === 'new') {
|
||||
this.render_new_doc(doctype, name, doctype_layout);
|
||||
} else {
|
||||
frappe.show_not_found(route);
|
||||
frappe.show_not_found();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,20 +142,6 @@ frappe.views.ImageView = class ImageView extends frappe.views.ListView {
|
|||
`;
|
||||
}
|
||||
|
||||
get_image_url(data) {
|
||||
var url;
|
||||
url = data.image ? data.image : data[this.meta.image_field];
|
||||
|
||||
// absolute url for mobile
|
||||
if (window.cordova && !frappe.utils.is_url(url)) {
|
||||
url = frappe.base_url + url;
|
||||
}
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get_attached_images() {
|
||||
return frappe
|
||||
.call({
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ frappe.provide("frappe.views");
|
|||
|
||||
function init() {
|
||||
fluxify.doAction('init', opts);
|
||||
store.on('change:columns', make_columns);
|
||||
store.off('change:columns').on('change:columns', make_columns);
|
||||
prepare();
|
||||
store.on('change:cur_list', setup_restore_columns);
|
||||
store.on('change:columns', setup_restore_columns);
|
||||
|
|
@ -605,9 +605,10 @@ frappe.provide("frappe.views");
|
|||
function make_dom() {
|
||||
var opts = {
|
||||
name: card.name,
|
||||
title: remove_img_tags(card.title),
|
||||
title: frappe.utils.html2text(card.title),
|
||||
disable_click: card._disable_click ? 'disable-click' : '',
|
||||
creation: card.creation,
|
||||
image_url: cur_list.get_image_url(card),
|
||||
};
|
||||
self.$card = $(frappe.render_template('kanban_card', opts))
|
||||
.appendTo(wrapper);
|
||||
|
|
@ -705,6 +706,7 @@ frappe.provide("frappe.views");
|
|||
title: card[state.card_meta.title_field.fieldname],
|
||||
creation: moment(card.creation).format('MMM DD, YYYY'),
|
||||
_liked_by: card._liked_by,
|
||||
image: card[cur_list.meta.image_field],
|
||||
tags: card._user_tags,
|
||||
column: card[state.board.field_name],
|
||||
assigned_list: card.assigned_list || assigned_list,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
<div class="kanban-card-wrapper {{ disable_click }}" data-name="{{escape(name)}}">
|
||||
<a class="kanban-card-redirect" href="#">
|
||||
<div class="kanban-card content">
|
||||
<div class="kanban-title-area">
|
||||
<div class="kanban-card-title" title="{{title}}">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="kanban-card-creation">
|
||||
{{ creation }}
|
||||
</div>
|
||||
{% if(image_url) { %}
|
||||
<div class="kanban-image">
|
||||
<img src="{{image_url}}" alt="{{title}}">
|
||||
</div>
|
||||
<div class="kanban-card-meta">
|
||||
{% } %}
|
||||
<div class="kanban-card-body">
|
||||
<div class="kanban-title-area">
|
||||
<div class="kanban-card-title ellipsis" title="{{title}}">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="kanban-card-creation">
|
||||
{{ creation }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="kanban-card-meta">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="kanban-column-header">
|
||||
<span class="kanban-column-title">
|
||||
<span class="indicator-pill {{indicator}}"></span>
|
||||
<span class="kanban-title">{{ __(title) }}</span>
|
||||
<span class="kanban-title ellipsis" title="{{title}}">{{ __(title) }}</span>
|
||||
</span>
|
||||
<div class="column-options dropdown pull-right">
|
||||
<a data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ frappe.views.Page = class Page {
|
|||
return;
|
||||
}
|
||||
this.wrapper = frappe.container.add_page(this.name);
|
||||
this.wrapper.label = this.pagedoc.title || this.pagedoc.name;
|
||||
this.wrapper.page_name = this.pagedoc.name;
|
||||
|
||||
// set content, script and style
|
||||
|
|
|
|||
|
|
@ -1203,11 +1203,11 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
|
|||
// Rerender the reports dropdown,
|
||||
// so that this report is included in the dropdown as well.
|
||||
frappe.boot.user.all_reports[r.message] = {
|
||||
ref_doctype: "Item",
|
||||
ref_doctype: this.doctype,
|
||||
report_type: "Report Builder",
|
||||
title: r.message,
|
||||
};
|
||||
this.list_sidebar.setup_reports();
|
||||
|
||||
frappe.set_route('List', this.doctype, 'Report', r.message);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,23 @@ frappe.views.TreeView = Class.extend({
|
|||
this.post_render();
|
||||
},
|
||||
|
||||
rebuild_tree: function() {
|
||||
let me = this;
|
||||
|
||||
frappe.call({
|
||||
"method": "frappe.utils.nestedset.rebuild_tree",
|
||||
"args": {
|
||||
'doctype': me.doctype,
|
||||
'parent_field': "parent_"+me.doctype.toLowerCase().replace(/ /g, '_'),
|
||||
},
|
||||
"callback": function(r) {
|
||||
if (!r.exc) {
|
||||
me.make_tree();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
post_render: function() {
|
||||
var me = this;
|
||||
me.opts.post_render && me.opts.post_render(me);
|
||||
|
|
@ -368,7 +385,7 @@ frappe.views.TreeView = Class.extend({
|
|||
}, "add");
|
||||
}
|
||||
},
|
||||
set_menu_item: function(){
|
||||
set_menu_item: function() {
|
||||
var me = this;
|
||||
|
||||
this.menu_items = [
|
||||
|
|
@ -393,6 +410,17 @@ frappe.views.TreeView = Class.extend({
|
|||
},
|
||||
];
|
||||
|
||||
if (frappe.user.has_role('System Manager')) {
|
||||
this.menu_items.push(
|
||||
{
|
||||
label: __('Rebuild Tree'),
|
||||
action: function() {
|
||||
me.rebuild_tree();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (me.opts.menu_items) {
|
||||
me.menu_items.push.apply(me.menu_items, me.opts.menu_items)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,8 +78,9 @@
|
|||
|
||||
.btn.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--white);
|
||||
white-space: nowrap;
|
||||
--icon-stroke: white;
|
||||
--icon-stroke: currentColor;
|
||||
--icon-fill-bg: var(--primary-color);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ textarea.form-control {
|
|||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.frappe-control[data-fieldtype="Data"] .control-input {
|
||||
.frappe-control[data-fieldtype="Data"] .control-input, .control-value {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
|
@ -4,6 +4,14 @@
|
|||
|
||||
font-family: inherit;
|
||||
z-index: 9999 !important;
|
||||
background: var(--fg-color);
|
||||
color: var(--text-color);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
&--nav {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
&--time-current-hours, &--time-current-minutes, &--time-current-seconds {
|
||||
font-family: inherit;
|
||||
|
|
@ -45,6 +53,10 @@
|
|||
|
||||
}
|
||||
|
||||
&--time, &--buttons {
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
&--time-row {
|
||||
background-image: linear-gradient(to right, #0089FF, #0089FF);
|
||||
background-repeat: no-repeat;
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
--checkbox-right-margin: 8px;
|
||||
|
||||
.label-area {
|
||||
line-height: 1;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +83,8 @@ html.firefox, html.safari {
|
|||
@include card();
|
||||
}
|
||||
|
||||
.frappe-control[data-fieldtype="Select"] .control-input {
|
||||
.frappe-control[data-fieldtype="Select"] .control-input,
|
||||
.frappe-control[data-fieldtype="Select"].form-group {
|
||||
position: relative;
|
||||
|
||||
select {
|
||||
|
|
@ -94,14 +94,37 @@ html.firefox, html.safari {
|
|||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
.select-icon {
|
||||
padding-left: inherit;
|
||||
padding-right: inherit;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
height: 15px;
|
||||
right: 12px;
|
||||
pointer-events: none;
|
||||
use {
|
||||
stroke: var(--text-muted);
|
||||
top: 7px;
|
||||
right: 12px;
|
||||
|
||||
&.xs {
|
||||
top: 3px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
use {
|
||||
stroke: var(--text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding-left: inherit;
|
||||
padding-right: inherit;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 7px;
|
||||
left: 12px;
|
||||
max-width: 80%;
|
||||
|
||||
&.xs {
|
||||
top: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -138,3 +161,34 @@ html.firefox, html.safari {
|
|||
-webkit-transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
.icon, &:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
@extend .d-none;
|
||||
}
|
||||
|
||||
.margin {
|
||||
margin: var(--margin-sm);
|
||||
}
|
||||
.margin-top {
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
.margin-bottom {
|
||||
margin-bottom: var(--margin-sm);
|
||||
}
|
||||
.margin-left {
|
||||
margin-left: var(--margin-sm);
|
||||
}
|
||||
.margin-right {
|
||||
margin-right: var(--margin-sm);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,38 @@
|
|||
background-color: var(--bg-light-gray);
|
||||
}
|
||||
|
||||
@mixin broken-img(
|
||||
$content: null,
|
||||
$height: 100%,
|
||||
$top: 0,
|
||||
$left: 0,
|
||||
$background-color: var(--bg-color),
|
||||
$border-radius: var(--border-radius),
|
||||
) {
|
||||
|
||||
@if $content {
|
||||
img:after {
|
||||
content: url($content);
|
||||
}
|
||||
} @else {
|
||||
img:after {
|
||||
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
|
||||
}
|
||||
}
|
||||
|
||||
img[alt]:after {
|
||||
height: $height;
|
||||
top: $top;
|
||||
left: $left;
|
||||
background-color: $background-color;
|
||||
border-radius: $border-radius;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
@include flex();
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// @mixin img-foreground() {
|
||||
// content: "\f1c5";
|
||||
// display: block;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
font-size: var(--text-md);
|
||||
margin-right: 10px;
|
||||
&:before {
|
||||
content: url('/assets/frappe/icons/timeless/icon-right-arrow.svg');
|
||||
content: var(--right-arrow-svg);
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ $input-height: 28px !default;
|
|||
|
||||
// input
|
||||
--input-height: #{$input-height};
|
||||
--input-disabled-bg: var(--gray-200);
|
||||
|
||||
// timeline
|
||||
--timeline-item-icon-size: 34px;
|
||||
|
|
@ -60,4 +61,6 @@ $input-height: 28px !default;
|
|||
|
||||
// skeleton
|
||||
--skeleton-bg: var(--gray-100);
|
||||
|
||||
--right-arrow-svg: url("data: image/svg+xml;utf8, <svg width='6' height='8' viewBox='0 0 6 8' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M1.25 7.5L4.75 4L1.25 0.5' stroke='%231F272E' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@
|
|||
--highlight-color: var(--gray-700);
|
||||
--yellow-highlight-color: var(--yellow-700);
|
||||
|
||||
// input
|
||||
--input-disabled-bg: none;
|
||||
|
||||
.frappe-card {
|
||||
.btn-default {
|
||||
background-color: var(--bg-color);
|
||||
|
|
@ -142,4 +145,6 @@
|
|||
|
||||
// skeleton
|
||||
--skeleton-bg: var(--gray-800);
|
||||
|
||||
--right-arrow-svg: url("data: image/svg+xml;utf8, <svg width='6' height='8' viewBox='0 0 6 8' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M1.25 7.5L4.75 4L1.25 0.5' stroke='white' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,21 +98,10 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
img:after {
|
||||
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
|
||||
}
|
||||
|
||||
img[alt]:after {
|
||||
height: 70px;
|
||||
@include flex();
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -15px;
|
||||
left: 0px;
|
||||
width: 100%;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
@include broken-img(
|
||||
$height: 70px,
|
||||
$top: -15px,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
--dt-text-color: var(--text-light);
|
||||
--dt-text-light: var(--bg-color);
|
||||
--dt-spacer-1: 0.25rem;
|
||||
--dt-spacer-2: 0.5rem;
|
||||
--dt-spacer-2: var(--padding-xs);
|
||||
--dt-spacer-3: 1rem;
|
||||
--dt-border-radius: var(--border-radius);
|
||||
--dt-cell-bg: var(--fg-color);
|
||||
|
|
@ -26,6 +26,16 @@
|
|||
border-radius: 0px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
height: 100%;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&[data-fieldtype="Select"] .select-icon {
|
||||
top: 5px;
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.dt-header {
|
||||
|
|
|
|||
|
|
@ -3,19 +3,6 @@ html {
|
|||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
// transition
|
||||
* {
|
||||
transition: background-color 0.5s, background 0.5s;
|
||||
}
|
||||
|
||||
a,
|
||||
.badge {
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.btn {
|
||||
transition: background-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
|
|
@ -102,11 +89,8 @@ a.badge-hover {
|
|||
}
|
||||
|
||||
pre {
|
||||
color: var(--text-light)
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none !important;
|
||||
color: var(--text-light);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.col-xs-1 { @extend .col-1; }
|
||||
|
|
@ -150,23 +134,6 @@ footer {
|
|||
float: right;
|
||||
}
|
||||
|
||||
// .border-${position} {
|
||||
// .border-{$position} {
|
||||
// border-{$position}: 1px solid var(--border-color);
|
||||
// }
|
||||
// }
|
||||
// .border-#{$position} {
|
||||
// .border-#{$position} {
|
||||
// border-#{$position}: 1px solid var(--border-color);
|
||||
// }
|
||||
// }
|
||||
|
||||
// @include border-(top);
|
||||
// @include border-(bottom);
|
||||
// @include border-(left);
|
||||
// @include border-(right);
|
||||
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
|
@ -191,9 +158,6 @@ img {
|
|||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
@extend .d-none;
|
||||
}
|
||||
|
||||
.hide-control {
|
||||
@extend .d-none;
|
||||
|
|
@ -224,10 +188,6 @@ p {
|
|||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.fill-width {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: 800;
|
||||
|
|
@ -280,6 +240,7 @@ select.input-xs {
|
|||
/* popover */
|
||||
.popover {
|
||||
background-color: var(--popover-bg);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.bold {
|
||||
|
|
@ -386,31 +347,6 @@ kbd {
|
|||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
.icon, &:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.margin {
|
||||
margin: var(--margin-sm);
|
||||
}
|
||||
.margin-top {
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
.margin-bottom {
|
||||
margin-bottom: var(--margin-sm);
|
||||
}
|
||||
.margin-left {
|
||||
margin-left: var(--margin-sm);
|
||||
}
|
||||
.margin-right {
|
||||
margin-right: var(--margin-sm);
|
||||
}
|
||||
|
||||
.standard-sidebar {
|
||||
font-size: var(--text-base);
|
||||
|
||||
|
|
|
|||
|
|
@ -154,20 +154,10 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
img:after {
|
||||
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
|
||||
}
|
||||
|
||||
img[alt]:after {
|
||||
height: 175px;
|
||||
@include flex();
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
@include broken-img(
|
||||
$height: 175px,
|
||||
$border-radius: 0
|
||||
);
|
||||
}
|
||||
|
||||
.image-title {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
@import "variables";
|
||||
@import "css_variables";
|
||||
@import "../common/mixins.scss";
|
||||
@import "../common/global.scss";
|
||||
@import "../common/icons.scss";
|
||||
|
|
@ -37,7 +36,7 @@
|
|||
@import "calendar";
|
||||
@import "dashboard_view";
|
||||
@import "tree";
|
||||
@import "controls";
|
||||
@import "../common/controls";
|
||||
@import "data_import";
|
||||
@import "driver";
|
||||
@import "role_editor";
|
||||
|
|
|
|||
|
|
@ -117,11 +117,13 @@
|
|||
font-size: var(--text-lg);
|
||||
color: var(--text-color);
|
||||
font-weight: normal;
|
||||
padding-right: var(--padding-xs);
|
||||
max-width: 100%;
|
||||
|
||||
// margin-left: 10px;
|
||||
.kanban-title {
|
||||
font-size: var(--text-lg);
|
||||
margin-left: 10px;
|
||||
margin-left: var(--margin-sm);
|
||||
font-weight: var(--text-bold, 600);
|
||||
}
|
||||
}
|
||||
|
|
@ -151,13 +153,15 @@
|
|||
|
||||
.kanban-card {
|
||||
@include flex(flex, space-between, null, column);
|
||||
|
||||
@include card(
|
||||
$padding: var(--padding-sm) var(--padding-md),
|
||||
$background-color: var(--kanban-card-bg)
|
||||
);
|
||||
margin-top: var(--margin-sm);
|
||||
min-height: 100px;
|
||||
@include card(
|
||||
$padding: 0,
|
||||
$background-color: var(--kanban-card-bg)
|
||||
);
|
||||
.kanban-card-body {
|
||||
padding: var(--padding-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -209,6 +213,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
.kanban-image {
|
||||
height: 125px;
|
||||
|
||||
img {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
object-position: top;
|
||||
object-fit: cover;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
color: transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@include broken-img(
|
||||
$height: 125px,
|
||||
$top: -4px,
|
||||
)
|
||||
}
|
||||
|
||||
.kanban-card-edit {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
|
|
@ -287,6 +312,7 @@
|
|||
height: 22px;
|
||||
width: auto;
|
||||
padding: 2px 8px;
|
||||
margin-bottom: var(--margin-xs);
|
||||
margin-right: var(--margin-xs);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,61 @@
|
|||
.link-preview-popover {
|
||||
border-radius: 0;
|
||||
max-width: 100%;
|
||||
|
||||
.popover-content {
|
||||
padding: 0;
|
||||
.preview-popover-header {
|
||||
padding: var(--padding-md);
|
||||
padding: var(--padding-md) 25px;
|
||||
|
||||
.preview-header {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
.preview-header {
|
||||
@include flex(flex, null, center, column);
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
margin-right: var(--margin-sm);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
.preview-image {
|
||||
margin-bottom: var(--margin-sm);
|
||||
|
||||
.preview-name {
|
||||
display: block;
|
||||
}
|
||||
.avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
|
||||
.preview-title {
|
||||
display: block;
|
||||
.standard-image {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
.preview-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-table {
|
||||
padding: var(--padding-md);
|
||||
padding-bottom: var(--padding-xs);
|
||||
max-width: 330px;
|
||||
min-width: 200px;
|
||||
overflow-wrap: break-word;
|
||||
.preview-title:not(:empty) {
|
||||
margin-top: var(--margin-xs);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.preview-field {
|
||||
padding-bottom: var(--padding-sm);
|
||||
.preview-label {
|
||||
padding-bottom: 4px;
|
||||
.popover-body {
|
||||
padding: 0;
|
||||
.preview-table {
|
||||
padding-bottom: var(--padding-xs);
|
||||
max-width: 330px;
|
||||
min-width: 200px;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
.preview-field {
|
||||
|
||||
.preview-label {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.preview-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ql-snow .ql-editor {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--margin-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,20 +44,6 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.frappe-control[data-fieldtype="Select"] .control-input {
|
||||
select {
|
||||
height: auto;
|
||||
padding: var(--padding-xs) var(--padding-sm);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 13px;
|
||||
top: 7px;
|
||||
right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.label-area {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,11 +60,12 @@ $link-color: var(--text-color);
|
|||
// input
|
||||
$input-bg: var(--control-bg);
|
||||
$input-placeholder-color: var(--gray-500);
|
||||
$input-disabled-bg: var(--gray-200);
|
||||
$input-disabled-bg: var(--input-disabled-bg);
|
||||
$input-color: var(--text-color);
|
||||
$input-box-shadow: none;
|
||||
$input-focus-border-color: var(--gray-500);
|
||||
$input-border-radius: var(--border-radius);
|
||||
$input-btn-focus-width: 2px;
|
||||
|
||||
// dropdown
|
||||
$dropdown-color: var(--text-color);
|
||||
|
|
@ -94,11 +95,15 @@ $btn-active-box-shadow: var(--shadow-inset);
|
|||
$mark-bg: #FDF9AF;
|
||||
$mark-padding: 0;
|
||||
|
||||
// transitions
|
||||
$btn-transition: none;
|
||||
$input-transition: none;
|
||||
|
||||
// popover
|
||||
$enable-shadows: true;
|
||||
$popover-border-radius: var(--border-radius);
|
||||
$popover-bg: var(--popover-bg);
|
||||
$popover-box-shadow: var(--shadow-md);
|
||||
$popover-box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1);
|
||||
$popover-body-padding-x: var(--padding-md);
|
||||
$popover-body-padding-y: var(--padding-md);
|
||||
$popover-border-color: var(--dark-border-color);
|
||||
|
|
@ -134,9 +139,9 @@ $grid-breakpoints: (
|
|||
2xl: 1440px
|
||||
) !default;
|
||||
|
||||
@import 'dark';
|
||||
@import 'typography';
|
||||
@import '~bootstrap/scss/functions';
|
||||
@import '~bootstrap/scss/variables';
|
||||
@import "~bootstrap/scss/mixins";
|
||||
@import 'css_variables';
|
||||
@import 'dark';
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
@import '~quill/dist/quill.core';
|
||||
@import '~quill/dist/quill.snow.css';
|
||||
@import '~quill/dist/quill.bubble.css';
|
||||
@import 'variables';
|
||||
@import 'css_variables';
|
||||
@import '~bootstrap/scss/bootstrap';
|
||||
@import "../common/mixins.scss";
|
||||
@import "../common/global.scss";
|
||||
@import "../common/icons.scss";
|
||||
@import "../common/mixins";
|
||||
@import "../common/global";
|
||||
@import "../common/icons";
|
||||
@import 'base';
|
||||
@import "../common/flex";
|
||||
@import "../common/buttons";
|
||||
@import "../common/modal";
|
||||
@import "../common/indicator.scss";
|
||||
@import "../common/indicator";
|
||||
@import "../common/controls";
|
||||
@import 'multilevel_dropdown';
|
||||
@import 'website_image';
|
||||
@import 'website_avatar';
|
||||
|
|
@ -239,4 +242,4 @@ h5.modal-title {
|
|||
}
|
||||
.about-footer {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ $font-sizes: (
|
|||
}
|
||||
}
|
||||
|
||||
$border-radius: var(--border-radius);
|
||||
$border-radius-sm: var(--border-radius-sm);
|
||||
$border-radius-lg: var(--border-radius-lg);
|
||||
|
||||
$font-size-xs: 0.75rem !default;
|
||||
$font-size-sm: 0.875rem !default;
|
||||
$font-size-base: 1rem !default;
|
||||
|
|
@ -127,5 +131,6 @@ $spacers: (
|
|||
@import "~bootstrap/scss/functions";
|
||||
@import "~bootstrap/scss/variables";
|
||||
@import "~bootstrap/scss/mixins";
|
||||
@import 'css_variables';
|
||||
|
||||
$code-color: $purple;
|
||||
|
|
|
|||
|
|
@ -4,36 +4,10 @@ img {
|
|||
position: relative;
|
||||
}
|
||||
|
||||
// fallback for broken images
|
||||
// img:after {
|
||||
// content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
|
||||
// display: flex;
|
||||
// justify-content: center;
|
||||
// align-items: center;
|
||||
// position: absolute;
|
||||
// z-index: 1;
|
||||
// top: 0;
|
||||
// left: 0;
|
||||
// width: 100%;
|
||||
// height: 100%;
|
||||
// background-color: $light;
|
||||
// padding: 50% 0;
|
||||
// }
|
||||
|
||||
img:after {
|
||||
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
|
||||
}
|
||||
|
||||
img[alt]:after {
|
||||
height: 175px;
|
||||
@include flex();
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
@include broken-img(
|
||||
$height: 175px,
|
||||
$border-radius: 0,
|
||||
);
|
||||
|
||||
.website-image-placeholder {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,14 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from typing import Union, Callable
|
||||
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
|
||||
def apply():
|
||||
|
|
@ -79,3 +83,43 @@ class RateLimiter:
|
|||
def respond(self):
|
||||
if self.rejected:
|
||||
return Response(_("Too Many Requests"), status=429)
|
||||
|
||||
def rate_limit(key: str, limit: Union[int, Callable] = 5, seconds: int= 24*60*60, methods: Union[str, list]='ALL'):
|
||||
"""Decorator to rate limit an endpoint.
|
||||
|
||||
This will limit Number of requests per endpoint to `limit` within `seconds`.
|
||||
Uses redis cache to track request counts.
|
||||
|
||||
:param key: Key is used to identify the requests uniqueness
|
||||
:param limit: Maximum number of requests to allow with in window time
|
||||
:type limit: Callable or Integer
|
||||
:param seconds: window time to allow requests
|
||||
:param methods: Limit the validation for these methods.
|
||||
`ALL` is a wildcard that applies rate limit on all methods.
|
||||
:type methods: string or list or tuple
|
||||
|
||||
:returns: a decorator function that limit the number of requests per endpoint
|
||||
"""
|
||||
def ratelimit_decorator(fun):
|
||||
@wraps(fun)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Do not apply rate limits if method is not opted to check
|
||||
if methods != 'ALL' and frappe.request.method.upper() not in methods:
|
||||
return frappe.call(fun, **frappe.form_dict)
|
||||
|
||||
_limit = limit() if callable(limit) else limit
|
||||
|
||||
identity = frappe.form_dict[key]
|
||||
cache_key = f"rl:{frappe.form_dict.cmd}:{identity}"
|
||||
|
||||
value = frappe.cache().get_value(cache_key, expires=True) or 0
|
||||
if not value:
|
||||
frappe.cache().set_value(cache_key, 0, expires_in_sec=seconds)
|
||||
|
||||
value = frappe.cache().incrby(cache_key, 1)
|
||||
if value > _limit:
|
||||
frappe.throw(_("You hit the rate limit because of too many requests. Please try after sometime."))
|
||||
|
||||
return frappe.call(fun, **frappe.form_dict)
|
||||
return wrapper
|
||||
return ratelimit_decorator
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from frappe.utils import cint, cstr
|
|||
import frappe.model.meta
|
||||
import frappe.defaults
|
||||
import frappe.translate
|
||||
from frappe.utils.change_log import get_change_log
|
||||
import redis
|
||||
from six.moves.urllib.parse import unquote
|
||||
from six import text_type
|
||||
|
|
@ -117,6 +116,7 @@ def clear_expired_sessions():
|
|||
def get():
|
||||
"""get session boot info"""
|
||||
from frappe.boot import get_bootinfo, get_unseen_notes
|
||||
from frappe.utils.change_log import get_change_log
|
||||
|
||||
bootinfo = None
|
||||
if not getattr(frappe.conf,'disable_session_cache', None):
|
||||
|
|
|
|||
|
|
@ -319,7 +319,10 @@ def send_summary(timespan):
|
|||
|
||||
from_date = getdate(from_date)
|
||||
to_date = getdate()
|
||||
all_users = [user.email for user in get_enabled_system_users()]
|
||||
|
||||
# select only those users that have energy point email notifications enabled
|
||||
all_users = [user.email for user in get_enabled_system_users() if
|
||||
is_email_notifications_enabled_for_type(user.name, 'Energy Point')]
|
||||
|
||||
frappe.sendmail(
|
||||
subject = '{} energy points summary'.format(timespan),
|
||||
|
|
|
|||
10
frappe/templates/emails/newsletter.html
Normal file
10
frappe/templates/emails/newsletter.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<div style="text-align: justify;">
|
||||
<div style="width: 600px; margin: 10px auto;">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 12px; line-height: 20px;">
|
||||
<div>
|
||||
Open in <a style="color: #687178; text-decoration: underline;" href="/newsletters/{{ name }}" target="_blank">web</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -140,7 +140,7 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}"
|
|||
style="width: 12px; height: 12px; margin-top: 5px;">
|
||||
<path d="M2 9.66667L5.33333 13L14 3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{% elif df.fieldtype=="Image" %}
|
||||
{% elif df.fieldtype in ("Image", "Attach Image") and frappe.utils.is_image(doc[doc.meta.get_field(df.fieldname).options]) %}
|
||||
<img src="{{ doc[doc.meta.get_field(df.fieldname).options] }}"
|
||||
class="img-responsive"
|
||||
{%- if df.print_width %} style="width: {{ get_width(df) }};"{% endif %}>
|
||||
|
|
@ -151,7 +151,7 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}"
|
|||
{% elif df.fieldtype=="Signature" %}
|
||||
<img src="{{ doc[df.fieldname] }}" class="signature-img img-responsive"
|
||||
{%- if df.print_width %} style="width: {{ get_width(df) }};"{% endif %}>
|
||||
{% elif df.fieldtype == "Attach" and doc[df.fieldname] and frappe.utils.is_image(doc[df.fieldname]) %}
|
||||
{% elif df.fieldtype in ("Attach", "Attach Image") and frappe.utils.is_image(doc[df.fieldname]) %}
|
||||
<img src="{{ doc[df.fieldname] }}" class="img-responsive"
|
||||
{%- if df.print_width %} style="width: {{ get_width(df) }};"{% endif %}>
|
||||
{% elif df.fieldtype=="HTML" %}
|
||||
|
|
@ -165,7 +165,12 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}"
|
|||
{%- endmacro %}
|
||||
|
||||
{% macro get_width(df) -%}
|
||||
{%- if df.print_width -%}{{ (df.print_width|str).replace("px", "") }}px
|
||||
{%- if df.print_width -%}
|
||||
{%- if df.print_width.endswith("%") -%}
|
||||
{{ df.print_width }}
|
||||
{%- else -%}
|
||||
{{ df.print_width.replace("px", "") }}px
|
||||
{%- endif -%}
|
||||
{%- elif df.fieldtype in ("Int", "Check", "Float", "Currency") -%}{{ 80 }}px
|
||||
{%- else -%}{{ 150 }}px{% endif -%}
|
||||
{%- endmacro %}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue