Merge branch 'develop' into default-desk-page

This commit is contained in:
Suraj Shetty 2021-01-26 09:55:18 +05:30 committed by GitHub
commit 0363d787e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 1048 additions and 99 deletions

View file

@ -15,7 +15,7 @@ If your issue is not clear or does not meet the guidelines, then it will be clos
### General Issue Guidelines
1. **Search existing Issues:** Before raising a Issue, search if it has been raised before. Maybe add a 👍 or give additional help by creating a mockup if it is not already created.
2. **Report each issue separately:** Don't club multiple, unreleated issues in one note.
2. **Report each issue separately:** Don't club multiple, unrelated issues in one note.
3. **Brief:** Please don't include long explanations. Use screenshots and bullet points instead of descriptive paragraphs.
### Bug Report Guidelines

View file

@ -104,11 +104,11 @@ install:
- cd ./frappe-bench
- sed -i 's/watch:/# watch:/g' Procfile
- sed -i 's/schedule:/# schedule:/g' Procfile
- sed -i 's/^watch:/# watch:/g' Procfile
- sed -i 's/^schedule:/# schedule:/g' Procfile
- if [ $TYPE == "server" ]; then sed -i 's/socketio:/# socketio:/g' Procfile; fi
- if [ $TYPE == "server" ]; then sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile; fi
- if [ $TYPE == "server" ]; then sed -i 's/^socketio:/# socketio:/g' Procfile; fi
- if [ $TYPE == "server" ]; then sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile; fi
- if [ $TYPE == "ui" ]; then bench setup requirements --node; fi

View file

@ -23,7 +23,7 @@
"repeat_on_last_day",
"column_break_12",
"next_schedule_date",
"section_break_12",
"section_break_16",
"repeat_on_days",
"notification",
"notify_by_email",
@ -198,20 +198,20 @@
"label": "Repeat on Days",
"options": "Auto Repeat Day"
},
{
"depends_on": "eval:doc.frequency==='Weekly';",
"fieldname": "section_break_12",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "submit_on_creation",
"fieldtype": "Check",
"label": "Submit on Creation"
},
{
"depends_on": "eval:doc.frequency==='Weekly';",
"fieldname": "section_break_16",
"fieldtype": "Section Break"
}
],
"links": [],
"modified": "2020-12-10 10:43:13.449172",
"modified": "2021-01-12 09:24:49.719611",
"modified_by": "Administrator",
"module": "Automation",
"name": "Auto Repeat",

View file

@ -77,6 +77,11 @@ def get_data():
"name": "OAuth Provider Settings",
"description": _("Settings for OAuth Provider"),
},
{
"type": "doctype",
"name": "Connected App",
"description": _("Connect to any OAuth Provider"),
},
]
},
{

View file

@ -751,7 +751,7 @@ class Row:
self.warnings.append(
{
"row": self.row_number,
"message": _("{0} is a mandatory field asdadsf").format(id_field.label),
"message": _("{0} is a mandatory field").format(id_field.label),
}
)
return

View file

@ -642,10 +642,15 @@
"group": "Activity",
"link_doctype": "ToDo",
"link_fieldname": "owner"
},
{
"group": "Integrations",
"link_doctype": "Token Cache",
"link_fieldname": "user"
}
],
"max_attachments": 5,
"modified": "2020-08-26 19:48:49.677800",
"modified": "2020-10-18 15:18:53.126800",
"modified_by": "Administrator",
"module": "Core",
"name": "User",

View file

@ -73,7 +73,7 @@ def has_permission(doc, ptype, user):
if doc.report_name in allowed_reports:
return True
else:
allowed_doctypes = [frappe.permissions.get_doctypes_with_read()]
allowed_doctypes = frappe.permissions.get_doctypes_with_read()
if doc.document_type in allowed_doctypes:
return True

View file

