@@ -144,8 +144,8 @@ jobs: | |||
DB: ${{ matrix.DB }} | |||
TYPE: ${{ matrix.TYPE }} | |||
- name: Coverage | |||
if: matrix.TYPE == 'server' | |||
- name: Coverage - Pull Request | |||
if: matrix.TYPE == 'server' && github.event_name == 'pull_request' | |||
run: | | |||
cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} | |||
cd ${GITHUB_WORKSPACE} | |||
@@ -156,3 +156,17 @@ jobs: | |||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |||
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} | |||
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 | |||
""" | |||
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 | |||
import os, sys, importlib, inspect, json | |||
import os, sys, importlib, inspect, json, warnings | |||
import typing | |||
from past.builtins import cmp | |||
import click | |||
@@ -40,6 +39,8 @@ __title__ = "Frappe Framework" | |||
local = Local() | |||
controllers = {} | |||
warnings.simplefilter('always', DeprecationWarning) | |||
warnings.simplefilter('always', PendingDeprecationWarning) | |||
class _dict(dict): | |||
"""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 | |||
from werkzeug.serving import run_simple | |||
patch_werkzeug_reloader() | |||
if profile: | |||
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_evalex=not in_test_env, | |||
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.sid = frappe.session['sid'] | |||
bootinfo.user_groups = frappe.get_all('User Group', pluck="name") | |||
bootinfo.modules = {} | |||
bootinfo.module_list = [] | |||
load_desktop_data(bootinfo) | |||
@@ -203,10 +203,13 @@ def install_app(context, apps): | |||
@click.command("list-apps") | |||
@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text") | |||
@pass_context | |||
def list_apps(context): | |||
def list_apps(context, format): | |||
"List apps in site" | |||
summary_dict = {} | |||
def fix_whitespaces(text): | |||
if site == context.sites[-1]: | |||
text = text.rstrip() | |||
@@ -235,18 +238,23 @@ def list_apps(context): | |||
] | |||
applications_summary = "\n".join(installed_applications) | |||
summary = f"{site_title}\n{applications_summary}\n" | |||
summary_dict[site] = [app.app_name for app in apps] | |||
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_dict[site] = installed_applications | |||
summary = fix_whitespaces(summary) | |||
if applications_summary and summary: | |||
if format == "text" and applications_summary and summary: | |||
print(summary) | |||
frappe.destroy() | |||
if format == "json": | |||
click.echo(frappe.as_json(summary_dict)) | |||
@click.command('add-system-manager') | |||
@click.argument('email') | |||
@@ -548,7 +556,7 @@ def move(dest_dir, site): | |||
site_dump_exists = os.path.exists(final_new_path) | |||
count = int(count or 0) + 1 | |||
os.rename(old_path, final_new_path) | |||
shutil.move(old_path, final_new_path) | |||
frappe.destroy() | |||
return final_new_path | |||
@@ -96,22 +96,54 @@ def destroy_all_sessions(context, reason=None): | |||
raise SiteNotSpecifiedError | |||
@click.command('show-config') | |||
@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text") | |||
@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') | |||
@@ -470,6 +502,7 @@ def console(context): | |||
locals()[app] = __import__(app) | |||
except ModuleNotFoundError: | |||
failed_to_import.append(app) | |||
all_apps.remove(app) | |||
print("Apps in this namespace:\n{}".format(", ".join(all_apps))) | |||
if failed_to_import: | |||
@@ -652,20 +685,27 @@ def make_app(destination, app_name): | |||
@click.command('set-config') | |||
@click.argument('key') | |||
@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 | |||
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" | |||
from frappe.installer import update_site_config | |||
import ast | |||
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) | |||
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') | |||
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: | |||
for site in context.sites: | |||
frappe.init(site=site) | |||
@@ -722,50 +762,6 @@ def rebuild_global_search(context, static_pages=False): | |||
if not context.sites: | |||
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 = [ | |||
build, | |||
@@ -1,8 +1,6 @@ | |||
# -*- coding: utf-8 -*- | |||
# Copyright (c) {year}, {app_publisher} and contributors | |||
# For license information, please see license.txt | |||
from __future__ import unicode_literals | |||
# import frappe | |||
{base_class_import} | |||
@@ -1,7 +1,5 @@ | |||
# -*- coding: utf-8 -*- | |||
# Copyright (c) {year}, {app_publisher} and Contributors | |||
# See license.txt | |||
from __future__ import unicode_literals | |||
# import frappe | |||
import unittest | |||
@@ -83,12 +83,61 @@ class DocType(Document): | |||
if not self.is_new(): | |||
self.before_update = frappe.get_doc('DocType', self.name) | |||
self.setup_fields_to_fetch() | |||
self.validate_field_name_conflicts() | |||
check_email_append_to(self) | |||
if self.default_print_format and not self.custom: | |||
frappe.throw(_('Standard DocType cannot have default print format, use Customize Form')) | |||
if frappe.conf.get('developer_mode'): | |||
self.owner = 'Administrator' | |||
self.modified_by = 'Administrator' | |||
def validate_field_name_conflicts(self): | |||
"""Check if field names dont conflict with controller properties and methods""" | |||
core_doctypes = [ | |||
"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): | |||
# clear user cache so that on the next reload this doctype is included in boot | |||
clear_user_cache(frappe.session.user) | |||
@@ -1174,11 +1223,19 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): | |||
else: | |||
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)) | |||
def clear_linked_doctype_cache(): | |||
@@ -1,7 +1,6 @@ | |||
# Copyright (c) 2013, {app_publisher} and contributors | |||
# For license information, please see license.txt | |||
from __future__ import unicode_literals | |||
# import frappe | |||
def execute(filters=None): | |||
@@ -56,6 +56,7 @@ class User(Document): | |||
def after_insert(self): | |||
create_notification_settings(self.name) | |||
frappe.cache().delete_key('users_for_mentions') | |||
def validate(self): | |||
self.check_demo() | |||
@@ -129,6 +130,9 @@ class User(Document): | |||
if self.time_zone: | |||
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): | |||
"""Returns true if current user is the session user""" | |||
return self.name == frappe.session.user | |||
@@ -389,6 +393,9 @@ class User(Document): | |||
# delete notification settings | |||
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): | |||
self.check_demo() | |||
@@ -9,7 +9,7 @@ import frappe | |||
class UserGroup(Document): | |||
def after_insert(self): | |||
frappe.publish_realtime('user_group_added', self.name) | |||
frappe.cache().delete_key('user_groups') | |||
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.ui.make_app_page({ | |||
parent: wrapper, | |||
title: 'Recorder', | |||
title: __('Recorder'), | |||
single_column: true, | |||
card_layout: true | |||
}); | |||
@@ -64,8 +64,8 @@ class CustomField(Document): | |||
self.translatable = 0 | |||
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): | |||
if not frappe.flags.in_setup_wizard: | |||
@@ -5,6 +5,7 @@ | |||
.download-backup-card { | |||
display: block; | |||
text-decoration: none; | |||
margin-bottom: var(--margin-lg); | |||
} | |||
.download-backup-card:hover { | |||
@@ -1,7 +1,7 @@ | |||
frappe.pages['backups'].on_page_load = function(wrapper) { | |||
var page = frappe.ui.make_app_page({ | |||
parent: wrapper, | |||
title: 'Download Backups', | |||
title: __('Download Backups'), | |||
single_column: true | |||
}); | |||
@@ -1,7 +1,7 @@ | |||
frappe.pages['translation-tool'].on_page_load = function(wrapper) { | |||
var page = frappe.ui.make_app_page({ | |||
parent: wrapper, | |||
title: 'Translation Tool', | |||
title: __('Translation Tool'), | |||
single_column: true, | |||
card_layout: true, | |||
}); | |||
@@ -8,7 +8,7 @@ | |||
</div> | |||
<div class="chart-wrapper performance-heatmap"> | |||
<div class="null-state"> | |||
<span>No Data to Show</span> | |||
<span>{%=__("No Data to Show") %}</span> | |||
</div> | |||
</div> | |||
</div> | |||
@@ -19,7 +19,7 @@ | |||
</div> | |||
<div class="chart-wrapper performance-percentage-chart"> | |||
<div class="null-state"> | |||
<span>No Data to Show</span> | |||
<span>{%=__("No Data to Show") %}</span> | |||
</div> | |||
</div> | |||
</div> | |||
@@ -30,7 +30,7 @@ | |||
</div> | |||
<div class="chart-wrapper performance-line-chart"> | |||
<div class="null-state"> | |||
<span>No Data to Show</span> | |||
<span>{%=__("No Data to Show") %}</span> | |||
</div> | |||
</div> | |||
</div> | |||
@@ -41,4 +41,4 @@ | |||
<div class="recent-activity-footer"></div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> |
@@ -377,10 +377,17 @@ def handle_duration_fieldtype_values(result, columns): | |||
if fieldtype == "Duration": | |||
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 | |||
@@ -221,3 +221,37 @@ def validate_and_sanitize_search_inputs(fn, instance, args, kwargs): | |||
return [] | |||
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.password import get_decrypted_password | |||
from frappe.custom.doctype.custom_field.custom_field import create_custom_field | |||
from frappe.integrations.oauth2 import validate_url | |||
class EventProducer(Document): | |||
@@ -56,7 +55,7 @@ class EventProducer(Document): | |||
self.reload() | |||
def check_url(self): | |||
if not validate_url(self.producer_url): | |||
if not frappe.utils.validate_url(self.producer_url): | |||
frappe.throw(_('Invalid URL')) | |||
# 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": [ | |||
{ | |||
"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": [ | |||
{ | |||
"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": [ | |||
{ | |||
"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": [ | |||
{ | |||
"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 | |||
from urllib.parse import quote, urlencode, urlparse | |||
import jwt | |||
from urllib.parse import quote, urlencode | |||
from oauthlib.oauth2 import FatalClientError, OAuth2Error | |||
from oauthlib.openid.connect.core.endpoints.pre_configured import ( | |||
Server as WebApplicationServer, | |||
) | |||
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(): | |||
if not getattr(frappe.local, 'oauth_server', None): | |||
if not getattr(frappe.local, "oauth_server", None): | |||
oauth_validator = OAuthWebRequestValidator() | |||
frappe.local.oauth_server = WebApplicationServer(oauth_validator) | |||
return frappe.local.oauth_server | |||
def sanitize_kwargs(param_kwargs): | |||
"""Remove 'data' and 'cmd' keys, if present.""" | |||
arguments = param_kwargs | |||
arguments.pop('data', None) | |||
arguments.pop('cmd', None) | |||
arguments.pop("data", None) | |||
arguments.pop("cmd", None) | |||
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() | |||
def approve(*args, **kwargs): | |||
r = frappe.request | |||
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( | |||
uri=frappe.flags.oauth_credentials['redirect_uri'], | |||
uri=frappe.flags.oauth_credentials["redirect_uri"], | |||
body=r.get_data(), | |||
headers=r.headers, | |||
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["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) | |||
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" | |||
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["location"] = "/login?" + encode_params({'redirect-to': frappe.request.url}) | |||
frappe.local.response["location"] = "/login?" + encode_params( | |||
{"redirect-to": frappe.request.url} | |||
) | |||
else: | |||
try: | |||
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["location"] = success_url | |||
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) | |||
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) | |||
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: | |||
r = frappe.request | |||
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) | |||
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() | |||
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: | |||
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.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 frappe.db.field_exists("DocType", "is_tree"): | |||
@@ -1347,6 +1347,22 @@ class Document(BaseDocument): | |||
from frappe.desk.doctype.tag.tag import DocTags | |||
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): | |||
"""Execute an action on a document (called by background worker)""" | |||
doc = frappe.get_doc(doctype, name) | |||
@@ -1,65 +1,16 @@ | |||
from __future__ import print_function, unicode_literals | |||
import frappe | |||
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 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): | |||
@@ -67,7 +18,7 @@ class OAuthWebRequestValidator(RequestValidator): | |||
# Pre- and post-authorization. | |||
def validate_client_id(self, client_id, request, *args, **kwargs): | |||
# 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: | |||
request.client = frappe.get_doc("OAuth Client", client_id).as_dict() | |||
return True | |||
@@ -78,7 +29,9 @@ class OAuthWebRequestValidator(RequestValidator): | |||
# Is the client allowed to use the supplied redirect_uri? i.e. has | |||
# 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: | |||
return True | |||
@@ -89,7 +42,9 @@ class OAuthWebRequestValidator(RequestValidator): | |||
# The redirect used if none has been supplied. | |||
# Prefer your clients to pre register a redirect uri rather than | |||
# 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 | |||
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 | |||
# authorization request. | |||
scopes = get_client_scopes(client_id) | |||
request.scopes = scopes #Apparently this is possible. | |||
request.scopes = scopes # Apparently this is possible. | |||
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 | |||
@@ -121,38 +80,69 @@ class OAuthWebRequestValidator(RequestValidator): | |||
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.redirect_uri_bound_to_authorization_code = request.redirect_uri | |||
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) | |||
frappe.db.commit() | |||
def authenticate_client(self, request, *args, **kwargs): | |||
#Get ClientID in URL | |||
# Get ClientID in URL | |||
if request.client_id: | |||
oc = frappe.get_doc("OAuth Client", request.client_id) | |||
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: | |||
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: | |||
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: | |||
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: | |||
request.client = request.client or oc.as_dict() | |||
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) | |||
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 | |||
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: | |||
# Don't allow public (non-authenticated) clients | |||
return False | |||
@@ -164,28 +154,72 @@ class OAuthWebRequestValidator(RequestValidator): | |||
# Validate the code belongs to the client. Add associated scopes, | |||
# 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 = [] | |||
for vcode in validcodes: | |||
checkcodes.append(vcode["name"]) | |||
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 | |||
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 | |||
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. | |||
# 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): | |||
# Remember to associate it with request.scopes, request.user and | |||
@@ -195,19 +229,30 @@ class OAuthWebRequestValidator(RequestValidator): | |||
# access_token to now + expires_in seconds. | |||
otoken = frappe.new_doc("OAuth Bearer Token") | |||
otoken.client = request.client['name'] | |||
otoken.client = request.client["name"] | |||
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.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) | |||
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 | |||
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): | |||
# Remember to check expiration and scope membership | |||
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) | |||
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 | |||
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 | |||
# Token refresh request | |||
def get_original_scopes(self, refresh_token, request, *args, **kwargs): | |||
# Obtain the token associated with the given refresh_token and | |||
# return its scopes, these will be passed on to the refreshed | |||
# access token if the client did not specify a scope during the | |||
# 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 | |||
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) | |||
Method is used by: | |||
- Revocation Endpoint | |||
- Revocation Endpoint | |||
""" | |||
otoken = None | |||
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": | |||
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: | |||
otoken = frappe.db.set_value("OAuth Bearer Token", token, 'status', 'Revoked') | |||
frappe.db.set_value("OAuth Bearer Token", token, "status", "Revoked") | |||
frappe.db.commit() | |||
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: | |||
return False | |||
@@ -287,36 +345,84 @@ class OAuthWebRequestValidator(RequestValidator): | |||
return True | |||
# 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): | |||
"""Ensure the logged in user has authorized silent OpenID authorization. | |||
@@ -328,9 +434,9 @@ class OAuthWebRequestValidator(RequestValidator): | |||
:rtype: True or False | |||
Method is used by: | |||
- OpenIDConnectAuthCode | |||
- OpenIDConnectImplicit | |||
- OpenIDConnectHybrid | |||
- OpenIDConnectAuthCode | |||
- OpenIDConnectImplicit | |||
- OpenIDConnectHybrid | |||
""" | |||
if request.prompt == "login": | |||
False | |||
@@ -351,9 +457,9 @@ class OAuthWebRequestValidator(RequestValidator): | |||
:rtype: True or False | |||
Method is used by: | |||
- OpenIDConnectAuthCode | |||
- OpenIDConnectImplicit | |||
- OpenIDConnectHybrid | |||
- OpenIDConnectAuthCode | |||
- OpenIDConnectImplicit | |||
- OpenIDConnectHybrid | |||
""" | |||
if frappe.session.user == "Guest" or request.prompt.lower() == "login": | |||
return False | |||
@@ -373,32 +479,77 @@ class OAuthWebRequestValidator(RequestValidator): | |||
:rtype: True or False | |||
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 | |||
else: | |||
return False | |||
return False | |||
def validate_user(self, username, password, client, request, *args, **kwargs): | |||
"""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.authenticate(username, password) | |||
if login_manager.user == "Guest": | |||
return False | |||
request.user = login_manager.user | |||
return True | |||
def get_cookie_dict_from_headers(r): | |||
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 | |||
def calculate_at_hash(access_token, hash_alg): | |||
"""Helper method for calculating an access token | |||
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 | |||
case sensitive string. | |||
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) | |||
truncated = hash_digest[:cut_at] | |||
from jwt.utils import base64url_encode | |||
at_hash = base64url_encode(truncated) | |||
return at_hash.decode('utf-8') | |||
return at_hash.decode("utf-8") | |||
def delete_oauth2_data(): | |||
# Delete Invalid Authorization Code and Revoked Token | |||
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: | |||
commit_code = True | |||
if len(token_list) > 0: | |||
@@ -439,3 +594,58 @@ def delete_oauth2_data(): | |||
def get_client_scopes(client_id): | |||
scopes_string = frappe.db.get_value("OAuth Client", client_id, "scopes") | |||
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); | |||
}); | |||
this.setup_user_group_listeners(); | |||
// listen to build errors | |||
this.setup_build_error_listener(); | |||
@@ -476,14 +474,19 @@ frappe.Application = Class.extend({ | |||
$('<link rel="icon" href="' + link + '" type="image/x-icon">').appendTo("head"); | |||
}, | |||
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() { | |||
@@ -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() { | |||
frappe.realtime.on('energy_point_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) { | |||
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-header"> | |||
<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.toggle_container_scroll('.modal-dialog', 'modal-dialog-scrollable'); | |||
this.toggle_container_scroll('.grid-form-body .form-area', 'scrollable'); | |||
this.autocomplete_open = true; | |||
}); | |||
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; | |||
}); | |||
@@ -78,35 +78,25 @@ frappe.ui.form.ControlComment = frappe.ui.form.ControlTextEditor.extend({ | |||
}, | |||
get_mention_options() { | |||
if (!(this.mentions && this.mentions.length)) { | |||
if (!this.enable_mentions) { | |||
return null; | |||
} | |||
const at_values = this.mentions.slice(); | |||
let me = this; | |||
return { | |||
allowedChars: /^[A-Za-z0-9_]*$/, | |||
mentionDenotationChars: ["@"], | |||
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({ | |||
format_for_input: function(value) { | |||
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() { | |||
@@ -10,7 +10,7 @@ frappe.ui.form.ControlFloat = frappe.ui.form.ControlInt.extend({ | |||
number_format = this.get_number_format(); | |||
} | |||
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() { | |||
@@ -241,16 +241,10 @@ frappe.ui.form.ControlLink = frappe.ui.form.ControlData.extend({ | |||
}); | |||
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.$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; | |||
}); | |||
@@ -12,6 +12,7 @@ class MentionBlot extends Embed { | |||
denotationChar.innerHTML = data.denotationChar; | |||
node.appendChild(denotationChar); | |||
node.innerHTML += data.value; | |||
node.innerHTML += `${data.isGroup === 'true' ? frappe.utils.icon('users') : ''}`; | |||
node.dataset.id = data.id; | |||
node.dataset.value = data.value; | |||
node.dataset.denotationChar = data.denotationChar; | |||
@@ -24,7 +24,7 @@ frappe.ui.form.Footer = Class.extend({ | |||
parent: this.wrapper.find(".comment-box"), | |||
render_input: true, | |||
only_input: true, | |||
mentions: frappe.utils.get_names_for_mentions(), | |||
enable_mentions: true, | |||
df: { | |||
fieldtype: 'Comment', | |||
fieldname: 'comment' | |||
@@ -492,7 +492,7 @@ class FormTimeline extends BaseTimeline { | |||
fieldname: 'comment', | |||
label: 'Comment' | |||
}, | |||
mentions: frappe.utils.get_names_for_mentions(), | |||
enable_mentions: true, | |||
render_input: true, | |||
only_input: true, | |||
no_wrapper: true | |||
@@ -451,7 +451,7 @@ frappe.ui.form.Form = class FrappeForm { | |||
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.dashboard.after_refresh() | |||
]); | |||
@@ -7,7 +7,8 @@ export default class GridRow { | |||
$.extend(this, opts); | |||
if (this.doc && this.parent_df.options) { | |||
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_list = []; | |||
@@ -66,7 +66,7 @@ export default class GridRowForm { | |||
</div> | |||
</div> | |||
<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-shortcuts"> | |||
<span> ${frappe.utils.icon("keyboard", "md")} </span> | |||
@@ -32,7 +32,7 @@ frappe.ui.form.Toolbar = class Toolbar { | |||
} | |||
set_title() { | |||
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) { | |||
let title_field = (this.frm.doc[this.frm.meta.title_field] || "").toString().trim(); | |||
var title = strip_html(title_field || this.frm.docname); | |||
@@ -551,7 +551,7 @@ frappe.ui.form.Toolbar = class Toolbar { | |||
let fields = this.frm.fields | |||
.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({ | |||
title: __('Jump to field'), | |||
@@ -150,13 +150,13 @@ frappe.views.ListViewSelect = class ListViewSelect { | |||
const views_wrapper = this.sidebar.sidebar.find(".views-section"); | |||
views_wrapper.find(".sidebar-label").html(`${__(view)}`); | |||
const $dropdown = views_wrapper.find(".views-dropdown"); | |||
let placeholder = `Select ${view}`; | |||
let placeholder = `${__("Select {0}", [__(view)])}`; | |||
let html = ``; | |||
if (!items || !items.length) { | |||
html = `<div class="empty-state"> | |||
${__("No {} Found", [view])} | |||
${__("No {0} Found", [__(view)])} | |||
</div>`; | |||
} else { | |||
const page_name = this.get_page_name(); | |||
@@ -5,7 +5,7 @@ | |||
<div class="tag-filters-area"> | |||
<div class="active-tag-filters"> | |||
<button class="btn btn-default btn-xs add-filter text-muted"> | |||
Add Filter | |||
{{ __("Add Filter") }} | |||
</button> | |||
</div> | |||
</div> | |||
@@ -71,12 +71,12 @@ | |||
</div> | |||
<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'" > | |||
<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 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 v-if="requests.length != 0" class="list-paging-area"> | |||
@@ -108,12 +108,12 @@ export default { | |||
return { | |||
requests: [], | |||
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: { | |||
sort: "duration", | |||
@@ -140,7 +140,7 @@ export default { | |||
mounted() { | |||
this.fetch_status(); | |||
this.refresh(); | |||
this.$root.page.set_secondary_action("Clear", () => { | |||
this.$root.page.set_secondary_action(__("Clear"), () => { | |||
frappe.set_route("recorder"); | |||
this.clear(); | |||
}); | |||
@@ -151,11 +151,11 @@ export default { | |||
const current_page = this.query.pagination.page; | |||
const total_pages = this.query.pagination.total; | |||
return [{ | |||
label: "First", | |||
label: __("First"), | |||
number: 1, | |||
status: (current_page == 1) ? "disabled" : "", | |||
},{ | |||
label: "Previous", | |||
label: __("Previous"), | |||
number: Math.max(current_page - 1, 1), | |||
status: (current_page == 1) ? "disabled" : "", | |||
}, { | |||
@@ -163,11 +163,11 @@ export default { | |||
number: current_page, | |||
status: "btn-info", | |||
}, { | |||
label: "Next", | |||
label: __("Next"), | |||
number: Math.min(current_page + 1, total_pages), | |||
status: (current_page == total_pages) ? "disabled" : "", | |||
}, { | |||
label: "Last", | |||
label: __("Last"), | |||
number: total_pages, | |||
status: (current_page == total_pages) ? "disabled" : "", | |||
}]; | |||
@@ -230,11 +230,11 @@ export default { | |||
}, | |||
update_buttons: function() { | |||
if(this.status.status == "Active") { | |||
this.$root.page.set_primary_action("Stop", () => { | |||
this.$root.page.set_primary_action(__("Stop"), () => { | |||
this.stop(); | |||
}); | |||
} else { | |||
this.$root.page.set_primary_action("Start", () => { | |||
this.$root.page.set_primary_action(__("Start"), () => { | |||
this.start(); | |||
}); | |||
} | |||
@@ -16,7 +16,7 @@ | |||
</div> | |||
<div class="row form-section visible-section"> | |||
<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 class="col-sm-2 filter-list"> | |||
<div class="sort-selector"> | |||
@@ -37,7 +37,7 @@ | |||
<div class="checkbox"> | |||
<label> | |||
<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> | |||
</div> | |||
</div> | |||
@@ -48,15 +48,15 @@ | |||
<div class="grid-row"> | |||
<div class="data-row row"> | |||
<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="static-area ellipsis">Query</div> | |||
<div class="static-area ellipsis">{{ __("Query") }}</div> | |||
</div> | |||
<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 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> | |||
@@ -82,7 +82,7 @@ | |||
<div class="recorder-form-in-grid" v-if="showing == call.index"> | |||
<div class="grid-form-heading" @click="showing = null"> | |||
<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;"> | |||
<span class="hidden-xs octicon octicon-triangle-up"></span> | |||
</div> | |||
@@ -98,25 +98,25 @@ | |||
<form> | |||
<div class="frappe-control"> | |||
<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> | |||
</div> | |||
<div class="frappe-control input-max-width"> | |||
<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> | |||
</div> | |||
<div class="frappe-control input-max-width"> | |||
<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> | |||
</div> | |||
<div class="frappe-control"> | |||
<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"> | |||
<table class="table table-striped"> | |||
<thead> | |||
@@ -137,7 +137,7 @@ | |||
</div> | |||
<div class="frappe-control" v-if="call.explain_result[0]"> | |||
<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"> | |||
<table class="table table-striped"> | |||
<thead> | |||
@@ -165,7 +165,7 @@ | |||
</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> | |||
@@ -201,19 +201,19 @@ export default { | |||
data() { | |||
return { | |||
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: [ | |||
{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: { | |||
sort: "duration", | |||
@@ -236,11 +236,11 @@ export default { | |||
const current_page = this.query.pagination.page; | |||
const total_pages = this.query.pagination.total; | |||
return [{ | |||
label: "First", | |||
label: __("First"), | |||
number: 1, | |||
status: (current_page == 1) ? "disabled" : "", | |||
},{ | |||
label: "Previous", | |||
label: __("Previous"), | |||
number: Math.max(current_page - 1, 1), | |||
status: (current_page == 1) ? "disabled" : "", | |||
}, { | |||
@@ -248,11 +248,11 @@ export default { | |||
number: current_page, | |||
status: "btn-info", | |||
}, { | |||
label: "Next", | |||
label: __("Next"), | |||
number: Math.min(current_page + 1, total_pages), | |||
status: (current_page == total_pages) ? "disabled" : "", | |||
}, { | |||
label: "Last", | |||
label: __("Last"), | |||
number: total_pages, | |||
status: (current_page == total_pages) ? "disabled" : "", | |||
}]; | |||
@@ -6,6 +6,9 @@ import RecorderRoot from "./RecorderRoot.vue"; | |||
import RecorderDetail from "./RecorderDetail.vue"; | |||
import RequestDetail from "./RequestDetail.vue"; | |||
Vue.prototype.__ = window.__; | |||
Vue.prototype.frappe = window.frappe; | |||
Vue.use(VueRouter); | |||
const routes = [ | |||
{ | |||
@@ -36,18 +36,6 @@ frappe.ui.FieldSelect = Class.extend({ | |||
var item = me.awesomplete.get_item(value); | |||
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) { | |||
for(var i in this.filter_fields) | |||
@@ -312,7 +312,7 @@ class NotificationsView extends BaseNotificationsView { | |||
this.container.append($(`<div class="notification-null-state"> | |||
<div class="text-center"> | |||
<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"> | |||
${__('Looks like you haven’t received any notifications.')} | |||
</div></div></div>`)); | |||
@@ -430,7 +430,7 @@ class EventsView extends BaseNotificationsView { | |||
<div class="notification-null-state"> | |||
<div class="text-center"> | |||
<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"> | |||
${__('There are no upcoming events for you.')} | |||
</div></div></div> | |||
@@ -11,6 +11,34 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher { | |||
title: __("Switch Theme") | |||
}); | |||
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() { | |||
@@ -1272,31 +1272,6 @@ Object.assign(frappe.utils, { | |||
</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) { | |||
let w = window.open( | |||
frappe.urllib.get_full_url( | |||
@@ -68,6 +68,8 @@ frappe.breadcrumbs = { | |||
if (breadcrumbs.doctype && ["print", "form"].includes(view)) { | |||
this.set_list_breadcrumb(breadcrumbs); | |||
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() { | |||
return super.setup_defaults() | |||
.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; | |||
}); | |||
} | |||
@@ -271,7 +271,7 @@ frappe.views.DashboardView = class DashboardView extends frappe.views.ListView { | |||
show_add_chart_dialog() { | |||
let fields = this.get_field_options(); | |||
const dialog = new frappe.ui.Dialog({ | |||
title: __("Add a {0} Chart", [this.doctype]), | |||
title: __("Add a {0} Chart", [__(this.doctype)]), | |||
fields: [ | |||
{ | |||
fieldname: 'new_or_existing', | |||
@@ -1,3 +1,5 @@ | |||
// TODO: Refactor for better UX | |||
frappe.provide("frappe.views"); | |||
(function() { | |||
@@ -185,7 +187,7 @@ frappe.provide("frappe.views"); | |||
new_index: card.new_index, | |||
}; | |||
} | |||
frappe.dom.freeze(); | |||
frappe.call({ | |||
method: method_prefix + method_name, | |||
args: args, | |||
@@ -198,6 +200,7 @@ frappe.provide("frappe.views"); | |||
cards: cards, | |||
columns: columns | |||
}); | |||
frappe.dom.unfreeze(); | |||
} | |||
}).fail(function() { | |||
// revert original order | |||
@@ -205,6 +208,7 @@ frappe.provide("frappe.views"); | |||
cards: _cards, | |||
columns: _columns | |||
}); | |||
frappe.dom.unfreeze(); | |||
}); | |||
}, | |||
update_order: function(updater) { | |||
@@ -335,12 +335,12 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { | |||
let message; | |||
if (dashboard_name) { | |||
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 { | |||
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 { | |||
wrapper[0].innerHTML = | |||
`<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>`; | |||
} | |||
} | |||
@@ -1094,7 +1094,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { | |||
return Object.assign(column, { | |||
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, | |||
editable: false, | |||
compareValue: compareFn, | |||
@@ -1343,7 +1343,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { | |||
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) { | |||
@@ -87,11 +87,13 @@ export default class WebForm extends frappe.ui.FieldGroup { | |||
} | |||
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() { | |||
@@ -190,9 +190,11 @@ export default class WebFormList { | |||
make_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( | |||
actions, | |||
@@ -2,7 +2,6 @@ import Widget from "./base_widget.js"; | |||
frappe.provide("frappe.utils"); | |||
const INDICATOR_COLORS = ["Grey", "Green", "Red", "Orange", "Pink", "Yellow", "Blue", "Cyan", "Teal"]; | |||
export default class ShortcutWidget extends Widget { | |||
constructor(opts) { | |||
opts.shadow = true; | |||
@@ -79,7 +78,7 @@ export default class ShortcutWidget extends Widget { | |||
this.action_area.empty(); | |||
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); | |||
} | |||
} |
@@ -237,9 +237,19 @@ class ShortcutDialog extends WidgetDialog { | |||
hidden: 1, | |||
}, | |||
{ | |||
fieldtype: "Color", | |||
fieldtype: "Select", | |||
fieldname: "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", | |||
@@ -24,6 +24,17 @@ | |||
--blue-100: #D3E9FC; | |||
--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-800: #44622A; | |||
--green-700: #518B21; | |||
@@ -151,6 +162,8 @@ | |||
--bg-gray: var(--gray-200); | |||
--bg-light-gray: var(--gray-100); | |||
--bg-purple: var(--purple-100); | |||
--bg-pink: var(--pink-50); | |||
--bg-cyan: var(--cyan-50); | |||
--text-on-blue: var(--blue-600); | |||
--text-on-light-blue: var(--blue-500); | |||
@@ -163,6 +176,8 @@ | |||
--text-on-gray: var(--gray-600); | |||
--text-on-light-gray: var(--gray-800); | |||
--text-on-purple: var(--purple-500); | |||
--text-on-pink: var(--pink-500); | |||
--text-on-cyan: var(--cyan-600); | |||
--awesomplete-hover-bg: var(--control-bg); | |||
@@ -76,6 +76,22 @@ input[type="checkbox"] { | |||
@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"].form-group { | |||
position: relative; | |||
@@ -77,6 +77,16 @@ | |||
@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 { | |||
@include indicator-color('blue'); | |||
} | |||
@@ -131,6 +141,16 @@ | |||
@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-right.darkgrey, | |||
.indicator-pill-round.darkgrey { | |||
@@ -2,25 +2,50 @@ h5.modal-title { | |||
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 { | |||
// 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 { | |||
border-color: var(--border-color); | |||
} | |||
.modal-header { | |||
position: sticky; | |||
top: 0; | |||
z-index: 3; | |||
background: inherit; | |||
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 { | |||
font-weight: 500; | |||
line-height: 2em; | |||
font-size: $font-size-lg; | |||
max-width: calc(100% - 80px); | |||
} | |||
.modal-actions { | |||
@@ -60,9 +85,17 @@ body.modal-open { | |||
} | |||
} | |||
.awesomplete ul { | |||
z-index: 2; | |||
} | |||
.modal-footer { | |||
position: sticky; | |||
bottom: 0; | |||
z-index: 1; | |||
background: inherit; | |||
padding: var(--padding-md) var(--padding-lg); | |||
border-top: 0; | |||
border-top: 1px solid var(--border-color); | |||
justify-content: space-between; | |||
button { | |||
@@ -105,6 +105,7 @@ | |||
padding: 10px 12px; | |||
height: initial; | |||
line-height: initial; | |||
cursor: pointer; | |||
&.selected { | |||
background-color: var(--control-bg); | |||
@@ -163,7 +164,7 @@ | |||
} | |||
.ql-editor td { | |||
border: 1px solid var(--border-color); | |||
border: 1px solid var(--dark-border-color); | |||
} | |||
.ql-editor blockquote { | |||
@@ -196,5 +197,8 @@ | |||
} | |||
.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 { | |||
// SIZE & SPACING | |||
margin: 0px 30px; | |||
width: 180px; | |||
min-width: 180px; | |||
max-width: 300px; | |||
height: 62px; | |||
// LAYOUT | |||
@@ -9,11 +9,6 @@ html { | |||
} | |||
/* Works on Chrome, Edge, and Safari */ | |||
*::-webkit-scrollbar { | |||
width: 6px; | |||
height: 6px; | |||
} | |||
*::-webkit-scrollbar-thumb { | |||
background: var(--scrollbar-thumb-color); | |||
} | |||
@@ -23,7 +18,12 @@ html { | |||
background: var(--scrollbar-track-color); | |||
} | |||
*::-webkit-scrollbar { | |||
width: 6px; | |||
height: 6px; | |||
} | |||
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 | |||
self.execute( | |||
"bench --site {site} set-config backup '{includes}' --as-dict", | |||
"bench --site {site} set-config backup '{includes}' --parse", | |||
{"includes": json.dumps(backup["includes"])}, | |||
) | |||
self.execute("bench --site {site} backup --verbose") | |||
@@ -226,7 +226,7 @@ class TestCommands(BaseTestCommands): | |||
# test 8: take a backup with frappe.conf.backup.excludes | |||
self.execute( | |||
"bench --site {site} set-config backup '{excludes}' --as-dict", | |||
"bench --site {site} set-config backup '{excludes}' --parse", | |||
{"excludes": json.dumps(backup["excludes"])}, | |||
) | |||
self.execute("bench --site {site} backup --verbose") | |||
@@ -365,6 +365,43 @@ class TestCommands(BaseTestCommands): | |||
installed_apps = set(frappe.get_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): | |||
bench_path = frappe.utils.get_bench_path() | |||
test1_path = os.path.join(bench_path, "test1.txt") | |||
@@ -2,10 +2,14 @@ | |||
# MIT License. See license.txt | |||
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 urllib.parse import urlencode, quote | |||
import frappe | |||
from frappe.test_runner import make_test_records | |||
from frappe.integrations.oauth2 import encode_params | |||
class TestOAuth20(unittest.TestCase): | |||
@@ -34,11 +38,7 @@ class TestOAuth20(unittest.TestCase): | |||
self.assertFalse(check_valid_openid_response()) | |||
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() | |||
login(session) | |||
@@ -71,7 +71,8 @@ class TestOAuth20(unittest.TestCase): | |||
"grant_type": "authorization_code", | |||
"code": auth_code, | |||
"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(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): | |||
client = frappe.get_doc("OAuth Client", self.client_id) | |||
client.grant_type = "Authorization Code" | |||
@@ -203,6 +252,61 @@ class TestOAuth20(unittest.TestCase): | |||
self.assertTrue(response_dict.get("token_type")) | |||
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): | |||
"""Return True for valid response.""" | |||
@@ -233,3 +337,12 @@ def login(session): | |||
def get_full_url(endpoint): | |||
"""Turn '/endpoint' into 'http://127.0.0.1:8000/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, | |||
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: | |||
messages.extend([(None, message) for message in re.findall('"([^:,^"]*):', report.query) if is_translatable(message)]) | |||
messages.append((None,report.report_name)) | |||
return messages | |||
@@ -18,8 +18,7 @@ from email.utils import formataddr, parseaddr | |||
from gzip import GzipFile | |||
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 | |||
import frappe | |||
@@ -72,7 +71,7 @@ def get_formatted_email(user, mail=None): | |||
def extract_email_id(email): | |||
"""fetch only the email part of the Email Address""" | |||
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") | |||
return email_id | |||
@@ -370,14 +369,14 @@ def get_site_url(site): | |||
def encode_dict(d, encoding="utf-8"): | |||
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) | |||
return d | |||
def decode_dict(d, encoding="utf-8"): | |||
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") | |||
return d | |||
@@ -644,7 +643,7 @@ def parse_json(val): | |||
""" | |||
Parses json if string else return | |||
""" | |||
if isinstance(val, string_types): | |||
if isinstance(val, str): | |||
val = json.loads(val) | |||
if isinstance(val, dict): | |||
val = frappe._dict(val) | |||
@@ -813,3 +812,11 @@ def groupby_metric(iterable: typing.Dict[str, list], key: str): | |||
for item in items: | |||
records.setdefault(item[key], {}).setdefault(category, []).append(item) | |||
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 | |||
from __future__ import unicode_literals, print_function | |||
@@ -126,16 +126,12 @@ recursive-include {app_name} *.svg | |||
recursive-include {app_name} *.txt | |||
recursive-exclude {app_name} *.pyc""" | |||
init_template = """# -*- coding: utf-8 -*- | |||
from __future__ import unicode_literals | |||
init_template = """ | |||
__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_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(): | |||
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: | |||
install_requires = f.read().strip().split('\\n') | |||
@@ -1,11 +1,10 @@ | |||
import functools | |||
import requests | |||
from terminaltables import AsciiTable | |||
@functools.lru_cache(maxsize=1024) | |||
def get_first_party_apps(): | |||
"""Get list of all apps under orgs: frappe. erpnext from GitHub""" | |||
import requests | |||
apps = [] | |||
for org in ["frappe", "erpnext"]: | |||
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): | |||
from terminaltables import AsciiTable | |||
print(AsciiTable(data).table) | |||
@@ -49,3 +50,9 @@ def log(message, colour=''): | |||
colour = colours.get(colour, "") | |||
end_line = '\033[0m' | |||
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", | |||
"field_order": [ | |||
"email", | |||
"status" | |||
"status", | |||
"anonymization_matrix", | |||
"deletion_steps" | |||
], | |||
"fields": [ | |||
{ | |||
@@ -27,10 +29,23 @@ | |||
"label": "Status", | |||
"options": "Pending Verification\nPending Approval\nDeleted", | |||
"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": [], | |||
"modified": "2021-02-28 12:36:08.219719", | |||
"modified": "2021-04-23 13:25:53.629308", | |||
"modified_by": "Administrator", | |||
"module": "Website", | |||
"name": "Personal Data Deletion Request", | |||
@@ -10,6 +10,8 @@ from frappe.model.document import Document | |||
from frappe.utils import get_fullname | |||
from frappe.utils.user import get_system_managers | |||
from frappe.utils.verified_command import get_signed_params, verify_request | |||
import json | |||
from frappe.core.utils import find | |||
class PersonalDataDeletionRequest(Document): | |||
@@ -118,6 +120,24 @@ class PersonalDataDeletionRequest(Document): | |||
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): | |||
self.__redact_partial_match_data(doctype) | |||
self.rename_documents(doctype) | |||
@@ -143,11 +163,11 @@ class PersonalDataDeletionRequest(Document): | |||
def redact_full_match_data(self, ref, email): | |||
"""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( | |||
ref["doctype"], | |||
filters={filter_by: ("like", "%" + email + "%")}, | |||
filters={filter_by: email}, | |||
fields=["name", filter_by], | |||
) | |||
@@ -185,7 +205,7 @@ class PersonalDataDeletionRequest(Document): | |||
return anonymize_fields_dict | |||
def redact_doc(self, doc, ref): | |||
filter_by = ref["filter_by"] | |||
filter_by = ref.get("filter_by", "owner") | |||
meta = frappe.get_meta(ref["doctype"]) | |||
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 | |||
) | |||
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 | |||
anon = anon or self.name | |||
if set_data: | |||
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.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.set_step_status(doctype["doctype"]) | |||
if commit: | |||
frappe.db.commit() | |||
frappe.rename_doc("User", email, anon, force=True, show_alert=False) | |||
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): | |||
self.anon = anon or self.name | |||
@@ -290,9 +346,8 @@ def confirm_deletion(email, name, host_name): | |||
frappe.db.commit() | |||
frappe.respond_as_web_page( | |||
_("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", | |||
) | |||
@@ -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", | |||
"in_list_view": 1, | |||
"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", | |||
@@ -146,7 +146,7 @@ | |||
], | |||
"istable": 1, | |||
"links": [], | |||
"modified": "2020-11-10 23:20:44.354862", | |||
"modified": "2021-04-30 12:02:25.422345", | |||
"modified_by": "Administrator", | |||
"module": "Website", | |||
"name": "Web Form Field", | |||
@@ -21,7 +21,7 @@ $('.dropdown-menu a.dropdown-toggle').on('click', function (e) { | |||
frappe.get_modal = function (title, content) { | |||
return $( | |||
`<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-header"> | |||
<h5 class="modal-title">${title}</h5> | |||
@@ -16,7 +16,9 @@ | |||
{%- endif -%} | |||
<div class="split-section-content col-12 {{ left_col if image_on_right else right_col }} {{ align_content }}"> | |||
<h2>{{ title }}</h2> | |||
{%- if content -%} | |||
<p>{{ content }}</p> | |||
{%- endif -%} | |||
{%- if link_label and link_url -%} | |||
<a href="{{ link_url }}">{{ link_label }}</a> | |||