1 # $Id: client.py,v 1.113 2003-04-10 05:12:41 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', '')
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 = smtplib.SMTP(self.db.config.MAILHOST)
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):
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 Also handle the ":queryname" variable and save off the query to
1294 the user's query list.
1295 '''
1296 # generic edit is per-class only
1297 if not self.searchPermission():
1298 self.error_message.append(
1299 _('You do not have permission to search %s' %self.classname))
1301 # add a faked :filter form variable for each filtering prop
1302 props = self.db.classes[self.classname].getprops()
1303 queryname = ''
1304 for key in self.form.keys():
1305 # special vars
1306 if self.FV_QUERYNAME.match(key):
1307 queryname = self.form[key].value.strip()
1308 continue
1310 if not props.has_key(key):
1311 continue
1312 if isinstance(self.form[key], type([])):
1313 # search for at least one entry which is not empty
1314 for minifield in self.form[key]:
1315 if minifield.value:
1316 break
1317 else:
1318 continue
1319 else:
1320 if not self.form[key].value:
1321 continue
1322 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1324 # handle saving the query params
1325 if queryname:
1326 # parse the environment and figure what the query _is_
1327 req = HTMLRequest(self)
1328 url = req.indexargs_href('', {})
1330 # handle editing an existing query
1331 try:
1332 qid = self.db.query.lookup(queryname)
1333 self.db.query.set(qid, klass=self.classname, url=url)
1334 except KeyError:
1335 # create a query
1336 qid = self.db.query.create(name=queryname,
1337 klass=self.classname, url=url)
1339 # and add it to the user's query multilink
1340 queries = self.db.user.get(self.userid, 'queries')
1341 queries.append(qid)
1342 self.db.user.set(self.userid, queries=queries)
1344 # commit the query change to the database
1345 self.db.commit()
1347 def searchPermission(self):
1348 ''' Determine whether the user has permission to search this class.
1350 Base behaviour is to check the user can view this class.
1351 '''
1352 if not self.db.security.hasPermission('View', self.userid,
1353 self.classname):
1354 return 0
1355 return 1
1358 def retireAction(self):
1359 ''' Retire the context item.
1360 '''
1361 # if we want to view the index template now, then unset the nodeid
1362 # context info (a special-case for retire actions on the index page)
1363 nodeid = self.nodeid
1364 if self.template == 'index':
1365 self.nodeid = None
1367 # generic edit is per-class only
1368 if not self.retirePermission():
1369 self.error_message.append(
1370 _('You do not have permission to retire %s' %self.classname))
1371 return
1373 # make sure we don't try to retire admin or anonymous
1374 if self.classname == 'user' and \
1375 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1376 self.error_message.append(
1377 _('You may not retire the admin or anonymous user'))
1378 return
1380 # do the retire
1381 self.db.getclass(self.classname).retire(nodeid)
1382 self.db.commit()
1384 self.ok_message.append(
1385 _('%(classname)s %(itemid)s has been retired')%{
1386 'classname': self.classname.capitalize(), 'itemid': nodeid})
1388 def retirePermission(self):
1389 ''' Determine whether the user has permission to retire this class.
1391 Base behaviour is to check the user can edit this class.
1392 '''
1393 if not self.db.security.hasPermission('Edit', self.userid,
1394 self.classname):
1395 return 0
1396 return 1
1399 def showAction(self, typere=re.compile('[@:]type'),
1400 numre=re.compile('[@:]number')):
1401 ''' Show a node of a particular class/id
1402 '''
1403 t = n = ''
1404 for key in self.form.keys():
1405 if typere.match(key):
1406 t = self.form[key].value.strip()
1407 elif numre.match(key):
1408 n = self.form[key].value.strip()
1409 if not t:
1410 raise ValueError, 'Invalid %s number'%t
1411 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1412 raise Redirect, url
1414 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1415 ''' Pull properties out of the form.
1417 In the following, <bracketed> values are variable, ":" may be
1418 one of ":" or "@", and other text "required" is fixed.
1420 Properties are specified as form variables:
1422 <propname>
1423 - property on the current context item
1425 <designator>:<propname>
1426 - property on the indicated item
1428 <classname>-<N>:<propname>
1429 - property on the Nth new item of classname
1431 Once we have determined the "propname", we check to see if it
1432 is one of the special form values:
1434 :required
1435 The named property values must be supplied or a ValueError
1436 will be raised.
1438 :remove:<propname>=id(s)
1439 The ids will be removed from the multilink property.
1441 :add:<propname>=id(s)
1442 The ids will be added to the multilink property.
1444 :link:<propname>=<designator>
1445 Used to add a link to new items created during edit.
1446 These are collected up and returned in all_links. This will
1447 result in an additional linking operation (either Link set or
1448 Multilink append) after the edit/create is done using
1449 all_props in _editnodes. The <propname> on the current item
1450 will be set/appended the id of the newly created item of
1451 class <designator> (where <designator> must be
1452 <classname>-<N>).
1454 Any of the form variables may be prefixed with a classname or
1455 designator.
1457 The return from this method is a dict of
1458 (classname, id): properties
1459 ... this dict _always_ has an entry for the current context,
1460 even if it's empty (ie. a submission for an existing issue that
1461 doesn't result in any changes would return {('issue','123'): {}})
1462 The id may be None, which indicates that an item should be
1463 created.
1465 If a String property's form value is a file upload, then we
1466 try to set additional properties "filename" and "type" (if
1467 they are valid for the class).
1469 Two special form values are supported for backwards
1470 compatibility:
1471 :note - create a message (with content, author and date), link
1472 to the context item. This is ALWAYS desginated "msg-1".
1473 :file - create a file, attach to the current item and any
1474 message created by :note. This is ALWAYS designated
1475 "file-1".
1477 We also check that FileClass items have a "content" property with
1478 actual content, otherwise we remove them from all_props before
1479 returning.
1480 '''
1481 # some very useful variables
1482 db = self.db
1483 form = self.form
1485 if not hasattr(self, 'FV_SPECIAL'):
1486 # generate the regexp for handling special form values
1487 classes = '|'.join(db.classes.keys())
1488 # specials for parsePropsFromForm
1489 # handle the various forms (see unit tests)
1490 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1491 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1493 # these indicate the default class / item
1494 default_cn = self.classname
1495 default_cl = self.db.classes[default_cn]
1496 default_nodeid = self.nodeid
1498 # we'll store info about the individual class/item edit in these
1499 all_required = {} # one entry per class/item
1500 all_props = {} # one entry per class/item
1501 all_propdef = {} # note - only one entry per class
1502 all_links = [] # as many as are required
1504 # we should always return something, even empty, for the context
1505 all_props[(default_cn, default_nodeid)] = {}
1507 keys = form.keys()
1508 timezone = db.getUserTimezone()
1510 # sentinels for the :note and :file props
1511 have_note = have_file = 0
1513 # extract the usable form labels from the form
1514 matches = []
1515 for key in keys:
1516 m = self.FV_SPECIAL.match(key)
1517 if m:
1518 matches.append((key, m.groupdict()))
1520 # now handle the matches
1521 for key, d in matches:
1522 if d['classname']:
1523 # we got a designator
1524 cn = d['classname']
1525 cl = self.db.classes[cn]
1526 nodeid = d['id']
1527 propname = d['propname']
1528 elif d['note']:
1529 # the special note field
1530 cn = 'msg'
1531 cl = self.db.classes[cn]
1532 nodeid = '-1'
1533 propname = 'content'
1534 all_links.append((default_cn, default_nodeid, 'messages',
1535 [('msg', '-1')]))
1536 have_note = 1
1537 elif d['file']:
1538 # the special file field
1539 cn = 'file'
1540 cl = self.db.classes[cn]
1541 nodeid = '-1'
1542 propname = 'content'
1543 all_links.append((default_cn, default_nodeid, 'files',
1544 [('file', '-1')]))
1545 have_file = 1
1546 else:
1547 # default
1548 cn = default_cn
1549 cl = default_cl
1550 nodeid = default_nodeid
1551 propname = d['propname']
1553 # the thing this value relates to is...
1554 this = (cn, nodeid)
1556 # get more info about the class, and the current set of
1557 # form props for it
1558 if not all_propdef.has_key(cn):
1559 all_propdef[cn] = cl.getprops()
1560 propdef = all_propdef[cn]
1561 if not all_props.has_key(this):
1562 all_props[this] = {}
1563 props = all_props[this]
1565 # is this a link command?
1566 if d['link']:
1567 value = []
1568 for entry in extractFormList(form[key]):
1569 m = self.FV_DESIGNATOR.match(entry)
1570 if not m:
1571 raise ValueError, \
1572 'link "%s" value "%s" not a designator'%(key, entry)
1573 value.append((m.group(1), m.group(2)))
1575 # make sure the link property is valid
1576 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1577 not isinstance(propdef[propname], hyperdb.Link)):
1578 raise ValueError, '%s %s is not a link or '\
1579 'multilink property'%(cn, propname)
1581 all_links.append((cn, nodeid, propname, value))
1582 continue
1584 # detect the special ":required" variable
1585 if d['required']:
1586 all_required[this] = extractFormList(form[key])
1587 continue
1589 # get the required values list
1590 if not all_required.has_key(this):
1591 all_required[this] = []
1592 required = all_required[this]
1594 # see if we're performing a special multilink action
1595 mlaction = 'set'
1596 if d['remove']:
1597 mlaction = 'remove'
1598 elif d['add']:
1599 mlaction = 'add'
1601 # does the property exist?
1602 if not propdef.has_key(propname):
1603 if mlaction != 'set':
1604 raise ValueError, 'You have submitted a %s action for'\
1605 ' the property "%s" which doesn\'t exist'%(mlaction,
1606 propname)
1607 # the form element is probably just something we don't care
1608 # about - ignore it
1609 continue
1610 proptype = propdef[propname]
1612 # Get the form value. This value may be a MiniFieldStorage or a list
1613 # of MiniFieldStorages.
1614 value = form[key]
1616 # handle unpacking of the MiniFieldStorage / list form value
1617 if isinstance(proptype, hyperdb.Multilink):
1618 value = extractFormList(value)
1619 else:
1620 # multiple values are not OK
1621 if isinstance(value, type([])):
1622 raise ValueError, 'You have submitted more than one value'\
1623 ' for the %s property'%propname
1624 # value might be a file upload...
1625 if not hasattr(value, 'filename') or value.filename is None:
1626 # nope, pull out the value and strip it
1627 value = value.value.strip()
1629 # now that we have the props field, we need a teensy little
1630 # extra bit of help for the old :note field...
1631 if d['note'] and value:
1632 props['author'] = self.db.getuid()
1633 props['date'] = date.Date()
1635 # handle by type now
1636 if isinstance(proptype, hyperdb.Password):
1637 if not value:
1638 # ignore empty password values
1639 continue
1640 for key, d in matches:
1641 if d['confirm'] and d['propname'] == propname:
1642 confirm = form[key]
1643 break
1644 else:
1645 raise ValueError, 'Password and confirmation text do '\
1646 'not match'
1647 if isinstance(confirm, type([])):
1648 raise ValueError, 'You have submitted more than one value'\
1649 ' for the %s property'%propname
1650 if value != confirm.value:
1651 raise ValueError, 'Password and confirmation text do '\
1652 'not match'
1653 value = password.Password(value)
1655 elif isinstance(proptype, hyperdb.Link):
1656 # see if it's the "no selection" choice
1657 if value == '-1' or not value:
1658 # if we're creating, just don't include this property
1659 if not nodeid or nodeid.startswith('-'):
1660 continue
1661 value = None
1662 else:
1663 # handle key values
1664 link = proptype.classname
1665 if not num_re.match(value):
1666 try:
1667 value = db.classes[link].lookup(value)
1668 except KeyError:
1669 raise ValueError, _('property "%(propname)s": '
1670 '%(value)s not a %(classname)s')%{
1671 'propname': propname, 'value': value,
1672 'classname': link}
1673 except TypeError, message:
1674 raise ValueError, _('you may only enter ID values '
1675 'for property "%(propname)s": %(message)s')%{
1676 'propname': propname, 'message': message}
1677 elif isinstance(proptype, hyperdb.Multilink):
1678 # perform link class key value lookup if necessary
1679 link = proptype.classname
1680 link_cl = db.classes[link]
1681 l = []
1682 for entry in value:
1683 if not entry: continue
1684 if not num_re.match(entry):
1685 try:
1686 entry = link_cl.lookup(entry)
1687 except KeyError:
1688 raise ValueError, _('property "%(propname)s": '
1689 '"%(value)s" not an entry of %(classname)s')%{
1690 'propname': propname, 'value': entry,
1691 'classname': link}
1692 except TypeError, message:
1693 raise ValueError, _('you may only enter ID values '
1694 'for property "%(propname)s": %(message)s')%{
1695 'propname': propname, 'message': message}
1696 l.append(entry)
1697 l.sort()
1699 # now use that list of ids to modify the multilink
1700 if mlaction == 'set':
1701 value = l
1702 else:
1703 # we're modifying the list - get the current list of ids
1704 if props.has_key(propname):
1705 existing = props[propname]
1706 elif nodeid and not nodeid.startswith('-'):
1707 existing = cl.get(nodeid, propname, [])
1708 else:
1709 existing = []
1711 # now either remove or add
1712 if mlaction == 'remove':
1713 # remove - handle situation where the id isn't in
1714 # the list
1715 for entry in l:
1716 try:
1717 existing.remove(entry)
1718 except ValueError:
1719 raise ValueError, _('property "%(propname)s": '
1720 '"%(value)s" not currently in list')%{
1721 'propname': propname, 'value': entry}
1722 else:
1723 # add - easy, just don't dupe
1724 for entry in l:
1725 if entry not in existing:
1726 existing.append(entry)
1727 value = existing
1728 value.sort()
1730 elif value == '':
1731 # if we're creating, just don't include this property
1732 if not nodeid or nodeid.startswith('-'):
1733 continue
1734 # other types should be None'd if there's no value
1735 value = None
1736 else:
1737 # handle ValueErrors for all these in a similar fashion
1738 try:
1739 if isinstance(proptype, hyperdb.String):
1740 if (hasattr(value, 'filename') and
1741 value.filename is not None):
1742 # skip if the upload is empty
1743 if not value.filename:
1744 continue
1745 # this String is actually a _file_
1746 # try to determine the file content-type
1747 fn = value.filename.split('\\')[-1]
1748 if propdef.has_key('name'):
1749 props['name'] = fn
1750 # use this info as the type/filename properties
1751 if propdef.has_key('type'):
1752 props['type'] = mimetypes.guess_type(fn)[0]
1753 if not props['type']:
1754 props['type'] = "application/octet-stream"
1755 # finally, read the content
1756 value = value.value
1757 else:
1758 # normal String fix the CRLF/CR -> LF stuff
1759 value = fixNewlines(value)
1761 elif isinstance(proptype, hyperdb.Date):
1762 value = date.Date(value, offset=timezone)
1763 elif isinstance(proptype, hyperdb.Interval):
1764 value = date.Interval(value)
1765 elif isinstance(proptype, hyperdb.Boolean):
1766 value = value.lower() in ('yes', 'true', 'on', '1')
1767 elif isinstance(proptype, hyperdb.Number):
1768 value = float(value)
1769 except ValueError, msg:
1770 raise ValueError, _('Error with %s property: %s')%(
1771 propname, msg)
1773 # get the old value
1774 if nodeid and not nodeid.startswith('-'):
1775 try:
1776 existing = cl.get(nodeid, propname)
1777 except KeyError:
1778 # this might be a new property for which there is
1779 # no existing value
1780 if not propdef.has_key(propname):
1781 raise
1783 # make sure the existing multilink is sorted
1784 if isinstance(proptype, hyperdb.Multilink):
1785 existing.sort()
1787 # "missing" existing values may not be None
1788 if not existing:
1789 if isinstance(proptype, hyperdb.String) and not existing:
1790 # some backends store "missing" Strings as empty strings
1791 existing = None
1792 elif isinstance(proptype, hyperdb.Number) and not existing:
1793 # some backends store "missing" Numbers as 0 :(
1794 existing = 0
1795 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1796 # likewise Booleans
1797 existing = 0
1799 # if changed, set it
1800 if value != existing:
1801 props[propname] = value
1802 else:
1803 # don't bother setting empty/unset values
1804 if value is None:
1805 continue
1806 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1807 continue
1808 elif isinstance(proptype, hyperdb.String) and value == '':
1809 continue
1811 props[propname] = value
1813 # register this as received if required?
1814 if propname in required and value is not None:
1815 required.remove(propname)
1817 # check to see if we need to specially link a file to the note
1818 if have_note and have_file:
1819 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1821 # see if all the required properties have been supplied
1822 s = []
1823 for thing, required in all_required.items():
1824 if not required:
1825 continue
1826 if len(required) > 1:
1827 p = 'properties'
1828 else:
1829 p = 'property'
1830 s.append('Required %s %s %s not supplied'%(thing[0], p,
1831 ', '.join(required)))
1832 if s:
1833 raise ValueError, '\n'.join(s)
1835 # check that FileClass entries have a "content" property with
1836 # content, otherwise remove them
1837 for (cn, id), props in all_props.items():
1838 cl = self.db.classes[cn]
1839 if not isinstance(cl, hyperdb.FileClass):
1840 continue
1841 # we also don't want to create FileClass items with no content
1842 if not props.get('content', ''):
1843 del all_props[(cn, id)]
1844 return all_props, all_links
1846 def fixNewlines(text):
1847 ''' Homogenise line endings.
1849 Different web clients send different line ending values, but
1850 other systems (eg. email) don't necessarily handle those line
1851 endings. Our solution is to convert all line endings to LF.
1852 '''
1853 text = text.replace('\r\n', '\n')
1854 return text.replace('\r', '\n')
1856 def extractFormList(value):
1857 ''' Extract a list of values from the form value.
1859 It may be one of:
1860 [MiniFieldStorage, MiniFieldStorage, ...]
1861 MiniFieldStorage('value,value,...')
1862 MiniFieldStorage('value')
1863 '''
1864 # multiple values are OK
1865 if isinstance(value, type([])):
1866 # it's a list of MiniFieldStorages
1867 value = [i.value.strip() for i in value]
1868 else:
1869 # it's a MiniFieldStorage, but may be a comma-separated list
1870 # of values
1871 value = [i.strip() for i in value.value.split(',')]
1873 # filter out the empty bits
1874 return filter(None, value)