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