mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add 'user' variable to trigger formulas
Summary: The 'user' variable has a similar API to the one from access rules: it contains properties about a user, such as their full name and email address, as well as optional, user-defined attributes that are populated via user attribute tables. Test Plan: Python unit tests. Reviewers: alexmojaki, paulfitz, dsagal Reviewed By: alexmojaki, dsagal Subscribers: paulfitz, dsagal, alexmojaki Differential Revision: https://phab.getgrist.com/D2898
This commit is contained in:
parent
6c114ef439
commit
e5eeb3ec80
@ -1,5 +1,6 @@
|
||||
import { PartialPermissionSet } from 'app/common/ACLPermissions';
|
||||
import { CellValue, RowRecord } from 'app/common/DocActions';
|
||||
import { Role } from './roles';
|
||||
|
||||
export interface RuleSet {
|
||||
tableId: '*' | string;
|
||||
@ -36,7 +37,16 @@ export interface InfoEditor {
|
||||
}
|
||||
|
||||
// Represents user info, which may include properties which are themselves RowRecords.
|
||||
export type UserInfo = Record<string, CellValue|InfoView|Record<string, string>>;
|
||||
export interface UserInfo {
|
||||
Name: string | null;
|
||||
Email: string | null;
|
||||
Access: Role | null;
|
||||
Origin: string | null;
|
||||
LinkKey: Record<string, string | undefined>;
|
||||
UserID: number | null;
|
||||
[attributes: string]: unknown;
|
||||
toJSON(): {[key: string]: any};
|
||||
}
|
||||
|
||||
/**
|
||||
* Input into the AclMatchFunc. Compiled formulas evaluate AclMatchInput to produce a boolean.
|
||||
|
@ -835,7 +835,8 @@ export class ActiveDoc extends EventEmitter {
|
||||
// Autocompletion can leak names of tables and columns.
|
||||
if (!await this._granularAccess.canScanData(docSession)) { return []; }
|
||||
await this.waitForInitialization();
|
||||
return this._pyCall('autocomplete', txt, tableId, columnId);
|
||||
const user = await this._granularAccess.getCachedUser(docSession);
|
||||
return this._pyCall('autocomplete', txt, tableId, columnId, user.toJSON());
|
||||
}
|
||||
|
||||
public fetchURL(docSession: DocSession, url: string): Promise<UploadResult> {
|
||||
@ -980,7 +981,10 @@ export class ActiveDoc extends EventEmitter {
|
||||
* Should only be called by a Sharing object, with this._modificationLock held, since the
|
||||
* actions may need to be rolled back if final access control checks fail.
|
||||
*/
|
||||
public async applyActionsToDataEngine(userActions: UserAction[]): Promise<SandboxActionBundle> {
|
||||
public async applyActionsToDataEngine(
|
||||
docSession: OptDocSession|null,
|
||||
userActions: UserAction[]
|
||||
): Promise<SandboxActionBundle> {
|
||||
const [normalActions, onDemandActions] = this._onDemandActions.splitByOnDemand(userActions);
|
||||
|
||||
let sandboxActionBundle: SandboxActionBundle;
|
||||
@ -989,7 +993,8 @@ export class ActiveDoc extends EventEmitter {
|
||||
if (normalActions[0][0] !== 'Calculate') {
|
||||
await this.waitForInitialization();
|
||||
}
|
||||
sandboxActionBundle = await this._rawPyCall('apply_user_actions', normalActions);
|
||||
const user = docSession ? await this._granularAccess.getCachedUser(docSession) : undefined;
|
||||
sandboxActionBundle = await this._rawPyCall('apply_user_actions', normalActions, user?.toJSON());
|
||||
await this._reportDataEngineMemory();
|
||||
} else {
|
||||
// Create default SandboxActionBundle to use if the data engine is not called.
|
||||
|
@ -210,6 +210,11 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
return this._getUser(docSession);
|
||||
}
|
||||
|
||||
public async getCachedUser(docSession: OptDocSession): Promise<UserInfo> {
|
||||
const access = await this._getAccess(docSession);
|
||||
return access.getUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether user has any access to table.
|
||||
*/
|
||||
@ -1182,7 +1187,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
} else {
|
||||
fullUser = getDocSessionUser(docSession);
|
||||
}
|
||||
const user: UserInfo = {};
|
||||
const user = new User();
|
||||
user.Access = access;
|
||||
user.UserID = fullUser?.id || null;
|
||||
user.Email = fullUser?.email || null;
|
||||
@ -2061,3 +2066,41 @@ export function filterColValues(action: DataAction,
|
||||
// Return all actions, in a consistent order for test purposes.
|
||||
return [action, ...[...parts.keys()].sort().map(key => parts.get(key)!)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a user, including any user attributes.
|
||||
*
|
||||
* Serializes into a more compact JSON form that excludes full
|
||||
* row data, only keeping user info and table/row ids for any
|
||||
* user attributes.
|
||||
*
|
||||
* See `user.py` for the sandbox equivalent that deserializes objects of this class.
|
||||
*/
|
||||
export class User implements UserInfo {
|
||||
public Name: string | null = null;
|
||||
public UserID: number | null = null;
|
||||
public Access: Role | null = null;
|
||||
public Origin: string | null = null;
|
||||
public LinkKey: Record<string, string | undefined> = {};
|
||||
public Email: string | null = null;
|
||||
[attribute: string]: any;
|
||||
|
||||
constructor(_info: Record<string, unknown> = {}) {
|
||||
Object.assign(this, _info);
|
||||
}
|
||||
|
||||
public toJSON() {
|
||||
const results: {[key: string]: any} = {};
|
||||
for (const [key, value] of Object.entries(this)) {
|
||||
if (value instanceof RecordView) {
|
||||
// Only include the table id and first matching row id.
|
||||
results[key] = [getTableId(value.data), value.get('id')];
|
||||
} else if (value instanceof EmptyRecordView) {
|
||||
results[key] = null;
|
||||
} else {
|
||||
results[key] = value;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { ALL_PERMISSION_PROPS, emptyPermissionSet,
|
||||
MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,
|
||||
toMixed } from 'app/common/ACLPermissions';
|
||||
import { ACLRuleCollection } from 'app/common/ACLRuleCollection';
|
||||
import { AclMatchInput, RuleSet } from 'app/common/GranularAccessClause';
|
||||
import { AclMatchInput, RuleSet, UserInfo } from 'app/common/GranularAccessClause';
|
||||
import { getSetMapValue } from 'app/common/gutil';
|
||||
import * as log from 'app/server/lib/log';
|
||||
import { mapValues } from 'lodash';
|
||||
@ -79,6 +79,10 @@ abstract class RuleInfo<MixedT extends TableT, TableT> {
|
||||
return this._mergeFullAccess(tableAccess);
|
||||
}
|
||||
|
||||
public getUser(): UserInfo {
|
||||
return this._input.user;
|
||||
}
|
||||
|
||||
protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;
|
||||
protected abstract _mergeTableAccess(access: MixedT[]): TableT;
|
||||
protected abstract _mergeFullAccess(access: TableT[]): MixedT;
|
||||
|
@ -361,7 +361,7 @@ export class Sharing {
|
||||
}
|
||||
|
||||
private async _applyActionsToDataEngine(docSession: OptDocSession|null, userActions: UserAction[]) {
|
||||
const sandboxActionBundle = await this._activeDoc.applyActionsToDataEngine(userActions);
|
||||
const sandboxActionBundle = await this._activeDoc.applyActionsToDataEngine(docSession, userActions);
|
||||
const undo = getEnvContent(sandboxActionBundle.undo);
|
||||
const docActions = getEnvContent(sandboxActionBundle.stored).concat(
|
||||
getEnvContent(sandboxActionBundle.calc));
|
||||
@ -377,7 +377,7 @@ export class Sharing {
|
||||
} catch (e) {
|
||||
// should not commit. Don't write to db. Remove changes from sandbox.
|
||||
try {
|
||||
await this._activeDoc.applyActionsToDataEngine([['ApplyUndoActions', undo]]);
|
||||
await this._activeDoc.applyActionsToDataEngine(docSession, [['ApplyUndoActions', undo]]);
|
||||
} finally {
|
||||
await accessControl.finishedBundle();
|
||||
}
|
||||
|
@ -82,10 +82,6 @@ class BaseColumn(object):
|
||||
'method' function. The method may refer to variables in the generated "usercode" module, and
|
||||
it's important that all such references are to the rebuilt "usercode" module.
|
||||
"""
|
||||
if not self._is_formula and method:
|
||||
# Include the current value of the cell as the third parameter (to default formulas).
|
||||
self.method = lambda rec, table: method(rec, table, self.get_cell_value(int(rec)))
|
||||
else:
|
||||
self.method = method
|
||||
|
||||
def is_formula(self):
|
||||
|
@ -33,16 +33,17 @@ from relation import SingleRowsIdentityRelation
|
||||
import schema
|
||||
from schema import RecalcWhen
|
||||
import table as table_module
|
||||
from user import User # pylint:disable=wrong-import-order
|
||||
import useractions
|
||||
import column
|
||||
import repl
|
||||
import urllib_patch # noqa imported for side effect
|
||||
import urllib_patch # noqa imported for side effect # pylint:disable=unused-import
|
||||
|
||||
log = logger.Logger(__name__, logger.INFO)
|
||||
|
||||
if six.PY2:
|
||||
reload(sys)
|
||||
sys.setdefaultencoding('utf8') # noqa
|
||||
sys.setdefaultencoding('utf8') # noqa # pylint:disable=no-member
|
||||
|
||||
|
||||
class OrderError(Exception):
|
||||
@ -129,7 +130,7 @@ class Engine(object):
|
||||
Returns a TableData object containing the full data for the table. Formula columns
|
||||
are included only if formulas is True.
|
||||
|
||||
apply_user_actions(user_actions)
|
||||
apply_user_actions(user_actions, user)
|
||||
Applies a list of UserActions, which are tuples consisting of the name of the action
|
||||
method (as defind in useractions.py) and the arguments to it. Returns ActionGroup tuple,
|
||||
containing several categories of DocActions, including the results of computations.
|
||||
@ -238,6 +239,9 @@ class Engine(object):
|
||||
# current cell.
|
||||
self._cell_required_error = None
|
||||
|
||||
# User that is currently applying user actions.
|
||||
self._user = None
|
||||
|
||||
# Initial empty context for autocompletions; we update it when we generate the usercode module.
|
||||
self._autocomplete_context = AutocompleteContext({})
|
||||
|
||||
@ -860,6 +864,9 @@ class Engine(object):
|
||||
try:
|
||||
if cycle:
|
||||
raise depend.CircularRefError("Circular Reference")
|
||||
if not col.is_formula():
|
||||
result = col.method(record, table.user_table, col.get_cell_value(int(record)), self._user)
|
||||
else:
|
||||
result = col.method(record, table.user_table)
|
||||
if self._cell_required_error:
|
||||
raise self._cell_required_error # pylint: disable=raising-bad-type
|
||||
@ -1146,7 +1153,7 @@ class Engine(object):
|
||||
"""
|
||||
self._unused_lookups.add(lookup_map_column)
|
||||
|
||||
def apply_user_actions(self, user_actions):
|
||||
def apply_user_actions(self, user_actions, user=None):
|
||||
"""
|
||||
Applies the list of user_actions. Returns an ActionGroup.
|
||||
"""
|
||||
@ -1156,7 +1163,7 @@ class Engine(object):
|
||||
# everything, and only filter what we send.
|
||||
|
||||
self.out_actions = action_obj.ActionGroup()
|
||||
|
||||
self._user = User(user, self.tables) if user else None
|
||||
checkpoint = self._get_undo_checkpoint()
|
||||
try:
|
||||
for user_action in user_actions:
|
||||
@ -1210,6 +1217,7 @@ class Engine(object):
|
||||
|
||||
self.out_actions.flush_calc_changes()
|
||||
self.out_actions.check_sanity()
|
||||
self._user = None
|
||||
return self.out_actions
|
||||
|
||||
def acl_split(self, action_group):
|
||||
@ -1281,7 +1289,7 @@ class Engine(object):
|
||||
if not self._compute_stack:
|
||||
self._bring_lookups_up_to_date(doc_action)
|
||||
|
||||
def autocomplete(self, txt, table_id, column_id):
|
||||
def autocomplete(self, txt, table_id, column_id, user):
|
||||
"""
|
||||
Return a list of suggested completions of the python fragment supplied.
|
||||
"""
|
||||
@ -1297,10 +1305,13 @@ class Engine(object):
|
||||
|
||||
# Remove values from the context that need to be recomputed.
|
||||
context.pop('value', None)
|
||||
context.pop('user', None)
|
||||
|
||||
column = table.get_column(column_id) if table.has_column(column_id) else None
|
||||
if column and not column.is_formula():
|
||||
# Add trigger formula completions.
|
||||
context['value'] = column.sample_value()
|
||||
context['user'] = User(user, self.tables, is_sample=True)
|
||||
|
||||
completer = rlcompleter.Completer(context)
|
||||
results = []
|
||||
|
@ -77,12 +77,15 @@ class GenCode(object):
|
||||
self._usercode = None
|
||||
|
||||
def _make_formula_field(self, col_info, table_id, name=None, include_type=True,
|
||||
include_value_arg=False):
|
||||
additional_params=()):
|
||||
"""Returns the code for a formula field."""
|
||||
# If the caller didn't specify a special name, use the colId
|
||||
name = name or col_info.colId
|
||||
|
||||
decl = "def %s(rec, table%s):\n" % (name, (", value" if include_value_arg else ""))
|
||||
decl = "def %s(%s):\n" % (
|
||||
name,
|
||||
', '.join(['rec', 'table'] + list(additional_params))
|
||||
)
|
||||
|
||||
# This is where we get to use the formula cache, and save the work of rebuilding formulas.
|
||||
key = (table_id, col_info.colId, col_info.formula)
|
||||
@ -105,7 +108,7 @@ class GenCode(object):
|
||||
parts.append(self._make_formula_field(col_info, table_id,
|
||||
name=table.get_default_func_name(col_info.colId),
|
||||
include_type=False,
|
||||
include_value_arg=True))
|
||||
additional_params=['value', 'user']))
|
||||
parts.append("%s = %s\n" % (col_info.colId, get_grist_type(col_info.type)))
|
||||
return textbuilder.Combiner(parts)
|
||||
|
||||
|
@ -55,8 +55,8 @@ def run(sandbox):
|
||||
sandbox.register(method.__name__, wrapper)
|
||||
|
||||
@export
|
||||
def apply_user_actions(action_reprs):
|
||||
action_group = eng.apply_user_actions([useractions.from_repr(u) for u in action_reprs])
|
||||
def apply_user_actions(action_reprs, user=None):
|
||||
action_group = eng.apply_user_actions([useractions.from_repr(u) for u in action_reprs], user)
|
||||
return eng.acl_split(action_group).to_json_obj()
|
||||
|
||||
@export
|
||||
@ -73,8 +73,8 @@ def run(sandbox):
|
||||
return eng.acl_split(action_group).to_json_obj()
|
||||
|
||||
@export
|
||||
def autocomplete(txt, table_id, column_id):
|
||||
return eng.autocomplete(txt, table_id, column_id)
|
||||
def autocomplete(txt, table_id, column_id, user):
|
||||
return eng.autocomplete(txt, table_id, column_id, user)
|
||||
|
||||
@export
|
||||
def find_col_from_values(values, n, opt_table_id):
|
||||
|
@ -3,6 +3,16 @@ import test_engine
|
||||
from schema import RecalcWhen
|
||||
|
||||
class TestCompletion(test_engine.EngineTestCase):
|
||||
user = {
|
||||
'Name': 'Foo',
|
||||
'UserID': 1,
|
||||
'StudentInfo': ['Students', 1],
|
||||
'LinkKey': {},
|
||||
'Origin': None,
|
||||
'Email': 'foo@example.com',
|
||||
'Access': 'owners'
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super(TestCompletion, self).setUp()
|
||||
self.load_sample(testsamples.sample_students)
|
||||
@ -23,35 +33,109 @@ class TestCompletion(test_engine.EngineTestCase):
|
||||
)
|
||||
|
||||
def test_keyword(self):
|
||||
self.assertEqual(self.engine.autocomplete("for", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("for", "Address", "city", self.user),
|
||||
["for", "format("])
|
||||
|
||||
def test_grist(self):
|
||||
self.assertEqual(self.engine.autocomplete("gri", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("gri", "Address", "city", self.user),
|
||||
["grist"])
|
||||
|
||||
def test_value(self):
|
||||
# Should only appear if column exists and is a trigger formula.
|
||||
self.assertEqual(self.engine.autocomplete("val", "Schools", "lastModified"),
|
||||
["value"])
|
||||
self.assertEqual(self.engine.autocomplete("val", "Students", "schoolCities"),
|
||||
[])
|
||||
self.assertEqual(self.engine.autocomplete("val", "Students", "nonexistentColumn"),
|
||||
[])
|
||||
self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier"),
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("val", "Schools", "lastModified", self.user),
|
||||
["value"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("val", "Students", "schoolCities", self.user),
|
||||
[]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("val", "Students", "nonexistentColumn", self.user),
|
||||
[]
|
||||
)
|
||||
self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier", self.user),
|
||||
["value"])
|
||||
# Should have same type as column.
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModifier")),
|
||||
{'value.startswith(', 'value.replace(', 'value.title('})
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModified")),
|
||||
{'value.month', 'value.strftime(', 'value.replace('})
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("value.m", "Schools", "lastModified")),
|
||||
{'value.month', 'value.minute'})
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("value.", "Schools", "lastModifier", self.user)),
|
||||
{'value.startswith(', 'value.replace(', 'value.title('}
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("value.", "Schools", "lastModified", self.user)),
|
||||
{'value.month', 'value.strftime(', 'value.replace('}
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("value.m", "Schools", "lastModified", self.user)),
|
||||
{'value.month', 'value.minute'}
|
||||
)
|
||||
|
||||
def test_user(self):
|
||||
# Should only appear if column exists and is a trigger formula.
|
||||
self.assertEqual(self.engine.autocomplete("use", "Schools", "lastModified", self.user),
|
||||
["user"])
|
||||
self.assertEqual(self.engine.autocomplete("use", "Students", "schoolCities", self.user),
|
||||
[])
|
||||
self.assertEqual(self.engine.autocomplete("use", "Students", "nonexistentColumn", self.user),
|
||||
[])
|
||||
self.assertEqual(self.engine.autocomplete("user", "Schools", "lastModifier", self.user),
|
||||
["user"])
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("user.", "Schools", "lastModified", self.user),
|
||||
[
|
||||
'user.Access',
|
||||
'user.Email',
|
||||
'user.LinkKey',
|
||||
'user.Name',
|
||||
'user.Origin',
|
||||
'user.StudentInfo',
|
||||
'user.UserID'
|
||||
]
|
||||
)
|
||||
# Should follow user attribute references and autocomplete those types.
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("user.StudentInfo.", "Schools", "lastModified", self.user),
|
||||
[
|
||||
'user.StudentInfo.birthDate',
|
||||
'user.StudentInfo.firstName',
|
||||
'user.StudentInfo.id',
|
||||
'user.StudentInfo.lastName',
|
||||
'user.StudentInfo.lastVisit',
|
||||
'user.StudentInfo.school',
|
||||
'user.StudentInfo.schoolCities',
|
||||
'user.StudentInfo.schoolIds',
|
||||
'user.StudentInfo.schoolName'
|
||||
]
|
||||
)
|
||||
# Should not show user attribute completions if user doesn't have attribute.
|
||||
user2 = {
|
||||
'Name': 'Bar',
|
||||
'Origin': None,
|
||||
'Email': 'baro@example.com',
|
||||
'LinkKey': {},
|
||||
'UserID': 2,
|
||||
'Access': 'owners'
|
||||
}
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("user.", "Schools", "lastModified", user2),
|
||||
[
|
||||
'user.Access',
|
||||
'user.Email',
|
||||
'user.LinkKey',
|
||||
'user.Name',
|
||||
'user.Origin',
|
||||
'user.UserID'
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("user.StudentInfo.", "Schools", "schoolCities", user2),
|
||||
[]
|
||||
)
|
||||
|
||||
def test_function(self):
|
||||
self.assertEqual(self.engine.autocomplete("MEDI", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("MEDI", "Address", "city", self.user),
|
||||
[('MEDIAN', '(value, *more_values)', True)])
|
||||
self.assertEqual(self.engine.autocomplete("ma", "Address", "city"), [
|
||||
self.assertEqual(self.engine.autocomplete("ma", "Address", "city", self.user), [
|
||||
('MAX', '(value, *more_values)', True),
|
||||
('MAXA', '(value, *more_values)', True),
|
||||
'map(',
|
||||
@ -60,30 +144,30 @@ class TestCompletion(test_engine.EngineTestCase):
|
||||
])
|
||||
|
||||
def test_member(self):
|
||||
self.assertEqual(self.engine.autocomplete("datetime.tz", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("datetime.tz", "Address", "city", self.user),
|
||||
["datetime.tzinfo("])
|
||||
|
||||
def test_case_insensitive(self):
|
||||
self.assertEqual(self.engine.autocomplete("medi", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("medi", "Address", "city", self.user),
|
||||
[('MEDIAN', '(value, *more_values)', True)])
|
||||
self.assertEqual(self.engine.autocomplete("std", "Address", "city"), [
|
||||
self.assertEqual(self.engine.autocomplete("std", "Address", "city", self.user), [
|
||||
('STDEV', '(value, *more_values)', True),
|
||||
('STDEVA', '(value, *more_values)', True),
|
||||
('STDEVP', '(value, *more_values)', True),
|
||||
('STDEVPA', '(value, *more_values)', True)
|
||||
])
|
||||
self.assertEqual(self.engine.autocomplete("stu", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("stu", "Address", "city", self.user),
|
||||
["Students"])
|
||||
|
||||
# Add a table name whose lowercase version conflicts with a builtin.
|
||||
self.apply_user_action(['AddTable', 'Max', []])
|
||||
self.assertEqual(self.engine.autocomplete("max", "Address", "city"), [
|
||||
self.assertEqual(self.engine.autocomplete("max", "Address", "city", self.user), [
|
||||
('MAX', '(value, *more_values)', True),
|
||||
('MAXA', '(value, *more_values)', True),
|
||||
'Max',
|
||||
'max(',
|
||||
])
|
||||
self.assertEqual(self.engine.autocomplete("MAX", "Address", "city"), [
|
||||
self.assertEqual(self.engine.autocomplete("MAX", "Address", "city", self.user), [
|
||||
('MAX', '(value, *more_values)', True),
|
||||
('MAXA', '(value, *more_values)', True),
|
||||
])
|
||||
@ -91,85 +175,106 @@ class TestCompletion(test_engine.EngineTestCase):
|
||||
|
||||
def test_suggest_globals_and_tables(self):
|
||||
# Should suggest globals and table names.
|
||||
self.assertEqual(self.engine.autocomplete("ME", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("ME", "Address", "city", self.user),
|
||||
[('MEDIAN', '(value, *more_values)', True)])
|
||||
self.assertEqual(self.engine.autocomplete("Ad", "Address", "city"), ['Address'])
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city")), {
|
||||
self.assertEqual(self.engine.autocomplete("Ad", "Address", "city", self.user), ['Address'])
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city", self.user)), {
|
||||
'Schools',
|
||||
'Students',
|
||||
('SUM', '(value1, *more_values)', True),
|
||||
('STDEV', '(value, *more_values)', True),
|
||||
})
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("s", "Address", "city")), {
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("s", "Address", "city", self.user)), {
|
||||
'Schools',
|
||||
'Students',
|
||||
'sum(',
|
||||
('SUM', '(value1, *more_values)', True),
|
||||
('STDEV', '(value, *more_values)', True),
|
||||
})
|
||||
self.assertEqual(self.engine.autocomplete("Addr", "Schools", "budget"), ['Address'])
|
||||
self.assertEqual(self.engine.autocomplete("Addr", "Schools", "budget", self.user), ['Address'])
|
||||
|
||||
def test_suggest_columns(self):
|
||||
self.assertEqual(self.engine.autocomplete("$ci", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("$ci", "Address", "city", self.user),
|
||||
["$city"])
|
||||
self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city", self.user),
|
||||
["rec.id"])
|
||||
self.assertEqual(len(self.engine.autocomplete("$", "Address", "city")),
|
||||
self.assertEqual(len(self.engine.autocomplete("$", "Address", "city", self.user)),
|
||||
2)
|
||||
|
||||
# A few more detailed examples.
|
||||
self.assertEqual(self.engine.autocomplete("$", "Students", "school"),
|
||||
self.assertEqual(self.engine.autocomplete("$", "Students", "school", self.user),
|
||||
['$birthDate', '$firstName', '$id', '$lastName', '$lastVisit',
|
||||
'$school', '$schoolCities', '$schoolIds', '$schoolName'])
|
||||
self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate"), ['$firstName'])
|
||||
self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit"),
|
||||
self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate", self.user),
|
||||
['$firstName'])
|
||||
self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit", self.user),
|
||||
['$school', '$schoolCities', '$schoolIds', '$schoolName'])
|
||||
|
||||
def test_suggest_lookup_methods(self):
|
||||
# Should suggest lookup formulas for tables.
|
||||
self.assertEqual(self.engine.autocomplete("Address.", "Students", "firstName"), [
|
||||
self.assertEqual(self.engine.autocomplete("Address.", "Students", "firstName", self.user), [
|
||||
'Address.all',
|
||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
||||
])
|
||||
|
||||
self.assertEqual(self.engine.autocomplete("Address.lookup", "Students", "lastName"), [
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("Address.lookup", "Students", "lastName", self.user),
|
||||
[
|
||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(self.engine.autocomplete("address.look", "Students", "schoolName"), [
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("address.look", "Students", "schoolName", self.user),
|
||||
[
|
||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
def test_suggest_column_type_methods(self):
|
||||
# Should treat columns as correct types.
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("$firstName.", "Students", "firstName")),
|
||||
{'$firstName.startswith(', '$firstName.replace(', '$firstName.title('})
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("$birthDate.", "Students", "lastName")),
|
||||
{'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('})
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("$lastVisit.m", "Students", "firstName")),
|
||||
{'$lastVisit.month', '$lastVisit.minute'})
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("$school.", "Students", "firstName")),
|
||||
{'$school.address', '$school.name',
|
||||
'$school.yearFounded', '$school.budget'})
|
||||
self.assertEqual(self.engine.autocomplete("$school.year", "Students", "lastName"),
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("$firstName.", "Students", "firstName", self.user)),
|
||||
{'$firstName.startswith(', '$firstName.replace(', '$firstName.title('}
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("$birthDate.", "Students", "lastName", self.user)),
|
||||
{'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('}
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("$lastVisit.m", "Students", "firstName", self.user)),
|
||||
{'$lastVisit.month', '$lastVisit.minute'}
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("$school.", "Students", "firstName", self.user)),
|
||||
{'$school.address', '$school.name', '$school.yearFounded', '$school.budget'}
|
||||
)
|
||||
self.assertEqual(self.engine.autocomplete("$school.year", "Students", "lastName", self.user),
|
||||
['$school.yearFounded'])
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("$yearFounded.", "Schools", "budget")),
|
||||
{'$yearFounded.denominator', # Only integers have this
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("$yearFounded.", "Schools", "budget", self.user)),
|
||||
{
|
||||
'$yearFounded.denominator', # Only integers have this
|
||||
'$yearFounded.bit_length(', # and this
|
||||
'$yearFounded.real'})
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("$budget.", "Schools", "budget")),
|
||||
{'$budget.is_integer(', # Only floats have this
|
||||
'$budget.real'})
|
||||
'$yearFounded.real'
|
||||
}
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("$budget.", "Schools", "budget", self.user)),
|
||||
{'$budget.is_integer(', '$budget.real'} # Only floats have this
|
||||
)
|
||||
|
||||
def test_suggest_follows_references(self):
|
||||
# Should follow references and autocomplete those types.
|
||||
self.assertEqual(self.engine.autocomplete("$school.name.st", "Students", "firstName"),
|
||||
['$school.name.startswith(', '$school.name.strip('])
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("$school.name.st", "Students", "firstName", self.user),
|
||||
['$school.name.startswith(', '$school.name.strip(']
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("$school.yearFounded.","Students", "firstName")),
|
||||
set(self.engine.autocomplete("$school.yearFounded.","Students", "firstName", self.user)),
|
||||
{
|
||||
'$school.yearFounded.denominator',
|
||||
'$school.yearFounded.bit_length(',
|
||||
@ -177,7 +282,11 @@ class TestCompletion(test_engine.EngineTestCase):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(self.engine.autocomplete("$school.address.", "Students", "lastName"),
|
||||
['$school.address.city', '$school.address.id'])
|
||||
self.assertEqual(self.engine.autocomplete("$school.address.city.st", "Students", "lastName"),
|
||||
['$school.address.city.startswith(', '$school.address.city.strip('])
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("$school.address.", "Students", "lastName", self.user),
|
||||
['$school.address.city', '$school.address.id']
|
||||
)
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("$school.address.city.st", "Students", "lastName", self.user),
|
||||
['$school.address.city.startswith(', '$school.address.city.strip(']
|
||||
)
|
||||
|
@ -334,14 +334,14 @@ class EngineTestCase(unittest.TestCase):
|
||||
def add_records(self, table_name, col_names, row_data):
|
||||
return self.apply_user_action(self.add_records_action(table_name, [col_names] + row_data))
|
||||
|
||||
def apply_user_action(self, user_action_repr, is_undo=False):
|
||||
def apply_user_action(self, user_action_repr, is_undo=False, user=None):
|
||||
if not is_undo:
|
||||
log.debug("Applying user action %r" % (user_action_repr,))
|
||||
if self._undo_state_tracker is not None:
|
||||
doc_state = self.getFullEngineData()
|
||||
|
||||
self.call_counts.clear()
|
||||
out_actions = self.engine.apply_user_actions([useractions.from_repr(user_action_repr)])
|
||||
out_actions = self.engine.apply_user_actions([useractions.from_repr(user_action_repr)], user)
|
||||
out_actions.calls = self.call_counts.copy()
|
||||
|
||||
if not is_undo and self._undo_state_tracker is not None:
|
||||
|
@ -309,18 +309,27 @@ class TestRenames(test_engine.EngineTestCase):
|
||||
]})
|
||||
|
||||
def test_rename_table_autocomplete(self):
|
||||
user = {
|
||||
'Name': 'Foo',
|
||||
'UserID': 1,
|
||||
'LinkKey': {},
|
||||
'Origin': None,
|
||||
'Email': 'foo@example.com',
|
||||
'Access': 'owners'
|
||||
}
|
||||
|
||||
# Renaming a table should not leave the old name available for auto-complete.
|
||||
self.load_sample(self.sample)
|
||||
names = {"People", "Persons"}
|
||||
self.assertEqual(
|
||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city")),
|
||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city", user)),
|
||||
{"People"}
|
||||
)
|
||||
|
||||
# Rename the table and ensure that "People" is no longer present among top-level names.
|
||||
out_actions = self.apply_user_action(["RenameTable", "People", "Persons"])
|
||||
self.assertEqual(
|
||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city")),
|
||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city", user)),
|
||||
{"Persons"}
|
||||
)
|
||||
|
||||
|
@ -522,3 +522,62 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
|
||||
[1, "Whale", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", time3],
|
||||
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "ATLANTIC", time2],
|
||||
])
|
||||
|
||||
def test_last_modified_by_recipe(self):
|
||||
user1 = {
|
||||
'Name': 'Foo Bar',
|
||||
'UserID': 1,
|
||||
'StudentInfo': ['Students', 1],
|
||||
'LinkKey': {},
|
||||
'Origin': None,
|
||||
'Email': 'foo.bar@getgrist.com',
|
||||
'Access': 'owners'
|
||||
}
|
||||
user2 = {
|
||||
'Name': 'Baz Qux',
|
||||
'UserID': 2,
|
||||
'StudentInfo': ['Students', 1],
|
||||
'LinkKey': {},
|
||||
'Origin': None,
|
||||
'Email': 'baz.qux@getgrist.com',
|
||||
'Access': 'owners'
|
||||
}
|
||||
# Use formula to store last modified by data (user name and email). Check that it works as expected.
|
||||
self.load_sample(self.sample)
|
||||
self.add_column('Creatures', 'LastModifiedBy', type='Text', isFormula=False,
|
||||
formula="user.Name + ' <' + user.Email + '>'", recalcWhen=RecalcWhen.MANUAL_UPDATES
|
||||
)
|
||||
self.assertTableData("Creatures", data=[
|
||||
["id","Name", "Ocean", "BossDef", "BossNvr", "BossUpd", "BossAll", "OceanName", "LastModifiedBy"],
|
||||
[1, "Dolphin", 2, "Arthur", "Arthur", "Arthur", "Arthur", "Atlantic", ""],
|
||||
])
|
||||
|
||||
self.apply_user_action(
|
||||
['AddRecord', "Creatures", None, {"Name": "Manatee", "Ocean": 2}],
|
||||
user=user1
|
||||
)
|
||||
self.apply_user_action(
|
||||
['UpdateRecord', "Creatures", 1, {"Ocean": 3}],
|
||||
user=user2
|
||||
)
|
||||
|
||||
self.assertTableData("Creatures", data=[
|
||||
["id","Name", "Ocean", "BossDef", "BossNvr", "BossUpd", "BossAll", "OceanName", "LastModifiedBy"],
|
||||
[1, "Dolphin", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", "Baz Qux <baz.qux@getgrist.com>"],
|
||||
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "Atlantic", "Foo Bar <foo.bar@getgrist.com>"],
|
||||
])
|
||||
|
||||
# An indirect change doesn't affect the user, but a direct change does.
|
||||
self.apply_user_action(
|
||||
['UpdateRecord', "Oceans", 2, {"Name": "ATLANTIC"}],
|
||||
user=user2
|
||||
)
|
||||
self.apply_user_action(
|
||||
['UpdateRecord', "Creatures", 1, {"Name": "Whale"}],
|
||||
user=user1
|
||||
)
|
||||
self.assertTableData("Creatures", data=[
|
||||
["id","Name", "Ocean", "BossDef", "BossNvr", "BossUpd", "BossAll", "OceanName", "LastModifiedBy"],
|
||||
[1, "Whale", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", "Foo Bar <foo.bar@getgrist.com>"],
|
||||
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "ATLANTIC", "Foo Bar <foo.bar@getgrist.com>"],
|
||||
])
|
||||
|
54
sandbox/grist/test_user.py
Normal file
54
sandbox/grist/test_user.py
Normal file
@ -0,0 +1,54 @@
|
||||
from user import User
|
||||
import test_engine
|
||||
import testsamples
|
||||
|
||||
class TestUser(test_engine.EngineTestCase):
|
||||
# pylint: disable=no-member
|
||||
def setUp(self):
|
||||
super(TestUser, self).setUp()
|
||||
self.load_sample(testsamples.sample_students)
|
||||
|
||||
def test_constructor_sets_user_attributes(self):
|
||||
data = {
|
||||
'Access': 'owners',
|
||||
'Name': 'Foo Bar',
|
||||
'Email': 'email@example.com',
|
||||
'UserID': 1,
|
||||
'LinkKey': {
|
||||
'Param1': 'Param1Value',
|
||||
'Param2': 'Param2Value'
|
||||
},
|
||||
'Origin': 'https://getgrist.com',
|
||||
'StudentInfo': ['Students', 1]
|
||||
}
|
||||
u = User(data, self.engine.tables)
|
||||
self.assertEqual(u.Name, 'Foo Bar')
|
||||
self.assertEqual(u.Email, 'email@example.com')
|
||||
self.assertEqual(u.UserID, 1)
|
||||
self.assertEqual(u.LinkKey.Param1, 'Param1Value')
|
||||
self.assertEqual(u.LinkKey.Param2, 'Param2Value')
|
||||
self.assertEqual(u.Access, 'owners')
|
||||
self.assertEqual(u.Origin, 'https://getgrist.com')
|
||||
self.assertEqual(u.StudentInfo.id, 1)
|
||||
self.assertEqual(u.StudentInfo.firstName, 'Barack')
|
||||
self.assertEqual(u.StudentInfo.lastName, 'Obama')
|
||||
self.assertEqual(u.StudentInfo.schoolName, 'Columbia')
|
||||
|
||||
def test_setting_is_sample_substitutes_attributes_with_samples(self):
|
||||
data = {
|
||||
'Access': 'owners',
|
||||
'Name': None,
|
||||
'Email': 'email@getgrist.com',
|
||||
'UserID': 1,
|
||||
'LinkKey': {
|
||||
'Param1': 'Param1Value',
|
||||
'Param2': 'Param2Value'
|
||||
},
|
||||
'Origin': 'https://getgrist.com',
|
||||
'StudentInfo': ['Students', 1]
|
||||
}
|
||||
u = User(data, self.engine.tables, is_sample=True)
|
||||
self.assertEqual(u.StudentInfo.id, 0)
|
||||
self.assertEqual(u.StudentInfo.firstName, '')
|
||||
self.assertEqual(u.StudentInfo.lastName, '')
|
||||
self.assertEqual(u.StudentInfo.schoolName, '')
|
62
sandbox/grist/user.py
Normal file
62
sandbox/grist/user.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""
|
||||
This module contains a class for creating a User containing
|
||||
basic user info and optional, user-defined attributes that reference
|
||||
user attribute tables.
|
||||
|
||||
A User has the same API as the 'user' variable from
|
||||
access rules. Currently, its primary purpose is to expose
|
||||
user info to trigger formulas, so that they can reference info
|
||||
about the current user.
|
||||
|
||||
The 'data' parameter represents a dictionary containing at least
|
||||
the following fields:
|
||||
|
||||
- Access: string or None
|
||||
- UserID: integer or None
|
||||
- Email: string or None
|
||||
- Name: string or None
|
||||
- Origin: string or None
|
||||
- LinkKey: dictionary
|
||||
|
||||
Additional keys may be included, which may have a value that is
|
||||
either None or of type tuple with the following shape:
|
||||
|
||||
[table_id, row_id]
|
||||
|
||||
The first element is the id (name) of the user attribute table, and the
|
||||
second element is the id of the row that matched based on the
|
||||
user attribute definition.
|
||||
|
||||
See 'GranularAccess.ts' for the Node equivalent that
|
||||
serializes the user information found in 'data'.
|
||||
"""
|
||||
import six
|
||||
|
||||
class User(object):
|
||||
"""
|
||||
User containing user info and optional attributes.
|
||||
|
||||
Setting 'is_sample' will substitute user attributes with
|
||||
typed equivalents, for use by autocompletion.
|
||||
"""
|
||||
def __init__(self, data, tables, is_sample=False):
|
||||
for attr in ('Access', 'UserID', 'Email', 'Name', 'Origin'):
|
||||
setattr(self, attr, data[attr])
|
||||
|
||||
self.LinkKey = LinkKey(data['LinkKey'])
|
||||
|
||||
for name, value in six.iteritems(data):
|
||||
if hasattr(self, name) or not value:
|
||||
continue
|
||||
table_name, row_id = value
|
||||
table = tables.get(table_name)
|
||||
if not table:
|
||||
continue
|
||||
# TODO: Investigate use of __dir__ in Record for type information
|
||||
record = table.sample_record if is_sample else table.get_record(row_id)
|
||||
setattr(self, name, record)
|
||||
|
||||
class LinkKey(object):
|
||||
def __init__(self, data):
|
||||
for name, value in six.iteritems(data):
|
||||
setattr(self, name, value)
|
@ -53,7 +53,7 @@ class Address:
|
||||
city = grist.Text()
|
||||
state = grist.Text()
|
||||
|
||||
def _default_country(rec, table, value):
|
||||
def _default_country(rec, table, value, user):
|
||||
return 'US'
|
||||
country = grist.Text()
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user