@ -42,7 +42,6 @@ def cancel(doctype=None, name=None, workflow_state_fieldname=None, workflow_stat
except Exception:
frappe.errprint(frappe.utils.get_traceback())
frappe.msgprint(frappe._("Did not cancel"))
raise
def send_updated_docs(doc):

View file

@ -18,14 +18,14 @@ def install():
@frappe.whitelist()
def update_genders():
default_genders = [_("Male"), _("Female"), _("Other"),_("Transgender"), _("Genderqueer"), _("Non-Conforming"),_("Prefer not to say")]
default_genders = ["Male", "Female", "Other","Transgender", "Genderqueer", "Non-Conforming","Prefer not to say"]
records = [{'doctype': 'Gender', 'gender': d} for d in default_genders]
for record in records:
frappe.get_doc(record).insert(ignore_permissions=True, ignore_if_duplicate=True)
@frappe.whitelist()
def update_salutations():
default_salutations = [_("Mr"), _("Ms"), _('Mx'), _("Dr"), _("Mrs"), _("Madam"), _("Miss"), _("Master"), _("Prof")]
default_salutations = ["Mr", "Ms", 'Mx', "Dr", "Mrs", "Madam", "Miss", "Master", "Prof"]
records = [{'doctype': 'Salutation', 'salutation': d} for d in default_salutations]
for record in records:
doc = frappe.new_doc(record.get("doctype"))

View file

@ -81,7 +81,7 @@ class AutoEmailReport(Document):
if self.format == 'HTML':
columns, data = make_links(columns, data)
columns = update_field_types(columns)
return self.get_html_table(columns, data)
elif self.format == 'XLSX':
@ -236,5 +236,14 @@ def make_links(columns, data):
elif col.fieldtype == "Dynamic Link":
if col.options and row.get(col.fieldname) and row.get(col.options):
row[col.fieldname] = get_link_to_form(row[col.options], row[col.fieldname])
elif col.fieldtype == "Currency":
row[col.fieldname] = frappe.format_value(row[col.fieldname], col)
return columns, data
def update_field_types(columns):
for col in columns:
if col.fieldtype in ("Link", "Dynamic Link", "Currency") and col.options != "Currency":
col.fieldtype = "Data"
col.options = ""
return columns

View file

@ -210,7 +210,7 @@ class EmailAccount(Document):
elif not in_receive and any(map(lambda t: t in message, auth_error_codes)):
self.throw_invalid_credentials_exception()
else:
frappe.throw(e)
frappe.throw(cstr(e))
except socket.error:
if in_receive:

View file

@ -2,58 +2,66 @@
# License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals
import frappe, unittest
from frappe.utils import getdate, add_days
import unittest
from random import choice
from frappe.email.doctype.newsletter.newsletter import confirmed_unsubscribe, send_scheduled_email
from six.moves.urllib.parse import unquote
import frappe
from frappe.email.doctype.newsletter.newsletter import (
confirmed_unsubscribe,
send_scheduled_email,
)
from frappe.email.doctype.newsletter.newsletter import get_newsletter_list
from frappe.email.queue import flush
from frappe.utils import add_days, getdate
test_dependencies = ["Email Group"]
emails = [
"test_subscriber1@example.com",
"test_subscriber2@example.com",
"test_subscriber3@example.com",
"test1@example.com",
]
emails = ["test_subscriber1@example.com", "test_subscriber2@example.com",
"test_subscriber3@example.com", "test1@example.com"]
class TestNewsletter(unittest.TestCase):
def setUp(self):
frappe.set_user("Administrator")
frappe.db.sql('delete from `tabEmail Group Member`')
frappe.db.sql("delete from `tabEmail Group Member`")
if not frappe.db.exists("Email Group", "_Test Email Group"):
frappe.get_doc({"doctype": "Email Group", "title": "_Test Email Group"}).insert()
group_exist=frappe.db.exists("Email Group", "_Test Email Group")
if len(group_exist) == 0:
frappe.get_doc({
"doctype": "Email Group",
"title": "_Test Email Group"
}).insert()
for email in emails:
frappe.get_doc({
"doctype": "Email Group Member",
"email": email,
"email_group": "_Test Email Group"
}).insert()
frappe.get_doc({
"doctype": "Email Group Member",
"email": email,
"email_group": "_Test Email Group"
}).insert()
def test_send(self):
name = self.send_newsletter()
self.send_newsletter()
email_queue_list = [frappe.get_doc('Email Queue', e.name) for e in frappe.get_all("Email Queue")]
email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")]
self.assertEqual(len(email_queue_list), 4)
recipients = [e.recipients[0].recipient for e in email_queue_list]
for email in emails:
self.assertTrue(email in recipients)
recipients = set([e.recipients[0].recipient for e in email_queue_list])
self.assertTrue(set(emails).issubset(recipients))
def test_unsubscribe(self):
# test unsubscribe
name = self.send_newsletter()
from frappe.email.queue import flush
to_unsubscribe = choice(emails)
group = frappe.get_all("Newsletter Email Group", filters={"parent": name}, fields=["email_group"])
flush(from_test=True)
to_unsubscribe = unquote(frappe.local.flags.signed_query_string.split("email=")[1].split("&")[0])
group = frappe.get_all("Newsletter Email Group", filters={"parent" : name}, fields=["email_group"])
confirmed_unsubscribe(to_unsubscribe, group[0].email_group)
name = self.send_newsletter()
email_queue_list = [frappe.get_doc('Email Queue', e.name) for e in frappe.get_all("Email Queue")]
email_queue_list = [
frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")
]
self.assertEqual(len(email_queue_list), 3)
recipients = [e.recipients[0].recipient for e in email_queue_list]
for email in emails:
if email != to_unsubscribe:
self.assertTrue(email in recipients)
@ -86,7 +94,6 @@ class TestNewsletter(unittest.TestCase):
def test_portal(self):
self.send_newsletter(1)
frappe.set_user("test1@example.com")
from frappe.email.doctype.newsletter.newsletter import get_newsletter_list
newsletters = get_newsletter_list("Newsletter", None, None, 0)
self.assertEqual(len(newsletters), 1)
@ -106,4 +113,4 @@ class TestNewsletter(unittest.TestCase):
self.assertEqual(len(email_queue_list), 4)
recipients = [e.recipients[0].recipient for e in email_queue_list]
for email in emails:
self.assertTrue(email in recipients)
self.assertTrue(email in recipients)

View file

@ -295,7 +295,7 @@ def set_update(update, producer_site):
if data.changed:
local_doc.update(data.changed)
if data.removed:
update_row_removed(local_doc, data.removed)
local_doc = update_row_removed(local_doc, data.removed)
if data.row_changed:
update_row_changed(local_doc, data.row_changed)
if data.added:
@ -318,7 +318,17 @@ def update_row_removed(local_doc, removed):
for tablename, rownames in iteritems(removed):
table = local_doc.get_table_field_doctype(tablename)
for row in rownames:
frappe.db.delete(table, row)
table_rows = local_doc.get(tablename)
child_table_row = get_child_table_row(table_rows, row)
table_rows.remove(child_table_row)
local_doc.set(tablename, table_rows)
return local_doc
def get_child_table_row(table_rows, row):
for entry in table_rows:
if entry.get('name') == row:
return entry
def update_row_changed(local_doc, changed):

View file

@ -13,7 +13,7 @@
{
"hidden": 0,
"label": "Authentication",
"links": "[\n {\n \"description\": \"Enter keys to enable login via Facebook, Google, GitHub.\",\n \"label\": \"Social Login Key\",\n \"name\": \"Social Login Key\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Ldap settings\",\n \"label\": \"LDAP Settings\",\n \"name\": \"LDAP Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Register OAuth Client App\",\n \"label\": \"OAuth Client\",\n \"name\": \"OAuth Client\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Settings for OAuth Provider\",\n \"label\": \"OAuth Provider Settings\",\n \"name\": \"OAuth Provider Settings\",\n \"type\": \"doctype\"\n }\n]"
"links": "[\n {\n \"description\": \"Enter keys to enable login via Facebook, Google, GitHub.\",\n \"label\": \"Social Login Key\",\n \"name\": \"Social Login Key\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Ldap settings\",\n \"label\": \"LDAP Settings\",\n \"name\": \"LDAP Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Register OAuth Client App\",\n \"label\": \"OAuth Client\",\n \"name\": \"OAuth Client\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Settings for OAuth Provider\",\n \"label\": \"OAuth Provider Settings\",\n \"name\": \"OAuth Provider Settings\",\n \"type\": \"doctype\"\n }\n ,\n {\n \"description\": \"Connect to any OAuth Provider\",\n \"label\": \"Connected App\",\n \"name\": \"Connected App\",\n \"type\": \"doctype\"\n }\n]"
},
{
"hidden": 0,

View file

@ -0,0 +1,38 @@
// Copyright (c) 2019, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on('Connected App', {
refresh: frm => {
frm.add_custom_button(__('Get OpenID Configuration'), async () => {
if (!frm.doc.openid_configuration) {
frappe.msgprint(__('Please enter OpenID Configuration URL'));
} else {
try {
const response = await fetch(frm.doc.openid_configuration);
const oidc = await response.json();
frm.set_value('authorization_uri', oidc.authorization_endpoint);
frm.set_value('token_uri', oidc.token_endpoint);
frm.set_value('userinfo_uri', oidc.userinfo_endpoint);
frm.set_value('introspection_uri', oidc.introspection_endpoint);
frm.set_value('revocation_uri', oidc.revocation_endpoint);
} catch (error) {
frappe.msgprint(__('Please check OpenID Configuration URL'));
}
}
});
if (!frm.is_new()) {
frm.add_custom_button(__('Connect to {}', [frm.doc.provider_name]), async () => {
frappe.call({
method: 'initiate_web_application_flow',
doc: frm.doc,
callback: function(r) {
window.open(r.message, '_blank');
}
});
});
}
frm.toggle_display('sb_client_credentials_section', !frm.is_new());
}
});

