SP/web2py/gluon/contrib/spreadsheet.py

905 lines
32 KiB
Python
Raw Normal View History

2018-10-25 15:33:07 +00:00
# -*- coding: utf-8 -*-
"""
Developed by Massimo Di Pierro, optional component of web2py, BSDv3 license.
"""
from __future__ import print_function
import re
import pickle
import copy
import json
def quote(text):
return str(text).replace('\\', '\\\\').replace("'", "\\'")
class Node:
def __init__(self, name, value, url='.', readonly=False, active=True,
onchange=None, select=False, size=4, **kwarg):
self.url = url
self.name = name
self.value = str(value)
self.computed_value = ''
self.incoming = {}
self.outcoming = {}
self.readonly = readonly
self.active = active
self.onchange = onchange
self.size = size
self.locked = False
self.select = value if select and not isinstance(value, str) else False
def xml(self):
if self.select:
selectAttributes = dict(_name=self.name,_id=self.name,_size=self.size,
_onblur="ajax('%s/blur',['%s']);"%(self.url,self.name))
# _onkeyup="ajax('%s/keyup',['%s'], ':eval');"%(self.url,self.name),
# _onfocus="ajax('%s/focus',['%s'], ':eval');"%(self.url,self.name),
for k,v in selectAttributes.items():
self.select[k] = v
return self.select.xml()
else:
return """<input name="%s" id="%s" value="%s" size="%s"
onkeyup="ajax('%s/keyup',['%s'], ':eval');"
onfocus="ajax('%s/focus',['%s'], ':eval');"
onblur="ajax('%s/blur',['%s'], ':eval');" %s/>
""" % (self.name, self.name, self.computed_value, self.size,
self.url, self.name, self.url, self.name, self.url, self.name,
(self.readonly and 'readonly ') or '')
def __repr__(self):
return '%s:%s' % (self.name, self.computed_value)
class Sheet:
"""
Basic class for creating web spreadsheets
New features:
-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"'([^'\\]|\\.)*'|"
+ r'"""([^"]|"{1,2}(?!"))*"""|'
+ r'"([^"\\]|\\.)*")', re.DOTALL)
def dumps(self):
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) as 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
@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 = json.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)
"""
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 json.dumps(result)
return jquery
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="", c_headers=None,
r_headers=None, **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
self.nodes = {}
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.attributes = self.get_attributes(kwarg)
self.tr_attributes = {}
self.td_attributes = {}
self.c_headers = c_headers
self.r_headers = r_headers
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 key in self.client["cells"]:
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):
indices = [k for (k, node) in enumerate(other_list) if k == node]
if indices:
del other_list[indices[0]]
def changed(self, node, changed_nodes=[]):
for other_node in node.outcoming:
if not other_node in changed_nodes:
changed_nodes.append(other_node)
self.changed(other_node, changed_nodes)
return changed_nodes
def define(self, name, obj):
self.environment[name] = obj
def cell(self, key, value, readonly=False, active=True,
onchange=None, select=False, **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 formulas
Value can be a function(r, c) which returns a string
"""
if not self.regex.match(key):
raise SyntaxError("Invalid cell name: %s" % key)
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, select=select, **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)
node = self.nodes[key]
node.value = value
if value[:1] == '=' and node.active:
# clear all edges involving current node
for other_node in node.incoming:
del other_node.outcoming[node]
node.incoming.clear()
# build new edges
command = self.re_strings.sub("''", value[1:])
node.locked = False
for match in self.regex.finditer(command):
other_key = match.group()
if other_key == key:
self.computed_value = self.error % dict(error='cycle')
self.modified = {}
break
if other_key in self.nodes:
other_node = self.nodes[other_key]
other_node.outcoming[node] = True
node.incoming[other_node] = True
elif not other_key in self.allowed_keywords and \
not other_key in self.environment:
node.locked = True
node.computed_value = \
self.error % dict(
error='invalid keyword: ' + other_key)
self.modified = {}
break
self.compute(node)
else:
try:
node.computed_value = int(node.value)
except:
try:
node.computed_value = float(node.value)
except:
node.computed_value = node.value
self.environment[key] = node.computed_value
if node.onchange:
node.onchange(node)
self.modified = self.iterate(node)
def compute(self, node):
if node.value[:1] == '=' and not node.locked:
try:
exec('__value__=' + node.value[1:], {}, self.environment)
node.computed_value = self.environment['__value__']
del self.environment['__value__']
except Exception as e:
node.computed_value = self.error % dict(error=str(e))
self.environment[node.name] = node.computed_value
if node.onchange:
node.onchange(node)
def iterate(self, node):
output = {node.name: node.computed_value}
changed_nodes = self.changed(node)
while changed_nodes:
ok = False
set_changed_nodes = set(changed_nodes)
for (k, other_node) in enumerate(changed_nodes):
#print other_node, changed_nodes
if not set(other_node.incoming.keys()).\
intersection(set_changed_nodes):
#print 'ok'
self.compute(other_node)
output[other_node.name] = other_node.computed_value
#print other_node
del changed_nodes[k]
ok = True
break
if not ok:
return {}
return output
def __getitem__(self, key):
return self.nodes[str(key)]
def get_computed_values(self):
d = {}
for key in self.nodes:
node = self.nodes[key]
if node.value[:1] != '=' or not node.active:
d[key] = node.computed_value
return d
def set_computed_values(self, d):
for key in d:
if not key in self.nodes:
continue
node = self.nodes[key]
if node.value[:1] != '=' or not node.active:
node.value = d[key]
def sheet(self):
import gluon.html
(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.SCRIPT)
regex = re.compile('r\d+c\d+')
if not self.c_headers:
header = TR(TH(), *[TH('c%s' % c)
for c in range(self.cols)])
else:
header = TR(TH(), *[TH('%s' % c)
for c in self.c_headers])
rows = []
for r in range(self.rows):
if not self.r_headers:
tds = [TH('r%s' % r), ]
else:
tds = [TH('%s' % self.r_headers[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=json.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)]),
data, **attributes)
def xml(self):
return self.sheet().xml()
if __name__ == '__main__':
s = Sheet(0, 0)
s.cell('a', value="2")
s.cell('b', value="=sin(a)")
s.cell('c', value="=cos(a)**2+b*b")
print(s['c'].computed_value)