@@ -144,8 +144,8 @@ jobs: | |||||
DB: ${{ matrix.DB }} | DB: ${{ matrix.DB }} | ||||
TYPE: ${{ matrix.TYPE }} | TYPE: ${{ matrix.TYPE }} | ||||
- name: Coverage | |||||
if: matrix.TYPE == 'server' | |||||
- name: Coverage - Pull Request | |||||
if: matrix.TYPE == 'server' && github.event_name == 'pull_request' | |||||
run: | | run: | | ||||
cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} | cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} | ||||
cd ${GITHUB_WORKSPACE} | cd ${GITHUB_WORKSPACE} | ||||
@@ -156,3 +156,17 @@ jobs: | |||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} | ||||
COVERALLS_SERVICE_NAME: github | COVERALLS_SERVICE_NAME: github | ||||
- name: Coverage - Push | |||||
if: matrix.TYPE == 'server' && github.event_name == 'push' | |||||
run: | | |||||
cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} | |||||
cd ${GITHUB_WORKSPACE} | |||||
pip install coveralls==2.2.0 | |||||
pip install coverage==4.5.4 | |||||
coveralls --service=github-actions | |||||
env: | |||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |||||
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} | |||||
COVERALLS_SERVICE_NAME: github-actions | |||||
@@ -10,11 +10,10 @@ be used to build database driven apps. | |||||
Read the documentation: https://frappeframework.com/docs | Read the documentation: https://frappeframework.com/docs | ||||
""" | """ | ||||
from __future__ import unicode_literals, print_function | |||||
from six import iteritems, binary_type, text_type, string_types, PY2 | |||||
from six import iteritems, binary_type, text_type, string_types | |||||
from werkzeug.local import Local, release_local | from werkzeug.local import Local, release_local | ||||
import os, sys, importlib, inspect, json | |||||
import os, sys, importlib, inspect, json, warnings | |||||
import typing | import typing | ||||
from past.builtins import cmp | from past.builtins import cmp | ||||
import click | import click | ||||
@@ -40,6 +39,8 @@ __title__ = "Frappe Framework" | |||||
local = Local() | local = Local() | ||||
controllers = {} | controllers = {} | ||||
warnings.simplefilter('always', DeprecationWarning) | |||||
warnings.simplefilter('always', PendingDeprecationWarning) | |||||
class _dict(dict): | class _dict(dict): | ||||
"""dict like object that exposes keys as attributes""" | """dict like object that exposes keys as attributes""" | ||||
@@ -294,6 +294,7 @@ def serve(port=8000, profile=False, no_reload=False, no_threading=False, site=No | |||||
_sites_path = sites_path | _sites_path = sites_path | ||||
from werkzeug.serving import run_simple | from werkzeug.serving import run_simple | ||||
patch_werkzeug_reloader() | |||||
if profile: | if profile: | ||||
application = ProfilerMiddleware(application, sort_by=('cumtime', 'calls')) | application = ProfilerMiddleware(application, sort_by=('cumtime', 'calls')) | ||||
@@ -324,3 +325,23 @@ def serve(port=8000, profile=False, no_reload=False, no_threading=False, site=No | |||||
use_debugger=not in_test_env, | use_debugger=not in_test_env, | ||||
use_evalex=not in_test_env, | use_evalex=not in_test_env, | ||||
threaded=not no_threading) | threaded=not no_threading) | ||||
def patch_werkzeug_reloader(): | |||||
""" | |||||
This function monkey patches Werkzeug reloader to ignore reloading files in | |||||
the __pycache__ directory. | |||||
To be deprecated when upgrading to Werkzeug 2. | |||||
""" | |||||
from werkzeug._reloader import WatchdogReloaderLoop | |||||
trigger_reload = WatchdogReloaderLoop.trigger_reload | |||||
def custom_trigger_reload(self, filename): | |||||
if os.path.basename(os.path.dirname(filename)) == "__pycache__": | |||||
return | |||||
return trigger_reload(self, filename) | |||||
WatchdogReloaderLoop.trigger_reload = custom_trigger_reload |
@@ -42,8 +42,6 @@ def get_bootinfo(): | |||||
bootinfo.user_info = get_user_info() | bootinfo.user_info = get_user_info() | ||||
bootinfo.sid = frappe.session['sid'] | bootinfo.sid = frappe.session['sid'] | ||||
bootinfo.user_groups = frappe.get_all('User Group', pluck="name") | |||||
bootinfo.modules = {} | bootinfo.modules = {} | ||||
bootinfo.module_list = [] | bootinfo.module_list = [] | ||||
load_desktop_data(bootinfo) | load_desktop_data(bootinfo) | ||||
@@ -203,10 +203,13 @@ def install_app(context, apps): | |||||
@click.command("list-apps") | @click.command("list-apps") | ||||
@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text") | |||||
@pass_context | @pass_context | ||||
def list_apps(context): | |||||
def list_apps(context, format): | |||||
"List apps in site" | "List apps in site" | ||||
summary_dict = {} | |||||
def fix_whitespaces(text): | def fix_whitespaces(text): | ||||
if site == context.sites[-1]: | if site == context.sites[-1]: | ||||
text = text.rstrip() | text = text.rstrip() | ||||
@@ -235,18 +238,23 @@ def list_apps(context): | |||||
] | ] | ||||
applications_summary = "\n".join(installed_applications) | applications_summary = "\n".join(installed_applications) | ||||
summary = f"{site_title}\n{applications_summary}\n" | summary = f"{site_title}\n{applications_summary}\n" | ||||
summary_dict[site] = [app.app_name for app in apps] | |||||
else: | else: | ||||
applications_summary = "\n".join(frappe.get_installed_apps()) | |||||
installed_applications = frappe.get_installed_apps() | |||||
applications_summary = "\n".join(installed_applications) | |||||
summary = f"{site_title}\n{applications_summary}\n" | summary = f"{site_title}\n{applications_summary}\n" | ||||
summary_dict[site] = installed_applications | |||||
summary = fix_whitespaces(summary) | summary = fix_whitespaces(summary) | ||||
if applications_summary and summary: | |||||
if format == "text" and applications_summary and summary: | |||||
print(summary) | print(summary) | ||||
frappe.destroy() | frappe.destroy() | ||||
if format == "json": | |||||
click.echo(frappe.as_json(summary_dict)) | |||||
@click.command('add-system-manager') | @click.command('add-system-manager') | ||||
@click.argument('email') | @click.argument('email') | ||||
@@ -548,7 +556,7 @@ def move(dest_dir, site): | |||||
site_dump_exists = os.path.exists(final_new_path) | site_dump_exists = os.path.exists(final_new_path) | ||||
count = int(count or 0) + 1 | count = int(count or 0) + 1 | ||||
os.rename(old_path, final_new_path) | |||||
shutil.move(old_path, final_new_path) | |||||
frappe.destroy() | frappe.destroy() | ||||
return final_new_path | return final_new_path | ||||
@@ -96,22 +96,54 @@ def destroy_all_sessions(context, reason=None): | |||||
raise SiteNotSpecifiedError | raise SiteNotSpecifiedError | ||||
@click.command('show-config') | @click.command('show-config') | ||||
@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text") | |||||
@pass_context | @pass_context | ||||
def show_config(context): | |||||
"print configuration file" | |||||
print("\t\033[92m{:<50}\033[0m \033[92m{:<15}\033[0m".format('Config','Value')) | |||||
sites_path = os.path.join(frappe.utils.get_bench_path(), 'sites') | |||||
site_path = context.sites[0] | |||||
configuration = frappe.get_site_config(sites_path=sites_path, site_path=site_path) | |||||
print_config(configuration) | |||||
def show_config(context, format): | |||||
"Print configuration file to STDOUT in speified format" | |||||
if not context.sites: | |||||
raise SiteNotSpecifiedError | |||||
sites_config = {} | |||||
sites_path = os.getcwd() | |||||
from frappe.utils.commands import render_table | |||||
def transform_config(config, prefix=None): | |||||
prefix = f"{prefix}." if prefix else "" | |||||
site_config = [] | |||||
for conf, value in config.items(): | |||||
if isinstance(value, dict): | |||||
site_config += transform_config(value, prefix=f"{prefix}{conf}") | |||||
else: | |||||
log_value = json.dumps(value) if isinstance(value, list) else value | |||||
site_config += [[f"{prefix}{conf}", log_value]] | |||||
return site_config | |||||
for site in context.sites: | |||||
frappe.init(site) | |||||
if len(context.sites) != 1 and format == "text": | |||||
if context.sites.index(site) != 0: | |||||
click.echo() | |||||
click.secho(f"Site {site}", fg="yellow") | |||||
def print_config(config): | |||||
for conf, value in config.items(): | |||||
if isinstance(value, dict): | |||||
print_config(value) | |||||
else: | |||||
print("\t{:<50} {:<15}".format(conf, value)) | |||||
configuration = frappe.get_site_config(sites_path=sites_path, site_path=site) | |||||
if format == "text": | |||||
data = transform_config(configuration) | |||||
data.insert(0, ['Config','Value']) | |||||
render_table(data) | |||||
if format == "json": | |||||
sites_config[site] = configuration | |||||
frappe.destroy() | |||||
if format == "json": | |||||
click.echo(frappe.as_json(sites_config)) | |||||
@click.command('reset-perms') | @click.command('reset-perms') | ||||
@@ -470,6 +502,7 @@ def console(context): | |||||
locals()[app] = __import__(app) | locals()[app] = __import__(app) | ||||
except ModuleNotFoundError: | except ModuleNotFoundError: | ||||
failed_to_import.append(app) | failed_to_import.append(app) | ||||
all_apps.remove(app) | |||||
print("Apps in this namespace:\n{}".format(", ".join(all_apps))) | print("Apps in this namespace:\n{}".format(", ".join(all_apps))) | ||||
if failed_to_import: | if failed_to_import: | ||||
@@ -652,20 +685,27 @@ def make_app(destination, app_name): | |||||
@click.command('set-config') | @click.command('set-config') | ||||
@click.argument('key') | @click.argument('key') | ||||
@click.argument('value') | @click.argument('value') | ||||
@click.option('-g', '--global', 'global_', is_flag = True, default = False, help = 'Set Global Site Config') | |||||
@click.option('--as-dict', is_flag=True, default=False) | |||||
@click.option('-g', '--global', 'global_', is_flag=True, default=False, help='Set value in bench config') | |||||
@click.option('-p', '--parse', is_flag=True, default=False, help='Evaluate as Python Object') | |||||
@click.option('--as-dict', is_flag=True, default=False, help='Legacy: Evaluate as Python Object') | |||||
@pass_context | @pass_context | ||||
def set_config(context, key, value, global_ = False, as_dict=False): | |||||
def set_config(context, key, value, global_=False, parse=False, as_dict=False): | |||||
"Insert/Update a value in site_config.json" | "Insert/Update a value in site_config.json" | ||||
from frappe.installer import update_site_config | from frappe.installer import update_site_config | ||||
import ast | |||||
if as_dict: | if as_dict: | ||||
from frappe.utils.commands import warn | |||||
warn("--as-dict will be deprecated in v14. Use --parse instead", category=PendingDeprecationWarning) | |||||
parse = as_dict | |||||
if parse: | |||||
import ast | |||||
value = ast.literal_eval(value) | value = ast.literal_eval(value) | ||||
if global_: | if global_: | ||||
sites_path = os.getcwd() # big assumption. | |||||
sites_path = os.getcwd() | |||||
common_site_config_path = os.path.join(sites_path, 'common_site_config.json') | common_site_config_path = os.path.join(sites_path, 'common_site_config.json') | ||||
update_site_config(key, value, validate = False, site_config_path = common_site_config_path) | |||||
update_site_config(key, value, validate=False, site_config_path=common_site_config_path) | |||||
else: | else: | ||||
for site in context.sites: | for site in context.sites: | ||||
frappe.init(site=site) | frappe.init(site=site) | ||||
@@ -722,50 +762,6 @@ def rebuild_global_search(context, static_pages=False): | |||||
if not context.sites: | if not context.sites: | ||||
raise SiteNotSpecifiedError | raise SiteNotSpecifiedError | ||||
@click.command('auto-deploy') | |||||
@click.argument('app') | |||||
@click.option('--migrate', is_flag=True, default=False, help='Migrate after pulling') | |||||
@click.option('--restart', is_flag=True, default=False, help='Restart after migration') | |||||
@click.option('--remote', default='upstream', help='Remote, default is "upstream"') | |||||
@pass_context | |||||
def auto_deploy(context, app, migrate=False, restart=False, remote='upstream'): | |||||
'''Pull and migrate sites that have new version''' | |||||
from frappe.utils.gitutils import get_app_branch | |||||
from frappe.utils import get_sites | |||||
branch = get_app_branch(app) | |||||
app_path = frappe.get_app_path(app) | |||||
# fetch | |||||
subprocess.check_output(['git', 'fetch', remote, branch], cwd = app_path) | |||||
# get diff | |||||
if subprocess.check_output(['git', 'diff', '{0}..{1}/{0}'.format(branch, remote)], cwd = app_path): | |||||
print('Updates found for {0}'.format(app)) | |||||
if app=='frappe': | |||||
# run bench update | |||||
import shlex | |||||
subprocess.check_output(shlex.split('bench update --no-backup'), cwd = '..') | |||||
else: | |||||
updated = False | |||||
subprocess.check_output(['git', 'pull', '--rebase', remote, branch], | |||||
cwd = app_path) | |||||
# find all sites with that app | |||||
for site in get_sites(): | |||||
frappe.init(site) | |||||
if app in frappe.get_installed_apps(): | |||||
print('Updating {0}'.format(site)) | |||||
updated = True | |||||
subprocess.check_output(['bench', '--site', site, 'clear-cache'], cwd = '..') | |||||
if migrate: | |||||
subprocess.check_output(['bench', '--site', site, 'migrate'], cwd = '..') | |||||
frappe.destroy() | |||||
if updated or restart: | |||||
subprocess.check_output(['bench', 'restart'], cwd = '..') | |||||
else: | |||||
print('No Updates') | |||||
commands = [ | commands = [ | ||||
build, | build, | ||||
@@ -1,8 +1,6 @@ | |||||
# -*- coding: utf-8 -*- | |||||
# Copyright (c) {year}, {app_publisher} and contributors | # Copyright (c) {year}, {app_publisher} and contributors | ||||
# For license information, please see license.txt | # For license information, please see license.txt | ||||
from __future__ import unicode_literals | |||||
# import frappe | # import frappe | ||||
{base_class_import} | {base_class_import} | ||||
@@ -1,7 +1,5 @@ | |||||
# -*- coding: utf-8 -*- | |||||
# Copyright (c) {year}, {app_publisher} and Contributors | # Copyright (c) {year}, {app_publisher} and Contributors | ||||
# See license.txt | # See license.txt | ||||
from __future__ import unicode_literals | |||||
# import frappe | # import frappe | ||||
import unittest | import unittest | ||||
@@ -83,12 +83,61 @@ class DocType(Document): | |||||
if not self.is_new(): | if not self.is_new(): | ||||
self.before_update = frappe.get_doc('DocType', self.name) | self.before_update = frappe.get_doc('DocType', self.name) | ||||
self.setup_fields_to_fetch() | self.setup_fields_to_fetch() | ||||
self.validate_field_name_conflicts() | |||||
check_email_append_to(self) | check_email_append_to(self) | ||||
if self.default_print_format and not self.custom: | if self.default_print_format and not self.custom: | ||||
frappe.throw(_('Standard DocType cannot have default print format, use Customize Form')) | frappe.throw(_('Standard DocType cannot have default print format, use Customize Form')) | ||||
if frappe.conf.get('developer_mode'): | |||||
self.owner = 'Administrator' | |||||
self.modified_by = 'Administrator' | |||||
def validate_field_name_conflicts(self): | |||||
"""Check if field names dont conflict with controller properties and methods""" | |||||
core_doctypes = [ | |||||
"Custom DocPerm", | |||||
"DocPerm", | |||||
"Custom Field", | |||||
"Customize Form Field", | |||||
"DocField", | |||||
] | |||||
if self.name in core_doctypes: | |||||
return | |||||
from frappe.model.base_document import get_controller | |||||
try: | |||||
controller = get_controller(self.name) | |||||
except ImportError: | |||||
controller = Document | |||||
available_objects = {x for x in dir(controller) if isinstance(x, str)} | |||||
property_set = { | |||||
x for x in available_objects if isinstance(getattr(controller, x, None), property) | |||||
} | |||||
method_set = { | |||||
x for x in available_objects if x not in property_set and callable(getattr(controller, x, None)) | |||||
} | |||||
for docfield in self.get("fields") or []: | |||||
conflict_type = None | |||||
field = docfield.fieldname | |||||
field_label = docfield.label or docfield.fieldname | |||||
if docfield.fieldname in method_set: | |||||
conflict_type = "controller method" | |||||
if docfield.fieldname in property_set: | |||||
conflict_type = "class property" | |||||
if conflict_type: | |||||
frappe.throw( | |||||
_("Fieldname '{0}' conflicting with a {1} of the name {2} in {3}") | |||||
.format(field_label, conflict_type, field, self.name) | |||||
) | |||||
def after_insert(self): | def after_insert(self): | ||||
# clear user cache so that on the next reload this doctype is included in boot | # clear user cache so that on the next reload this doctype is included in boot | ||||
clear_user_cache(frappe.session.user) | clear_user_cache(frappe.session.user) | ||||
@@ -1174,11 +1223,19 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): | |||||
else: | else: | ||||
raise | raise | ||||
def check_if_fieldname_conflicts_with_methods(doctype, fieldname): | |||||
doc = frappe.get_doc({"doctype": doctype}) | |||||
method_list = [method for method in dir(doc) if isinstance(method, str) and callable(getattr(doc, method))] | |||||
def check_fieldname_conflicts(doctype, fieldname): | |||||
"""Checks if fieldname conflicts with methods or properties""" | |||||
if fieldname in method_list: | |||||
doc = frappe.get_doc({"doctype": doctype}) | |||||
available_objects = [x for x in dir(doc) if isinstance(x, str)] | |||||
property_list = [ | |||||
x for x in available_objects if isinstance(getattr(type(doc), x, None), property) | |||||
] | |||||
method_list = [ | |||||
x for x in available_objects if x not in property_list and callable(getattr(doc, x)) | |||||
] | |||||
if fieldname in method_list + property_list: | |||||
frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname)) | frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname)) | ||||
def clear_linked_doctype_cache(): | def clear_linked_doctype_cache(): | ||||
@@ -1,7 +1,6 @@ | |||||
# Copyright (c) 2013, {app_publisher} and contributors | # Copyright (c) 2013, {app_publisher} and contributors | ||||
# For license information, please see license.txt | # For license information, please see license.txt | ||||
from __future__ import unicode_literals | |||||
# import frappe | # import frappe | ||||
def execute(filters=None): | def execute(filters=None): | ||||
@@ -56,6 +56,7 @@ class User(Document): | |||||
def after_insert(self): | def after_insert(self): | ||||
create_notification_settings(self.name) | create_notification_settings(self.name) | ||||
frappe.cache().delete_key('users_for_mentions') | |||||
def validate(self): | def validate(self): | ||||
self.check_demo() | self.check_demo() | ||||
@@ -129,6 +130,9 @@ class User(Document): | |||||
if self.time_zone: | if self.time_zone: | ||||
frappe.defaults.set_default("time_zone", self.time_zone, self.name) | frappe.defaults.set_default("time_zone", self.time_zone, self.name) | ||||
if self.has_value_changed('allow_in_mentions') or self.has_value_changed('user_type'): | |||||
frappe.cache().delete_key('users_for_mentions') | |||||
def has_website_permission(self, ptype, user, verbose=False): | def has_website_permission(self, ptype, user, verbose=False): | ||||
"""Returns true if current user is the session user""" | """Returns true if current user is the session user""" | ||||
return self.name == frappe.session.user | return self.name == frappe.session.user | ||||
@@ -389,6 +393,9 @@ class User(Document): | |||||
# delete notification settings | # delete notification settings | ||||
frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True) | frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True) | ||||
if self.get('allow_in_mentions'): | |||||
frappe.cache().delete_key('users_for_mentions') | |||||
def before_rename(self, old_name, new_name, merge=False): | def before_rename(self, old_name, new_name, merge=False): | ||||
self.check_demo() | self.check_demo() | ||||
@@ -9,7 +9,7 @@ import frappe | |||||
class UserGroup(Document): | class UserGroup(Document): | ||||
def after_insert(self): | def after_insert(self): | ||||
frappe.publish_realtime('user_group_added', self.name) | |||||
frappe.cache().delete_key('user_groups') | |||||
def on_trash(self): | def on_trash(self): | ||||
frappe.publish_realtime('user_group_deleted', self.name) | |||||
frappe.cache().delete_key('user_groups') |
@@ -1,7 +1,7 @@ | |||||
frappe.pages['recorder'].on_page_load = function(wrapper) { | frappe.pages['recorder'].on_page_load = function(wrapper) { | ||||
frappe.ui.make_app_page({ | frappe.ui.make_app_page({ | ||||
parent: wrapper, | parent: wrapper, | ||||
title: 'Recorder', | |||||
title: __('Recorder'), | |||||
single_column: true, | single_column: true, | ||||
card_layout: true | card_layout: true | ||||
}); | }); | ||||
@@ -64,8 +64,8 @@ class CustomField(Document): | |||||
self.translatable = 0 | self.translatable = 0 | ||||
if not self.flags.ignore_validate: | if not self.flags.ignore_validate: | ||||
from frappe.core.doctype.doctype.doctype import check_if_fieldname_conflicts_with_methods | |||||
check_if_fieldname_conflicts_with_methods(self.dt, self.fieldname) | |||||
from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts | |||||
check_fieldname_conflicts(self.dt, self.fieldname) | |||||
def on_update(self): | def on_update(self): | ||||
if not frappe.flags.in_setup_wizard: | if not frappe.flags.in_setup_wizard: | ||||
@@ -5,6 +5,7 @@ | |||||
.download-backup-card { | .download-backup-card { | ||||
display: block; | display: block; | ||||
text-decoration: none; | text-decoration: none; | ||||
margin-bottom: var(--margin-lg); | |||||
} | } | ||||
.download-backup-card:hover { | .download-backup-card:hover { | ||||
@@ -1,7 +1,7 @@ | |||||
frappe.pages['backups'].on_page_load = function(wrapper) { | frappe.pages['backups'].on_page_load = function(wrapper) { | ||||
var page = frappe.ui.make_app_page({ | var page = frappe.ui.make_app_page({ | ||||
parent: wrapper, | parent: wrapper, | ||||
title: 'Download Backups', | |||||
title: __('Download Backups'), | |||||
single_column: true | single_column: true | ||||
}); | }); | ||||
@@ -1,7 +1,7 @@ | |||||
frappe.pages['translation-tool'].on_page_load = function(wrapper) { | frappe.pages['translation-tool'].on_page_load = function(wrapper) { | ||||
var page = frappe.ui.make_app_page({ | var page = frappe.ui.make_app_page({ | ||||
parent: wrapper, | parent: wrapper, | ||||
title: 'Translation Tool', | |||||
title: __('Translation Tool'), | |||||
single_column: true, | single_column: true, | ||||
card_layout: true, | card_layout: true, | ||||
}); | }); | ||||
@@ -8,7 +8,7 @@ | |||||
</div> | </div> | ||||
<div class="chart-wrapper performance-heatmap"> | <div class="chart-wrapper performance-heatmap"> | ||||
<div class="null-state"> | <div class="null-state"> | ||||
<span>No Data to Show</span> | |||||
<span>{%=__("No Data to Show") %}</span> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -19,7 +19,7 @@ | |||||
</div> | </div> | ||||
<div class="chart-wrapper performance-percentage-chart"> | <div class="chart-wrapper performance-percentage-chart"> | ||||
<div class="null-state"> | <div class="null-state"> | ||||
<span>No Data to Show</span> | |||||
<span>{%=__("No Data to Show") %}</span> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -30,7 +30,7 @@ | |||||
</div> | </div> | ||||
<div class="chart-wrapper performance-line-chart"> | <div class="chart-wrapper performance-line-chart"> | ||||
<div class="null-state"> | <div class="null-state"> | ||||
<span>No Data to Show</span> | |||||
<span>{%=__("No Data to Show") %}</span> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -41,4 +41,4 @@ | |||||
<div class="recent-activity-footer"></div> | <div class="recent-activity-footer"></div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | |||||
</div> |
@@ -377,10 +377,17 @@ def handle_duration_fieldtype_values(result, columns): | |||||
if fieldtype == "Duration": | if fieldtype == "Duration": | ||||
for entry in range(0, len(result)): | for entry in range(0, len(result)): | ||||
val_in_seconds = result[entry][i] | |||||
if val_in_seconds: | |||||
duration_val = format_duration(val_in_seconds) | |||||
result[entry][i] = duration_val | |||||
row = result[entry] | |||||
if isinstance(row, dict): | |||||
val_in_seconds = row[col.fieldname] | |||||
if val_in_seconds: | |||||
duration_val = format_duration(val_in_seconds) | |||||
row[col.fieldname] = duration_val | |||||
else: | |||||
val_in_seconds = row[i] | |||||
if val_in_seconds: | |||||
duration_val = format_duration(val_in_seconds) | |||||
row[i] = duration_val | |||||
return result | return result | ||||
@@ -221,3 +221,37 @@ def validate_and_sanitize_search_inputs(fn, instance, args, kwargs): | |||||
return [] | return [] | ||||
return fn(**kwargs) | return fn(**kwargs) | ||||
@frappe.whitelist() | |||||
def get_names_for_mentions(search_term): | |||||
users_for_mentions = frappe.cache().get_value('users_for_mentions', get_users_for_mentions) | |||||
user_groups = frappe.cache().get_value('user_groups', get_user_groups) | |||||
filtered_mentions = [] | |||||
for mention_data in users_for_mentions + user_groups: | |||||
if search_term.lower() not in mention_data.value.lower(): | |||||
continue | |||||
mention_data['link'] = frappe.utils.get_url_to_form( | |||||
'User Group' if mention_data.get('is_group') else 'User Profile', | |||||
mention_data['id'] | |||||
) | |||||
filtered_mentions.append(mention_data) | |||||
return sorted(filtered_mentions, key=lambda d: d['value']) | |||||
def get_users_for_mentions(): | |||||
return frappe.get_all('User', | |||||
fields=['name as id', 'full_name as value'], | |||||
filters={ | |||||
'name': ['not in', ('Administrator', 'Guest')], | |||||
'allowed_in_mentions': True, | |||||
'user_type': 'System User', | |||||
}) | |||||
def get_user_groups(): | |||||
return frappe.get_all('User Group', fields=['name as id', 'name as value'], update={ | |||||
'is_group': True | |||||
}) |
@@ -15,7 +15,6 @@ from frappe.utils.background_jobs import get_jobs | |||||
from frappe.utils.data import get_url, get_link_to_form | from frappe.utils.data import get_url, get_link_to_form | ||||
from frappe.utils.password import get_decrypted_password | from frappe.utils.password import get_decrypted_password | ||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_field | from frappe.custom.doctype.custom_field.custom_field import create_custom_field | ||||
from frappe.integrations.oauth2 import validate_url | |||||
class EventProducer(Document): | class EventProducer(Document): | ||||
@@ -56,7 +55,7 @@ class EventProducer(Document): | |||||
self.reload() | self.reload() | ||||
def check_url(self): | def check_url(self): | ||||
if not validate_url(self.producer_url): | |||||
if not frappe.utils.validate_url(self.producer_url): | |||||
frappe.throw(_('Invalid URL')) | frappe.throw(_('Invalid URL')) | ||||
# remove '/' from the end of the url like http://test_site.com/ | # remove '/' from the end of the url like http://test_site.com/ | ||||
@@ -1,256 +1,112 @@ | |||||
{ | { | ||||
"allow_copy": 0, | |||||
"allow_import": 0, | |||||
"allow_rename": 0, | |||||
"autoname": "field:authorization_code", | |||||
"beta": 0, | |||||
"creation": "2016-08-24 14:12:13.647159", | |||||
"custom": 0, | |||||
"docstatus": 0, | |||||
"doctype": "DocType", | |||||
"document_type": "Document", | |||||
"editable_grid": 1, | |||||
"engine": "InnoDB", | |||||
"actions": [], | |||||
"autoname": "field:authorization_code", | |||||
"creation": "2016-08-24 14:12:13.647159", | |||||
"doctype": "DocType", | |||||
"document_type": "Document", | |||||
"editable_grid": 1, | |||||
"engine": "InnoDB", | |||||
"field_order": [ | |||||
"client", | |||||
"user", | |||||
"scopes", | |||||
"authorization_code", | |||||
"expiration_time", | |||||
"redirect_uri_bound_to_authorization_code", | |||||
"validity", | |||||
"nonce", | |||||
"code_challenge", | |||||
"code_challenge_method" | |||||
], | |||||
"fields": [ | "fields": [ | ||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "client", | |||||
"fieldtype": "Link", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 1, | |||||
"in_standard_filter": 1, | |||||
"label": "Client", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"options": "OAuth Client", | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "client", | |||||
"fieldtype": "Link", | |||||
"in_list_view": 1, | |||||
"in_standard_filter": 1, | |||||
"label": "Client", | |||||
"options": "OAuth Client", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "user", | |||||
"fieldtype": "Link", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 0, | |||||
"label": "User", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"options": "User", | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "user", | |||||
"fieldtype": "Link", | |||||
"label": "User", | |||||
"options": "User", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "scopes", | |||||
"fieldtype": "Text", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 0, | |||||
"label": "Scopes", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "scopes", | |||||
"fieldtype": "Text", | |||||
"label": "Scopes", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "authorization_code", | |||||
"fieldtype": "Text", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 1, | |||||
"in_standard_filter": 0, | |||||
"label": "Authorization Code", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "authorization_code", | |||||
"fieldtype": "Data", | |||||
"in_list_view": 1, | |||||
"label": "Authorization Code", | |||||
"read_only": 1, | |||||
"unique": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "expiration_time", | |||||
"fieldtype": "Datetime", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 0, | |||||
"label": "Expiration time", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "expiration_time", | |||||
"fieldtype": "Datetime", | |||||
"label": "Expiration time", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "redirect_uri_bound_to_authorization_code", | |||||
"fieldtype": "Data", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 0, | |||||
"label": "Redirect URI Bound To Auth Code", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"permlevel": 0, | |||||
"precision": "", | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "redirect_uri_bound_to_authorization_code", | |||||
"fieldtype": "Data", | |||||
"label": "Redirect URI Bound To Auth Code", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "validity", | |||||
"fieldtype": "Select", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 1, | |||||
"in_standard_filter": 0, | |||||
"label": "Validity", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"options": "Valid\nInvalid", | |||||
"permlevel": 0, | |||||
"precision": "", | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
"fieldname": "validity", | |||||
"fieldtype": "Select", | |||||
"in_list_view": 1, | |||||
"label": "Validity", | |||||
"options": "Valid\nInvalid", | |||||
"read_only": 1 | |||||
}, | |||||
{ | |||||
"fieldname": "nonce", | |||||
"fieldtype": "Data", | |||||
"label": "nonce", | |||||
"read_only": 1 | |||||
}, | |||||
{ | |||||
"fieldname": "code_challenge", | |||||
"fieldtype": "Data", | |||||
"label": "Code Challenge", | |||||
"read_only": 1 | |||||
}, | |||||
{ | |||||
"fieldname": "code_challenge_method", | |||||
"fieldtype": "Select", | |||||
"label": "Code challenge method", | |||||
"options": "\ns256\nplain", | |||||
"read_only": 1 | |||||
} | } | ||||
], | |||||
"hide_heading": 0, | |||||
"hide_toolbar": 0, | |||||
"idx": 0, | |||||
"image_view": 0, | |||||
"in_create": 0, | |||||
"is_submittable": 0, | |||||
"issingle": 0, | |||||
"istable": 0, | |||||
"max_attachments": 0, | |||||
"modified": "2017-03-08 14:40:04.113884", | |||||
"modified_by": "Administrator", | |||||
"module": "Integrations", | |||||
"name": "OAuth Authorization Code", | |||||
"name_case": "", | |||||
"owner": "Administrator", | |||||
], | |||||
"links": [], | |||||
"modified": "2021-04-26 07:23:02.980612", | |||||
"modified_by": "Administrator", | |||||
"module": "Integrations", | |||||
"name": "OAuth Authorization Code", | |||||
"owner": "Administrator", | |||||
"permissions": [ | "permissions": [ | ||||
{ | { | ||||
"amend": 0, | |||||
"apply_user_permissions": 0, | |||||
"cancel": 0, | |||||
"create": 0, | |||||
"delete": 1, | |||||
"email": 1, | |||||
"export": 1, | |||||
"if_owner": 0, | |||||
"import": 0, | |||||
"is_custom": 0, | |||||
"permlevel": 0, | |||||
"print": 1, | |||||
"read": 1, | |||||
"report": 1, | |||||
"role": "System Manager", | |||||
"set_user_permissions": 0, | |||||
"share": 1, | |||||
"submit": 0, | |||||
"write": 0 | |||||
"delete": 1, | |||||
"email": 1, | |||||
"export": 1, | |||||
"print": 1, | |||||
"read": 1, | |||||
"report": 1, | |||||
"role": "System Manager", | |||||
"share": 1 | |||||
} | } | ||||
], | |||||
"quick_entry": 0, | |||||
"read_only": 0, | |||||
"read_only_onload": 0, | |||||
"sort_field": "modified", | |||||
"sort_order": "DESC", | |||||
"track_changes": 1, | |||||
"track_seen": 0 | |||||
], | |||||
"sort_field": "modified", | |||||
"sort_order": "DESC" | |||||
} | } |
@@ -1,283 +1,96 @@ | |||||
{ | { | ||||
"allow_copy": 0, | |||||
"allow_import": 0, | |||||
"allow_rename": 0, | |||||
"autoname": "field:access_token", | |||||
"beta": 0, | |||||
"creation": "2016-08-24 14:10:17.471264", | |||||
"custom": 0, | |||||
"docstatus": 0, | |||||
"doctype": "DocType", | |||||
"document_type": "Document", | |||||
"editable_grid": 1, | |||||
"engine": "InnoDB", | |||||
"actions": [], | |||||
"autoname": "field:access_token", | |||||
"creation": "2016-08-24 14:10:17.471264", | |||||
"doctype": "DocType", | |||||
"document_type": "Document", | |||||
"editable_grid": 1, | |||||
"engine": "InnoDB", | |||||
"field_order": [ | |||||
"client", | |||||
"user", | |||||
"scopes", | |||||
"access_token", | |||||
"refresh_token", | |||||
"expiration_time", | |||||
"expires_in", | |||||
"status" | |||||
], | |||||
"fields": [ | "fields": [ | ||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "client", | |||||
"fieldtype": "Link", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 1, | |||||
"in_standard_filter": 1, | |||||
"label": "Client", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"options": "OAuth Client", | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "client", | |||||
"fieldtype": "Link", | |||||
"in_list_view": 1, | |||||
"in_standard_filter": 1, | |||||
"label": "Client", | |||||
"options": "OAuth Client", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "user", | |||||
"fieldtype": "Link", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 0, | |||||
"label": "User", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"options": "User", | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "user", | |||||
"fieldtype": "Link", | |||||
"label": "User", | |||||
"options": "User", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "scopes", | |||||
"fieldtype": "Text", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 0, | |||||
"label": "Scopes", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "scopes", | |||||
"fieldtype": "Text", | |||||
"label": "Scopes", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "access_token", | |||||
"fieldtype": "Data", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 0, | |||||
"label": "Access Token", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "access_token", | |||||
"fieldtype": "Data", | |||||
"label": "Access Token", | |||||
"read_only": 1, | |||||
"unique": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "refresh_token", | |||||
"fieldtype": "Data", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 0, | |||||
"label": "Refresh Token", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "refresh_token", | |||||
"fieldtype": "Data", | |||||
"label": "Refresh Token", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "expiration_time", | |||||
"fieldtype": "Datetime", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 0, | |||||
"label": "Expiration time", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"permlevel": 0, | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "expiration_time", | |||||
"fieldtype": "Datetime", | |||||
"label": "Expiration time", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "expires_in", | |||||
"fieldtype": "Int", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 0, | |||||
"label": "Expires In", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"permlevel": 0, | |||||
"precision": "", | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
}, | |||||
"fieldname": "expires_in", | |||||
"fieldtype": "Int", | |||||
"label": "Expires In", | |||||
"read_only": 1 | |||||
}, | |||||
{ | { | ||||
"allow_on_submit": 0, | |||||
"bold": 0, | |||||
"collapsible": 0, | |||||
"columns": 0, | |||||
"fieldname": "status", | |||||
"fieldtype": "Select", | |||||
"hidden": 0, | |||||
"ignore_user_permissions": 0, | |||||
"ignore_xss_filter": 0, | |||||
"in_filter": 0, | |||||
"in_list_view": 0, | |||||
"in_standard_filter": 1, | |||||
"label": "Status", | |||||
"length": 0, | |||||
"no_copy": 0, | |||||
"options": "Active\nRevoked", | |||||
"permlevel": 0, | |||||
"precision": "", | |||||
"print_hide": 0, | |||||
"print_hide_if_no_value": 0, | |||||
"read_only": 0, | |||||
"remember_last_selected_value": 0, | |||||
"report_hide": 0, | |||||
"reqd": 0, | |||||
"search_index": 0, | |||||
"set_only_once": 0, | |||||
"unique": 0 | |||||
"fieldname": "status", | |||||
"fieldtype": "Select", | |||||
"in_standard_filter": 1, | |||||
"label": "Status", | |||||
"options": "Active\nRevoked", | |||||
"read_only": 1 | |||||
} | } | ||||
], | |||||
"hide_heading": 0, | |||||
"hide_toolbar": 0, | |||||
"idx": 0, | |||||
"image_view": 0, | |||||
"in_create": 0, | |||||
"is_submittable": 0, | |||||
"issingle": 0, | |||||
"istable": 0, | |||||
"max_attachments": 0, | |||||
"modified": "2017-03-08 14:40:04.209039", | |||||
"modified_by": "Administrator", | |||||
"module": "Integrations", | |||||
"name": "OAuth Bearer Token", | |||||
"name_case": "", | |||||
"owner": "Administrator", | |||||
], | |||||
"links": [], | |||||
"modified": "2021-04-26 06:40:34.922441", | |||||
"modified_by": "Administrator", | |||||
"module": "Integrations", | |||||
"name": "OAuth Bearer Token", | |||||
"owner": "Administrator", | |||||
"permissions": [ | "permissions": [ | ||||
{ | { | ||||
"amend": 0, | |||||
"apply_user_permissions": 0, | |||||
"cancel": 0, | |||||
"create": 0, | |||||
"delete": 1, | |||||
"email": 1, | |||||
"export": 1, | |||||
"if_owner": 0, | |||||
"import": 0, | |||||
"is_custom": 0, | |||||
"permlevel": 0, | |||||
"print": 1, | |||||
"read": 1, | |||||
"report": 1, | |||||
"role": "System Manager", | |||||
"set_user_permissions": 0, | |||||
"share": 1, | |||||
"submit": 0, | |||||
"write": 0 | |||||
"delete": 1, | |||||
"email": 1, | |||||
"export": 1, | |||||
"print": 1, | |||||
"read": 1, | |||||
"report": 1, | |||||
"role": "System Manager", | |||||
"share": 1 | |||||
} | } | ||||
], | |||||
"quick_entry": 0, | |||||
"read_only": 0, | |||||
"read_only_onload": 0, | |||||
"sort_field": "modified", | |||||
"sort_order": "DESC", | |||||
"track_changes": 1, | |||||
"track_seen": 0 | |||||
], | |||||
"sort_field": "modified", | |||||
"sort_order": "DESC" | |||||
} | } |
@@ -1,202 +1,257 @@ | |||||
from __future__ import unicode_literals | |||||
import hashlib | |||||
import json | import json | ||||
from urllib.parse import quote, urlencode, urlparse | |||||
import jwt | |||||
from urllib.parse import quote, urlencode | |||||
from oauthlib.oauth2 import FatalClientError, OAuth2Error | from oauthlib.oauth2 import FatalClientError, OAuth2Error | ||||
from oauthlib.openid.connect.core.endpoints.pre_configured import ( | |||||
Server as WebApplicationServer, | |||||
) | |||||
import frappe | import frappe | ||||
from frappe import _ | |||||
from frappe.oauth import OAuthWebRequestValidator, WebApplicationServer | |||||
from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import get_oauth_settings | |||||
from frappe.oauth import ( | |||||
OAuthWebRequestValidator, | |||||
generate_json_error_response, | |||||
get_server_url, | |||||
get_userinfo, | |||||
) | |||||
from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import ( | |||||
get_oauth_settings, | |||||
) | |||||
def get_oauth_server(): | def get_oauth_server(): | ||||
if not getattr(frappe.local, 'oauth_server', None): | |||||
if not getattr(frappe.local, "oauth_server", None): | |||||
oauth_validator = OAuthWebRequestValidator() | oauth_validator = OAuthWebRequestValidator() | ||||
frappe.local.oauth_server = WebApplicationServer(oauth_validator) | frappe.local.oauth_server = WebApplicationServer(oauth_validator) | ||||
return frappe.local.oauth_server | return frappe.local.oauth_server | ||||
def sanitize_kwargs(param_kwargs): | def sanitize_kwargs(param_kwargs): | ||||
"""Remove 'data' and 'cmd' keys, if present.""" | """Remove 'data' and 'cmd' keys, if present.""" | ||||
arguments = param_kwargs | arguments = param_kwargs | ||||
arguments.pop('data', None) | |||||
arguments.pop('cmd', None) | |||||
arguments.pop("data", None) | |||||
arguments.pop("cmd", None) | |||||
return arguments | return arguments | ||||
def encode_params(params): | |||||
""" | |||||
Encode a dict of params into a query string. | |||||
Use `quote_via=urllib.parse.quote` so that whitespaces will be encoded as | |||||
`%20` instead of as `+`. This is needed because oauthlib cannot handle `+` | |||||
as a whitespace. | |||||
""" | |||||
return urlencode(params, quote_via=quote) | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def approve(*args, **kwargs): | def approve(*args, **kwargs): | ||||
r = frappe.request | r = frappe.request | ||||
try: | try: | ||||
scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request( | |||||
r.url, | |||||
r.method, | |||||
r.get_data(), | |||||
r.headers | |||||
( | |||||
scopes, | |||||
frappe.flags.oauth_credentials, | |||||
) = get_oauth_server().validate_authorization_request( | |||||
r.url, r.method, r.get_data(), r.headers | |||||
) | ) | ||||
headers, body, status = get_oauth_server().create_authorization_response( | headers, body, status = get_oauth_server().create_authorization_response( | ||||
uri=frappe.flags.oauth_credentials['redirect_uri'], | |||||
uri=frappe.flags.oauth_credentials["redirect_uri"], | |||||
body=r.get_data(), | body=r.get_data(), | ||||
headers=r.headers, | headers=r.headers, | ||||
scopes=scopes, | scopes=scopes, | ||||
credentials=frappe.flags.oauth_credentials | |||||
credentials=frappe.flags.oauth_credentials, | |||||
) | ) | ||||
uri = headers.get('Location', None) | |||||
uri = headers.get("Location", None) | |||||
frappe.local.response["type"] = "redirect" | frappe.local.response["type"] = "redirect" | ||||
frappe.local.response["location"] = uri | frappe.local.response["location"] = uri | ||||
return | |||||
except (FatalClientError, OAuth2Error) as e: | |||||
return generate_json_error_response(e) | |||||
except FatalClientError as e: | |||||
return e | |||||
except OAuth2Error as e: | |||||
return e | |||||
@frappe.whitelist(allow_guest=True) | @frappe.whitelist(allow_guest=True) | ||||
def authorize(**kwargs): | def authorize(**kwargs): | ||||
success_url = "/api/method/frappe.integrations.oauth2.approve?" + encode_params(sanitize_kwargs(kwargs)) | |||||
success_url = "/api/method/frappe.integrations.oauth2.approve?" + encode_params( | |||||
sanitize_kwargs(kwargs) | |||||
) | |||||
failure_url = frappe.form_dict["redirect_uri"] + "?error=access_denied" | failure_url = frappe.form_dict["redirect_uri"] + "?error=access_denied" | ||||
if frappe.session.user == 'Guest': | |||||
#Force login, redirect to preauth again. | |||||
if frappe.session.user == "Guest": | |||||
# Force login, redirect to preauth again. | |||||
frappe.local.response["type"] = "redirect" | frappe.local.response["type"] = "redirect" | ||||
frappe.local.response["location"] = "/login?" + encode_params({'redirect-to': frappe.request.url}) | |||||
frappe.local.response["location"] = "/login?" + encode_params( | |||||
{"redirect-to": frappe.request.url} | |||||
) | |||||
else: | else: | ||||
try: | try: | ||||
r = frappe.request | r = frappe.request | ||||
scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request( | |||||
r.url, | |||||
r.method, | |||||
r.get_data(), | |||||
r.headers | |||||
( | |||||
scopes, | |||||
frappe.flags.oauth_credentials, | |||||
) = get_oauth_server().validate_authorization_request( | |||||
r.url, r.method, r.get_data(), r.headers | |||||
) | ) | ||||
skip_auth = frappe.db.get_value("OAuth Client", frappe.flags.oauth_credentials['client_id'], "skip_authorization") | |||||
unrevoked_tokens = frappe.get_all("OAuth Bearer Token", filters={"status":"Active"}) | |||||
skip_auth = frappe.db.get_value( | |||||
"OAuth Client", | |||||
frappe.flags.oauth_credentials["client_id"], | |||||
"skip_authorization", | |||||
) | |||||
unrevoked_tokens = frappe.get_all( | |||||
"OAuth Bearer Token", filters={"status": "Active"} | |||||
) | |||||
if skip_auth or (get_oauth_settings().skip_authorization == "Auto" and unrevoked_tokens): | |||||
if skip_auth or ( | |||||
get_oauth_settings().skip_authorization == "Auto" and unrevoked_tokens | |||||
): | |||||
frappe.local.response["type"] = "redirect" | frappe.local.response["type"] = "redirect" | ||||
frappe.local.response["location"] = success_url | frappe.local.response["location"] = success_url | ||||
else: | else: | ||||
#Show Allow/Deny screen. | |||||
response_html_params = frappe._dict({ | |||||
"client_id": frappe.db.get_value("OAuth Client", kwargs['client_id'], "app_name"), | |||||
"success_url": success_url, | |||||
"failure_url": failure_url, | |||||
"details": scopes | |||||
}) | |||||
resp_html = frappe.render_template("templates/includes/oauth_confirmation.html", response_html_params) | |||||
# Show Allow/Deny screen. | |||||
response_html_params = frappe._dict( | |||||
{ | |||||
"client_id": frappe.db.get_value( | |||||
"OAuth Client", kwargs["client_id"], "app_name" | |||||
), | |||||
"success_url": success_url, | |||||
"failure_url": failure_url, | |||||
"details": scopes, | |||||
} | |||||
) | |||||
resp_html = frappe.render_template( | |||||
"templates/includes/oauth_confirmation.html", response_html_params | |||||
) | |||||
frappe.respond_as_web_page("Confirm Access", resp_html) | frappe.respond_as_web_page("Confirm Access", resp_html) | ||||
except FatalClientError as e: | |||||
return e | |||||
except OAuth2Error as e: | |||||
return e | |||||
except (FatalClientError, OAuth2Error) as e: | |||||
return generate_json_error_response(e) | |||||
@frappe.whitelist(allow_guest=True) | @frappe.whitelist(allow_guest=True) | ||||
def get_token(*args, **kwargs): | def get_token(*args, **kwargs): | ||||
#Check whether frappe server URL is set | |||||
frappe_server_url = frappe.db.get_value("Social Login Key", "frappe", "base_url") or None | |||||
if not frappe_server_url: | |||||
frappe.throw(_("Please set Base URL in Social Login Key for Frappe")) | |||||
try: | try: | ||||
r = frappe.request | r = frappe.request | ||||
headers, body, status = get_oauth_server().create_token_response( | headers, body, status = get_oauth_server().create_token_response( | ||||
r.url, | |||||
r.method, | |||||
r.form, | |||||
r.headers, | |||||
frappe.flags.oauth_credentials | |||||
r.url, r.method, r.form, r.headers, frappe.flags.oauth_credentials | |||||
) | ) | ||||
out = frappe._dict(json.loads(body)) | |||||
if not out.error and "openid" in out.scope: | |||||
token_user = frappe.db.get_value("OAuth Bearer Token", out.access_token, "user") | |||||
token_client = frappe.db.get_value("OAuth Bearer Token", out.access_token, "client") | |||||
client_secret = frappe.db.get_value("OAuth Client", token_client, "client_secret") | |||||
if token_user in ["Guest", "Administrator"]: | |||||
frappe.throw(_("Logged in as Guest or Administrator")) | |||||
id_token_header = { | |||||
"typ":"jwt", | |||||
"alg":"HS256" | |||||
} | |||||
id_token = { | |||||
"aud": token_client, | |||||
"exp": int((frappe.db.get_value("OAuth Bearer Token", out.access_token, "expiration_time") - frappe.utils.datetime.datetime(1970, 1, 1)).total_seconds()), | |||||
"sub": frappe.db.get_value("User Social Login", {"parent":token_user, "provider": "frappe"}, "userid"), | |||||
"iss": frappe_server_url, | |||||
"at_hash": frappe.oauth.calculate_at_hash(out.access_token, hashlib.sha256) | |||||
} | |||||
body = frappe._dict(json.loads(body)) | |||||
id_token_encoded = jwt.encode(id_token, client_secret, algorithm='HS256', headers=id_token_header) | |||||
out.update({"id_token": frappe.safe_decode(id_token_encoded)}) | |||||
if body.error: | |||||
frappe.local.response = body | |||||
frappe.local.response["http_status_code"] = 400 | |||||
return | |||||
frappe.local.response = out | |||||
frappe.local.response = body | |||||
return | |||||
except FatalClientError as e: | |||||
return e | |||||
except (FatalClientError, OAuth2Error) as e: | |||||
return generate_json_error_response(e) | |||||
@frappe.whitelist(allow_guest=True) | @frappe.whitelist(allow_guest=True) | ||||
def revoke_token(*args, **kwargs): | def revoke_token(*args, **kwargs): | ||||
r = frappe.request | |||||
headers, body, status = get_oauth_server().create_revocation_response( | |||||
r.url, | |||||
headers=r.headers, | |||||
body=r.form, | |||||
http_method=r.method | |||||
) | |||||
try: | |||||
r = frappe.request | |||||
headers, body, status = get_oauth_server().create_revocation_response( | |||||
r.url, | |||||
headers=r.headers, | |||||
body=r.form, | |||||
http_method=r.method, | |||||
) | |||||
except (FatalClientError, OAuth2Error): | |||||
pass | |||||
# status_code must be 200 | |||||
frappe.local.response = frappe._dict({}) | |||||
frappe.local.response["http_status_code"] = status or 200 | |||||
return | |||||
frappe.local.response['http_status_code'] = status | |||||
if status == 200: | |||||
return "success" | |||||
else: | |||||
return "bad request" | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def openid_profile(*args, **kwargs): | def openid_profile(*args, **kwargs): | ||||
picture = None | |||||
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 | |||||
user_profile = frappe._dict({ | |||||
"sub": frappe_userid, | |||||
"name": " ".join(filter(None, [first_name, last_name])), | |||||
"given_name": first_name, | |||||
"family_name": last_name, | |||||
"email": name, | |||||
"picture": picture | |||||
}) | |||||
frappe.local.response = user_profile | |||||
def validate_url(url_string): | |||||
try: | try: | ||||
result = urlparse(url_string) | |||||
return result.scheme and result.scheme in ["http", "https", "ftp", "ftps"] | |||||
except: | |||||
return False | |||||
r = frappe.request | |||||
headers, body, status = get_oauth_server().create_userinfo_response( | |||||
r.url, | |||||
headers=r.headers, | |||||
body=r.form, | |||||
) | |||||
body = frappe._dict(json.loads(body)) | |||||
frappe.local.response = body | |||||
return | |||||
def encode_params(params): | |||||
""" | |||||
Encode a dict of params into a query string. | |||||
except (FatalClientError, OAuth2Error) as e: | |||||
return generate_json_error_response(e) | |||||
Use `quote_via=urllib.parse.quote` so that whitespaces will be encoded as | |||||
`%20` instead of as `+`. This is needed because oauthlib cannot handle `+` | |||||
as a whitespace. | |||||
""" | |||||
return urlencode(params, quote_via=quote) | |||||
@frappe.whitelist(allow_guest=True) | |||||
def openid_configuration(): | |||||
frappe_server_url = get_server_url() | |||||
frappe.local.response = frappe._dict( | |||||
{ | |||||
"issuer": frappe_server_url, | |||||
"authorization_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.authorize", | |||||
"token_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.get_token", | |||||
"userinfo_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.openid_profile", | |||||
"revocation_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.revoke_token", | |||||
"introspection_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.introspect_token", | |||||
"response_types_supported": [ | |||||
"code", | |||||
"token", | |||||
"code id_token", | |||||
"code token id_token", | |||||
"id_token", | |||||
"id_token token", | |||||
], | |||||
"subject_types_supported": ["public"], | |||||
"id_token_signing_alg_values_supported": ["HS256"], | |||||
} | |||||
) | |||||
@frappe.whitelist(allow_guest=True) | |||||
def introspect_token(token=None, token_type_hint=None): | |||||
if token_type_hint not in ["access_token", "refresh_token"]: | |||||
token_type_hint = "access_token" | |||||
try: | |||||
bearer_token = None | |||||
if token_type_hint == "access_token": | |||||
bearer_token = frappe.get_doc("OAuth Bearer Token", {"access_token": token}) | |||||
elif token_type_hint == "refresh_token": | |||||
bearer_token = frappe.get_doc( | |||||
"OAuth Bearer Token", {"refresh_token": token} | |||||
) | |||||
client = frappe.get_doc("OAuth Client", bearer_token.client) | |||||
token_response = frappe._dict( | |||||
{ | |||||
"client_id": client.client_id, | |||||
"trusted_client": client.skip_authorization, | |||||
"active": bearer_token.status == "Active", | |||||
"exp": round(bearer_token.expiration_time.timestamp()), | |||||
"scope": bearer_token.scopes, | |||||
} | |||||
) | |||||
if "openid" in bearer_token.scopes: | |||||
sub = frappe.get_value( | |||||
"User Social Login", | |||||
{"provider": "frappe", "parent": bearer_token.user}, | |||||
"userid", | |||||
) | |||||
if sub: | |||||
token_response.update({"sub": sub}) | |||||
user = frappe.get_doc("User", bearer_token.user) | |||||
userinfo = get_userinfo(user) | |||||
token_response.update(userinfo) | |||||
frappe.local.response = token_response | |||||
except Exception: | |||||
frappe.local.response = frappe._dict({"active": False}) |
@@ -34,8 +34,9 @@ def get_controller(doctype): | |||||
from frappe.model.document import Document | from frappe.model.document import Document | ||||
from frappe.utils.nestedset import NestedSet | from frappe.utils.nestedset import NestedSet | ||||
module_name, custom = frappe.db.get_value("DocType", doctype, ("module", "custom"), cache=True) \ | |||||
or ["Core", False] | |||||
module_name, custom = frappe.db.get_value( | |||||
"DocType", doctype, ("module", "custom"), cache=True | |||||
) or ["Core", False] | |||||
if custom: | if custom: | ||||
if frappe.db.field_exists("DocType", "is_tree"): | if frappe.db.field_exists("DocType", "is_tree"): | ||||
@@ -1347,6 +1347,22 @@ class Document(BaseDocument): | |||||
from frappe.desk.doctype.tag.tag import DocTags | from frappe.desk.doctype.tag.tag import DocTags | ||||
return DocTags(self.doctype).get_tags(self.name).split(",")[1:] | return DocTags(self.doctype).get_tags(self.name).split(",")[1:] | ||||
def __repr__(self): | |||||
name = self.name or "unsaved" | |||||
doctype = self.__class__.__name__ | |||||
docstatus = f" docstatus={self.docstatus}" if self.docstatus else "" | |||||
parent = f" parent={self.parent}" if self.parent else "" | |||||
return f"<{doctype}: {name}{docstatus}{parent}>" | |||||
def __str__(self): | |||||
name = self.name or "unsaved" | |||||
doctype = self.__class__.__name__ | |||||
return f"{doctype}({name})" | |||||
def execute_action(doctype, name, action, **kwargs): | def execute_action(doctype, name, action, **kwargs): | ||||
"""Execute an action on a document (called by background worker)""" | """Execute an action on a document (called by background worker)""" | ||||
doc = frappe.get_doc(doctype, name) | doc = frappe.get_doc(doctype, name) | ||||
@@ -1,65 +1,16 @@ | |||||
from __future__ import print_function, unicode_literals | |||||
import frappe | |||||
import pytz | import pytz | ||||
import jwt | |||||
import hashlib | |||||
import re | |||||
import base64 | |||||
import datetime | |||||
from frappe import _ | |||||
from frappe.auth import LoginManager | |||||
from http import cookies | from http import cookies | ||||
from oauthlib.oauth2.rfc6749.tokens import BearerToken | |||||
from oauthlib.oauth2.rfc6749.grant_types import AuthorizationCodeGrant, ImplicitGrant, ResourceOwnerPasswordCredentialsGrant, ClientCredentialsGrant, RefreshTokenGrant | |||||
from oauthlib.oauth2 import RequestValidator | |||||
from oauthlib.oauth2.rfc6749.endpoints.authorization import AuthorizationEndpoint | |||||
from oauthlib.oauth2.rfc6749.endpoints.token import TokenEndpoint | |||||
from oauthlib.oauth2.rfc6749.endpoints.resource import ResourceEndpoint | |||||
from oauthlib.oauth2.rfc6749.endpoints.revocation import RevocationEndpoint | |||||
from oauthlib.common import Request | |||||
from six.moves.urllib.parse import unquote | |||||
def get_url_delimiter(separator_character=" "): | |||||
return separator_character | |||||
from oauthlib.openid import RequestValidator | |||||
from urllib.parse import urlparse, unquote | |||||
class WebApplicationServer(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoint, | |||||
RevocationEndpoint): | |||||
"""An all-in-one endpoint featuring Authorization code grant and Bearer tokens.""" | |||||
def __init__(self, request_validator, token_generator=None, | |||||
token_expires_in=None, refresh_token_generator=None, **kwargs): | |||||
"""Construct a new web application server. | |||||
:param request_validator: An implementation of | |||||
oauthlib.oauth2.RequestValidator. | |||||
:param token_expires_in: An int or a function to generate a token | |||||
expiration offset (in seconds) given a | |||||
oauthlib.common.Request object. | |||||
:param token_generator: A function to generate a token from a request. | |||||
:param refresh_token_generator: A function to generate a token from a | |||||
request for the refresh token. | |||||
:param kwargs: Extra parameters to pass to authorization-, | |||||
token-, resource-, and revocation-endpoint constructors. | |||||
""" | |||||
implicit_grant = ImplicitGrant(request_validator) | |||||
auth_grant = AuthorizationCodeGrant(request_validator) | |||||
refresh_grant = RefreshTokenGrant(request_validator) | |||||
resource_owner_password_credentials_grant = ResourceOwnerPasswordCredentialsGrant(request_validator) | |||||
bearer = BearerToken(request_validator, token_generator, | |||||
token_expires_in, refresh_token_generator) | |||||
AuthorizationEndpoint.__init__(self, default_response_type='code', | |||||
response_types={ | |||||
'code': auth_grant, | |||||
'token': implicit_grant | |||||
}, | |||||
default_token_type=bearer) | |||||
TokenEndpoint.__init__(self, default_grant_type='authorization_code', | |||||
grant_types={ | |||||
'authorization_code': auth_grant, | |||||
'refresh_token': refresh_grant, | |||||
'password': resource_owner_password_credentials_grant | |||||
}, | |||||
default_token_type=bearer) | |||||
ResourceEndpoint.__init__(self, default_token='Bearer', | |||||
token_types={'Bearer': bearer}) | |||||
RevocationEndpoint.__init__(self, request_validator) | |||||
import frappe | |||||
from frappe.auth import LoginManager | |||||
class OAuthWebRequestValidator(RequestValidator): | class OAuthWebRequestValidator(RequestValidator): | ||||
@@ -67,7 +18,7 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
# Pre- and post-authorization. | # Pre- and post-authorization. | ||||
def validate_client_id(self, client_id, request, *args, **kwargs): | def validate_client_id(self, client_id, request, *args, **kwargs): | ||||
# Simple validity check, does client exist? Not banned? | # Simple validity check, does client exist? Not banned? | ||||
cli_id = frappe.db.get_value("OAuth Client",{ "name":client_id }) | |||||
cli_id = frappe.db.get_value("OAuth Client", {"name": client_id}) | |||||
if cli_id: | if cli_id: | ||||
request.client = frappe.get_doc("OAuth Client", client_id).as_dict() | request.client = frappe.get_doc("OAuth Client", client_id).as_dict() | ||||
return True | return True | ||||
@@ -78,7 +29,9 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
# Is the client allowed to use the supplied redirect_uri? i.e. has | # Is the client allowed to use the supplied redirect_uri? i.e. has | ||||
# the client previously registered this EXACT redirect uri. | # the client previously registered this EXACT redirect uri. | ||||
redirect_uris = frappe.db.get_value("OAuth Client", client_id, 'redirect_uris').split(get_url_delimiter()) | |||||
redirect_uris = frappe.db.get_value( | |||||
"OAuth Client", client_id, "redirect_uris" | |||||
).split(get_url_delimiter()) | |||||
if redirect_uri in redirect_uris: | if redirect_uri in redirect_uris: | ||||
return True | return True | ||||
@@ -89,7 +42,9 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
# The redirect used if none has been supplied. | # The redirect used if none has been supplied. | ||||
# Prefer your clients to pre register a redirect uri rather than | # Prefer your clients to pre register a redirect uri rather than | ||||
# supplying one on each authorization request. | # supplying one on each authorization request. | ||||
redirect_uri = frappe.db.get_value("OAuth Client", client_id, 'default_redirect_uri') | |||||
redirect_uri = frappe.db.get_value( | |||||
"OAuth Client", client_id, "default_redirect_uri" | |||||
) | |||||
return redirect_uri | return redirect_uri | ||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): | def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): | ||||
@@ -101,19 +56,23 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
# Scopes a client will authorize for if none are supplied in the | # Scopes a client will authorize for if none are supplied in the | ||||
# authorization request. | # authorization request. | ||||
scopes = get_client_scopes(client_id) | scopes = get_client_scopes(client_id) | ||||
request.scopes = scopes #Apparently this is possible. | |||||
request.scopes = scopes # Apparently this is possible. | |||||
return scopes | return scopes | ||||
def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): | |||||
# Clients should only be allowed to use one type of response type, the | |||||
# one associated with their one allowed grant type. | |||||
# In this case it must be "code". | |||||
allowed_response_types = [client.response_type.lower(), | |||||
"code token", "code id_token", "code token id_token", | |||||
"code+token", "code+id_token", "code+token id_token"] | |||||
return (response_type in allowed_response_types) | |||||
def validate_response_type( | |||||
self, client_id, response_type, client, request, *args, **kwargs | |||||
): | |||||
allowed_response_types = [ | |||||
# From OAuth Client response_type field | |||||
client.response_type.lower(), | |||||
# OIDC | |||||
"id_token", | |||||
"id_token token", | |||||
"code id_token", | |||||
"code token id_token", | |||||
] | |||||
return response_type in allowed_response_types | |||||
# Post-authorization | # Post-authorization | ||||
@@ -121,38 +80,69 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
cookie_dict = get_cookie_dict_from_headers(request) | cookie_dict = get_cookie_dict_from_headers(request) | ||||
oac = frappe.new_doc('OAuth Authorization Code') | |||||
oac = frappe.new_doc("OAuth Authorization Code") | |||||
oac.scopes = get_url_delimiter().join(request.scopes) | oac.scopes = get_url_delimiter().join(request.scopes) | ||||
oac.redirect_uri_bound_to_authorization_code = request.redirect_uri | oac.redirect_uri_bound_to_authorization_code = request.redirect_uri | ||||
oac.client = client_id | oac.client = client_id | ||||
oac.user = unquote(cookie_dict['user_id'].value) | |||||
oac.authorization_code = code['code'] | |||||
oac.user = unquote(cookie_dict["user_id"].value) | |||||
oac.authorization_code = code["code"] | |||||
if request.nonce: | |||||
oac.nonce = request.nonce | |||||
if request.code_challenge and request.code_challenge_method: | |||||
oac.code_challenge = request.code_challenge | |||||
oac.code_challenge_method = request.code_challenge_method.lower() | |||||
oac.save(ignore_permissions=True) | oac.save(ignore_permissions=True) | ||||
frappe.db.commit() | frappe.db.commit() | ||||
def authenticate_client(self, request, *args, **kwargs): | def authenticate_client(self, request, *args, **kwargs): | ||||
#Get ClientID in URL | |||||
# Get ClientID in URL | |||||
if request.client_id: | if request.client_id: | ||||
oc = frappe.get_doc("OAuth Client", request.client_id) | oc = frappe.get_doc("OAuth Client", request.client_id) | ||||
else: | else: | ||||
#Extract token, instantiate OAuth Bearer Token and use clientid from there. | |||||
# Extract token, instantiate OAuth Bearer Token and use clientid from there. | |||||
if "refresh_token" in frappe.form_dict: | if "refresh_token" in frappe.form_dict: | ||||
oc = frappe.get_doc("OAuth Client", frappe.db.get_value("OAuth Bearer Token", {"refresh_token": frappe.form_dict["refresh_token"]}, 'client')) | |||||
oc = frappe.get_doc( | |||||
"OAuth Client", | |||||
frappe.db.get_value( | |||||
"OAuth Bearer Token", | |||||
{"refresh_token": frappe.form_dict["refresh_token"]}, | |||||
"client", | |||||
), | |||||
) | |||||
elif "token" in frappe.form_dict: | elif "token" in frappe.form_dict: | ||||
oc = frappe.get_doc("OAuth Client", frappe.db.get_value("OAuth Bearer Token", frappe.form_dict["token"], 'client')) | |||||
oc = frappe.get_doc( | |||||
"OAuth Client", | |||||
frappe.db.get_value( | |||||
"OAuth Bearer Token", frappe.form_dict["token"], "client" | |||||
), | |||||
) | |||||
else: | else: | ||||
oc = frappe.get_doc("OAuth Client", frappe.db.get_value("OAuth Bearer Token", frappe.get_request_header("Authorization").split(" ")[1], 'client')) | |||||
oc = frappe.get_doc( | |||||
"OAuth Client", | |||||
frappe.db.get_value( | |||||
"OAuth Bearer Token", | |||||
frappe.get_request_header("Authorization").split(" ")[1], | |||||
"client", | |||||
), | |||||
) | |||||
try: | try: | ||||
request.client = request.client or oc.as_dict() | request.client = request.client or oc.as_dict() | ||||
except Exception as e: | except Exception as e: | ||||
print("Failed body authentication: Application %s does not exist".format(cid=request.client_id)) | |||||
return generate_json_error_response(e) | |||||
cookie_dict = get_cookie_dict_from_headers(request) | cookie_dict = get_cookie_dict_from_headers(request) | ||||
user_id = unquote(cookie_dict.get('user_id').value) if 'user_id' in cookie_dict else "Guest" | |||||
user_id = ( | |||||
unquote(cookie_dict.get("user_id").value) | |||||
if "user_id" in cookie_dict | |||||
else "Guest" | |||||
) | |||||
return frappe.session.user == user_id | return frappe.session.user == user_id | ||||
def authenticate_client_id(self, client_id, request, *args, **kwargs): | def authenticate_client_id(self, client_id, request, *args, **kwargs): | ||||
cli_id = frappe.db.get_value('OAuth Client', client_id, 'name') | |||||
cli_id = frappe.db.get_value("OAuth Client", client_id, "name") | |||||
if not cli_id: | if not cli_id: | ||||
# Don't allow public (non-authenticated) clients | # Don't allow public (non-authenticated) clients | ||||
return False | return False | ||||
@@ -164,28 +154,72 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
# Validate the code belongs to the client. Add associated scopes, | # Validate the code belongs to the client. Add associated scopes, | ||||
# state and user to request.scopes and request.user. | # state and user to request.scopes and request.user. | ||||
validcodes = frappe.get_all("OAuth Authorization Code", filters={"client": client_id, "validity": "Valid"}) | |||||
validcodes = frappe.get_all( | |||||
"OAuth Authorization Code", | |||||
filters={"client": client_id, "validity": "Valid"}, | |||||
) | |||||
checkcodes = [] | checkcodes = [] | ||||
for vcode in validcodes: | for vcode in validcodes: | ||||
checkcodes.append(vcode["name"]) | checkcodes.append(vcode["name"]) | ||||
if code in checkcodes: | if code in checkcodes: | ||||
request.scopes = frappe.db.get_value("OAuth Authorization Code", code, 'scopes').split(get_url_delimiter()) | |||||
request.user = frappe.db.get_value("OAuth Authorization Code", code, 'user') | |||||
request.scopes = frappe.db.get_value( | |||||
"OAuth Authorization Code", code, "scopes" | |||||
).split(get_url_delimiter()) | |||||
request.user = frappe.db.get_value("OAuth Authorization Code", code, "user") | |||||
code_challenge_method = frappe.db.get_value( | |||||
"OAuth Authorization Code", code, "code_challenge_method" | |||||
) | |||||
code_challenge = frappe.db.get_value( | |||||
"OAuth Authorization Code", code, "code_challenge" | |||||
) | |||||
if code_challenge and not request.code_verifier: | |||||
if frappe.db.exists("OAuth Authorization Code", code): | |||||
frappe.delete_doc( | |||||
"OAuth Authorization Code", code, ignore_permissions=True | |||||
) | |||||
frappe.db.commit() | |||||
return False | |||||
if code_challenge_method == "s256": | |||||
m = hashlib.sha256() | |||||
m.update(bytes(request.code_verifier, "utf-8")) | |||||
code_verifier = base64.b64encode(m.digest()).decode("utf-8") | |||||
code_verifier = re.sub(r"\+", "-", code_verifier) | |||||
code_verifier = re.sub(r"\/", "_", code_verifier) | |||||
code_verifier = re.sub(r"=", "", code_verifier) | |||||
return code_challenge == code_verifier | |||||
elif code_challenge_method == "plain": | |||||
return code_challenge == request.code_verifier | |||||
return True | return True | ||||
else: | |||||
return False | |||||
def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **kwargs): | |||||
saved_redirect_uri = frappe.db.get_value('OAuth Client', client_id, 'default_redirect_uri') | |||||
return False | |||||
def confirm_redirect_uri( | |||||
self, client_id, code, redirect_uri, client, *args, **kwargs | |||||
): | |||||
saved_redirect_uri = frappe.db.get_value( | |||||
"OAuth Client", client_id, "default_redirect_uri" | |||||
) | |||||
redirect_uris = frappe.db.get_value("OAuth Client", client_id, "redirect_uris") | |||||
if redirect_uris: | |||||
redirect_uris = redirect_uris.split(get_url_delimiter()) | |||||
return redirect_uri in redirect_uris | |||||
return saved_redirect_uri == redirect_uri | return saved_redirect_uri == redirect_uri | ||||
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): | |||||
def validate_grant_type( | |||||
self, client_id, grant_type, client, request, *args, **kwargs | |||||
): | |||||
# Clients should only be allowed to use one type of grant. | # Clients should only be allowed to use one type of grant. | ||||
# In this case, it must be "authorization_code" or "refresh_token" | # In this case, it must be "authorization_code" or "refresh_token" | ||||
return (grant_type in ["authorization_code", "refresh_token", "password"]) | |||||
return grant_type in ["authorization_code", "refresh_token", "password"] | |||||
def save_bearer_token(self, token, request, *args, **kwargs): | def save_bearer_token(self, token, request, *args, **kwargs): | ||||
# Remember to associate it with request.scopes, request.user and | # Remember to associate it with request.scopes, request.user and | ||||
@@ -195,19 +229,30 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
# access_token to now + expires_in seconds. | # access_token to now + expires_in seconds. | ||||
otoken = frappe.new_doc("OAuth Bearer Token") | otoken = frappe.new_doc("OAuth Bearer Token") | ||||
otoken.client = request.client['name'] | |||||
otoken.client = request.client["name"] | |||||
try: | try: | ||||
otoken.user = request.user if request.user else frappe.db.get_value("OAuth Bearer Token", {"refresh_token":request.body.get("refresh_token")}, "user") | |||||
except Exception as e: | |||||
otoken.user = ( | |||||
request.user | |||||
if request.user | |||||
else frappe.db.get_value( | |||||
"OAuth Bearer Token", | |||||
{"refresh_token": request.body.get("refresh_token")}, | |||||
"user", | |||||
) | |||||
) | |||||
except Exception: | |||||
otoken.user = frappe.session.user | otoken.user = frappe.session.user | ||||
otoken.scopes = get_url_delimiter().join(request.scopes) | otoken.scopes = get_url_delimiter().join(request.scopes) | ||||
otoken.access_token = token['access_token'] | |||||
otoken.refresh_token = token.get('refresh_token') | |||||
otoken.expires_in = token['expires_in'] | |||||
otoken.access_token = token["access_token"] | |||||
otoken.refresh_token = token.get("refresh_token") | |||||
otoken.expires_in = token["expires_in"] | |||||
otoken.save(ignore_permissions=True) | otoken.save(ignore_permissions=True) | ||||
frappe.db.commit() | frappe.db.commit() | ||||
default_redirect_uri = frappe.db.get_value("OAuth Client", request.client['name'], "default_redirect_uri") | |||||
default_redirect_uri = frappe.db.get_value( | |||||
"OAuth Client", request.client["name"], "default_redirect_uri" | |||||
) | |||||
return default_redirect_uri | return default_redirect_uri | ||||
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): | def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): | ||||
@@ -222,24 +267,35 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
def validate_bearer_token(self, token, scopes, request): | def validate_bearer_token(self, token, scopes, request): | ||||
# Remember to check expiration and scope membership | # Remember to check expiration and scope membership | ||||
otoken = frappe.get_doc("OAuth Bearer Token", token) | otoken = frappe.get_doc("OAuth Bearer Token", token) | ||||
token_expiration_local = otoken.expiration_time.replace(tzinfo=pytz.timezone(frappe.utils.get_time_zone())) | |||||
token_expiration_local = otoken.expiration_time.replace( | |||||
tzinfo=pytz.timezone(frappe.utils.get_time_zone()) | |||||
) | |||||
token_expiration_utc = token_expiration_local.astimezone(pytz.utc) | token_expiration_utc = token_expiration_local.astimezone(pytz.utc) | ||||
is_token_valid = (frappe.utils.datetime.datetime.utcnow().replace(tzinfo=pytz.utc) < token_expiration_utc) \ | |||||
and otoken.status != "Revoked" | |||||
client_scopes = frappe.db.get_value("OAuth Client", otoken.client, 'scopes').split(get_url_delimiter()) | |||||
is_token_valid = ( | |||||
frappe.utils.datetime.datetime.utcnow().replace(tzinfo=pytz.utc) | |||||
< token_expiration_utc | |||||
) and otoken.status != "Revoked" | |||||
client_scopes = frappe.db.get_value( | |||||
"OAuth Client", otoken.client, "scopes" | |||||
).split(get_url_delimiter()) | |||||
are_scopes_valid = True | are_scopes_valid = True | ||||
for scp in scopes: | for scp in scopes: | ||||
are_scopes_valid = are_scopes_valid and True if scp in client_scopes else False | |||||
are_scopes_valid = ( | |||||
are_scopes_valid and True if scp in client_scopes else False | |||||
) | |||||
return is_token_valid and are_scopes_valid | return is_token_valid and are_scopes_valid | ||||
# Token refresh request | # Token refresh request | ||||
def get_original_scopes(self, refresh_token, request, *args, **kwargs): | def get_original_scopes(self, refresh_token, request, *args, **kwargs): | ||||
# Obtain the token associated with the given refresh_token and | # Obtain the token associated with the given refresh_token and | ||||
# return its scopes, these will be passed on to the refreshed | # return its scopes, these will be passed on to the refreshed | ||||
# access token if the client did not specify a scope during the | # access token if the client did not specify a scope during the | ||||
# request. | # request. | ||||
obearer_token = frappe.get_doc("OAuth Bearer Token", {"refresh_token": refresh_token}) | |||||
obearer_token = frappe.get_doc( | |||||
"OAuth Bearer Token", {"refresh_token": refresh_token} | |||||
) | |||||
return obearer_token.scopes | return obearer_token.scopes | ||||
def revoke_token(self, token, token_type_hint, request, *args, **kwargs): | def revoke_token(self, token, token_type_hint, request, *args, **kwargs): | ||||
@@ -250,36 +306,38 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
:param request: The HTTP Request (oauthlib.common.Request) | :param request: The HTTP Request (oauthlib.common.Request) | ||||
Method is used by: | Method is used by: | ||||
- Revocation Endpoint | |||||
- Revocation Endpoint | |||||
""" | """ | ||||
otoken = None | |||||
if token_type_hint == "access_token": | if token_type_hint == "access_token": | ||||
otoken = frappe.db.set_value("OAuth Bearer Token", token, 'status', 'Revoked') | |||||
frappe.db.set_value("OAuth Bearer Token", token, "status", "Revoked") | |||||
elif token_type_hint == "refresh_token": | elif token_type_hint == "refresh_token": | ||||
otoken = frappe.db.set_value("OAuth Bearer Token", {"refresh_token": token}, 'status', 'Revoked') | |||||
frappe.db.set_value( | |||||
"OAuth Bearer Token", {"refresh_token": token}, "status", "Revoked" | |||||
) | |||||
else: | else: | ||||
otoken = frappe.db.set_value("OAuth Bearer Token", token, 'status', 'Revoked') | |||||
frappe.db.set_value("OAuth Bearer Token", token, "status", "Revoked") | |||||
frappe.db.commit() | frappe.db.commit() | ||||
def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): | def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): | ||||
# """Ensure the Bearer token is valid and authorized access to scopes. | |||||
"""Ensure the Bearer token is valid and authorized access to scopes. | |||||
# OBS! The request.user attribute should be set to the resource owner | |||||
# associated with this refresh token. | |||||
OBS! The request.user attribute should be set to the resource owner | |||||
associated with this refresh token. | |||||
# :param refresh_token: Unicode refresh token | |||||
# :param client: Client object set by you, see authenticate_client. | |||||
# :param request: The HTTP Request (oauthlib.common.Request) | |||||
# :rtype: True or False | |||||
:param refresh_token: Unicode refresh token | |||||
:param client: Client object set by you, see authenticate_client. | |||||
:param request: The HTTP Request (oauthlib.common.Request) | |||||
:rtype: True or False | |||||
# Method is used by: | |||||
# - Authorization Code Grant (indirectly by issuing refresh tokens) | |||||
# - Resource Owner Password Credentials Grant (also indirectly) | |||||
# - Refresh Token Grant | |||||
# """ | |||||
Method is used by: | |||||
- Authorization Code Grant (indirectly by issuing refresh tokens) | |||||
- Resource Owner Password Credentials Grant (also indirectly) | |||||
- Refresh Token Grant | |||||
""" | |||||
otoken = frappe.get_doc("OAuth Bearer Token", {"refresh_token": refresh_token, "status": "Active"}) | |||||
otoken = frappe.get_doc( | |||||
"OAuth Bearer Token", {"refresh_token": refresh_token, "status": "Active"} | |||||
) | |||||
if not otoken: | if not otoken: | ||||
return False | return False | ||||
@@ -287,36 +345,84 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
return True | return True | ||||
# OpenID Connect | # OpenID Connect | ||||
def get_id_token(self, token, token_handler, request): | |||||
""" | |||||
In the OpenID Connect workflows when an ID Token is requested this method is called. | |||||
Subclasses should implement the construction, signing and optional encryption of the | |||||
ID Token as described in the OpenID Connect spec. | |||||
In addition to the standard OAuth2 request properties, the request may also contain | |||||
these OIDC specific properties which are useful to this method: | |||||
def finalize_id_token(self, id_token, token, token_handler, request): | |||||
# Check whether frappe server URL is set | |||||
id_token_header = {"typ": "jwt", "alg": "HS256"} | |||||
- nonce, if workflow is implicit or hybrid and it was provided | |||||
- claims, if provided to the original Authorization Code request | |||||
user = frappe.get_doc( | |||||
"User", | |||||
frappe.session.user, | |||||
) | |||||
The token parameter is a dict which may contain an ``access_token`` entry, in which | |||||
case the resulting ID Token *should* include a calculated ``at_hash`` claim. | |||||
if request.nonce: | |||||
id_token["nonce"] = request.nonce | |||||
Similarly, when the request parameter has a ``code`` property defined, the ID Token | |||||
*should* include a calculated ``c_hash`` claim. | |||||
userinfo = get_userinfo(user) | |||||
http://openid.net/specs/openid-connect-core-1_0.html (sections `3.1.3.6`_, `3.2.2.10`_, `3.3.2.11`_) | |||||
if userinfo.get("iss"): | |||||
id_token["iss"] = userinfo.get("iss") | |||||
.. _`3.1.3.6`: http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken | |||||
.. _`3.2.2.10`: http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken | |||||
.. _`3.3.2.11`: http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken | |||||
if "openid" in request.scopes: | |||||
id_token.update(userinfo) | |||||
:param token: A Bearer token dict | |||||
:param token_handler: the token handler (BearerToken class) | |||||
:param request: the HTTP Request (oauthlib.common.Request) | |||||
:return: The ID Token (a JWS signed JWT) | |||||
""" | |||||
# the request.scope should be used by the get_id_token() method to determine which claims to include in the resulting id_token | |||||
id_token_encoded = jwt.encode( | |||||
payload=id_token, | |||||
key=request.client.client_secret, | |||||
algorithm="HS256", | |||||
headers=id_token_header, | |||||
) | |||||
return frappe.safe_decode(id_token_encoded) | |||||
def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): | |||||
if frappe.get_value("OAuth Authorization Code", code, "validity") == "Valid": | |||||
return frappe.get_value("OAuth Authorization Code", code, "nonce") | |||||
return None | |||||
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): | |||||
scope = frappe.get_value("OAuth Client", client_id, "scopes") | |||||
if not scope: | |||||
scope = [] | |||||
else: | |||||
scope = scope.split(get_url_delimiter()) | |||||
return scope | |||||
def get_jwt_bearer_token(self, token, token_handler, request): | |||||
now = datetime.datetime.now() | |||||
id_token = dict( | |||||
aud=token.client_id, | |||||
iat=round(now.timestamp()), | |||||
at_hash=calculate_at_hash(token.access_token, hashlib.sha256), | |||||
) | |||||
return self.finalize_id_token(id_token, token, token_handler, request) | |||||
def get_userinfo_claims(self, request): | |||||
user = frappe.get_doc("User", frappe.session.user) | |||||
userinfo = get_userinfo(user) | |||||
return userinfo | |||||
def validate_id_token(self, token, scopes, request): | |||||
try: | |||||
id_token = frappe.get_doc("OAuth Bearer Token", token) | |||||
if id_token.status == "Active": | |||||
return True | |||||
except Exception: | |||||
return False | |||||
return False | |||||
def validate_jwt_bearer_token(self, token, scopes, request): | |||||
try: | |||||
jwt = frappe.get_doc("OAuth Bearer Token", token) | |||||
if jwt.status == "Active": | |||||
return True | |||||
except Exception: | |||||
return False | |||||
return False | |||||
def validate_silent_authorization(self, request): | def validate_silent_authorization(self, request): | ||||
"""Ensure the logged in user has authorized silent OpenID authorization. | """Ensure the logged in user has authorized silent OpenID authorization. | ||||
@@ -328,9 +434,9 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
:rtype: True or False | :rtype: True or False | ||||
Method is used by: | Method is used by: | ||||
- OpenIDConnectAuthCode | |||||
- OpenIDConnectImplicit | |||||
- OpenIDConnectHybrid | |||||
- OpenIDConnectAuthCode | |||||
- OpenIDConnectImplicit | |||||
- OpenIDConnectHybrid | |||||
""" | """ | ||||
if request.prompt == "login": | if request.prompt == "login": | ||||
False | False | ||||
@@ -351,9 +457,9 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
:rtype: True or False | :rtype: True or False | ||||
Method is used by: | Method is used by: | ||||
- OpenIDConnectAuthCode | |||||
- OpenIDConnectImplicit | |||||
- OpenIDConnectHybrid | |||||
- OpenIDConnectAuthCode | |||||
- OpenIDConnectImplicit | |||||
- OpenIDConnectHybrid | |||||
""" | """ | ||||
if frappe.session.user == "Guest" or request.prompt.lower() == "login": | if frappe.session.user == "Guest" or request.prompt.lower() == "login": | ||||
return False | return False | ||||
@@ -373,32 +479,77 @@ class OAuthWebRequestValidator(RequestValidator): | |||||
:rtype: True or False | :rtype: True or False | ||||
Method is used by: | Method is used by: | ||||
- OpenIDConnectAuthCode | |||||
- OpenIDConnectImplicit | |||||
- OpenIDConnectHybrid | |||||
- OpenIDConnectAuthCode | |||||
- OpenIDConnectImplicit | |||||
- OpenIDConnectHybrid | |||||
""" | """ | ||||
if id_token_hint and id_token_hint == frappe.db.get_value("User Social Login", {"parent":frappe.session.user, "provider": "frappe"}, "userid"): | |||||
if id_token_hint: | |||||
try: | |||||
user = None | |||||
payload = jwt.decode( | |||||
id_token_hint, | |||||
options={ | |||||
"verify_signature": False, | |||||
"verify_aud": False, | |||||
}, | |||||
) | |||||
client_id, client_secret = frappe.get_value( | |||||
"OAuth Client", | |||||
payload.get("aud"), | |||||
["client_id", "client_secret"], | |||||
) | |||||
if payload.get("sub") and client_id and client_secret: | |||||
user = frappe.db.get_value( | |||||
"User Social Login", | |||||
{"userid": payload.get("sub"), "provider": "frappe"}, | |||||
"parent", | |||||
) | |||||
user = frappe.get_doc("User", user) | |||||
verified_payload = jwt.decode( | |||||
id_token_hint, | |||||
key=client_secret, | |||||
audience=client_id, | |||||
algorithm="HS256", | |||||
options={ | |||||
"verify_exp": False, | |||||
}, | |||||
) | |||||
if verified_payload: | |||||
return user.name == frappe.session.user | |||||
except Exception: | |||||
return False | |||||
elif frappe.session.user != "Guest": | |||||
return True | return True | ||||
else: | |||||
return False | |||||
return False | |||||
def validate_user(self, username, password, client, request, *args, **kwargs): | def validate_user(self, username, password, client, request, *args, **kwargs): | ||||
"""Ensure the username and password is valid. | """Ensure the username and password is valid. | ||||
Method is used by: | |||||
- Resource Owner Password Credentials Grant | |||||
""" | |||||
Method is used by: | |||||
- Resource Owner Password Credentials Grant | |||||
""" | |||||
login_manager = LoginManager() | login_manager = LoginManager() | ||||
login_manager.authenticate(username, password) | login_manager.authenticate(username, password) | ||||
if login_manager.user == "Guest": | |||||
return False | |||||
request.user = login_manager.user | request.user = login_manager.user | ||||
return True | return True | ||||
def get_cookie_dict_from_headers(r): | def get_cookie_dict_from_headers(r): | ||||
cookie = cookies.BaseCookie() | cookie = cookies.BaseCookie() | ||||
if r.headers.get('Cookie'): | |||||
cookie.load(r.headers.get('Cookie')) | |||||
if r.headers.get("Cookie"): | |||||
cookie.load(r.headers.get("Cookie")) | |||||
return cookie | return cookie | ||||
def calculate_at_hash(access_token, hash_alg): | def calculate_at_hash(access_token, hash_alg): | ||||
"""Helper method for calculating an access token | """Helper method for calculating an access token | ||||
hash, as described in http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken | hash, as described in http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken | ||||
@@ -409,21 +560,25 @@ def calculate_at_hash(access_token, hash_alg): | |||||
then take the left-most 128 bits and base64url encode them. The at_hash value is a | then take the left-most 128 bits and base64url encode them. The at_hash value is a | ||||
case sensitive string. | case sensitive string. | ||||
Args: | Args: | ||||
access_token (str): An access token string. | |||||
hash_alg (callable): A callable returning a hash object, e.g. hashlib.sha256 | |||||
access_token (str): An access token string. | |||||
hash_alg (callable): A callable returning a hash object, e.g. hashlib.sha256 | |||||
""" | """ | ||||
hash_digest = hash_alg(access_token.encode('utf-8')).digest() | |||||
hash_digest = hash_alg(access_token.encode("utf-8")).digest() | |||||
cut_at = int(len(hash_digest) / 2) | cut_at = int(len(hash_digest) / 2) | ||||
truncated = hash_digest[:cut_at] | truncated = hash_digest[:cut_at] | ||||
from jwt.utils import base64url_encode | from jwt.utils import base64url_encode | ||||
at_hash = base64url_encode(truncated) | at_hash = base64url_encode(truncated) | ||||
return at_hash.decode('utf-8') | |||||
return at_hash.decode("utf-8") | |||||
def delete_oauth2_data(): | def delete_oauth2_data(): | ||||
# Delete Invalid Authorization Code and Revoked Token | # Delete Invalid Authorization Code and Revoked Token | ||||
commit_code, commit_token = False, False | commit_code, commit_token = False, False | ||||
code_list = frappe.get_all("OAuth Authorization Code", filters={"validity":"Invalid"}) | |||||
token_list = frappe.get_all("OAuth Bearer Token", filters={"status":"Revoked"}) | |||||
code_list = frappe.get_all( | |||||
"OAuth Authorization Code", filters={"validity": "Invalid"} | |||||
) | |||||
token_list = frappe.get_all("OAuth Bearer Token", filters={"status": "Revoked"}) | |||||
if len(code_list) > 0: | if len(code_list) > 0: | ||||
commit_code = True | commit_code = True | ||||
if len(token_list) > 0: | if len(token_list) > 0: | ||||
@@ -439,3 +594,58 @@ def delete_oauth2_data(): | |||||
def get_client_scopes(client_id): | def get_client_scopes(client_id): | ||||
scopes_string = frappe.db.get_value("OAuth Client", client_id, "scopes") | scopes_string = frappe.db.get_value("OAuth Client", client_id, "scopes") | ||||
return scopes_string.split() | return scopes_string.split() | ||||
def get_userinfo(user): | |||||
picture = None | |||||
frappe_server_url = get_server_url() | |||||
if user.user_image: | |||||
if frappe.utils.validate_url(user.user_image): | |||||
picture = user.user_image | |||||
else: | |||||
picture = frappe_server_url + "/" + user.user_image | |||||
userinfo = frappe._dict( | |||||
{ | |||||
"sub": frappe.db.get_value( | |||||
"User Social Login", | |||||
{"parent": user.name, "provider": "frappe"}, | |||||
"userid", | |||||
), | |||||
"name": " ".join(filter(None, [user.first_name, user.last_name])), | |||||
"given_name": user.first_name, | |||||
"family_name": user.last_name, | |||||
"email": user.email, | |||||
"picture": picture, | |||||
"roles": frappe.get_roles(user.name), | |||||
"iss": frappe_server_url, | |||||
} | |||||
) | |||||
return userinfo | |||||
def get_url_delimiter(separator_character=" "): | |||||
return separator_character | |||||
def generate_json_error_response(e): | |||||
if not e: | |||||
e = frappe._dict({}) | |||||
frappe.local.response = frappe._dict( | |||||
{ | |||||
"description": getattr(e, "description", "Internal Server Error"), | |||||
"status_code": getattr(e, "status_code", 500), | |||||
"error": getattr(e, "error", "internal_server_error"), | |||||
} | |||||
) | |||||
frappe.local.response["http_status_code"] = getattr(e, "status_code", 500) | |||||
return | |||||
def get_server_url(): | |||||
request_url = urlparse(frappe.request.url) | |||||
request_url = f"{request_url.scheme}://{request_url.netloc}" | |||||
return frappe.get_value("Social Login Key", "frappe", "base_url") or request_url |
@@ -114,8 +114,6 @@ frappe.Application = Class.extend({ | |||||
dialog.get_close_btn().toggle(false); | dialog.get_close_btn().toggle(false); | ||||
}); | }); | ||||
this.setup_user_group_listeners(); | |||||
// listen to build errors | // listen to build errors | ||||
this.setup_build_error_listener(); | this.setup_build_error_listener(); | ||||
@@ -476,14 +474,19 @@ frappe.Application = Class.extend({ | |||||
$('<link rel="icon" href="' + link + '" type="image/x-icon">').appendTo("head"); | $('<link rel="icon" href="' + link + '" type="image/x-icon">').appendTo("head"); | ||||
}, | }, | ||||
trigger_primary_action: function() { | trigger_primary_action: function() { | ||||
if(window.cur_dialog && cur_dialog.display) { | |||||
// trigger primary | |||||
cur_dialog.get_primary_btn().trigger("click"); | |||||
} else if(cur_frm && cur_frm.page.btn_primary.is(':visible')) { | |||||
cur_frm.page.btn_primary.trigger('click'); | |||||
} else if(frappe.container.page.save_action) { | |||||
frappe.container.page.save_action(); | |||||
} | |||||
// to trigger change event on active input before triggering primary action | |||||
$(document.activeElement).blur(); | |||||
// wait for possible JS validations triggered after blur (it might change primary button) | |||||
setTimeout(() => { | |||||
if (window.cur_dialog && cur_dialog.display) { | |||||
// trigger primary | |||||
cur_dialog.get_primary_btn().trigger("click"); | |||||
} else if (cur_frm && cur_frm.page.btn_primary.is(':visible')) { | |||||
cur_frm.page.btn_primary.trigger('click'); | |||||
} else if (frappe.container.page.save_action) { | |||||
frappe.container.page.save_action(); | |||||
} | |||||
}, 100); | |||||
}, | }, | ||||
set_rtl: function() { | set_rtl: function() { | ||||
@@ -593,15 +596,6 @@ frappe.Application = Class.extend({ | |||||
} | } | ||||
}, | }, | ||||
setup_user_group_listeners() { | |||||
frappe.realtime.on('user_group_added', (user_group) => { | |||||
frappe.boot.user_groups && frappe.boot.user_groups.push(user_group); | |||||
}); | |||||
frappe.realtime.on('user_group_deleted', (user_group) => { | |||||
frappe.boot.user_groups = (frappe.boot.user_groups || []).filter(el => el !== user_group); | |||||
}); | |||||
}, | |||||
setup_energy_point_listeners() { | setup_energy_point_listeners() { | ||||
frappe.realtime.on('energy_point_alert', (message) => { | frappe.realtime.on('energy_point_alert', (message) => { | ||||
frappe.show_alert(message); | frappe.show_alert(message); | ||||
@@ -319,7 +319,7 @@ frappe.get_data_pill = (label, target_id=null, remove_action=null, image=null) = | |||||
frappe.get_modal = function(title, content) { | frappe.get_modal = function(title, content) { | ||||
return $(`<div class="modal fade" style="overflow: auto;" tabindex="-1"> | return $(`<div class="modal fade" style="overflow: auto;" tabindex="-1"> | ||||
<div class="modal-dialog modal-dialog-scrollable"> | |||||
<div class="modal-dialog"> | |||||
<div class="modal-content"> | <div class="modal-content"> | ||||
<div class="modal-header"> | <div class="modal-header"> | ||||
<div class="fill-width flex title-section"> | <div class="fill-width flex title-section"> | ||||
@@ -90,16 +90,10 @@ frappe.ui.form.ControlAutocomplete = frappe.ui.form.ControlData.extend({ | |||||
}); | }); | ||||
this.$input.on("awesomplete-open", () => { | this.$input.on("awesomplete-open", () => { | ||||
this.toggle_container_scroll('.modal-dialog', 'modal-dialog-scrollable'); | |||||
this.toggle_container_scroll('.grid-form-body .form-area', 'scrollable'); | |||||
this.autocomplete_open = true; | this.autocomplete_open = true; | ||||
}); | }); | ||||
this.$input.on("awesomplete-close", () => { | this.$input.on("awesomplete-close", () => { | ||||
this.toggle_container_scroll('.modal-dialog', 'modal-dialog-scrollable', true); | |||||
this.toggle_container_scroll('.grid-form-body .form-area', 'scrollable', true); | |||||
this.autocomplete_open = false; | this.autocomplete_open = false; | ||||
}); | }); | ||||
@@ -78,35 +78,25 @@ frappe.ui.form.ControlComment = frappe.ui.form.ControlTextEditor.extend({ | |||||
}, | }, | ||||
get_mention_options() { | get_mention_options() { | ||||
if (!(this.mentions && this.mentions.length)) { | |||||
if (!this.enable_mentions) { | |||||
return null; | return null; | ||||
} | } | ||||
const at_values = this.mentions.slice(); | |||||
let me = this; | |||||
return { | return { | ||||
allowedChars: /^[A-Za-z0-9_]*$/, | allowedChars: /^[A-Za-z0-9_]*$/, | ||||
mentionDenotationChars: ["@"], | mentionDenotationChars: ["@"], | ||||
isolateCharacter: true, | isolateCharacter: true, | ||||
source: function (searchTerm, renderList, mentionChar) { | |||||
let values; | |||||
if (mentionChar === "@") { | |||||
values = at_values; | |||||
} | |||||
if (searchTerm.length === 0) { | |||||
renderList(values, searchTerm); | |||||
} else { | |||||
const matches = []; | |||||
for (let i = 0; i < values.length; i++) { | |||||
if (~values[i].value.toLowerCase().indexOf(searchTerm.toLowerCase())) { | |||||
matches.push(values[i]); | |||||
} | |||||
} | |||||
renderList(matches, searchTerm); | |||||
} | |||||
}, | |||||
source: frappe.utils.debounce(async function(search_term, renderList) { | |||||
let method = me.mention_search_method || 'frappe.desk.search.get_names_for_mentions'; | |||||
let values = await frappe.xcall(method, { | |||||
search_term | |||||
}); | |||||
renderList(values, search_term); | |||||
}, 300), | |||||
renderItem(item) { | |||||
let value = item.value; | |||||
return `${value} ${item.is_group ? frappe.utils.icon('users') : ''}`; | |||||
} | |||||
}; | }; | ||||
}, | }, | ||||
@@ -1,7 +1,7 @@ | |||||
frappe.ui.form.ControlCurrency = frappe.ui.form.ControlFloat.extend({ | frappe.ui.form.ControlCurrency = frappe.ui.form.ControlFloat.extend({ | ||||
format_for_input: function(value) { | format_for_input: function(value) { | ||||
var formatted_value = format_number(value, this.get_number_format(), this.get_precision()); | var formatted_value = format_number(value, this.get_number_format(), this.get_precision()); | ||||
return isNaN(parseFloat(value)) ? "" : formatted_value; | |||||
return isNaN(Number(value)) ? "" : formatted_value; | |||||
}, | }, | ||||
get_precision: function() { | get_precision: function() { | ||||
@@ -10,7 +10,7 @@ frappe.ui.form.ControlFloat = frappe.ui.form.ControlInt.extend({ | |||||
number_format = this.get_number_format(); | number_format = this.get_number_format(); | ||||
} | } | ||||
var formatted_value = format_number(value, number_format, this.get_precision()); | var formatted_value = format_number(value, number_format, this.get_precision()); | ||||
return isNaN(parseFloat(value)) ? "" : formatted_value; | |||||
return isNaN(Number(value)) ? "" : formatted_value; | |||||
}, | }, | ||||
get_number_format: function() { | get_number_format: function() { | ||||
@@ -241,16 +241,10 @@ frappe.ui.form.ControlLink = frappe.ui.form.ControlData.extend({ | |||||
}); | }); | ||||
this.$input.on("awesomplete-open", () => { | this.$input.on("awesomplete-open", () => { | ||||
this.toggle_container_scroll('.modal-dialog', 'modal-dialog-scrollable'); | |||||
this.toggle_container_scroll('.grid-form-body .form-area', 'scrollable'); | |||||
this.autocomplete_open = true; | this.autocomplete_open = true; | ||||
}); | }); | ||||
this.$input.on("awesomplete-close", () => { | this.$input.on("awesomplete-close", () => { | ||||
this.toggle_container_scroll('.modal-dialog', 'modal-dialog-scrollable', true); | |||||
this.toggle_container_scroll('.grid-form-body .form-area', 'scrollable', true); | |||||
this.autocomplete_open = false; | this.autocomplete_open = false; | ||||
}); | }); | ||||
@@ -12,6 +12,7 @@ class MentionBlot extends Embed { | |||||
denotationChar.innerHTML = data.denotationChar; | denotationChar.innerHTML = data.denotationChar; | ||||
node.appendChild(denotationChar); | node.appendChild(denotationChar); | ||||
node.innerHTML += data.value; | node.innerHTML += data.value; | ||||
node.innerHTML += `${data.isGroup === 'true' ? frappe.utils.icon('users') : ''}`; | |||||
node.dataset.id = data.id; | node.dataset.id = data.id; | ||||
node.dataset.value = data.value; | node.dataset.value = data.value; | ||||
node.dataset.denotationChar = data.denotationChar; | node.dataset.denotationChar = data.denotationChar; | ||||
@@ -24,7 +24,7 @@ frappe.ui.form.Footer = Class.extend({ | |||||
parent: this.wrapper.find(".comment-box"), | parent: this.wrapper.find(".comment-box"), | ||||
render_input: true, | render_input: true, | ||||
only_input: true, | only_input: true, | ||||
mentions: frappe.utils.get_names_for_mentions(), | |||||
enable_mentions: true, | |||||
df: { | df: { | ||||
fieldtype: 'Comment', | fieldtype: 'Comment', | ||||
fieldname: 'comment' | fieldname: 'comment' | ||||
@@ -492,7 +492,7 @@ class FormTimeline extends BaseTimeline { | |||||
fieldname: 'comment', | fieldname: 'comment', | ||||
label: 'Comment' | label: 'Comment' | ||||
}, | }, | ||||
mentions: frappe.utils.get_names_for_mentions(), | |||||
enable_mentions: true, | |||||
render_input: true, | render_input: true, | ||||
only_input: true, | only_input: true, | ||||
no_wrapper: true | no_wrapper: true | ||||
@@ -451,7 +451,7 @@ frappe.ui.form.Form = class FrappeForm { | |||||
return this.script_manager.trigger("onload_post_render"); | return this.script_manager.trigger("onload_post_render"); | ||||
} | } | ||||
}, | }, | ||||
() => this.is_new() && this.focus_on_first_input(), | |||||
() => this.cscript.is_onload && this.is_new() && this.focus_on_first_input(), | |||||
() => this.run_after_load_hook(), | () => this.run_after_load_hook(), | ||||
() => this.dashboard.after_refresh() | () => this.dashboard.after_refresh() | ||||
]); | ]); | ||||
@@ -7,7 +7,8 @@ export default class GridRow { | |||||
$.extend(this, opts); | $.extend(this, opts); | ||||
if (this.doc && this.parent_df.options) { | if (this.doc && this.parent_df.options) { | ||||
frappe.meta.make_docfield_copy_for(this.parent_df.options, this.doc.name, this.docfields); | frappe.meta.make_docfield_copy_for(this.parent_df.options, this.doc.name, this.docfields); | ||||
this.docfields = frappe.meta.get_docfields(this.parent_df.options, this.doc.name); | |||||
const docfields = frappe.meta.get_docfields(this.parent_df.options, this.doc.name); | |||||
this.docfields = docfields.length ? docfields : opts.docfields; | |||||
} | } | ||||
this.columns = {}; | this.columns = {}; | ||||
this.columns_list = []; | this.columns_list = []; | ||||
@@ -66,7 +66,7 @@ export default class GridRowForm { | |||||
</div> | </div> | ||||
</div> | </div> | ||||
<div class="grid-form-body"> | <div class="grid-form-body"> | ||||
<div class="form-area scrollable"></div> | |||||
<div class="form-area"></div> | |||||
<div class="grid-footer-toolbar hidden-xs flex justify-between"> | <div class="grid-footer-toolbar hidden-xs flex justify-between"> | ||||
<div class="grid-shortcuts"> | <div class="grid-shortcuts"> | ||||
<span> ${frappe.utils.icon("keyboard", "md")} </span> | <span> ${frappe.utils.icon("keyboard", "md")} </span> | ||||
@@ -32,7 +32,7 @@ frappe.ui.form.Toolbar = class Toolbar { | |||||
} | } | ||||
set_title() { | set_title() { | ||||
if (this.frm.is_new()) { | if (this.frm.is_new()) { | ||||
var title = __('New {0}', [this.frm.meta.name]); | |||||
var title = __('New {0}', [__(this.frm.meta.name)]); | |||||
} else if (this.frm.meta.title_field) { | } else if (this.frm.meta.title_field) { | ||||
let title_field = (this.frm.doc[this.frm.meta.title_field] || "").toString().trim(); | let title_field = (this.frm.doc[this.frm.meta.title_field] || "").toString().trim(); | ||||
var title = strip_html(title_field || this.frm.docname); | var title = strip_html(title_field || this.frm.docname); | ||||
@@ -551,7 +551,7 @@ frappe.ui.form.Toolbar = class Toolbar { | |||||
let fields = this.frm.fields | let fields = this.frm.fields | ||||
.filter(visible_fields_filter) | .filter(visible_fields_filter) | ||||
.map(f => ({ label: f.df.label, value: f.df.fieldname })); | |||||
.map(f => ({ label: __(f.df.label), value: f.df.fieldname })); | |||||
let dialog = new frappe.ui.Dialog({ | let dialog = new frappe.ui.Dialog({ | ||||
title: __('Jump to field'), | title: __('Jump to field'), | ||||
@@ -150,13 +150,13 @@ frappe.views.ListViewSelect = class ListViewSelect { | |||||
const views_wrapper = this.sidebar.sidebar.find(".views-section"); | const views_wrapper = this.sidebar.sidebar.find(".views-section"); | ||||
views_wrapper.find(".sidebar-label").html(`${__(view)}`); | views_wrapper.find(".sidebar-label").html(`${__(view)}`); | ||||
const $dropdown = views_wrapper.find(".views-dropdown"); | const $dropdown = views_wrapper.find(".views-dropdown"); | ||||
let placeholder = `Select ${view}`; | |||||
let placeholder = `${__("Select {0}", [__(view)])}`; | |||||
let html = ``; | let html = ``; | ||||
if (!items || !items.length) { | if (!items || !items.length) { | ||||
html = `<div class="empty-state"> | html = `<div class="empty-state"> | ||||
${__("No {} Found", [view])} | |||||
${__("No {0} Found", [__(view)])} | |||||
</div>`; | </div>`; | ||||
} else { | } else { | ||||
const page_name = this.get_page_name(); | const page_name = this.get_page_name(); | ||||
@@ -5,7 +5,7 @@ | |||||
<div class="tag-filters-area"> | <div class="tag-filters-area"> | ||||
<div class="active-tag-filters"> | <div class="active-tag-filters"> | ||||
<button class="btn btn-default btn-xs add-filter text-muted"> | <button class="btn btn-default btn-xs add-filter text-muted"> | ||||
Add Filter | |||||
{{ __("Add Filter") }} | |||||
</button> | </button> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -71,12 +71,12 @@ | |||||
</div> | </div> | ||||
<div v-if="requests.length == 0" class="no-result text-muted flex justify-center align-center" style=""> | <div v-if="requests.length == 0" class="no-result text-muted flex justify-center align-center" style=""> | ||||
<div class="msg-box no-border" v-if="status.status == 'Inactive'" > | <div class="msg-box no-border" v-if="status.status == 'Inactive'" > | ||||
<p>Recorder is Inactive</p> | |||||
<p><button class="btn btn-primary btn-sm btn-new-doc" @click="start()">Start Recording</button></p> | |||||
<p>{{ __("Recorder is Inactive") }}</p> | |||||
<p><button class="btn btn-primary btn-sm btn-new-doc" @click="start()">{{ __("Start Recording") }}</button></p> | |||||
</div> | </div> | ||||
<div class="msg-box no-border" v-if="status.status == 'Active'" > | <div class="msg-box no-border" v-if="status.status == 'Active'" > | ||||
<p>No Requests found</p> | |||||
<p>Go make some noise</p> | |||||
<p>{{ __("No Requests found") }}</p> | |||||
<p>{{ __("Go make some noise") }}</p> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
<div v-if="requests.length != 0" class="list-paging-area"> | <div v-if="requests.length != 0" class="list-paging-area"> | ||||
@@ -108,12 +108,12 @@ export default { | |||||
return { | return { | ||||
requests: [], | requests: [], | ||||
columns: [ | columns: [ | ||||
{label: "Path", slug: "path"}, | |||||
{label: "Duration (ms)", slug: "duration", sortable: true, number: true}, | |||||
{label: "Time in Queries (ms)", slug: "time_queries", sortable: true, number: true}, | |||||
{label: "Queries", slug: "queries", sortable: true, number: true}, | |||||
{label: "Method", slug: "method"}, | |||||
{label: "Time", slug: "time", sortable: true}, | |||||
{label: __("Path"), slug: "path"}, | |||||
{label: __("Duration (ms)"), slug: "duration", sortable: true, number: true}, | |||||
{label: __("Time in Queries (ms)"), slug: "time_queries", sortable: true, number: true}, | |||||
{label: __("Queries"), slug: "queries", sortable: true, number: true}, | |||||
{label: __("Method"), slug: "method"}, | |||||
{label: __("Time"), slug: "time", sortable: true}, | |||||
], | ], | ||||
query: { | query: { | ||||
sort: "duration", | sort: "duration", | ||||
@@ -140,7 +140,7 @@ export default { | |||||
mounted() { | mounted() { | ||||
this.fetch_status(); | this.fetch_status(); | ||||
this.refresh(); | this.refresh(); | ||||
this.$root.page.set_secondary_action("Clear", () => { | |||||
this.$root.page.set_secondary_action(__("Clear"), () => { | |||||
frappe.set_route("recorder"); | frappe.set_route("recorder"); | ||||
this.clear(); | this.clear(); | ||||
}); | }); | ||||
@@ -151,11 +151,11 @@ export default { | |||||
const current_page = this.query.pagination.page; | const current_page = this.query.pagination.page; | ||||
const total_pages = this.query.pagination.total; | const total_pages = this.query.pagination.total; | ||||
return [{ | return [{ | ||||
label: "First", | |||||
label: __("First"), | |||||
number: 1, | number: 1, | ||||
status: (current_page == 1) ? "disabled" : "", | status: (current_page == 1) ? "disabled" : "", | ||||
},{ | },{ | ||||
label: "Previous", | |||||
label: __("Previous"), | |||||
number: Math.max(current_page - 1, 1), | number: Math.max(current_page - 1, 1), | ||||
status: (current_page == 1) ? "disabled" : "", | status: (current_page == 1) ? "disabled" : "", | ||||
}, { | }, { | ||||
@@ -163,11 +163,11 @@ export default { | |||||
number: current_page, | number: current_page, | ||||
status: "btn-info", | status: "btn-info", | ||||
}, { | }, { | ||||
label: "Next", | |||||
label: __("Next"), | |||||
number: Math.min(current_page + 1, total_pages), | number: Math.min(current_page + 1, total_pages), | ||||
status: (current_page == total_pages) ? "disabled" : "", | status: (current_page == total_pages) ? "disabled" : "", | ||||
}, { | }, { | ||||
label: "Last", | |||||
label: __("Last"), | |||||
number: total_pages, | number: total_pages, | ||||
status: (current_page == total_pages) ? "disabled" : "", | status: (current_page == total_pages) ? "disabled" : "", | ||||
}]; | }]; | ||||
@@ -230,11 +230,11 @@ export default { | |||||
}, | }, | ||||
update_buttons: function() { | update_buttons: function() { | ||||
if(this.status.status == "Active") { | if(this.status.status == "Active") { | ||||
this.$root.page.set_primary_action("Stop", () => { | |||||
this.$root.page.set_primary_action(__("Stop"), () => { | |||||
this.stop(); | this.stop(); | ||||
}); | }); | ||||
} else { | } else { | ||||
this.$root.page.set_primary_action("Start", () => { | |||||
this.$root.page.set_primary_action(__("Start"), () => { | |||||
this.start(); | this.start(); | ||||
}); | }); | ||||
} | } | ||||
@@ -16,7 +16,7 @@ | |||||
</div> | </div> | ||||
<div class="row form-section visible-section"> | <div class="row form-section visible-section"> | ||||
<div class="col-sm-10"> | <div class="col-sm-10"> | ||||
<h6 class="form-section-heading uppercase">SQL Queries</h6> | |||||
<h6 class="form-section-heading uppercase">{{ __("SQL Queries") }}</h6> | |||||
</div> | </div> | ||||
<div class="col-sm-2 filter-list"> | <div class="col-sm-2 filter-list"> | ||||
<div class="sort-selector"> | <div class="sort-selector"> | ||||
@@ -37,7 +37,7 @@ | |||||
<div class="checkbox"> | <div class="checkbox"> | ||||
<label> | <label> | ||||
<span class="input-area"><input type="checkbox" class="input-with-feedback bold" data-fieldtype="Check" v-model="group_duplicates"></span> | <span class="input-area"><input type="checkbox" class="input-with-feedback bold" data-fieldtype="Check" v-model="group_duplicates"></span> | ||||
<span class="label-area small">Group Duplicate Queries</span> | |||||
<span class="label-area small">{{ __("Group Duplicate Queries") }}</span> | |||||
</label> | </label> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -48,15 +48,15 @@ | |||||
<div class="grid-row"> | <div class="grid-row"> | ||||
<div class="data-row row"> | <div class="data-row row"> | ||||
<div class="row-index col col-xs-1"> | <div class="row-index col col-xs-1"> | ||||
<span>Index</span></div> | |||||
<span>{{ __("Index") }}</span></div> | |||||
<div class="col grid-static-col col-xs-6"> | <div class="col grid-static-col col-xs-6"> | ||||
<div class="static-area ellipsis">Query</div> | |||||
<div class="static-area ellipsis">{{ __("Query") }}</div> | |||||
</div> | </div> | ||||
<div class="col grid-static-col col-xs-2"> | <div class="col grid-static-col col-xs-2"> | ||||
<div class="static-area ellipsis text-right">Duration (ms)</div> | |||||
<div class="static-area ellipsis text-right">{{ __("Duration (ms)") }}</div> | |||||
</div> | </div> | ||||
<div class="col grid-static-col col-xs-2"> | <div class="col grid-static-col col-xs-2"> | ||||
<div class="static-area ellipsis text-right">Exact Copies</div> | |||||
<div class="static-area ellipsis text-right">{{ __("Exact Copies") }}</div> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -82,7 +82,7 @@ | |||||
<div class="recorder-form-in-grid" v-if="showing == call.index"> | <div class="recorder-form-in-grid" v-if="showing == call.index"> | ||||
<div class="grid-form-heading" @click="showing = null"> | <div class="grid-form-heading" @click="showing = null"> | ||||
<div class="toolbar grid-header-toolbar"> | <div class="toolbar grid-header-toolbar"> | ||||
<span class="panel-title">SQL Query #<span class="grid-form-row-index">{{ call.index }}</span></span> | |||||
<span class="panel-title">{{ __("SQL Query") }} #<span class="grid-form-row-index">{{ call.index }}</span></span> | |||||
<div class="btn btn-default btn-xs pull-right" style="margin-left: 7px;"> | <div class="btn btn-default btn-xs pull-right" style="margin-left: 7px;"> | ||||
<span class="hidden-xs octicon octicon-triangle-up"></span> | <span class="hidden-xs octicon octicon-triangle-up"></span> | ||||
</div> | </div> | ||||
@@ -98,25 +98,25 @@ | |||||
<form> | <form> | ||||
<div class="frappe-control"> | <div class="frappe-control"> | ||||
<div class="form-group"> | <div class="form-group"> | ||||
<div class="clearfix"><label class="control-label">Query</label></div> | |||||
<div class="clearfix"><label class="control-label">{{ __("Query") }}</label></div> | |||||
<div class="control-value like-disabled-input for-description"><pre>{{ call.query }}</pre></div> | <div class="control-value like-disabled-input for-description"><pre>{{ call.query }}</pre></div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
<div class="frappe-control input-max-width"> | <div class="frappe-control input-max-width"> | ||||
<div class="form-group"> | <div class="form-group"> | ||||
<div class="clearfix"><label class="control-label">Duration (ms)</label></div> | |||||
<div class="clearfix"><label class="control-label">{{ __("Duration (ms)") }}</label></div> | |||||
<div class="control-value like-disabled-input">{{ call.duration }}</div> | <div class="control-value like-disabled-input">{{ call.duration }}</div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
<div class="frappe-control input-max-width"> | <div class="frappe-control input-max-width"> | ||||
<div class="form-group"> | <div class="form-group"> | ||||
<div class="clearfix"><label class="control-label">Exact Copies</label></div> | |||||
<div class="clearfix"><label class="control-label">{{ __("Exact Copies") }}</label></div> | |||||
<div class="control-value like-disabled-input">{{ call.exact_copies }}</div> | <div class="control-value like-disabled-input">{{ call.exact_copies }}</div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
<div class="frappe-control"> | <div class="frappe-control"> | ||||
<div class="form-group"> | <div class="form-group"> | ||||
<div class="clearfix"><label class="control-label">Stack Trace</label></div> | |||||
<div class="clearfix"><label class="control-label"{{ __("Stack Trace") }}</label></div> | |||||
<div class="control-value like-disabled-input for-description" style="overflow:auto"> | <div class="control-value like-disabled-input for-description" style="overflow:auto"> | ||||
<table class="table table-striped"> | <table class="table table-striped"> | ||||
<thead> | <thead> | ||||
@@ -137,7 +137,7 @@ | |||||
</div> | </div> | ||||
<div class="frappe-control" v-if="call.explain_result[0]"> | <div class="frappe-control" v-if="call.explain_result[0]"> | ||||
<div class="form-group"> | <div class="form-group"> | ||||
<div class="clearfix"><label class="control-label">SQL Explain</label></div> | |||||
<div class="clearfix"><label class="control-label">{{ __("SQL Explain") }}</label></div> | |||||
<div class="control-value like-disabled-input for-description" style="overflow:auto"> | <div class="control-value like-disabled-input for-description" style="overflow:auto"> | ||||
<table class="table table-striped"> | <table class="table table-striped"> | ||||
<thead> | <thead> | ||||
@@ -165,7 +165,7 @@ | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
<div v-if="request.calls.length == 0" class="grid-empty text-center">No Data</div> | |||||
<div v-if="request.calls.length == 0" class="grid-empty text-center">{{ __("No Data") }}</div> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -201,19 +201,19 @@ export default { | |||||
data() { | data() { | ||||
return { | return { | ||||
columns: [ | columns: [ | ||||
{label: "Path", slug: "path", type: "Data", class: "col-sm-6"}, | |||||
{label: "CMD", slug: "cmd", type: "Data", class: "col-sm-6"}, | |||||
{label: "Time", slug: "time", type: "Time", class: "col-sm-6"}, | |||||
{label: "Duration (ms)", slug: "duration", type: "Float", class: "col-sm-6"}, | |||||
{label: "Number of Queries", slug: "queries", type: "Int", class: "col-sm-6"}, | |||||
{label: "Time in Queries (ms)", slug: "time_queries", type: "Float", class: "col-sm-6"}, | |||||
{label: "Request Headers", slug: "headers", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"}, | |||||
{label: "Form Dict", slug: "form_dict", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"}, | |||||
{label: __("Path"), slug: "path", type: "Data", class: "col-sm-6"}, | |||||
{label: __("CMD"), slug: "cmd", type: "Data", class: "col-sm-6"}, | |||||
{label: __("Time"), slug: "time", type: "Time", class: "col-sm-6"}, | |||||
{label: __("Duration (ms)"), slug: "duration", type: "Float", class: "col-sm-6"}, | |||||
{label: __("Number of Queries"), slug: "queries", type: "Int", class: "col-sm-6"}, | |||||
{label: __("Time in Queries (ms)"), slug: "time_queries", type: "Float", class: "col-sm-6"}, | |||||
{label: __("Request Headers"), slug: "headers", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"}, | |||||
{label: __("Form Dict"), slug: "form_dict", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"}, | |||||
], | ], | ||||
table_columns: [ | table_columns: [ | ||||
{label: "Execution Order", slug: "index", sortable: true}, | |||||
{label: "Duration (ms)", slug: "duration", sortable: true}, | |||||
{label: "Exact Copies", slug: "exact_copies", sortable: true}, | |||||
{label: __("Execution Order"), slug: "index", sortable: true}, | |||||
{label: __("Duration (ms)"), slug: "duration", sortable: true}, | |||||
{label: __("Exact Copies"), slug: "exact_copies", sortable: true}, | |||||
], | ], | ||||
query: { | query: { | ||||
sort: "duration", | sort: "duration", | ||||
@@ -236,11 +236,11 @@ export default { | |||||
const current_page = this.query.pagination.page; | const current_page = this.query.pagination.page; | ||||
const total_pages = this.query.pagination.total; | const total_pages = this.query.pagination.total; | ||||
return [{ | return [{ | ||||
label: "First", | |||||
label: __("First"), | |||||
number: 1, | number: 1, | ||||
status: (current_page == 1) ? "disabled" : "", | status: (current_page == 1) ? "disabled" : "", | ||||
},{ | },{ | ||||
label: "Previous", | |||||
label: __("Previous"), | |||||
number: Math.max(current_page - 1, 1), | number: Math.max(current_page - 1, 1), | ||||
status: (current_page == 1) ? "disabled" : "", | status: (current_page == 1) ? "disabled" : "", | ||||
}, { | }, { | ||||
@@ -248,11 +248,11 @@ export default { | |||||
number: current_page, | number: current_page, | ||||
status: "btn-info", | status: "btn-info", | ||||
}, { | }, { | ||||
label: "Next", | |||||
label: __("Next"), | |||||
number: Math.min(current_page + 1, total_pages), | number: Math.min(current_page + 1, total_pages), | ||||
status: (current_page == total_pages) ? "disabled" : "", | status: (current_page == total_pages) ? "disabled" : "", | ||||
}, { | }, { | ||||
label: "Last", | |||||
label: __("Last"), | |||||
number: total_pages, | number: total_pages, | ||||
status: (current_page == total_pages) ? "disabled" : "", | status: (current_page == total_pages) ? "disabled" : "", | ||||
}]; | }]; | ||||
@@ -6,6 +6,9 @@ import RecorderRoot from "./RecorderRoot.vue"; | |||||
import RecorderDetail from "./RecorderDetail.vue"; | import RecorderDetail from "./RecorderDetail.vue"; | ||||
import RequestDetail from "./RequestDetail.vue"; | import RequestDetail from "./RequestDetail.vue"; | ||||
Vue.prototype.__ = window.__; | |||||
Vue.prototype.frappe = window.frappe; | |||||
Vue.use(VueRouter); | Vue.use(VueRouter); | ||||
const routes = [ | const routes = [ | ||||
{ | { | ||||
@@ -36,18 +36,6 @@ frappe.ui.FieldSelect = Class.extend({ | |||||
var item = me.awesomplete.get_item(value); | var item = me.awesomplete.get_item(value); | ||||
me.$input.val(item.label); | me.$input.val(item.label); | ||||
}); | }); | ||||
this.$input.on("awesomplete-open", () => { | |||||
let modal = this.$input.parents('.modal-dialog')[0]; | |||||
if (modal) { | |||||
$(modal).removeClass("modal-dialog-scrollable"); | |||||
} | |||||
}); | |||||
this.$input.on("awesomplete-close", () => { | |||||
let modal = this.$input.parents('.modal-dialog')[0]; | |||||
if (modal) { | |||||
$(modal).addClass("modal-dialog-scrollable"); | |||||
} | |||||
}); | |||||
if(this.filter_fields) { | if(this.filter_fields) { | ||||
for(var i in this.filter_fields) | for(var i in this.filter_fields) | ||||
@@ -312,7 +312,7 @@ class NotificationsView extends BaseNotificationsView { | |||||
this.container.append($(`<div class="notification-null-state"> | this.container.append($(`<div class="notification-null-state"> | ||||
<div class="text-center"> | <div class="text-center"> | ||||
<img src="/assets/frappe/images/ui-states/notification-empty-state.svg" alt="Generic Empty State" class="null-state"> | <img src="/assets/frappe/images/ui-states/notification-empty-state.svg" alt="Generic Empty State" class="null-state"> | ||||
<div class="title">No New notifications</div> | |||||
<div class="title">${__('No New notifications')}</div> | |||||
<div class="subtitle"> | <div class="subtitle"> | ||||
${__('Looks like you haven’t received any notifications.')} | ${__('Looks like you haven’t received any notifications.')} | ||||
</div></div></div>`)); | </div></div></div>`)); | ||||
@@ -430,7 +430,7 @@ class EventsView extends BaseNotificationsView { | |||||
<div class="notification-null-state"> | <div class="notification-null-state"> | ||||
<div class="text-center"> | <div class="text-center"> | ||||
<img src="/assets/frappe/images/ui-states/event-empty-state.svg" alt="Generic Empty State" class="null-state"> | <img src="/assets/frappe/images/ui-states/event-empty-state.svg" alt="Generic Empty State" class="null-state"> | ||||
<div class="title">No Upcoming Events</div> | |||||
<div class="title">${__('No Upcoming Events')}</div> | |||||
<div class="subtitle"> | <div class="subtitle"> | ||||
${__('There are no upcoming events for you.')} | ${__('There are no upcoming events for you.')} | ||||
</div></div></div> | </div></div></div> | ||||
@@ -11,6 +11,34 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { | |||||
title: __("Switch Theme") | title: __("Switch Theme") | ||||
}); | }); | ||||
this.body = $(`<div class="theme-grid"></div>`).appendTo(this.dialog.$body); | this.body = $(`<div class="theme-grid"></div>`).appendTo(this.dialog.$body); | ||||
this.bind_events(); | |||||
} | |||||
bind_events() { | |||||
this.dialog.$wrapper.on('keydown', (e) => { | |||||
if (!this.themes) return; | |||||
const key = frappe.ui.keys.get_key(e); | |||||
let increment_by; | |||||
if (key === "right") { | |||||
increment_by = 1; | |||||
} else if (key === "left") { | |||||
increment_by = -1; | |||||
} else { | |||||
return; | |||||
} | |||||
const current_index = this.themes.findIndex(theme => { | |||||
return theme.name === this.current_theme; | |||||
}); | |||||
const new_theme = this.themes[current_index + increment_by]; | |||||
if (!new_theme) return; | |||||
new_theme.$html.click(); | |||||
return false; | |||||
}); | |||||
} | } | ||||
refresh() { | refresh() { | ||||
@@ -1272,31 +1272,6 @@ Object.assign(frappe.utils, { | |||||
</div>`); | </div>`); | ||||
}, | }, | ||||
get_names_for_mentions() { | |||||
let names_for_mentions = Object.keys(frappe.boot.user_info || []) | |||||
.filter(user => { | |||||
return !["Administrator", "Guest"].includes(user) | |||||
&& frappe.boot.user_info[user].allowed_in_mentions | |||||
&& frappe.boot.user_info[user].user_type === 'System User'; | |||||
}) | |||||
.map(user => { | |||||
return { | |||||
id: frappe.boot.user_info[user].name, | |||||
value: frappe.boot.user_info[user].fullname, | |||||
}; | |||||
}); | |||||
frappe.boot.user_groups && frappe.boot.user_groups.map(group => { | |||||
names_for_mentions.push({ | |||||
id: group, | |||||
value: group, | |||||
is_group: true, | |||||
link: frappe.utils.get_form_link('User Group', group) | |||||
}); | |||||
}); | |||||
return names_for_mentions; | |||||
}, | |||||
print(doctype, docname, print_format, letterhead, lang_code) { | print(doctype, docname, print_format, letterhead, lang_code) { | ||||
let w = window.open( | let w = window.open( | ||||
frappe.urllib.get_full_url( | frappe.urllib.get_full_url( | ||||
@@ -68,6 +68,8 @@ frappe.breadcrumbs = { | |||||
if (breadcrumbs.doctype && ["print", "form"].includes(view)) { | if (breadcrumbs.doctype && ["print", "form"].includes(view)) { | ||||
this.set_list_breadcrumb(breadcrumbs); | this.set_list_breadcrumb(breadcrumbs); | ||||
this.set_form_breadcrumb(breadcrumbs, view); | this.set_form_breadcrumb(breadcrumbs, view); | ||||
} else if (breadcrumbs.doctype && view === 'list') { | |||||
this.set_list_breadcrumb(breadcrumbs); | |||||
} | } | ||||
} | } | ||||
@@ -8,7 +8,7 @@ frappe.views.DashboardView = class DashboardView extends frappe.views.ListView { | |||||
setup_defaults() { | setup_defaults() { | ||||
return super.setup_defaults() | return super.setup_defaults() | ||||
.then(() => { | .then(() => { | ||||
this.page_title = __('{0} Dashboard', [this.doctype]); | |||||
this.page_title = __('{0} Dashboard', [__(this.doctype)]); | |||||
this.dashboard_settings = frappe.get_user_settings(this.doctype)['dashboard_settings'] || null; | this.dashboard_settings = frappe.get_user_settings(this.doctype)['dashboard_settings'] || null; | ||||
}); | }); | ||||
} | } | ||||
@@ -271,7 +271,7 @@ frappe.views.DashboardView = class DashboardView extends frappe.views.ListView { | |||||
show_add_chart_dialog() { | show_add_chart_dialog() { | ||||
let fields = this.get_field_options(); | let fields = this.get_field_options(); | ||||
const dialog = new frappe.ui.Dialog({ | const dialog = new frappe.ui.Dialog({ | ||||
title: __("Add a {0} Chart", [this.doctype]), | |||||
title: __("Add a {0} Chart", [__(this.doctype)]), | |||||
fields: [ | fields: [ | ||||
{ | { | ||||
fieldname: 'new_or_existing', | fieldname: 'new_or_existing', | ||||
@@ -1,3 +1,5 @@ | |||||
// TODO: Refactor for better UX | |||||
frappe.provide("frappe.views"); | frappe.provide("frappe.views"); | ||||
(function() { | (function() { | ||||
@@ -185,7 +187,7 @@ frappe.provide("frappe.views"); | |||||
new_index: card.new_index, | new_index: card.new_index, | ||||
}; | }; | ||||
} | } | ||||
frappe.dom.freeze(); | |||||
frappe.call({ | frappe.call({ | ||||
method: method_prefix + method_name, | method: method_prefix + method_name, | ||||
args: args, | args: args, | ||||
@@ -198,6 +200,7 @@ frappe.provide("frappe.views"); | |||||
cards: cards, | cards: cards, | ||||
columns: columns | columns: columns | ||||
}); | }); | ||||
frappe.dom.unfreeze(); | |||||
} | } | ||||
}).fail(function() { | }).fail(function() { | ||||
// revert original order | // revert original order | ||||
@@ -205,6 +208,7 @@ frappe.provide("frappe.views"); | |||||
cards: _cards, | cards: _cards, | ||||
columns: _columns | columns: _columns | ||||
}); | }); | ||||
frappe.dom.unfreeze(); | |||||
}); | }); | ||||
}, | }, | ||||
update_order: function(updater) { | update_order: function(updater) { | ||||
@@ -335,12 +335,12 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { | |||||
let message; | let message; | ||||
if (dashboard_name) { | if (dashboard_name) { | ||||
let dashboard_route_html = `<a href="#dashboard-view/${dashboard_name}">${dashboard_name}</a>`; | let dashboard_route_html = `<a href="#dashboard-view/${dashboard_name}">${dashboard_name}</a>`; | ||||
message = __("New {0} {1} added to Dashboard {2}", [doctype, name, dashboard_route_html]); | |||||
message = __("New {0} {1} added to Dashboard {2}", [__(doctype), name, dashboard_route_html]); | |||||
} else { | } else { | ||||
message = __("New {0} {1} created", [doctype, name]); | |||||
message = __("New {0} {1} created", [__(doctype), name]); | |||||
} | } | ||||
frappe.msgprint(message, __("New {0} Created", [doctype])); | |||||
frappe.msgprint(message, __("New {0} Created", [__(doctype)])); | |||||
}); | }); | ||||
} | } | ||||
@@ -937,7 +937,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { | |||||
else { | else { | ||||
wrapper[0].innerHTML = | wrapper[0].innerHTML = | ||||
`<div class="flex justify-center align-center text-muted" style="height: 120px; display: flex;"> | `<div class="flex justify-center align-center text-muted" style="height: 120px; display: flex;"> | ||||
<div>Please select X and Y fields</div> | |||||
<div>${__("Please select X and Y fields")}</div> | |||||
</div>`; | </div>`; | ||||
} | } | ||||
} | } | ||||
@@ -1094,7 +1094,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { | |||||
return Object.assign(column, { | return Object.assign(column, { | ||||
id: column.fieldname, | id: column.fieldname, | ||||
name: __(column.label), | |||||
name: __(column.label, null, `Column of report '${this.report_name}'`), // context has to match context in get_messages_from_report in translate.py | |||||
width: parseInt(column.width) || null, | width: parseInt(column.width) || null, | ||||
editable: false, | editable: false, | ||||
compareValue: compareFn, | compareValue: compareFn, | ||||
@@ -1343,7 +1343,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { | |||||
open_url_post(frappe.request.url, args); | open_url_post(frappe.request.url, args); | ||||
} | } | ||||
}, __('Export Report: '+ this.report_name), __('Download')); | |||||
}, __('Export Report: {0}', [this.report_name]), __('Download')); | |||||
} | } | ||||
get_data_for_csv(include_indentation) { | get_data_for_csv(include_indentation) { | ||||
@@ -87,11 +87,13 @@ export default class WebForm extends frappe.ui.FieldGroup { | |||||
} | } | ||||
setup_delete_button() { | setup_delete_button() { | ||||
this.add_button_to_header( | |||||
frappe.utils.icon('delete'), | |||||
"danger", | |||||
() => this.delete() | |||||
); | |||||
frappe.has_permission(this.doc_type, "", "delete", () => { | |||||
this.add_button_to_header( | |||||
frappe.utils.icon('delete'), | |||||
"danger", | |||||
() => this.delete() | |||||
); | |||||
}); | |||||
} | } | ||||
setup_print_button() { | setup_print_button() { | ||||
@@ -190,9 +190,11 @@ export default class WebFormList { | |||||
make_actions() { | make_actions() { | ||||
const actions = document.querySelector(".list-view-actions"); | const actions = document.querySelector(".list-view-actions"); | ||||
this.addButton(actions, "delete-rows", "danger", true, "Delete", () => | |||||
this.delete_rows() | |||||
); | |||||
frappe.has_permission(this.doctype, "", "delete", () => { | |||||
this.addButton(actions, "delete-rows", "danger", true, "Delete", () => | |||||
this.delete_rows() | |||||
); | |||||
}); | |||||
this.addButton( | this.addButton( | ||||
actions, | actions, | ||||
@@ -2,7 +2,6 @@ import Widget from "./base_widget.js"; | |||||
frappe.provide("frappe.utils"); | frappe.provide("frappe.utils"); | ||||
const INDICATOR_COLORS = ["Grey", "Green", "Red", "Orange", "Pink", "Yellow", "Blue", "Cyan", "Teal"]; | |||||
export default class ShortcutWidget extends Widget { | export default class ShortcutWidget extends Widget { | ||||
constructor(opts) { | constructor(opts) { | ||||
opts.shadow = true; | opts.shadow = true; | ||||
@@ -79,7 +78,7 @@ export default class ShortcutWidget extends Widget { | |||||
this.action_area.empty(); | this.action_area.empty(); | ||||
const label = get_label(); | const label = get_label(); | ||||
let color = INDICATOR_COLORS.includes(this.color) && count ? this.color.toLowerCase() : 'gray'; | |||||
let color = this.color && count ? this.color.toLowerCase() : 'gray'; | |||||
$(`<div class="indicator-pill ellipsis ${color}">${label}</div>`).appendTo(this.action_area); | $(`<div class="indicator-pill ellipsis ${color}">${label}</div>`).appendTo(this.action_area); | ||||
} | } | ||||
} | } |
@@ -237,9 +237,19 @@ class ShortcutDialog extends WidgetDialog { | |||||
hidden: 1, | hidden: 1, | ||||
}, | }, | ||||
{ | { | ||||
fieldtype: "Color", | |||||
fieldtype: "Select", | |||||
fieldname: "color", | fieldname: "color", | ||||
label: __("Color"), | label: __("Color"), | ||||
options: ["Grey", "Green", "Red", "Orange", "Pink", "Yellow", "Blue", "Cyan"], | |||||
default: "Grey", | |||||
onchange: () => { | |||||
let color = this.dialog.fields_dict.color.value.toLowerCase(); | |||||
let $select = this.dialog.fields_dict.color.$input; | |||||
if (!$select.parent().find('.color-box').get(0)) { | |||||
$(`<div class="color-box"></div>`).insertBefore($select.get(0)); | |||||
} | |||||
$select.parent().find('.color-box').get(0).style.backgroundColor = `var(--text-on-${color})`; | |||||
} | |||||
}, | }, | ||||
{ | { | ||||
fieldtype: "Column Break", | fieldtype: "Column Break", | ||||
@@ -24,6 +24,17 @@ | |||||
--blue-100: #D3E9FC; | --blue-100: #D3E9FC; | ||||
--blue-50 : #F0F8FE; | --blue-50 : #F0F8FE; | ||||
--cyan-900: #006464; | |||||
--cyan-800: #007272; | |||||
--cyan-700: #008b8b; | |||||
--cyan-600: #02c5c5; | |||||
--cyan-500: #00ffff; | |||||
--cyan-400: #2ef8f8; | |||||
--cyan-300: #6efcfc; | |||||
--cyan-200: #a0f8f8; | |||||
--cyan-100: #c7fcfc; | |||||
--cyan-50 : #dafafa; | |||||
--green-900: #2D401D; | --green-900: #2D401D; | ||||
--green-800: #44622A; | --green-800: #44622A; | ||||
--green-700: #518B21; | --green-700: #518B21; | ||||
@@ -151,6 +162,8 @@ | |||||
--bg-gray: var(--gray-200); | --bg-gray: var(--gray-200); | ||||
--bg-light-gray: var(--gray-100); | --bg-light-gray: var(--gray-100); | ||||
--bg-purple: var(--purple-100); | --bg-purple: var(--purple-100); | ||||
--bg-pink: var(--pink-50); | |||||
--bg-cyan: var(--cyan-50); | |||||
--text-on-blue: var(--blue-600); | --text-on-blue: var(--blue-600); | ||||
--text-on-light-blue: var(--blue-500); | --text-on-light-blue: var(--blue-500); | ||||
@@ -163,6 +176,8 @@ | |||||
--text-on-gray: var(--gray-600); | --text-on-gray: var(--gray-600); | ||||
--text-on-light-gray: var(--gray-800); | --text-on-light-gray: var(--gray-800); | ||||
--text-on-purple: var(--purple-500); | --text-on-purple: var(--purple-500); | ||||
--text-on-pink: var(--pink-500); | |||||
--text-on-cyan: var(--cyan-600); | |||||
--awesomplete-hover-bg: var(--control-bg); | --awesomplete-hover-bg: var(--control-bg); | ||||
@@ -76,6 +76,22 @@ input[type="checkbox"] { | |||||
@include card(); | @include card(); | ||||
} | } | ||||
.frappe-control[data-fieldtype="Select"].frappe-control[data-fieldname="color"] { | |||||
select { | |||||
padding-left: 40px; | |||||
} | |||||
.color-box { | |||||
position: absolute; | |||||
top: calc(50% - 11px); | |||||
left: 8px; | |||||
width: 22px; | |||||
height: 22px; | |||||
border-radius: 5px; | |||||
z-index: 1; | |||||
} | |||||
} | |||||
.frappe-control[data-fieldtype="Select"] .control-input, | .frappe-control[data-fieldtype="Select"] .control-input, | ||||
.frappe-control[data-fieldtype="Select"].form-group { | .frappe-control[data-fieldtype="Select"].form-group { | ||||
position: relative; | position: relative; | ||||
@@ -77,6 +77,16 @@ | |||||
@include indicator-pill-color('green'); | @include indicator-pill-color('green'); | ||||
} | } | ||||
.indicator.cyan { | |||||
@include indicator-color('cyan'); | |||||
} | |||||
.indicator-pill.cyan, | |||||
.indicator-pill-right.cyan, | |||||
.indicator-pill-round.cyan { | |||||
@include indicator-pill-color('cyan'); | |||||
} | |||||
.indicator.blue { | .indicator.blue { | ||||
@include indicator-color('blue'); | @include indicator-color('blue'); | ||||
} | } | ||||
@@ -131,6 +141,16 @@ | |||||
@include indicator-pill-color('red'); | @include indicator-pill-color('red'); | ||||
} | } | ||||
.indicator.pink { | |||||
@include indicator-color('pink'); | |||||
} | |||||
.indicator-pill.pink, | |||||
.indicator-pill-right.pink, | |||||
.indicator-pill-round.pink { | |||||
@include indicator-pill-color('pink'); | |||||
} | |||||
.indicator-pill.darkgrey, | .indicator-pill.darkgrey, | ||||
.indicator-pill-right.darkgrey, | .indicator-pill-right.darkgrey, | ||||
.indicator-pill-round.darkgrey { | .indicator-pill-round.darkgrey { | ||||
@@ -2,25 +2,50 @@ h5.modal-title { | |||||
margin: 0px !important; | margin: 0px !important; | ||||
} | } | ||||
body.modal-open { | |||||
overflow: auto; | |||||
height: auto; | |||||
min-height: 100%; | |||||
// Hack to fix incorrect padding applied by Bootstrap | |||||
body.modal-open[style^="padding-right"] { | |||||
padding-right: 12px !important; | |||||
header.navbar { | |||||
padding-right: 12px !important; | |||||
margin-right: -12px !important; | |||||
} | |||||
} | } | ||||
.modal { | .modal { | ||||
// Same scrollbar as body | |||||
scrollbar-width: auto; | |||||
&::-webkit-scrollbar { | |||||
width: 12px; | |||||
height: 12px; | |||||
} | |||||
// Hide scrollbar on touch devices | |||||
@media(max-width: 991px) { | |||||
scrollbar-width: none; | |||||
&::-webkit-scrollbar { | |||||
width: 0; | |||||
height: 0; | |||||
} | |||||
} | |||||
.modal-content { | .modal-content { | ||||
border-color: var(--border-color); | border-color: var(--border-color); | ||||
} | } | ||||
.modal-header { | .modal-header { | ||||
position: sticky; | |||||
top: 0; | |||||
z-index: 3; | |||||
background: inherit; | |||||
padding: var(--padding-md) var(--padding-lg); | padding: var(--padding-md) var(--padding-lg); | ||||
padding-bottom: 0; | |||||
border-bottom: 0; | |||||
// padding-bottom: 0; | |||||
border-bottom: 1px solid var(--border-color); | |||||
.modal-title { | .modal-title { | ||||
font-weight: 500; | font-weight: 500; | ||||
line-height: 2em; | line-height: 2em; | ||||
font-size: $font-size-lg; | font-size: $font-size-lg; | ||||
max-width: calc(100% - 80px); | |||||
} | } | ||||
.modal-actions { | .modal-actions { | ||||
@@ -60,9 +85,17 @@ body.modal-open { | |||||
} | } | ||||
} | } | ||||
.awesomplete ul { | |||||
z-index: 2; | |||||
} | |||||
.modal-footer { | .modal-footer { | ||||
position: sticky; | |||||
bottom: 0; | |||||
z-index: 1; | |||||
background: inherit; | |||||
padding: var(--padding-md) var(--padding-lg); | padding: var(--padding-md) var(--padding-lg); | ||||
border-top: 0; | |||||
border-top: 1px solid var(--border-color); | |||||
justify-content: space-between; | justify-content: space-between; | ||||
button { | button { | ||||
@@ -105,6 +105,7 @@ | |||||
padding: 10px 12px; | padding: 10px 12px; | ||||
height: initial; | height: initial; | ||||
line-height: initial; | line-height: initial; | ||||
cursor: pointer; | |||||
&.selected { | &.selected { | ||||
background-color: var(--control-bg); | background-color: var(--control-bg); | ||||
@@ -163,7 +164,7 @@ | |||||
} | } | ||||
.ql-editor td { | .ql-editor td { | ||||
border: 1px solid var(--border-color); | |||||
border: 1px solid var(--dark-border-color); | |||||
} | } | ||||
.ql-editor blockquote { | .ql-editor blockquote { | ||||
@@ -196,5 +197,8 @@ | |||||
} | } | ||||
.mention[data-is-group="true"] { | .mention[data-is-group="true"] { | ||||
background-color: var(--group-mention-bg-color); | |||||
.icon { | |||||
margin-top: -2px; | |||||
margin-left: 4px; | |||||
} | |||||
} | } |
@@ -161,7 +161,8 @@ | |||||
.summary-item { | .summary-item { | ||||
// SIZE & SPACING | // SIZE & SPACING | ||||
margin: 0px 30px; | margin: 0px 30px; | ||||
width: 180px; | |||||
min-width: 180px; | |||||
max-width: 300px; | |||||
height: 62px; | height: 62px; | ||||
// LAYOUT | // LAYOUT | ||||
@@ -9,11 +9,6 @@ html { | |||||
} | } | ||||
/* Works on Chrome, Edge, and Safari */ | /* Works on Chrome, Edge, and Safari */ | ||||
*::-webkit-scrollbar { | |||||
width: 6px; | |||||
height: 6px; | |||||
} | |||||
*::-webkit-scrollbar-thumb { | *::-webkit-scrollbar-thumb { | ||||
background: var(--scrollbar-thumb-color); | background: var(--scrollbar-thumb-color); | ||||
} | } | ||||
@@ -23,7 +18,12 @@ html { | |||||
background: var(--scrollbar-track-color); | background: var(--scrollbar-track-color); | ||||
} | } | ||||
*::-webkit-scrollbar { | |||||
width: 6px; | |||||
height: 6px; | |||||
} | |||||
body::-webkit-scrollbar { | body::-webkit-scrollbar { | ||||
width: unset; | |||||
height: unset; | |||||
width: 12px; | |||||
height: 12px; | |||||
} | } |
@@ -216,7 +216,7 @@ class TestCommands(BaseTestCommands): | |||||
# test 7: take a backup with frappe.conf.backup.includes | # test 7: take a backup with frappe.conf.backup.includes | ||||
self.execute( | self.execute( | ||||
"bench --site {site} set-config backup '{includes}' --as-dict", | |||||
"bench --site {site} set-config backup '{includes}' --parse", | |||||
{"includes": json.dumps(backup["includes"])}, | {"includes": json.dumps(backup["includes"])}, | ||||
) | ) | ||||
self.execute("bench --site {site} backup --verbose") | self.execute("bench --site {site} backup --verbose") | ||||
@@ -226,7 +226,7 @@ class TestCommands(BaseTestCommands): | |||||
# test 8: take a backup with frappe.conf.backup.excludes | # test 8: take a backup with frappe.conf.backup.excludes | ||||
self.execute( | self.execute( | ||||
"bench --site {site} set-config backup '{excludes}' --as-dict", | |||||
"bench --site {site} set-config backup '{excludes}' --parse", | |||||
{"excludes": json.dumps(backup["excludes"])}, | {"excludes": json.dumps(backup["excludes"])}, | ||||
) | ) | ||||
self.execute("bench --site {site} backup --verbose") | self.execute("bench --site {site} backup --verbose") | ||||
@@ -365,6 +365,43 @@ class TestCommands(BaseTestCommands): | |||||
installed_apps = set(frappe.get_installed_apps()) | installed_apps = set(frappe.get_installed_apps()) | ||||
self.assertSetEqual(list_apps, installed_apps) | self.assertSetEqual(list_apps, installed_apps) | ||||
# test 3: parse json format | |||||
self.execute("bench --site all list-apps --format json") | |||||
self.assertEquals(self.returncode, 0) | |||||
self.assertIsInstance(json.loads(self.stdout), dict) | |||||
self.execute("bench --site {site} list-apps --format json") | |||||
self.assertIsInstance(json.loads(self.stdout), dict) | |||||
self.execute("bench --site {site} list-apps -f json") | |||||
self.assertIsInstance(json.loads(self.stdout), dict) | |||||
def test_show_config(self): | |||||
# test 1: sanity check for command | |||||
self.execute("bench --site all show-config") | |||||
self.assertEquals(self.returncode, 0) | |||||
# test 2: test keys in table text | |||||
self.execute( | |||||
"bench --site {site} set-config test_key '{second_order}' --parse", | |||||
{"second_order": json.dumps({"test_key": "test_value"})}, | |||||
) | |||||
self.execute("bench --site {site} show-config") | |||||
self.assertEquals(self.returncode, 0) | |||||
self.assertIn("test_key.test_key", self.stdout.split()) | |||||
self.assertIn("test_value", self.stdout.split()) | |||||
# test 3: parse json format | |||||
self.execute("bench --site all show-config --format json") | |||||
self.assertEquals(self.returncode, 0) | |||||
self.assertIsInstance(json.loads(self.stdout), dict) | |||||
self.execute("bench --site {site} show-config --format json") | |||||
self.assertIsInstance(json.loads(self.stdout), dict) | |||||
self.execute("bench --site {site} show-config -f json") | |||||
self.assertIsInstance(json.loads(self.stdout), dict) | |||||
def test_get_bench_relative_path(self): | def test_get_bench_relative_path(self): | ||||
bench_path = frappe.utils.get_bench_path() | bench_path = frappe.utils.get_bench_path() | ||||
test1_path = os.path.join(bench_path, "test1.txt") | test1_path = os.path.join(bench_path, "test1.txt") | ||||
@@ -2,10 +2,14 @@ | |||||
# MIT License. See license.txt | # MIT License. See license.txt | ||||
from __future__ import unicode_literals | from __future__ import unicode_literals | ||||
import unittest, frappe, requests, time | |||||
from frappe.test_runner import make_test_records | |||||
import unittest | |||||
import requests | |||||
import jwt | |||||
from six.moves.urllib.parse import urlparse, parse_qs, urljoin | from six.moves.urllib.parse import urlparse, parse_qs, urljoin | ||||
from urllib.parse import urlencode, quote | from urllib.parse import urlencode, quote | ||||
import frappe | |||||
from frappe.test_runner import make_test_records | |||||
from frappe.integrations.oauth2 import encode_params | from frappe.integrations.oauth2 import encode_params | ||||
class TestOAuth20(unittest.TestCase): | class TestOAuth20(unittest.TestCase): | ||||
@@ -34,11 +38,7 @@ class TestOAuth20(unittest.TestCase): | |||||
self.assertFalse(check_valid_openid_response()) | self.assertFalse(check_valid_openid_response()) | ||||
def test_login_using_authorization_code(self): | def test_login_using_authorization_code(self): | ||||
client = frappe.get_doc("OAuth Client", self.client_id) | |||||
client.grant_type = "Authorization Code" | |||||
client.response_type = "Code" | |||||
client.save() | |||||
frappe.db.commit() | |||||
update_client_for_auth_code_grant(self.client_id) | |||||
session = requests.Session() | session = requests.Session() | ||||
login(session) | login(session) | ||||
@@ -71,7 +71,8 @@ class TestOAuth20(unittest.TestCase): | |||||
"grant_type": "authorization_code", | "grant_type": "authorization_code", | ||||
"code": auth_code, | "code": auth_code, | ||||
"redirect_uri": self.redirect_uri, | "redirect_uri": self.redirect_uri, | ||||
"client_id": self.client_id | |||||
"client_id": self.client_id, | |||||
"scope": self.scope, | |||||
}) | }) | ||||
) | ) | ||||
@@ -86,6 +87,54 @@ class TestOAuth20(unittest.TestCase): | |||||
self.assertTrue(bearer_token.get("token_type") == "Bearer") | self.assertTrue(bearer_token.get("token_type") == "Bearer") | ||||
self.assertTrue(check_valid_openid_response(bearer_token.get("access_token"))) | self.assertTrue(check_valid_openid_response(bearer_token.get("access_token"))) | ||||
def test_login_using_authorization_code_with_pkce(self): | |||||
update_client_for_auth_code_grant(self.client_id) | |||||
session = requests.Session() | |||||
login(session) | |||||
redirect_destination = None | |||||
# Go to Authorize url | |||||
try: | |||||
session.get( | |||||
get_full_url("/api/method/frappe.integrations.oauth2.authorize"), | |||||
params=encode_params({ | |||||
"client_id": self.client_id, | |||||
"scope": self.scope, | |||||
"response_type": "code", | |||||
"redirect_uri": self.redirect_uri, | |||||
"code_challenge_method": 'S256', | |||||
"code_challenge": '21XaP8MJjpxCMRxgEzBP82sZ73PRLqkyBUta1R309J0' , | |||||
}) | |||||
) | |||||
except requests.exceptions.ConnectionError as ex: | |||||
redirect_destination = ex.request.url | |||||
# Get authorization code from redirected URL | |||||
query = parse_qs(urlparse(redirect_destination).query) | |||||
auth_code = query.get("code")[0] | |||||
# Request for bearer token | |||||
token_response = requests.post( | |||||
get_full_url("/api/method/frappe.integrations.oauth2.get_token"), | |||||
headers=self.form_header, | |||||
data=encode_params({ | |||||
"grant_type": "authorization_code", | |||||
"code": auth_code, | |||||
"redirect_uri": self.redirect_uri, | |||||
"client_id": self.client_id, | |||||
"scope": self.scope, | |||||
"code_verifier": "420", | |||||
}) | |||||
) | |||||
# Parse bearer token json | |||||
bearer_token = token_response.json() | |||||
self.assertTrue(bearer_token.get("access_token")) | |||||
self.assertTrue(bearer_token.get("id_token")) | |||||
def test_revoke_token(self): | def test_revoke_token(self): | ||||
client = frappe.get_doc("OAuth Client", self.client_id) | client = frappe.get_doc("OAuth Client", self.client_id) | ||||
client.grant_type = "Authorization Code" | client.grant_type = "Authorization Code" | ||||
@@ -203,6 +252,61 @@ class TestOAuth20(unittest.TestCase): | |||||
self.assertTrue(response_dict.get("token_type")) | self.assertTrue(response_dict.get("token_type")) | ||||
self.assertTrue(check_valid_openid_response(response_dict.get("access_token")[0])) | self.assertTrue(check_valid_openid_response(response_dict.get("access_token")[0])) | ||||
def test_openid_code_id_token(self): | |||||
client = update_client_for_auth_code_grant(self.client_id) | |||||
session = requests.Session() | |||||
login(session) | |||||
redirect_destination = None | |||||
nonce = frappe.generate_hash() | |||||
# Go to Authorize url | |||||
try: | |||||
session.get( | |||||
get_full_url("/api/method/frappe.integrations.oauth2.authorize"), | |||||
params=encode_params({ | |||||
"client_id": self.client_id, | |||||
"scope": self.scope, | |||||
"response_type": "code", | |||||
"redirect_uri": self.redirect_uri, | |||||
"nonce": nonce, | |||||
}) | |||||
) | |||||
except requests.exceptions.ConnectionError as ex: | |||||
redirect_destination = ex.request.url | |||||
# Get authorization code from redirected URL | |||||
query = parse_qs(urlparse(redirect_destination).query) | |||||
auth_code = query.get("code")[0] | |||||
# Request for bearer token | |||||
token_response = requests.post( | |||||
get_full_url("/api/method/frappe.integrations.oauth2.get_token"), | |||||
headers=self.form_header, | |||||
data=encode_params({ | |||||
"grant_type": "authorization_code", | |||||
"code": auth_code, | |||||
"redirect_uri": self.redirect_uri, | |||||
"client_id": self.client_id, | |||||
"scope": self.scope, | |||||
}) | |||||
) | |||||
# Parse bearer token json | |||||
bearer_token = token_response.json() | |||||
id_token = bearer_token.get("id_token") | |||||
payload = jwt.decode( | |||||
id_token, | |||||
audience=client.client_id, | |||||
key=client.client_secret, | |||||
algorithm="HS256", | |||||
) | |||||
self.assertTrue(payload.get("nonce") == nonce) | |||||
def check_valid_openid_response(access_token=None): | def check_valid_openid_response(access_token=None): | ||||
"""Return True for valid response.""" | """Return True for valid response.""" | ||||
@@ -233,3 +337,12 @@ def login(session): | |||||
def get_full_url(endpoint): | def get_full_url(endpoint): | ||||
"""Turn '/endpoint' into 'http://127.0.0.1:8000/endpoint'.""" | """Turn '/endpoint' into 'http://127.0.0.1:8000/endpoint'.""" | ||||
return urljoin(frappe.utils.get_url(), endpoint) | return urljoin(frappe.utils.get_url(), endpoint) | ||||
def update_client_for_auth_code_grant(client_id): | |||||
client = frappe.get_doc("OAuth Client", client_id) | |||||
client.grant_type = "Authorization Code" | |||||
client.response_type = "Code" | |||||
client.save() | |||||
frappe.db.commit() | |||||
return client |
@@ -443,8 +443,16 @@ def get_messages_from_report(name): | |||||
messages = _get_messages_from_page_or_report("Report", name, | messages = _get_messages_from_page_or_report("Report", name, | ||||
frappe.db.get_value("DocType", report.ref_doctype, "module")) | frappe.db.get_value("DocType", report.ref_doctype, "module")) | ||||
if report.columns: | |||||
context = "Column of report '%s'" % report.name # context has to match context in `prepare_columns` in query_report.js | |||||
messages.extend([(None, report_column.label, context) for report_column in report.columns]) | |||||
if report.filters: | |||||
messages.extend([(None, report_filter.label) for report_filter in report.filters]) | |||||
if report.query: | if report.query: | ||||
messages.extend([(None, message) for message in re.findall('"([^:,^"]*):', report.query) if is_translatable(message)]) | messages.extend([(None, message) for message in re.findall('"([^:,^"]*):', report.query) if is_translatable(message)]) | ||||
messages.append((None,report.report_name)) | messages.append((None,report.report_name)) | ||||
return messages | return messages | ||||
@@ -18,8 +18,7 @@ from email.utils import formataddr, parseaddr | |||||
from gzip import GzipFile | from gzip import GzipFile | ||||
from typing import Generator, Iterable | from typing import Generator, Iterable | ||||
from six import string_types, text_type | |||||
from six.moves.urllib.parse import quote | |||||
from urllib.parse import quote, urlparse | |||||
from werkzeug.test import Client | from werkzeug.test import Client | ||||
import frappe | import frappe | ||||
@@ -72,7 +71,7 @@ def get_formatted_email(user, mail=None): | |||||
def extract_email_id(email): | def extract_email_id(email): | ||||
"""fetch only the email part of the Email Address""" | """fetch only the email part of the Email Address""" | ||||
email_id = parse_addr(email)[1] | email_id = parse_addr(email)[1] | ||||
if email_id and isinstance(email_id, string_types) and not isinstance(email_id, text_type): | |||||
if email_id and isinstance(email_id, str) and not isinstance(email_id, str): | |||||
email_id = email_id.decode("utf-8", "ignore") | email_id = email_id.decode("utf-8", "ignore") | ||||
return email_id | return email_id | ||||
@@ -370,14 +369,14 @@ def get_site_url(site): | |||||
def encode_dict(d, encoding="utf-8"): | def encode_dict(d, encoding="utf-8"): | ||||
for key in d: | for key in d: | ||||
if isinstance(d[key], string_types) and isinstance(d[key], text_type): | |||||
if isinstance(d[key], str) and isinstance(d[key], str): | |||||
d[key] = d[key].encode(encoding) | d[key] = d[key].encode(encoding) | ||||
return d | return d | ||||
def decode_dict(d, encoding="utf-8"): | def decode_dict(d, encoding="utf-8"): | ||||
for key in d: | for key in d: | ||||
if isinstance(d[key], string_types) and not isinstance(d[key], text_type): | |||||
if isinstance(d[key], str) and not isinstance(d[key], str): | |||||
d[key] = d[key].decode(encoding, "ignore") | d[key] = d[key].decode(encoding, "ignore") | ||||
return d | return d | ||||
@@ -644,7 +643,7 @@ def parse_json(val): | |||||
""" | """ | ||||
Parses json if string else return | Parses json if string else return | ||||
""" | """ | ||||
if isinstance(val, string_types): | |||||
if isinstance(val, str): | |||||
val = json.loads(val) | val = json.loads(val) | ||||
if isinstance(val, dict): | if isinstance(val, dict): | ||||
val = frappe._dict(val) | val = frappe._dict(val) | ||||
@@ -813,3 +812,11 @@ def groupby_metric(iterable: typing.Dict[str, list], key: str): | |||||
for item in items: | for item in items: | ||||
records.setdefault(item[key], {}).setdefault(category, []).append(item) | records.setdefault(item[key], {}).setdefault(category, []).append(item) | ||||
return records | return records | ||||
def validate_url(url_string): | |||||
try: | |||||
result = urlparse(url_string) | |||||
return result.scheme and result.scheme in ["http", "https", "ftp", "ftps"] | |||||
except Exception: | |||||
return False | |||||
@@ -1,4 +1,4 @@ | |||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors | |||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors | |||||
# MIT License. See license.txt | # MIT License. See license.txt | ||||
from __future__ import unicode_literals, print_function | from __future__ import unicode_literals, print_function | ||||
@@ -126,16 +126,12 @@ recursive-include {app_name} *.svg | |||||
recursive-include {app_name} *.txt | recursive-include {app_name} *.txt | ||||
recursive-exclude {app_name} *.pyc""" | recursive-exclude {app_name} *.pyc""" | ||||
init_template = """# -*- coding: utf-8 -*- | |||||
from __future__ import unicode_literals | |||||
init_template = """ | |||||
__version__ = '0.0.1' | __version__ = '0.0.1' | ||||
""" | """ | ||||
hooks_template = """# -*- coding: utf-8 -*- | |||||
from __future__ import unicode_literals | |||||
from . import __version__ as app_version | |||||
hooks_template = """from . import __version__ as app_version | |||||
app_name = "{app_name}" | app_name = "{app_name}" | ||||
app_title = "{app_title}" | app_title = "{app_title}" | ||||
@@ -312,9 +308,7 @@ user_data_fields = [ | |||||
""" | """ | ||||
desktop_template = """# -*- coding: utf-8 -*- | |||||
from __future__ import unicode_literals | |||||
from frappe import _ | |||||
desktop_template = """from frappe import _ | |||||
def get_data(): | def get_data(): | ||||
return [ | return [ | ||||
@@ -328,8 +322,7 @@ def get_data(): | |||||
] | ] | ||||
""" | """ | ||||
setup_template = """# -*- coding: utf-8 -*- | |||||
from setuptools import setup, find_packages | |||||
setup_template = """from setuptools import setup, find_packages | |||||
with open('requirements.txt') as f: | with open('requirements.txt') as f: | ||||
install_requires = f.read().strip().split('\\n') | install_requires = f.read().strip().split('\\n') | ||||
@@ -1,11 +1,10 @@ | |||||
import functools | import functools | ||||
import requests | |||||
from terminaltables import AsciiTable | |||||
@functools.lru_cache(maxsize=1024) | @functools.lru_cache(maxsize=1024) | ||||
def get_first_party_apps(): | def get_first_party_apps(): | ||||
"""Get list of all apps under orgs: frappe. erpnext from GitHub""" | """Get list of all apps under orgs: frappe. erpnext from GitHub""" | ||||
import requests | |||||
apps = [] | apps = [] | ||||
for org in ["frappe", "erpnext"]: | for org in ["frappe", "erpnext"]: | ||||
req = requests.get(f"https://api.github.com/users/{org}/repos", {"type": "sources", "per_page": 200}) | req = requests.get(f"https://api.github.com/users/{org}/repos", {"type": "sources", "per_page": 200}) | ||||
@@ -15,6 +14,8 @@ def get_first_party_apps(): | |||||
def render_table(data): | def render_table(data): | ||||
from terminaltables import AsciiTable | |||||
print(AsciiTable(data).table) | print(AsciiTable(data).table) | ||||
@@ -49,3 +50,9 @@ def log(message, colour=''): | |||||
colour = colours.get(colour, "") | colour = colours.get(colour, "") | ||||
end_line = '\033[0m' | end_line = '\033[0m' | ||||
print(colour + message + end_line) | print(colour + message + end_line) | ||||
def warn(message, category=None): | |||||
from warnings import warn | |||||
warn(message=message, category=category, stacklevel=2) |
@@ -6,7 +6,9 @@ | |||||
"engine": "InnoDB", | "engine": "InnoDB", | ||||
"field_order": [ | "field_order": [ | ||||
"email", | "email", | ||||
"status" | |||||
"status", | |||||
"anonymization_matrix", | |||||
"deletion_steps" | |||||
], | ], | ||||
"fields": [ | "fields": [ | ||||
{ | { | ||||
@@ -27,10 +29,23 @@ | |||||
"label": "Status", | "label": "Status", | ||||
"options": "Pending Verification\nPending Approval\nDeleted", | "options": "Pending Verification\nPending Approval\nDeleted", | ||||
"read_only": 1 | "read_only": 1 | ||||
}, | |||||
{ | |||||
"fieldname": "anonymization_matrix", | |||||
"fieldtype": "Code", | |||||
"label": "Anonymization Matrix", | |||||
"options": "JSON", | |||||
"read_only": 1 | |||||
}, | |||||
{ | |||||
"fieldname": "deletion_steps", | |||||
"fieldtype": "Table", | |||||
"label": "Deletion Steps ", | |||||
"options": "Personal Data Deletion Step" | |||||
} | } | ||||
], | ], | ||||
"links": [], | "links": [], | ||||
"modified": "2021-02-28 12:36:08.219719", | |||||
"modified": "2021-04-23 13:25:53.629308", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Website", | "module": "Website", | ||||
"name": "Personal Data Deletion Request", | "name": "Personal Data Deletion Request", | ||||
@@ -10,6 +10,8 @@ from frappe.model.document import Document | |||||
from frappe.utils import get_fullname | from frappe.utils import get_fullname | ||||
from frappe.utils.user import get_system_managers | from frappe.utils.user import get_system_managers | ||||
from frappe.utils.verified_command import get_signed_params, verify_request | from frappe.utils.verified_command import get_signed_params, verify_request | ||||
import json | |||||
from frappe.core.utils import find | |||||
class PersonalDataDeletionRequest(Document): | class PersonalDataDeletionRequest(Document): | ||||
@@ -118,6 +120,24 @@ class PersonalDataDeletionRequest(Document): | |||||
now=frappe.flags.in_test, | now=frappe.flags.in_test, | ||||
) | ) | ||||
def add_deletion_steps(self): | |||||
if self.deletion_steps: | |||||
return | |||||
for step in self.full_match_privacy_docs + self.partial_privacy_docs: | |||||
row_data = { | |||||
"status": "Pending", | |||||
"document_type": step.get("doctype"), | |||||
"partial": step.get("partial") or False, | |||||
"fields": json.dumps(step.get("redact_fields", [])), | |||||
"filtered_by": step.get("filtered_by") or "", | |||||
} | |||||
self.append("deletion_steps", row_data) | |||||
self.anonymization_matrix = json.dumps(self.anonymization_value_map, indent=4) | |||||
self.save() | |||||
self.reload() | |||||
def redact_partial_match_data(self, doctype): | def redact_partial_match_data(self, doctype): | ||||
self.__redact_partial_match_data(doctype) | self.__redact_partial_match_data(doctype) | ||||
self.rename_documents(doctype) | self.rename_documents(doctype) | ||||
@@ -143,11 +163,11 @@ class PersonalDataDeletionRequest(Document): | |||||
def redact_full_match_data(self, ref, email): | def redact_full_match_data(self, ref, email): | ||||
"""Replaces the entire field value by the values set in the anonymization_value_map""" | """Replaces the entire field value by the values set in the anonymization_value_map""" | ||||
filter_by = ref["filter_by"] | |||||
filter_by = ref.get("filter_by", "owner") | |||||
docs = frappe.get_all( | docs = frappe.get_all( | ||||
ref["doctype"], | ref["doctype"], | ||||
filters={filter_by: ("like", "%" + email + "%")}, | |||||
filters={filter_by: email}, | |||||
fields=["name", filter_by], | fields=["name", filter_by], | ||||
) | ) | ||||
@@ -185,7 +205,7 @@ class PersonalDataDeletionRequest(Document): | |||||
return anonymize_fields_dict | return anonymize_fields_dict | ||||
def redact_doc(self, doc, ref): | def redact_doc(self, doc, ref): | ||||
filter_by = ref["filter_by"] | |||||
filter_by = ref.get("filter_by", "owner") | |||||
meta = frappe.get_meta(ref["doctype"]) | meta = frappe.get_meta(ref["doctype"]) | ||||
filter_by_meta = meta.get_field(filter_by) | filter_by_meta = meta.get_field(filter_by) | ||||
@@ -207,21 +227,57 @@ class PersonalDataDeletionRequest(Document): | |||||
ref["doctype"], doc["name"], self.anon, force=True, show_alert=False | ref["doctype"], doc["name"], self.anon, force=True, show_alert=False | ||||
) | ) | ||||
def _anonymize_data(self, email=None, anon=None, set_data=True): | |||||
def _anonymize_data(self, email=None, anon=None, set_data=True, commit=False): | |||||
email = email or self.email | email = email or self.email | ||||
anon = anon or self.name | anon = anon or self.name | ||||
if set_data: | if set_data: | ||||
self.__set_anonymization_data(email, anon) | self.__set_anonymization_data(email, anon) | ||||
for doctype in self.full_match_privacy_docs: | |||||
self.add_deletion_steps() | |||||
self.full_match_doctypes = ( | |||||
x | |||||
for x in self.full_match_privacy_docs | |||||
if filter( | |||||
lambda x: x.document_type == x and x.status == "Pending", self.deletion_steps | |||||
) | |||||
) | |||||
self.partial_match_doctypes = ( | |||||
x | |||||
for x in self.partial_privacy_docs | |||||
if filter( | |||||
lambda x: x.document_type == x and x.status == "Pending", self.deletion_steps | |||||
) | |||||
) | |||||
for doctype in self.full_match_doctypes: | |||||
self.redact_full_match_data(doctype, email) | self.redact_full_match_data(doctype, email) | ||||
self.set_step_status(doctype["doctype"]) | |||||
if commit: | |||||
frappe.db.commit() | |||||
for doctype in self.partial_privacy_docs: | |||||
for doctype in self.partial_match_doctypes: | |||||
self.redact_partial_match_data(doctype) | self.redact_partial_match_data(doctype) | ||||
self.set_step_status(doctype["doctype"]) | |||||
if commit: | |||||
frappe.db.commit() | |||||
frappe.rename_doc("User", email, anon, force=True, show_alert=False) | frappe.rename_doc("User", email, anon, force=True, show_alert=False) | ||||
self.db_set("status", "Deleted") | self.db_set("status", "Deleted") | ||||
if commit: | |||||
frappe.db.commit() | |||||
def set_step_status(self, step, status="Deleted"): | |||||
del_step = find(self.deletion_steps, lambda x: x.document_type == step and x.status != status) | |||||
if not del_step: | |||||
del_step = find(self.deletion_steps, lambda x: x.document_type == step) | |||||
del_step.status = status | |||||
self.save() | |||||
self.reload() | |||||
def __set_anonymization_data(self, email, anon): | def __set_anonymization_data(self, email, anon): | ||||
self.anon = anon or self.name | self.anon = anon or self.name | ||||
@@ -290,9 +346,8 @@ def confirm_deletion(email, name, host_name): | |||||
frappe.db.commit() | frappe.db.commit() | ||||
frappe.respond_as_web_page( | frappe.respond_as_web_page( | ||||
_("Confirmed"), | _("Confirmed"), | ||||
_( | |||||
"The process for deletion of {0} data associated with {1} has been initiated." | |||||
).format(host_name, email), | |||||
_("The process for deletion of {0} data associated with {1} has been initiated.") | |||||
.format(host_name, email), | |||||
indicator_color="green", | indicator_color="green", | ||||
) | ) | ||||
@@ -0,0 +1,66 @@ | |||||
{ | |||||
"actions": [], | |||||
"creation": "2021-04-23 13:25:26.162797", | |||||
"doctype": "DocType", | |||||
"editable_grid": 1, | |||||
"engine": "InnoDB", | |||||
"field_order": [ | |||||
"document_type", | |||||
"status", | |||||
"partial", | |||||
"fields", | |||||
"filtered_by" | |||||
], | |||||
"fields": [ | |||||
{ | |||||
"allow_in_quick_entry": 1, | |||||
"fieldname": "document_type", | |||||
"fieldtype": "Data", | |||||
"in_list_view": 1, | |||||
"label": "Document Type", | |||||
"read_only": 1, | |||||
"reqd": 1 | |||||
}, | |||||
{ | |||||
"fieldname": "status", | |||||
"fieldtype": "Select", | |||||
"in_list_view": 1, | |||||
"in_preview": 1, | |||||
"label": "Status", | |||||
"options": "Pending\nDeleted", | |||||
"read_only": 1 | |||||
}, | |||||
{ | |||||
"default": "0", | |||||
"fieldname": "partial", | |||||
"fieldtype": "Check", | |||||
"in_preview": 1, | |||||
"label": "Partial", | |||||
"read_only": 1 | |||||
}, | |||||
{ | |||||
"fieldname": "fields", | |||||
"fieldtype": "Small Text", | |||||
"label": "Fields", | |||||
"read_only": 1 | |||||
}, | |||||
{ | |||||
"fieldname": "filtered_by", | |||||
"fieldtype": "Data", | |||||
"label": "Filtered By", | |||||
"read_only": 1 | |||||
} | |||||
], | |||||
"index_web_pages_for_search": 1, | |||||
"istable": 1, | |||||
"links": [], | |||||
"modified": "2021-04-23 13:48:59.658681", | |||||
"modified_by": "Administrator", | |||||
"module": "Website", | |||||
"name": "Personal Data Deletion Step", | |||||
"owner": "Administrator", | |||||
"permissions": [], | |||||
"sort_field": "modified", | |||||
"sort_order": "DESC", | |||||
"track_changes": 1 | |||||
} |
@@ -0,0 +1,10 @@ | |||||
# -*- coding: utf-8 -*- | |||||
# Copyright (c) 2021, Frappe Technologies and contributors | |||||
# For license information, please see license.txt | |||||
from __future__ import unicode_literals | |||||
# import frappe | |||||
from frappe.model.document import Document | |||||
class PersonalDataDeletionStep(Document): | |||||
pass |
@@ -39,7 +39,7 @@ | |||||
"fieldtype": "Select", | "fieldtype": "Select", | ||||
"in_list_view": 1, | "in_list_view": 1, | ||||
"label": "Fieldtype", | "label": "Fieldtype", | ||||
"options": "Attach\nAttach Image\nCheck\nCurrency\nData\nDate\nDatetime\nDuration\nFloat\nHTML\nInt\nLink\nRating\nSelect\nSmall Text\nText\nText Editor\nTable\nSection Break\nColumn Break" | |||||
"options": "Attach\nAttach Image\nCheck\nCurrency\nData\nDate\nDatetime\nDuration\nFloat\nHTML\nInt\nLink\nPassword\nRating\nSelect\nSmall Text\nText\nText Editor\nTable\nSection Break\nColumn Break" | |||||
}, | }, | ||||
{ | { | ||||
"fieldname": "label", | "fieldname": "label", | ||||
@@ -146,7 +146,7 @@ | |||||
], | ], | ||||
"istable": 1, | "istable": 1, | ||||
"links": [], | "links": [], | ||||
"modified": "2020-11-10 23:20:44.354862", | |||||
"modified": "2021-04-30 12:02:25.422345", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Website", | "module": "Website", | ||||
"name": "Web Form Field", | "name": "Web Form Field", | ||||
@@ -21,7 +21,7 @@ $('.dropdown-menu a.dropdown-toggle').on('click', function (e) { | |||||
frappe.get_modal = function (title, content) { | frappe.get_modal = function (title, content) { | ||||
return $( | return $( | ||||
`<div class="modal" tabindex="-1" role="dialog"> | `<div class="modal" tabindex="-1" role="dialog"> | ||||
<div class="modal-dialog modal-dialog-scrollable" role="document"> | |||||
<div class="modal-dialog" role="document"> | |||||
<div class="modal-content"> | <div class="modal-content"> | ||||
<div class="modal-header"> | <div class="modal-header"> | ||||
<h5 class="modal-title">${title}</h5> | <h5 class="modal-title">${title}</h5> | ||||
@@ -16,7 +16,9 @@ | |||||
{%- endif -%} | {%- endif -%} | ||||
<div class="split-section-content col-12 {{ left_col if image_on_right else right_col }} {{ align_content }}"> | <div class="split-section-content col-12 {{ left_col if image_on_right else right_col }} {{ align_content }}"> | ||||
<h2>{{ title }}</h2> | <h2>{{ title }}</h2> | ||||
{%- if content -%} | |||||
<p>{{ content }}</p> | <p>{{ content }}</p> | ||||
{%- endif -%} | |||||
{%- if link_label and link_url -%} | {%- if link_label and link_url -%} | ||||
<a href="{{ link_url }}">{{ link_label }}</a> | <a href="{{ link_url }}">{{ link_label }}</a> | ||||