View file

@ -0,0 +1,166 @@
{
"actions": [],
"beta": 1,
"creation": "2019-01-24 15:51:06.362222",
"doctype": "DocType",
"document_type": "Document",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"provider_name",
"cb_00",
"openid_configuration",
"sb_client_credentials_section",
"client_id",
"redirect_uri",
"cb_01",
"client_secret",
"sb_scope_section",
"scopes",
"sb_endpoints_section",
"authorization_uri",
"token_uri",
"revocation_uri",
"cb_02",
"userinfo_uri",
"introspection_uri",
"section_break_18",
"query_parameters"
],
"fields": [
{
"fieldname": "provider_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Provider Name",
"reqd": 1
},
{
"fieldname": "cb_00",
"fieldtype": "Column Break"
},
{
"fieldname": "openid_configuration",
"fieldtype": "Data",
"label": "OpenID Configuration"
},
{
"collapsible": 1,
"fieldname": "sb_client_credentials_section",
"fieldtype": "Section Break",
"label": "Client Credentials"
},
{
"fieldname": "client_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Client Id"
},
{
"fieldname": "redirect_uri",
"fieldtype": "Data",
"label": "Redirect URI",
"read_only": 1
},
{
"fieldname": "cb_01",
"fieldtype": "Column Break"
},
{
"fieldname": "client_secret",
"fieldtype": "Password",
"label": "Client Secret"
},
{
"collapsible": 1,
"fieldname": "sb_scope_section",
"fieldtype": "Section Break",
"label": "Scopes"
},
{
"collapsible": 1,
"fieldname": "sb_endpoints_section",
"fieldtype": "Section Break",
"label": "Endpoints"
},
{
"fieldname": "cb_02",
"fieldtype": "Column Break"
},
{
"fieldname": "scopes",
"fieldtype": "Table",
"label": "Scopes",
"options": "OAuth Scope"
},
{
"fieldname": "authorization_uri",
"fieldtype": "Data",
"label": "Authorization URI"
},
{
"fieldname": "token_uri",
"fieldtype": "Data",
"label": "Token URI"
},
{
"fieldname": "revocation_uri",
"fieldtype": "Data",
"label": "Revocation URI"
},
{
"fieldname": "userinfo_uri",
"fieldtype": "Data",
"label": "Userinfo URI"
},
{
"fieldname": "introspection_uri",
"fieldtype": "Data",
"label": "Introspection URI"
},
{
"fieldname": "section_break_18",
"fieldtype": "Section Break",
"label": "Extra Parameters"
},
{
"fieldname": "query_parameters",
"fieldtype": "Table",
"label": "Query Parameters",
"options": "Query Parameters"
}
],
"links": [
{
"link_doctype": "Token Cache",
"link_fieldname": "connected_app"
}
],
"modified": "2020-11-16 16:29:50.277405",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Connected App",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"read": 1,
"role": "All"
}
],
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "provider_name",
"track_changes": 1
}

View file

