comparison test/rest_common.py @ 5653:ba67e397f063

Fix string/bytes issues under python 3. 1) cgi/client.py: override cgi.FieldStorage's make_file so that file is always created in binary/byte mode. This means that json (and xml) are bytes not strings. 2) rest.py: try harder to find dicttoxml in roundup directory or on sys.path. This just worked under python 2 but python 3 only searches sys.path by default and does not search relative like python 2. 3) rest.py: replace headers.getheader call removed from python 3 with equivalent code. 4) rest.py: make value returned from dispatch into bytes not string. 5) test/caseinsensitivedict.py, test/test_CaseInsensitiveDict.py: get code from stackoverflow that implements a case insensitive key dict. So dict['foo'], dict['Foo'] are the same entry. Used for looking up headers in mocked http rewuset header array. 6) test/rest_common.py: rework tests for etags and rest to properly supply bytes to the called routines. Calls to s2b and b2s and use of BytesIO and overriding make_file in cgi.FieldStorage to try to make sure it works under python 3.
author John Rouillard <rouilj@ieee.org>
date Sun, 17 Mar 2019 19:28:26 -0400
parents 095db27e8064
children 207e0f5d551c
comparison
equal deleted inserted replaced
5652:9689d1bf9bb0 5653:ba67e397f063
6 from roundup.cgi.exceptions import * 6 from roundup.cgi.exceptions import *
7 from roundup import password, hyperdb 7 from roundup import password, hyperdb
8 from roundup.rest import RestfulInstance, calculate_etag 8 from roundup.rest import RestfulInstance, calculate_etag
9 from roundup.backends import list_backends 9 from roundup.backends import list_backends
10 from roundup.cgi import client 10 from roundup.cgi import client
11 from roundup.anypy.strings import s2b, b2s
11 import random 12 import random
12 13
13 from .db_test_base import setupTracker 14 from .db_test_base import setupTracker
14 15
15 from .mocknull import MockNull 16 from .mocknull import MockNull
16 17
17 from io import StringIO 18 from io import BytesIO
18 import json 19 import json
20
21 from .caseinsensitivedict import CaseInsensitiveDict
19 22
20 NEEDS_INSTANCE = 1 23 NEEDS_INSTANCE = 1
21 24
22 25
23 class TestCase(): 26 class TestCase():
66 'PATH_INFO': 'http://localhost/rounduptest/rest/', 69 'PATH_INFO': 'http://localhost/rounduptest/rest/',
67 'HTTP_HOST': 'localhost', 70 'HTTP_HOST': 'localhost',
68 'TRACKER_NAME': 'rounduptest' 71 'TRACKER_NAME': 'rounduptest'
69 } 72 }
70 self.dummy_client = client.Client(self.instance, MockNull(), env, [], None) 73 self.dummy_client = client.Client(self.instance, MockNull(), env, [], None)
71 self.dummy_client.request.headers.getheader = self.get_header 74 self.dummy_client.request.headers = CaseInsensitiveDict()
72 self.empty_form = cgi.FieldStorage() 75 self.empty_form = cgi.FieldStorage()
73 76
74 self.server = RestfulInstance(self.dummy_client, self.db) 77 self.server = RestfulInstance(self.dummy_client, self.db)
75 78
76 def tearDown(self): 79 def tearDown(self):
78 try: 81 try:
79 shutil.rmtree(self.dirname) 82 shutil.rmtree(self.dirname)
80 except OSError as error: 83 except OSError as error:
81 if error.errno not in (errno.ENOENT, errno.ESRCH): 84 if error.errno not in (errno.ENOENT, errno.ESRCH):
82 raise 85 raise
83
84 def get_header (self, header, not_found=None):
85 try:
86 return self.headers[header.lower()]
87 except (AttributeError, KeyError):
88 return not_found
89 86
90 def testGet(self): 87 def testGet(self):
91 """ 88 """
92 Retrieve all three users 89 Retrieve all three users
93 obtain data for 'joe' 90 obtain data for 'joe'
349 Both will be checked if availble. If either one 346 Both will be checked if availble. If either one
350 fails, the etag check will fail. 347 fails, the etag check will fail.
351 348
352 Run over header only, etag in form only, both, 349 Run over header only, etag in form only, both,
353 each one broke and no etag. Use the put command 350 each one broke and no etag. Use the put command
354 to triger the etag checking code. 351 to trigger the etag checking code.
355 ''' 352 '''
356 for mode in ('header', 'etag', 'both', 353 for mode in ('header', 'etag', 'both',
357 'brokenheader', 'brokenetag', 'none'): 354 'brokenheader', 'brokenetag', 'none'):
358 try: 355 try:
359 # clean up any old header 356 # use lower case for key to delete. Probably
360 del(self.headers) 357 # a bug.
361 except AttributeError: 358 del(self.dummy_client.request.headers['etag'])
359 except (AttributeError,KeyError):
362 pass 360 pass
363 361
364 form = cgi.FieldStorage() 362 form = cgi.FieldStorage()
365 etag = calculate_etag(self.db.user.getnode(self.joeid)) 363 etag = calculate_etag(self.db.user.getnode(self.joeid))
366 form.list = [ 364 form.list = [
367 cgi.MiniFieldStorage('data', 'Joe Doe Doe'), 365 cgi.MiniFieldStorage('data', 'Joe Doe Doe'),
368 ] 366 ]
369 367
370 if mode == 'header': 368 if mode == 'header':
371 print("Mode = %s"%mode) 369 print("Mode = %s"%mode)
372 self.headers = {'etag': etag} 370 self.dummy_client.request.headers['Etag'] = etag
373 elif mode == 'etag': 371 elif mode == 'etag':
374 print("Mode = %s"%mode) 372 print("Mode = %s"%mode)
375 form.list.append(cgi.MiniFieldStorage('@etag', etag)) 373 form.list.append(cgi.MiniFieldStorage('@etag', etag))
376 elif mode == 'both': 374 elif mode == 'both':
377 print("Mode = %s"%mode) 375 print("Mode = %s"%mode)
378 self.headers = {'etag': etag} 376 self.dummy_client.request.headers['Etag'] = etag
379 form.list.append(cgi.MiniFieldStorage('@etag', etag)) 377 form.list.append(cgi.MiniFieldStorage('@etag', etag))
380 elif mode == 'brokenheader': 378 elif mode == 'brokenheader':
381 print("Mode = %s"%mode) 379 print("Mode = %s"%mode)
382 self.headers = {'etag': 'bad'} 380 self.dummy_client.request.headers['Etag'] = 'bad'
383 form.list.append(cgi.MiniFieldStorage('@etag', etag)) 381 form.list.append(cgi.MiniFieldStorage('@etag', etag))
384 elif mode == 'brokenetag': 382 elif mode == 'brokenetag':
385 print("Mode = %s"%mode) 383 print("Mode = %s"%mode)
386 self.headers = {'etag': etag} 384 self.dummy_client.request.headers['Etag'] = etag
387 form.list.append(cgi.MiniFieldStorage('@etag', 'bad')) 385 form.list.append(cgi.MiniFieldStorage('@etag', 'bad'))
388 elif mode == 'none': 386 elif mode == 'none':
389 print( "Mode = %s"%mode) 387 print( "Mode = %s"%mode)
390 else: 388 else:
391 self.fail("unknown mode found") 389 self.fail("unknown mode found")
396 if mode not in ('brokenheader', 'brokenetag', 'none'): 394 if mode not in ('brokenheader', 'brokenetag', 'none'):
397 self.assertEqual(self.dummy_client.response_code, 200) 395 self.assertEqual(self.dummy_client.response_code, 200)
398 else: 396 else:
399 self.assertEqual(self.dummy_client.response_code, 412) 397 self.assertEqual(self.dummy_client.response_code, 412)
400 398
399 def make_file(self):
400 import tempfile
401 return tempfile.TemporaryFile("wb+")
402
401 def testDispatch(self): 403 def testDispatch(self):
402 """ 404 """
403 run changes through rest dispatch(). This also tests 405 run changes through rest dispatch(). This also tests
404 sending json payload through code as dispatch is the 406 sending json payload through code as dispatch is the
405 code that changes json payload into something we can 407 code that changes json payload into something we can
406 process. 408 process.
407 """ 409 """
408 # Set joe's 'realname' using json data. 410
411 # Override the make_file so it is always set to binary
412 # read mode. This is needed so we can send a json
413 # body.
414 saved_make_file = cgi.FieldStorage.make_file
415 cgi.FieldStorage.make_file = self.make_file
416
417
418 # TEST #1
419 # PUT: joe's 'realname' using json data.
409 # simulate: /rest/data/user/<id>/realname 420 # simulate: /rest/data/user/<id>/realname
410 # use etag in header 421 # use etag in header
411 etag = calculate_etag(self.db.user.getnode(self.joeid)) 422 etag = calculate_etag(self.db.user.getnode(self.joeid))
412 body=u'{ "data": "Joe Doe 1" }' 423 body='{ "data": "Joe Doe 1" }'
413 env = { "CONTENT_TYPE": "application/json", 424 env = { "CONTENT_TYPE": "application/json",
414 "CONTENT_LENGTH": len(body), 425 "CONTENT_LENGTH": len(body),
415 "REQUEST_METHOD": "PUT" 426 "REQUEST_METHOD": "PUT"
416 } 427 }
417 headers={"accept": "application/json", 428 headers={"accept": "application/json",
418 "content-type": env['CONTENT_TYPE'], 429 "content-type": env['CONTENT_TYPE'],
419 "etag": etag 430 "etag": etag
420 } 431 }
421 self.headers=headers
422 # we need to generate a FieldStorage the looks like 432 # we need to generate a FieldStorage the looks like
423 # FieldStorage(None, None, 'string') rather than 433 # FieldStorage(None, None, 'string') rather than
424 # FieldStorage(None, None, []) 434 # FieldStorage(None, None, [])
425 body_file=StringIO(body) # FieldStorage needs a file 435 body_file=BytesIO(s2b(body)) # FieldStorage needs a file
436 cgi.FieldStorage.make_file = self.make_file
426 form = cgi.FieldStorage(body_file, 437 form = cgi.FieldStorage(body_file,
427 headers=headers, 438 headers=headers,
428 environ=env) 439 environ=env)
429 self.server.client.request.headers.getheader=self.get_header 440 self.server.client.request.headers.update(headers) # set headers
430 results = self.server.dispatch('PUT', 441 results = self.server.dispatch('PUT',
431 "/rest/data/user/%s/realname"%self.joeid, 442 "/rest/data/user/%s/realname"%self.joeid,
432 form) 443 form)
433
434 self.assertEqual(self.server.client.response_code, 200) 444 self.assertEqual(self.server.client.response_code, 200)
435 results = self.server.get_element('user', self.joeid, self.empty_form) 445 results = self.server.get_element('user', self.joeid, self.empty_form)
436 self.assertEqual(self.dummy_client.response_code, 200) 446 self.assertEqual(self.dummy_client.response_code, 200)
437 self.assertEqual(results['data']['attributes']['realname'], 447 self.assertEqual(results['data']['attributes']['realname'],
438 'Joe Doe 1') 448 'Joe Doe 1')
439 del(self.headers) 449 self.server.client.request.headers.clear() # set headers
440 450
451 # TEST #2
441 # Set joe's 'realname' using json data. 452 # Set joe's 'realname' using json data.
442 # simulate: /rest/data/user/<id>/realname 453 # simulate: /rest/data/user/<id>/realname
443 # use etag in payload 454 # use etag in payload
444 etag = calculate_etag(self.db.user.getnode(self.joeid)) 455 etag = calculate_etag(self.db.user.getnode(self.joeid))
445 body=u'{ "@etag": "%s", "data": "Joe Doe 2" }'%etag 456 body='{ "@etag": "%s", "data": "Joe Doe 2" }'%etag
446 env = { "CONTENT_TYPE": "application/json", 457 env = { "CONTENT_TYPE": "application/json",
447 "CONTENT_LENGTH": len(body), 458 "CONTENT_LENGTH": len(body),
448 "REQUEST_METHOD": "PUT" 459 "REQUEST_METHOD": "PUT"
449 } 460 }
450 headers={"accept": "application/json", 461 headers={"accept": "application/json",
451 "content-type": env['CONTENT_TYPE'] 462 "content-type": env['CONTENT_TYPE']
452 } 463 }
453 self.headers=headers 464 body_file=BytesIO(s2b(body)) # FieldStorage needs a file
454 body_file=StringIO(body) # FieldStorage needs a file
455 form = cgi.FieldStorage(body_file, 465 form = cgi.FieldStorage(body_file,
456 headers=headers, 466 headers=headers,
457 environ=env) 467 environ=env)
458 self.server.client.request.headers.getheader=self.get_header 468 self.server.client.request.headers.update(headers) # set headers
459 results = self.server.dispatch('PUT', 469 results = self.server.dispatch('PUT',
460 "/rest/data/user/%s/realname"%self.joeid, 470 "/rest/data/user/%s/realname"%self.joeid,
461 form) 471 form)
462 472
463 self.assertEqual(self.server.client.response_code, 200) 473 self.assertEqual(self.server.client.response_code, 200)
464 results = self.server.get_element('user', self.joeid, self.empty_form) 474 results = self.server.get_element('user', self.joeid, self.empty_form)
465 self.assertEqual(self.dummy_client.response_code, 200) 475 self.assertEqual(self.dummy_client.response_code, 200)
466 self.assertEqual(results['data']['attributes']['realname'], 476 self.assertEqual(results['data']['attributes']['realname'],
467 'Joe Doe 2') 477 'Joe Doe 2')
468 del(self.headers) 478 self.server.client.request.headers.clear()
469 479
480 # TEST #3
470 # change Joe's realname via a normal web form 481 # change Joe's realname via a normal web form
471 # This generates a FieldStorage that looks like:
472 # FieldStorage(None, None, [])
473 # use etag from header 482 # use etag from header
474 # 483 #
475 # also use a GET on the uri via the dispatch to get 484 # Also do a GET using dispatch to get the results from the db.
476 # the results from the db.
477 etag = calculate_etag(self.db.user.getnode(self.joeid)) 485 etag = calculate_etag(self.db.user.getnode(self.joeid))
478 headers={"etag": etag, 486 headers={"etag": etag,
479 "accept": "application/json", 487 "accept": "application/json",
480 } 488 }
481 form = cgi.FieldStorage() 489 form = cgi.FieldStorage()
482 form.list = [ 490 form.list = [
483 cgi.MiniFieldStorage('data', 'Joe Doe'), 491 cgi.MiniFieldStorage('data', 'Joe Doe'),
484 ] 492 ]
485 self.headers = headers 493 self.server.client.request.headers.update(headers) # set headers
486 self.server.client.request.headers.getheader = self.get_header
487 results = self.server.dispatch('PUT', 494 results = self.server.dispatch('PUT',
488 "/rest/data/user/%s/realname"%self.joeid, 495 "/rest/data/user/%s/realname"%self.joeid,
489 form) 496 form)
490 self.assertEqual(self.dummy_client.response_code, 200) 497 self.assertEqual(self.dummy_client.response_code, 200)
491 results = self.server.dispatch('GET', 498 results = self.server.dispatch('GET',
492 "/rest/data/user/%s/realname"%self.joeid, 499 "/rest/data/user/%s/realname"%self.joeid,
493 self.empty_form) 500 self.empty_form)
494 self.assertEqual(self.dummy_client.response_code, 200) 501 self.assertEqual(self.dummy_client.response_code, 200)
495 json_dict = json.loads(results) 502
496 503 json_dict = json.loads(b2s(results))
497 self.assertEqual(json_dict['data']['data'], 'Joe Doe') 504 self.assertEqual(json_dict['data']['data'], 'Joe Doe')
498 self.assertEqual(json_dict['data']['link'], 505 self.assertEqual(json_dict['data']['link'],
499 "http://tracker.example/cgi-bin/" 506 "http://tracker.example/cgi-bin/"
500 "roundup.cgi/bugs/rest/data/user/3/realname") 507 "roundup.cgi/bugs/rest/data/user/3/realname")
501 self.assertEqual(json_dict['data']['type'], "<type 'str'>") 508 self.assertIn(json_dict['data']['type'], ("<class 'str'>",
509 "<type 'str'>"))
502 self.assertEqual(json_dict['data']["id"], "3") 510 self.assertEqual(json_dict['data']["id"], "3")
503 del(self.headers) 511 self.server.client.request.headers.clear()
504 512
505 513
506 # PATCH joe's email address with json 514 # TEST #4
515 # PATCH: joe's email address with json
507 # save address so we can use it later 516 # save address so we can use it later
508 stored_results = self.server.get_element('user', self.joeid, 517 stored_results = self.server.get_element('user', self.joeid,
509 self.empty_form) 518 self.empty_form)
510 self.assertEqual(self.dummy_client.response_code, 200) 519 self.assertEqual(self.dummy_client.response_code, 200)
511 520
512 etag = calculate_etag(self.db.user.getnode(self.joeid)) 521 etag = calculate_etag(self.db.user.getnode(self.joeid))
513 body=u'{ "address": "demo2@example.com", "@etag": "%s"}'%etag 522 body='{ "address": "demo2@example.com", "@etag": "%s"}'%etag
514 env = { "CONTENT_TYPE": "application/json", 523 env = { "CONTENT_TYPE": "application/json",
515 "CONTENT_LENGTH": len(body), 524 "CONTENT_LENGTH": len(body),
516 "REQUEST_METHOD": "PATCH" 525 "REQUEST_METHOD": "PATCH"
517 } 526 }
518 headers={"accept": "application/json", 527 headers={"accept": "application/json",
519 "content-type": env['CONTENT_TYPE'] 528 "content-type": env['CONTENT_TYPE']
520 } 529 }
521 self.headers=headers 530 body_file=BytesIO(s2b(body)) # FieldStorage needs a file
522 body_file=StringIO(body) # FieldStorage needs a file
523 form = cgi.FieldStorage(body_file, 531 form = cgi.FieldStorage(body_file,
524 headers=headers, 532 headers=headers,
525 environ=env) 533 environ=env)
526 self.server.client.request.headers.getheader=self.get_header 534 self.server.client.request.headers.update(headers) # set headers
527 results = self.server.dispatch('PATCH', 535 results = self.server.dispatch('PATCH',
528 "/rest/data/user/%s"%self.joeid, 536 "/rest/data/user/%s"%self.joeid,
529 form) 537 form)
530 538
531 self.assertEqual(self.server.client.response_code, 200) 539 self.assertEqual(self.server.client.response_code, 200)
532 results = self.server.get_element('user', self.joeid, self.empty_form) 540 results = self.server.get_element('user', self.joeid, self.empty_form)
533 self.assertEqual(self.dummy_client.response_code, 200) 541 self.assertEqual(self.dummy_client.response_code, 200)
534 self.assertEqual(results['data']['attributes']['address'], 542 self.assertEqual(results['data']['attributes']['address'],
535 'demo2@example.com') 543 'demo2@example.com')
536 544 # and set it back reusing env and headers from last test
537 # and set it back
538 etag = calculate_etag(self.db.user.getnode(self.joeid)) 545 etag = calculate_etag(self.db.user.getnode(self.joeid))
539 body=u'{ "address": "%s", "@etag": "%s"}'%( 546 body='{ "address": "%s", "@etag": "%s"}'%(
540 stored_results['data']['attributes']['address'], 547 stored_results['data']['attributes']['address'],
541 etag) 548 etag)
542 # reuse env and headers from prior test. 549 # reuse env and headers from prior test.
543 body_file=StringIO(body) # FieldStorage needs a file 550 body_file=BytesIO(s2b(body)) # FieldStorage needs a file
544 form = cgi.FieldStorage(body_file, 551 form = cgi.FieldStorage(body_file,
545 headers=headers, 552 headers=headers,
546 environ=env) 553 environ=env)
547 self.server.client.request.headers.getheader=self.get_header
548 results = self.server.dispatch('PATCH', 554 results = self.server.dispatch('PATCH',
549 "/rest/data/user/%s"%self.joeid, 555 "/rest/data/user/%s"%self.joeid,
550 form) 556 form)
551 557
552 self.assertEqual(self.server.client.response_code, 200) 558 self.assertEqual(self.server.client.response_code, 200)
553 results = self.server.get_element('user', self.joeid, self.empty_form) 559 results = self.server.get_element('user', self.joeid, self.empty_form)
554 self.assertEqual(self.dummy_client.response_code, 200) 560 self.assertEqual(self.dummy_client.response_code, 200)
555 self.assertEqual(results['data']['attributes']['address'], 561 self.assertEqual(results['data']['attributes']['address'],
556 'random@home.org') 562 'random@home.org')
557 del(self.headers) 563 self.server.client.request.headers.clear()
558 564
559 # POST to create new issue 565
560 body=u'{ "title": "foo bar", "priority": "critical" }' 566 # TEST #5
561 567 # POST: create new issue
568 # no etag needed
569 # FIXME at some point we probably want to implement
570 # Post Once Only, so we need to add a Post Once Exactly
571 # test and a resubmit as well.
572 etag = "not needed"
573 body='{ "title": "foo bar", "priority": "critical" }'
562 env = { "CONTENT_TYPE": "application/json", 574 env = { "CONTENT_TYPE": "application/json",
563 "CONTENT_LENGTH": len(body), 575 "CONTENT_LENGTH": len(body),
564 "REQUEST_METHOD": "POST" 576 "REQUEST_METHOD": "POST"
565 } 577 }
566 headers={"accept": "application/json", 578 headers={"accept": "application/json",
567 "content-type": env['CONTENT_TYPE'] 579 "content-type": env['CONTENT_TYPE']
568 } 580 }
569 self.headers=headers 581 body_file=BytesIO(s2b(body)) # FieldStorage needs a file
570 body_file=StringIO(body) # FieldStorage needs a file
571 form = cgi.FieldStorage(body_file, 582 form = cgi.FieldStorage(body_file,
572 headers=headers, 583 headers=headers,
573 environ=env) 584 environ=env)
574 self.server.client.request.headers.getheader=self.get_header 585 self.server.client.request.headers.update(headers) # set headers
575 results = self.server.dispatch('POST', 586 results = self.server.dispatch('POST',
576 "/rest/data/issue", 587 "/rest/data/issue",
577 form) 588 form)
578
579 self.assertEqual(self.server.client.response_code, 201) 589 self.assertEqual(self.server.client.response_code, 201)
580 json_dict = json.loads(results) 590 json_dict = json.loads(b2s(results))
581 issue_id=json_dict['data']['id'] 591 issue_id=json_dict['data']['id']
582 results = self.server.get_element('issue', 592 results = self.server.get_element('issue',
583 str(issue_id), # must be a string not unicode 593 str(issue_id), # must be a string not unicode
584 self.empty_form) 594 self.empty_form)
585 self.assertEqual(self.dummy_client.response_code, 200) 595 self.assertEqual(self.dummy_client.response_code, 200)
586 self.assertEqual(results['data']['attributes']['title'], 596 self.assertEqual(results['data']['attributes']['title'],
587 'foo bar') 597 'foo bar')
588 del(self.headers) 598 self.server.client.request.headers.clear()
599
600 # reset the make_file method in the class
601 cgi.FieldStorage.make_file = saved_make_file
589 602
590 def testPut(self): 603 def testPut(self):
591 """ 604 """
592 Change joe's 'realname' 605 Change joe's 'realname'
593 Check if we can't change admin's detail 606 Check if we can't change admin's detail
613 etag = calculate_etag(self.db.user.getnode(self.joeid)) 626 etag = calculate_etag(self.db.user.getnode(self.joeid))
614 form.list = [ 627 form.list = [
615 cgi.MiniFieldStorage('data', 'Joe Doe Doe'), 628 cgi.MiniFieldStorage('data', 'Joe Doe Doe'),
616 ] 629 ]
617 630
618 self.headers = {'etag': etag } # use etag in header 631 self.dummy_client.request.headers['ETag'] = etag # use etag in header
619 results = self.server.put_attribute( 632 results = self.server.put_attribute(
620 'user', self.joeid, 'realname', form 633 'user', self.joeid, 'realname', form
621 ) 634 )
622 self.assertEqual(self.dummy_client.response_code, 200) 635 self.assertEqual(self.dummy_client.response_code, 200)
623 results = self.server.get_attribute( 636 results = self.server.get_attribute(
624 'user', self.joeid, 'realname', self.empty_form 637 'user', self.joeid, 'realname', self.empty_form
625 ) 638 )
626 self.assertEqual(self.dummy_client.response_code, 200) 639 self.assertEqual(self.dummy_client.response_code, 200)
627 self.assertEqual(results['data']['data'], 'Joe Doe Doe') 640 self.assertEqual(results['data']['data'], 'Joe Doe Doe')
628 del(self.headers) 641 del(self.dummy_client.request.headers['etag'])
629 642
630 # Reset joe's 'realname'. etag in body 643 # Reset joe's 'realname'. etag in body
631 form = cgi.FieldStorage() 644 form = cgi.FieldStorage()
632 etag = calculate_etag(self.db.user.getnode(self.joeid)) 645 etag = calculate_etag(self.db.user.getnode(self.joeid))
633 form.list = [ 646 form.list = [

Roundup Issue Tracker: http://roundup-tracker.org/