1 # $Id: client.py,v 1.96 2003-02-20 07:13:14 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random
10 from roundup import roundupdb, date, hyperdb, password
11 from roundup.i18n import _
13 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
16 from roundup.cgi.PageTemplates import PageTemplate
18 class HTTPException(Exception):
19 pass
20 class Unauthorised(HTTPException):
21 pass
22 class NotFound(HTTPException):
23 pass
24 class Redirect(HTTPException):
25 pass
27 # XXX actually _use_ FormError
28 class FormError(ValueError):
29 ''' An "expected" exception occurred during form parsing.
30 - ie. something we know can go wrong, and don't want to alarm the
31 user with
33 We trap this at the user interface level and feed back a nice error
34 to the user.
35 '''
36 pass
38 class SendFile(Exception):
39 ''' Send a file from the database '''
41 class SendStaticFile(Exception):
42 ''' Send a static file from the instance html directory '''
44 def initialiseSecurity(security):
45 ''' Create some Permissions and Roles on the security object
47 This function is directly invoked by security.Security.__init__()
48 as a part of the Security object instantiation.
49 '''
50 security.addPermission(name="Web Registration",
51 description="User may register through the web")
52 p = security.addPermission(name="Web Access",
53 description="User may access the web interface")
54 security.addPermissionToRole('Admin', p)
56 # doing Role stuff through the web - make sure Admin can
57 p = security.addPermission(name="Web Roles",
58 description="User may manipulate user Roles through the web")
59 security.addPermissionToRole('Admin', p)
61 class Client:
62 ''' Instantiate to handle one CGI request.
64 See inner_main for request processing.
66 Client attributes at instantiation:
67 "path" is the PATH_INFO inside the instance (with no leading '/')
68 "base" is the base URL for the instance
69 "form" is the cgi form, an instance of FieldStorage from the standard
70 cgi module
71 "additional_headers" is a dictionary of additional HTTP headers that
72 should be sent to the client
73 "response_code" is the HTTP response code to send to the client
75 During the processing of a request, the following attributes are used:
76 "error_message" holds a list of error messages
77 "ok_message" holds a list of OK messages
78 "session" is the current user session id
79 "user" is the current user's name
80 "userid" is the current user's id
81 "template" is the current :template context
82 "classname" is the current class context name
83 "nodeid" is the current context item id
85 User Identification:
86 If the user has no login cookie, then they are anonymous and are logged
87 in as that user. This typically gives them all Permissions assigned to the
88 Anonymous Role.
90 Once a user logs in, they are assigned a session. The Client instance
91 keeps the nodeid of the session as the "session" attribute.
94 Special form variables:
95 Note that in various places throughout this code, special form
96 variables of the form :<name> are used. The colon (":") part may
97 actually be one of either ":" or "@".
98 '''
100 #
101 # special form variables
102 #
103 FV_TEMPLATE = re.compile(r'[@:]template')
104 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
105 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
107 # edit form variable handling (see unit tests)
108 FV_LABELS = r'''
109 ^(
110 (?P<note>[@:]note)|
111 (?P<file>[@:]file)|
112 (
113 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
114 ((?P<required>[@:]required$)| # :required
115 (
116 (
117 (?P<add>[@:]add[@:])| # :add:<prop>
118 (?P<remove>[@:]remove[@:])| # :remove:<prop>
119 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
120 (?P<link>[@:]link[@:])| # :link:<prop>
121 ([@:]) # just a separator
122 )?
123 (?P<propname>[^@:]+) # <prop>
124 )
125 )
126 )
127 )$'''
129 # Note: index page stuff doesn't appear here:
130 # columns, sort, sortdir, filter, group, groupdir, search_text,
131 # pagesize, startwith
133 def __init__(self, instance, request, env, form=None):
134 hyperdb.traceMark()
135 self.instance = instance
136 self.request = request
137 self.env = env
139 # save off the path
140 self.path = env['PATH_INFO']
142 # this is the base URL for this tracker
143 self.base = self.instance.config.TRACKER_WEB
145 # this is the "cookie path" for this tracker (ie. the path part of
146 # the "base" url)
147 self.cookie_path = urlparse.urlparse(self.base)[2]
148 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
149 self.instance.config.TRACKER_NAME)
151 # see if we need to re-parse the environment for the form (eg Zope)
152 if form is None:
153 self.form = cgi.FieldStorage(environ=env)
154 else:
155 self.form = form
157 # turn debugging on/off
158 try:
159 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
160 except ValueError:
161 # someone gave us a non-int debug level, turn it off
162 self.debug = 0
164 # flag to indicate that the HTTP headers have been sent
165 self.headers_done = 0
167 # additional headers to send with the request - must be registered
168 # before the first write
169 self.additional_headers = {}
170 self.response_code = 200
173 def main(self):
174 ''' Wrap the real main in a try/finally so we always close off the db.
175 '''
176 try:
177 self.inner_main()
178 finally:
179 if hasattr(self, 'db'):
180 self.db.close()
182 def inner_main(self):
183 ''' Process a request.
185 The most common requests are handled like so:
186 1. figure out who we are, defaulting to the "anonymous" user
187 see determine_user
188 2. figure out what the request is for - the context
189 see determine_context
190 3. handle any requested action (item edit, search, ...)
191 see handle_action
192 4. render a template, resulting in HTML output
194 In some situations, exceptions occur:
195 - HTTP Redirect (generally raised by an action)
196 - SendFile (generally raised by determine_context)
197 serve up a FileClass "content" property
198 - SendStaticFile (generally raised by determine_context)
199 serve up a file from the tracker "html" directory
200 - Unauthorised (generally raised by an action)
201 the action is cancelled, the request is rendered and an error
202 message is displayed indicating that permission was not
203 granted for the action to take place
204 - NotFound (raised wherever it needs to be)
205 percolates up to the CGI interface that called the client
206 '''
207 self.ok_message = []
208 self.error_message = []
209 try:
210 # make sure we're identified (even anonymously)
211 self.determine_user()
212 # figure out the context and desired content template
213 self.determine_context()
214 # possibly handle a form submit action (may change self.classname
215 # and self.template, and may also append error/ok_messages)
216 self.handle_action()
217 # now render the page
219 # we don't want clients caching our dynamic pages
220 self.additional_headers['Cache-Control'] = 'no-cache'
221 self.additional_headers['Pragma'] = 'no-cache'
222 self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
224 # render the content
225 self.write(self.renderContext())
226 except Redirect, url:
227 # let's redirect - if the url isn't None, then we need to do
228 # the headers, otherwise the headers have been set before the
229 # exception was raised
230 if url:
231 self.additional_headers['Location'] = url
232 self.response_code = 302
233 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
234 except SendFile, designator:
235 self.serve_file(designator)
236 except SendStaticFile, file:
237 self.serve_static_file(str(file))
238 except Unauthorised, message:
239 self.classname=None
240 self.template=''
241 self.error_message.append(message)
242 self.write(self.renderContext())
243 except NotFound:
244 # pass through
245 raise
246 except:
247 # everything else
248 self.write(cgitb.html())
250 def clean_sessions(self):
251 '''age sessions, remove when they haven't been used for a week.
252 Do it only once an hour'''
253 sessions = self.db.sessions
254 last_clean = sessions.get('last_clean', 'last_use') or 0
256 week = 60*60*24*7
257 hour = 60*60
258 now = time.time()
259 if now - last_clean > hour:
260 # remove age sessions
261 for sessid in sessions.list():
262 interval = now - sessions.get(sessid, 'last_use')
263 if interval > week:
264 sessions.destroy(sessid)
265 sessions.set('last_clean', last_use=time.time())
267 def determine_user(self):
268 ''' Determine who the user is
269 '''
270 # determine the uid to use
271 self.opendb('admin')
272 # clean age sessions
273 self.clean_sessions()
274 # make sure we have the session Class
275 sessions = self.db.sessions
277 # look up the user session cookie
278 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
279 user = 'anonymous'
281 # bump the "revision" of the cookie since the format changed
282 if (cookie.has_key(self.cookie_name) and
283 cookie[self.cookie_name].value != 'deleted'):
285 # get the session key from the cookie
286 self.session = cookie[self.cookie_name].value
287 # get the user from the session
288 try:
289 # update the lifetime datestamp
290 sessions.set(self.session, last_use=time.time())
291 sessions.commit()
292 user = sessions.get(self.session, 'user')
293 except KeyError:
294 user = 'anonymous'
296 # sanity check on the user still being valid, getting the userid
297 # at the same time
298 try:
299 self.userid = self.db.user.lookup(user)
300 except (KeyError, TypeError):
301 user = 'anonymous'
303 # make sure the anonymous user is valid if we're using it
304 if user == 'anonymous':
305 self.make_user_anonymous()
306 else:
307 self.user = user
309 # reopen the database as the correct user
310 self.opendb(self.user)
312 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
313 ''' Determine the context of this page from the URL:
315 The URL path after the instance identifier is examined. The path
316 is generally only one entry long.
318 - if there is no path, then we are in the "home" context.
319 * if the path is "_file", then the additional path entry
320 specifies the filename of a static file we're to serve up
321 from the instance "html" directory. Raises a SendStaticFile
322 exception.
323 - if there is something in the path (eg "issue"), it identifies
324 the tracker class we're to display.
325 - if the path is an item designator (eg "issue123"), then we're
326 to display a specific item.
327 * if the path starts with an item designator and is longer than
328 one entry, then we're assumed to be handling an item of a
329 FileClass, and the extra path information gives the filename
330 that the client is going to label the download with (ie
331 "file123/image.png" is nicer to download than "file123"). This
332 raises a SendFile exception.
334 Both of the "*" types of contexts stop before we bother to
335 determine the template we're going to use. That's because they
336 don't actually use templates.
338 The template used is specified by the :template CGI variable,
339 which defaults to:
341 only classname suplied: "index"
342 full item designator supplied: "item"
344 We set:
345 self.classname - the class to display, can be None
346 self.template - the template to render the current context with
347 self.nodeid - the nodeid of the class we're displaying
348 '''
349 # default the optional variables
350 self.classname = None
351 self.nodeid = None
353 # see if a template or messages are specified
354 template_override = ok_message = error_message = None
355 for key in self.form.keys():
356 if self.FV_TEMPLATE.match(key):
357 template_override = self.form[key].value
358 elif self.FV_OK_MESSAGE.match(key):
359 ok_message = self.form[key].value
360 elif self.FV_ERROR_MESSAGE.match(key):
361 error_message = self.form[key].value
363 # determine the classname and possibly nodeid
364 path = self.path.split('/')
365 if not path or path[0] in ('', 'home', 'index'):
366 if template_override is not None:
367 self.template = template_override
368 else:
369 self.template = ''
370 return
371 elif path[0] == '_file':
372 raise SendStaticFile, os.path.join(*path[1:])
373 else:
374 self.classname = path[0]
375 if len(path) > 1:
376 # send the file identified by the designator in path[0]
377 raise SendFile, path[0]
379 # see if we got a designator
380 m = dre.match(self.classname)
381 if m:
382 self.classname = m.group(1)
383 self.nodeid = m.group(2)
384 if not self.db.getclass(self.classname).hasnode(self.nodeid):
385 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
386 # with a designator, we default to item view
387 self.template = 'item'
388 else:
389 # with only a class, we default to index view
390 self.template = 'index'
392 # make sure the classname is valid
393 try:
394 self.db.getclass(self.classname)
395 except KeyError:
396 raise NotFound, self.classname
398 # see if we have a template override
399 if template_override is not None:
400 self.template = template_override
402 # see if we were passed in a message
403 if ok_message:
404 self.ok_message.append(ok_message)
405 if error_message:
406 self.error_message.append(error_message)
408 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
409 ''' Serve the file from the content property of the designated item.
410 '''
411 m = dre.match(str(designator))
412 if not m:
413 raise NotFound, str(designator)
414 classname, nodeid = m.group(1), m.group(2)
415 if classname != 'file':
416 raise NotFound, designator
418 # we just want to serve up the file named
419 file = self.db.file
420 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
421 self.write(file.get(nodeid, 'content'))
423 def serve_static_file(self, file):
424 # we just want to serve up the file named
425 mt = mimetypes.guess_type(str(file))[0]
426 self.additional_headers['Content-Type'] = mt
427 self.write(open(os.path.join(self.instance.config.TEMPLATES,
428 file)).read())
430 def renderContext(self):
431 ''' Return a PageTemplate for the named page
432 '''
433 name = self.classname
434 extension = self.template
435 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
437 # catch errors so we can handle PT rendering errors more nicely
438 args = {
439 'ok_message': self.ok_message,
440 'error_message': self.error_message
441 }
442 try:
443 # let the template render figure stuff out
444 return pt.render(self, None, None, **args)
445 except NoTemplate, message:
446 return '<strong>%s</strong>'%message
447 except:
448 # everything else
449 return cgitb.pt_html()
451 # these are the actions that are available
452 actions = (
453 ('edit', 'editItemAction'),
454 ('editCSV', 'editCSVAction'),
455 ('new', 'newItemAction'),
456 ('register', 'registerAction'),
457 ('login', 'loginAction'),
458 ('logout', 'logout_action'),
459 ('search', 'searchAction'),
460 ('retire', 'retireAction'),
461 ('show', 'showAction'),
462 )
463 def handle_action(self):
464 ''' Determine whether there should be an _action called.
466 The action is defined by the form variable :action which
467 identifies the method on this object to call. The four basic
468 actions are defined in the "actions" sequence on this class:
469 "edit" -> self.editItemAction
470 "new" -> self.newItemAction
471 "register" -> self.registerAction
472 "login" -> self.loginAction
473 "logout" -> self.logout_action
474 "search" -> self.searchAction
475 "retire" -> self.retireAction
476 '''
477 if not self.form.has_key(':action'):
478 return None
479 try:
480 # get the action, validate it
481 action = self.form[':action'].value
482 for name, method in self.actions:
483 if name == action:
484 break
485 else:
486 raise ValueError, 'No such action "%s"'%action
488 # call the mapped action
489 getattr(self, method)()
490 except Redirect:
491 raise
492 except Unauthorised:
493 raise
494 except:
495 self.db.rollback()
496 s = StringIO.StringIO()
497 traceback.print_exc(None, s)
498 self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
500 def write(self, content):
501 if not self.headers_done:
502 self.header()
503 self.request.wfile.write(content)
505 def header(self, headers=None, response=None):
506 '''Put up the appropriate header.
507 '''
508 if headers is None:
509 headers = {'Content-Type':'text/html'}
510 if response is None:
511 response = self.response_code
513 # update with additional info
514 headers.update(self.additional_headers)
516 if not headers.has_key('Content-Type'):
517 headers['Content-Type'] = 'text/html'
518 self.request.send_response(response)
519 for entry in headers.items():
520 self.request.send_header(*entry)
521 self.request.end_headers()
522 self.headers_done = 1
523 if self.debug:
524 self.headers_sent = headers
526 def set_cookie(self, user):
527 ''' Set up a session cookie for the user and store away the user's
528 login info against the session.
529 '''
530 # TODO generate a much, much stronger session key ;)
531 self.session = binascii.b2a_base64(repr(random.random())).strip()
533 # clean up the base64
534 if self.session[-1] == '=':
535 if self.session[-2] == '=':
536 self.session = self.session[:-2]
537 else:
538 self.session = self.session[:-1]
540 # insert the session in the sessiondb
541 self.db.sessions.set(self.session, user=user, last_use=time.time())
543 # and commit immediately
544 self.db.sessions.commit()
546 # expire us in a long, long time
547 expire = Cookie._getdate(86400*365)
549 # generate the cookie path - make sure it has a trailing '/'
550 self.additional_headers['Set-Cookie'] = \
551 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
552 expire, self.cookie_path)
554 def make_user_anonymous(self):
555 ''' Make us anonymous
557 This method used to handle non-existence of the 'anonymous'
558 user, but that user is mandatory now.
559 '''
560 self.userid = self.db.user.lookup('anonymous')
561 self.user = 'anonymous'
563 def opendb(self, user):
564 ''' Open the database.
565 '''
566 # open the db if the user has changed
567 if not hasattr(self, 'db') or user != self.db.journaltag:
568 if hasattr(self, 'db'):
569 self.db.close()
570 self.db = self.instance.open(user)
572 #
573 # Actions
574 #
575 def loginAction(self):
576 ''' Attempt to log a user in.
578 Sets up a session for the user which contains the login
579 credentials.
580 '''
581 # we need the username at a minimum
582 if not self.form.has_key('__login_name'):
583 self.error_message.append(_('Username required'))
584 return
586 # get the login info
587 self.user = self.form['__login_name'].value
588 if self.form.has_key('__login_password'):
589 password = self.form['__login_password'].value
590 else:
591 password = ''
593 # make sure the user exists
594 try:
595 self.userid = self.db.user.lookup(self.user)
596 except KeyError:
597 name = self.user
598 self.error_message.append(_('No such user "%(name)s"')%locals())
599 self.make_user_anonymous()
600 return
602 # verify the password
603 if not self.verifyPassword(self.userid, password):
604 self.make_user_anonymous()
605 self.error_message.append(_('Incorrect password'))
606 return
608 # make sure we're allowed to be here
609 if not self.loginPermission():
610 self.make_user_anonymous()
611 self.error_message.append(_("You do not have permission to login"))
612 return
614 # now we're OK, re-open the database for real, using the user
615 self.opendb(self.user)
617 # set the session cookie
618 self.set_cookie(self.user)
620 def verifyPassword(self, userid, password):
621 ''' Verify the password that the user has supplied
622 '''
623 stored = self.db.user.get(self.userid, 'password')
624 if password == stored:
625 return 1
626 if not password and not stored:
627 return 1
628 return 0
630 def loginPermission(self):
631 ''' Determine whether the user has permission to log in.
633 Base behaviour is to check the user has "Web Access".
634 '''
635 if not self.db.security.hasPermission('Web Access', self.userid):
636 return 0
637 return 1
639 def logout_action(self):
640 ''' Make us really anonymous - nuke the cookie too
641 '''
642 # log us out
643 self.make_user_anonymous()
645 # construct the logout cookie
646 now = Cookie._getdate()
647 self.additional_headers['Set-Cookie'] = \
648 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
649 now, self.cookie_path)
651 # Let the user know what's going on
652 self.ok_message.append(_('You are logged out'))
654 def registerAction(self):
655 '''Attempt to create a new user based on the contents of the form
656 and then set the cookie.
658 return 1 on successful login
659 '''
660 # create the new user
661 cl = self.db.user
663 # parse the props from the form
664 try:
665 props = self.parsePropsFromForm()
666 except (ValueError, KeyError), message:
667 self.error_message.append(_('Error: ') + str(message))
668 return
670 # make sure we're allowed to register
671 if not self.registerPermission(props):
672 raise Unauthorised, _("You do not have permission to register")
674 # re-open the database as "admin"
675 if self.user != 'admin':
676 self.opendb('admin')
678 # create the new user
679 cl = self.db.user
680 try:
681 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
682 self.userid = cl.create(**props['user'])
683 self.db.commit()
684 except (ValueError, KeyError), message:
685 self.error_message.append(message)
686 return
688 # log the new user in
689 self.user = cl.get(self.userid, 'username')
690 # re-open the database for real, using the user
691 self.opendb(self.user)
693 # if we have a session, update it
694 if hasattr(self, 'session'):
695 self.db.sessions.set(self.session, user=self.user,
696 last_use=time.time())
697 else:
698 # new session cookie
699 self.set_cookie(self.user)
701 # nice message
702 message = _('You are now registered, welcome!')
704 # redirect to the item's edit page
705 raise Redirect, '%s%s%s?+ok_message=%s'%(
706 self.base, self.classname, self.userid, urllib.quote(message))
708 def registerPermission(self, props):
709 ''' Determine whether the user has permission to register
711 Base behaviour is to check the user has "Web Registration".
712 '''
713 # registration isn't allowed to supply roles
714 if props.has_key('roles'):
715 return 0
716 if self.db.security.hasPermission('Web Registration', self.userid):
717 return 1
718 return 0
720 def editItemAction(self):
721 ''' Perform an edit of an item in the database.
723 See parsePropsFromForm and _editnodes for special variables
724 '''
725 # parse the props from the form
726 if 1:
727 # try:
728 props, links = self.parsePropsFromForm()
729 # except (ValueError, KeyError), message:
730 # self.error_message.append(_('Error: ') + str(message))
731 # return
733 # handle the props
734 if 1:
735 # try:
736 message = self._editnodes(props, links)
737 # except (ValueError, KeyError, IndexError), message:
738 # self.error_message.append(_('Error: ') + str(message))
739 # return
741 # commit now that all the tricky stuff is done
742 self.db.commit()
744 # redirect to the item's edit page
745 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
746 self.nodeid, urllib.quote(message))
748 def editItemPermission(self, props):
749 ''' Determine whether the user has permission to edit this item.
751 Base behaviour is to check the user can edit this class. If we're
752 editing the "user" class, users are allowed to edit their own
753 details. Unless it's the "roles" property, which requires the
754 special Permission "Web Roles".
755 '''
756 # if this is a user node and the user is editing their own node, then
757 # we're OK
758 has = self.db.security.hasPermission
759 if self.classname == 'user':
760 # reject if someone's trying to edit "roles" and doesn't have the
761 # right permission.
762 if props.has_key('roles') and not has('Web Roles', self.userid,
763 'user'):
764 return 0
765 # if the item being edited is the current user, we're ok
766 if self.nodeid == self.userid:
767 return 1
768 if self.db.security.hasPermission('Edit', self.userid, self.classname):
769 return 1
770 return 0
772 def newItemAction(self):
773 ''' Add a new item to the database.
775 This follows the same form as the editItemAction, with the same
776 special form values.
777 '''
778 # parse the props from the form
779 # XXX reinstate exception handling
780 # try:
781 if 1:
782 props, links = self.parsePropsFromForm()
783 # except (ValueError, KeyError), message:
784 # self.error_message.append(_('Error: ') + str(message))
785 # return
787 # handle the props - edit or create
788 # XXX reinstate exception handling
789 # try:
790 if 1:
791 # create the context here
792 # cn = self.classname
793 # nid = self._createnode(cn, props[(cn, None)])
794 # del props[(cn, None)]
796 # when it hits the None element, it'll set self.nodeid
797 messages = self._editnodes(props, links) #, {(cn, None): nid})
799 # except (ValueError, KeyError, IndexError), message:
800 # # these errors might just be indicative of user dumbness
801 # self.error_message.append(_('Error: ') + str(message))
802 # return
804 # commit now that all the tricky stuff is done
805 self.db.commit()
807 # redirect to the new item's page
808 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
809 self.nodeid, urllib.quote(messages))
811 def newItemPermission(self, props):
812 ''' Determine whether the user has permission to create (edit) this
813 item.
815 Base behaviour is to check the user can edit this class. No
816 additional property checks are made. Additionally, new user items
817 may be created if the user has the "Web Registration" Permission.
818 '''
819 has = self.db.security.hasPermission
820 if self.classname == 'user' and has('Web Registration', self.userid,
821 'user'):
822 return 1
823 if has('Edit', self.userid, self.classname):
824 return 1
825 return 0
828 #
829 # Utility methods for editing
830 #
831 def _editnodes(self, all_props, all_links, newids=None):
832 ''' Use the props in all_props to perform edit and creation, then
833 use the link specs in all_links to do linking.
834 '''
835 # print '='*75
836 # print 'ALL_PROPS', all_props
837 # figure dependencies and re-work links
838 deps = {}
839 links = {}
840 for cn, nodeid, propname, vlist in all_links:
841 if not all_props.has_key((cn, nodeid)):
842 # link item to link to doesn't (and won't) exist
843 continue
844 for value in vlist:
845 if not all_props.has_key(value):
846 # link item to link to doesn't (and won't) exist
847 continue
848 deps.setdefault((cn, nodeid), []).append(value)
849 links.setdefault(value, []).append((cn, nodeid, propname))
851 # print '*'*75
852 # print 'LINKS', links
853 # print 'DEPS', deps
855 # figure chained dependencies ordering
856 order = []
857 done = {}
858 # loop detection
859 change = 0
860 while len(all_props) != len(done):
861 for needed in all_props.keys():
862 if done.has_key(needed):
863 continue
864 tlist = deps.get(needed, [])
865 # print 'SOLVING', needed, tlist
866 for target in tlist:
867 if not done.has_key(target):
868 break
869 else:
870 # print 'DONE', needed
871 done[needed] = 1
872 order.append(needed)
873 change = 1
874 if not change:
875 raise ValueError, 'linking must not loop!'
877 # now, edit / create
878 m = []
879 for needed in order:
880 props = all_props[needed]
881 if not props:
882 # nothing to do
883 continue
884 cn, nodeid = needed
886 if nodeid is not None and int(nodeid) > 0:
887 # make changes to the node
888 props = self._changenode(cn, nodeid, props)
890 # and some nice feedback for the user
891 if props:
892 info = ', '.join(props.keys())
893 m.append('%s %s %s edited ok'%(cn, nodeid, info))
894 else:
895 m.append('%s %s - nothing changed'%(cn, nodeid))
896 else:
897 assert props
899 # make a new node
900 newid = self._createnode(cn, props)
901 if nodeid is None:
902 self.nodeid = newid
903 nodeid = newid
905 # and some nice feedback for the user
906 m.append('%s %s created'%(cn, newid))
908 # fill in new ids in links
909 if links.has_key(needed):
910 for linkcn, linkid, linkprop in links[needed]:
911 props = all_props[(linkcn, linkid)]
912 cl = self.db.classes[linkcn]
913 propdef = cl.getprops()[linkprop]
914 if not props.has_key(linkprop):
915 if linkid is None or linkid.startswith('-'):
916 # linking to a new item
917 if isinstance(propdef, hyperdb.Multilink):
918 props[linkprop] = [newid]
919 else:
920 props[linkprop] = newid
921 else:
922 # linking to an existing item
923 if isinstance(propdef, hyperdb.Multilink):
924 existing = cl.get(linkid, linkprop)[:]
925 existing.append(nodeid)
926 props[linkprop] = existing
927 else:
928 props[linkprop] = newid
930 return '<br>'.join(m)
932 def _changenode(self, cn, nodeid, props):
933 ''' change the node based on the contents of the form
934 '''
935 # check for permission
936 if not self.editItemPermission(props):
937 raise PermissionError, 'You do not have permission to edit %s'%cn
939 # make the changes
940 cl = self.db.classes[cn]
941 return cl.set(nodeid, **props)
943 def _createnode(self, cn, props):
944 ''' create a node based on the contents of the form
945 '''
946 # check for permission
947 if not self.newItemPermission(props):
948 raise PermissionError, 'You do not have permission to create %s'%cn
950 # create the node and return its id
951 cl = self.db.classes[cn]
952 return cl.create(**props)
954 #
955 # More actions
956 #
957 def editCSVAction(self):
958 ''' Performs an edit of all of a class' items in one go.
960 The "rows" CGI var defines the CSV-formatted entries for the
961 class. New nodes are identified by the ID 'X' (or any other
962 non-existent ID) and removed lines are retired.
963 '''
964 # this is per-class only
965 if not self.editCSVPermission():
966 self.error_message.append(
967 _('You do not have permission to edit %s' %self.classname))
969 # get the CSV module
970 try:
971 import csv
972 except ImportError:
973 self.error_message.append(_(
974 'Sorry, you need the csv module to use this function.<br>\n'
975 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
976 return
978 cl = self.db.classes[self.classname]
979 idlessprops = cl.getprops(protected=0).keys()
980 idlessprops.sort()
981 props = ['id'] + idlessprops
983 # do the edit
984 rows = self.form['rows'].value.splitlines()
985 p = csv.parser()
986 found = {}
987 line = 0
988 for row in rows[1:]:
989 line += 1
990 values = p.parse(row)
991 # not a complete row, keep going
992 if not values: continue
994 # skip property names header
995 if values == props:
996 continue
998 # extract the nodeid
999 nodeid, values = values[0], values[1:]
1000 found[nodeid] = 1
1002 # confirm correct weight
1003 if len(idlessprops) != len(values):
1004 self.error_message.append(
1005 _('Not enough values on line %(line)s')%{'line':line})
1006 return
1008 # extract the new values
1009 d = {}
1010 for name, value in zip(idlessprops, values):
1011 value = value.strip()
1012 # only add the property if it has a value
1013 if value:
1014 # if it's a multilink, split it
1015 if isinstance(cl.properties[name], hyperdb.Multilink):
1016 value = value.split(':')
1017 d[name] = value
1019 # perform the edit
1020 if cl.hasnode(nodeid):
1021 # edit existing
1022 cl.set(nodeid, **d)
1023 else:
1024 # new node
1025 found[cl.create(**d)] = 1
1027 # retire the removed entries
1028 for nodeid in cl.list():
1029 if not found.has_key(nodeid):
1030 cl.retire(nodeid)
1032 # all OK
1033 self.db.commit()
1035 self.ok_message.append(_('Items edited OK'))
1037 def editCSVPermission(self):
1038 ''' Determine whether the user has permission to edit this class.
1040 Base behaviour is to check the user can edit this class.
1041 '''
1042 if not self.db.security.hasPermission('Edit', self.userid,
1043 self.classname):
1044 return 0
1045 return 1
1047 def searchAction(self):
1048 ''' Mangle some of the form variables.
1050 Set the form ":filter" variable based on the values of the
1051 filter variables - if they're set to anything other than
1052 "dontcare" then add them to :filter.
1054 Also handle the ":queryname" variable and save off the query to
1055 the user's query list.
1056 '''
1057 # generic edit is per-class only
1058 if not self.searchPermission():
1059 self.error_message.append(
1060 _('You do not have permission to search %s' %self.classname))
1062 # add a faked :filter form variable for each filtering prop
1063 # XXX migrate to new : @ +
1064 props = self.db.classes[self.classname].getprops()
1065 for key in self.form.keys():
1066 if not props.has_key(key): continue
1067 if isinstance(self.form[key], type([])):
1068 # search for at least one entry which is not empty
1069 for minifield in self.form[key]:
1070 if minifield.value:
1071 break
1072 else:
1073 continue
1074 else:
1075 if not self.form[key].value: continue
1076 self.form.value.append(cgi.MiniFieldStorage(':filter', key))
1078 # handle saving the query params
1079 if self.form.has_key(':queryname'):
1080 queryname = self.form[':queryname'].value.strip()
1081 if queryname:
1082 # parse the environment and figure what the query _is_
1083 req = HTMLRequest(self)
1084 url = req.indexargs_href('', {})
1086 # handle editing an existing query
1087 try:
1088 qid = self.db.query.lookup(queryname)
1089 self.db.query.set(qid, klass=self.classname, url=url)
1090 except KeyError:
1091 # create a query
1092 qid = self.db.query.create(name=queryname,
1093 klass=self.classname, url=url)
1095 # and add it to the user's query multilink
1096 queries = self.db.user.get(self.userid, 'queries')
1097 queries.append(qid)
1098 self.db.user.set(self.userid, queries=queries)
1100 # commit the query change to the database
1101 self.db.commit()
1103 def searchPermission(self):
1104 ''' Determine whether the user has permission to search this class.
1106 Base behaviour is to check the user can view this class.
1107 '''
1108 if not self.db.security.hasPermission('View', self.userid,
1109 self.classname):
1110 return 0
1111 return 1
1114 def retireAction(self):
1115 ''' Retire the context item.
1116 '''
1117 # if we want to view the index template now, then unset the nodeid
1118 # context info (a special-case for retire actions on the index page)
1119 nodeid = self.nodeid
1120 if self.template == 'index':
1121 self.nodeid = None
1123 # generic edit is per-class only
1124 if not self.retirePermission():
1125 self.error_message.append(
1126 _('You do not have permission to retire %s' %self.classname))
1127 return
1129 # make sure we don't try to retire admin or anonymous
1130 if self.classname == 'user' and \
1131 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1132 self.error_message.append(
1133 _('You may not retire the admin or anonymous user'))
1134 return
1136 # do the retire
1137 self.db.getclass(self.classname).retire(nodeid)
1138 self.db.commit()
1140 self.ok_message.append(
1141 _('%(classname)s %(itemid)s has been retired')%{
1142 'classname': self.classname.capitalize(), 'itemid': nodeid})
1144 def retirePermission(self):
1145 ''' Determine whether the user has permission to retire this class.
1147 Base behaviour is to check the user can edit this class.
1148 '''
1149 if not self.db.security.hasPermission('Edit', self.userid,
1150 self.classname):
1151 return 0
1152 return 1
1155 def showAction(self):
1156 ''' Show a node
1157 '''
1158 # XXX allow : @ +
1159 t = self.form[':type'].value
1160 n = self.form[':number'].value
1161 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1162 raise Redirect, url
1164 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1165 ''' Pull properties out of the form.
1167 In the following, <bracketed> values are variable, ":" may be
1168 one of ":" or "@", and other text "required" is fixed.
1170 Properties are specified as form variables:
1172 <propname>
1173 - property on the current context item
1175 <designator>:<propname>
1176 - property on the indicated item
1178 <classname>-<N>:<propname>
1179 - property on the Nth new item of classname
1181 Once we have determined the "propname", we check to see if it
1182 is one of the special form values:
1184 :required
1185 The named property values must be supplied or a ValueError
1186 will be raised.
1188 :remove:<propname>=id(s)
1189 The ids will be removed from the multilink property.
1191 :add:<propname>=id(s)
1192 The ids will be added to the multilink property.
1194 :link:<propname>=<designator>
1195 Used to add a link to new items created during edit.
1196 These are collected up and returned in all_links. This will
1197 result in an additional linking operation (either Link set or
1198 Multilink append) after the edit/create is done using
1199 all_props in _editnodes. The <propname> on the current item
1200 will be set/appended the id of the newly created item of
1201 class <designator> (where <designator> must be
1202 <classname>-<N>).
1204 Any of the form variables may be prefixed with a classname or
1205 designator.
1207 The return from this method is a dict of
1208 (classname, id): properties
1209 ... this dict _always_ has an entry for the current context,
1210 even if it's empty (ie. a submission for an existing issue that
1211 doesn't result in any changes would return {('issue','123'): {}})
1212 The id may be None, which indicates that an item should be
1213 created.
1215 If a String property's form value is a file upload, then we
1216 try to set additional properties "filename" and "type" (if
1217 they are valid for the class).
1219 Two special form values are supported for backwards
1220 compatibility:
1221 :note - create a message (with content, author and date), link
1222 to the context item. This is ALWAYS desginated "msg-1".
1223 :file - create a file, attach to the current item and any
1224 message created by :note. This is ALWAYS designated
1225 "file-1".
1227 We also check that FileClass items have a "content" property with
1228 actual content, otherwise we remove them from all_props before
1229 returning.
1230 '''
1231 # some very useful variables
1232 db = self.db
1233 form = self.form
1235 if not hasattr(self, 'FV_SPECIAL'):
1236 # generate the regexp for handling special form values
1237 classes = '|'.join(db.classes.keys())
1238 # specials for parsePropsFromForm
1239 # handle the various forms (see unit tests)
1240 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1241 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1243 # these indicate the default class / item
1244 default_cn = self.classname
1245 default_cl = self.db.classes[default_cn]
1246 default_nodeid = self.nodeid
1248 # we'll store info about the individual class/item edit in these
1249 all_required = {} # one entry per class/item
1250 all_props = {} # one entry per class/item
1251 all_propdef = {} # note - only one entry per class
1252 all_links = [] # as many as are required
1254 # we should always return something, even empty, for the context
1255 all_props[(default_cn, default_nodeid)] = {}
1257 keys = form.keys()
1258 timezone = db.getUserTimezone()
1260 # sentinels for the :note and :file props
1261 have_note = have_file = 0
1263 # extract the usable form labels from the form
1264 matches = []
1265 for key in keys:
1266 m = self.FV_SPECIAL.match(key)
1267 if m:
1268 matches.append((key, m.groupdict()))
1270 # now handle the matches
1271 for key, d in matches:
1272 if d['classname']:
1273 # we got a designator
1274 cn = d['classname']
1275 cl = self.db.classes[cn]
1276 nodeid = d['id']
1277 propname = d['propname']
1278 elif d['note']:
1279 # the special note field
1280 cn = 'msg'
1281 cl = self.db.classes[cn]
1282 nodeid = '-1'
1283 propname = 'content'
1284 all_links.append((default_cn, default_nodeid, 'messages',
1285 [('msg', '-1')]))
1286 have_note = 1
1287 elif d['file']:
1288 # the special file field
1289 cn = 'file'
1290 cl = self.db.classes[cn]
1291 nodeid = '-1'
1292 propname = 'content'
1293 all_links.append((default_cn, default_nodeid, 'files',
1294 [('file', '-1')]))
1295 have_file = 1
1296 else:
1297 # default
1298 cn = default_cn
1299 cl = default_cl
1300 nodeid = default_nodeid
1301 propname = d['propname']
1303 # the thing this value relates to is...
1304 this = (cn, nodeid)
1306 # get more info about the class, and the current set of
1307 # form props for it
1308 if not all_propdef.has_key(cn):
1309 all_propdef[cn] = cl.getprops()
1310 propdef = all_propdef[cn]
1311 if not all_props.has_key(this):
1312 all_props[this] = {}
1313 props = all_props[this]
1315 # is this a link command?
1316 if d['link']:
1317 value = []
1318 for entry in extractFormList(form[key]):
1319 m = self.FV_DESIGNATOR.match(entry)
1320 if not m:
1321 raise ValueError, \
1322 'link "%s" value "%s" not a designator'%(key, entry)
1323 value.append((m.group(1), m.group(2)))
1325 # make sure the link property is valid
1326 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1327 not isinstance(propdef[propname], hyperdb.Link)):
1328 raise ValueError, '%s %s is not a link or '\
1329 'multilink property'%(cn, propname)
1331 all_links.append((cn, nodeid, propname, value))
1332 continue
1334 # detect the special ":required" variable
1335 if d['required']:
1336 all_required[this] = extractFormList(form[key])
1337 continue
1339 # get the required values list
1340 if not all_required.has_key(this):
1341 all_required[this] = []
1342 required = all_required[this]
1344 # see if we're performing a special multilink action
1345 mlaction = 'set'
1346 if d['remove']:
1347 mlaction = 'remove'
1348 elif d['add']:
1349 mlaction = 'add'
1351 # does the property exist?
1352 if not propdef.has_key(propname):
1353 if mlaction != 'set':
1354 raise ValueError, 'You have submitted a %s action for'\
1355 ' the property "%s" which doesn\'t exist'%(mlaction,
1356 propname)
1357 # the form element is probably just something we don't care
1358 # about - ignore it
1359 continue
1360 proptype = propdef[propname]
1362 # Get the form value. This value may be a MiniFieldStorage or a list
1363 # of MiniFieldStorages.
1364 value = form[key]
1366 # handle unpacking of the MiniFieldStorage / list form value
1367 if isinstance(proptype, hyperdb.Multilink):
1368 value = extractFormList(value)
1369 else:
1370 # multiple values are not OK
1371 if isinstance(value, type([])):
1372 raise ValueError, 'You have submitted more than one value'\
1373 ' for the %s property'%propname
1374 # value might be a file upload...
1375 if not hasattr(value, 'filename') or value.filename is None:
1376 # nope, pull out the value and strip it
1377 value = value.value.strip()
1379 # now that we have the props field, we need a teensy little
1380 # extra bit of help for the old :note field...
1381 if d['note'] and value:
1382 props['author'] = self.db.getuid()
1383 props['date'] = date.Date()
1385 # handle by type now
1386 if isinstance(proptype, hyperdb.Password):
1387 if not value:
1388 # ignore empty password values
1389 continue
1390 for key, d in matches:
1391 if d['confirm'] and d['propname'] == propname:
1392 confirm = form[key]
1393 break
1394 else:
1395 raise ValueError, 'Password and confirmation text do '\
1396 'not match'
1397 if isinstance(confirm, type([])):
1398 raise ValueError, 'You have submitted more than one value'\
1399 ' for the %s property'%propname
1400 if value != confirm.value:
1401 raise ValueError, 'Password and confirmation text do '\
1402 'not match'
1403 value = password.Password(value)
1405 elif isinstance(proptype, hyperdb.Link):
1406 # see if it's the "no selection" choice
1407 if value == '-1' or not value:
1408 # if we're creating, just don't include this property
1409 if not nodeid or nodeid.startswith('-'):
1410 continue
1411 value = None
1412 else:
1413 # handle key values
1414 link = proptype.classname
1415 if not num_re.match(value):
1416 try:
1417 value = db.classes[link].lookup(value)
1418 except KeyError:
1419 raise ValueError, _('property "%(propname)s": '
1420 '%(value)s not a %(classname)s')%{
1421 'propname': propname, 'value': value,
1422 'classname': link}
1423 except TypeError, message:
1424 raise ValueError, _('you may only enter ID values '
1425 'for property "%(propname)s": %(message)s')%{
1426 'propname': propname, 'message': message}
1427 elif isinstance(proptype, hyperdb.Multilink):
1428 # perform link class key value lookup if necessary
1429 link = proptype.classname
1430 link_cl = db.classes[link]
1431 l = []
1432 for entry in value:
1433 if not entry: continue
1434 if not num_re.match(entry):
1435 try:
1436 entry = link_cl.lookup(entry)
1437 except KeyError:
1438 raise ValueError, _('property "%(propname)s": '
1439 '"%(value)s" not an entry of %(classname)s')%{
1440 'propname': propname, 'value': entry,
1441 'classname': link}
1442 except TypeError, message:
1443 raise ValueError, _('you may only enter ID values '
1444 'for property "%(propname)s": %(message)s')%{
1445 'propname': propname, 'message': message}
1446 l.append(entry)
1447 l.sort()
1449 # now use that list of ids to modify the multilink
1450 if mlaction == 'set':
1451 value = l
1452 else:
1453 # we're modifying the list - get the current list of ids
1454 if props.has_key(propname):
1455 existing = props[propname]
1456 elif nodeid and not nodeid.startswith('-'):
1457 existing = cl.get(nodeid, propname, [])
1458 else:
1459 existing = []
1461 # now either remove or add
1462 if mlaction == 'remove':
1463 # remove - handle situation where the id isn't in
1464 # the list
1465 for entry in l:
1466 try:
1467 existing.remove(entry)
1468 except ValueError:
1469 raise ValueError, _('property "%(propname)s": '
1470 '"%(value)s" not currently in list')%{
1471 'propname': propname, 'value': entry}
1472 else:
1473 # add - easy, just don't dupe
1474 for entry in l:
1475 if entry not in existing:
1476 existing.append(entry)
1477 value = existing
1478 value.sort()
1480 elif value == '':
1481 # if we're creating, just don't include this property
1482 if not nodeid or nodeid.startswith('-'):
1483 continue
1484 # other types should be None'd if there's no value
1485 value = None
1486 else:
1487 if isinstance(proptype, hyperdb.String):
1488 if (hasattr(value, 'filename') and
1489 value.filename is not None):
1490 # skip if the upload is empty
1491 if not value.filename:
1492 continue
1493 # this String is actually a _file_
1494 # try to determine the file content-type
1495 filename = value.filename.split('\\')[-1]
1496 if propdef.has_key('name'):
1497 props['name'] = filename
1498 # use this info as the type/filename properties
1499 if propdef.has_key('type'):
1500 props['type'] = mimetypes.guess_type(filename)[0]
1501 if not props['type']:
1502 props['type'] = "application/octet-stream"
1503 # finally, read the content
1504 value = value.value
1505 else:
1506 # normal String fix the CRLF/CR -> LF stuff
1507 value = fixNewlines(value)
1509 elif isinstance(proptype, hyperdb.Date):
1510 value = date.Date(value, offset=timezone)
1511 elif isinstance(proptype, hyperdb.Interval):
1512 value = date.Interval(value)
1513 elif isinstance(proptype, hyperdb.Boolean):
1514 value = value.lower() in ('yes', 'true', 'on', '1')
1515 elif isinstance(proptype, hyperdb.Number):
1516 value = float(value)
1518 # get the old value
1519 if nodeid and not nodeid.startswith('-'):
1520 try:
1521 existing = cl.get(nodeid, propname)
1522 except KeyError:
1523 # this might be a new property for which there is
1524 # no existing value
1525 if not propdef.has_key(propname):
1526 raise
1528 # make sure the existing multilink is sorted
1529 if isinstance(proptype, hyperdb.Multilink):
1530 existing.sort()
1532 # "missing" existing values may not be None
1533 if not existing:
1534 if isinstance(proptype, hyperdb.String) and not existing:
1535 # some backends store "missing" Strings as empty strings
1536 existing = None
1537 elif isinstance(proptype, hyperdb.Number) and not existing:
1538 # some backends store "missing" Numbers as 0 :(
1539 existing = 0
1540 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1541 # likewise Booleans
1542 existing = 0
1544 # if changed, set it
1545 if value != existing:
1546 props[propname] = value
1547 else:
1548 # don't bother setting empty/unset values
1549 if value is None:
1550 continue
1551 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1552 continue
1553 elif isinstance(proptype, hyperdb.String) and value == '':
1554 continue
1556 props[propname] = value
1558 # register this as received if required?
1559 if propname in required and value is not None:
1560 required.remove(propname)
1562 # check to see if we need to specially link a file to the note
1563 if have_note and have_file:
1564 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1566 # see if all the required properties have been supplied
1567 s = []
1568 for thing, required in all_required.items():
1569 if not required:
1570 continue
1571 if len(required) > 1:
1572 p = 'properties'
1573 else:
1574 p = 'property'
1575 s.append('Required %s %s %s not supplied'%(thing[0], p,
1576 ', '.join(required)))
1577 if s:
1578 raise ValueError, '\n'.join(s)
1580 # check that FileClass entries have a "content" property with
1581 # content, otherwise remove them
1582 for (cn, id), props in all_props.items():
1583 cl = self.db.classes[cn]
1584 if not isinstance(cl, hyperdb.FileClass):
1585 continue
1586 # we also don't want to create FileClass items with no content
1587 if not props.get('content', ''):
1588 del all_props[(cn, id)]
1589 return all_props, all_links
1591 def fixNewlines(text):
1592 ''' Homogenise line endings.
1594 Different web clients send different line ending values, but
1595 other systems (eg. email) don't necessarily handle those line
1596 endings. Our solution is to convert all line endings to LF.
1597 '''
1598 text = text.replace('\r\n', '\n')
1599 return text.replace('\r', '\n')
1601 def extractFormList(value):
1602 ''' Extract a list of values from the form value.
1604 It may be one of:
1605 [MiniFieldStorage, MiniFieldStorage, ...]
1606 MiniFieldStorage('value,value,...')
1607 MiniFieldStorage('value')
1608 '''
1609 # multiple values are OK
1610 if isinstance(value, type([])):
1611 # it's a list of MiniFieldStorages
1612 value = [i.value.strip() for i in value]
1613 else:
1614 # it's a MiniFieldStorage, but may be a comma-separated list
1615 # of values
1616 value = [i.strip() for i in value.value.split(',')]
1618 # filter out the empty bits
1619 return filter(None, value)