1 # $Id: client.py,v 1.105 2003-03-17 04:26:24 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 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
820 del props['__time']
821 self.userid = cl.create(**props)
822 # clear the props from the otk database
823 self.db.otks.destroy(otk)
824 self.db.commit()
825 except (ValueError, KeyError), message:
826 self.error_message.append(str(message))
827 return
829 # log the new user in
830 self.user = cl.get(self.userid, 'username')
831 # re-open the database for real, using the user
832 self.opendb(self.user)
834 # if we have a session, update it
835 if hasattr(self, 'session'):
836 self.db.sessions.set(self.session, user=self.user,
837 last_use=time.time())
838 else:
839 # new session cookie
840 self.set_cookie(self.user)
842 # nice message
843 message = _('You are now registered, welcome!')
845 # redirect to the item's edit page
846 raise Redirect, '%suser%s?@ok_message=%s'%(
847 self.base, self.userid, urllib.quote(message))
849 def passResetAction(self):
850 ''' Handle password reset requests.
852 Presence of either "name" or "address" generate email.
853 Presense of "otk" performs the reset.
854 '''
855 if self.form.has_key('otk'):
856 # pull the rego information out of the otk database
857 otk = self.form['otk'].value
858 uid = self.db.otks.get(otk, 'uid')
860 # re-open the database as "admin"
861 if self.user != 'admin':
862 self.opendb('admin')
864 # change the password
865 newpw = ''.join([random.choice(self.chars) for x in range(8)])
867 cl = self.db.user
868 # XXX we need to make the "default" page be able to display errors!
869 try:
870 # set the password
871 cl.set(uid, password=password.Password(newpw))
872 # clear the props from the otk database
873 self.db.otks.destroy(otk)
874 self.db.commit()
875 except (ValueError, KeyError), message:
876 self.error_message.append(str(message))
877 return
879 # user info
880 address = self.db.user.get(uid, 'address')
881 name = self.db.user.get(uid, 'username')
883 # send the email
884 tracker_name = self.db.config.TRACKER_NAME
885 subject = 'Password reset for %s'%tracker_name
886 body = '''
887 The password has been reset for username "%(name)s".
889 Your password is now: %(password)s
890 '''%{'name': name, 'password': newpw}
891 if not self.sendEmail(address, subject, body):
892 return
894 self.ok_message.append('Password reset and email sent to %s'%address)
895 return
897 # no OTK, so now figure the user
898 if self.form.has_key('username'):
899 name = self.form['username'].value
900 try:
901 uid = self.db.user.lookup(name)
902 except KeyError:
903 self.error_message.append('Unknown username')
904 return
905 address = self.db.user.get(uid, 'address')
906 elif self.form.has_key('address'):
907 address = self.form['address'].value
908 uid = uidFromAddress(self.db, ('', address), create=0)
909 if not uid:
910 self.error_message.append('Unknown email address')
911 return
912 name = self.db.user.get(uid, 'username')
913 else:
914 self.error_message.append('You need to specify a username '
915 'or address')
916 return
918 # generate the one-time-key and store the props for later
919 otk = ''.join([random.choice(self.chars) for x in range(32)])
920 self.db.otks.set(otk, uid=uid, __time=time.time())
922 # send the email
923 tracker_name = self.db.config.TRACKER_NAME
924 subject = 'Confirm reset of password for %s'%tracker_name
925 body = '''
926 Someone, perhaps you, has requested that the password be changed for your
927 username, "%(name)s". If you wish to proceed with the change, please follow
928 the link below:
930 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
932 You should then receive another email with the new password.
933 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
934 if not self.sendEmail(address, subject, body):
935 return
937 self.ok_message.append('Email sent to %s'%address)
939 def editItemAction(self):
940 ''' Perform an edit of an item in the database.
942 See parsePropsFromForm and _editnodes for special variables
943 '''
944 # parse the props from the form
945 try:
946 props, links = self.parsePropsFromForm()
947 except (ValueError, KeyError), message:
948 self.error_message.append(_('Error: ') + str(message))
949 return
951 # handle the props
952 try:
953 message = self._editnodes(props, links)
954 except (ValueError, KeyError, IndexError), message:
955 self.error_message.append(_('Error: ') + str(message))
956 return
958 # commit now that all the tricky stuff is done
959 self.db.commit()
961 # redirect to the item's edit page
962 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
963 self.nodeid, urllib.quote(message))
965 def editItemPermission(self, props):
966 ''' Determine whether the user has permission to edit this item.
968 Base behaviour is to check the user can edit this class. If we're
969 editing the "user" class, users are allowed to edit their own
970 details. Unless it's the "roles" property, which requires the
971 special Permission "Web Roles".
972 '''
973 # if this is a user node and the user is editing their own node, then
974 # we're OK
975 has = self.db.security.hasPermission
976 if self.classname == 'user':
977 # reject if someone's trying to edit "roles" and doesn't have the
978 # right permission.
979 if props.has_key('roles') and not has('Web Roles', self.userid,
980 'user'):
981 return 0
982 # if the item being edited is the current user, we're ok
983 if self.nodeid == self.userid:
984 return 1
985 if self.db.security.hasPermission('Edit', self.userid, self.classname):
986 return 1
987 return 0
989 def newItemAction(self):
990 ''' Add a new item to the database.
992 This follows the same form as the editItemAction, with the same
993 special form values.
994 '''
995 # parse the props from the form
996 try:
997 props, links = self.parsePropsFromForm()
998 except (ValueError, KeyError), message:
999 self.error_message.append(_('Error: ') + str(message))
1000 return
1002 # handle the props - edit or create
1003 try:
1004 # when it hits the None element, it'll set self.nodeid
1005 messages = self._editnodes(props, links)
1007 except (ValueError, KeyError, IndexError), message:
1008 # these errors might just be indicative of user dumbness
1009 self.error_message.append(_('Error: ') + str(message))
1010 return
1012 # commit now that all the tricky stuff is done
1013 self.db.commit()
1015 print '%s%s%s?@ok_message=%s'%(self.base, self.classname,
1016 self.nodeid, urllib.quote(messages))
1017 # redirect to the new item's page
1018 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
1019 self.nodeid, urllib.quote(messages))
1021 def newItemPermission(self, props):
1022 ''' Determine whether the user has permission to create (edit) this
1023 item.
1025 Base behaviour is to check the user can edit this class. No
1026 additional property checks are made. Additionally, new user items
1027 may be created if the user has the "Web Registration" Permission.
1028 '''
1029 has = self.db.security.hasPermission
1030 if self.classname == 'user' and has('Web Registration', self.userid,
1031 'user'):
1032 return 1
1033 if has('Edit', self.userid, self.classname):
1034 return 1
1035 return 0
1038 #
1039 # Utility methods for editing
1040 #
1041 def _editnodes(self, all_props, all_links, newids=None):
1042 ''' Use the props in all_props to perform edit and creation, then
1043 use the link specs in all_links to do linking.
1044 '''
1045 # figure dependencies and re-work links
1046 deps = {}
1047 links = {}
1048 for cn, nodeid, propname, vlist in all_links:
1049 if not all_props.has_key((cn, nodeid)):
1050 # link item to link to doesn't (and won't) exist
1051 continue
1052 for value in vlist:
1053 if not all_props.has_key(value):
1054 # link item to link to doesn't (and won't) exist
1055 continue
1056 deps.setdefault((cn, nodeid), []).append(value)
1057 links.setdefault(value, []).append((cn, nodeid, propname))
1059 # figure chained dependencies ordering
1060 order = []
1061 done = {}
1062 # loop detection
1063 change = 0
1064 while len(all_props) != len(done):
1065 for needed in all_props.keys():
1066 if done.has_key(needed):
1067 continue
1068 tlist = deps.get(needed, [])
1069 for target in tlist:
1070 if not done.has_key(target):
1071 break
1072 else:
1073 done[needed] = 1
1074 order.append(needed)
1075 change = 1
1076 if not change:
1077 raise ValueError, 'linking must not loop!'
1079 # now, edit / create
1080 m = []
1081 for needed in order:
1082 props = all_props[needed]
1083 if not props:
1084 # nothing to do
1085 continue
1086 cn, nodeid = needed
1088 if nodeid is not None and int(nodeid) > 0:
1089 # make changes to the node
1090 props = self._changenode(cn, nodeid, props)
1092 # and some nice feedback for the user
1093 if props:
1094 info = ', '.join(props.keys())
1095 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1096 else:
1097 m.append('%s %s - nothing changed'%(cn, nodeid))
1098 else:
1099 assert props
1101 # make a new node
1102 newid = self._createnode(cn, props)
1103 if nodeid is None:
1104 self.nodeid = newid
1105 nodeid = newid
1107 # and some nice feedback for the user
1108 m.append('%s %s created'%(cn, newid))
1110 # fill in new ids in links
1111 if links.has_key(needed):
1112 for linkcn, linkid, linkprop in links[needed]:
1113 props = all_props[(linkcn, linkid)]
1114 cl = self.db.classes[linkcn]
1115 propdef = cl.getprops()[linkprop]
1116 if not props.has_key(linkprop):
1117 if linkid is None or linkid.startswith('-'):
1118 # linking to a new item
1119 if isinstance(propdef, hyperdb.Multilink):
1120 props[linkprop] = [newid]
1121 else:
1122 props[linkprop] = newid
1123 else:
1124 # linking to an existing item
1125 if isinstance(propdef, hyperdb.Multilink):
1126 existing = cl.get(linkid, linkprop)[:]
1127 existing.append(nodeid)
1128 props[linkprop] = existing
1129 else:
1130 props[linkprop] = newid
1132 return '<br>'.join(m)
1134 def _changenode(self, cn, nodeid, props):
1135 ''' change the node based on the contents of the form
1136 '''
1137 # check for permission
1138 if not self.editItemPermission(props):
1139 raise Unauthorised, 'You do not have permission to edit %s'%cn
1141 # make the changes
1142 cl = self.db.classes[cn]
1143 return cl.set(nodeid, **props)
1145 def _createnode(self, cn, props):
1146 ''' create a node based on the contents of the form
1147 '''
1148 # check for permission
1149 if not self.newItemPermission(props):
1150 raise Unauthorised, 'You do not have permission to create %s'%cn
1152 # create the node and return its id
1153 cl = self.db.classes[cn]
1154 return cl.create(**props)
1156 #
1157 # More actions
1158 #
1159 def editCSVAction(self):
1160 ''' Performs an edit of all of a class' items in one go.
1162 The "rows" CGI var defines the CSV-formatted entries for the
1163 class. New nodes are identified by the ID 'X' (or any other
1164 non-existent ID) and removed lines are retired.
1165 '''
1166 # this is per-class only
1167 if not self.editCSVPermission():
1168 self.error_message.append(
1169 _('You do not have permission to edit %s' %self.classname))
1171 # get the CSV module
1172 try:
1173 import csv
1174 except ImportError:
1175 self.error_message.append(_(
1176 'Sorry, you need the csv module to use this function.<br>\n'
1177 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1178 return
1180 cl = self.db.classes[self.classname]
1181 idlessprops = cl.getprops(protected=0).keys()
1182 idlessprops.sort()
1183 props = ['id'] + idlessprops
1185 # do the edit
1186 rows = self.form['rows'].value.splitlines()
1187 p = csv.parser()
1188 found = {}
1189 line = 0
1190 for row in rows[1:]:
1191 line += 1
1192 values = p.parse(row)
1193 # not a complete row, keep going
1194 if not values: continue
1196 # skip property names header
1197 if values == props:
1198 continue
1200 # extract the nodeid
1201 nodeid, values = values[0], values[1:]
1202 found[nodeid] = 1
1204 # confirm correct weight
1205 if len(idlessprops) != len(values):
1206 self.error_message.append(
1207 _('Not enough values on line %(line)s')%{'line':line})
1208 return
1210 # extract the new values
1211 d = {}
1212 for name, value in zip(idlessprops, values):
1213 value = value.strip()
1214 # only add the property if it has a value
1215 if value:
1216 # if it's a multilink, split it
1217 if isinstance(cl.properties[name], hyperdb.Multilink):
1218 value = value.split(':')
1219 d[name] = value
1221 # perform the edit
1222 if cl.hasnode(nodeid):
1223 # edit existing
1224 cl.set(nodeid, **d)
1225 else:
1226 # new node
1227 found[cl.create(**d)] = 1
1229 # retire the removed entries
1230 for nodeid in cl.list():
1231 if not found.has_key(nodeid):
1232 cl.retire(nodeid)
1234 # all OK
1235 self.db.commit()
1237 self.ok_message.append(_('Items edited OK'))
1239 def editCSVPermission(self):
1240 ''' Determine whether the user has permission to edit this class.
1242 Base behaviour is to check the user can edit this class.
1243 '''
1244 if not self.db.security.hasPermission('Edit', self.userid,
1245 self.classname):
1246 return 0
1247 return 1
1249 def searchAction(self):
1250 ''' Mangle some of the form variables.
1252 Set the form ":filter" variable based on the values of the
1253 filter variables - if they're set to anything other than
1254 "dontcare" then add them to :filter.
1256 Also handle the ":queryname" variable and save off the query to
1257 the user's query list.
1258 '''
1259 # generic edit is per-class only
1260 if not self.searchPermission():
1261 self.error_message.append(
1262 _('You do not have permission to search %s' %self.classname))
1264 # add a faked :filter form variable for each filtering prop
1265 props = self.db.classes[self.classname].getprops()
1266 queryname = ''
1267 for key in self.form.keys():
1268 # special vars
1269 if self.FV_QUERYNAME.match(key):
1270 queryname = self.form[key].value.strip()
1271 continue
1273 if not props.has_key(key):
1274 continue
1275 if isinstance(self.form[key], type([])):
1276 # search for at least one entry which is not empty
1277 for minifield in self.form[key]:
1278 if minifield.value:
1279 break
1280 else:
1281 continue
1282 else:
1283 if not self.form[key].value: continue
1284 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1286 # handle saving the query params
1287 if queryname:
1288 # parse the environment and figure what the query _is_
1289 req = HTMLRequest(self)
1290 url = req.indexargs_href('', {})
1292 # handle editing an existing query
1293 try:
1294 qid = self.db.query.lookup(queryname)
1295 self.db.query.set(qid, klass=self.classname, url=url)
1296 except KeyError:
1297 # create a query
1298 qid = self.db.query.create(name=queryname,
1299 klass=self.classname, url=url)
1301 # and add it to the user's query multilink
1302 queries = self.db.user.get(self.userid, 'queries')
1303 queries.append(qid)
1304 self.db.user.set(self.userid, queries=queries)
1306 # commit the query change to the database
1307 self.db.commit()
1309 def searchPermission(self):
1310 ''' Determine whether the user has permission to search this class.
1312 Base behaviour is to check the user can view this class.
1313 '''
1314 if not self.db.security.hasPermission('View', self.userid,
1315 self.classname):
1316 return 0
1317 return 1
1320 def retireAction(self):
1321 ''' Retire the context item.
1322 '''
1323 # if we want to view the index template now, then unset the nodeid
1324 # context info (a special-case for retire actions on the index page)
1325 nodeid = self.nodeid
1326 if self.template == 'index':
1327 self.nodeid = None
1329 # generic edit is per-class only
1330 if not self.retirePermission():
1331 self.error_message.append(
1332 _('You do not have permission to retire %s' %self.classname))
1333 return
1335 # make sure we don't try to retire admin or anonymous
1336 if self.classname == 'user' and \
1337 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1338 self.error_message.append(
1339 _('You may not retire the admin or anonymous user'))
1340 return
1342 # do the retire
1343 self.db.getclass(self.classname).retire(nodeid)
1344 self.db.commit()
1346 self.ok_message.append(
1347 _('%(classname)s %(itemid)s has been retired')%{
1348 'classname': self.classname.capitalize(), 'itemid': nodeid})
1350 def retirePermission(self):
1351 ''' Determine whether the user has permission to retire this class.
1353 Base behaviour is to check the user can edit this class.
1354 '''
1355 if not self.db.security.hasPermission('Edit', self.userid,
1356 self.classname):
1357 return 0
1358 return 1
1361 def showAction(self, typere=re.compile('[@:]type'),
1362 numre=re.compile('[@:]number')):
1363 ''' Show a node of a particular class/id
1364 '''
1365 t = n = ''
1366 for key in self.form.keys():
1367 if typere.match(key):
1368 t = self.form[key].value.strip()
1369 elif numre.match(key):
1370 n = self.form[key].value.strip()
1371 if not t:
1372 raise ValueError, 'Invalid %s number'%t
1373 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1374 raise Redirect, url
1376 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1377 ''' Pull properties out of the form.
1379 In the following, <bracketed> values are variable, ":" may be
1380 one of ":" or "@", and other text "required" is fixed.
1382 Properties are specified as form variables:
1384 <propname>
1385 - property on the current context item
1387 <designator>:<propname>
1388 - property on the indicated item
1390 <classname>-<N>:<propname>
1391 - property on the Nth new item of classname
1393 Once we have determined the "propname", we check to see if it
1394 is one of the special form values:
1396 :required
1397 The named property values must be supplied or a ValueError
1398 will be raised.
1400 :remove:<propname>=id(s)
1401 The ids will be removed from the multilink property.
1403 :add:<propname>=id(s)
1404 The ids will be added to the multilink property.
1406 :link:<propname>=<designator>
1407 Used to add a link to new items created during edit.
1408 These are collected up and returned in all_links. This will
1409 result in an additional linking operation (either Link set or
1410 Multilink append) after the edit/create is done using
1411 all_props in _editnodes. The <propname> on the current item
1412 will be set/appended the id of the newly created item of
1413 class <designator> (where <designator> must be
1414 <classname>-<N>).
1416 Any of the form variables may be prefixed with a classname or
1417 designator.
1419 The return from this method is a dict of
1420 (classname, id): properties
1421 ... this dict _always_ has an entry for the current context,
1422 even if it's empty (ie. a submission for an existing issue that
1423 doesn't result in any changes would return {('issue','123'): {}})
1424 The id may be None, which indicates that an item should be
1425 created.
1427 If a String property's form value is a file upload, then we
1428 try to set additional properties "filename" and "type" (if
1429 they are valid for the class).
1431 Two special form values are supported for backwards
1432 compatibility:
1433 :note - create a message (with content, author and date), link
1434 to the context item. This is ALWAYS desginated "msg-1".
1435 :file - create a file, attach to the current item and any
1436 message created by :note. This is ALWAYS designated
1437 "file-1".
1439 We also check that FileClass items have a "content" property with
1440 actual content, otherwise we remove them from all_props before
1441 returning.
1442 '''
1443 # some very useful variables
1444 db = self.db
1445 form = self.form
1447 if not hasattr(self, 'FV_SPECIAL'):
1448 # generate the regexp for handling special form values
1449 classes = '|'.join(db.classes.keys())
1450 # specials for parsePropsFromForm
1451 # handle the various forms (see unit tests)
1452 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1453 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1455 # these indicate the default class / item
1456 default_cn = self.classname
1457 default_cl = self.db.classes[default_cn]
1458 default_nodeid = self.nodeid
1460 # we'll store info about the individual class/item edit in these
1461 all_required = {} # one entry per class/item
1462 all_props = {} # one entry per class/item
1463 all_propdef = {} # note - only one entry per class
1464 all_links = [] # as many as are required
1466 # we should always return something, even empty, for the context
1467 all_props[(default_cn, default_nodeid)] = {}
1469 keys = form.keys()
1470 timezone = db.getUserTimezone()
1472 # sentinels for the :note and :file props
1473 have_note = have_file = 0
1475 # extract the usable form labels from the form
1476 matches = []
1477 for key in keys:
1478 m = self.FV_SPECIAL.match(key)
1479 if m:
1480 matches.append((key, m.groupdict()))
1482 # now handle the matches
1483 for key, d in matches:
1484 if d['classname']:
1485 # we got a designator
1486 cn = d['classname']
1487 cl = self.db.classes[cn]
1488 nodeid = d['id']
1489 propname = d['propname']
1490 elif d['note']:
1491 # the special note field
1492 cn = 'msg'
1493 cl = self.db.classes[cn]
1494 nodeid = '-1'
1495 propname = 'content'
1496 all_links.append((default_cn, default_nodeid, 'messages',
1497 [('msg', '-1')]))
1498 have_note = 1
1499 elif d['file']:
1500 # the special file field
1501 cn = 'file'
1502 cl = self.db.classes[cn]
1503 nodeid = '-1'
1504 propname = 'content'
1505 all_links.append((default_cn, default_nodeid, 'files',
1506 [('file', '-1')]))
1507 have_file = 1
1508 else:
1509 # default
1510 cn = default_cn
1511 cl = default_cl
1512 nodeid = default_nodeid
1513 propname = d['propname']
1515 # the thing this value relates to is...
1516 this = (cn, nodeid)
1518 # get more info about the class, and the current set of
1519 # form props for it
1520 if not all_propdef.has_key(cn):
1521 all_propdef[cn] = cl.getprops()
1522 propdef = all_propdef[cn]
1523 if not all_props.has_key(this):
1524 all_props[this] = {}
1525 props = all_props[this]
1527 # is this a link command?
1528 if d['link']:
1529 value = []
1530 for entry in extractFormList(form[key]):
1531 m = self.FV_DESIGNATOR.match(entry)
1532 if not m:
1533 raise ValueError, \
1534 'link "%s" value "%s" not a designator'%(key, entry)
1535 value.append((m.group(1), m.group(2)))
1537 # make sure the link property is valid
1538 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1539 not isinstance(propdef[propname], hyperdb.Link)):
1540 raise ValueError, '%s %s is not a link or '\
1541 'multilink property'%(cn, propname)
1543 all_links.append((cn, nodeid, propname, value))
1544 continue
1546 # detect the special ":required" variable
1547 if d['required']:
1548 all_required[this] = extractFormList(form[key])
1549 continue
1551 # get the required values list
1552 if not all_required.has_key(this):
1553 all_required[this] = []
1554 required = all_required[this]
1556 # see if we're performing a special multilink action
1557 mlaction = 'set'
1558 if d['remove']:
1559 mlaction = 'remove'
1560 elif d['add']:
1561 mlaction = 'add'
1563 # does the property exist?
1564 if not propdef.has_key(propname):
1565 if mlaction != 'set':
1566 raise ValueError, 'You have submitted a %s action for'\
1567 ' the property "%s" which doesn\'t exist'%(mlaction,
1568 propname)
1569 # the form element is probably just something we don't care
1570 # about - ignore it
1571 continue
1572 proptype = propdef[propname]
1574 # Get the form value. This value may be a MiniFieldStorage or a list
1575 # of MiniFieldStorages.
1576 value = form[key]
1578 # handle unpacking of the MiniFieldStorage / list form value
1579 if isinstance(proptype, hyperdb.Multilink):
1580 value = extractFormList(value)
1581 else:
1582 # multiple values are not OK
1583 if isinstance(value, type([])):
1584 raise ValueError, 'You have submitted more than one value'\
1585 ' for the %s property'%propname
1586 # value might be a file upload...
1587 if not hasattr(value, 'filename') or value.filename is None:
1588 # nope, pull out the value and strip it
1589 value = value.value.strip()
1591 # now that we have the props field, we need a teensy little
1592 # extra bit of help for the old :note field...
1593 if d['note'] and value:
1594 props['author'] = self.db.getuid()
1595 props['date'] = date.Date()
1597 # handle by type now
1598 if isinstance(proptype, hyperdb.Password):
1599 if not value:
1600 # ignore empty password values
1601 continue
1602 for key, d in matches:
1603 if d['confirm'] and d['propname'] == propname:
1604 confirm = form[key]
1605 break
1606 else:
1607 raise ValueError, 'Password and confirmation text do '\
1608 'not match'
1609 if isinstance(confirm, type([])):
1610 raise ValueError, 'You have submitted more than one value'\
1611 ' for the %s property'%propname
1612 if value != confirm.value:
1613 raise ValueError, 'Password and confirmation text do '\
1614 'not match'
1615 value = password.Password(value)
1617 elif isinstance(proptype, hyperdb.Link):
1618 # see if it's the "no selection" choice
1619 if value == '-1' or not value:
1620 # if we're creating, just don't include this property
1621 if not nodeid or nodeid.startswith('-'):
1622 continue
1623 value = None
1624 else:
1625 # handle key values
1626 link = proptype.classname
1627 if not num_re.match(value):
1628 try:
1629 value = db.classes[link].lookup(value)
1630 except KeyError:
1631 raise ValueError, _('property "%(propname)s": '
1632 '%(value)s not a %(classname)s')%{
1633 'propname': propname, 'value': value,
1634 'classname': link}
1635 except TypeError, message:
1636 raise ValueError, _('you may only enter ID values '
1637 'for property "%(propname)s": %(message)s')%{
1638 'propname': propname, 'message': message}
1639 elif isinstance(proptype, hyperdb.Multilink):
1640 # perform link class key value lookup if necessary
1641 link = proptype.classname
1642 link_cl = db.classes[link]
1643 l = []
1644 for entry in value:
1645 if not entry: continue
1646 if not num_re.match(entry):
1647 try:
1648 entry = link_cl.lookup(entry)
1649 except KeyError:
1650 raise ValueError, _('property "%(propname)s": '
1651 '"%(value)s" not an entry of %(classname)s')%{
1652 'propname': propname, 'value': entry,
1653 'classname': link}
1654 except TypeError, message:
1655 raise ValueError, _('you may only enter ID values '
1656 'for property "%(propname)s": %(message)s')%{
1657 'propname': propname, 'message': message}
1658 l.append(entry)
1659 l.sort()
1661 # now use that list of ids to modify the multilink
1662 if mlaction == 'set':
1663 value = l
1664 else:
1665 # we're modifying the list - get the current list of ids
1666 if props.has_key(propname):
1667 existing = props[propname]
1668 elif nodeid and not nodeid.startswith('-'):
1669 existing = cl.get(nodeid, propname, [])
1670 else:
1671 existing = []
1673 # now either remove or add
1674 if mlaction == 'remove':
1675 # remove - handle situation where the id isn't in
1676 # the list
1677 for entry in l:
1678 try:
1679 existing.remove(entry)
1680 except ValueError:
1681 raise ValueError, _('property "%(propname)s": '
1682 '"%(value)s" not currently in list')%{
1683 'propname': propname, 'value': entry}
1684 else:
1685 # add - easy, just don't dupe
1686 for entry in l:
1687 if entry not in existing:
1688 existing.append(entry)
1689 value = existing
1690 value.sort()
1692 elif value == '':
1693 # if we're creating, just don't include this property
1694 if not nodeid or nodeid.startswith('-'):
1695 continue
1696 # other types should be None'd if there's no value
1697 value = None
1698 else:
1699 if isinstance(proptype, hyperdb.String):
1700 if (hasattr(value, 'filename') and
1701 value.filename is not None):
1702 # skip if the upload is empty
1703 if not value.filename:
1704 continue
1705 # this String is actually a _file_
1706 # try to determine the file content-type
1707 filename = value.filename.split('\\')[-1]
1708 if propdef.has_key('name'):
1709 props['name'] = filename
1710 # use this info as the type/filename properties
1711 if propdef.has_key('type'):
1712 props['type'] = mimetypes.guess_type(filename)[0]
1713 if not props['type']:
1714 props['type'] = "application/octet-stream"
1715 # finally, read the content
1716 value = value.value
1717 else:
1718 # normal String fix the CRLF/CR -> LF stuff
1719 value = fixNewlines(value)
1721 elif isinstance(proptype, hyperdb.Date):
1722 value = date.Date(value, offset=timezone)
1723 elif isinstance(proptype, hyperdb.Interval):
1724 value = date.Interval(value)
1725 elif isinstance(proptype, hyperdb.Boolean):
1726 value = value.lower() in ('yes', 'true', 'on', '1')
1727 elif isinstance(proptype, hyperdb.Number):
1728 value = float(value)
1730 # get the old value
1731 if nodeid and not nodeid.startswith('-'):
1732 try:
1733 existing = cl.get(nodeid, propname)
1734 except KeyError:
1735 # this might be a new property for which there is
1736 # no existing value
1737 if not propdef.has_key(propname):
1738 raise
1740 # make sure the existing multilink is sorted
1741 if isinstance(proptype, hyperdb.Multilink):
1742 existing.sort()
1744 # "missing" existing values may not be None
1745 if not existing:
1746 if isinstance(proptype, hyperdb.String) and not existing:
1747 # some backends store "missing" Strings as empty strings
1748 existing = None
1749 elif isinstance(proptype, hyperdb.Number) and not existing:
1750 # some backends store "missing" Numbers as 0 :(
1751 existing = 0
1752 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1753 # likewise Booleans
1754 existing = 0
1756 # if changed, set it
1757 if value != existing:
1758 props[propname] = value
1759 else:
1760 # don't bother setting empty/unset values
1761 if value is None:
1762 continue
1763 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1764 continue
1765 elif isinstance(proptype, hyperdb.String) and value == '':
1766 continue
1768 props[propname] = value
1770 # register this as received if required?
1771 if propname in required and value is not None:
1772 required.remove(propname)
1774 # check to see if we need to specially link a file to the note
1775 if have_note and have_file:
1776 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1778 # see if all the required properties have been supplied
1779 s = []
1780 for thing, required in all_required.items():
1781 if not required:
1782 continue
1783 if len(required) > 1:
1784 p = 'properties'
1785 else:
1786 p = 'property'
1787 s.append('Required %s %s %s not supplied'%(thing[0], p,
1788 ', '.join(required)))
1789 if s:
1790 raise ValueError, '\n'.join(s)
1792 # check that FileClass entries have a "content" property with
1793 # content, otherwise remove them
1794 for (cn, id), props in all_props.items():
1795 cl = self.db.classes[cn]
1796 if not isinstance(cl, hyperdb.FileClass):
1797 continue
1798 # we also don't want to create FileClass items with no content
1799 if not props.get('content', ''):
1800 del all_props[(cn, id)]
1801 return all_props, all_links
1803 def fixNewlines(text):
1804 ''' Homogenise line endings.
1806 Different web clients send different line ending values, but
1807 other systems (eg. email) don't necessarily handle those line
1808 endings. Our solution is to convert all line endings to LF.
1809 '''
1810 text = text.replace('\r\n', '\n')
1811 return text.replace('\r', '\n')
1813 def extractFormList(value):
1814 ''' Extract a list of values from the form value.
1816 It may be one of:
1817 [MiniFieldStorage, MiniFieldStorage, ...]
1818 MiniFieldStorage('value,value,...')
1819 MiniFieldStorage('value')
1820 '''
1821 # multiple values are OK
1822 if isinstance(value, type([])):
1823 # it's a list of MiniFieldStorages
1824 value = [i.value.strip() for i in value]
1825 else:
1826 # it's a MiniFieldStorage, but may be a comma-separated list
1827 # of values
1828 value = [i.strip() for i in value.value.split(',')]
1830 # filter out the empty bits
1831 return filter(None, value)