@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt
import os
from urllib.parse import urljoin
from urllib.parse import urlencode
import frappe
from frappe import _
from frappe.model.document import Document
from requests_oauthlib import OAuth2Session
if any((os.getenv('CI'), frappe.conf.developer_mode, frappe.conf.allow_tests)):
# Disable mandatory TLS in developer mode and tests
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'
class ConnectedApp(Document):
"""Connect to a remote oAuth Server. Retrieve and store user's access token
in a Token Cache.
"""
def validate(self):
base_url = frappe.utils.get_url()
callback_path = '/api/method/frappe.integrations.doctype.connected_app.connected_app.callback/' + self.name
self.redirect_uri = urljoin(base_url, callback_path)
def get_oauth2_session(self, user=None, init=False):
token = None
token_updater = None
if not init:
user = user or frappe.session.user
token_cache = self.get_user_token(user)
token = token_cache.get_json()
token_updater = token_cache.update_data
return OAuth2Session(
client_id=self.client_id,
token=token,
token_updater=token_updater,
auto_refresh_url=self.token_uri,
redirect_uri=self.redirect_uri,
scope=self.get_scopes()
)
def initiate_web_application_flow(self, user=None, success_uri=None):
"""Return an authorization URL for the user. Save state in Token Cache."""
user = user or frappe.session.user
oauth = self.get_oauth2_session(init=True)
query_params = self.get_query_params()
authorization_url, state = oauth.authorization_url(self.authorization_uri, **query_params)
token_cache = self.get_token_cache(user)
if not token_cache:
token_cache = frappe.new_doc('Token Cache')
token_cache.user = user
token_cache.connected_app = self.name
token_cache.success_uri = success_uri
token_cache.state = state
token_cache.save(ignore_permissions=True)
frappe.db.commit()
return authorization_url
def get_user_token(self, user=None, success_uri=None):
"""Return an existing user token or initiate a Web Application Flow."""
user = user or frappe.session.user
token_cache = self.get_token_cache(user)
if token_cache:
return token_cache
redirect = self.initiate_web_application_flow(user, success_uri)
frappe.local.response['type'] = 'redirect'
frappe.local.response['location'] = redirect
return redirect
def get_token_cache(self, user):
token_cache = None
token_cache_name = self.name + '-' + user
if frappe.db.exists('Token Cache', token_cache_name):
token_cache = frappe.get_doc('Token Cache', token_cache_name)
return token_cache
def get_scopes(self):
return [row.scope for row in self.scopes]
def get_query_params(self):
return {param.key: param.value for param in self.query_parameters}
@frappe.whitelist(allow_guest=True)
def callback(code=None, state=None):
"""Handle client's code.
Called during the oauthorization flow by the remote oAuth2 server to
transmit a code that can be used by the local server to obtain an access
token.
"""
if frappe.request.method != 'GET':
frappe.throw(_('Invalid request method: {}').format(frappe.request.method))
if frappe.session.user == 'Guest':
frappe.local.response['type'] = 'redirect'
frappe.local.response['location'] = '/login?' + urlencode({'redirect-to': frappe.request.url})
return
path = frappe.request.path[1:].split('/')
if len(path) != 4 or not path[3]:
frappe.throw(_('Invalid Parameters.'))
connected_app = frappe.get_doc('Connected App', path[3])
token_cache = frappe.get_doc('Token Cache', connected_app.name + '-' + frappe.session.user)
if state != token_cache.state:
frappe.throw(_('Invalid state.'))
oauth_session = connected_app.get_oauth2_session(init=True)
query_params = connected_app.get_query_params()
token = oauth_session.fetch_token(connected_app.token_uri,
code=code,
client_secret=connected_app.get_password('client_secret'),
include_client_id=True,
**query_params
)
token_cache.update_data(token)
frappe.local.response['type'] = 'redirect'
frappe.local.response['location'] = token_cache.get('success_uri') or connected_app.get_url()

View file

@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
# See license.txt
from __future__ import unicode_literals
import unittest
import requests
from urllib.parse import urljoin
import frappe
from frappe.integrations.doctype.social_login_key.test_social_login_key import create_or_update_social_login_key
def get_user(usr, pwd):
user = frappe.new_doc('User')
user.email = usr
user.enabled = 1
user.first_name = "_Test"
user.new_password = pwd
user.roles = []
user.append('roles', {
'doctype': 'Has Role',
'parentfield': 'roles',
'role': 'System Manager'
})
user.insert()
return user
def get_connected_app():
doctype = 'Connected App'
connected_app = frappe.new_doc(doctype)
connected_app.provider_name = 'frappe'
connected_app.scopes = []
connected_app.append('scopes', {'scope': 'all'})
connected_app.insert()
return connected_app
def get_oauth_client():
oauth_client = frappe.new_doc('OAuth Client')
oauth_client.app_name = '_Test Connected App'
oauth_client.redirect_uris = 'to be replaced'
oauth_client.default_redirect_uri = 'to be replaced'
oauth_client.grant_type = 'Authorization Code'
oauth_client.response_type = 'Code'
oauth_client.skip_authorization = 1
oauth_client.insert()
return oauth_client
class TestConnectedApp(unittest.TestCase):
def setUp(self):
"""Set up a Connected App that connects to our own oAuth provider.
Frappe comes with it's own oAuth2 provider that we can test against. The
client credentials can be obtained from an "OAuth Client". All depends
on "Social Login Key" so we create one as well.
The redirect URIs from "Connected App" and "OAuth Client" have to match.
Frappe's "Authorization URL" and "Access Token URL" (actually they're
just endpoints) are stored in "Social Login Key" so we get them from
there.
"""
self.user_name = 'test-connected-app@example.com'
self.user_password = 'Eastern_43A1W'
self.user = get_user(self.user_name, self.user_password)
self.connected_app = get_connected_app()
self.oauth_client = get_oauth_client()
social_login_key = create_or_update_social_login_key()
self.base_url = social_login_key.get('base_url')
frappe.db.commit()
self.connected_app.reload()
self.oauth_client.reload()
redirect_uri = self.connected_app.get('redirect_uri')
self.oauth_client.update({
'redirect_uris': redirect_uri,
'default_redirect_uri': redirect_uri
})
self.oauth_client.save()
self.connected_app.update({
'authorization_uri': urljoin(self.base_url, social_login_key.get('authorize_url')),
'client_id': self.oauth_client.get('client_id'),
'client_secret': self.oauth_client.get('client_secret'),
'token_uri': urljoin(self.base_url, social_login_key.get('access_token_url'))
})
self.connected_app.save()
frappe.db.commit()
self.connected_app.reload()
self.oauth_client.reload()
def test_web_application_flow(self):
"""Simulate a logged in user who opens the authorization URL."""
def login():
return session.get(urljoin(self.base_url, '/api/method/login'), params={
'usr': self.user_name,
'pwd': self.user_password
})
session = requests.Session()
# first login of a new user on a new site fails with "401 UNAUTHORIZED"
# when anybody fixes that, the two lines below can be removed
first_login = login()
self.assertEqual(first_login.status_code, 401)
second_login = login()
self.assertEqual(second_login.status_code, 200)
authorization_url = self.connected_app.initiate_web_application_flow(user=self.user_name)
auth_response = session.get(authorization_url)
self.assertEqual(auth_response.status_code, 200)
callback_response = session.get(auth_response.url)
self.assertEqual(callback_response.status_code, 200)
self.token_cache = self.connected_app.get_token_cache(self.user_name)
token = self.token_cache.get_password('access_token')
self.assertNotEqual(token, None)
oauth2_session = self.connected_app.get_oauth2_session(self.user_name)
resp = oauth2_session.get(urljoin(self.base_url, '/api/method/frappe.auth.get_logged_user'))
self.assertEqual(resp.json().get('message'), self.user_name)
def tearDown(self):
def delete_if_exists(attribute):
doc = getattr(self, attribute, None)
if doc:
doc.delete()
delete_if_exists('token_cache')
delete_if_exists('connected_app')
if getattr(self, 'oauth_client', None):
tokens = frappe.get_all('OAuth Bearer Token', filters={
'client': self.oauth_client.name
})
for token in tokens:
doc = frappe.get_doc('OAuth Bearer Token', token.name)
doc.delete()
codes = frappe.get_all('OAuth Authorization Code', filters={
'client': self.oauth_client.name
})
for code in codes:
doc = frappe.get_doc('OAuth Authorization Code', code.name)
doc.delete()
delete_if_exists('user')
delete_if_exists('oauth_client')
frappe.db.commit()

