Skip to content

Commit c89eb71

Browse files
committed
issue2550833 enhance the export csv action to include the keys for
liked items rather than id's. So for nosy list display usernames and not numbers. The original code was renamed and also made available. See change document.
1 parent 683bebd commit c89eb71

File tree

4 files changed

+268
-10
lines changed

4 files changed

+268
-10
lines changed

CHANGES.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ Features:
4343
other than GET that doesn't have a payload. E.G. DELETE, PATCH or
4444
OPTIONS. Verbs like PUT and POST usually have payloads, so this
4545
patch doesn't touch processing of these methods. (John Rouillard)
46+
- issue2550833: the export_csv web action now returns labels/names
47+
rather than id's. Replace calls to export_csv with the export_csv_id
48+
action to return the same data as the old export_csv action. (Tom
49+
Ekberg (tekberg), Andreas (anrounham14) edited/applied and tests
50+
created by John Rouillard)
4651

4752
Fixed:
4853

roundup/cgi/actions.py

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
__all__ = ['Action', 'ShowAction', 'RetireAction', 'RestoreAction', 'SearchAction',
1515
'EditCSVAction', 'EditItemAction', 'PassResetAction',
1616
'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction',
17-
'NewItemAction', 'ExportCSVAction']
17+
'NewItemAction', 'ExportCSVAction', 'ExportCSVWithIdAction']
1818

