improved spreadsheet, issue 860, thanks Alan

This commit is contained in:
mdipierro
2012-06-25 14:35:10 -05:00
parent a364dc5fde
commit 72e830694d
2 changed files with 678 additions and 65 deletions
+1 -1
View File
@@ -1 +1 @@
Version 2.00.0 (2012-06-25 14:31:49) dev Version 2.00.0 (2012-06-25 14:35:08) dev
+677 -64
View File
@@ -1,43 +1,21 @@
# -*- coding: utf-8 -*-
""" """
Developed by Massimo Di Pierro, optional component of web2py, GPL2 license. Developed by Massimo Di Pierro, optional component of web2py, GPL2 license.
""" """
import re import re
import pickle import pickle
import copy import copy
import simplejson
def quote(text): def quote(text):
return str(text).replace('\\', '\\\\').replace("'", "\\'") return str(text).replace('\\', '\\\\').replace("'", "\\'")
class Node: class Node:
"""
Example::
# controller
from gluon.contrib.spreadsheet import Sheet
def callback():
return cache.ram('sheet1', lambda: None, None).process(request)
def index():
sheet = cache.ram('sheet1',
lambda: Sheet(10, 10, URL(r=request, f='callback')), 0)
#sheet.cell('r0c3', value='=r0c0+r0c1+r0c2', readonly=True)
return dict(sheet=sheet)
# view
{{extend 'layout.html'}}
{{=sheet}}
or insert invidivual cells via
{{=sheet.nodes['r0c0']}}
"""
def __init__(self, name, value, url='.', readonly=False, active=True, def __init__(self, name, value, url='.', readonly=False, active=True,
onchange=None): onchange=None, **kwarg):
self.url = url self.url = url
self.name = name self.name = name
self.value = str(value) self.value = str(value)
@@ -49,7 +27,7 @@ class Node:
self.onchange = onchange self.onchange = onchange
self.size = 4 self.size = 4
self.locked = False self.locked = False
def xml(self): def xml(self):
return """<input name="%s" id="%s" value="%s" size="%s" return """<input name="%s" id="%s" value="%s" size="%s"
onkeyup="ajax('%s/keyup',['%s'], ':eval');" onkeyup="ajax('%s/keyup',['%s'], ':eval');"
@@ -64,9 +42,192 @@ class Node:
class Sheet: class Sheet:
"""
Basic class for creating web spreadsheets
New features:
regex=re.compile('(?<!\w)[a-zA-Z_]\w*') -dal spreadsheets:
It receives a Rows object instance and presents
the selected data in a cell per field basis (table rows
are sheet rows as well)
Input should be short extension data as numeric data
or math expressions but can be anything supported by
unicode.
-row(), column() and matrix() class methods:
These new methods allow to set bulk data sets
without calling .cell() for each node
Example::
# controller
from gluon.contrib.spreadsheet import Sheet
def callback():
return cache.ram('sheet1', lambda: None, None).process(request)
def index():
# standard spreadsheet method
sheet = cache.ram('sheet1',
lambda: Sheet(10, 10, URL(r=request, f='callback')), 0)
#sheet.cell('r0c3', value='=r0c0+r0c1+r0c2', readonly=True)
return dict(sheet=sheet)
def index():
# database spreadsheet method
sheet = cache.ram('sheet1',
lambda: Sheet(10, 10, URL(r=request, f='callback'),
data=db(db.mydata).select()), 0)
return dict(sheet=sheet)
# view
{{extend 'layout.html'}}
{{=sheet}}
or insert invidivual cells via
{{=sheet.nodes['r0c0']}}
Sheet stores a JavaScript w2p_spreadsheet_data object
for retrieving data updates from the client.
The data structure of the js object is as follows:
# columns: a dict with colname, column index map
# colnames: a dict with column index, colname map
# id_columns: list with id columns
# id_colnames: dict with id colname: column index map
# cells: dict of "rncn": value pairs
# modified: dict of modified cells for client-side
Also, there is a class method Sheet.update(data) that
processes the json data as sent by the client and
returns a set of db modifications (see the method help for
more details)
client JavaScript objects:
-var w2p_spreadsheet_data
Stores cell updates by key and
Used for updated cells control
-var w2p_spreadsheet_update_button
Stores the id of the update command
Used for event binding (update click)
var w2p_spreadsheet_update_result
object attributes:
modified - n updated records
errors - n errors
message - a message for feedback and errors
Stores the ajax db update call returned stats
and the db_callback string js
Used after calling w2p_spreadsheet_update_db()
-function w2p_spreadsheet_update_cell(a)
Used for responding to normal cell events
(encapsulates old behavior)
-function w2p_spreadsheet_update_db_callback(result)
Called after a background db update
-function w2p_spreadsheet_update_db()
Called for updating the database with
client data
First method: Sending data trough a form helper:
(the data payload must be inserted in a form field before
submission)
-Applying db changes made client-side
Example controller:
...
# present a submit button with the spreadsheet
form = SQLFORM.factory(Field("<name>",
"text",
readable=False, writable=False,
formname="<formname>"))
# submit button label
form.elements("input [type=submit]").attributes["_value"] = \
T("Update database")
form.elements("textarea")[0].attributes["_style"] = "display: none;"
w2p_spreadsheet_update_script = SCRIPT('''
jQuery(
function(){
jQuery("<formname>").submit(function(){
jQuery("[name=<name>]").val(JSON.stringify(
w2p_spreadsheet_data)
);
});
}
);
''')
# retrieve changes
if form.process().accepted:
data = form.vars.<name>
changes = Sheet.updated(data)
# Do db I/O:
for table, rows in changes.iteritems():
for row, values in rows.iteritems():
db[table][row].update_record(**values)
# the action view should expose {{=form}}, {{=sheet}}, {{=myscript}}
return dict(form=form, sheet=sheet,
myscript=w2p_spreadseet_update_script)
Second method: Sending data updates with .ajax()
-spreadsheet page's view:
{{
=INPUT(_type="button", _value="update data",
_id="w2p_spreadsheet_update_data")
}}
{{=SCRIPT('''
jQuery(function(){
jQuery("#w2p_spreadsheet_update_data").click(
function(){
jQuery.ajax({url: "%s",
type: "POST",
data:
{data:
JSON.stringify(w2p_spreadsheet_data)}
}
);
}
);
});
''' % URL(c="default", f="modified"))}}
-controller: modified function
def modified():
data = request.vars.data
changes = Sheet.updated(data)
# (for db I/O see first method)
return "ok"
Third method:
When creating a Sheet instance, pass a kwarg update_button=<button id>
This step will auto process the updated data with db I/O (requires calling
.process() with db=<DAL instance>). You must expose an html element which
supports the .click() event, i.e. a normal button.
# TODO:
# -¿SIGNED URLS?
# -Delete checkbox columns for each table and default
# -Deletable=True option for showing/hiding delete checkboxes
# -process() method support for db I/O
"""
regex = re.compile('(?<!\w)[a-zA-Z_]\w*')
pregex = re.compile('\d+')
re_strings = re.compile(r'(?P<name>' re_strings = re.compile(r'(?P<name>'
+ r"[uU]?[rR]?'''([^']+|'{1,2}(?!'))*'''|" + r"[uU]?[rR]?'''([^']+|'{1,2}(?!'))*'''|"
+ r"'([^'\\]|\\.)*'|" + r"'([^'\\]|\\.)*'|"
@@ -77,33 +238,180 @@ class Sheet:
dump = pickle.dumps(self) dump = pickle.dumps(self)
return dump return dump
@staticmethod
def position(key):
""" Returns int row, int col for a 'rncn' formatted key'"""
try:
r, c = Sheet.pregex.findall(key)
r, c = int(r), int(c)
except (ValueError, IndexError, TypeError), e:
error = "%s. %s" % \
("Unexpected position parameter",
"Must be a key of type 'rncn'")
raise ValueError(error)
return r, c
@staticmethod @staticmethod
def loads(data): def loads(data):
sheet = pickle.loads(data) sheet = pickle.loads(data)
return sheet return sheet
def process(self, request): @staticmethod
def updated(data):
""" Reads spreadsheet update information sent client-side.
Returns a dict with updated database rows/fields.
Structure:
{<tablename>:{
<id>:{<fieldname>:<new value>,
<fieldname>:<new value>,
...
},
...
}
}
data dict argument:
# columns: (a dict with colname, column index map)
# colnames: (a dict with column index, colname map)
# id_columns: list with id columns
# id_colnames: dict with id colname: column index map
# cells: dict of "rncn": value pairs
# modified: dict of modified cells for client-side
"""
data = simplejson.loads(data)
# record update dict
changes = {}
# read column index per table
# table, id column map
tablenames = {}
for colname, i in data["id_colnames"].iteritems():
tablenames[colname.split(".")[0]] = i
# Separate multitable rows
# Identify each row id (old data)
# Build a dict with table/row/field
# update information.
# collect new data by row (modified):
for key, value in data["modified"].iteritems():
r, c = Sheet.position(key)
# don't apply repeated values
if data["cells"][key] != value:
# read tablename
tablename, fieldname = data["colnames"][str(c)].split(".")
# read db record id
row_id_key = "r%sc%s" % (r, tablenames[tablename])
row_id = data["cells"][row_id_key]
changes.setdefault(tablename, {})
changes[tablename].setdefault(row_id, {})
changes[tablename][row_id][fieldname] = value
return changes
def process(self, request, db=None, db_callback=None):
""" """
call this in action that creates table, it will handle ajax callbacks call this in action that creates table, it will handle ajax callbacks
optional db (a DAL instance). It's required for db I/O
optional callback string. js commands to call after successful
ajax db update.
db_callback string format keys:
modified (number of rows updated)
""" """
cell = request.vars.keys()[0]
if request.args(0) == 'focus': if not request.args(0) == "data":
return "jQuery('#%s').val('%s');" % (cell, quote(self[cell].value)) # normal cell processing
value = request.vars[cell] cell = request.vars.keys()[0]
self[cell] = value
if request.args(0) == 'blur': if request.args(0) == 'focus':
return "jQuery('#%s').val('%s');" \ return "jQuery('#%(cell)s').val('%(value)s');" % \
% (cell, quote(self[cell].computed_value)) dict(cell=cell, value=quote(self[cell].value))
elif request.args(0) == 'keyup':
jquery = '' value = request.vars[cell]
for other_key in self.modified: self[cell] = value
if other_key != cell:
jquery += "jQuery('#%s').val('%s');" % \ if request.args(0) == 'blur':
(other_key, quote(self[other_key].computed_value)) return "jQuery('#%(cell)s').val('%(value)s');" % \
dict(cell=cell, value=quote(self[cell].computed_value))
elif request.args(0) == 'keyup':
jquery = ''
for other_key in self.modified:
if other_key != cell:
jquery += "jQuery('#%(other_key)s').val('%(value)s');" % \
dict(other_key=other_key,
value=quote(self[other_key].computed_value))
else:
# spreadsheet db update
result = dict(modified=0,
errors=0,
message="",
db_callback="")
if db is not None:
data = request.vars["data"]
changes = self.updated(data)
# Do db I/O:
for table, rows in changes.iteritems():
for row, values in rows.iteritems():
db[table][row].update_record(**values)
result["modified"] += 1
if db_callback is not None:
result["db_callback"] = db_callback
else:
result["message"] = "Sheet.process Error. No db found."
return simplejson.dumps(result)
return jquery return jquery
def __init__(self, rows, cols, url='.', readonly=False, active=True, def get_attributes(self, data):
onchange=None): attributes = {}
for k in data.keys():
if k.startswith("_"):
attributes[k] = data[k]
return attributes
def even_or_odd(self, v):
""" Used for table row stripping """
if v % 2 == 0:
return "even"
else:
return "odd"
def __init__(self, rows, cols, url='.', readonly=False,
active=True, onchange=None, value=None, data=None,
headers=None, update_button="", **kwarg):
"""
Arguments:
headers: a dict with "table.fieldname": name values
value: common value for all spreadsheet
(can be a lambda x, y: z or function reference)
Rows and cols values will be updated automatically to fit
the data boundaries when the data argument is a Rows object.
self.client: for storing sheet data client side
columns: a dict with colname, column index map
colnames: a dict with column index, colname map
id_columns: list with id columns
id_colnames: dict with id colname: column index map
cells: dict of "rncn": value pairs
modified: dict of modified cells for client-side
edition.
"""
self.rows = rows self.rows = rows
self.cols = cols self.cols = cols
self.url = url self.url = url
@@ -111,9 +419,74 @@ class Sheet:
self.error = 'ERROR: %(error)s' self.error = 'ERROR: %(error)s'
self.allowed_keywords = ['for', 'in', 'if', 'else', 'and', 'or', 'not', self.allowed_keywords = ['for', 'in', 'if', 'else', 'and', 'or', 'not',
'i', 'j', 'k', 'x', 'y', 'z', 'sum'] 'i', 'j', 'k', 'x', 'y', 'z', 'sum']
self.value = value
self.environment = {} self.environment = {}
[self.cell('r%sc%s'%(k/cols, k%cols), '0.0', readonly, active, onchange) self.attributes = self.get_attributes(kwarg)
for k in xrange(rows*cols)] self.tr_attributes = {}
self.td_attributes = {}
self.data = data
self.readonly = readonly
self.update_button = update_button
self.client = {
"columns": {},
"colnames": {},
"id_columns": [],
"id_colnames": {},
"cells": {},
"modified": {},
"headers": headers
}
# if db and query:
if self.data is not None:
# retrieve row columns length
self.rows = len(self.data)
# retrieve rows length
self.cols = len(self.data.colnames)
# map row data to rncn values
for x, colname in enumerate(self.data.colnames):
self.client["columns"][colname] = x
self.client["colnames"][x] = colname
for x, row in enumerate(self.data):
for colname, y in self.client["columns"].iteritems():
key = "r%sc%s" % (x, y)
tablename, fieldname = colname.split(".")
try:
value = row[tablename][fieldname]
except (KeyError, AttributeError):
# single table query
value = row[fieldname]
self.client["cells"][key] = str(value)
# TODO: support different id keys
if ".id" in colname:
self.client["id_columns"].append(y)
self.client["id_colnames"][colname] = y
for k in xrange(self.rows*self.cols):
key = 'r%sc%s'%(k/self.cols, k%self.cols)
r, c = self.position(key)
if self.client["cells"].has_key(key):
value = self.client["cells"][key]
# readonly id values
if c in self.client["id_columns"]:
readonly = True
else:
readonly = self.readonly
elif self.value is not None:
if callable(self.value):
value = self.value(r, c)
else:
value = self.value
else:
value = '0.00'
self.cell(key, value,
readonly, active, onchange)
exec('from math import *', {}, self.environment) exec('from math import *', {}, self.environment)
def delete_from(self, other_list): def delete_from(self, other_list):
@@ -131,19 +504,179 @@ class Sheet:
def define(self, name, obj): def define(self, name, obj):
self.environment[name] = obj self.environment[name] = obj
def cell(self, key, value, readonly=False, active=True, onchange=None): def cell(self, key, value, readonly=False, active=True,
onchange=None, **kwarg):
""" """
key is the name of the cell key is the name of the cell
value is the initial value of the cell. It can be a formula "=1+3" value is the initial value of the cell. It can be a formula "=1+3"
a cell is active if it evaluates formuls a cell is active if it evaluates formulas
Value can be a function(r, c) which returns a string
""" """
key = str(key)
if not self.regex.match(key): if not self.regex.match(key):
raise SyntaxError, "Invalid cell name: %s" % key raise SyntaxError, "Invalid cell name: %s" % key
node = Node(key, value, self.url, readonly, active, onchange) else:
attributes = self.get_attributes(kwarg)
if attributes is not None:
self.td_attributes[key] = attributes
key = str(key)
r, c = self.position(key)
if callable(value):
value = value(r, c)
node = Node(key, value, self.url, readonly, active,
onchange, **kwarg)
self.nodes[key] = node self.nodes[key] = node
self[key] = value self[key] = value
def get_cell_arguments(self, data, default=None):
"""Reads cell arguments from a dict object"""
active = True
onchange = None
readonly = False
value = ""
if default is not None:
data.update(default)
if "active" in data:
active = data["active"]
if "readonly" in data:
readonly = data["readonly"]
if "onchange" in data:
onchange = data["onchange"]
if "value" in data:
value = data["value"]
return active, onchange, readonly, value
def row(self, row, cells, value=None, **kwarg):
# row: row index (0, 1, ...)
# cells: a sequence of values or a dict of dict with
# arg: value pairs
# one column example:
# {"0": {"value":1.0, "readonly":False, "active":True, "onchange":None}}
# value: common value for all cells
attributes = self.get_attributes(kwarg)
if attributes is not None:
self.tr_attributes[str(row)] = attributes
if isinstance(cells, dict):
for col, data in cells.iteritems():
key = "r%sc%s" % (row, col)
active, onchange, readonly, cell_value = \
self.get_cell_arguments(data, default=kwarg)
if value is None:
v = cell_value
else:
v = value
self.cell(key, v, active=active,
readonly=readonly,
onchange=onchange, **attributes)
else:
active, onchange, readonly, all_value = \
self.get_cell_arguments(kwarg)
for col, cell_value in enumerate(cells):
key = "r%sc%s" % (row, col)
if value is None:
v = cell_value
else:
v = value
self.cell(key, v, active=active,
onchange=onchange,
readonly=readonly, **attributes)
def column(self, col, cells, value=None, **kwarg):
"""
# col: column index (0, 1, ...)
# cells: a sequence of values or a dict of dict with
# arg: value pairs
# one row example:
# {"0": {"value":1.0, "readonly":False, "active":True, "onchange":None}}
# value: common value for all cells
"""
attributes = self.get_attributes(kwarg)
if isinstance(cells, dict):
for row, data in cells.iteritems():
key = "r%sc%s" % (row, col)
active, onchange, readonly, cell_value = \
self.get_cell_arguments(data, default=kwarg)
if value is None:
v = cell_value
else:
v = value
self.cell(key, v, active=active, readonly=readonly,
onchange=onchange, **attributes)
else:
active, onchange, readonly, all_value = \
self.get_cell_arguments(kwarg)
for row, cell_value in enumerate(cells):
key = "r%sc%s" % (row, col)
if value is None:
v = cell_value
else:
v = value
self.cell(key, v, active=active,
onchange=onchange, readonly=readonly,
**attributes)
def matrix(self, cells, starts="r0c0", ends=None, value=None, **kwarg):
"""
Insert a n x n matrix or a set of cells
# starts: upper left cell
# ends: lower right cell
# cells: a sequence of value sequences
# or a dict with "rncn" keys
# Example 1 cells:
# ((v11, v12, ... v1n),
(vn2, vn2, ... vnn))
# Example 2 cells:
# {"r0c0": {...}, ... "rncn": {...}}
# value: common value for all cells
"""
attributes = self.get_attributes(kwarg)
starts_r, starts_c = self.position(starts)
ends_r, ends_c = None, None
if ends is not None:
ends_r, ends_c = self.position(ends)
if isinstance(cells, dict):
for key, data in cells.iteritems():
r, c = self.position(key)
key = "r%sc%s" % (r+starts_r, c+starts_c)
active, onchange, readonly, cell_value = \
self.get_cell_arguments(data, default=kwarg)
if value is None:
v = cell_value
else:
v = value
if (ends is None) or ((ends_r >= r+starts_r) and\
(ends_c >= c+starts_c)):
self.cell(key, v, active=active,
readonly=readonly,
onchange=onchange, **attributes)
else:
active, onchange, readonly, all_value = \
self.get_cell_arguments(kwarg)
for r, row in enumerate(cells):
for c, cell_value in enumerate(row):
if value is None:
v = cell_value
else:
v = value
key = "r%sc%s" % (r+starts_r, c+starts_c)
if (ends is None) or \
((ends_r >= r+starts_r) and\
(ends_c >= c+starts_c)):
self.cell(key, v,
active=active,
onchange=onchange,
readonly=readonly,
**attributes)
def __setitem__(self, key, value): def __setitem__(self, key, value):
key = str(key) key = str(key)
value = str(value) value = str(value)
@@ -240,19 +773,103 @@ class Sheet:
if node.value[:1] != '=' or not node.active: if node.value[:1] != '=' or not node.active:
node.value = d[key] node.value = d[key]
def xml(self): def sheet(self):
import gluon.html import gluon.html
(DIV, TABLE, TR, TD, TH, BR) = \ (DIV, TABLE, TR, TD, TH, BR, SCRIPT) = \
(gluon.html.DIV, gluon.html.TABLE, gluon.html.TR, gluon.html.TD, (gluon.html.DIV, gluon.html.TABLE, gluon.html.TR, gluon.html.TD,
gluon.html.TH, gluon.html.BR) gluon.html.TH, gluon.html.BR, gluon.html.SCRIPT)
regex = re.compile('r\d+c\d+') regex = re.compile('r\d+c\d+')
return DIV(TABLE(TR(TH(), *[TH('c%s' % c) for c in range(self.cols)]),
*[TR(TH('r%s' % r), *[TD(self.nodes['r%sc%s'%(r, c)]) \ header = TR(TH(), *[TH('c%s' % c) \
for c in range(self.cols)]) \ for c in range(self.cols)])
for r in range(self.rows)]), rows = []
for r in range(self.rows):
tds = [TH('r%s' % r),]
for c in range(self.cols):
key = 'r%sc%s'%(r, c)
attributes = {"_class": "w2p_spreadsheet_col_%s" % \
self.even_or_odd(c)}
if key in self.td_attributes:
attributes.update(self.td_attributes[key])
td = TD(self.nodes[key], **attributes)
tds.append(td)
attributes = {"_class": "w2p_spreadsheet_row_%s" % \
self.even_or_odd(r)}
if str(r) in self.tr_attributes:
attributes.update(self.tr_attributes[str(r)])
rows.append(TR(*tds, **attributes))
attributes = {"_class": "w2p_spreadsheet"}
attributes.update(self.attributes)
table = TABLE(header, *rows, **self.attributes)
if len(self.client["cells"]) >= 1:
data = SCRIPT(\
"""
var w2p_spreadsheet_data = %(data)s;
var w2p_spreadsheet_update_button = "%(update_button)s";
var w2p_spreadsheet_update_result = null;
function w2p_spreadsheet_update_cell(a){
// update data
w2p_spreadsheet_data.modified[this.id] = this.value;
}
function w2p_spreadsheet_update_db_callback(result){
w2p_spreadsheet_update_result = result;
eval(w2p_spreadsheet_update_result.db_callback);
}
function w2p_spreadsheet_update_db(){
// ajax background db update
jQuery.ajax({url: "%(url)s/data",
type: "POST",
data:
{data: JSON.stringify(w2p_spreadsheet_data)},
dataType: "json",
success: w2p_spreadsheet_update_db_callback
});
}
// add onchange cell update event
jQuery(function(){
jQuery(".%(name)s input").change(w2p_spreadsheet_update_cell);
});
if (w2p_spreadsheet_update_button != ""){
jQuery(function(){
jQuery("#" + w2p_spreadsheet_update_button).click(w2p_spreadsheet_update_db);
});
}
""" % dict(data=simplejson.dumps(self.client),
name=attributes["_class"],
url=self.url,
update_button=self.update_button))
# extra row for fieldnames
unsorted_headers = []
if self.client["headers"] is not None:
for fieldname, name in self.client["headers"].iteritems():
unsorted_headers.append((self.client["columns"][fieldname],
name))
else:
for fieldname, c in self.client["columns"].iteritems():
unsorted_headers.append((c, fieldname))
sorted_headers = [TH(),] + \
[TH(header[1]) for header in sorted(unsorted_headers)]
table.insert(0, TR(*sorted_headers, _class="%s_fieldnames" % \
attributes["_class"]))
else:
data = SCRIPT(""" // web2py Spreadsheets: no db data.""")
return DIV(table,
BR(), BR(),
TABLE(*[TR(TH(key), TD(self.nodes[key])) \ TABLE(*[TR(TH(key), TD(self.nodes[key])) \
for key in self.nodes if not regex.match(key)])).xml() for key in self.nodes if not regex.match(key)]),
data, **attributes)
def xml(self):
return self.sheet().xml()
if __name__ == '__main__': if __name__ == '__main__':
s = Sheet(0, 0) s = Sheet(0, 0)
@@ -261,7 +878,3 @@ if __name__ == '__main__':
s.cell('c', value="=cos(a)**2+b*b") s.cell('c', value="=cos(a)**2+b*b")
print s['c'].computed_value print s['c'].computed_value