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.
"""
import re
import pickle
import copy
import simplejson
def quote(text):
return str(text).replace('\\', '\\\\').replace("'", "\\'")
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,
onchange=None):
onchange=None, **kwarg):
self.url = url
self.name = name
self.value = str(value)
@@ -49,7 +27,7 @@ class Node:
self.onchange = onchange
self.size = 4
self.locked = False
def xml(self):
return """<input name="%s" id="%s" value="%s" size="%s"
onkeyup="ajax('%s/keyup',['%s'], ':eval');"
@@ -64,9 +42,192 @@ class Node:
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>'
+ r"[uU]?[rR]?'''([^']+|'{1,2}(?!'))*'''|"
+ r"'([^'\\]|\\.)*'|"
@@ -77,33 +238,180 @@ class Sheet:
dump = pickle.dumps(self)
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
def loads(data):
sheet = pickle.loads(data)
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
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':
return "jQuery('#%s').val('%s');" % (cell, quote(self[cell].value))
value = request.vars[cell]
self[cell] = value
if request.args(0) == 'blur':
return "jQuery('#%s').val('%s');" \
% (cell, quote(self[cell].computed_value))
elif request.args(0) == 'keyup':
jquery = ''
for other_key in self.modified:
if other_key != cell:
jquery += "jQuery('#%s').val('%s');" % \
(other_key, quote(self[other_key].computed_value))
if not request.args(0) == "data":
# normal cell processing
cell = request.vars.keys()[0]
if request.args(0) == 'focus':
return "jQuery('#%(cell)s').val('%(value)s');" % \
dict(cell=cell, value=quote(self[cell].value))
value = request.vars[cell]
self[cell] = value
if request.args(0) == 'blur':
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
def __init__(self, rows, cols, url='.', readonly=False, active=True,
onchange=None):
def get_attributes(self, data):
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.cols = cols
self.url = url
@@ -111,9 +419,74 @@ class Sheet:
self.error = 'ERROR: %(error)s'
self.allowed_keywords = ['for', 'in', 'if', 'else', 'and', 'or', 'not',
'i', 'j', 'k', 'x', 'y', 'z', 'sum']
self.value = value
self.environment = {}
[self.cell('r%sc%s'%(k/cols, k%cols), '0.0', readonly, active, onchange)
for k in xrange(rows*cols)]
self.attributes = self.get_attributes(kwarg)
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)
def delete_from(self, other_list):
@@ -131,19 +504,179 @@ class Sheet:
def define(self, 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
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):
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[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):
key = str(key)
value = str(value)
@@ -240,19 +773,103 @@ class Sheet:
if node.value[:1] != '=' or not node.active:
node.value = d[key]
def xml(self):
def sheet(self):
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.TH, gluon.html.BR)
gluon.html.TH, gluon.html.BR, gluon.html.SCRIPT)
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)]) \
for c in range(self.cols)]) \
for r in range(self.rows)]),
header = TR(TH(), *[TH('c%s' % c) \
for c in range(self.cols)])
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(),
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__':
s = Sheet(0, 0)
@@ -261,7 +878,3 @@ if __name__ == '__main__':
s.cell('c', value="=cos(a)**2+b*b")
print s['c'].computed_value