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 { PartialPermissionSet } from 'app/common/ACLPermissions';
|
||||||
import { CellValue, RowRecord } from 'app/common/DocActions';
|
import { CellValue, RowRecord } from 'app/common/DocActions';
|
||||||
|
import { Role } from './roles';
|
||||||
|
|
||||||
export interface RuleSet {
|
export interface RuleSet {
|
||||||
tableId: '*' | string;
|
tableId: '*' | string;
|
||||||
@ -36,7 +37,16 @@ export interface InfoEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Represents user info, which may include properties which are themselves RowRecords.
|
// 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.
|
* 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.
|
// Autocompletion can leak names of tables and columns.
|
||||||
if (!await this._granularAccess.canScanData(docSession)) { return []; }
|
if (!await this._granularAccess.canScanData(docSession)) { return []; }
|
||||||
await this.waitForInitialization();
|
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> {
|
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
|
* 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.
|
* 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);
|
const [normalActions, onDemandActions] = this._onDemandActions.splitByOnDemand(userActions);
|
||||||
|
|
||||||
let sandboxActionBundle: SandboxActionBundle;
|
let sandboxActionBundle: SandboxActionBundle;
|
||||||
@ -989,7 +993,8 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
if (normalActions[0][0] !== 'Calculate') {
|
if (normalActions[0][0] !== 'Calculate') {
|
||||||
await this.waitForInitialization();
|
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();
|
await this._reportDataEngineMemory();
|
||||||
} else {
|
} else {
|
||||||
// Create default SandboxActionBundle to use if the data engine is not called.
|
// 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);
|
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.
|
* Check whether user has any access to table.
|
||||||
*/
|
*/
|
||||||
@ -1182,7 +1187,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
} else {
|
} else {
|
||||||
fullUser = getDocSessionUser(docSession);
|
fullUser = getDocSessionUser(docSession);
|
||||||
}
|
}
|
||||||
const user: UserInfo = {};
|
const user = new User();
|
||||||
user.Access = access;
|
user.Access = access;
|
||||||
user.UserID = fullUser?.id || null;
|
user.UserID = fullUser?.id || null;
|
||||||
user.Email = fullUser?.email || 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 all actions, in a consistent order for test purposes.
|
||||||
return [action, ...[...parts.keys()].sort().map(key => parts.get(key)!)];
|
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,
|
MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,
|
||||||
toMixed } from 'app/common/ACLPermissions';
|
toMixed } from 'app/common/ACLPermissions';
|
||||||
import { ACLRuleCollection } from 'app/common/ACLRuleCollection';
|
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 { getSetMapValue } from 'app/common/gutil';
|
||||||
import * as log from 'app/server/lib/log';
|
import * as log from 'app/server/lib/log';
|
||||||
import { mapValues } from 'lodash';
|
import { mapValues } from 'lodash';
|
||||||
@ -79,6 +79,10 @@ abstract class RuleInfo<MixedT extends TableT, TableT> {
|
|||||||
return this._mergeFullAccess(tableAccess);
|
return this._mergeFullAccess(tableAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getUser(): UserInfo {
|
||||||
|
return this._input.user;
|
||||||
|
}
|
||||||
|
|
||||||
protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;
|
protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;
|
||||||
protected abstract _mergeTableAccess(access: MixedT[]): TableT;
|
protected abstract _mergeTableAccess(access: MixedT[]): TableT;
|
||||||
protected abstract _mergeFullAccess(access: TableT[]): MixedT;
|
protected abstract _mergeFullAccess(access: TableT[]): MixedT;
|
||||||
|
@ -361,7 +361,7 @@ export class Sharing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _applyActionsToDataEngine(docSession: OptDocSession|null, userActions: UserAction[]) {
|
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 undo = getEnvContent(sandboxActionBundle.undo);
|
||||||
const docActions = getEnvContent(sandboxActionBundle.stored).concat(
|
const docActions = getEnvContent(sandboxActionBundle.stored).concat(
|
||||||
getEnvContent(sandboxActionBundle.calc));
|
getEnvContent(sandboxActionBundle.calc));
|
||||||
@ -377,7 +377,7 @@ export class Sharing {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// should not commit. Don't write to db. Remove changes from sandbox.
|
// should not commit. Don't write to db. Remove changes from sandbox.
|
||||||
try {
|
try {
|
||||||
await this._activeDoc.applyActionsToDataEngine([['ApplyUndoActions', undo]]);
|
await this._activeDoc.applyActionsToDataEngine(docSession, [['ApplyUndoActions', undo]]);
|
||||||
} finally {
|
} finally {
|
||||||
await accessControl.finishedBundle();
|
await accessControl.finishedBundle();
|
||||||
}
|
}
|
||||||
|
@ -82,11 +82,7 @@ class BaseColumn(object):
|
|||||||
'method' function. The method may refer to variables in the generated "usercode" module, and
|
'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.
|
it's important that all such references are to the rebuilt "usercode" module.
|
||||||
"""
|
"""
|
||||||
if not self._is_formula and method:
|
self.method = 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):
|
def is_formula(self):
|
||||||
"""
|
"""
|
||||||
|
@ -33,16 +33,17 @@ from relation import SingleRowsIdentityRelation
|
|||||||
import schema
|
import schema
|
||||||
from schema import RecalcWhen
|
from schema import RecalcWhen
|
||||||
import table as table_module
|
import table as table_module
|
||||||
|
from user import User # pylint:disable=wrong-import-order
|
||||||
import useractions
|
import useractions
|
||||||
import column
|
import column
|
||||||
import repl
|
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)
|
log = logger.Logger(__name__, logger.INFO)
|
||||||
|
|
||||||
if six.PY2:
|
if six.PY2:
|
||||||
reload(sys)
|
reload(sys)
|
||||||
sys.setdefaultencoding('utf8') # noqa
|
sys.setdefaultencoding('utf8') # noqa # pylint:disable=no-member
|
||||||
|
|
||||||
|
|
||||||
class OrderError(Exception):
|
class OrderError(Exception):
|
||||||
@ -129,7 +130,7 @@ class Engine(object):
|
|||||||
Returns a TableData object containing the full data for the table. Formula columns
|
Returns a TableData object containing the full data for the table. Formula columns
|
||||||
are included only if formulas is True.
|
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
|
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,
|
method (as defind in useractions.py) and the arguments to it. Returns ActionGroup tuple,
|
||||||
containing several categories of DocActions, including the results of computations.
|
containing several categories of DocActions, including the results of computations.
|
||||||
@ -238,6 +239,9 @@ class Engine(object):
|
|||||||
# current cell.
|
# current cell.
|
||||||
self._cell_required_error = None
|
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.
|
# Initial empty context for autocompletions; we update it when we generate the usercode module.
|
||||||
self._autocomplete_context = AutocompleteContext({})
|
self._autocomplete_context = AutocompleteContext({})
|
||||||
|
|
||||||
@ -860,7 +864,10 @@ class Engine(object):
|
|||||||
try:
|
try:
|
||||||
if cycle:
|
if cycle:
|
||||||
raise depend.CircularRefError("Circular Reference")
|
raise depend.CircularRefError("Circular Reference")
|
||||||
result = col.method(record, table.user_table)
|
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:
|
if self._cell_required_error:
|
||||||
raise self._cell_required_error # pylint: disable=raising-bad-type
|
raise self._cell_required_error # pylint: disable=raising-bad-type
|
||||||
self.formula_tracer(col, record)
|
self.formula_tracer(col, record)
|
||||||
@ -1146,7 +1153,7 @@ class Engine(object):
|
|||||||
"""
|
"""
|
||||||
self._unused_lookups.add(lookup_map_column)
|
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.
|
Applies the list of user_actions. Returns an ActionGroup.
|
||||||
"""
|
"""
|
||||||
@ -1156,7 +1163,7 @@ class Engine(object):
|
|||||||
# everything, and only filter what we send.
|
# everything, and only filter what we send.
|
||||||
|
|
||||||
self.out_actions = action_obj.ActionGroup()
|
self.out_actions = action_obj.ActionGroup()
|
||||||
|
self._user = User(user, self.tables) if user else None
|
||||||
checkpoint = self._get_undo_checkpoint()
|
checkpoint = self._get_undo_checkpoint()
|
||||||
try:
|
try:
|
||||||
for user_action in user_actions:
|
for user_action in user_actions:
|
||||||
@ -1210,6 +1217,7 @@ class Engine(object):
|
|||||||
|
|
||||||
self.out_actions.flush_calc_changes()
|
self.out_actions.flush_calc_changes()
|
||||||
self.out_actions.check_sanity()
|
self.out_actions.check_sanity()
|
||||||
|
self._user = None
|
||||||
return self.out_actions
|
return self.out_actions
|
||||||
|
|
||||||
def acl_split(self, action_group):
|
def acl_split(self, action_group):
|
||||||
@ -1281,7 +1289,7 @@ class Engine(object):
|
|||||||
if not self._compute_stack:
|
if not self._compute_stack:
|
||||||
self._bring_lookups_up_to_date(doc_action)
|
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.
|
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.
|
# Remove values from the context that need to be recomputed.
|
||||||
context.pop('value', None)
|
context.pop('value', None)
|
||||||
|
context.pop('user', None)
|
||||||
|
|
||||||
column = table.get_column(column_id) if table.has_column(column_id) else None
|
column = table.get_column(column_id) if table.has_column(column_id) else None
|
||||||
if column and not column.is_formula():
|
if column and not column.is_formula():
|
||||||
|
# Add trigger formula completions.
|
||||||
context['value'] = column.sample_value()
|
context['value'] = column.sample_value()
|
||||||
|
context['user'] = User(user, self.tables, is_sample=True)
|
||||||
|
|
||||||
completer = rlcompleter.Completer(context)
|
completer = rlcompleter.Completer(context)
|
||||||
results = []
|
results = []
|
||||||
|
@ -77,12 +77,15 @@ class GenCode(object):
|
|||||||
self._usercode = None
|
self._usercode = None
|
||||||
|
|
||||||
def _make_formula_field(self, col_info, table_id, name=None, include_type=True,
|
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."""
|
"""Returns the code for a formula field."""
|
||||||
# If the caller didn't specify a special name, use the colId
|
# If the caller didn't specify a special name, use the colId
|
||||||
name = name or col_info.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.
|
# 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)
|
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,
|
parts.append(self._make_formula_field(col_info, table_id,
|
||||||
name=table.get_default_func_name(col_info.colId),
|
name=table.get_default_func_name(col_info.colId),
|
||||||
include_type=False,
|
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)))
|
parts.append("%s = %s\n" % (col_info.colId, get_grist_type(col_info.type)))
|
||||||
return textbuilder.Combiner(parts)
|
return textbuilder.Combiner(parts)
|
||||||
|
|
||||||
|
@ -55,8 +55,8 @@ def run(sandbox):
|
|||||||
sandbox.register(method.__name__, wrapper)
|
sandbox.register(method.__name__, wrapper)
|
||||||
|
|
||||||
@export
|
@export
|
||||||
def apply_user_actions(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])
|
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()
|
return eng.acl_split(action_group).to_json_obj()
|
||||||
|
|
||||||
@export
|
@export
|
||||||
@ -73,8 +73,8 @@ def run(sandbox):
|
|||||||
return eng.acl_split(action_group).to_json_obj()
|
return eng.acl_split(action_group).to_json_obj()
|
||||||
|
|
||||||
@export
|
@export
|
||||||
def autocomplete(txt, table_id, column_id):
|
def autocomplete(txt, table_id, column_id, user):
|
||||||
return eng.autocomplete(txt, table_id, column_id)
|
return eng.autocomplete(txt, table_id, column_id, user)
|
||||||
|
|
||||||
@export
|
@export
|
||||||
def find_col_from_values(values, n, opt_table_id):
|
def find_col_from_values(values, n, opt_table_id):
|
||||||
|
@ -3,6 +3,16 @@ import test_engine
|
|||||||
from schema import RecalcWhen
|
from schema import RecalcWhen
|
||||||
|
|
||||||
class TestCompletion(test_engine.EngineTestCase):
|
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):
|
def setUp(self):
|
||||||
super(TestCompletion, self).setUp()
|
super(TestCompletion, self).setUp()
|
||||||
self.load_sample(testsamples.sample_students)
|
self.load_sample(testsamples.sample_students)
|
||||||
@ -23,35 +33,109 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_keyword(self):
|
def test_keyword(self):
|
||||||
self.assertEqual(self.engine.autocomplete("for", "Address", "city"),
|
self.assertEqual(self.engine.autocomplete("for", "Address", "city", self.user),
|
||||||
["for", "format("])
|
["for", "format("])
|
||||||
|
|
||||||
def test_grist(self):
|
def test_grist(self):
|
||||||
self.assertEqual(self.engine.autocomplete("gri", "Address", "city"),
|
self.assertEqual(self.engine.autocomplete("gri", "Address", "city", self.user),
|
||||||
["grist"])
|
["grist"])
|
||||||
|
|
||||||
def test_value(self):
|
def test_value(self):
|
||||||
# Should only appear if column exists and is a trigger formula.
|
# Should only appear if column exists and is a trigger formula.
|
||||||
self.assertEqual(self.engine.autocomplete("val", "Schools", "lastModified"),
|
self.assertEqual(
|
||||||
["value"])
|
self.engine.autocomplete("val", "Schools", "lastModified", self.user),
|
||||||
self.assertEqual(self.engine.autocomplete("val", "Students", "schoolCities"),
|
["value"]
|
||||||
[])
|
)
|
||||||
self.assertEqual(self.engine.autocomplete("val", "Students", "nonexistentColumn"),
|
self.assertEqual(
|
||||||
[])
|
self.engine.autocomplete("val", "Students", "schoolCities", self.user),
|
||||||
self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier"),
|
[]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.engine.autocomplete("val", "Students", "nonexistentColumn", self.user),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier", self.user),
|
||||||
["value"])
|
["value"])
|
||||||
# Should have same type as column.
|
# Should have same type as column.
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModifier")),
|
self.assertGreaterEqual(
|
||||||
{'value.startswith(', 'value.replace(', 'value.title('})
|
set(self.engine.autocomplete("value.", "Schools", "lastModifier", self.user)),
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModified")),
|
{'value.startswith(', 'value.replace(', 'value.title('}
|
||||||
{'value.month', 'value.strftime(', 'value.replace('})
|
)
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("value.m", "Schools", "lastModified")),
|
self.assertGreaterEqual(
|
||||||
{'value.month', 'value.minute'})
|
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):
|
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)])
|
[('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),
|
('MAX', '(value, *more_values)', True),
|
||||||
('MAXA', '(value, *more_values)', True),
|
('MAXA', '(value, *more_values)', True),
|
||||||
'map(',
|
'map(',
|
||||||
@ -60,30 +144,30 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
])
|
])
|
||||||
|
|
||||||
def test_member(self):
|
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("])
|
["datetime.tzinfo("])
|
||||||
|
|
||||||
def test_case_insensitive(self):
|
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)])
|
[('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),
|
('STDEV', '(value, *more_values)', True),
|
||||||
('STDEVA', '(value, *more_values)', True),
|
('STDEVA', '(value, *more_values)', True),
|
||||||
('STDEVP', '(value, *more_values)', True),
|
('STDEVP', '(value, *more_values)', True),
|
||||||
('STDEVPA', '(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"])
|
["Students"])
|
||||||
|
|
||||||
# Add a table name whose lowercase version conflicts with a builtin.
|
# Add a table name whose lowercase version conflicts with a builtin.
|
||||||
self.apply_user_action(['AddTable', 'Max', []])
|
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),
|
('MAX', '(value, *more_values)', True),
|
||||||
('MAXA', '(value, *more_values)', True),
|
('MAXA', '(value, *more_values)', True),
|
||||||
'Max',
|
'Max',
|
||||||
'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),
|
('MAX', '(value, *more_values)', True),
|
||||||
('MAXA', '(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):
|
def test_suggest_globals_and_tables(self):
|
||||||
# Should suggest globals and table names.
|
# 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)])
|
[('MEDIAN', '(value, *more_values)', True)])
|
||||||
self.assertEqual(self.engine.autocomplete("Ad", "Address", "city"), ['Address'])
|
self.assertEqual(self.engine.autocomplete("Ad", "Address", "city", self.user), ['Address'])
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city")), {
|
self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city", self.user)), {
|
||||||
'Schools',
|
'Schools',
|
||||||
'Students',
|
'Students',
|
||||||
('SUM', '(value1, *more_values)', True),
|
('SUM', '(value1, *more_values)', True),
|
||||||
('STDEV', '(value, *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',
|
'Schools',
|
||||||
'Students',
|
'Students',
|
||||||
'sum(',
|
'sum(',
|
||||||
('SUM', '(value1, *more_values)', True),
|
('SUM', '(value1, *more_values)', True),
|
||||||
('STDEV', '(value, *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):
|
def test_suggest_columns(self):
|
||||||
self.assertEqual(self.engine.autocomplete("$ci", "Address", "city"),
|
self.assertEqual(self.engine.autocomplete("$ci", "Address", "city", self.user),
|
||||||
["$city"])
|
["$city"])
|
||||||
self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city"),
|
self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city", self.user),
|
||||||
["rec.id"])
|
["rec.id"])
|
||||||
self.assertEqual(len(self.engine.autocomplete("$", "Address", "city")),
|
self.assertEqual(len(self.engine.autocomplete("$", "Address", "city", self.user)),
|
||||||
2)
|
2)
|
||||||
|
|
||||||
# A few more detailed examples.
|
# 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',
|
['$birthDate', '$firstName', '$id', '$lastName', '$lastVisit',
|
||||||
'$school', '$schoolCities', '$schoolIds', '$schoolName'])
|
'$school', '$schoolCities', '$schoolIds', '$schoolName'])
|
||||||
self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate"), ['$firstName'])
|
self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate", self.user),
|
||||||
self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit"),
|
['$firstName'])
|
||||||
|
self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit", self.user),
|
||||||
['$school', '$schoolCities', '$schoolIds', '$schoolName'])
|
['$school', '$schoolCities', '$schoolIds', '$schoolName'])
|
||||||
|
|
||||||
def test_suggest_lookup_methods(self):
|
def test_suggest_lookup_methods(self):
|
||||||
# Should suggest lookup formulas for tables.
|
# 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.all',
|
||||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||||
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
])
|
])
|
||||||
|
|
||||||
self.assertEqual(self.engine.autocomplete("Address.lookup", "Students", "lastName"), [
|
self.assertEqual(
|
||||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
self.engine.autocomplete("Address.lookup", "Students", "lastName", self.user),
|
||||||
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
[
|
||||||
])
|
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||||
|
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(self.engine.autocomplete("address.look", "Students", "schoolName"), [
|
self.assertEqual(
|
||||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
self.engine.autocomplete("address.look", "Students", "schoolName", self.user),
|
||||||
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
[
|
||||||
])
|
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||||
|
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def test_suggest_column_type_methods(self):
|
def test_suggest_column_type_methods(self):
|
||||||
# Should treat columns as correct types.
|
# Should treat columns as correct types.
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("$firstName.", "Students", "firstName")),
|
self.assertGreaterEqual(
|
||||||
{'$firstName.startswith(', '$firstName.replace(', '$firstName.title('})
|
set(self.engine.autocomplete("$firstName.", "Students", "firstName", self.user)),
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("$birthDate.", "Students", "lastName")),
|
{'$firstName.startswith(', '$firstName.replace(', '$firstName.title('}
|
||||||
{'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('})
|
)
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("$lastVisit.m", "Students", "firstName")),
|
self.assertGreaterEqual(
|
||||||
{'$lastVisit.month', '$lastVisit.minute'})
|
set(self.engine.autocomplete("$birthDate.", "Students", "lastName", self.user)),
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("$school.", "Students", "firstName")),
|
{'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('}
|
||||||
{'$school.address', '$school.name',
|
)
|
||||||
'$school.yearFounded', '$school.budget'})
|
self.assertGreaterEqual(
|
||||||
self.assertEqual(self.engine.autocomplete("$school.year", "Students", "lastName"),
|
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'])
|
['$school.yearFounded'])
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("$yearFounded.", "Schools", "budget")),
|
self.assertGreaterEqual(
|
||||||
{'$yearFounded.denominator', # Only integers have this
|
set(self.engine.autocomplete("$yearFounded.", "Schools", "budget", self.user)),
|
||||||
'$yearFounded.bit_length(', # and this
|
{
|
||||||
'$yearFounded.real'})
|
'$yearFounded.denominator', # Only integers have this
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("$budget.", "Schools", "budget")),
|
'$yearFounded.bit_length(', # and this
|
||||||
{'$budget.is_integer(', # Only floats have this
|
'$yearFounded.real'
|
||||||
'$budget.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):
|
def test_suggest_follows_references(self):
|
||||||
# Should follow references and autocomplete those types.
|
# Should follow references and autocomplete those types.
|
||||||
self.assertEqual(self.engine.autocomplete("$school.name.st", "Students", "firstName"),
|
self.assertEqual(
|
||||||
['$school.name.startswith(', '$school.name.strip('])
|
self.engine.autocomplete("$school.name.st", "Students", "firstName", self.user),
|
||||||
|
['$school.name.startswith(', '$school.name.strip(']
|
||||||
|
)
|
||||||
self.assertGreaterEqual(
|
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.denominator',
|
||||||
'$school.yearFounded.bit_length(',
|
'$school.yearFounded.bit_length(',
|
||||||
@ -177,7 +282,11 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(self.engine.autocomplete("$school.address.", "Students", "lastName"),
|
self.assertEqual(
|
||||||
['$school.address.city', '$school.address.id'])
|
self.engine.autocomplete("$school.address.", "Students", "lastName", self.user),
|
||||||
self.assertEqual(self.engine.autocomplete("$school.address.city.st", "Students", "lastName"),
|
['$school.address.city', '$school.address.id']
|
||||||
['$school.address.city.startswith(', '$school.address.city.strip('])
|
)
|
||||||
|
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):
|
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))
|
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:
|
if not is_undo:
|
||||||
log.debug("Applying user action %r" % (user_action_repr,))
|
log.debug("Applying user action %r" % (user_action_repr,))
|
||||||
if self._undo_state_tracker is not None:
|
if self._undo_state_tracker is not None:
|
||||||
doc_state = self.getFullEngineData()
|
doc_state = self.getFullEngineData()
|
||||||
|
|
||||||
self.call_counts.clear()
|
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()
|
out_actions.calls = self.call_counts.copy()
|
||||||
|
|
||||||
if not is_undo and self._undo_state_tracker is not None:
|
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):
|
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.
|
# Renaming a table should not leave the old name available for auto-complete.
|
||||||
self.load_sample(self.sample)
|
self.load_sample(self.sample)
|
||||||
names = {"People", "Persons"}
|
names = {"People", "Persons"}
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city")),
|
names.intersection(self.engine.autocomplete("Pe", "Address", "city", user)),
|
||||||
{"People"}
|
{"People"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Rename the table and ensure that "People" is no longer present among top-level names.
|
# Rename the table and ensure that "People" is no longer present among top-level names.
|
||||||
out_actions = self.apply_user_action(["RenameTable", "People", "Persons"])
|
out_actions = self.apply_user_action(["RenameTable", "People", "Persons"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city")),
|
names.intersection(self.engine.autocomplete("Pe", "Address", "city", user)),
|
||||||
{"Persons"}
|
{"Persons"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -522,3 +522,62 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
|
|||||||
[1, "Whale", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", time3],
|
[1, "Whale", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", time3],
|
||||||
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "ATLANTIC", time2],
|
[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()
|
city = grist.Text()
|
||||||
state = grist.Text()
|
state = grist.Text()
|
||||||
|
|
||||||
def _default_country(rec, table, value):
|
def _default_country(rec, table, value, user):
|
||||||
return 'US'
|
return 'US'
|
||||||
country = grist.Text()
|
country = grist.Text()
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user