View file

@ -0,0 +1,13 @@
[
{
"doctype": "Connected App",
"provider_name": "frappe",
"client_id": "test_client_id",
"client_secret": "test_client_secret",
"scopes": [
{
"scope": "all"
}
]
}
]

View file

@ -1,7 +1,6 @@
[
{
"app_name": "_Test OAuth Client",
"client_id": "test_client_id",
"app_name": "_Test OAuth Client",
"client_secret": "test_client_secret",
"default_redirect_uri": "http://localhost",
"docstatus": 0,

View file

@ -0,0 +1,30 @@
{
"actions": [],
"creation": "2020-07-15 22:08:14.616585",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"scope"
],
"fields": [
{
"fieldname": "scope",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Scope"
}
],
"istable": 1,
"links": [],
"modified": "2020-07-15 22:15:18.930632",
"modified_by": "Administrator",
"module": "Integrations",
"name": "OAuth Scope",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View file

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

View file

@ -0,0 +1,37 @@
{
"actions": [],
"creation": "2020-11-16 14:54:37.226914",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"key",
"value"
],
"fields": [
{
"fieldname": "key",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Key",
"reqd": 1
},
{
"fieldname": "value",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Value",
"reqd": 1
}
],
"istable": 1,
"links": [],
"modified": "2020-11-16 15:18:35.887149",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Query Parameters",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC"
}

View file

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

View file

@ -22,3 +22,17 @@ def make_social_login_key(**kwargs):
kwargs["provider_name"] = "Test OAuth2 Provider"
doc = frappe.get_doc(kwargs)
return doc
def create_or_update_social_login_key():
# used in other tests (connected app, oauth20)
try:
social_login_key = frappe.get_doc("Social Login Key", "frappe")
except frappe.DoesNotExistError:
social_login_key = frappe.new_doc("Social Login Key")
social_login_key.get_social_login_provider("Frappe", initialize=True)
social_login_key.base_url = frappe.utils.get_url()
social_login_key.enable_social_login = 0
social_login_key.save()
frappe.db.commit()
return social_login_key

View file

@ -0,0 +1,18 @@
[
{
"doctype": "Token Cache",
"user": "test@example.com",
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"token_type": "Bearer",
"expires_in": 1000,
"scopes": [
{
"scope": "all"
},
{
"scope": "openid"
}
]
}
]

View file

@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
# See license.txt
from __future__ import unicode_literals
import unittest
import frappe
test_dependencies = ['User', 'Connected App', 'Token Cache']
class TestTokenCache(unittest.TestCase):
def setUp(self):
self.token_cache = frappe.get_last_doc('Token Cache')
self.token_cache.update({'connected_app': frappe.get_last_doc('Connected App').name})
self.token_cache.save()
def test_get_auth_header(self):
self.token_cache.get_auth_header()
def test_update_data(self):
self.token_cache.update_data({
'access_token': 'new-access-token',
'refresh_token': 'new-refresh-token',
'token_type': 'bearer',
'expires_in': 2000,
'scope': 'new scope'
})
def test_get_expires_in(self):
self.token_cache.get_expires_in()
def test_is_expired(self):
self.token_cache.is_expired()
def get_json(self):
self.token_cache.get_json()

View file

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

View file

@ -0,0 +1,110 @@
{
"actions": [],
"autoname": "format:{connected_app}-{user}",
"beta": 1,
"creation": "2019-01-24 16:56:55.631096",
"doctype": "DocType",
"document_type": "System",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"user",
"connected_app",
"provider_name",
"access_token",
"refresh_token",
"expires_in",
"state",
"scopes",
"success_uri",
"token_type"
],
"fields": [
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User",
"read_only": 1
},
{
"fieldname": "connected_app",
"fieldtype": "Link",
"label": "Connected App",
"options": "Connected App",
"read_only": 1
},
{
"fieldname": "access_token",
"fieldtype": "Password",
"label": "Access Token",
"read_only": 1
},
{
"fieldname": "refresh_token",
"fieldtype": "Password",
"label": "Refresh Token",
"read_only": 1
},
{
"fieldname": "expires_in",
"fieldtype": "Int",
"label": "Expires In",
"read_only": 1
},
{
"fieldname": "state",
"fieldtype": "Data",
"label": "State",
"read_only": 1
},
{
"fieldname": "scopes",
"fieldtype": "Table",
"label": "Scopes",
"options": "OAuth Scope",
"read_only": 1
},
{
"fieldname": "success_uri",
"fieldtype": "Data",
"label": "Success URI",
"read_only": 1
},
{
"fieldname": "token_type",
"fieldtype": "Data",
"label": "Token Type",
"read_only": 1
},
{
"fetch_from": "connected_app.provider_name",
"fieldname": "provider_name",
"fieldtype": "Data",
"label": "Provider Name",
"read_only": 1
}
],
"links": [],
"modified": "2020-11-13 13:35:53.714352",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Token Cache",
"owner": "Administrator",
"permissions": [
{
"delete": 1,
"read": 1,
"role": "System Manager"
},
{
"delete": 1,
"if_owner": 1,
"read": 1,
"role": "All"
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View file

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
from datetime import datetime, timedelta
import frappe
from frappe import _
from frappe.utils import cstr, cint
from frappe.model.document import Document
class TokenCache(Document):
def get_auth_header(self):
if self.access_token:
headers = {'Authorization': 'Bearer ' + self.get_password('access_token')}
return headers
raise frappe.exceptions.DoesNotExistError
def update_data(self, data):
"""
Store data returned by authorization flow.
Params:
data - Dict with access_token, refresh_token, expires_in and scope.
"""
token_type = cstr(data.get('token_type', '')).lower()
if token_type not in ['bearer', 'mac']:
frappe.throw(_('Received an invalid token type.'))
# 'Bearer' or 'MAC'
token_type = token_type.title() if token_type == 'bearer' else token_type.upper()
self.token_type = token_type
self.access_token = cstr(data.get('access_token', ''))
self.refresh_token = cstr(data.get('refresh_token', ''))
self.expires_in = cint(data.get('expires_in', 0))
new_scopes = data.get('scope')
if new_scopes:
if isinstance(new_scopes, str):
new_scopes = new_scopes.split(' ')
if isinstance(new_scopes, list):
self.scopes = None
for scope in new_scopes:
self.append('scopes', {'scope': scope})
self.state = None
self.save(ignore_permissions=True)
frappe.db.commit()
return self
def get_expires_in(self):
expiry_time = frappe.utils.get_datetime(self.modified) + timedelta(self.expires_in)
return (datetime.now() - expiry_time).total_seconds()
def is_expired(self):
return self.get_expires_in() < 0
def get_json(self):
return {
'access_token': self.get_password('access_token', ''),
'refresh_token': self.get_password('refresh_token', ''),
'expires_in': self.get_expires_in(),
'token_type': self.token_type
}

View file

@ -20,6 +20,7 @@ def get_oauth_server():
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)

View file

@ -484,6 +484,8 @@ class Meta(Document):
if not data.transactions:
# init groups
data.transactions = []
if not data.non_standard_fieldnames:
data.non_standard_fieldnames = {}
for link in dashboard_links:

View file

@ -21,8 +21,16 @@ def update_document_title(doctype, docname, title_field=None, old_title=None, ne
docname = rename_doc(doctype=doctype, old=docname, new=new_name, merge=merge)
if old_title and new_title and not old_title == new_title:
frappe.db.set_value(doctype, docname, title_field, new_title)
frappe.msgprint(_('Saved'), alert=True, indicator='green')
try:
frappe.db.set_value(doctype, docname, title_field, new_title)
frappe.msgprint(_('Saved'), alert=True, indicator='green')
except Exception as e:
if frappe.db.is_duplicate_entry(e):
frappe.throw(
_("{0} {1} already exists").format(doctype, frappe.bold(docname)),
title=_("Duplicate Name"),
exc=frappe.DuplicateEntryError
)
return docname

View file

@ -2,9 +2,23 @@ import frappe
def execute():
frappe.reload_doctype('Website Theme')
frappe.reload_doc('website', 'doctype', 'website_theme_ignore_app')
frappe.reload_doc('website', 'doctype', 'color')
for theme in frappe.get_all('Website Theme'):
doc = frappe.get_doc('Website Theme', theme.name)
if not doc.get('custom_scss') and doc.theme_scss:
# move old theme to new theme
doc.custom_scss = doc.theme_scss
if doc.background_color:
setup_color_record(doc.background_color)
doc.save()
def setup_color_record(color):
frappe.get_doc({
"doctype": "Color",
"__newname": color,
"color": color,
}).save()

View file

@ -10,7 +10,7 @@ frappe.ui.form.ControlCode = frappe.ui.form.ControlText.extend({
.appendTo(this.input_area);
this.expanded = false;
this.$expand_button = $(`<button class="btn btn-xs btn-default">${__('Expand')}</button>`).click(() => {
this.$expand_button = $(`<button class="btn btn-xs btn-default">${this.get_button_label()}</button>`).click(() => {
this.expanded = !this.expanded;
this.refresh_height();
this.toggle_label();
@ -38,8 +38,11 @@ frappe.ui.form.ControlCode = frappe.ui.form.ControlText.extend({
},
toggle_label() {
const button_label = this.expanded ? __('Collapse') : __('Expand');
this.$expand_button && this.$expand_button.text(button_label);
this.$expand_button && this.$expand_button.text(this.get_button_label());
},
get_button_label() {
return this.expanded ? __('Collapse', null, 'Shrink code field.') : __('Expand', null, 'Enlarge code field.');
},
set_language() {

View file

@ -17,7 +17,7 @@ frappe.ui.form.ControlGeolocation = frappe.ui.form.ControlData.extend({
this.map_area.prependTo($input_wrapper);
this.$wrapper.find('.control-input').addClass("hidden");
if ($input_wrapper.is(':visible')) {
if (this.frm) {
this.make_map();
} else {
$(document).on('frappe.ui.Dialog:shown', () => {

View file

@ -130,7 +130,7 @@ frappe.form.formatters = {
} else if(docfield && doctype) {
if (!frappe.model.can_select(doctype) && frappe.model.can_read(doctype)) {
return `<a class="grey"
#Form/${encodeURIComponent(doctype)}/${encodeURIComponent(original_value)}
href="#Form/${encodeURIComponent(doctype)}/${encodeURIComponent(original_value)}"
data-doctype="${doctype}"
data-name="${original_value}">
${__(options && options.label || value)}</a>`;

View file

@ -568,13 +568,15 @@ export default class GridRow {
this.wrapper.removeClass("grid-row-open");
}
open_prev() {
if(this.grid.grid_rows[this.doc.idx-2]) {
this.grid.grid_rows[this.doc.idx-2].toggle_view(true);
const row_index = this.wrapper.index();
if (this.grid.grid_rows[row_index - 1]) {
this.grid.grid_rows[row_index - 1].toggle_view(true);
}
}
open_next() {
if(this.grid.grid_rows[this.doc.idx]) {
this.grid.grid_rows[this.doc.idx].toggle_view(true);
const row_index = this.wrapper.index();
if (this.grid.grid_rows[row_index + 1]) {
this.grid.grid_rows[row_index + 1].toggle_view(true);
} else {
this.grid.add_new_row(null, null, true);
}

View file

@ -16,17 +16,19 @@ window.refresh_field = function(n, docname, table_field) {
if(typeof n==typeof [])
refresh_many(n, docname, table_field);
if (n && typeof n==='string' && table_field){
if (n && typeof n==='string' && table_field) {
var grid = cur_frm.fields_dict[table_field].grid,
field = frappe.utils.filter_dict(grid.docfields, {fieldname: n});
if (field && field.length){
field = frappe.utils.filter_dict(grid.docfields, {fieldname: n}),
grid_row = grid.grid_rows_by_docname[docname];
if (field && field.length) {
field = field[0];
var meta = frappe.meta.get_docfield(field.parent, field.fieldname, docname);
$.extend(field, meta);
if (docname){
cur_frm.fields_dict[table_field].grid.grid_rows_by_docname[docname].refresh_field(n);
if (grid_row) {
grid_row.refresh_field(n);
} else {
cur_frm.fields_dict[table_field].grid.refresh();
grid.refresh();
}
}
} else if(cur_frm) {

View file

@ -99,7 +99,7 @@ frappe.ui.form.Sidebar = class {
__("{0} edited this {1}", [
frappe.user.full_name(this.frm.doc.modified_by).bold(),
"<br>" + comment_when(this.frm.doc.modified),
])
], "For example, 'Jon Doe edited this 5 minutes ago'.")
);
this.sidebar
.find(".created-by")
@ -107,7 +107,7 @@ frappe.ui.form.Sidebar = class {
__("{0} created this {1}", [
frappe.user.full_name(this.frm.doc.owner).bold(),
"<br>" + comment_when(this.frm.doc.creation),
])
], "For example, 'Jon Doe created this 5 minutes ago'.")
);
this.refresh_like();

View file

@ -55,7 +55,7 @@ $.extend(frappe.user, {
name: 'Guest',
full_name: function(uid) {
return uid === frappe.session.user ?
__("You") :
__("You", null, "Name of the current user. For example: You edited this 5 hours ago.") :
frappe.user_info(uid).fullname;
},
image: function(uid) {

View file

@ -672,10 +672,19 @@ frappe.views.CommunicationComposer = Class.extend({
}
},
setup_earlier_reply: function() {
get_default_outgoing_email_account_signature: function() {
return frappe.db.get_value('Email Account', { 'default_outgoing': 1, 'add_signature': 1 }, 'signature');
},
setup_earlier_reply: async function() {
let fields = this.dialog.fields_dict;
let signature = frappe.boot.user.email_signature || "";
if (!signature) {
const res = await this.get_default_outgoing_email_account_signature();
signature = res.message.signature;
}
if(!frappe.utils.is_html(signature)) {
signature = signature.replace(/\n/g, "<br>");
}
@ -756,4 +765,3 @@ frappe.views.CommunicationComposer = Class.extend({
return text.replace(/\n{3,}/g, '\n\n');
}
});

View file

@ -444,7 +444,7 @@ export default class OnboardingWidget extends Widget {
set_actions() {
this.action_area.empty();
const dismiss = $(
`<div class="small" style="cursor:pointer;">${__('Dismiss')}</div>`
`<div class="small" style="cursor:pointer;">${__('Dismiss', null, 'Stop showing the onboarding widget.')}</div>`
);
dismiss.on("click", () => {
let dismissed = JSON.parse(

View file

@ -29,11 +29,11 @@
}
.hero.align-center {
h1, .hero-subtitle, .hero-buttons {
h1, .hero-title, .hero-subtitle, .hero-buttons {
text-align: center;
}
.hero-subtitle {
.hero-title, .hero-subtitle {
margin-left: auto;
margin-right: auto;
}

View file

@ -5,8 +5,11 @@
<div>{{ frappe.render_template(df.options, {"doc": doc}) or "" }}</div>
{%- elif df.fieldtype in ("Text", "Text Editor", "Code", "Long Text") -%}
{{ render_text_field(df, doc) }}
{%- elif df.fieldtype in ("Image", "Attach Image", "Attach")
and (guess_mimetype(doc[df.fieldname])[0] or "").startswith("image/") -%}
{%- elif df.fieldtype in ("Image", "Attach Image")
and (
(guess_mimetype(doc[df.fieldname])[0] or "").startswith("image/")
or doc[df.fieldname].startswith("http")
) -%}
{{ render_image(df, doc) }}
{%- elif df.fieldtype=="Geolocation" -%}
{{ render_geolocation(df, doc) }}
@ -123,15 +126,14 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}"
{% include doc.print_templates[df.fieldname] %}
{% elif df.fieldtype=="Check" %}
<i class="{{ 'fa fa-check' if doc[df.fieldname] }}"></i>
{% elif df.fieldtype=="Image" %}
{% elif df.fieldtype in ("Image", "Attach Image") %}
<img src="{{ doc[doc.meta.get_field(df.fieldname).options] }}"
class="img-responsive"
{%- if df.print_width %} style="width: {{ get_width(df) }};"{% endif %}>
{% elif df.fieldtype=="Signature" %}
<img src="{{ doc[df.fieldname] }}" class="signature-img img-responsive"
{%- if df.print_width %} style="width: {{ get_width(df) }};"{% endif %}>
{% elif df.fieldtype in ("Attach", "Attach Image") and doc[df.fieldname]
and frappe.utils.is_image(doc[df.fieldname]) %}
{% elif df.fieldtype == "Attach" and doc[df.fieldname] and frappe.utils.is_image(doc[df.fieldname]) %}
<img src="{{ doc[df.fieldname] }}" class="img-responsive"
{%- if df.print_width %} style="width: {{ get_width(df) }};"{% endif %}>
{% elif df.fieldtype=="HTML" %}

View file

@ -154,14 +154,22 @@ def get_time_zone():
return frappe.cache().get_value("time_zone", _get_time_zone)
def convert_utc_to_user_timezone(utc_timestamp):
def convert_utc_to_timezone(utc_timestamp, time_zone):
from pytz import timezone, UnknownTimeZoneError
utcnow = timezone('UTC').localize(utc_timestamp)
try:
return utcnow.astimezone(timezone(get_time_zone()))
return utcnow.astimezone(timezone(time_zone))
except UnknownTimeZoneError:
return utcnow
def get_datetime_in_timezone(time_zone):
utc_timestamp = datetime.datetime.utcnow()
return convert_utc_to_timezone(utc_timestamp, time_zone)
def convert_utc_to_user_timezone(utc_timestamp):
time_zone = get_time_zone()
return convert_utc_to_timezone(utc_timestamp, time_zone)
def now():
"""return current datetime as yyyy-mm-dd hh:mm:ss"""
if frappe.flags.current_date:
@ -369,7 +377,7 @@ def format_duration(seconds, hide_days=False):
example: converts 12885 to '3h 34m 45s' where 12885 = seconds in float
"""
seconds = cint(seconds)
total_duration = {

View file

@ -230,12 +230,19 @@ def update_oauth_user(user, data, provider):
save = True
user = frappe.new_doc("User")
gender = (data.get("gender") or "").title()
if not frappe.db.exists("Gender", gender):
doc = frappe.new_doc("Gender", {"gender": gender})
doc.insert(ignore_permissions=True)
user.update({
"doctype":"User",
"first_name": get_first_name(data),
"last_name": get_last_name(data),
"email": get_email(data),
"gender": (data.get("gender") or "").title(),
"gender": gender,
"enabled": 1,
"new_password": frappe.generate_hash(get_email(data)),
"location": data.get("location"),
@ -306,7 +313,7 @@ def redirect_post_login(desk_user, redirect_to=None, provider=None):
frappe.local.response["type"] = "redirect"
if not redirect_to:
# the #desktop is added to prevent a facebook redirect bug
# the #workspace is added to prevent a facebook redirect bug
desk_uri = "/desk#workspace" if provider == 'facebook' else '/desk'
redirect_to = desk_uri if desk_user else "/me"
redirect_to = frappe.utils.get_url(redirect_to)

View file

@ -222,6 +222,7 @@ VALID_UTILS = (
"get_last_day_of_week",
"get_last_day",
"get_time",
"get_datetime_in_timezone",
"get_datetime_str",
"get_date_str",
"get_time_str",

View file

@ -65,8 +65,10 @@
},
{
"fieldname": "theme_url",
"fieldtype": "Read Only",
"label": "Theme URL"
"fieldtype": "Data",
"hidden": 1,
"label": "Theme URL",
"read_only": 1
},
{
"collapsible": 1,
@ -179,7 +181,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-09-24 11:42:33.867840",
"modified": "2021-01-18 17:43:39.804765",
"modified_by": "Administrator",
"module": "Website",
"name": "Website Theme",

View file

@ -18,7 +18,7 @@ $('.dropdown-menu a.dropdown-toggle').on('click', function (e) {
return false;
});
frappe.get_modal = function(title, content) {
frappe.get_modal = function (title, content) {
return $(
`<div class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
@ -33,6 +33,10 @@ frappe.get_modal = function(title, content) {
${content}
</div>
<div class="modal-footer hidden">
<button type="button" class="btn btn-default btn-sm btn-modal-close" data-dismiss="modal">
<i class="octicon octicon-x visible-xs" style="padding: 1px 0px;"></i>
<span class="hidden-xs">${__("Close")}</span>
</button>
<button type="button" class="btn btn-sm btn-primary hidden"></button>
</div>
</div>

View file

@ -5,9 +5,7 @@
</div>
{% endif %}
<div class="testimonial-content">
<span></span>
{{ content }}
<span></span>
“{{ content }}”
</div>
<div class="testimonial-by">
{{ name }}

View file

@ -49,7 +49,7 @@ pypng==0.0.20
PyQRCode==1.2.1
python-dateutil==2.8.1
pytz==2019.3
PyYAML==5.3.1
PyYAML==5.4
rauth==0.7.3
redis==3.5.3
requests-oauthlib==1.3.0