(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 { CellValue, RowRecord } from 'app/common/DocActions';
import { Role } from './roles';
export interface RuleSet {
tableId: '*' | string;
@ -36,7 +37,16 @@ export interface InfoEditor {
}
// Represents user info, which may include properties which are themselves RowRecords.
export type UserInfo = Record<string, CellValue|InfoView|Record<string, string>>;
export interface UserInfo {
Name: string | null;
Email: string | null;
Access: Role | null;
Origin: string | null;
LinkKey: Record<string, string | undefined>;
UserID: number | null;
[attributes: string]: unknown;
toJSON(): {[key: string]: any};
}
/**
* Input into the AclMatchFunc. Compiled formulas evaluate AclMatchInput to produce a boolean.

View File

@ -835,7 +835,8 @@ export class ActiveDoc extends EventEmitter {
// Autocompletion can leak names of tables and columns.
if (!await this._granularAccess.canScanData(docSession)) { return []; }
await this.waitForInitialization();
return this._pyCall('autocomplete', txt, tableId, columnId);
const user = await this._granularAccess.getCachedUser(docSession);
return this._pyCall('autocomplete', txt, tableId, columnId, user.toJSON());
}
public fetchURL(docSession: DocSession, url: string): Promise<UploadResult> {
@ -980,7 +981,10 @@ export class ActiveDoc extends EventEmitter {
* Should only be called by a Sharing object, with this._modificationLock held, since the
* actions may need to be rolled back if final access control checks fail.
*/
public async applyActionsToDataEngine(userActions: UserAction[]): Promise<SandboxActionBundle> {
public async applyActionsToDataEngine(
docSession: OptDocSession|null,
userActions: UserAction[]
): Promise<SandboxActionBundle> {
const [normalActions, onDemandActions] = this._onDemandActions.splitByOnDemand(userActions);
let sandboxActionBundle: SandboxActionBundle;
@ -989,7 +993,8 @@ export class ActiveDoc extends EventEmitter {
if (normalActions[0][0] !== 'Calculate') {
await this.waitForInitialization();
}
sandboxActionBundle = await this._rawPyCall('apply_user_actions', normalActions);
const user = docSession ? await this._granularAccess.getCachedUser(docSession) : undefined;
sandboxActionBundle = await this._rawPyCall('apply_user_actions', normalActions, user?.toJSON());
await this._reportDataEngineMemory();
} else {
// Create default SandboxActionBundle to use if the data engine is not called.

View File

@ -210,6 +210,11 @@ export class GranularAccess implements GranularAccessForBundle {
return this._getUser(docSession);
}
public async getCachedUser(docSession: OptDocSession): Promise<UserInfo> {
const access = await this._getAccess(docSession);
return access.getUser();
}
/**
* Check whether user has any access to table.
*/
@ -1182,7 +1187,7 @@ export class GranularAccess implements GranularAccessForBundle {
} else {
fullUser = getDocSessionUser(docSession);
}
const user: UserInfo = {};
const user = new User();
user.Access = access;
user.UserID = fullUser?.id || null;
user.Email = fullUser?.email || null;
@ -2061,3 +2066,41 @@ export function filterColValues(action: DataAction,
// Return all actions, in a consistent order for test purposes.
return [action, ...[...parts.keys()].sort().map(key => parts.get(key)!)];
}
/**
* Information about a user, including any user attributes.
*
* Serializes into a more compact JSON form that excludes full
* row data, only keeping user info and table/row ids for any
* user attributes.
*
* See `user.py` for the sandbox equivalent that deserializes objects of this class.
*/
export class User implements UserInfo {
public Name: string | null = null;
public UserID: number | null = null;
public Access: Role | null = null;
public Origin: string | null = null;
public LinkKey: Record<string, string | undefined> = {};
public Email: string | null = null;
[attribute: string]: any;
constructor(_info: Record<string, unknown> = {}) {
Object.assign(this, _info);
}
public toJSON() {
const results: {[key: string]: any} = {};
for (const [key, value] of Object.entries(this)) {
if (value instanceof RecordView) {
// Only include the table id and first matching row id.
results[key] = [getTableId(value.data), value.get('id')];
} else if (value instanceof EmptyRecordView) {
results[key] = null;
} else {
results[key] = value;
}
}
return results;
}
}

View File

@ -3,7 +3,7 @@ import { ALL_PERMISSION_PROPS, emptyPermissionSet,
MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,
toMixed } from 'app/common/ACLPermissions';
import { ACLRuleCollection } from 'app/common/ACLRuleCollection';
import { AclMatchInput, RuleSet } from 'app/common/GranularAccessClause';
import { AclMatchInput, RuleSet, UserInfo } from 'app/common/GranularAccessClause';
import { getSetMapValue } from 'app/common/gutil';
import * as log from 'app/server/lib/log';
import { mapValues } from 'lodash';
@ -79,6 +79,10 @@ abstract class RuleInfo<MixedT extends TableT, TableT> {
return this._mergeFullAccess(tableAccess);
}
public getUser(): UserInfo {
return this._input.user;
}
protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;
protected abstract _mergeTableAccess(access: MixedT[]): TableT;
protected abstract _mergeFullAccess(access: TableT[]): MixedT;

View File

@ -361,7 +361,7 @@ export class Sharing {
}
private async _applyActionsToDataEngine(docSession: OptDocSession|null, userActions: UserAction[]) {
const sandboxActionBundle = await this._activeDoc.applyActionsToDataEngine(userActions);
const sandboxActionBundle = await this._activeDoc.applyActionsToDataEngine(docSession, userActions);
const undo = getEnvContent(sandboxActionBundle.undo);
const docActions = getEnvContent(sandboxActionBundle.stored).concat(
getEnvContent(sandboxActionBundle.calc));
@ -377,7 +377,7 @@ export class Sharing {
} catch (e) {
// should not commit. Don't write to db. Remove changes from sandbox.
try {
await this._activeDoc.applyActionsToDataEngine([['ApplyUndoActions', undo]]);
await this._activeDoc.applyActionsToDataEngine(docSession, [['ApplyUndoActions', undo]]);
} finally {
await accessControl.finishedBundle();
}

View File

@ -82,11 +82,7 @@ class BaseColumn(object):
'method' function. The method may refer to variables in the generated "usercode" module, and
it's important that all such references are to the rebuilt "usercode" module.
"""
if not self._is_formula and method:
# Include the current value of the cell as the third parameter (to default formulas).
self.method = lambda rec, table: method(rec, table, self.get_cell_value(int(rec)))
else:
self.method = method
self.method = method
def is_formula(self):
"""

View File

@ -33,16 +33,17 @@ from relation import SingleRowsIdentityRelation
import schema
from schema import RecalcWhen
import table as table_module
from user import User # pylint:disable=wrong-import-order
import useractions
import column
import repl
import urllib_patch # noqa imported for side effect
import urllib_patch # noqa imported for side effect # pylint:disable=unused-import
log = logger.Logger(__name__, logger.INFO)
if six.PY2:
reload(sys)
sys.setdefaultencoding('utf8') # noqa
sys.setdefaultencoding('utf8') # noqa # pylint:disable=no-member
class OrderError(Exception):
@ -129,7 +130,7 @@ class Engine(object):
Returns a TableData object containing the full data for the table. Formula columns
are included only if formulas is True.
apply_user_actions(user_actions)
apply_user_actions(user_actions, user)
Applies a list of UserActions, which are tuples consisting of the name of the action
method (as defind in useractions.py) and the arguments to it. Returns ActionGroup tuple,
containing several categories of DocActions, including the results of computations.
@ -238,6 +239,9 @@ class Engine(object):
# current cell.
self._cell_required_error = None
# User that is currently applying user actions.
self._user = None
# Initial empty context for autocompletions; we update it when we generate the usercode module.
self._autocomplete_context = AutocompleteContext({})
@ -860,7 +864,10 @@ class Engine(object):
try:
if cycle:
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:
raise self._cell_required_error # pylint: disable=raising-bad-type
self.formula_tracer(col, record)
@ -1146,7 +1153,7 @@ class Engine(object):
"""
self._unused_lookups.add(lookup_map_column)
def apply_user_actions(self, user_actions):
def apply_user_actions(self, user_actions, user=None):
"""
Applies the list of user_actions. Returns an ActionGroup.
"""
@ -1156,7 +1163,7 @@ class Engine(object):
# everything, and only filter what we send.
self.out_actions = action_obj.ActionGroup()
self._user = User(user, self.tables) if user else None
checkpoint = self._get_undo_checkpoint()
try:
for user_action in user_actions:
@ -1210,6 +1217,7 @@ class Engine(object):
self.out_actions.flush_calc_changes()
self.out_actions.check_sanity()
self._user = None
return self.out_actions
def acl_split(self, action_group):
@ -1281,7 +1289,7 @@ class Engine(object):
if not self._compute_stack:
self._bring_lookups_up_to_date(doc_action)
def autocomplete(self, txt, table_id, column_id):
def autocomplete(self, txt, table_id, column_id, user):
"""
Return a list of suggested completions of the python fragment supplied.
"""
@ -1297,10 +1305,13 @@ class Engine(object):
# Remove values from the context that need to be recomputed.
context.pop('value', None)
context.pop('user', None)
column = table.get_column(column_id) if table.has_column(column_id) else None
if column and not column.is_formula():
# Add trigger formula completions.
context['value'] = column.sample_value()
context['user'] = User(user, self.tables, is_sample=True)
completer = rlcompleter.Completer(context)
results = []

View File

@ -77,12 +77,15 @@ class GenCode(object):
self._usercode = None
def _make_formula_field(self, col_info, table_id, name=None, include_type=True,
include_value_arg=False):
additional_params=()):
"""Returns the code for a formula field."""
# If the caller didn't specify a special name, use the colId
name = name or col_info.colId
decl = "def %s(rec, table%s):\n" % (name, (", value" if include_value_arg else ""))
decl = "def %s(%s):\n" % (
name,
', '.join(['rec', 'table'] + list(additional_params))
)
# This is where we get to use the formula cache, and save the work of rebuilding formulas.
key = (table_id, col_info.colId, col_info.formula)
@ -105,7 +108,7 @@ class GenCode(object):
parts.append(self._make_formula_field(col_info, table_id,
name=table.get_default_func_name(col_info.colId),
include_type=False,
include_value_arg=True))
additional_params=['value', 'user']))
parts.append("%s = %s\n" % (col_info.colId, get_grist_type(col_info.type)))
return textbuilder.Combiner(parts)

View File

@ -55,8 +55,8 @@ def run(sandbox):
sandbox.register(method.__name__, wrapper)
@export
def apply_user_actions(action_reprs):
action_group = eng.apply_user_actions([useractions.from_repr(u) for u in action_reprs])
def apply_user_actions(action_reprs, user=None):
action_group = eng.apply_user_actions([useractions.from_repr(u) for u in action_reprs], user)
return eng.acl_split(action_group).to_json_obj()
@export
@ -73,8 +73,8 @@ def run(sandbox):
return eng.acl_split(action_group).to_json_obj()
@export
def autocomplete(txt, table_id, column_id):
return eng.autocomplete(txt, table_id, column_id)
def autocomplete(txt, table_id, column_id, user):
return eng.autocomplete(txt, table_id, column_id, user)
@export
def find_col_from_values(values, n, opt_table_id):

View File

@ -3,6 +3,16 @@ import test_engine
from schema import RecalcWhen
class TestCompletion(test_engine.EngineTestCase):
user = {
'Name': 'Foo',
'UserID': 1,
'StudentInfo': ['Students', 1],
'LinkKey': {},
'Origin': None,
'Email': 'foo@example.com',
'Access': 'owners'
}
def setUp(self):
super(TestCompletion, self).setUp()
self.load_sample(testsamples.sample_students)
@ -23,35 +33,109 @@ class TestCompletion(test_engine.EngineTestCase):
)
def test_keyword(self):
self.assertEqual(self.engine.autocomplete("for", "Address", "city"),
self.assertEqual(self.engine.autocomplete("for", "Address", "city", self.user),
["for", "format("])
def test_grist(self):
self.assertEqual(self.engine.autocomplete("gri", "Address", "city"),
self.assertEqual(self.engine.autocomplete("gri", "Address", "city", self.user),
["grist"])
def test_value(self):
# Should only appear if column exists and is a trigger formula.
self.assertEqual(self.engine.autocomplete("val", "Schools", "lastModified"),
["value"])
self.assertEqual(self.engine.autocomplete("val", "Students", "schoolCities"),
[])
self.assertEqual(self.engine.autocomplete("val", "Students", "nonexistentColumn"),
[])
self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier"),
self.assertEqual(
self.engine.autocomplete("val", "Schools", "lastModified", self.user),
["value"]
)
self.assertEqual(
self.engine.autocomplete("val", "Students", "schoolCities", self.user),
[]
)
self.assertEqual(
self.engine.autocomplete("val", "Students", "nonexistentColumn", self.user),
[]
)
self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier", self.user),
["value"])
# Should have same type as column.
self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModifier")),
{'value.startswith(', 'value.replace(', 'value.title('})
self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModified")),
{'value.month', 'value.strftime(', 'value.replace('})
self.assertGreaterEqual(set(self.engine.autocomplete("value.m", "Schools", "lastModified")),
{'value.month', 'value.minute'})
self.assertGreaterEqual(
set(self.engine.autocomplete("value.", "Schools", "lastModifier", self.user)),
{'value.startswith(', 'value.replace(', 'value.title('}
)
self.assertGreaterEqual(
set(self.engine.autocomplete("value.", "Schools", "lastModified", self.user)),
{'value.month', 'value.strftime(', 'value.replace('}
)
self.assertGreaterEqual(
set(self.engine.autocomplete("value.m", "Schools", "lastModified", self.user)),
{'value.month', 'value.minute'}
)
def test_user(self):
# Should only appear if column exists and is a trigger formula.
self.assertEqual(self.engine.autocomplete("use", "Schools", "lastModified", self.user),
["user"])
self.assertEqual(self.engine.autocomplete("use", "Students", "schoolCities", self.user),
[])
self.assertEqual(self.engine.autocomplete("use", "Students", "nonexistentColumn", self.user),
[])
self.assertEqual(self.engine.autocomplete("user", "Schools", "lastModifier", self.user),
["user"])
self.assertEqual(
self.engine.autocomplete("user.", "Schools", "lastModified", self.user),
[
'user.Access',
'user.Email',
'user.LinkKey',
'user.Name',
'user.Origin',
'user.StudentInfo',
'user.UserID'
]
)
# Should follow user attribute references and autocomplete those types.
self.assertEqual(
self.engine.autocomplete("user.StudentInfo.", "Schools", "lastModified", self.user),
[
'user.StudentInfo.birthDate',
'user.StudentInfo.firstName',
'user.StudentInfo.id',
'user.StudentInfo.lastName',
'user.StudentInfo.lastVisit',
'user.StudentInfo.school',
'user.StudentInfo.schoolCities',
'user.StudentInfo.schoolIds',
'user.StudentInfo.schoolName'
]
)
# Should not show user attribute completions if user doesn't have attribute.
user2 = {
'Name': 'Bar',
'Origin': None,
'Email': 'baro@example.com',
'LinkKey': {},
'UserID': 2,
'Access': 'owners'
}
self.assertEqual(
self.engine.autocomplete("user.", "Schools", "lastModified", user2),
[
'user.Access',
'user.Email',
'user.LinkKey',
'user.Name',
'user.Origin',
'user.UserID'
]
)
self.assertEqual(
self.engine.autocomplete("user.StudentInfo.", "Schools", "schoolCities", user2),
[]
)
def test_function(self):
self.assertEqual(self.engine.autocomplete("MEDI", "Address", "city"),
self.assertEqual(self.engine.autocomplete("MEDI", "Address", "city", self.user),
[('MEDIAN', '(value, *more_values)', True)])
self.assertEqual(self.engine.autocomplete("ma", "Address", "city"), [
self.assertEqual(self.engine.autocomplete("ma", "Address", "city", self.user), [
('MAX', '(value, *more_values)', True),
('MAXA', '(value, *more_values)', True),
'map(',
@ -60,30 +144,30 @@ class TestCompletion(test_engine.EngineTestCase):
])
def test_member(self):
self.assertEqual(self.engine.autocomplete("datetime.tz", "Address", "city"),
self.assertEqual(self.engine.autocomplete("datetime.tz", "Address", "city", self.user),
["datetime.tzinfo("])
def test_case_insensitive(self):
self.assertEqual(self.engine.autocomplete("medi", "Address", "city"),
self.assertEqual(self.engine.autocomplete("medi", "Address", "city", self.user),
[('MEDIAN', '(value, *more_values)', True)])
self.assertEqual(self.engine.autocomplete("std", "Address", "city"), [
self.assertEqual(self.engine.autocomplete("std", "Address", "city", self.user), [
('STDEV', '(value, *more_values)', True),
('STDEVA', '(value, *more_values)', True),
('STDEVP', '(value, *more_values)', True),
('STDEVPA', '(value, *more_values)', True)
])
self.assertEqual(self.engine.autocomplete("stu", "Address", "city"),
self.assertEqual(self.engine.autocomplete("stu", "Address", "city", self.user),
["Students"])
# Add a table name whose lowercase version conflicts with a builtin.
self.apply_user_action(['AddTable', 'Max', []])
self.assertEqual(self.engine.autocomplete("max", "Address", "city"), [
self.assertEqual(self.engine.autocomplete("max", "Address", "city", self.user), [
('MAX', '(value, *more_values)', True),
('MAXA', '(value, *more_values)', True),
'Max',
'max(',
])
self.assertEqual(self.engine.autocomplete("MAX", "Address", "city"), [
self.assertEqual(self.engine.autocomplete("MAX", "Address", "city", self.user), [
('MAX', '(value, *more_values)', True),
('MAXA', '(value, *more_values)', True),
])
@ -91,85 +175,106 @@ class TestCompletion(test_engine.EngineTestCase):
def test_suggest_globals_and_tables(self):
# Should suggest globals and table names.
self.assertEqual(self.engine.autocomplete("ME", "Address", "city"),
self.assertEqual(self.engine.autocomplete("ME", "Address", "city", self.user),
[('MEDIAN', '(value, *more_values)', True)])
self.assertEqual(self.engine.autocomplete("Ad", "Address", "city"), ['Address'])
self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city")), {
self.assertEqual(self.engine.autocomplete("Ad", "Address", "city", self.user), ['Address'])
self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city", self.user)), {
'Schools',
'Students',
('SUM', '(value1, *more_values)', True),
('STDEV', '(value, *more_values)', True),
})
self.assertGreaterEqual(set(self.engine.autocomplete("s", "Address", "city")), {
self.assertGreaterEqual(set(self.engine.autocomplete("s", "Address", "city", self.user)), {
'Schools',
'Students',
'sum(',
('SUM', '(value1, *more_values)', True),
('STDEV', '(value, *more_values)', True),
})
self.assertEqual(self.engine.autocomplete("Addr", "Schools", "budget"), ['Address'])
self.assertEqual(self.engine.autocomplete("Addr", "Schools", "budget", self.user), ['Address'])
def test_suggest_columns(self):
self.assertEqual(self.engine.autocomplete("$ci", "Address", "city"),
self.assertEqual(self.engine.autocomplete("$ci", "Address", "city", self.user),
["$city"])
self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city"),
self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city", self.user),
["rec.id"])
self.assertEqual(len(self.engine.autocomplete("$", "Address", "city")),
self.assertEqual(len(self.engine.autocomplete("$", "Address", "city", self.user)),
2)
# A few more detailed examples.
self.assertEqual(self.engine.autocomplete("$", "Students", "school"),
self.assertEqual(self.engine.autocomplete("$", "Students", "school", self.user),
['$birthDate', '$firstName', '$id', '$lastName', '$lastVisit',
'$school', '$schoolCities', '$schoolIds', '$schoolName'])
self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate"), ['$firstName'])
self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit"),
self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate", self.user),
['$firstName'])
self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit", self.user),
['$school', '$schoolCities', '$schoolIds', '$schoolName'])
def test_suggest_lookup_methods(self):
# Should suggest lookup formulas for tables.
self.assertEqual(self.engine.autocomplete("Address.", "Students", "firstName"), [
self.assertEqual(self.engine.autocomplete("Address.", "Students", "firstName", self.user), [
'Address.all',
('Address.lookupOne', '(colName=<value>, ...)', True),
('Address.lookupRecords', '(colName=<value>, ...)', True),
])
self.assertEqual(self.engine.autocomplete("Address.lookup", "Students", "lastName"), [
('Address.lookupOne', '(colName=<value>, ...)', True),
('Address.lookupRecords', '(colName=<value>, ...)', True),
])
self.assertEqual(
self.engine.autocomplete("Address.lookup", "Students", "lastName", self.user),
[
('Address.lookupOne', '(colName=<value>, ...)', True),
('Address.lookupRecords', '(colName=<value>, ...)', True),
]
)
self.assertEqual(self.engine.autocomplete("address.look", "Students", "schoolName"), [
('Address.lookupOne', '(colName=<value>, ...)', True),
('Address.lookupRecords', '(colName=<value>, ...)', True),
])
self.assertEqual(
self.engine.autocomplete("address.look", "Students", "schoolName", self.user),
[
('Address.lookupOne', '(colName=<value>, ...)', True),
('Address.lookupRecords', '(colName=<value>, ...)', True),
]
)
def test_suggest_column_type_methods(self):
# Should treat columns as correct types.
self.assertGreaterEqual(set(self.engine.autocomplete("$firstName.", "Students", "firstName")),
{'$firstName.startswith(', '$firstName.replace(', '$firstName.title('})
self.assertGreaterEqual(set(self.engine.autocomplete("$birthDate.", "Students", "lastName")),
{'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('})
self.assertGreaterEqual(set(self.engine.autocomplete("$lastVisit.m", "Students", "firstName")),
{'$lastVisit.month', '$lastVisit.minute'})
self.assertGreaterEqual(set(self.engine.autocomplete("$school.", "Students", "firstName")),
{'$school.address', '$school.name',
'$school.yearFounded', '$school.budget'})
self.assertEqual(self.engine.autocomplete("$school.year", "Students", "lastName"),
self.assertGreaterEqual(
set(self.engine.autocomplete("$firstName.", "Students", "firstName", self.user)),
{'$firstName.startswith(', '$firstName.replace(', '$firstName.title('}
)
self.assertGreaterEqual(
set(self.engine.autocomplete("$birthDate.", "Students", "lastName", self.user)),
{'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('}
)
self.assertGreaterEqual(
set(self.engine.autocomplete("$lastVisit.m", "Students", "firstName", self.user)),
{'$lastVisit.month', '$lastVisit.minute'}
)
self.assertGreaterEqual(
set(self.engine.autocomplete("$school.", "Students", "firstName", self.user)),
{'$school.address', '$school.name', '$school.yearFounded', '$school.budget'}
)
self.assertEqual(self.engine.autocomplete("$school.year", "Students", "lastName", self.user),
['$school.yearFounded'])
self.assertGreaterEqual(set(self.engine.autocomplete("$yearFounded.", "Schools", "budget")),
{'$yearFounded.denominator', # Only integers have this
'$yearFounded.bit_length(', # and this
'$yearFounded.real'})
self.assertGreaterEqual(set(self.engine.autocomplete("$budget.", "Schools", "budget")),
{'$budget.is_integer(', # Only floats have this
'$budget.real'})
self.assertGreaterEqual(
set(self.engine.autocomplete("$yearFounded.", "Schools", "budget", self.user)),
{
'$yearFounded.denominator', # Only integers have this
'$yearFounded.bit_length(', # and this
'$yearFounded.real'
}
)
self.assertGreaterEqual(
set(self.engine.autocomplete("$budget.", "Schools", "budget", self.user)),
{'$budget.is_integer(', '$budget.real'} # Only floats have this
)
def test_suggest_follows_references(self):
# Should follow references and autocomplete those types.
self.assertEqual(self.engine.autocomplete("$school.name.st", "Students", "firstName"),
['$school.name.startswith(', '$school.name.strip('])
self.assertEqual(
self.engine.autocomplete("$school.name.st", "Students", "firstName", self.user),
['$school.name.startswith(', '$school.name.strip(']
)
self.assertGreaterEqual(
set(self.engine.autocomplete("$school.yearFounded.","Students", "firstName")),
set(self.engine.autocomplete("$school.yearFounded.","Students", "firstName", self.user)),
{
'$school.yearFounded.denominator',
'$school.yearFounded.bit_length(',
@ -177,7 +282,11 @@ class TestCompletion(test_engine.EngineTestCase):
}
)
self.assertEqual(self.engine.autocomplete("$school.address.", "Students", "lastName"),
['$school.address.city', '$school.address.id'])
self.assertEqual(self.engine.autocomplete("$school.address.city.st", "Students", "lastName"),
['$school.address.city.startswith(', '$school.address.city.strip('])
self.assertEqual(
self.engine.autocomplete("$school.address.", "Students", "lastName", self.user),
['$school.address.city', '$school.address.id']
)
self.assertEqual(
self.engine.autocomplete("$school.address.city.st", "Students", "lastName", self.user),
['$school.address.city.startswith(', '$school.address.city.strip(']
)

View File

@ -334,14 +334,14 @@ class EngineTestCase(unittest.TestCase):
def add_records(self, table_name, col_names, row_data):
return self.apply_user_action(self.add_records_action(table_name, [col_names] + row_data))
def apply_user_action(self, user_action_repr, is_undo=False):
def apply_user_action(self, user_action_repr, is_undo=False, user=None):
if not is_undo:
log.debug("Applying user action %r" % (user_action_repr,))
if self._undo_state_tracker is not None:
doc_state = self.getFullEngineData()
self.call_counts.clear()
out_actions = self.engine.apply_user_actions([useractions.from_repr(user_action_repr)])
out_actions = self.engine.apply_user_actions([useractions.from_repr(user_action_repr)], user)
out_actions.calls = self.call_counts.copy()
if not is_undo and self._undo_state_tracker is not None:

View File

@ -309,18 +309,27 @@ class TestRenames(test_engine.EngineTestCase):
]})
def test_rename_table_autocomplete(self):
user = {
'Name': 'Foo',
'UserID': 1,
'LinkKey': {},
'Origin': None,
'Email': 'foo@example.com',
'Access': 'owners'
}
# Renaming a table should not leave the old name available for auto-complete.
self.load_sample(self.sample)
names = {"People", "Persons"}
self.assertEqual(
names.intersection(self.engine.autocomplete("Pe", "Address", "city")),
names.intersection(self.engine.autocomplete("Pe", "Address", "city", user)),
{"People"}
)
# Rename the table and ensure that "People" is no longer present among top-level names.
out_actions = self.apply_user_action(["RenameTable", "People", "Persons"])
self.assertEqual(
names.intersection(self.engine.autocomplete("Pe", "Address", "city")),
names.intersection(self.engine.autocomplete("Pe", "Address", "city", user)),
{"Persons"}
)

View File

@ -522,3 +522,62 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
[1, "Whale", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", time3],
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "ATLANTIC", time2],
])
def test_last_modified_by_recipe(self):
user1 = {
'Name': 'Foo Bar',
'UserID': 1,
'StudentInfo': ['Students', 1],
'LinkKey': {},
'Origin': None,
'Email': 'foo.bar@getgrist.com',
'Access': 'owners'
}
user2 = {
'Name': 'Baz Qux',
'UserID': 2,
'StudentInfo': ['Students', 1],
'LinkKey': {},
'Origin': None,
'Email': 'baz.qux@getgrist.com',
'Access': 'owners'
}
# Use formula to store last modified by data (user name and email). Check that it works as expected.
self.load_sample(self.sample)
self.add_column('Creatures', 'LastModifiedBy', type='Text', isFormula=False,
formula="user.Name + ' <' + user.Email + '>'", recalcWhen=RecalcWhen.MANUAL_UPDATES
)
self.assertTableData("Creatures", data=[
["id","Name", "Ocean", "BossDef", "BossNvr", "BossUpd", "BossAll", "OceanName", "LastModifiedBy"],
[1, "Dolphin", 2, "Arthur", "Arthur", "Arthur", "Arthur", "Atlantic", ""],
])
self.apply_user_action(
['AddRecord', "Creatures", None, {"Name": "Manatee", "Ocean": 2}],
user=user1
)
self.apply_user_action(
['UpdateRecord', "Creatures", 1, {"Ocean": 3}],
user=user2
)
self.assertTableData("Creatures", data=[
["id","Name", "Ocean", "BossDef", "BossNvr", "BossUpd", "BossAll", "OceanName", "LastModifiedBy"],
[1, "Dolphin", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", "Baz Qux <baz.qux@getgrist.com>"],
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "Atlantic", "Foo Bar <foo.bar@getgrist.com>"],
])
# An indirect change doesn't affect the user, but a direct change does.
self.apply_user_action(
['UpdateRecord', "Oceans", 2, {"Name": "ATLANTIC"}],
user=user2
)
self.apply_user_action(
['UpdateRecord', "Creatures", 1, {"Name": "Whale"}],
user=user1
)
self.assertTableData("Creatures", data=[
["id","Name", "Ocean", "BossDef", "BossNvr", "BossUpd", "BossAll", "OceanName", "LastModifiedBy"],
[1, "Whale", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", "Foo Bar <foo.bar@getgrist.com>"],
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "ATLANTIC", "Foo Bar <foo.bar@getgrist.com>"],
])

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()
state = grist.Text()
def _default_country(rec, table, value):
def _default_country(rec, table, value, user):
return 'US'
country = grist.Text()