(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:
George Gevoian 2021-07-14 17:45:53 -07:00
parent 6c114ef439
commit e5eeb3ec80
16 changed files with 464 additions and 99 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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();
} }

View File

@ -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):
""" """

View File

@ -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 = []

View File

@ -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)

View File

@ -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):

View File

@ -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(']
)

View File

@ -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:

View File

@ -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"}
) )

View File

@ -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>"],
])

View 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
View 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)

View File

@ -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()