From e5eeb3ec80ed570399104c01a2c28576cbed807f Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Wed, 14 Jul 2021 17:45:53 -0700 Subject: [PATCH] (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 --- app/common/GranularAccessClause.ts | 12 +- app/server/lib/ActiveDoc.ts | 11 +- app/server/lib/GranularAccess.ts | 45 ++++- app/server/lib/PermissionInfo.ts | 6 +- app/server/lib/Sharing.ts | 4 +- sandbox/grist/column.py | 6 +- sandbox/grist/engine.py | 25 ++- sandbox/grist/gencode.py | 9 +- sandbox/grist/main.py | 8 +- sandbox/grist/test_completion.py | 243 ++++++++++++++++++------- sandbox/grist/test_engine.py | 4 +- sandbox/grist/test_renames.py | 13 +- sandbox/grist/test_trigger_formulas.py | 59 ++++++ sandbox/grist/test_user.py | 54 ++++++ sandbox/grist/user.py | 62 +++++++ sandbox/grist/usercode.py | 2 +- 16 files changed, 464 insertions(+), 99 deletions(-) create mode 100644 sandbox/grist/test_user.py create mode 100644 sandbox/grist/user.py diff --git a/app/common/GranularAccessClause.ts b/app/common/GranularAccessClause.ts index bc8a977c..511ab4fb 100644 --- a/app/common/GranularAccessClause.ts +++ b/app/common/GranularAccessClause.ts @@ -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>; +export interface UserInfo { + Name: string | null; + Email: string | null; + Access: Role | null; + Origin: string | null; + LinkKey: Record; + UserID: number | null; + [attributes: string]: unknown; + toJSON(): {[key: string]: any}; +} /** * Input into the AclMatchFunc. Compiled formulas evaluate AclMatchInput to produce a boolean. diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 354e4540..42082f2c 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -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 { @@ -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 { + public async applyActionsToDataEngine( + docSession: OptDocSession|null, + userActions: UserAction[] + ): Promise { 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. diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 83a12588..1b7957aa 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -210,6 +210,11 @@ export class GranularAccess implements GranularAccessForBundle { return this._getUser(docSession); } + public async getCachedUser(docSession: OptDocSession): Promise { + 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 = {}; + public Email: string | null = null; + [attribute: string]: any; + + constructor(_info: Record = {}) { + 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; + } +} diff --git a/app/server/lib/PermissionInfo.ts b/app/server/lib/PermissionInfo.ts index c1b32658..8ffa2462 100644 --- a/app/server/lib/PermissionInfo.ts +++ b/app/server/lib/PermissionInfo.ts @@ -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 { 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; diff --git a/app/server/lib/Sharing.ts b/app/server/lib/Sharing.ts index bb7adf02..19d2feef 100644 --- a/app/server/lib/Sharing.ts +++ b/app/server/lib/Sharing.ts @@ -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(); } diff --git a/sandbox/grist/column.py b/sandbox/grist/column.py index 463fc3f3..52aa77e8 100644 --- a/sandbox/grist/column.py +++ b/sandbox/grist/column.py @@ -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): """ diff --git a/sandbox/grist/engine.py b/sandbox/grist/engine.py index 4f4abc4a..f7672f9f 100644 --- a/sandbox/grist/engine.py +++ b/sandbox/grist/engine.py @@ -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 = [] diff --git a/sandbox/grist/gencode.py b/sandbox/grist/gencode.py index 12801ad6..bf041207 100644 --- a/sandbox/grist/gencode.py +++ b/sandbox/grist/gencode.py @@ -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) diff --git a/sandbox/grist/main.py b/sandbox/grist/main.py index ee37cdb0..802aae97 100644 --- a/sandbox/grist/main.py +++ b/sandbox/grist/main.py @@ -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): diff --git a/sandbox/grist/test_completion.py b/sandbox/grist/test_completion.py index 96698a8f..0fcc4241 100644 --- a/sandbox/grist/test_completion.py +++ b/sandbox/grist/test_completion.py @@ -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=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), ]) - self.assertEqual(self.engine.autocomplete("Address.lookup", "Students", "lastName"), [ - ('Address.lookupOne', '(colName=, ...)', True), - ('Address.lookupRecords', '(colName=, ...)', True), - ]) + self.assertEqual( + self.engine.autocomplete("Address.lookup", "Students", "lastName", self.user), + [ + ('Address.lookupOne', '(colName=, ...)', True), + ('Address.lookupRecords', '(colName=, ...)', True), + ] + ) - self.assertEqual(self.engine.autocomplete("address.look", "Students", "schoolName"), [ - ('Address.lookupOne', '(colName=, ...)', True), - ('Address.lookupRecords', '(colName=, ...)', True), - ]) + self.assertEqual( + self.engine.autocomplete("address.look", "Students", "schoolName", self.user), + [ + ('Address.lookupOne', '(colName=, ...)', True), + ('Address.lookupRecords', '(colName=, ...)', 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('] + ) diff --git a/sandbox/grist/test_engine.py b/sandbox/grist/test_engine.py index 966f4f84..510559af 100644 --- a/sandbox/grist/test_engine.py +++ b/sandbox/grist/test_engine.py @@ -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: diff --git a/sandbox/grist/test_renames.py b/sandbox/grist/test_renames.py index fbd3bfea..9e3b1aa0 100644 --- a/sandbox/grist/test_renames.py +++ b/sandbox/grist/test_renames.py @@ -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"} ) diff --git a/sandbox/grist/test_trigger_formulas.py b/sandbox/grist/test_trigger_formulas.py index 37cbb881..89cb8567 100644 --- a/sandbox/grist/test_trigger_formulas.py +++ b/sandbox/grist/test_trigger_formulas.py @@ -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 "], + [2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "Atlantic", "Foo Bar "], + ]) + + # 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 "], + [2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "ATLANTIC", "Foo Bar "], + ]) diff --git a/sandbox/grist/test_user.py b/sandbox/grist/test_user.py new file mode 100644 index 00000000..a4955a64 --- /dev/null +++ b/sandbox/grist/test_user.py @@ -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, '') diff --git a/sandbox/grist/user.py b/sandbox/grist/user.py new file mode 100644 index 00000000..c3f12a15 --- /dev/null +++ b/sandbox/grist/user.py @@ -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) diff --git a/sandbox/grist/usercode.py b/sandbox/grist/usercode.py index 157fa92d..9b1c4f1b 100644 --- a/sandbox/grist/usercode.py +++ b/sandbox/grist/usercode.py @@ -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()