1919
# used by a couple of routines
2020
chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
@@ -1294,6 +1294,159 @@ def verifyPassword(self, userid, givenpw):
12941294
class ExportCSVAction(Action):
12951295
name = 'export'
12961296
permissionType = 'View'
1297+
list_sep = ';' # Separator for list types
1298+
1299+
def handle(self):
1300+
''' Export the specified search query as CSV. '''
1301+
# figure the request
1302+
request = templating.HTMLRequest(self.client)
1303+
filterspec = request.filterspec
1304+
sort = request.sort
1305+
group = request.group
1306+
columns = request.columns
1307+
klass = self.db.getclass(request.classname)
1308+
1309+
# check if all columns exist on class
1310+
# the exception must be raised before sending header
1311+
props = klass.getprops()
1312+
for cname in columns:
1313+
if cname not in props:
1314+
# use error code 400: Bad Request. Do not use
1315+
# error code 404: Not Found.
1316+
self.client.response_code = 400
1317+
raise exceptions.NotFound(
1318+
self._('Column "%(column)s" not found in %(class)s')
1319+
% {'column': cgi.escape(cname), 'class': request.classname})
1320+
1321+
# full-text search
1322+
if request.search_text:
1323+
matches = self.db.indexer.search(
1324+
re.findall(r'\b\w{2,25}\b', request.search_text), klass)
1325+
else:
1326+
matches = None
1327+
1328+
header = self.client.additional_headers
1329+
header['Content-Type'] = 'text/csv; charset=%s' % self.client.charset
1330+
# some browsers will honor the filename here...
1331+
header['Content-Disposition'] = 'inline; filename=query.csv'
1332+
1333+
self.client.header()
1334+
1335+
if self.client.env['REQUEST_METHOD'] == 'HEAD':
1336+
# all done, return a dummy string
1337+
return 'dummy'
1338+
1339+
wfile = self.client.request.wfile
1340+
if self.client.charset != self.client.STORAGE_CHARSET:
1341+
wfile = codecs.EncodedFile(wfile,
1342+
self.client.STORAGE_CHARSET, self.client.charset, 'replace')
1343+
1344+
writer = csv.writer(wfile)
1345+
1346+
# handle different types of columns.
1347+
def repr_no_right(cls, col):
1348+
"""User doesn't have the right to see the value of col."""
1349+
def fct(arg):
1350+
return "[hidden]"
1351+
return fct
1352+
def repr_link(cls, col):
1353+
"""Generate a function which returns the string representation of
1354+
a link depending on `cls` and `col`."""
1355+
def fct(arg):
1356+
if arg == None:
1357+
return ""
1358+
else:
1359+
return str(cls.get(arg, col))
1360+
return fct
1361+
def repr_list(cls, col):
1362+
def fct(arg):
1363+
if arg == None:
1364+
return ""
1365+
elif type(arg) is list:
1366+
seq = [str(cls.get(val, col)) for val in arg]
1367+
return self.list_sep.join(seq)
1368+
return fct
1369+
def repr_date():
1370+
def fct(arg):
1371+
if arg == None:
1372+
return ""
1373+
else:
1374+
if (arg.local(self.db.getUserTimezone()).pretty('%H:%M') ==
1375+
'00:00'):
1376+
fmt = '%Y-%m-%d'
1377+
else:
1378+
fmt = '%Y-%m-%d %H:%M'
1379+
return arg.local(self.db.getUserTimezone()).pretty(fmt)
1380+
return fct
1381+
def repr_val():
1382+
def fct(arg):
1383+
if arg == None:
1384+
return ""
1385+
else:
1386+
return str(arg)
1387+
return fct
1388+
1389+
props = klass.getprops()
1390+
1391+
# Determine translation map.
1392+
ncols = []
1393+
represent = {}
1394+
for col in columns:
1395+
ncols.append(col)
1396+
represent[col] = repr_val()
1397+
if isinstance(props[col], hyperdb.Multilink):
1398+
cname = props[col].classname
1399+
cclass = self.db.getclass(cname)
1400+
represent[col] = repr_list(cclass, 'name')
1401+
if not self.hasPermission(self.permissionType, classname=cname):
1402+
represent[col] = repr_no_right(cclass, 'name')
1403+
else:
1404+
if 'name' in cclass.getprops():
1405+
represent[col] = repr_list(cclass, 'name')
1406+
elif cname == 'user':
1407+
represent[col] = repr_list(cclass, 'realname')
1408+
if isinstance(props[col], hyperdb.Link):
1409+
cname = props[col].classname
1410+
cclass = self.db.getclass(cname)
1411+
if not self.hasPermission(self.permissionType, classname=cname):
1412+
represent[col] = repr_no_right(cclass, 'name')
1413+
else:
1414+
if 'name' in cclass.getprops():
1415+
represent[col] = repr_link(cclass, 'name')
1416+
elif cname == 'user':
1417+
represent[col] = repr_link(cclass, 'realname')
1418+
if isinstance(props[col], hyperdb.Date):
1419+
represent[col] = repr_date()
1420+
1421+
columns = ncols
1422+
# generate the CSV output
1423+
self.client._socket_op(writer.writerow, columns)
1424+
# and search
1425+
for itemid in klass.filter(matches, filterspec, sort, group):
1426+
row = []
1427+
# don't put out a row of [hidden] fields if the user has
1428+
# no access to the issue.
1429+
if not self.hasPermission(self.permissionType, itemid=itemid,
1430+
classname=request.classname):
1431+
continue
1432+
for name in columns:
1433+
# check permission for this property on this item
1434+
# TODO: Permission filter doesn't work for the 'user' class
1435+
if not self.hasPermission(self.permissionType, itemid=itemid,
1436+
classname=request.classname, property=name):
1437+
repr_function = repr_no_right(request.classname, name)
1438+
else:
1439+
repr_function = represent[name]
1440+
row.append(repr_function(klass.get(itemid, name)))
1441+
self.client._socket_op(writer.writerow, row)
1442+
return '\n'
1443+
1444+
class ExportCSVWithIdAction(Action):
1445+
''' A variation of ExportCSVAction that returns ID number rather than
1446+
names. This is the original csv export function.
1447+
'''
1448+
name = 'export'
1449+
permissionType = 'View'
12971450

