1 # $Id: client.py,v 1.111 2003-03-26 06:46:17 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, MimeWriter, smtplib, socket, quopri
9 import stat, rfc822, string
11 from roundup import roundupdb, date, hyperdb, password
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
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', '')
35 # XXX actually _use_ FormError
36 class FormError(ValueError):
37 ''' An "expected" exception occurred during form parsing.
38 - ie. something we know can go wrong, and don't want to alarm the
39 user with
41 We trap this at the user interface level and feed back a nice error
42 to the user.
43 '''
44 pass
46 class SendFile(Exception):
47 ''' Send a file from the database '''
49 class SendStaticFile(Exception):
50 ''' Send a static file from the instance html directory '''
52 def initialiseSecurity(security):
53 ''' Create some Permissions and Roles on the security object
55 This function is directly invoked by security.Security.__init__()
56 as a part of the Security object instantiation.
57 '''
58 security.addPermission(name="Web Registration",
59 description="User may register through the web")
60 p = security.addPermission(name="Web Access",
61 description="User may access the web interface")
62 security.addPermissionToRole('Admin', p)
64 # doing Role stuff through the web - make sure Admin can
65 p = security.addPermission(name="Web Roles",
66 description="User may manipulate user Roles through the web")
67 security.addPermissionToRole('Admin', p)
69 class Client:
70 ''' Instantiate to handle one CGI request.
72 See inner_main for request processing.
74 Client attributes at instantiation:
75 "path" is the PATH_INFO inside the instance (with no leading '/')
76 "base" is the base URL for the instance
77 "form" is the cgi form, an instance of FieldStorage from the standard
78 cgi module
79 "additional_headers" is a dictionary of additional HTTP headers that
80 should be sent to the client
81 "response_code" is the HTTP response code to send to the client
83 During the processing of a request, the following attributes are used:
84 "error_message" holds a list of error messages
85 "ok_message" holds a list of OK messages
86 "session" is the current user session id
87 "user" is the current user's name
88 "userid" is the current user's id
89 "template" is the current :template context
90 "classname" is the current class context name
91 "nodeid" is the current context item id
93 User Identification:
94 If the user has no login cookie, then they are anonymous and are logged
95 in as that user. This typically gives them all Permissions assigned to the
96 Anonymous Role.
98 Once a user logs in, they are assigned a session. The Client instance
99 keeps the nodeid of the session as the "session" attribute.
102 Special form variables:
103 Note that in various places throughout this code, special form
104 variables of the form :<name> are used. The colon (":") part may
105 actually be one of either ":" or "@".
106 '''
108 #
109 # special form variables
110 #
111 FV_TEMPLATE = re.compile(r'[@:]template')
112 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
113 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
115 FV_QUERYNAME = re.compile(r'[@:]queryname')
117 # edit form variable handling (see unit tests)
118 FV_LABELS = r'''
119 ^(
120 (?P<note>[@:]note)|
121 (?P<file>[@:]file)|
122 (
123 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
124 ((?P<required>[@:]required$)| # :required
125 (
126 (
127 (?P<add>[@:]add[@:])| # :add:<prop>
128 (?P<remove>[@:]remove[@:])| # :remove:<prop>
129 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
130 (?P<link>[@:]link[@:])| # :link:<prop>
131 ([@:]) # just a separator
132 )?
133 (?P<propname>[^@:]+) # <prop>
134 )
135 )
136 )
137 )$'''
139 # Note: index page stuff doesn't appear here:
140 # columns, sort, sortdir, filter, group, groupdir, search_text,
141 # pagesize, startwith
143 def __init__(self, instance, request, env, form=None):
144 hyperdb.traceMark()
145 self.instance = instance
146 self.request = request
147 self.env = env
149 # save off the path
150 self.path = env['PATH_INFO']
152 # this is the base URL for this tracker
153 self.base = self.instance.config.TRACKER_WEB
155 # this is the "cookie path" for this tracker (ie. the path part of
156 # the "base" url)
157 self.cookie_path = urlparse.urlparse(self.base)[2]
158 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
159 self.instance.config.TRACKER_NAME)
161 # see if we need to re-parse the environment for the form (eg Zope)
162 if form is None:
163 self.form = cgi.FieldStorage(environ=env)
164 else:
165 self.form = form
167 # turn debugging on/off
168 try:
169 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
170 except ValueError:
171 # someone gave us a non-int debug level, turn it off
172 self.debug = 0
174 # flag to indicate that the HTTP headers have been sent
175 self.headers_done = 0
177 # additional headers to send with the request - must be registered
178 # before the first write
179 self.additional_headers = {}
180 self.response_code = 200
183 def main(self):
184 ''' Wrap the real main in a try/finally so we always close off the db.
185 '''
186 try:
187 self.inner_main()
188 finally:
189 if hasattr(self, 'db'):
190 self.db.close()
192 def inner_main(self):
193 ''' Process a request.
195 The most common requests are handled like so:
196 1. figure out who we are, defaulting to the "anonymous" user
197 see determine_user
198 2. figure out what the request is for - the context
199 see determine_context
200 3. handle any requested action (item edit, search, ...)
201 see handle_action
202 4. render a template, resulting in HTML output
204 In some situations, exceptions occur:
205 - HTTP Redirect (generally raised by an action)
206 - SendFile (generally raised by determine_context)
207 serve up a FileClass "content" property
208 - SendStaticFile (generally raised by determine_context)
209 serve up a file from the tracker "html" directory
210 - Unauthorised (generally raised by an action)
211 the action is cancelled, the request is rendered and an error
212 message is displayed indicating that permission was not
213 granted for the action to take place
214 - NotFound (raised wherever it needs to be)
215 percolates up to the CGI interface that called the client
216 '''
217 self.ok_message = []
218 self.error_message = []
219 try:
220 # make sure we're identified (even anonymously)
221 self.determine_user()
222 # figure out the context and desired content template
223 self.determine_context()
224 # possibly handle a form submit action (may change self.classname
225 # and self.template, and may also append error/ok_messages)
226 self.handle_action()
227 # now render the page
229 # we don't want clients caching our dynamic pages
230 self.additional_headers['Cache-Control'] = 'no-cache'
231 self.additional_headers['Pragma'] = 'no-cache'
233 # expire this page 5 seconds from now
234 date = rfc822.formatdate(time.time() + 5)
235 self.additional_headers['Expires'] = date
237 # render the content
238 self.write(self.renderContext())
239 except Redirect, url:
240 # let's redirect - if the url isn't None, then we need to do
241 # the headers, otherwise the headers have been set before the
242 # exception was raised
243 if url:
244 self.additional_headers['Location'] = url
245 self.response_code = 302
246 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
247 except SendFile, designator:
248 self.serve_file(designator)
249 except SendStaticFile, file:
250 try:
251 self.serve_static_file(str(file))
252 except NotModified:
253 # send the 304 response
254 self.request.send_response(304)
255 self.request.end_headers()
256 except Unauthorised, message:
257 self.classname = None
258 self.template = ''
259 self.error_message.append(message)
260 self.write(self.renderContext())
261 except NotFound:
262 # pass through
263 raise
264 except:
265 # everything else
266 self.write(cgitb.html())
268 def clean_sessions(self):
269 ''' Age sessions, remove when they haven't been used for a week.
271 Do it only once an hour.
273 Note: also cleans One Time Keys, and other "session" based
274 stuff.
275 '''
276 sessions = self.db.sessions
277 last_clean = sessions.get('last_clean', 'last_use') or 0
279 week = 60*60*24*7
280 hour = 60*60
281 now = time.time()
282 if now - last_clean > hour:
283 # remove aged sessions
284 for sessid in sessions.list():
285 interval = now - sessions.get(sessid, 'last_use')
286 if interval > week:
287 sessions.destroy(sessid)
288 # remove aged otks
289 otks = self.db.otks
290 for sessid in otks.list():
291 interval = now - otks.get(sessid, '__time')
292 if interval > week:
293 otks.destroy(sessid)
294 sessions.set('last_clean', last_use=time.time())
296 def determine_user(self):
297 ''' Determine who the user is
298 '''
299 # determine the uid to use
300 self.opendb('admin')
301 # clean age sessions
302 self.clean_sessions()
303 # make sure we have the session Class
304 sessions = self.db.sessions
306 # look up the user session cookie
307 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
308 user = 'anonymous'
310 # bump the "revision" of the cookie since the format changed
311 if (cookie.has_key(self.cookie_name) and
312 cookie[self.cookie_name].value != 'deleted'):
314 # get the session key from the cookie
315 self.session = cookie[self.cookie_name].value
316 # get the user from the session
317 try:
318 # update the lifetime datestamp
319 sessions.set(self.session, last_use=time.time())
320 sessions.commit()
321 user = sessions.get(self.session, 'user')
322 except KeyError:
323 user = 'anonymous'
325 # sanity check on the user still being valid, getting the userid
326 # at the same time
327 try:
328 self.userid = self.db.user.lookup(user)
329 except (KeyError, TypeError):
330 user = 'anonymous'
332 # make sure the anonymous user is valid if we're using it
333 if user == 'anonymous':
334 self.make_user_anonymous()
335 else:
336 self.user = user
338 # reopen the database as the correct user
339 self.opendb(self.user)
341 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
342 ''' Determine the context of this page from the URL:
344 The URL path after the instance identifier is examined. The path
345 is generally only one entry long.
347 - if there is no path, then we are in the "home" context.
348 * if the path is "_file", then the additional path entry
349 specifies the filename of a static file we're to serve up
350 from the instance "html" directory. Raises a SendStaticFile
351 exception.
352 - if there is something in the path (eg "issue"), it identifies
353 the tracker class we're to display.
354 - if the path is an item designator (eg "issue123"), then we're
355 to display a specific item.
356 * if the path starts with an item designator and is longer than
357 one entry, then we're assumed to be handling an item of a
358 FileClass, and the extra path information gives the filename
359 that the client is going to label the download with (ie
360 "file123/image.png" is nicer to download than "file123"). This
361 raises a SendFile exception.
363 Both of the "*" types of contexts stop before we bother to
364 determine the template we're going to use. That's because they
365 don't actually use templates.
367 The template used is specified by the :template CGI variable,
368 which defaults to:
370 only classname suplied: "index"
371 full item designator supplied: "item"
373 We set:
374 self.classname - the class to display, can be None
375 self.template - the template to render the current context with
376 self.nodeid - the nodeid of the class we're displaying
377 '''
378 # default the optional variables
379 self.classname = None
380 self.nodeid = None
382 # see if a template or messages are specified
383 template_override = ok_message = error_message = None
384 for key in self.form.keys():
385 if self.FV_TEMPLATE.match(key):
386 template_override = self.form[key].value
387 elif self.FV_OK_MESSAGE.match(key):
388 ok_message = self.form[key].value
389 elif self.FV_ERROR_MESSAGE.match(key):
390 error_message = self.form[key].value
392 # determine the classname and possibly nodeid
393 path = self.path.split('/')
394 if not path or path[0] in ('', 'home', 'index'):
395 if template_override is not None:
396 self.template = template_override
397 else:
398 self.template = ''
399 return
400 elif path[0] == '_file':
401 raise SendStaticFile, os.path.join(*path[1:])
402 else:
403 self.classname = path[0]
404 if len(path) > 1:
405 # send the file identified by the designator in path[0]
406 raise SendFile, path[0]
408 # see if we got a designator
409 m = dre.match(self.classname)
410 if m:
411 self.classname = m.group(1)
412 self.nodeid = m.group(2)
413 if not self.db.getclass(self.classname).hasnode(self.nodeid):
414 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
415 # with a designator, we default to item view
416 self.template = 'item'
417 else:
418 # with only a class, we default to index view
419 self.template = 'index'
421 # make sure the classname is valid
422 try:
423 self.db.getclass(self.classname)
424 except KeyError:
425 raise NotFound, self.classname
427 # see if we have a template override
428 if template_override is not None:
429 self.template = template_override
431 # see if we were passed in a message
432 if ok_message:
433 self.ok_message.append(ok_message)
434 if error_message:
435 self.error_message.append(error_message)
437 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
438 ''' Serve the file from the content property of the designated item.
439 '''
440 m = dre.match(str(designator))
441 if not m:
442 raise NotFound, str(designator)
443 classname, nodeid = m.group(1), m.group(2)
444 if classname != 'file':
445 raise NotFound, designator
447 # we just want to serve up the file named
448 file = self.db.file
449 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
450 self.write(file.get(nodeid, 'content'))
452 def serve_static_file(self, file):
453 ims = None
454 # see if there's an if-modified-since...
455 if hasattr(self.request, 'headers'):
456 ims = self.request.headers.getheader('if-modified-since')
457 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
458 # cgi will put the header in the env var
459 ims = self.env['HTTP_IF_MODIFIED_SINCE']
460 filename = os.path.join(self.instance.config.TEMPLATES, file)
461 lmt = os.stat(filename)[stat.ST_MTIME]
462 if ims:
463 ims = rfc822.parsedate(ims)[:6]
464 lmtt = time.gmtime(lmt)[:6]
465 if lmtt <= ims:
466 raise NotModified
468 # we just want to serve up the file named
469 file = str(file)
470 mt = mimetypes.guess_type(file)[0]
471 if not mt:
472 if file.endswith('.css'):
473 mt = 'text/css'
474 else:
475 mt = 'text/plain'
476 self.additional_headers['Content-Type'] = mt
477 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
478 self.write(open(filename, 'rb').read())
480 def renderContext(self):
481 ''' Return a PageTemplate for the named page
482 '''
483 name = self.classname
484 extension = self.template
485 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
487 # catch errors so we can handle PT rendering errors more nicely
488 args = {
489 'ok_message': self.ok_message,
490 'error_message': self.error_message
491 }
492 try:
493 # let the template render figure stuff out
494 return pt.render(self, None, None, **args)
495 except NoTemplate, message:
496 return '<strong>%s</strong>'%message
497 except:
498 # everything else
499 return cgitb.pt_html()
501 # these are the actions that are available
502 actions = (
503 ('edit', 'editItemAction'),
504 ('editcsv', 'editCSVAction'),
505 ('new', 'newItemAction'),
506 ('register', 'registerAction'),
507 ('confrego', 'confRegoAction'),
508 ('passrst', 'passResetAction'),
509 ('login', 'loginAction'),
510 ('logout', 'logout_action'),
511 ('search', 'searchAction'),
512 ('retire', 'retireAction'),
513 ('show', 'showAction'),
514 )
515 def handle_action(self):
516 ''' Determine whether there should be an Action called.
518 The action is defined by the form variable :action which
519 identifies the method on this object to call. The actions
520 are defined in the "actions" sequence on this class.
521 '''
522 if self.form.has_key(':action'):
523 action = self.form[':action'].value.lower()
524 elif self.form.has_key('@action'):
525 action = self.form['@action'].value.lower()
526 else:
527 return None
528 try:
529 # get the action, validate it
530 for name, method in self.actions:
531 if name == action:
532 break
533 else:
534 raise ValueError, 'No such action "%s"'%action
535 # call the mapped action
536 getattr(self, method)()
537 except Redirect:
538 raise
539 except Unauthorised:
540 raise
542 def write(self, content):
543 if not self.headers_done:
544 self.header()
545 self.request.wfile.write(content)
547 def header(self, headers=None, response=None):
548 '''Put up the appropriate header.
549 '''
550 if headers is None:
551 headers = {'Content-Type':'text/html'}
552 if response is None:
553 response = self.response_code
555 # update with additional info
556 headers.update(self.additional_headers)
558 if not headers.has_key('Content-Type'):
559 headers['Content-Type'] = 'text/html'
560 self.request.send_response(response)
561 for entry in headers.items():
562 self.request.send_header(*entry)
563 self.request.end_headers()
564 self.headers_done = 1
565 if self.debug:
566 self.headers_sent = headers
568 def set_cookie(self, user):
569 ''' Set up a session cookie for the user and store away the user's
570 login info against the session.
571 '''
572 # TODO generate a much, much stronger session key ;)
573 self.session = binascii.b2a_base64(repr(random.random())).strip()
575 # clean up the base64
576 if self.session[-1] == '=':
577 if self.session[-2] == '=':
578 self.session = self.session[:-2]
579 else:
580 self.session = self.session[:-1]
582 # insert the session in the sessiondb
583 self.db.sessions.set(self.session, user=user, last_use=time.time())
585 # and commit immediately
586 self.db.sessions.commit()
588 # expire us in a long, long time
589 expire = Cookie._getdate(86400*365)
591 # generate the cookie path - make sure it has a trailing '/'
592 self.additional_headers['Set-Cookie'] = \
593 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
594 expire, self.cookie_path)
596 def make_user_anonymous(self):
597 ''' Make us anonymous
599 This method used to handle non-existence of the 'anonymous'
600 user, but that user is mandatory now.
601 '''
602 self.userid = self.db.user.lookup('anonymous')
603 self.user = 'anonymous'
605 def opendb(self, user):
606 ''' Open the database.
607 '''
608 # open the db if the user has changed
609 if not hasattr(self, 'db') or user != self.db.journaltag:
610 if hasattr(self, 'db'):
611 self.db.close()
612 self.db = self.instance.open(user)
614 #
615 # Actions
616 #
617 def loginAction(self):
618 ''' Attempt to log a user in.
620 Sets up a session for the user which contains the login
621 credentials.
622 '''
623 # we need the username at a minimum
624 if not self.form.has_key('__login_name'):
625 self.error_message.append(_('Username required'))
626 return
628 # get the login info
629 self.user = self.form['__login_name'].value
630 if self.form.has_key('__login_password'):
631 password = self.form['__login_password'].value
632 else:
633 password = ''
635 # make sure the user exists
636 try:
637 self.userid = self.db.user.lookup(self.user)
638 except KeyError:
639 name = self.user
640 self.error_message.append(_('No such user "%(name)s"')%locals())
641 self.make_user_anonymous()
642 return
644 # verify the password
645 if not self.verifyPassword(self.userid, password):
646 self.make_user_anonymous()
647 self.error_message.append(_('Incorrect password'))
648 return
650 # make sure we're allowed to be here
651 if not self.loginPermission():
652 self.make_user_anonymous()
653 self.error_message.append(_("You do not have permission to login"))
654 return
656 # now we're OK, re-open the database for real, using the user
657 self.opendb(self.user)
659 # set the session cookie
660 self.set_cookie(self.user)
662 def verifyPassword(self, userid, password):
663 ''' Verify the password that the user has supplied
664 '''
665 stored = self.db.user.get(self.userid, 'password')
666 if password == stored:
667 return 1
668 if not password and not stored:
669 return 1
670 return 0
672 def loginPermission(self):
673 ''' Determine whether the user has permission to log in.
675 Base behaviour is to check the user has "Web Access".
676 '''
677 if not self.db.security.hasPermission('Web Access', self.userid):
678 return 0
679 return 1
681 def logout_action(self):
682 ''' Make us really anonymous - nuke the cookie too
683 '''
684 # log us out
685 self.make_user_anonymous()
687 # construct the logout cookie
688 now = Cookie._getdate()
689 self.additional_headers['Set-Cookie'] = \
690 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
691 now, self.cookie_path)
693 # Let the user know what's going on
694 self.ok_message.append(_('You are logged out'))
696 chars = string.letters+string.digits
697 def registerAction(self):
698 '''Attempt to create a new user based on the contents of the form
699 and then set the cookie.
701 return 1 on successful login
702 '''
703 # parse the props from the form
704 try:
705 props = self.parsePropsFromForm()[0][('user', None)]
706 except (ValueError, KeyError), message:
707 self.error_message.append(_('Error: ') + str(message))
708 return
710 # make sure we're allowed to register
711 if not self.registerPermission(props):
712 raise Unauthorised, _("You do not have permission to register")
714 try:
715 self.db.user.lookup(props['username'])
716 self.error_message.append('Error: A user with the username "%s" '
717 'already exists'%props['username'])
718 return
719 except KeyError:
720 pass
722 # generate the one-time-key and store the props for later
723 otk = ''.join([random.choice(self.chars) for x in range(32)])
724 for propname, proptype in self.db.user.getprops().items():
725 value = props.get(propname, None)
726 if value is None:
727 pass
728 elif isinstance(proptype, hyperdb.Date):
729 props[propname] = str(value)
730 elif isinstance(proptype, hyperdb.Interval):
731 props[propname] = str(value)
732 elif isinstance(proptype, hyperdb.Password):
733 props[propname] = str(value)
734 props['__time'] = time.time()
735 self.db.otks.set(otk, **props)
737 # send the email
738 tracker_name = self.db.config.TRACKER_NAME
739 subject = 'Complete your registration to %s'%tracker_name
740 body = '''
741 To complete your registration of the user "%(name)s" with %(tracker)s,
742 please visit the following URL:
744 %(url)s?@action=confrego&otk=%(otk)s
745 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
746 'otk': otk}
747 if not self.sendEmail(props['address'], subject, body):
748 return
750 # commit changes to the database
751 self.db.commit()
753 # redirect to the "you're almost there" page
754 raise Redirect, '%suser?@template=rego_progress'%self.base
756 def sendEmail(self, to, subject, content):
757 # send email to the user's email address
758 message = StringIO.StringIO()
759 writer = MimeWriter.MimeWriter(message)
760 tracker_name = self.db.config.TRACKER_NAME
761 writer.addheader('Subject', encode_header(subject))
762 writer.addheader('To', to)
763 writer.addheader('From', roundupdb.straddr((tracker_name,
764 self.db.config.ADMIN_EMAIL)))
765 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
766 time.gmtime()))
767 # add a uniquely Roundup header to help filtering
768 writer.addheader('X-Roundup-Name', tracker_name)
769 # avoid email loops
770 writer.addheader('X-Roundup-Loop', 'hello')
771 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
772 body = writer.startbody('text/plain; charset=utf-8')
774 # message body, encoded quoted-printable
775 content = StringIO.StringIO(content)
776 quopri.encode(content, body, 0)
778 if SENDMAILDEBUG:
779 # don't send - just write to a file
780 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
781 self.db.config.ADMIN_EMAIL,
782 ', '.join(to),message.getvalue()))
783 else:
784 # now try to send the message
785 try:
786 # send the message as admin so bounces are sent there
787 # instead of to roundup
788 smtp = smtplib.SMTP(self.db.config.MAILHOST)
789 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
790 message.getvalue())
791 except socket.error, value:
792 self.error_message.append("Error: couldn't send email: "
793 "mailhost %s"%value)
794 return 0
795 except smtplib.SMTPException, msg:
796 self.error_message.append("Error: couldn't send email: %s"%msg)
797 return 0
798 return 1
800 def registerPermission(self, props):
801 ''' Determine whether the user has permission to register
803 Base behaviour is to check the user has "Web Registration".
804 '''
805 # registration isn't allowed to supply roles
806 if props.has_key('roles'):
807 return 0
808 if self.db.security.hasPermission('Web Registration', self.userid):
809 return 1
810 return 0
812 def confRegoAction(self):
813 ''' Grab the OTK, use it to load up the new user details
814 '''
815 # pull the rego information out of the otk database
816 otk = self.form['otk'].value
817 props = self.db.otks.getall(otk)
818 for propname, proptype in self.db.user.getprops().items():
819 value = props.get(propname, None)
820 if value is None:
821 pass
822 elif isinstance(proptype, hyperdb.Date):
823 props[propname] = date.Date(value)
824 elif isinstance(proptype, hyperdb.Interval):
825 props[propname] = date.Interval(value)
826 elif isinstance(proptype, hyperdb.Password):
827 props[propname] = password.Password()
828 props[propname].unpack(value)
830 # re-open the database as "admin"
831 if self.user != 'admin':
832 self.opendb('admin')
834 # create the new user
835 cl = self.db.user
836 # XXX we need to make the "default" page be able to display errors!
837 try:
838 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
839 del props['__time']
840 self.userid = cl.create(**props)
841 # clear the props from the otk database
842 self.db.otks.destroy(otk)
843 self.db.commit()
844 except (ValueError, KeyError), message:
845 self.error_message.append(str(message))
846 return
848 # log the new user in
849 self.user = cl.get(self.userid, 'username')
850 # re-open the database for real, using the user
851 self.opendb(self.user)
853 # if we have a session, update it
854 if hasattr(self, 'session'):
855 self.db.sessions.set(self.session, user=self.user,
856 last_use=time.time())
857 else:
858 # new session cookie
859 self.set_cookie(self.user)
861 # nice message
862 message = _('You are now registered, welcome!')
864 # redirect to the user's page
865 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
866 self.userid, urllib.quote(message))
868 def passResetAction(self):
869 ''' Handle password reset requests.
871 Presence of either "name" or "address" generate email.
872 Presense of "otk" performs the reset.
873 '''
874 if self.form.has_key('otk'):
875 # pull the rego information out of the otk database
876 otk = self.form['otk'].value
877 uid = self.db.otks.get(otk, 'uid')
878 if uid is None:
879 self.error_message.append('Invalid One Time Key!')
880 return
882 # re-open the database as "admin"
883 if self.user != 'admin':
884 self.opendb('admin')
886 # change the password
887 newpw = ''.join([random.choice(self.chars) for x in range(8)])
889 cl = self.db.user
890 # XXX we need to make the "default" page be able to display errors!
891 try:
892 # set the password
893 cl.set(uid, password=password.Password(newpw))
894 # clear the props from the otk database
895 self.db.otks.destroy(otk)
896 self.db.commit()
897 except (ValueError, KeyError), message:
898 self.error_message.append(str(message))
899 return
901 # user info
902 address = self.db.user.get(uid, 'address')
903 name = self.db.user.get(uid, 'username')
905 # send the email
906 tracker_name = self.db.config.TRACKER_NAME
907 subject = 'Password reset for %s'%tracker_name
908 body = '''
909 The password has been reset for username "%(name)s".
911 Your password is now: %(password)s
912 '''%{'name': name, 'password': newpw}
913 if not self.sendEmail(address, subject, body):
914 return
916 self.ok_message.append('Password reset and email sent to %s'%address)
917 return
919 # no OTK, so now figure the user
920 if self.form.has_key('username'):
921 name = self.form['username'].value
922 try:
923 uid = self.db.user.lookup(name)
924 except KeyError:
925 self.error_message.append('Unknown username')
926 return
927 address = self.db.user.get(uid, 'address')
928 elif self.form.has_key('address'):
929 address = self.form['address'].value
930 uid = uidFromAddress(self.db, ('', address), create=0)
931 if not uid:
932 self.error_message.append('Unknown email address')
933 return
934 name = self.db.user.get(uid, 'username')
935 else:
936 self.error_message.append('You need to specify a username '
937 'or address')
938 return
940 # generate the one-time-key and store the props for later
941 otk = ''.join([random.choice(self.chars) for x in range(32)])
942 self.db.otks.set(otk, uid=uid, __time=time.time())
944 # send the email
945 tracker_name = self.db.config.TRACKER_NAME
946 subject = 'Confirm reset of password for %s'%tracker_name
947 body = '''
948 Someone, perhaps you, has requested that the password be changed for your
949 username, "%(name)s". If you wish to proceed with the change, please follow
950 the link below:
952 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
954 You should then receive another email with the new password.
955 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
956 if not self.sendEmail(address, subject, body):
957 return
959 self.ok_message.append('Email sent to %s'%address)
961 def editItemAction(self):
962 ''' Perform an edit of an item in the database.
964 See parsePropsFromForm and _editnodes for special variables
965 '''
966 # parse the props from the form
967 try:
968 props, links = self.parsePropsFromForm()
969 except (ValueError, KeyError), message:
970 self.error_message.append(_('Error: ') + str(message))
971 return
973 # handle the props
974 try:
975 message = self._editnodes(props, links)
976 except (ValueError, KeyError, IndexError), message:
977 self.error_message.append(_('Error: ') + str(message))
978 return
980 # commit now that all the tricky stuff is done
981 self.db.commit()
983 # redirect to the item's edit page
984 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
985 self.classname, self.nodeid, urllib.quote(message),
986 urllib.quote(self.template))
988 def editItemPermission(self, props):
989 ''' Determine whether the user has permission to edit this item.
991 Base behaviour is to check the user can edit this class. If we're
992 editing the "user" class, users are allowed to edit their own
993 details. Unless it's the "roles" property, which requires the
994 special Permission "Web Roles".
995 '''
996 # if this is a user node and the user is editing their own node, then
997 # we're OK
998 has = self.db.security.hasPermission
999 if self.classname == 'user':
1000 # reject if someone's trying to edit "roles" and doesn't have the
1001 # right permission.
1002 if props.has_key('roles') and not has('Web Roles', self.userid,
1003 'user'):
1004 return 0
1005 # if the item being edited is the current user, we're ok
1006 if self.nodeid == self.userid:
1007 return 1
1008 if self.db.security.hasPermission('Edit', self.userid, self.classname):
1009 return 1
1010 return 0
1012 def newItemAction(self):
1013 ''' Add a new item to the database.
1015 This follows the same form as the editItemAction, with the same
1016 special form values.
1017 '''
1018 # parse the props from the form
1019 try:
1020 props, links = self.parsePropsFromForm()
1021 except (ValueError, KeyError), message:
1022 self.error_message.append(_('Error: ') + str(message))
1023 return
1025 # handle the props - edit or create
1026 try:
1027 # when it hits the None element, it'll set self.nodeid
1028 messages = self._editnodes(props, links)
1030 except (ValueError, KeyError, IndexError), message:
1031 # these errors might just be indicative of user dumbness
1032 self.error_message.append(_('Error: ') + str(message))
1033 return
1035 # commit now that all the tricky stuff is done
1036 self.db.commit()
1038 # redirect to the new item's page
1039 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1040 self.classname, self.nodeid, urllib.quote(messages),
1041 urllib.quote(self.template))
1043 def newItemPermission(self, props):
1044 ''' Determine whether the user has permission to create (edit) this
1045 item.
1047 Base behaviour is to check the user can edit this class. No
1048 additional property checks are made. Additionally, new user items
1049 may be created if the user has the "Web Registration" Permission.
1050 '''
1051 has = self.db.security.hasPermission
1052 if self.classname == 'user' and has('Web Registration', self.userid,
1053 'user'):
1054 return 1
1055 if has('Edit', self.userid, self.classname):
1056 return 1
1057 return 0
1060 #
1061 # Utility methods for editing
1062 #
1063 def _editnodes(self, all_props, all_links, newids=None):
1064 ''' Use the props in all_props to perform edit and creation, then
1065 use the link specs in all_links to do linking.
1066 '''
1067 # figure dependencies and re-work links
1068 deps = {}
1069 links = {}
1070 for cn, nodeid, propname, vlist in all_links:
1071 if not all_props.has_key((cn, nodeid)):
1072 # link item to link to doesn't (and won't) exist
1073 continue
1074 for value in vlist:
1075 if not all_props.has_key(value):
1076 # link item to link to doesn't (and won't) exist
1077 continue
1078 deps.setdefault((cn, nodeid), []).append(value)
1079 links.setdefault(value, []).append((cn, nodeid, propname))
1081 # figure chained dependencies ordering
1082 order = []
1083 done = {}
1084 # loop detection
1085 change = 0
1086 while len(all_props) != len(done):
1087 for needed in all_props.keys():
1088 if done.has_key(needed):
1089 continue
1090 tlist = deps.get(needed, [])
1091 for target in tlist:
1092 if not done.has_key(target):
1093 break
1094 else:
1095 done[needed] = 1
1096 order.append(needed)
1097 change = 1
1098 if not change:
1099 raise ValueError, 'linking must not loop!'
1101 # now, edit / create
1102 m = []
1103 for needed in order:
1104 props = all_props[needed]
1105 if not props:
1106 # nothing to do
1107 continue
1108 cn, nodeid = needed
1110 if nodeid is not None and int(nodeid) > 0:
1111 # make changes to the node
1112 props = self._changenode(cn, nodeid, props)
1114 # and some nice feedback for the user
1115 if props:
1116 info = ', '.join(props.keys())
1117 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1118 else:
1119 m.append('%s %s - nothing changed'%(cn, nodeid))
1120 else:
1121 assert props
1123 # make a new node
1124 newid = self._createnode(cn, props)
1125 if nodeid is None:
1126 self.nodeid = newid
1127 nodeid = newid
1129 # and some nice feedback for the user
1130 m.append('%s %s created'%(cn, newid))
1132 # fill in new ids in links
1133 if links.has_key(needed):
1134 for linkcn, linkid, linkprop in links[needed]:
1135 props = all_props[(linkcn, linkid)]
1136 cl = self.db.classes[linkcn]
1137 propdef = cl.getprops()[linkprop]
1138 if not props.has_key(linkprop):
1139 if linkid is None or linkid.startswith('-'):
1140 # linking to a new item
1141 if isinstance(propdef, hyperdb.Multilink):
1142 props[linkprop] = [newid]
1143 else:
1144 props[linkprop] = newid
1145 else:
1146 # linking to an existing item
1147 if isinstance(propdef, hyperdb.Multilink):
1148 existing = cl.get(linkid, linkprop)[:]
1149 existing.append(nodeid)
1150 props[linkprop] = existing
1151 else:
1152 props[linkprop] = newid
1154 return '<br>'.join(m)
1156 def _changenode(self, cn, nodeid, props):
1157 ''' change the node based on the contents of the form
1158 '''
1159 # check for permission
1160 if not self.editItemPermission(props):
1161 raise Unauthorised, 'You do not have permission to edit %s'%cn
1163 # make the changes
1164 cl = self.db.classes[cn]
1165 return cl.set(nodeid, **props)
1167 def _createnode(self, cn, props):
1168 ''' create a node based on the contents of the form
1169 '''
1170 # check for permission
1171 if not self.newItemPermission(props):
1172 raise Unauthorised, 'You do not have permission to create %s'%cn
1174 # create the node and return its id
1175 cl = self.db.classes[cn]
1176 return cl.create(**props)
1178 #
1179 # More actions
1180 #
1181 def editCSVAction(self):
1182 ''' Performs an edit of all of a class' items in one go.
1184 The "rows" CGI var defines the CSV-formatted entries for the
1185 class. New nodes are identified by the ID 'X' (or any other
1186 non-existent ID) and removed lines are retired.
1187 '''
1188 # this is per-class only
1189 if not self.editCSVPermission():
1190 self.error_message.append(
1191 _('You do not have permission to edit %s' %self.classname))
1193 # get the CSV module
1194 try:
1195 import csv
1196 except ImportError:
1197 self.error_message.append(_(
1198 'Sorry, you need the csv module to use this function.<br>\n'
1199 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1200 return
1202 cl = self.db.classes[self.classname]
1203 idlessprops = cl.getprops(protected=0).keys()
1204 idlessprops.sort()
1205 props = ['id'] + idlessprops
1207 # do the edit
1208 rows = self.form['rows'].value.splitlines()
1209 p = csv.parser()
1210 found = {}
1211 line = 0
1212 for row in rows[1:]:
1213 line += 1
1214 values = p.parse(row)
1215 # not a complete row, keep going
1216 if not values: continue
1218 # skip property names header
1219 if values == props:
1220 continue
1222 # extract the nodeid
1223 nodeid, values = values[0], values[1:]
1224 found[nodeid] = 1
1226 # see if the node exists
1227 if cl.hasnode(nodeid):
1228 exists = 1
1229 else:
1230 exists = 0
1232 # confirm correct weight
1233 if len(idlessprops) != len(values):
1234 self.error_message.append(
1235 _('Not enough values on line %(line)s')%{'line':line})
1236 return
1238 # extract the new values
1239 d = {}
1240 for name, value in zip(idlessprops, values):
1241 prop = cl.properties[name]
1242 value = value.strip()
1243 # only add the property if it has a value
1244 if value:
1245 # if it's a multilink, split it
1246 if isinstance(prop, hyperdb.Multilink):
1247 value = value.split(':')
1248 d[name] = value
1249 elif exists:
1250 # nuke the existing value
1251 if isinstance(prop, hyperdb.Multilink):
1252 d[name] = []
1253 else:
1254 d[name] = None
1256 # perform the edit
1257 if exists:
1258 # edit existing
1259 cl.set(nodeid, **d)
1260 else:
1261 # new node
1262 found[cl.create(**d)] = 1
1264 # retire the removed entries
1265 for nodeid in cl.list():
1266 if not found.has_key(nodeid):
1267 cl.retire(nodeid)
1269 # all OK
1270 self.db.commit()
1272 self.ok_message.append(_('Items edited OK'))
1274 def editCSVPermission(self):
1275 ''' Determine whether the user has permission to edit this class.
1277 Base behaviour is to check the user can edit this class.
1278 '''
1279 if not self.db.security.hasPermission('Edit', self.userid,
1280 self.classname):
1281 return 0
1282 return 1
1284 def searchAction(self):
1285 ''' Mangle some of the form variables.
1287 Set the form ":filter" variable based on the values of the
1288 filter variables - if they're set to anything other than
1289 "dontcare" then add them to :filter.
1291 Also handle the ":queryname" variable and save off the query to
1292 the user's query list.
1293 '''
1294 # generic edit is per-class only
1295 if not self.searchPermission():
1296 self.error_message.append(
1297 _('You do not have permission to search %s' %self.classname))
1299 # add a faked :filter form variable for each filtering prop
1300 props = self.db.classes[self.classname].getprops()
1301 queryname = ''
1302 for key in self.form.keys():
1303 # special vars
1304 if self.FV_QUERYNAME.match(key):
1305 queryname = self.form[key].value.strip()
1306 continue
1308 if not props.has_key(key):
1309 continue
1310 if isinstance(self.form[key], type([])):
1311 # search for at least one entry which is not empty
1312 for minifield in self.form[key]:
1313 if minifield.value:
1314 break
1315 else:
1316 continue
1317 else:
1318 if not self.form[key].value:
1319 continue
1320 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1322 # handle saving the query params
1323 if queryname:
1324 # parse the environment and figure what the query _is_
1325 req = HTMLRequest(self)
1326 url = req.indexargs_href('', {})
1328 # handle editing an existing query
1329 try:
1330 qid = self.db.query.lookup(queryname)
1331 self.db.query.set(qid, klass=self.classname, url=url)
1332 except KeyError:
1333 # create a query
1334 qid = self.db.query.create(name=queryname,
1335 klass=self.classname, url=url)
1337 # and add it to the user's query multilink
1338 queries = self.db.user.get(self.userid, 'queries')
1339 queries.append(qid)
1340 self.db.user.set(self.userid, queries=queries)
1342 # commit the query change to the database
1343 self.db.commit()
1345 def searchPermission(self):
1346 ''' Determine whether the user has permission to search this class.
1348 Base behaviour is to check the user can view this class.
1349 '''
1350 if not self.db.security.hasPermission('View', self.userid,
1351 self.classname):
1352 return 0
1353 return 1
1356 def retireAction(self):
1357 ''' Retire the context item.
1358 '''
1359 # if we want to view the index template now, then unset the nodeid
1360 # context info (a special-case for retire actions on the index page)
1361 nodeid = self.nodeid
1362 if self.template == 'index':
1363 self.nodeid = None
1365 # generic edit is per-class only
1366 if not self.retirePermission():
1367 self.error_message.append(
1368 _('You do not have permission to retire %s' %self.classname))
1369 return
1371 # make sure we don't try to retire admin or anonymous
1372 if self.classname == 'user' and \
1373 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1374 self.error_message.append(
1375 _('You may not retire the admin or anonymous user'))
1376 return
1378 # do the retire
1379 self.db.getclass(self.classname).retire(nodeid)
1380 self.db.commit()
1382 self.ok_message.append(
1383 _('%(classname)s %(itemid)s has been retired')%{
1384 'classname': self.classname.capitalize(), 'itemid': nodeid})
1386 def retirePermission(self):
1387 ''' Determine whether the user has permission to retire this class.
1389 Base behaviour is to check the user can edit this class.
1390 '''
1391 if not self.db.security.hasPermission('Edit', self.userid,
1392 self.classname):
1393 return 0
1394 return 1
1397 def showAction(self, typere=re.compile('[@:]type'),
1398 numre=re.compile('[@:]number')):
1399 ''' Show a node of a particular class/id
1400 '''
1401 t = n = ''
1402 for key in self.form.keys():
1403 if typere.match(key):
1404 t = self.form[key].value.strip()
1405 elif numre.match(key):
1406 n = self.form[key].value.strip()
1407 if not t:
1408 raise ValueError, 'Invalid %s number'%t
1409 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1410 raise Redirect, url
1412 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1413 ''' Pull properties out of the form.
1415 In the following, <bracketed> values are variable, ":" may be
1416 one of ":" or "@", and other text "required" is fixed.
1418 Properties are specified as form variables:
1420 <propname>
1421 - property on the current context item
1423 <designator>:<propname>
1424 - property on the indicated item
1426 <classname>-<N>:<propname>
1427 - property on the Nth new item of classname
1429 Once we have determined the "propname", we check to see if it
1430 is one of the special form values:
1432 :required
1433 The named property values must be supplied or a ValueError
1434 will be raised.
1436 :remove:<propname>=id(s)
1437 The ids will be removed from the multilink property.
1439 :add:<propname>=id(s)
1440 The ids will be added to the multilink property.
1442 :link:<propname>=<designator>
1443 Used to add a link to new items created during edit.
1444 These are collected up and returned in all_links. This will
1445 result in an additional linking operation (either Link set or
1446 Multilink append) after the edit/create is done using
1447 all_props in _editnodes. The <propname> on the current item
1448 will be set/appended the id of the newly created item of
1449 class <designator> (where <designator> must be
1450 <classname>-<N>).
1452 Any of the form variables may be prefixed with a classname or
1453 designator.
1455 The return from this method is a dict of
1456 (classname, id): properties
1457 ... this dict _always_ has an entry for the current context,
1458 even if it's empty (ie. a submission for an existing issue that
1459 doesn't result in any changes would return {('issue','123'): {}})
1460 The id may be None, which indicates that an item should be
1461 created.
1463 If a String property's form value is a file upload, then we
1464 try to set additional properties "filename" and "type" (if
1465 they are valid for the class).
1467 Two special form values are supported for backwards
1468 compatibility:
1469 :note - create a message (with content, author and date), link
1470 to the context item. This is ALWAYS desginated "msg-1".
1471 :file - create a file, attach to the current item and any
1472 message created by :note. This is ALWAYS designated
1473 "file-1".
1475 We also check that FileClass items have a "content" property with
1476 actual content, otherwise we remove them from all_props before
1477 returning.
1478 '''
1479 # some very useful variables
1480 db = self.db
1481 form = self.form
1483 if not hasattr(self, 'FV_SPECIAL'):
1484 # generate the regexp for handling special form values
1485 classes = '|'.join(db.classes.keys())
1486 # specials for parsePropsFromForm
1487 # handle the various forms (see unit tests)
1488 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1489 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1491 # these indicate the default class / item
1492 default_cn = self.classname
1493 default_cl = self.db.classes[default_cn]
1494 default_nodeid = self.nodeid
1496 # we'll store info about the individual class/item edit in these
1497 all_required = {} # one entry per class/item
1498 all_props = {} # one entry per class/item
1499 all_propdef = {} # note - only one entry per class
1500 all_links = [] # as many as are required
1502 # we should always return something, even empty, for the context
1503 all_props[(default_cn, default_nodeid)] = {}
1505 keys = form.keys()
1506 timezone = db.getUserTimezone()
1508 # sentinels for the :note and :file props
1509 have_note = have_file = 0
1511 # extract the usable form labels from the form
1512 matches = []
1513 for key in keys:
1514 m = self.FV_SPECIAL.match(key)
1515 if m:
1516 matches.append((key, m.groupdict()))
1518 # now handle the matches
1519 for key, d in matches:
1520 if d['classname']:
1521 # we got a designator
1522 cn = d['classname']
1523 cl = self.db.classes[cn]
1524 nodeid = d['id']
1525 propname = d['propname']
1526 elif d['note']:
1527 # the special note field
1528 cn = 'msg'
1529 cl = self.db.classes[cn]
1530 nodeid = '-1'
1531 propname = 'content'
1532 all_links.append((default_cn, default_nodeid, 'messages',
1533 [('msg', '-1')]))
1534 have_note = 1
1535 elif d['file']:
1536 # the special file field
1537 cn = 'file'
1538 cl = self.db.classes[cn]
1539 nodeid = '-1'
1540 propname = 'content'
1541 all_links.append((default_cn, default_nodeid, 'files',
1542 [('file', '-1')]))
1543 have_file = 1
1544 else:
1545 # default
1546 cn = default_cn
1547 cl = default_cl
1548 nodeid = default_nodeid
1549 propname = d['propname']
1551 # the thing this value relates to is...
1552 this = (cn, nodeid)
1554 # get more info about the class, and the current set of
1555 # form props for it
1556 if not all_propdef.has_key(cn):
1557 all_propdef[cn] = cl.getprops()
1558 propdef = all_propdef[cn]
1559 if not all_props.has_key(this):
1560 all_props[this] = {}
1561 props = all_props[this]
1563 # is this a link command?
1564 if d['link']:
1565 value = []
1566 for entry in extractFormList(form[key]):
1567 m = self.FV_DESIGNATOR.match(entry)
1568 if not m:
1569 raise ValueError, \
1570 'link "%s" value "%s" not a designator'%(key, entry)
1571 value.append((m.group(1), m.group(2)))
1573 # make sure the link property is valid
1574 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1575 not isinstance(propdef[propname], hyperdb.Link)):
1576 raise ValueError, '%s %s is not a link or '\
1577 'multilink property'%(cn, propname)
1579 all_links.append((cn, nodeid, propname, value))
1580 continue
1582 # detect the special ":required" variable
1583 if d['required']:
1584 all_required[this] = extractFormList(form[key])
1585 continue
1587 # get the required values list
1588 if not all_required.has_key(this):
1589 all_required[this] = []
1590 required = all_required[this]
1592 # see if we're performing a special multilink action
1593 mlaction = 'set'
1594 if d['remove']:
1595 mlaction = 'remove'
1596 elif d['add']:
1597 mlaction = 'add'
1599 # does the property exist?
1600 if not propdef.has_key(propname):
1601 if mlaction != 'set':
1602 raise ValueError, 'You have submitted a %s action for'\
1603 ' the property "%s" which doesn\'t exist'%(mlaction,
1604 propname)
1605 # the form element is probably just something we don't care
1606 # about - ignore it
1607 continue
1608 proptype = propdef[propname]
1610 # Get the form value. This value may be a MiniFieldStorage or a list
1611 # of MiniFieldStorages.
1612 value = form[key]
1614 # handle unpacking of the MiniFieldStorage / list form value
1615 if isinstance(proptype, hyperdb.Multilink):
1616 value = extractFormList(value)
1617 else:
1618 # multiple values are not OK
1619 if isinstance(value, type([])):
1620 raise ValueError, 'You have submitted more than one value'\
1621 ' for the %s property'%propname
1622 # value might be a file upload...
1623 if not hasattr(value, 'filename') or value.filename is None:
1624 # nope, pull out the value and strip it
1625 value = value.value.strip()
1627 # now that we have the props field, we need a teensy little
1628 # extra bit of help for the old :note field...
1629 if d['note'] and value:
1630 props['author'] = self.db.getuid()
1631 props['date'] = date.Date()
1633 # handle by type now
1634 if isinstance(proptype, hyperdb.Password):
1635 if not value:
1636 # ignore empty password values
1637 continue
1638 for key, d in matches:
1639 if d['confirm'] and d['propname'] == propname:
1640 confirm = form[key]
1641 break
1642 else:
1643 raise ValueError, 'Password and confirmation text do '\
1644 'not match'
1645 if isinstance(confirm, type([])):
1646 raise ValueError, 'You have submitted more than one value'\
1647 ' for the %s property'%propname
1648 if value != confirm.value:
1649 raise ValueError, 'Password and confirmation text do '\
1650 'not match'
1651 value = password.Password(value)
1653 elif isinstance(proptype, hyperdb.Link):
1654 # see if it's the "no selection" choice
1655 if value == '-1' or not value:
1656 # if we're creating, just don't include this property
1657 if not nodeid or nodeid.startswith('-'):
1658 continue
1659 value = None
1660 else:
1661 # handle key values
1662 link = proptype.classname
1663 if not num_re.match(value):
1664 try:
1665 value = db.classes[link].lookup(value)
1666 except KeyError:
1667 raise ValueError, _('property "%(propname)s": '
1668 '%(value)s not a %(classname)s')%{
1669 'propname': propname, 'value': value,
1670 'classname': link}
1671 except TypeError, message:
1672 raise ValueError, _('you may only enter ID values '
1673 'for property "%(propname)s": %(message)s')%{
1674 'propname': propname, 'message': message}
1675 elif isinstance(proptype, hyperdb.Multilink):
1676 # perform link class key value lookup if necessary
1677 link = proptype.classname
1678 link_cl = db.classes[link]
1679 l = []
1680 for entry in value:
1681 if not entry: continue
1682 if not num_re.match(entry):
1683 try:
1684 entry = link_cl.lookup(entry)
1685 except KeyError:
1686 raise ValueError, _('property "%(propname)s": '
1687 '"%(value)s" not an entry of %(classname)s')%{
1688 'propname': propname, 'value': entry,
1689 'classname': link}
1690 except TypeError, message:
1691 raise ValueError, _('you may only enter ID values '
1692 'for property "%(propname)s": %(message)s')%{
1693 'propname': propname, 'message': message}
1694 l.append(entry)
1695 l.sort()
1697 # now use that list of ids to modify the multilink
1698 if mlaction == 'set':
1699 value = l
1700 else:
1701 # we're modifying the list - get the current list of ids
1702 if props.has_key(propname):
1703 existing = props[propname]
1704 elif nodeid and not nodeid.startswith('-'):
1705 existing = cl.get(nodeid, propname, [])
1706 else:
1707 existing = []
1709 # now either remove or add
1710 if mlaction == 'remove':
1711 # remove - handle situation where the id isn't in
1712 # the list
1713 for entry in l:
1714 try:
1715 existing.remove(entry)
1716 except ValueError:
1717 raise ValueError, _('property "%(propname)s": '
1718 '"%(value)s" not currently in list')%{
1719 'propname': propname, 'value': entry}
1720 else:
1721 # add - easy, just don't dupe
1722 for entry in l:
1723 if entry not in existing:
1724 existing.append(entry)
1725 value = existing
1726 value.sort()
1728 elif value == '':
1729 # if we're creating, just don't include this property
1730 if not nodeid or nodeid.startswith('-'):
1731 continue
1732 # other types should be None'd if there's no value
1733 value = None
1734 else:
1735 # handle ValueErrors for all these in a similar fashion
1736 try:
1737 if isinstance(proptype, hyperdb.String):
1738 if (hasattr(value, 'filename') and
1739 value.filename is not None):
1740 # skip if the upload is empty
1741 if not value.filename:
1742 continue
1743 # this String is actually a _file_
1744 # try to determine the file content-type
1745 fn = value.filename.split('\\')[-1]
1746 if propdef.has_key('name'):
1747 props['name'] = fn
1748 # use this info as the type/filename properties
1749 if propdef.has_key('type'):
1750 props['type'] = mimetypes.guess_type(fn)[0]
1751 if not props['type']:
1752 props['type'] = "application/octet-stream"
1753 # finally, read the content
1754 value = value.value
1755 else:
1756 # normal String fix the CRLF/CR -> LF stuff
1757 value = fixNewlines(value)
1759 elif isinstance(proptype, hyperdb.Date):
1760 value = date.Date(value, offset=timezone)
1761 elif isinstance(proptype, hyperdb.Interval):
1762 value = date.Interval(value)
1763 elif isinstance(proptype, hyperdb.Boolean):
1764 value = value.lower() in ('yes', 'true', 'on', '1')
1765 elif isinstance(proptype, hyperdb.Number):
1766 value = float(value)
1767 except ValueError, msg:
1768 raise ValueError, _('Error with %s property: %s')%(
1769 propname, msg)
1771 # get the old value
1772 if nodeid and not nodeid.startswith('-'):
1773 try:
1774 existing = cl.get(nodeid, propname)
1775 except KeyError:
1776 # this might be a new property for which there is
1777 # no existing value
1778 if not propdef.has_key(propname):
1779 raise
1781 # make sure the existing multilink is sorted
1782 if isinstance(proptype, hyperdb.Multilink):
1783 existing.sort()
1785 # "missing" existing values may not be None
1786 if not existing:
1787 if isinstance(proptype, hyperdb.String) and not existing:
1788 # some backends store "missing" Strings as empty strings
1789 existing = None
1790 elif isinstance(proptype, hyperdb.Number) and not existing:
1791 # some backends store "missing" Numbers as 0 :(
1792 existing = 0
1793 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1794 # likewise Booleans
1795 existing = 0
1797 # if changed, set it
1798 if value != existing:
1799 props[propname] = value
1800 else:
1801 # don't bother setting empty/unset values
1802 if value is None:
1803 continue
1804 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1805 continue
1806 elif isinstance(proptype, hyperdb.String) and value == '':
1807 continue
1809 props[propname] = value
1811 # register this as received if required?
1812 if propname in required and value is not None:
1813 required.remove(propname)
1815 # check to see if we need to specially link a file to the note
1816 if have_note and have_file:
1817 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1819 # see if all the required properties have been supplied
1820 s = []
1821 for thing, required in all_required.items():
1822 if not required:
1823 continue
1824 if len(required) > 1:
1825 p = 'properties'
1826 else:
1827 p = 'property'
1828 s.append('Required %s %s %s not supplied'%(thing[0], p,
1829 ', '.join(required)))
1830 if s:
1831 raise ValueError, '\n'.join(s)
1833 # check that FileClass entries have a "content" property with
1834 # content, otherwise remove them
1835 for (cn, id), props in all_props.items():
1836 cl = self.db.classes[cn]
1837 if not isinstance(cl, hyperdb.FileClass):
1838 continue
1839 # we also don't want to create FileClass items with no content
1840 if not props.get('content', ''):
1841 del all_props[(cn, id)]
1842 return all_props, all_links
1844 def fixNewlines(text):
1845 ''' Homogenise line endings.
1847 Different web clients send different line ending values, but
1848 other systems (eg. email) don't necessarily handle those line
1849 endings. Our solution is to convert all line endings to LF.
1850 '''
1851 text = text.replace('\r\n', '\n')
1852 return text.replace('\r', '\n')
1854 def extractFormList(value):
1855 ''' Extract a list of values from the form value.
1857 It may be one of:
1858 [MiniFieldStorage, MiniFieldStorage, ...]
1859 MiniFieldStorage('value,value,...')
1860 MiniFieldStorage('value')
1861 '''
1862 # multiple values are OK
1863 if isinstance(value, type([])):
1864 # it's a list of MiniFieldStorages
1865 value = [i.value.strip() for i in value]
1866 else:
1867 # it's a MiniFieldStorage, but may be a comma-separated list
1868 # of values
1869 value = [i.strip() for i in value.value.split(',')]
1871 # filter out the empty bits
1872 return filter(None, value)