1 # $Id: client.py,v 1.134 2003-09-06 09:45:30 jlgijsbers 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, MimeWriter, smtplib, socket, quopri
9 import stat, rfc822, string
11 from roundup import roundupdb, date, hyperdb, password, token, rcsv
12 from roundup.i18n import _
13 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
15 from roundup.cgi.PageTemplates import PageTemplate
16 from roundup.rfc2822 import encode_header
17 from roundup.mailgw import uidFromAddress, openSMTPConnection
19 class HTTPException(Exception):
20 pass
21 class Unauthorised(HTTPException):
22 pass
23 class NotFound(HTTPException):
24 pass
25 class Redirect(HTTPException):
26 pass
27 class NotModified(HTTPException):
28 pass
30 # set to indicate to roundup not to actually _send_ email
31 # this var must contain a file to write the mail to
32 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
34 # used by a couple of routines
35 chars = string.ascii_letters+string.digits
37 # XXX actually _use_ FormError
38 class FormError(ValueError):
39 ''' An "expected" exception occurred during form parsing.
40 - ie. something we know can go wrong, and don't want to alarm the
41 user with
43 We trap this at the user interface level and feed back a nice error
44 to the user.
45 '''
46 pass
48 class SendFile(Exception):
49 ''' Send a file from the database '''
51 class SendStaticFile(Exception):
52 ''' Send a static file from the instance html directory '''
54 def initialiseSecurity(security):
55 ''' Create some Permissions and Roles on the security object
57 This function is directly invoked by security.Security.__init__()
58 as a part of the Security object instantiation.
59 '''
60 security.addPermission(name="Web Registration",
61 description="User may register through the web")
62 p = security.addPermission(name="Web Access",
63 description="User may access the web interface")
64 security.addPermissionToRole('Admin', p)
66 # doing Role stuff through the web - make sure Admin can
67 p = security.addPermission(name="Web Roles",
68 description="User may manipulate user Roles through the web")
69 security.addPermissionToRole('Admin', p)
71 # used to clean messages passed through CGI variables - HTML-escape any tag
72 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
73 # that people can't pass through nasties like <script>, <iframe>, ...
74 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
75 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
76 return mc.sub(clean_message_callback, message)
77 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
78 ''' Strip all non <a>,<i>,<b> and <br> tags from a string
79 '''
80 if ok.has_key(match.group(3).lower()):
81 return match.group(1)
82 return '<%s>'%match.group(2)
84 class Client:
85 ''' Instantiate to handle one CGI request.
87 See inner_main for request processing.
89 Client attributes at instantiation:
90 "path" is the PATH_INFO inside the instance (with no leading '/')
91 "base" is the base URL for the instance
92 "form" is the cgi form, an instance of FieldStorage from the standard
93 cgi module
94 "additional_headers" is a dictionary of additional HTTP headers that
95 should be sent to the client
96 "response_code" is the HTTP response code to send to the client
98 During the processing of a request, the following attributes are used:
99 "error_message" holds a list of error messages
100 "ok_message" holds a list of OK messages
101 "session" is the current user session id
102 "user" is the current user's name
103 "userid" is the current user's id
104 "template" is the current :template context
105 "classname" is the current class context name
106 "nodeid" is the current context item id
108 User Identification:
109 If the user has no login cookie, then they are anonymous and are logged
110 in as that user. This typically gives them all Permissions assigned to the
111 Anonymous Role.
113 Once a user logs in, they are assigned a session. The Client instance
114 keeps the nodeid of the session as the "session" attribute.
117 Special form variables:
118 Note that in various places throughout this code, special form
119 variables of the form :<name> are used. The colon (":") part may
120 actually be one of either ":" or "@".
121 '''
123 #
124 # special form variables
125 #
126 FV_TEMPLATE = re.compile(r'[@:]template')
127 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
128 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
130 FV_QUERYNAME = re.compile(r'[@:]queryname')
132 # edit form variable handling (see unit tests)
133 FV_LABELS = r'''
134 ^(
135 (?P<note>[@:]note)|
136 (?P<file>[@:]file)|
137 (
138 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
139 ((?P<required>[@:]required$)| # :required
140 (
141 (
142 (?P<add>[@:]add[@:])| # :add:<prop>
143 (?P<remove>[@:]remove[@:])| # :remove:<prop>
144 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
145 (?P<link>[@:]link[@:])| # :link:<prop>
146 ([@:]) # just a separator
147 )?
148 (?P<propname>[^@:]+) # <prop>
149 )
150 )
151 )
152 )$'''
154 # Note: index page stuff doesn't appear here:
155 # columns, sort, sortdir, filter, group, groupdir, search_text,
156 # pagesize, startwith
158 def __init__(self, instance, request, env, form=None):
159 hyperdb.traceMark()
160 self.instance = instance
161 self.request = request
162 self.env = env
164 # save off the path
165 self.path = env['PATH_INFO']
167 # this is the base URL for this tracker
168 self.base = self.instance.config.TRACKER_WEB
170 # this is the "cookie path" for this tracker (ie. the path part of
171 # the "base" url)
172 self.cookie_path = urlparse.urlparse(self.base)[2]
173 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
174 self.instance.config.TRACKER_NAME)
176 # see if we need to re-parse the environment for the form (eg Zope)
177 if form is None:
178 self.form = cgi.FieldStorage(environ=env)
179 else:
180 self.form = form
182 # turn debugging on/off
183 try:
184 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
185 except ValueError:
186 # someone gave us a non-int debug level, turn it off
187 self.debug = 0
189 # flag to indicate that the HTTP headers have been sent
190 self.headers_done = 0
192 # additional headers to send with the request - must be registered
193 # before the first write
194 self.additional_headers = {}
195 self.response_code = 200
198 def main(self):
199 ''' Wrap the real main in a try/finally so we always close off the db.
200 '''
201 try:
202 self.inner_main()
203 finally:
204 if hasattr(self, 'db'):
205 self.db.close()
207 def inner_main(self):
208 ''' Process a request.
210 The most common requests are handled like so:
211 1. figure out who we are, defaulting to the "anonymous" user
212 see determine_user
213 2. figure out what the request is for - the context
214 see determine_context
215 3. handle any requested action (item edit, search, ...)
216 see handle_action
217 4. render a template, resulting in HTML output
219 In some situations, exceptions occur:
220 - HTTP Redirect (generally raised by an action)
221 - SendFile (generally raised by determine_context)
222 serve up a FileClass "content" property
223 - SendStaticFile (generally raised by determine_context)
224 serve up a file from the tracker "html" directory
225 - Unauthorised (generally raised by an action)
226 the action is cancelled, the request is rendered and an error
227 message is displayed indicating that permission was not
228 granted for the action to take place
229 - NotFound (raised wherever it needs to be)
230 percolates up to the CGI interface that called the client
231 '''
232 self.ok_message = []
233 self.error_message = []
234 try:
235 # figure out the context and desired content template
236 # do this first so we don't authenticate for static files
237 # Note: this method opens the database as "admin" in order to
238 # perform context checks
239 self.determine_context()
241 # make sure we're identified (even anonymously)
242 self.determine_user()
244 # possibly handle a form submit action (may change self.classname
245 # and self.template, and may also append error/ok_messages)
246 self.handle_action()
248 # now render the page
249 # we don't want clients caching our dynamic pages
250 self.additional_headers['Cache-Control'] = 'no-cache'
251 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
252 # self.additional_headers['Pragma'] = 'no-cache'
254 # expire this page 5 seconds from now
255 date = rfc822.formatdate(time.time() + 5)
256 self.additional_headers['Expires'] = date
258 # render the content
259 self.write(self.renderContext())
260 except Redirect, url:
261 # let's redirect - if the url isn't None, then we need to do
262 # the headers, otherwise the headers have been set before the
263 # exception was raised
264 if url:
265 self.additional_headers['Location'] = url
266 self.response_code = 302
267 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
268 except SendFile, designator:
269 self.serve_file(designator)
270 except SendStaticFile, file:
271 try:
272 self.serve_static_file(str(file))
273 except NotModified:
274 # send the 304 response
275 self.request.send_response(304)
276 self.request.end_headers()
277 except Unauthorised, message:
278 self.classname = None
279 self.template = ''
280 self.error_message.append(message)
281 self.write(self.renderContext())
282 except NotFound:
283 # pass through
284 raise
285 except:
286 # everything else
287 self.write(cgitb.html())
289 def clean_sessions(self):
290 ''' Age sessions, remove when they haven't been used for a week.
292 Do it only once an hour.
294 Note: also cleans One Time Keys, and other "session" based
295 stuff.
296 '''
297 sessions = self.db.sessions
298 last_clean = sessions.get('last_clean', 'last_use') or 0
300 week = 60*60*24*7
301 hour = 60*60
302 now = time.time()
303 if now - last_clean > hour:
304 # remove aged sessions
305 for sessid in sessions.list():
306 interval = now - sessions.get(sessid, 'last_use')
307 if interval > week:
308 sessions.destroy(sessid)
309 # remove aged otks
310 otks = self.db.otks
311 for sessid in otks.list():
312 interval = now - otks.get(sessid, '__time')
313 if interval > week:
314 otks.destroy(sessid)
315 sessions.set('last_clean', last_use=time.time())
317 def determine_user(self):
318 ''' Determine who the user is
319 '''
320 # open the database as admin
321 self.opendb('admin')
323 # clean age sessions
324 self.clean_sessions()
326 # make sure we have the session Class
327 sessions = self.db.sessions
329 # look up the user session cookie
330 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
331 user = 'anonymous'
333 # bump the "revision" of the cookie since the format changed
334 if (cookie.has_key(self.cookie_name) and
335 cookie[self.cookie_name].value != 'deleted'):
337 # get the session key from the cookie
338 self.session = cookie[self.cookie_name].value
339 # get the user from the session
340 try:
341 # update the lifetime datestamp
342 sessions.set(self.session, last_use=time.time())
343 sessions.commit()
344 user = sessions.get(self.session, 'user')
345 except KeyError:
346 user = 'anonymous'
348 # sanity check on the user still being valid, getting the userid
349 # at the same time
350 try:
351 self.userid = self.db.user.lookup(user)
352 except (KeyError, TypeError):
353 user = 'anonymous'
355 # make sure the anonymous user is valid if we're using it
356 if user == 'anonymous':
357 self.make_user_anonymous()
358 else:
359 self.user = user
361 # reopen the database as the correct user
362 self.opendb(self.user)
364 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
365 ''' Determine the context of this page from the URL:
367 The URL path after the instance identifier is examined. The path
368 is generally only one entry long.
370 - if there is no path, then we are in the "home" context.
371 * if the path is "_file", then the additional path entry
372 specifies the filename of a static file we're to serve up
373 from the instance "html" directory. Raises a SendStaticFile
374 exception.
375 - if there is something in the path (eg "issue"), it identifies
376 the tracker class we're to display.
377 - if the path is an item designator (eg "issue123"), then we're
378 to display a specific item.
379 * if the path starts with an item designator and is longer than
380 one entry, then we're assumed to be handling an item of a
381 FileClass, and the extra path information gives the filename
382 that the client is going to label the download with (ie
383 "file123/image.png" is nicer to download than "file123"). This
384 raises a SendFile exception.
386 Both of the "*" types of contexts stop before we bother to
387 determine the template we're going to use. That's because they
388 don't actually use templates.
390 The template used is specified by the :template CGI variable,
391 which defaults to:
393 only classname suplied: "index"
394 full item designator supplied: "item"
396 We set:
397 self.classname - the class to display, can be None
398 self.template - the template to render the current context with
399 self.nodeid - the nodeid of the class we're displaying
400 '''
401 # default the optional variables
402 self.classname = None
403 self.nodeid = None
405 # see if a template or messages are specified
406 template_override = ok_message = error_message = None
407 for key in self.form.keys():
408 if self.FV_TEMPLATE.match(key):
409 template_override = self.form[key].value
410 elif self.FV_OK_MESSAGE.match(key):
411 ok_message = self.form[key].value
412 ok_message = clean_message(ok_message)
413 elif self.FV_ERROR_MESSAGE.match(key):
414 error_message = self.form[key].value
415 error_message = clean_message(error_message)
417 # determine the classname and possibly nodeid
418 path = self.path.split('/')
419 if not path or path[0] in ('', 'home', 'index'):
420 if template_override is not None:
421 self.template = template_override
422 else:
423 self.template = ''
424 return
425 elif path[0] == '_file':
426 raise SendStaticFile, os.path.join(*path[1:])
427 else:
428 self.classname = path[0]
429 if len(path) > 1:
430 # send the file identified by the designator in path[0]
431 raise SendFile, path[0]
433 # we need the db for further context stuff - open it as admin
434 self.opendb('admin')
436 # see if we got a designator
437 m = dre.match(self.classname)
438 if m:
439 self.classname = m.group(1)
440 self.nodeid = m.group(2)
441 if not self.db.getclass(self.classname).hasnode(self.nodeid):
442 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
443 # with a designator, we default to item view
444 self.template = 'item'
445 else:
446 # with only a class, we default to index view
447 self.template = 'index'
449 # make sure the classname is valid
450 try:
451 self.db.getclass(self.classname)
452 except KeyError:
453 raise NotFound, self.classname
455 # see if we have a template override
456 if template_override is not None:
457 self.template = template_override
459 # see if we were passed in a message
460 if ok_message:
461 self.ok_message.append(ok_message)
462 if error_message:
463 self.error_message.append(error_message)
465 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
466 ''' Serve the file from the content property of the designated item.
467 '''
468 m = dre.match(str(designator))
469 if not m:
470 raise NotFound, str(designator)
471 classname, nodeid = m.group(1), m.group(2)
472 if classname != 'file':
473 raise NotFound, designator
475 # we just want to serve up the file named
476 self.opendb('admin')
477 file = self.db.file
478 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
479 self.write(file.get(nodeid, 'content'))
481 def serve_static_file(self, file):
482 ims = None
483 # see if there's an if-modified-since...
484 if hasattr(self.request, 'headers'):
485 ims = self.request.headers.getheader('if-modified-since')
486 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
487 # cgi will put the header in the env var
488 ims = self.env['HTTP_IF_MODIFIED_SINCE']
489 filename = os.path.join(self.instance.config.TEMPLATES, file)
490 lmt = os.stat(filename)[stat.ST_MTIME]
491 if ims:
492 ims = rfc822.parsedate(ims)[:6]
493 lmtt = time.gmtime(lmt)[:6]
494 if lmtt <= ims:
495 raise NotModified
497 # we just want to serve up the file named
498 file = str(file)
499 mt = mimetypes.guess_type(file)[0]
500 if not mt:
501 if file.endswith('.css'):
502 mt = 'text/css'
503 else:
504 mt = 'text/plain'
505 self.additional_headers['Content-Type'] = mt
506 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
507 self.write(open(filename, 'rb').read())
509 def renderContext(self):
510 ''' Return a PageTemplate for the named page
511 '''
512 name = self.classname
513 extension = self.template
514 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
516 # catch errors so we can handle PT rendering errors more nicely
517 args = {
518 'ok_message': self.ok_message,
519 'error_message': self.error_message
520 }
521 try:
522 # let the template render figure stuff out
523 return pt.render(self, None, None, **args)
524 except NoTemplate, message:
525 return '<strong>%s</strong>'%message
526 except:
527 # everything else
528 return cgitb.pt_html()
530 # these are the actions that are available
531 actions = (
532 ('edit', 'editItemAction'),
533 ('editcsv', 'editCSVAction'),
534 ('new', 'newItemAction'),
535 ('register', 'registerAction'),
536 ('confrego', 'confRegoAction'),
537 ('passrst', 'passResetAction'),
538 ('login', 'loginAction'),
539 ('logout', 'logout_action'),
540 ('search', 'searchAction'),
541 ('retire', 'retireAction'),
542 ('show', 'showAction'),
543 )
544 def handle_action(self):
545 ''' Determine whether there should be an Action called.
547 The action is defined by the form variable :action which
548 identifies the method on this object to call. The actions
549 are defined in the "actions" sequence on this class.
550 '''
551 if self.form.has_key(':action'):
552 action = self.form[':action'].value.lower()
553 elif self.form.has_key('@action'):
554 action = self.form['@action'].value.lower()
555 else:
556 return None
557 try:
558 # get the action, validate it
559 for name, method in self.actions:
560 if name == action:
561 break
562 else:
563 raise ValueError, 'No such action "%s"'%action
564 # call the mapped action
565 getattr(self, method)()
566 except Redirect:
567 raise
568 except Unauthorised:
569 raise
571 def write(self, content):
572 if not self.headers_done:
573 self.header()
574 self.request.wfile.write(content)
576 def header(self, headers=None, response=None):
577 '''Put up the appropriate header.
578 '''
579 if headers is None:
580 headers = {'Content-Type':'text/html'}
581 if response is None:
582 response = self.response_code
584 # update with additional info
585 headers.update(self.additional_headers)
587 if not headers.has_key('Content-Type'):
588 headers['Content-Type'] = 'text/html'
589 self.request.send_response(response)
590 for entry in headers.items():
591 self.request.send_header(*entry)
592 self.request.end_headers()
593 self.headers_done = 1
594 if self.debug:
595 self.headers_sent = headers
597 def set_cookie(self, user):
598 ''' Set up a session cookie for the user and store away the user's
599 login info against the session.
600 '''
601 # TODO generate a much, much stronger session key ;)
602 self.session = binascii.b2a_base64(repr(random.random())).strip()
604 # clean up the base64
605 if self.session[-1] == '=':
606 if self.session[-2] == '=':
607 self.session = self.session[:-2]
608 else:
609 self.session = self.session[:-1]
611 # insert the session in the sessiondb
612 self.db.sessions.set(self.session, user=user, last_use=time.time())
614 # and commit immediately
615 self.db.sessions.commit()
617 # expire us in a long, long time
618 expire = Cookie._getdate(86400*365)
620 # generate the cookie path - make sure it has a trailing '/'
621 self.additional_headers['Set-Cookie'] = \
622 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
623 expire, self.cookie_path)
625 def make_user_anonymous(self):
626 ''' Make us anonymous
628 This method used to handle non-existence of the 'anonymous'
629 user, but that user is mandatory now.
630 '''
631 self.userid = self.db.user.lookup('anonymous')
632 self.user = 'anonymous'
634 def opendb(self, user):
635 ''' Open the database.
636 '''
637 # open the db if the user has changed
638 if not hasattr(self, 'db') or user != self.db.journaltag:
639 if hasattr(self, 'db'):
640 self.db.close()
641 self.db = self.instance.open(user)
643 #
644 # Actions
645 #
646 def loginAction(self):
647 ''' Attempt to log a user in.
649 Sets up a session for the user which contains the login
650 credentials.
651 '''
652 # we need the username at a minimum
653 if not self.form.has_key('__login_name'):
654 self.error_message.append(_('Username required'))
655 return
657 # get the login info
658 self.user = self.form['__login_name'].value
659 if self.form.has_key('__login_password'):
660 password = self.form['__login_password'].value
661 else:
662 password = ''
664 # make sure the user exists
665 try:
666 self.userid = self.db.user.lookup(self.user)
667 except KeyError:
668 name = self.user
669 self.error_message.append(_('No such user "%(name)s"')%locals())
670 self.make_user_anonymous()
671 return
673 # verify the password
674 if not self.verifyPassword(self.userid, password):
675 self.make_user_anonymous()
676 self.error_message.append(_('Incorrect password'))
677 return
679 # make sure we're allowed to be here
680 if not self.loginPermission():
681 self.make_user_anonymous()
682 self.error_message.append(_("You do not have permission to login"))
683 return
685 # now we're OK, re-open the database for real, using the user
686 self.opendb(self.user)
688 # set the session cookie
689 self.set_cookie(self.user)
691 def verifyPassword(self, userid, password):
692 ''' Verify the password that the user has supplied
693 '''
694 stored = self.db.user.get(self.userid, 'password')
695 if password == stored:
696 return 1
697 if not password and not stored:
698 return 1
699 return 0
701 def loginPermission(self):
702 ''' Determine whether the user has permission to log in.
704 Base behaviour is to check the user has "Web Access".
705 '''
706 if not self.db.security.hasPermission('Web Access', self.userid):
707 return 0
708 return 1
710 def logout_action(self):
711 ''' Make us really anonymous - nuke the cookie too
712 '''
713 # log us out
714 self.make_user_anonymous()
716 # construct the logout cookie
717 now = Cookie._getdate()
718 self.additional_headers['Set-Cookie'] = \
719 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
720 now, self.cookie_path)
722 # Let the user know what's going on
723 self.ok_message.append(_('You are logged out'))
725 def registerAction(self):
726 '''Attempt to create a new user based on the contents of the form
727 and then set the cookie.
729 return 1 on successful login
730 '''
731 # parse the props from the form
732 try:
733 props = self.parsePropsFromForm()[0][('user', None)]
734 except (ValueError, KeyError), message:
735 self.error_message.append(_('Error: ') + str(message))
736 return
738 # make sure we're allowed to register
739 if not self.registerPermission(props):
740 raise Unauthorised, _("You do not have permission to register")
742 try:
743 self.db.user.lookup(props['username'])
744 self.error_message.append('Error: A user with the username "%s" '
745 'already exists'%props['username'])
746 return
747 except KeyError:
748 pass
750 # generate the one-time-key and store the props for later
751 otk = ''.join([random.choice(chars) for x in range(32)])
752 for propname, proptype in self.db.user.getprops().items():
753 value = props.get(propname, None)
754 if value is None:
755 pass
756 elif isinstance(proptype, hyperdb.Date):
757 props[propname] = str(value)
758 elif isinstance(proptype, hyperdb.Interval):
759 props[propname] = str(value)
760 elif isinstance(proptype, hyperdb.Password):
761 props[propname] = str(value)
762 props['__time'] = time.time()
763 self.db.otks.set(otk, **props)
765 # send the email
766 tracker_name = self.db.config.TRACKER_NAME
767 subject = 'Complete your registration to %s'%tracker_name
768 body = '''
769 To complete your registration of the user "%(name)s" with %(tracker)s,
770 please visit the following URL:
772 %(url)s?@action=confrego&otk=%(otk)s
773 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
774 'otk': otk}
775 if not self.sendEmail(props['address'], subject, body):
776 return
778 # commit changes to the database
779 self.db.commit()
781 # redirect to the "you're almost there" page
782 raise Redirect, '%suser?@template=rego_progress'%self.base
784 def sendEmail(self, to, subject, content):
785 # send email to the user's email address
786 message = StringIO.StringIO()
787 writer = MimeWriter.MimeWriter(message)
788 tracker_name = self.db.config.TRACKER_NAME
789 writer.addheader('Subject', encode_header(subject))
790 writer.addheader('To', to)
791 writer.addheader('From', roundupdb.straddr((tracker_name,
792 self.db.config.ADMIN_EMAIL)))
793 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
794 time.gmtime()))
795 # add a uniquely Roundup header to help filtering
796 writer.addheader('X-Roundup-Name', tracker_name)
797 # avoid email loops
798 writer.addheader('X-Roundup-Loop', 'hello')
799 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
800 body = writer.startbody('text/plain; charset=utf-8')
802 # message body, encoded quoted-printable
803 content = StringIO.StringIO(content)
804 quopri.encode(content, body, 0)
806 if SENDMAILDEBUG:
807 # don't send - just write to a file
808 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
809 self.db.config.ADMIN_EMAIL,
810 ', '.join(to),message.getvalue()))
811 else:
812 # now try to send the message
813 try:
814 # send the message as admin so bounces are sent there
815 # instead of to roundup
816 smtp = openSMTPConnection(self.db.config)
817 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
818 message.getvalue())
819 except socket.error, value:
820 self.error_message.append("Error: couldn't send email: "
821 "mailhost %s"%value)
822 return 0
823 except smtplib.SMTPException, msg:
824 self.error_message.append("Error: couldn't send email: %s"%msg)
825 return 0
826 return 1
828 def registerPermission(self, props):
829 ''' Determine whether the user has permission to register
831 Base behaviour is to check the user has "Web Registration".
832 '''
833 # registration isn't allowed to supply roles
834 if props.has_key('roles'):
835 return 0
836 if self.db.security.hasPermission('Web Registration', self.userid):
837 return 1
838 return 0
840 def confRegoAction(self):
841 ''' Grab the OTK, use it to load up the new user details
842 '''
843 try:
844 # pull the rego information out of the otk database
845 self.userid = self.db.confirm_registration(self.form['otk'].value)
846 except (ValueError, KeyError), message:
847 # XXX: we need to make the "default" page be able to display errors!
848 self.error_message.append(str(message))
849 return
851 # log the new user in
852 self.user = self.db.user.get(self.userid, 'username')
853 # re-open the database for real, using the user
854 self.opendb(self.user)
856 # if we have a session, update it
857 if hasattr(self, 'session'):
858 self.db.sessions.set(self.session, user=self.user,
859 last_use=time.time())
860 else:
861 # new session cookie
862 self.set_cookie(self.user)
864 # nice message
865 message = _('You are now registered, welcome!')
867 # redirect to the user's page
868 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
869 self.userid, urllib.quote(message))
871 def passResetAction(self):
872 ''' Handle password reset requests.
874 Presence of either "name" or "address" generate email.
875 Presense of "otk" performs the reset.
876 '''
877 if self.form.has_key('otk'):
878 # pull the rego information out of the otk database
879 otk = self.form['otk'].value
880 uid = self.db.otks.get(otk, 'uid')
881 if uid is None:
882 self.error_message.append('Invalid One Time Key!')
883 return
885 # re-open the database as "admin"
886 if self.user != 'admin':
887 self.opendb('admin')
889 # change the password
890 newpw = password.generatePassword()
892 cl = self.db.user
893 # XXX we need to make the "default" page be able to display errors!
894 try:
895 # set the password
896 cl.set(uid, password=password.Password(newpw))
897 # clear the props from the otk database
898 self.db.otks.destroy(otk)
899 self.db.commit()
900 except (ValueError, KeyError), message:
901 self.error_message.append(str(message))
902 return
904 # user info
905 address = self.db.user.get(uid, 'address')
906 name = self.db.user.get(uid, 'username')
908 # send the email
909 tracker_name = self.db.config.TRACKER_NAME
910 subject = 'Password reset for %s'%tracker_name
911 body = '''
912 The password has been reset for username "%(name)s".
914 Your password is now: %(password)s
915 '''%{'name': name, 'password': newpw}
916 if not self.sendEmail(address, subject, body):
917 return
919 self.ok_message.append('Password reset and email sent to %s'%address)
920 return
922 # no OTK, so now figure the user
923 if self.form.has_key('username'):
924 name = self.form['username'].value
925 try:
926 uid = self.db.user.lookup(name)
927 except KeyError:
928 self.error_message.append('Unknown username')
929 return
930 address = self.db.user.get(uid, 'address')
931 elif self.form.has_key('address'):
932 address = self.form['address'].value
933 uid = uidFromAddress(self.db, ('', address), create=0)
934 if not uid:
935 self.error_message.append('Unknown email address')
936 return
937 name = self.db.user.get(uid, 'username')
938 else:
939 self.error_message.append('You need to specify a username '
940 'or address')
941 return
943 # generate the one-time-key and store the props for later
944 otk = ''.join([random.choice(chars) for x in range(32)])
945 self.db.otks.set(otk, uid=uid, __time=time.time())
947 # send the email
948 tracker_name = self.db.config.TRACKER_NAME
949 subject = 'Confirm reset of password for %s'%tracker_name
950 body = '''
951 Someone, perhaps you, has requested that the password be changed for your
952 username, "%(name)s". If you wish to proceed with the change, please follow
953 the link below:
955 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
957 You should then receive another email with the new password.
958 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
959 if not self.sendEmail(address, subject, body):
960 return
962 self.ok_message.append('Email sent to %s'%address)
964 def editItemAction(self):
965 ''' Perform an edit of an item in the database.
967 See parsePropsFromForm and _editnodes for special variables
968 '''
969 # parse the props from the form
970 try:
971 props, links = self.parsePropsFromForm()
972 except (ValueError, KeyError), message:
973 self.error_message.append(_('Parse Error: ') + str(message))
974 return
976 # handle the props
977 try:
978 message = self._editnodes(props, links)
979 except (ValueError, KeyError, IndexError), message:
980 self.error_message.append(_('Apply Error: ') + str(message))
981 return
983 # commit now that all the tricky stuff is done
984 self.db.commit()
986 # redirect to the item's edit page
987 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
988 self.classname, self.nodeid, urllib.quote(message),
989 urllib.quote(self.template))
991 def editItemPermission(self, props):
992 ''' Determine whether the user has permission to edit this item.
994 Base behaviour is to check the user can edit this class. If we're
995 editing the "user" class, users are allowed to edit their own
996 details. Unless it's the "roles" property, which requires the
997 special Permission "Web Roles".
998 '''
999 # if this is a user node and the user is editing their own node, then
1000 # we're OK
1001 has = self.db.security.hasPermission
1002 if self.classname == 'user':
1003 # reject if someone's trying to edit "roles" and doesn't have the
1004 # right permission.
1005 if props.has_key('roles') and not has('Web Roles', self.userid,
1006 'user'):
1007 return 0
1008 # if the item being edited is the current user, we're ok
1009 if self.nodeid == self.userid:
1010 return 1
1011 if self.db.security.hasPermission('Edit', self.userid, self.classname):
1012 return 1
1013 return 0
1015 def newItemAction(self):
1016 ''' Add a new item to the database.
1018 This follows the same form as the editItemAction, with the same
1019 special form values.
1020 '''
1021 # parse the props from the form
1022 try:
1023 props, links = self.parsePropsFromForm()
1024 except (ValueError, KeyError), message:
1025 self.error_message.append(_('Error: ') + str(message))
1026 return
1028 # handle the props - edit or create
1029 try:
1030 # when it hits the None element, it'll set self.nodeid
1031 messages = self._editnodes(props, links)
1033 except (ValueError, KeyError, IndexError), message:
1034 # these errors might just be indicative of user dumbness
1035 self.error_message.append(_('Error: ') + str(message))
1036 return
1038 # commit now that all the tricky stuff is done
1039 self.db.commit()
1041 # redirect to the new item's page
1042 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1043 self.classname, self.nodeid, urllib.quote(messages),
1044 urllib.quote(self.template))
1046 def newItemPermission(self, props):
1047 ''' Determine whether the user has permission to create (edit) this
1048 item.
1050 Base behaviour is to check the user can edit this class. No
1051 additional property checks are made. Additionally, new user items
1052 may be created if the user has the "Web Registration" Permission.
1053 '''
1054 has = self.db.security.hasPermission
1055 if self.classname == 'user' and has('Web Registration', self.userid,
1056 'user'):
1057 return 1
1058 if has('Edit', self.userid, self.classname):
1059 return 1
1060 return 0
1063 #
1064 # Utility methods for editing
1065 #
1066 def _editnodes(self, all_props, all_links, newids=None):
1067 ''' Use the props in all_props to perform edit and creation, then
1068 use the link specs in all_links to do linking.
1069 '''
1070 # figure dependencies and re-work links
1071 deps = {}
1072 links = {}
1073 for cn, nodeid, propname, vlist in all_links:
1074 if not all_props.has_key((cn, nodeid)):
1075 # link item to link to doesn't (and won't) exist
1076 continue
1077 for value in vlist:
1078 if not all_props.has_key(value):
1079 # link item to link to doesn't (and won't) exist
1080 continue
1081 deps.setdefault((cn, nodeid), []).append(value)
1082 links.setdefault(value, []).append((cn, nodeid, propname))
1084 # figure chained dependencies ordering
1085 order = []
1086 done = {}
1087 # loop detection
1088 change = 0
1089 while len(all_props) != len(done):
1090 for needed in all_props.keys():
1091 if done.has_key(needed):
1092 continue
1093 tlist = deps.get(needed, [])
1094 for target in tlist:
1095 if not done.has_key(target):
1096 break
1097 else:
1098 done[needed] = 1
1099 order.append(needed)
1100 change = 1
1101 if not change:
1102 raise ValueError, 'linking must not loop!'
1104 # now, edit / create
1105 m = []
1106 for needed in order:
1107 props = all_props[needed]
1108 if not props:
1109 # nothing to do
1110 continue
1111 cn, nodeid = needed
1113 if nodeid is not None and int(nodeid) > 0:
1114 # make changes to the node
1115 props = self._changenode(cn, nodeid, props)
1117 # and some nice feedback for the user
1118 if props:
1119 info = ', '.join(props.keys())
1120 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1121 else:
1122 m.append('%s %s - nothing changed'%(cn, nodeid))
1123 else:
1124 assert props
1126 # make a new node
1127 newid = self._createnode(cn, props)
1128 if nodeid is None:
1129 self.nodeid = newid
1130 nodeid = newid
1132 # and some nice feedback for the user
1133 m.append('%s %s created'%(cn, newid))
1135 # fill in new ids in links
1136 if links.has_key(needed):
1137 for linkcn, linkid, linkprop in links[needed]:
1138 props = all_props[(linkcn, linkid)]
1139 cl = self.db.classes[linkcn]
1140 propdef = cl.getprops()[linkprop]
1141 if not props.has_key(linkprop):
1142 if linkid is None or linkid.startswith('-'):
1143 # linking to a new item
1144 if isinstance(propdef, hyperdb.Multilink):
1145 props[linkprop] = [newid]
1146 else:
1147 props[linkprop] = newid
1148 else:
1149 # linking to an existing item
1150 if isinstance(propdef, hyperdb.Multilink):
1151 existing = cl.get(linkid, linkprop)[:]
1152 existing.append(nodeid)
1153 props[linkprop] = existing
1154 else:
1155 props[linkprop] = newid
1157 return '<br>'.join(m)
1159 def _changenode(self, cn, nodeid, props):
1160 ''' change the node based on the contents of the form
1161 '''
1162 # check for permission
1163 if not self.editItemPermission(props):
1164 raise Unauthorised, 'You do not have permission to edit %s'%cn
1166 # make the changes
1167 cl = self.db.classes[cn]
1168 return cl.set(nodeid, **props)
1170 def _createnode(self, cn, props):
1171 ''' create a node based on the contents of the form
1172 '''
1173 # check for permission
1174 if not self.newItemPermission(props):
1175 raise Unauthorised, 'You do not have permission to create %s'%cn
1177 # create the node and return its id
1178 cl = self.db.classes[cn]
1179 return cl.create(**props)
1181 #
1182 # More actions
1183 #
1184 def editCSVAction(self):
1185 ''' Performs an edit of all of a class' items in one go.
1187 The "rows" CGI var defines the CSV-formatted entries for the
1188 class. New nodes are identified by the ID 'X' (or any other
1189 non-existent ID) and removed lines are retired.
1190 '''
1191 # this is per-class only
1192 if not self.editCSVPermission():
1193 self.error_message.append(
1194 _('You do not have permission to edit %s' %self.classname))
1196 # get the CSV module
1197 if rcsv.error:
1198 self.error_message.append(_(rcsv.error))
1199 return
1201 cl = self.db.classes[self.classname]
1202 idlessprops = cl.getprops(protected=0).keys()
1203 idlessprops.sort()
1204 props = ['id'] + idlessprops
1206 # do the edit
1207 rows = StringIO.StringIO(self.form['rows'].value)
1208 reader = rcsv.reader(rows, rcsv.comma_separated)
1209 found = {}
1210 line = 0
1211 for values in reader:
1212 line += 1
1213 if line == 1: continue
1214 # skip property names header
1215 if values == props:
1216 continue
1218 # extract the nodeid
1219 nodeid, values = values[0], values[1:]
1220 found[nodeid] = 1
1222 # see if the node exists
1223 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1224 exists = 0
1225 else:
1226 exists = 1
1228 # confirm correct weight
1229 if len(idlessprops) != len(values):
1230 self.error_message.append(
1231 _('Not enough values on line %(line)s')%{'line':line})
1232 return
1234 # extract the new values
1235 d = {}
1236 for name, value in zip(idlessprops, values):
1237 prop = cl.properties[name]
1238 value = value.strip()
1239 # only add the property if it has a value
1240 if value:
1241 # if it's a multilink, split it
1242 if isinstance(prop, hyperdb.Multilink):
1243 value = value.split(':')
1244 elif isinstance(prop, hyperdb.Password):
1245 value = password.Password(value)
1246 elif isinstance(prop, hyperdb.Interval):
1247 value = date.Interval(value)
1248 elif isinstance(prop, hyperdb.Date):
1249 value = date.Date(value)
1250 elif isinstance(prop, hyperdb.Boolean):
1251 value = value.lower() in ('yes', 'true', 'on', '1')
1252 elif isinstance(prop, hyperdb.Number):
1253 value = float(value)
1254 d[name] = value
1255 elif exists:
1256 # nuke the existing value
1257 if isinstance(prop, hyperdb.Multilink):
1258 d[name] = []
1259 else:
1260 d[name] = None
1262 # perform the edit
1263 if exists:
1264 # edit existing
1265 cl.set(nodeid, **d)
1266 else:
1267 # new node
1268 found[cl.create(**d)] = 1
1270 # retire the removed entries
1271 for nodeid in cl.list():
1272 if not found.has_key(nodeid):
1273 cl.retire(nodeid)
1275 # all OK
1276 self.db.commit()
1278 self.ok_message.append(_('Items edited OK'))
1280 def editCSVPermission(self):
1281 ''' Determine whether the user has permission to edit this class.
1283 Base behaviour is to check the user can edit this class.
1284 '''
1285 if not self.db.security.hasPermission('Edit', self.userid,
1286 self.classname):
1287 return 0
1288 return 1
1290 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1291 ''' Mangle some of the form variables.
1293 Set the form ":filter" variable based on the values of the
1294 filter variables - if they're set to anything other than
1295 "dontcare" then add them to :filter.
1297 Handle the ":queryname" variable and save off the query to
1298 the user's query list.
1300 Split any String query values on whitespace and comma.
1301 '''
1302 # generic edit is per-class only
1303 if not self.searchPermission():
1304 self.error_message.append(
1305 _('You do not have permission to search %s' %self.classname))
1307 # add a faked :filter form variable for each filtering prop
1308 props = self.db.classes[self.classname].getprops()
1309 queryname = ''
1310 for key in self.form.keys():
1311 # special vars
1312 if self.FV_QUERYNAME.match(key):
1313 queryname = self.form[key].value.strip()
1314 continue
1316 if not props.has_key(key):
1317 continue
1318 if isinstance(self.form[key], type([])):
1319 # search for at least one entry which is not empty
1320 for minifield in self.form[key]:
1321 if minifield.value:
1322 break
1323 else:
1324 continue
1325 else:
1326 if not self.form[key].value:
1327 continue
1328 if isinstance(props[key], hyperdb.String):
1329 v = self.form[key].value
1330 l = token.token_split(v)
1331 if len(l) > 1 or l[0] != v:
1332 self.form.value.remove(self.form[key])
1333 # replace the single value with the split list
1334 for v in l:
1335 self.form.value.append(cgi.MiniFieldStorage(key, v))
1337 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1339 # handle saving the query params
1340 if queryname:
1341 # parse the environment and figure what the query _is_
1342 req = HTMLRequest(self)
1344 # The [1:] strips off the '?' character, it isn't part of the
1345 # query string.
1346 url = req.indexargs_href('', {})[1:]
1348 # handle editing an existing query
1349 try:
1350 qid = self.db.query.lookup(queryname)
1351 self.db.query.set(qid, klass=self.classname, url=url)
1352 except KeyError:
1353 # create a query
1354 qid = self.db.query.create(name=queryname,
1355 klass=self.classname, url=url)
1357 # and add it to the user's query multilink
1358 queries = self.db.user.get(self.userid, 'queries')
1359 queries.append(qid)
1360 self.db.user.set(self.userid, queries=queries)
1362 # commit the query change to the database
1363 self.db.commit()
1365 def searchPermission(self):
1366 ''' Determine whether the user has permission to search this class.
1368 Base behaviour is to check the user can view this class.
1369 '''
1370 if not self.db.security.hasPermission('View', self.userid,
1371 self.classname):
1372 return 0
1373 return 1
1376 def retireAction(self):
1377 ''' Retire the context item.
1378 '''
1379 # if we want to view the index template now, then unset the nodeid
1380 # context info (a special-case for retire actions on the index page)
1381 nodeid = self.nodeid
1382 if self.template == 'index':
1383 self.nodeid = None
1385 # generic edit is per-class only
1386 if not self.retirePermission():
1387 self.error_message.append(
1388 _('You do not have permission to retire %s' %self.classname))
1389 return
1391 # make sure we don't try to retire admin or anonymous
1392 if self.classname == 'user' and \
1393 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1394 self.error_message.append(
1395 _('You may not retire the admin or anonymous user'))
1396 return
1398 # do the retire
1399 self.db.getclass(self.classname).retire(nodeid)
1400 self.db.commit()
1402 self.ok_message.append(
1403 _('%(classname)s %(itemid)s has been retired')%{
1404 'classname': self.classname.capitalize(), 'itemid': nodeid})
1406 def retirePermission(self):
1407 ''' Determine whether the user has permission to retire this class.
1409 Base behaviour is to check the user can edit this class.
1410 '''
1411 if not self.db.security.hasPermission('Edit', self.userid,
1412 self.classname):
1413 return 0
1414 return 1
1417 def showAction(self, typere=re.compile('[@:]type'),
1418 numre=re.compile('[@:]number')):
1419 ''' Show a node of a particular class/id
1420 '''
1421 t = n = ''
1422 for key in self.form.keys():
1423 if typere.match(key):
1424 t = self.form[key].value.strip()
1425 elif numre.match(key):
1426 n = self.form[key].value.strip()
1427 if not t:
1428 raise ValueError, 'Invalid %s number'%t
1429 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1430 raise Redirect, url
1432 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1433 ''' Item properties and their values are edited with html FORM
1434 variables and their values. You can:
1436 - Change the value of some property of the current item.
1437 - Create a new item of any class, and edit the new item's
1438 properties,
1439 - Attach newly created items to a multilink property of the
1440 current item.
1441 - Remove items from a multilink property of the current item.
1442 - Specify that some properties are required for the edit
1443 operation to be successful.
1445 In the following, <bracketed> values are variable, "@" may be
1446 either ":" or "@", and other text "required" is fixed.
1448 Most properties are specified as form variables:
1450 <propname>
1451 - property on the current context item
1453 <designator>"@"<propname>
1454 - property on the indicated item (for editing related
1455 information)
1457 Designators name a specific item of a class.
1459 <classname><N>
1461 Name an existing item of class <classname>.
1463 <classname>"-"<N>
1465 Name the <N>th new item of class <classname>. If the form
1466 submission is successful, a new item of <classname> is
1467 created. Within the submitted form, a particular
1468 designator of this form always refers to the same new
1469 item.
1471 Once we have determined the "propname", we look at it to see
1472 if it's special:
1474 @required
1475 The associated form value is a comma-separated list of
1476 property names that must be specified when the form is
1477 submitted for the edit operation to succeed.
1479 When the <designator> is missing, the properties are
1480 for the current context item. When <designator> is
1481 present, they are for the item specified by
1482 <designator>.
1484 The "@required" specifier must come before any of the
1485 properties it refers to are assigned in the form.
1487 @remove@<propname>=id(s) or @add@<propname>=id(s)
1488 The "@add@" and "@remove@" edit actions apply only to
1489 Multilink properties. The form value must be a
1490 comma-separate list of keys for the class specified by
1491 the simple form variable. The listed items are added
1492 to (respectively, removed from) the specified
1493 property.
1495 @link@<propname>=<designator>
1496 If the edit action is "@link@", the simple form
1497 variable must specify a Link or Multilink property.
1498 The form value is a comma-separated list of
1499 designators. The item corresponding to each
1500 designator is linked to the property given by simple
1501 form variable. These are collected up and returned in
1502 all_links.
1504 None of the above (ie. just a simple form value)
1505 The value of the form variable is converted
1506 appropriately, depending on the type of the property.
1508 For a Link('klass') property, the form value is a
1509 single key for 'klass', where the key field is
1510 specified in dbinit.py.
1512 For a Multilink('klass') property, the form value is a
1513 comma-separated list of keys for 'klass', where the
1514 key field is specified in dbinit.py.
1516 Note that for simple-form-variables specifiying Link
1517 and Multilink properties, the linked-to class must
1518 have a key field.
1520 For a String() property specifying a filename, the
1521 file named by the form value is uploaded. This means we
1522 try to set additional properties "filename" and "type" (if
1523 they are valid for the class). Otherwise, the property
1524 is set to the form value.
1526 For Date(), Interval(), Boolean(), and Number()
1527 properties, the form value is converted to the
1528 appropriate
1530 Any of the form variables may be prefixed with a classname or
1531 designator.
1533 Two special form values are supported for backwards
1534 compatibility:
1536 @note
1537 This is equivalent to::
1539 @link@messages=msg-1
1540 @msg-1@content=value
1542 except that in addition, the "author" and "date"
1543 properties of "msg-1" are set to the userid of the
1544 submitter, and the current time, respectively.
1546 @file
1547 This is equivalent to::
1549 @link@files=file-1
1550 @file-1@content=value
1552 The String content value is handled as described above for
1553 file uploads.
1555 If both the "@note" and "@file" form variables are
1556 specified, the action::
1558 @link@msg-1@files=file-1
1560 is also performed.
1562 We also check that FileClass items have a "content" property with
1563 actual content, otherwise we remove them from all_props before
1564 returning.
1566 The return from this method is a dict of
1567 (classname, id): properties
1568 ... this dict _always_ has an entry for the current context,
1569 even if it's empty (ie. a submission for an existing issue that
1570 doesn't result in any changes would return {('issue','123'): {}})
1571 The id may be None, which indicates that an item should be
1572 created.
1573 '''
1574 # some very useful variables
1575 db = self.db
1576 form = self.form
1578 if not hasattr(self, 'FV_SPECIAL'):
1579 # generate the regexp for handling special form values
1580 classes = '|'.join(db.classes.keys())
1581 # specials for parsePropsFromForm
1582 # handle the various forms (see unit tests)
1583 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1584 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1586 # these indicate the default class / item
1587 default_cn = self.classname
1588 default_cl = self.db.classes[default_cn]
1589 default_nodeid = self.nodeid
1591 # we'll store info about the individual class/item edit in these
1592 all_required = {} # required props per class/item
1593 all_props = {} # props to set per class/item
1594 got_props = {} # props received per class/item
1595 all_propdef = {} # note - only one entry per class
1596 all_links = [] # as many as are required
1598 # we should always return something, even empty, for the context
1599 all_props[(default_cn, default_nodeid)] = {}
1601 keys = form.keys()
1602 timezone = db.getUserTimezone()
1604 # sentinels for the :note and :file props
1605 have_note = have_file = 0
1607 # extract the usable form labels from the form
1608 matches = []
1609 for key in keys:
1610 m = self.FV_SPECIAL.match(key)
1611 if m:
1612 matches.append((key, m.groupdict()))
1614 # now handle the matches
1615 for key, d in matches:
1616 if d['classname']:
1617 # we got a designator
1618 cn = d['classname']
1619 cl = self.db.classes[cn]
1620 nodeid = d['id']
1621 propname = d['propname']
1622 elif d['note']:
1623 # the special note field
1624 cn = 'msg'
1625 cl = self.db.classes[cn]
1626 nodeid = '-1'
1627 propname = 'content'
1628 all_links.append((default_cn, default_nodeid, 'messages',
1629 [('msg', '-1')]))
1630 have_note = 1
1631 elif d['file']:
1632 # the special file field
1633 cn = 'file'
1634 cl = self.db.classes[cn]
1635 nodeid = '-1'
1636 propname = 'content'
1637 all_links.append((default_cn, default_nodeid, 'files',
1638 [('file', '-1')]))
1639 have_file = 1
1640 else:
1641 # default
1642 cn = default_cn
1643 cl = default_cl
1644 nodeid = default_nodeid
1645 propname = d['propname']
1647 # the thing this value relates to is...
1648 this = (cn, nodeid)
1650 # get more info about the class, and the current set of
1651 # form props for it
1652 if not all_propdef.has_key(cn):
1653 all_propdef[cn] = cl.getprops()
1654 propdef = all_propdef[cn]
1655 if not all_props.has_key(this):
1656 all_props[this] = {}
1657 props = all_props[this]
1658 if not got_props.has_key(this):
1659 got_props[this] = {}
1661 # is this a link command?
1662 if d['link']:
1663 value = []
1664 for entry in extractFormList(form[key]):
1665 m = self.FV_DESIGNATOR.match(entry)
1666 if not m:
1667 raise ValueError, \
1668 'link "%s" value "%s" not a designator'%(key, entry)
1669 value.append((m.group(1), m.group(2)))
1671 # make sure the link property is valid
1672 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1673 not isinstance(propdef[propname], hyperdb.Link)):
1674 raise ValueError, '%s %s is not a link or '\
1675 'multilink property'%(cn, propname)
1677 all_links.append((cn, nodeid, propname, value))
1678 continue
1680 # detect the special ":required" variable
1681 if d['required']:
1682 all_required[this] = extractFormList(form[key])
1683 continue
1685 # see if we're performing a special multilink action
1686 mlaction = 'set'
1687 if d['remove']:
1688 mlaction = 'remove'
1689 elif d['add']:
1690 mlaction = 'add'
1692 # does the property exist?
1693 if not propdef.has_key(propname):
1694 if mlaction != 'set':
1695 raise ValueError, 'You have submitted a %s action for'\
1696 ' the property "%s" which doesn\'t exist'%(mlaction,
1697 propname)
1698 # the form element is probably just something we don't care
1699 # about - ignore it
1700 continue
1701 proptype = propdef[propname]
1703 # Get the form value. This value may be a MiniFieldStorage or a list
1704 # of MiniFieldStorages.
1705 value = form[key]
1707 # handle unpacking of the MiniFieldStorage / list form value
1708 if isinstance(proptype, hyperdb.Multilink):
1709 value = extractFormList(value)
1710 else:
1711 # multiple values are not OK
1712 if isinstance(value, type([])):
1713 raise ValueError, 'You have submitted more than one value'\
1714 ' for the %s property'%propname
1715 # value might be a file upload...
1716 if not hasattr(value, 'filename') or value.filename is None:
1717 # nope, pull out the value and strip it
1718 value = value.value.strip()
1720 # now that we have the props field, we need a teensy little
1721 # extra bit of help for the old :note field...
1722 if d['note'] and value:
1723 props['author'] = self.db.getuid()
1724 props['date'] = date.Date()
1726 # handle by type now
1727 if isinstance(proptype, hyperdb.Password):
1728 if not value:
1729 # ignore empty password values
1730 continue
1731 for key, d in matches:
1732 if d['confirm'] and d['propname'] == propname:
1733 confirm = form[key]
1734 break
1735 else:
1736 raise ValueError, 'Password and confirmation text do '\
1737 'not match'
1738 if isinstance(confirm, type([])):
1739 raise ValueError, 'You have submitted more than one value'\
1740 ' for the %s property'%propname
1741 if value != confirm.value:
1742 raise ValueError, 'Password and confirmation text do '\
1743 'not match'
1744 value = password.Password(value)
1746 elif isinstance(proptype, hyperdb.Link):
1747 # see if it's the "no selection" choice
1748 if value == '-1' or not value:
1749 # if we're creating, just don't include this property
1750 if not nodeid or nodeid.startswith('-'):
1751 continue
1752 value = None
1753 else:
1754 # handle key values
1755 link = proptype.classname
1756 if not num_re.match(value):
1757 try:
1758 value = db.classes[link].lookup(value)
1759 except KeyError:
1760 raise ValueError, _('property "%(propname)s": '
1761 '%(value)s not a %(classname)s')%{
1762 'propname': propname, 'value': value,
1763 'classname': link}
1764 except TypeError, message:
1765 raise ValueError, _('you may only enter ID values '
1766 'for property "%(propname)s": %(message)s')%{
1767 'propname': propname, 'message': message}
1768 elif isinstance(proptype, hyperdb.Multilink):
1769 # perform link class key value lookup if necessary
1770 link = proptype.classname
1771 link_cl = db.classes[link]
1772 l = []
1773 for entry in value:
1774 if not entry: continue
1775 if not num_re.match(entry):
1776 try:
1777 entry = link_cl.lookup(entry)
1778 except KeyError:
1779 raise ValueError, _('property "%(propname)s": '
1780 '"%(value)s" not an entry of %(classname)s')%{
1781 'propname': propname, 'value': entry,
1782 'classname': link}
1783 except TypeError, message:
1784 raise ValueError, _('you may only enter ID values '
1785 'for property "%(propname)s": %(message)s')%{
1786 'propname': propname, 'message': message}
1787 l.append(entry)
1788 l.sort()
1790 # now use that list of ids to modify the multilink
1791 if mlaction == 'set':
1792 value = l
1793 else:
1794 # we're modifying the list - get the current list of ids
1795 if props.has_key(propname):
1796 existing = props[propname]
1797 elif nodeid and not nodeid.startswith('-'):
1798 existing = cl.get(nodeid, propname, [])
1799 else:
1800 existing = []
1802 # now either remove or add
1803 if mlaction == 'remove':
1804 # remove - handle situation where the id isn't in
1805 # the list
1806 for entry in l:
1807 try:
1808 existing.remove(entry)
1809 except ValueError:
1810 raise ValueError, _('property "%(propname)s": '
1811 '"%(value)s" not currently in list')%{
1812 'propname': propname, 'value': entry}
1813 else:
1814 # add - easy, just don't dupe
1815 for entry in l:
1816 if entry not in existing:
1817 existing.append(entry)
1818 value = existing
1819 value.sort()
1821 elif value == '':
1822 # if we're creating, just don't include this property
1823 if not nodeid or nodeid.startswith('-'):
1824 continue
1825 # other types should be None'd if there's no value
1826 value = None
1827 else:
1828 # handle ValueErrors for all these in a similar fashion
1829 try:
1830 if isinstance(proptype, hyperdb.String):
1831 if (hasattr(value, 'filename') and
1832 value.filename is not None):
1833 # skip if the upload is empty
1834 if not value.filename:
1835 continue
1836 # this String is actually a _file_
1837 # try to determine the file content-type
1838 fn = value.filename.split('\\')[-1]
1839 if propdef.has_key('name'):
1840 props['name'] = fn
1841 # use this info as the type/filename properties
1842 if propdef.has_key('type'):
1843 props['type'] = mimetypes.guess_type(fn)[0]
1844 if not props['type']:
1845 props['type'] = "application/octet-stream"
1846 # finally, read the content
1847 value = value.value
1848 else:
1849 # normal String fix the CRLF/CR -> LF stuff
1850 value = fixNewlines(value)
1852 elif isinstance(proptype, hyperdb.Date):
1853 value = date.Date(value, offset=timezone)
1854 elif isinstance(proptype, hyperdb.Interval):
1855 value = date.Interval(value)
1856 elif isinstance(proptype, hyperdb.Boolean):
1857 value = value.lower() in ('yes', 'true', 'on', '1')
1858 elif isinstance(proptype, hyperdb.Number):
1859 value = float(value)
1860 except ValueError, msg:
1861 raise ValueError, _('Error with %s property: %s')%(
1862 propname, msg)
1864 # register that we got this property
1865 if value:
1866 got_props[this][propname] = 1
1868 # get the old value
1869 if nodeid and not nodeid.startswith('-'):
1870 try:
1871 existing = cl.get(nodeid, propname)
1872 except KeyError:
1873 # this might be a new property for which there is
1874 # no existing value
1875 if not propdef.has_key(propname):
1876 raise
1878 # make sure the existing multilink is sorted
1879 if isinstance(proptype, hyperdb.Multilink):
1880 existing.sort()
1882 # "missing" existing values may not be None
1883 if not existing:
1884 if isinstance(proptype, hyperdb.String) and not existing:
1885 # some backends store "missing" Strings as empty strings
1886 existing = None
1887 elif isinstance(proptype, hyperdb.Number) and not existing:
1888 # some backends store "missing" Numbers as 0 :(
1889 existing = 0
1890 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1891 # likewise Booleans
1892 existing = 0
1894 # if changed, set it
1895 if value != existing:
1896 props[propname] = value
1897 else:
1898 # don't bother setting empty/unset values
1899 if value is None:
1900 continue
1901 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1902 continue
1903 elif isinstance(proptype, hyperdb.String) and value == '':
1904 continue
1906 props[propname] = value
1908 # check to see if we need to specially link a file to the note
1909 if have_note and have_file:
1910 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1912 # see if all the required properties have been supplied
1913 s = []
1914 for thing, required in all_required.items():
1915 # register the values we got
1916 got = got_props.get(thing, {})
1917 for entry in required[:]:
1918 if got.has_key(entry):
1919 required.remove(entry)
1921 # any required values not present?
1922 if not required:
1923 continue
1925 # tell the user to entry the values required
1926 if len(required) > 1:
1927 p = 'properties'
1928 else:
1929 p = 'property'
1930 s.append('Required %s %s %s not supplied'%(thing[0], p,
1931 ', '.join(required)))
1932 if s:
1933 raise ValueError, '\n'.join(s)
1935 # When creating a FileClass node, it should have a non-empty content
1936 # property to be created. When editing a FileClass node, it should
1937 # either have a non-empty content property or no property at all. In
1938 # the latter case, nothing will change.
1939 for (cn, id), props in all_props.items():
1940 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1941 if id == '-1':
1942 if not props.get('content', ''):
1943 del all_props[(cn, id)]
1944 elif props.has_key('content') and not props['content']:
1945 raise ValueError, _('File is empty')
1946 return all_props, all_links
1948 def fixNewlines(text):
1949 ''' Homogenise line endings.
1951 Different web clients send different line ending values, but
1952 other systems (eg. email) don't necessarily handle those line
1953 endings. Our solution is to convert all line endings to LF.
1954 '''
1955 text = text.replace('\r\n', '\n')
1956 return text.replace('\r', '\n')
1958 def extractFormList(value):
1959 ''' Extract a list of values from the form value.
1961 It may be one of:
1962 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1963 MiniFieldStorage('value,value,...')
1964 MiniFieldStorage('value')
1965 '''
1966 # multiple values are OK
1967 if isinstance(value, type([])):
1968 # it's a list of MiniFieldStorages - join then into
1969 values = ','.join([i.value.strip() for i in value])
1970 else:
1971 # it's a MiniFieldStorage, but may be a comma-separated list
1972 # of values
1973 values = value.value
1975 value = [i.strip() for i in values.split(',')]
1977 # filter out the empty bits
1978 return filter(None, value)