12981451
def handle(self):
12991452
''' Export the specified search query as CSV. '''
@@ -1346,10 +1499,24 @@ def handle(self):
13461499
# and search
13471500
for itemid in klass.filter(matches, filterspec, sort, group):
13481501
row = []
1502+
# FIXME should this code raise an exception if an item
1503+
# is included that can't be accessed? Enabling this
1504+
# check will just skip the row for the inaccessible item.
1505+
# This makes it act more like the web interface.
1506+
#if not self.hasPermission(self.permissionType, itemid=itemid,
1507+
# classname=request.classname):
1508+
# continue
13491509
for name in columns:
13501510
# check permission to view this property on this item
1351-
if not self.hasPermission('View', itemid=itemid,
1511+
if not self.hasPermission(self.permissionType, itemid=itemid,
13521512
classname=request.classname, property=name):
1513+
# FIXME: is this correct, or should we just
1514+
# emit a '[hidden]' string. Note that this may
1515+
# allow an attacker to figure out hidden schema
1516+
# properties.
1517+
# A bad property name will result in an exception.
1518+
# A valid property results in a column of '[hidden]'
1519+
# values.
13531520
raise exceptions.Unauthorised(self._(
13541521
'You do not have permission to view %(class)s'
13551522
) % {'class': request.classname})
@@ -1358,7 +1525,6 @@ def handle(self):
13581525

13591526
return '\n'
13601527

1361-
13621528
class Bridge(BaseAction):
13631529
"""Make roundup.actions.Action executable via CGI request.
13641530

roundup/cgi/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,6 +1756,7 @@ def renderContext(self):
17561756
('retire', actions.RetireAction),
17571757
('show', actions.ShowAction),
17581758
('export_csv', actions.ExportCSVAction),
1759+
('export_csv_id', actions.ExportCSVWithIdAction),
17591760
)
17601761
def handle_action(self):
17611762
""" Determine whether there should be an Action called.

test/test_cgi.py

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,16 +1507,40 @@ def testRoles(self):
15071507
self.assert_(not item.hasRole(''))
15081508

15091509
def testCSVExport(self):
1510-
cl = self._make_client({'@columns': 'id,name'}, nodeid=None,
1511-
userid='1')
1512-
cl.classname = 'status'
1510+
cl = self._make_client(
1511+
{'@columns': 'id,title,status,keyword,assignedto,nosy'},
1512+
nodeid=None, userid='1')
1513+
cl.classname = 'issue'
1514+
1515+
demo_id=self.db.user.create(username='demo', address='[email protected]',
1516+
roles='User', realname='demo')
1517+
key_id1=self.db.keyword.create(name='keyword1')
1518+
key_id2=self.db.keyword.create(name='keyword2')
1519+
self.db.issue.create(title='foo1', status='2', assignedto='4', nosy=['3',demo_id])
1520+
self.db.issue.create(title='bar2', status='1', assignedto='3', keyword=[key_id1,key_id2])
1521+
self.db.issue.create(title='baz32', status='4')
15131522
output = StringIO()
15141523
cl.request = MockNull()
15151524
cl.request.wfile = output
1525+
# call export version that outputs names
15161526
actions.ExportCSVAction(cl).handle()
1517-
self.assertEquals('id,name\r\n1,unread\r\n2,deferred\r\n3,chatting\r\n'
1518-
'4,need-eg\r\n5,in-progress\r\n6,testing\r\n7,done-cbb\r\n'
1519-
'8,resolved\r\n',
1527+
#print(output.getvalue())
1528+
self.assertEquals('id,title,status,keyword,assignedto,nosy\r\n'
1529+
'1,foo1,deferred,,"Contrary, Mary","Bork, Chef;demo;Contrary, Mary"\r\n'
1530+
'2,bar2,unread,keyword1;keyword2,"Bork, Chef","Bork, Chef"\r\n'
1531+
'3,baz32,need-eg,,,\r\n',
1532+
1533+
output.getvalue())
1534+
output = StringIO()
1535+
cl.request = MockNull()
1536+
cl.request.wfile = output
1537+
# call export version that outputs id numbers
1538+
actions.ExportCSVWithIdAction(cl).handle()
1539+
#print(output.getvalue())
1540+
self.assertEquals('id,title,status,keyword,assignedto,nosy\r\n'
1541+
"1,foo1,2,[],4,\"['3', '5', '4']\"\r\n"
1542+
"2,bar2,1,\"['1', '2']\",3,['3']\r\n"
1543+
'3,baz32,4,[],None,[]\r\n',
15201544
output.getvalue())
15211545

15221546
def testCSVExportBadColumnName(self):
@@ -1545,6 +1569,68 @@ def testCSVExportFailPermissionBadColumn(self):
15451569
actions.ExportCSVAction(cl).handle)
15461570

15471571
def testCSVExportFailPermissionValidColumn(self):
1572+
passwd=password.Password('foo')
1573+
demo_id=self.db.user.create(username='demo', address='[email protected]',
1574+
roles='User', realname='demo',
1575+
password=passwd)
1576+
cl = self._make_client({'@columns': 'id,username,address,password'},
1577+
nodeid=None, userid=demo_id)
1578+
cl.classname = 'user'
1579+
output = StringIO()
1580+
cl.request = MockNull()
1581+
cl.request.wfile = output
1582+
# used to be self.assertRaises(exceptions.Unauthorised,
1583+
# but not acting like the column name is not found
1584+
1585+
actions.ExportCSVAction(cl).handle()
1586+
#print(output.getvalue())
1587+
self.assertEquals('id,username,address,password\r\n'
1588+
'1,admin,[hidden],[hidden]\r\n'
1589+
'2,anonymous,[hidden],[hidden]\r\n'
1590+
'3,Chef,[hidden],[hidden]\r\n'
1591+
'4,mary,[hidden],[hidden]\r\n'
1592+
'5,demo,[email protected],%s\r\n'%(passwd),
1593+
output.getvalue())
1594+
1595+
def testCSVExportWithId(self):
1596+
cl = self._make_client({'@columns': 'id,name'}, nodeid=None,
1597+
userid='1')
1598+
cl.classname = 'status'
1599+
output = StringIO()
1600+
cl.request = MockNull()
1601+
cl.request.wfile = output
1602+
actions.ExportCSVWithIdAction(cl).handle()
1603+
self.assertEquals('id,name\r\n1,unread\r\n2,deferred\r\n3,chatting\r\n'
1604+
'4,need-eg\r\n5,in-progress\r\n6,testing\r\n7,done-cbb\r\n'
1605+
'8,resolved\r\n',
1606+
output.getvalue())
1607+
1608+
def testCSVExportWithIdBadColumnName(self):
1609+
cl = self._make_client({'@columns': 'falseid,name'}, nodeid=None,
1610+
userid='1')
1611+
cl.classname = 'status'
1612+
output = StringIO()
1613+
cl.request = MockNull()
1614+
cl.request.wfile = output
1615+
self.assertRaises(exceptions.NotFound,
1616+
actions.ExportCSVWithIdAction(cl).handle)
1617+
1618+
def testCSVExportWithIdFailPermissionBadColumn(self):
1619+
cl = self._make_client({'@columns': 'id,email,password'}, nodeid=None,
1620+
userid='2')
1621+
cl.classname = 'user'
1622+
output = StringIO()
1623+
cl.request = MockNull()
1624+
cl.request.wfile = output
1625+
# used to be self.assertRaises(exceptions.Unauthorised,
1626+
# but not acting like the column name is not found
1627+
# see issue2550755 - should this return Unauthorised?
1628+
# The unauthorised user should never get to the point where
1629+
# they can determine if the column name is valid or not.
1630+
self.assertRaises(exceptions.NotFound,
1631+
actions.ExportCSVWithIdAction(cl).handle)
1632+
1633+
def testCSVExportWithIdFailPermissionValidColumn(self):
15481634
cl = self._make_client({'@columns': 'id,address,password'}, nodeid=None,
15491635
userid='2')
15501636
cl.classname = 'user'
@@ -1554,7 +1640,7 @@ def testCSVExportFailPermissionValidColumn(self):
15541640
# used to be self.assertRaises(exceptions.Unauthorised,
15551641
# but not acting like the column name is not found
15561642
self.assertRaises(exceptions.Unauthorised,
1557-
actions.ExportCSVAction(cl).handle)
1643+
actions.ExportCSVWithIdAction(cl).handle)
15581644

15591645
class TemplateHtmlRendering(unittest.TestCase):
15601646
''' try to test the rendering code for tal '''

0 commit comments

Comments
 (0)