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