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