mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add 'user' variable to trigger formulas
Summary: The 'user' variable has a similar API to the one from access rules: it contains properties about a user, such as their full name and email address, as well as optional, user-defined attributes that are populated via user attribute tables. Test Plan: Python unit tests. Reviewers: alexmojaki, paulfitz, dsagal Reviewed By: alexmojaki, dsagal Subscribers: paulfitz, dsagal, alexmojaki Differential Revision: https://phab.getgrist.com/D2898
This commit is contained in:
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -77,12 +77,15 @@ class GenCode(object):
|
||||
self._usercode = None
|
||||
|
||||
def _make_formula_field(self, col_info, table_id, name=None, include_type=True,
|
||||
include_value_arg=False):
|
||||
additional_params=()):
|
||||
"""Returns the code for a formula field."""
|
||||
# If the caller didn't specify a special name, use the colId
|
||||
name = name or col_info.colId
|
||||
|
||||
decl = "def %s(rec, table%s):\n" % (name, (", value" if include_value_arg else ""))
|
||||
decl = "def %s(%s):\n" % (
|
||||
name,
|
||||
', '.join(['rec', 'table'] + list(additional_params))
|
||||
)
|
||||
|
||||
# This is where we get to use the formula cache, and save the work of rebuilding formulas.
|
||||
key = (table_id, col_info.colId, col_info.formula)
|
||||
@@ -105,7 +108,7 @@ class GenCode(object):
|
||||
parts.append(self._make_formula_field(col_info, table_id,
|
||||
name=table.get_default_func_name(col_info.colId),
|
||||
include_type=False,
|
||||
include_value_arg=True))
|
||||
additional_params=['value', 'user']))
|
||||
parts.append("%s = %s\n" % (col_info.colId, get_grist_type(col_info.type)))
|
||||
return textbuilder.Combiner(parts)
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ def run(sandbox):
|
||||
sandbox.register(method.__name__, wrapper)
|
||||
|
||||
@export
|
||||
def apply_user_actions(action_reprs):
|
||||
action_group = eng.apply_user_actions([useractions.from_repr(u) for u in action_reprs])
|
||||
def apply_user_actions(action_reprs, user=None):
|
||||
action_group = eng.apply_user_actions([useractions.from_repr(u) for u in action_reprs], user)
|
||||
return eng.acl_split(action_group).to_json_obj()
|
||||
|
||||
@export
|
||||
@@ -73,8 +73,8 @@ def run(sandbox):
|
||||
return eng.acl_split(action_group).to_json_obj()
|
||||
|
||||
@export
|
||||
def autocomplete(txt, table_id, column_id):
|
||||
return eng.autocomplete(txt, table_id, column_id)
|
||||
def autocomplete(txt, table_id, column_id, user):
|
||||
return eng.autocomplete(txt, table_id, column_id, user)
|
||||
|
||||
@export
|
||||
def find_col_from_values(values, n, opt_table_id):
|
||||
|
||||
@@ -3,6 +3,16 @@ import test_engine
|
||||
from schema import RecalcWhen
|
||||
|
||||
class TestCompletion(test_engine.EngineTestCase):
|
||||
user = {
|
||||
'Name': 'Foo',
|
||||
'UserID': 1,
|
||||
'StudentInfo': ['Students', 1],
|
||||
'LinkKey': {},
|
||||
'Origin': None,
|
||||
'Email': 'foo@example.com',
|
||||
'Access': 'owners'
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super(TestCompletion, self).setUp()
|
||||
self.load_sample(testsamples.sample_students)
|
||||
@@ -23,35 +33,109 @@ class TestCompletion(test_engine.EngineTestCase):
|
||||
)
|
||||
|
||||
def test_keyword(self):
|
||||
self.assertEqual(self.engine.autocomplete("for", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("for", "Address", "city", self.user),
|
||||
["for", "format("])
|
||||
|
||||
def test_grist(self):
|
||||
self.assertEqual(self.engine.autocomplete("gri", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("gri", "Address", "city", self.user),
|
||||
["grist"])
|
||||
|
||||
def test_value(self):
|
||||
# Should only appear if column exists and is a trigger formula.
|
||||
self.assertEqual(self.engine.autocomplete("val", "Schools", "lastModified"),
|
||||
["value"])
|
||||
self.assertEqual(self.engine.autocomplete("val", "Students", "schoolCities"),
|
||||
[])
|
||||
self.assertEqual(self.engine.autocomplete("val", "Students", "nonexistentColumn"),
|
||||
[])
|
||||
self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier"),
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("val", "Schools", "lastModified", self.user),
|
||||
["value"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("val", "Students", "schoolCities", self.user),
|
||||
[]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("val", "Students", "nonexistentColumn", self.user),
|
||||
[]
|
||||
)
|
||||
self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier", self.user),
|
||||
["value"])
|
||||
# Should have same type as column.
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModifier")),
|
||||
{'value.startswith(', 'value.replace(', 'value.title('})
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModified")),
|
||||
{'value.month', 'value.strftime(', 'value.replace('})
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("value.m", "Schools", "lastModified")),
|
||||
{'value.month', 'value.minute'})
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("value.", "Schools", "lastModifier", self.user)),
|
||||
{'value.startswith(', 'value.replace(', 'value.title('}
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("value.", "Schools", "lastModified", self.user)),
|
||||
{'value.month', 'value.strftime(', 'value.replace('}
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
set(self.engine.autocomplete("value.m", "Schools", "lastModified", self.user)),
|
||||
{'value.month', 'value.minute'}
|
||||
)
|
||||
|
||||
def test_user(self):
|
||||
# Should only appear if column exists and is a trigger formula.
|
||||
self.assertEqual(self.engine.autocomplete("use", "Schools", "lastModified", self.user),
|
||||
["user"])
|
||||
self.assertEqual(self.engine.autocomplete("use", "Students", "schoolCities", self.user),
|
||||
[])
|
||||
self.assertEqual(self.engine.autocomplete("use", "Students", "nonexistentColumn", self.user),
|
||||
[])
|
||||
self.assertEqual(self.engine.autocomplete("user", "Schools", "lastModifier", self.user),
|
||||
["user"])
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("user.", "Schools", "lastModified", self.user),
|
||||
[
|
||||
'user.Access',
|
||||
'user.Email',
|
||||
'user.LinkKey',
|
||||
'user.Name',
|
||||
'user.Origin',
|
||||
'user.StudentInfo',
|
||||
'user.UserID'
|
||||
]
|
||||
)
|
||||
# Should follow user attribute references and autocomplete those types.
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("user.StudentInfo.", "Schools", "lastModified", self.user),
|
||||
[
|
||||
'user.StudentInfo.birthDate',
|
||||
'user.StudentInfo.firstName',
|
||||
'user.StudentInfo.id',
|
||||
'user.StudentInfo.lastName',
|
||||
'user.StudentInfo.lastVisit',
|
||||
'user.StudentInfo.school',
|
||||
'user.StudentInfo.schoolCities',
|
||||
'user.StudentInfo.schoolIds',
|
||||
'user.StudentInfo.schoolName'
|
||||
]
|
||||
)
|
||||
# Should not show user attribute completions if user doesn't have attribute.
|
||||
user2 = {
|
||||
'Name': 'Bar',
|
||||
'Origin': None,
|
||||
'Email': 'baro@example.com',
|
||||
'LinkKey': {},
|
||||
'UserID': 2,
|
||||
'Access': 'owners'
|
||||
}
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("user.", "Schools", "lastModified", user2),
|
||||
[
|
||||
'user.Access',
|
||||
'user.Email',
|
||||
'user.LinkKey',
|
||||
'user.Name',
|
||||
'user.Origin',
|
||||
'user.UserID'
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.engine.autocomplete("user.StudentInfo.", "Schools", "schoolCities", user2),
|
||||
[]
|
||||
)
|
||||
|
||||
def test_function(self):
|
||||
self.assertEqual(self.engine.autocomplete("MEDI", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("MEDI", "Address", "city", self.user),
|
||||
[('MEDIAN', '(value, *more_values)', True)])
|
||||
self.assertEqual(self.engine.autocomplete("ma", "Address", "city"), [
|
||||
self.assertEqual(self.engine.autocomplete("ma", "Address", "city", self.user), [
|
||||
('MAX', '(value, *more_values)', True),
|
||||
('MAXA', '(value, *more_values)', True),
|
||||
'map(',
|
||||
@@ -60,30 +144,30 @@ class TestCompletion(test_engine.EngineTestCase):
|
||||
])
|
||||
|
||||
def test_member(self):
|
||||
self.assertEqual(self.engine.autocomplete("datetime.tz", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("datetime.tz", "Address", "city", self.user),
|
||||
["datetime.tzinfo("])
|
||||
|
||||
def test_case_insensitive(self):
|
||||
self.assertEqual(self.engine.autocomplete("medi", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("medi", "Address", "city", self.user),
|
||||
[('MEDIAN', '(value, *more_values)', True)])
|
||||
self.assertEqual(self.engine.autocomplete("std", "Address", "city"), [
|
||||
self.assertEqual(self.engine.autocomplete("std", "Address", "city", self.user), [
|
||||
('STDEV', '(value, *more_values)', True),
|
||||
('STDEVA', '(value, *more_values)', True),
|
||||
('STDEVP', '(value, *more_values)', True),
|
||||
('STDEVPA', '(value, *more_values)', True)
|
||||
])
|
||||
self.assertEqual(self.engine.autocomplete("stu", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("stu", "Address", "city", self.user),
|
||||
["Students"])
|
||||
|
||||
# Add a table name whose lowercase version conflicts with a builtin.
|
||||
self.apply_user_action(['AddTable', 'Max', []])
|
||||
self.assertEqual(self.engine.autocomplete("max", "Address", "city"), [
|
||||
self.assertEqual(self.engine.autocomplete("max", "Address", "city", self.user), [
|
||||
('MAX', '(value, *more_values)', True),
|
||||
('MAXA', '(value, *more_values)', True),
|
||||
'Max',
|
||||
'max(',
|
||||
])
|
||||
self.assertEqual(self.engine.autocomplete("MAX", "Address", "city"), [
|
||||
self.assertEqual(self.engine.autocomplete("MAX", "Address", "city", self.user), [
|
||||
('MAX', '(value, *more_values)', True),
|
||||
('MAXA', '(value, *more_values)', True),
|
||||
])
|
||||
@@ -91,85 +175,106 @@ class TestCompletion(test_engine.EngineTestCase):
|
||||
|
||||
def test_suggest_globals_and_tables(self):
|
||||
# Should suggest globals and table names.
|
||||
self.assertEqual(self.engine.autocomplete("ME", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("ME", "Address", "city", self.user),
|
||||
[('MEDIAN', '(value, *more_values)', True)])
|
||||
self.assertEqual(self.engine.autocomplete("Ad", "Address", "city"), ['Address'])
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city")), {
|
||||
self.assertEqual(self.engine.autocomplete("Ad", "Address", "city", self.user), ['Address'])
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city", self.user)), {
|
||||
'Schools',
|
||||
'Students',
|
||||
('SUM', '(value1, *more_values)', True),
|
||||
('STDEV', '(value, *more_values)', True),
|
||||
})
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("s", "Address", "city")), {
|
||||
self.assertGreaterEqual(set(self.engine.autocomplete("s", "Address", "city", self.user)), {
|
||||
'Schools',
|
||||
'Students',
|
||||
'sum(',
|
||||
('SUM', '(value1, *more_values)', True),
|
||||
('STDEV', '(value, *more_values)', True),
|
||||
})
|
||||
self.assertEqual(self.engine.autocomplete("Addr", "Schools", "budget"), ['Address'])
|
||||
self.assertEqual(self.engine.autocomplete("Addr", "Schools", "budget", self.user), ['Address'])
|
||||
|
||||
def test_suggest_columns(self):
|
||||
self.assertEqual(self.engine.autocomplete("$ci", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("$ci", "Address", "city", self.user),
|
||||
["$city"])
|
||||
self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city"),
|
||||
self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city", self.user),
|
||||
["rec.id"])
|
||||
self.assertEqual(len(self.engine.autocomplete("$", "Address", "city")),
|
||||
self.assertEqual(len(self.engine.autocomplete("$", "Address", "city", self.user)),
|
||||
2)
|
||||
|
||||
# A few more detailed examples.
|
||||
self.assertEqual(self.engine.autocomplete("$", "Students", "school"),
|
||||
self.assertEqual(self.engine.autocomplete("$", "Students", "school", self.user),
|
||||
['$birthDate', '$firstName', '$id', '$lastName', '$lastVisit',
|
||||
'$school', '$schoolCities', '$schoolIds', '$schoolName'])
|
||||
self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate"), ['$firstName'])
|
||||
self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit"),
|
||||
self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate", self.user),
|
||||
['$firstName'])
|
||||
self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit", self.user),
|
||||
['$school', '$schoolCities', '$schoolIds', '$schoolName'])
|
||||
|
||||
def test_suggest_lookup_methods(self):
|
||||
# Should suggest lookup formulas for tables.
|
||||
self.assertEqual(self.engine.autocomplete("Address.", "Students", "firstName"), [
|
||||
self.assertEqual(self.engine.autocomplete("Address.", "Students", "firstName", self.user), [
|
||||
'Address.all',
|
||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
||||
])
|
||||
|
||||
self.assertEqual(self.engine.autocomplete("Address.lookup", "Students", "lastName"), [
|
||||
('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(']
|
||||
)
|
||||
|
||||
@@ -334,14 +334,14 @@ class EngineTestCase(unittest.TestCase):
|
||||
def add_records(self, table_name, col_names, row_data):
|
||||
return self.apply_user_action(self.add_records_action(table_name, [col_names] + row_data))
|
||||
|
||||
def apply_user_action(self, user_action_repr, is_undo=False):
|
||||
def apply_user_action(self, user_action_repr, is_undo=False, user=None):
|
||||
if not is_undo:
|
||||
log.debug("Applying user action %r" % (user_action_repr,))
|
||||
if self._undo_state_tracker is not None:
|
||||
doc_state = self.getFullEngineData()
|
||||
|
||||
self.call_counts.clear()
|
||||
out_actions = self.engine.apply_user_actions([useractions.from_repr(user_action_repr)])
|
||||
out_actions = self.engine.apply_user_actions([useractions.from_repr(user_action_repr)], user)
|
||||
out_actions.calls = self.call_counts.copy()
|
||||
|
||||
if not is_undo and self._undo_state_tracker is not None:
|
||||
|
||||
@@ -309,18 +309,27 @@ class TestRenames(test_engine.EngineTestCase):
|
||||
]})
|
||||
|
||||
def test_rename_table_autocomplete(self):
|
||||
user = {
|
||||
'Name': 'Foo',
|
||||
'UserID': 1,
|
||||
'LinkKey': {},
|
||||
'Origin': None,
|
||||
'Email': 'foo@example.com',
|
||||
'Access': 'owners'
|
||||
}
|
||||
|
||||
# Renaming a table should not leave the old name available for auto-complete.
|
||||
self.load_sample(self.sample)
|
||||
names = {"People", "Persons"}
|
||||
self.assertEqual(
|
||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city")),
|
||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city", user)),
|
||||
{"People"}
|
||||
)
|
||||
|
||||
# Rename the table and ensure that "People" is no longer present among top-level names.
|
||||
out_actions = self.apply_user_action(["RenameTable", "People", "Persons"])
|
||||
self.assertEqual(
|
||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city")),
|
||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city", user)),
|
||||
{"Persons"}
|
||||
)
|
||||
|
||||
|
||||
@@ -522,3 +522,62 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
|
||||
[1, "Whale", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", time3],
|
||||
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "ATLANTIC", time2],
|
||||
])
|
||||
|
||||
def test_last_modified_by_recipe(self):
|
||||
user1 = {
|
||||
'Name': 'Foo Bar',
|
||||
'UserID': 1,
|
||||
'StudentInfo': ['Students', 1],
|
||||
'LinkKey': {},
|
||||
'Origin': None,
|
||||
'Email': 'foo.bar@getgrist.com',
|
||||
'Access': 'owners'
|
||||
}
|
||||
user2 = {
|
||||
'Name': 'Baz Qux',
|
||||
'UserID': 2,
|
||||
'StudentInfo': ['Students', 1],
|
||||
'LinkKey': {},
|
||||
'Origin': None,
|
||||
'Email': 'baz.qux@getgrist.com',
|
||||
'Access': 'owners'
|
||||
}
|
||||
# Use formula to store last modified by data (user name and email). Check that it works as expected.
|
||||
self.load_sample(self.sample)
|
||||
self.add_column('Creatures', 'LastModifiedBy', type='Text', isFormula=False,
|
||||
formula="user.Name + ' <' + user.Email + '>'", recalcWhen=RecalcWhen.MANUAL_UPDATES
|
||||
)
|
||||
self.assertTableData("Creatures", data=[
|
||||
["id","Name", "Ocean", "BossDef", "BossNvr", "BossUpd", "BossAll", "OceanName", "LastModifiedBy"],
|
||||
[1, "Dolphin", 2, "Arthur", "Arthur", "Arthur", "Arthur", "Atlantic", ""],
|
||||
])
|
||||
|
||||
self.apply_user_action(
|
||||
['AddRecord', "Creatures", None, {"Name": "Manatee", "Ocean": 2}],
|
||||
user=user1
|
||||
)
|
||||
self.apply_user_action(
|
||||
['UpdateRecord', "Creatures", 1, {"Ocean": 3}],
|
||||
user=user2
|
||||
)
|
||||
|
||||
self.assertTableData("Creatures", data=[
|
||||
["id","Name", "Ocean", "BossDef", "BossNvr", "BossUpd", "BossAll", "OceanName", "LastModifiedBy"],
|
||||
[1, "Dolphin", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", "Baz Qux <baz.qux@getgrist.com>"],
|
||||
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "Atlantic", "Foo Bar <foo.bar@getgrist.com>"],
|
||||
])
|
||||
|
||||
# An indirect change doesn't affect the user, but a direct change does.
|
||||
self.apply_user_action(
|
||||
['UpdateRecord', "Oceans", 2, {"Name": "ATLANTIC"}],
|
||||
user=user2
|
||||
)
|
||||
self.apply_user_action(
|
||||
['UpdateRecord', "Creatures", 1, {"Name": "Whale"}],
|
||||
user=user1
|
||||
)
|
||||
self.assertTableData("Creatures", data=[
|
||||
["id","Name", "Ocean", "BossDef", "BossNvr", "BossUpd", "BossAll", "OceanName", "LastModifiedBy"],
|
||||
[1, "Whale", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", "Foo Bar <foo.bar@getgrist.com>"],
|
||||
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "ATLANTIC", "Foo Bar <foo.bar@getgrist.com>"],
|
||||
])
|
||||
|
||||
54
sandbox/grist/test_user.py
Normal file
54
sandbox/grist/test_user.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from user import User
|
||||
import test_engine
|
||||
import testsamples
|
||||
|
||||
class TestUser(test_engine.EngineTestCase):
|
||||
# pylint: disable=no-member
|
||||
def setUp(self):
|
||||
super(TestUser, self).setUp()
|
||||
self.load_sample(testsamples.sample_students)
|
||||
|
||||
def test_constructor_sets_user_attributes(self):
|
||||
data = {
|
||||
'Access': 'owners',
|
||||
'Name': 'Foo Bar',
|
||||
'Email': 'email@example.com',
|
||||
'UserID': 1,
|
||||
'LinkKey': {
|
||||
'Param1': 'Param1Value',
|
||||
'Param2': 'Param2Value'
|
||||
},
|
||||
'Origin': 'https://getgrist.com',
|
||||
'StudentInfo': ['Students', 1]
|
||||
}
|
||||
u = User(data, self.engine.tables)
|
||||
self.assertEqual(u.Name, 'Foo Bar')
|
||||
self.assertEqual(u.Email, 'email@example.com')
|
||||
self.assertEqual(u.UserID, 1)
|
||||
self.assertEqual(u.LinkKey.Param1, 'Param1Value')
|
||||
self.assertEqual(u.LinkKey.Param2, 'Param2Value')
|
||||
self.assertEqual(u.Access, 'owners')
|
||||
self.assertEqual(u.Origin, 'https://getgrist.com')
|
||||
self.assertEqual(u.StudentInfo.id, 1)
|
||||
self.assertEqual(u.StudentInfo.firstName, 'Barack')
|
||||
self.assertEqual(u.StudentInfo.lastName, 'Obama')
|
||||
self.assertEqual(u.StudentInfo.schoolName, 'Columbia')
|
||||
|
||||
def test_setting_is_sample_substitutes_attributes_with_samples(self):
|
||||
data = {
|
||||
'Access': 'owners',
|
||||
'Name': None,
|
||||
'Email': 'email@getgrist.com',
|
||||
'UserID': 1,
|
||||
'LinkKey': {
|
||||
'Param1': 'Param1Value',
|
||||
'Param2': 'Param2Value'
|
||||
},
|
||||
'Origin': 'https://getgrist.com',
|
||||
'StudentInfo': ['Students', 1]
|
||||
}
|
||||
u = User(data, self.engine.tables, is_sample=True)
|
||||
self.assertEqual(u.StudentInfo.id, 0)
|
||||
self.assertEqual(u.StudentInfo.firstName, '')
|
||||
self.assertEqual(u.StudentInfo.lastName, '')
|
||||
self.assertEqual(u.StudentInfo.schoolName, '')
|
||||
62
sandbox/grist/user.py
Normal file
62
sandbox/grist/user.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
This module contains a class for creating a User containing
|
||||
basic user info and optional, user-defined attributes that reference
|
||||
user attribute tables.
|
||||
|
||||
A User has the same API as the 'user' variable from
|
||||
access rules. Currently, its primary purpose is to expose
|
||||
user info to trigger formulas, so that they can reference info
|
||||
about the current user.
|
||||
|
||||
The 'data' parameter represents a dictionary containing at least
|
||||
the following fields:
|
||||
|
||||
- Access: string or None
|
||||
- UserID: integer or None
|
||||
- Email: string or None
|
||||
- Name: string or None
|
||||
- Origin: string or None
|
||||
- LinkKey: dictionary
|
||||
|
||||
Additional keys may be included, which may have a value that is
|
||||
either None or of type tuple with the following shape:
|
||||
|
||||
[table_id, row_id]
|
||||
|
||||
The first element is the id (name) of the user attribute table, and the
|
||||
second element is the id of the row that matched based on the
|
||||
user attribute definition.
|
||||
|
||||
See 'GranularAccess.ts' for the Node equivalent that
|
||||
serializes the user information found in 'data'.
|
||||
"""
|
||||
import six
|
||||
|
||||
class User(object):
|
||||
"""
|
||||
User containing user info and optional attributes.
|
||||
|
||||
Setting 'is_sample' will substitute user attributes with
|
||||
typed equivalents, for use by autocompletion.
|
||||
"""
|
||||
def __init__(self, data, tables, is_sample=False):
|
||||
for attr in ('Access', 'UserID', 'Email', 'Name', 'Origin'):
|
||||
setattr(self, attr, data[attr])
|
||||
|
||||
self.LinkKey = LinkKey(data['LinkKey'])
|
||||
|
||||
for name, value in six.iteritems(data):
|
||||
if hasattr(self, name) or not value:
|
||||
continue
|
||||
table_name, row_id = value
|
||||
table = tables.get(table_name)
|
||||
if not table:
|
||||
continue
|
||||
# TODO: Investigate use of __dir__ in Record for type information
|
||||
record = table.sample_record if is_sample else table.get_record(row_id)
|
||||
setattr(self, name, record)
|
||||
|
||||
class LinkKey(object):
|
||||
def __init__(self, data):
|
||||
for name, value in six.iteritems(data):
|
||||
setattr(self, name, value)
|
||||
@@ -53,7 +53,7 @@ class Address:
|
||||
city = grist.Text()
|
||||
state = grist.Text()
|
||||
|
||||
def _default_country(rec, table, value):
|
||||
def _default_country(rec, table, value, user):
|
||||
return 'US'
|
||||
country = grist.Text()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user