1 # $Id: client.py,v 1.112 2003-04-10 04:32:46 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()
228 # now render the page
229 # we don't want clients caching our dynamic pages
230 self.additional_headers['Cache-Control'] = 'no-cache'
231 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
232 # self.additional_headers['Pragma'] = 'no-cache'
234 # expire this page 5 seconds from now
235 date = rfc822.formatdate(time.time() + 5)
236 self.additional_headers['Expires'] = date
238 # render the content
239 self.write(self.renderContext())
240 except Redirect, url:
241 # let's redirect - if the url isn't None, then we need to do
242 # the headers, otherwise the headers have been set before the
243 # exception was raised
244 if url:
245 self.additional_headers['Location'] = url
246 self.response_code = 302
247 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
248 except SendFile, designator:
249 self.serve_file(designator)
250 except SendStaticFile, file:
251 try:
252 self.serve_static_file(str(file))
253 except NotModified:
254 # send the 304 response
255 self.request.send_response(304)
256 self.request.end_headers()
257 except Unauthorised, message:
258 self.classname = None
259 self.template = ''
260 self.error_message.append(message)
261 self.write(self.renderContext())
262 except NotFound:
263 # pass through
264 raise
265 except:
266 # everything else
267 self.write(cgitb.html())
269 def clean_sessions(self):
270 ''' Age sessions, remove when they haven't been used for a week.
272 Do it only once an hour.
274 Note: also cleans One Time Keys, and other "session" based
275 stuff.
276 '''
277 sessions = self.db.sessions
278 last_clean = sessions.get('last_clean', 'last_use') or 0
280 week = 60*60*24*7
281 hour = 60*60
282 now = time.time()
283 if now - last_clean > hour:
284 # remove aged sessions
285 for sessid in sessions.list():
286 interval = now - sessions.get(sessid, 'last_use')
287 if interval > week:
288 sessions.destroy(sessid)
289 # remove aged otks
290 otks = self.db.otks
291 for sessid in otks.list():
292 interval = now - otks.get(sessid, '__time')
293 if interval > week:
294 otks.destroy(sessid)
295 sessions.set('last_clean', last_use=time.time())
297 def determine_user(self):
298 ''' Determine who the user is
299 '''
300 # determine the uid to use
301 self.opendb('admin')
302 # clean age sessions
303 self.clean_sessions()
304 # make sure we have the session Class
305 sessions = self.db.sessions
307 # look up the user session cookie
308 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
309 user = 'anonymous'
311 # bump the "revision" of the cookie since the format changed
312 if (cookie.has_key(self.cookie_name) and
313 cookie[self.cookie_name].value != 'deleted'):
315 # get the session key from the cookie
316 self.session = cookie[self.cookie_name].value
317 # get the user from the session
318 try:
319 # update the lifetime datestamp
320 sessions.set(self.session, last_use=time.time())
321 sessions.commit()
322 user = sessions.get(self.session, 'user')
323 except KeyError:
324 user = 'anonymous'
326 # sanity check on the user still being valid, getting the userid
327 # at the same time
328 try:
329 self.userid = self.db.user.lookup(user)
330 except (KeyError, TypeError):
331 user = 'anonymous'
333 # make sure the anonymous user is valid if we're using it
334 if user == 'anonymous':
335 self.make_user_anonymous()
336 else:
337 self.user = user
339 # reopen the database as the correct user
340 self.opendb(self.user)
342 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
343 ''' Determine the context of this page from the URL:
345 The URL path after the instance identifier is examined. The path
346 is generally only one entry long.
348 - if there is no path, then we are in the "home" context.
349 * if the path is "_file", then the additional path entry
350 specifies the filename of a static file we're to serve up
351 from the instance "html" directory. Raises a SendStaticFile
352 exception.
353 - if there is something in the path (eg "issue"), it identifies
354 the tracker class we're to display.
355 - if the path is an item designator (eg "issue123"), then we're
356 to display a specific item.
357 * if the path starts with an item designator and is longer than
358 one entry, then we're assumed to be handling an item of a
359 FileClass, and the extra path information gives the filename
360 that the client is going to label the download with (ie
361 "file123/image.png" is nicer to download than "file123"). This
362 raises a SendFile exception.
364 Both of the "*" types of contexts stop before we bother to
365 determine the template we're going to use. That's because they
366 don't actually use templates.
368 The template used is specified by the :template CGI variable,
369 which defaults to:
371 only classname suplied: "index"
372 full item designator supplied: "item"
374 We set:
375 self.classname - the class to display, can be None
376 self.template - the template to render the current context with
377 self.nodeid - the nodeid of the class we're displaying
378 '''
379 # default the optional variables
380 self.classname = None
381 self.nodeid = None
383 # see if a template or messages are specified
384 template_override = ok_message = error_message = None
385 for key in self.form.keys():
386 if self.FV_TEMPLATE.match(key):
387 template_override = self.form[key].value
388 elif self.FV_OK_MESSAGE.match(key):
389 ok_message = self.form[key].value
390 elif self.FV_ERROR_MESSAGE.match(key):
391 error_message = self.form[key].value
393 # determine the classname and possibly nodeid
394 path = self.path.split('/')
395 if not path or path[0] in ('', 'home', 'index'):
396 if template_override is not None:
397 self.template = template_override
398 else:
399 self.template = ''
400 return
401 elif path[0] == '_file':
402 raise SendStaticFile, os.path.join(*path[1:])
403 else:
404 self.classname = path[0]
405 if len(path) > 1:
406 # send the file identified by the designator in path[0]
407 raise SendFile, path[0]
409 # see if we got a designator
410 m = dre.match(self.classname)
411 if m:
412 self.classname = m.group(1)
413 self.nodeid = m.group(2)
414 if not self.db.getclass(self.classname).hasnode(self.nodeid):
415 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
416 # with a designator, we default to item view
417 self.template = 'item'
418 else:
419 # with only a class, we default to index view
420 self.template = 'index'
422 # make sure the classname is valid
423 try:
424 self.db.getclass(self.classname)
425 except KeyError:
426 raise NotFound, self.classname
428 # see if we have a template override
429 if template_override is not None:
430 self.template = template_override
432 # see if we were passed in a message
433 if ok_message:
434 self.ok_message.append(ok_message)
435 if error_message:
436 self.error_message.append(error_message)
438 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
439 ''' Serve the file from the content property of the designated item.
440 '''
441 m = dre.match(str(designator))
442 if not m:
443 raise NotFound, str(designator)
444 classname, nodeid = m.group(1), m.group(2)
445 if classname != 'file':
446 raise NotFound, designator
448 # we just want to serve up the file named
449 file = self.db.file
450 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
451 self.write(file.get(nodeid, 'content'))
453 def serve_static_file(self, file):
454 ims = None
455 # see if there's an if-modified-since...
456 if hasattr(self.request, 'headers'):
457 ims = self.request.headers.getheader('if-modified-since')
458 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
459 # cgi will put the header in the env var
460 ims = self.env['HTTP_IF_MODIFIED_SINCE']
461 filename = os.path.join(self.instance.config.TEMPLATES, file)
462 lmt = os.stat(filename)[stat.ST_MTIME]
463 if ims:
464 ims = rfc822.parsedate(ims)[:6]
465 lmtt = time.gmtime(lmt)[:6]
466 if lmtt <= ims:
467 raise NotModified
469 # we just want to serve up the file named
470 file = str(file)
471 mt = mimetypes.guess_type(file)[0]
472 if not mt:
473 if file.endswith('.css'):
474 mt = 'text/css'
475 else:
476 mt = 'text/plain'
477 self.additional_headers['Content-Type'] = mt
478 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
479 self.write(open(filename, 'rb').read())
481 def renderContext(self):
482 ''' Return a PageTemplate for the named page
483 '''
484 name = self.classname
485 extension = self.template
486 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
488 # catch errors so we can handle PT rendering errors more nicely
489 args = {
490 'ok_message': self.ok_message,
491 'error_message': self.error_message
492 }
493 try:
494 # let the template render figure stuff out
495 return pt.render(self, None, None, **args)
496 except NoTemplate, message:
497 return '<strong>%s</strong>'%message
498 except:
499 # everything else
500 return cgitb.pt_html()
502 # these are the actions that are available
503 actions = (
504 ('edit', 'editItemAction'),
505 ('editcsv', 'editCSVAction'),
506 ('new', 'newItemAction'),
507 ('register', 'registerAction'),
508 ('confrego', 'confRegoAction'),
509 ('passrst', 'passResetAction'),
510 ('login', 'loginAction'),
511 ('logout', 'logout_action'),
512 ('search', 'searchAction'),
513 ('retire', 'retireAction'),
514 ('show', 'showAction'),
515 )
516 def handle_action(self):
517 ''' Determine whether there should be an Action called.
519 The action is defined by the form variable :action which
520 identifies the method on this object to call. The actions
521 are defined in the "actions" sequence on this class.
522 '''
523 if self.form.has_key(':action'):
524 action = self.form[':action'].value.lower()
525 elif self.form.has_key('@action'):
526 action = self.form['@action'].value.lower()
527 else:
528 return None
529 try:
530 # get the action, validate it
531 for name, method in self.actions:
532 if name == action:
533 break
534 else:
535 raise ValueError, 'No such action "%s"'%action
536 # call the mapped action
537 getattr(self, method)()
538 except Redirect:
539 raise
540 except Unauthorised:
541 raise
543 def write(self, content):
544 if not self.headers_done:
545 self.header()
546 self.request.wfile.write(content)
548 def header(self, headers=None, response=None):
549 '''Put up the appropriate header.
550 '''
551 if headers is None:
552 headers = {'Content-Type':'text/html'}
553 if response is None:
554 response = self.response_code
556 # update with additional info
557 headers.update(self.additional_headers)
559 if not headers.has_key('Content-Type'):
560 headers['Content-Type'] = 'text/html'
561 self.request.send_response(response)
562 for entry in headers.items():
563 self.request.send_header(*entry)
564 self.request.end_headers()
565 self.headers_done = 1
566 if self.debug:
567 self.headers_sent = headers
569 def set_cookie(self, user):
570 ''' Set up a session cookie for the user and store away the user's
571 login info against the session.
572 '''
573 # TODO generate a much, much stronger session key ;)
574 self.session = binascii.b2a_base64(repr(random.random())).strip()
576 # clean up the base64
577 if self.session[-1] == '=':
578 if self.session[-2] == '=':
579 self.session = self.session[:-2]
580 else:
581 self.session = self.session[:-1]
583 # insert the session in the sessiondb
584 self.db.sessions.set(self.session, user=user, last_use=time.time())
586 # and commit immediately
587 self.db.sessions.commit()
589 # expire us in a long, long time
590 expire = Cookie._getdate(86400*365)
592 # generate the cookie path - make sure it has a trailing '/'
593 self.additional_headers['Set-Cookie'] = \
594 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
595 expire, self.cookie_path)
597 def make_user_anonymous(self):
598 ''' Make us anonymous
600 This method used to handle non-existence of the 'anonymous'
601 user, but that user is mandatory now.
602 '''
603 self.userid = self.db.user.lookup('anonymous')
604 self.user = 'anonymous'
606 def opendb(self, user):
607 ''' Open the database.
608 '''
609 # open the db if the user has changed
610 if not hasattr(self, 'db') or user != self.db.journaltag:
611 if hasattr(self, 'db'):
612 self.db.close()
613 self.db = self.instance.open(user)
615 #
616 # Actions
617 #
618 def loginAction(self):
619 ''' Attempt to log a user in.
621 Sets up a session for the user which contains the login
622 credentials.
623 '''
624 # we need the username at a minimum
625 if not self.form.has_key('__login_name'):
626 self.error_message.append(_('Username required'))
627 return
629 # get the login info
630 self.user = self.form['__login_name'].value
631 if self.form.has_key('__login_password'):
632 password = self.form['__login_password'].value
633 else:
634 password = ''
636 # make sure the user exists
637 try:
638 self.userid = self.db.user.lookup(self.user)
639 except KeyError:
640 name = self.user
641 self.error_message.append(_('No such user "%(name)s"')%locals())
642 self.make_user_anonymous()
643 return
645 # verify the password
646 if not self.verifyPassword(self.userid, password):
647 self.make_user_anonymous()
648 self.error_message.append(_('Incorrect password'))
649 return
651 # make sure we're allowed to be here
652 if not self.loginPermission():
653 self.make_user_anonymous()
654 self.error_message.append(_("You do not have permission to login"))
655 return
657 # now we're OK, re-open the database for real, using the user
658 self.opendb(self.user)
660 # set the session cookie
661 self.set_cookie(self.user)
663 def verifyPassword(self, userid, password):
664 ''' Verify the password that the user has supplied
665 '''
666 stored = self.db.user.get(self.userid, 'password')
667 if password == stored:
668 return 1
669 if not password and not stored:
670 return 1
671 return 0
673 def loginPermission(self):
674 ''' Determine whether the user has permission to log in.
676 Base behaviour is to check the user has "Web Access".
677 '''
678 if not self.db.security.hasPermission('Web Access', self.userid):
679 return 0
680 return 1
682 def logout_action(self):
683 ''' Make us really anonymous - nuke the cookie too
684 '''
685 # log us out
686 self.make_user_anonymous()
688 # construct the logout cookie
689 now = Cookie._getdate()
690 self.additional_headers['Set-Cookie'] = \
691 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
692 now, self.cookie_path)
694 # Let the user know what's going on
695 self.ok_message.append(_('You are logged out'))
697 chars = string.letters+string.digits
698 def registerAction(self):
699 '''Attempt to create a new user based on the contents of the form
700 and then set the cookie.
702 return 1 on successful login
703 '''
704 # parse the props from the form
705 try:
706 props = self.parsePropsFromForm()[0][('user', None)]
707 except (ValueError, KeyError), message:
708 self.error_message.append(_('Error: ') + str(message))
709 return
711 # make sure we're allowed to register
712 if not self.registerPermission(props):
713 raise Unauthorised, _("You do not have permission to register")
715 try:
716 self.db.user.lookup(props['username'])
717 self.error_message.append('Error: A user with the username "%s" '
718 'already exists'%props['username'])
719 return
720 except KeyError:
721 pass
723 # generate the one-time-key and store the props for later
724 otk = ''.join([random.choice(self.chars) for x in range(32)])
725 for propname, proptype in self.db.user.getprops().items():
726 value = props.get(propname, None)
727 if value is None:
728 pass
729 elif isinstance(proptype, hyperdb.Date):
730 props[propname] = str(value)
731 elif isinstance(proptype, hyperdb.Interval):
732 props[propname] = str(value)
733 elif isinstance(proptype, hyperdb.Password):
734 props[propname] = str(value)
735 props['__time'] = time.time()
736 self.db.otks.set(otk, **props)
738 # send the email
739 tracker_name = self.db.config.TRACKER_NAME
740 subject = 'Complete your registration to %s'%tracker_name
741 body = '''
742 To complete your registration of the user "%(name)s" with %(tracker)s,
743 please visit the following URL:
745 %(url)s?@action=confrego&otk=%(otk)s
746 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
747 'otk': otk}
748 if not self.sendEmail(props['address'], subject, body):
749 return
751 # commit changes to the database
752 self.db.commit()
754 # redirect to the "you're almost there" page
755 raise Redirect, '%suser?@template=rego_progress'%self.base
757 def sendEmail(self, to, subject, content):
758 # send email to the user's email address
759 message = StringIO.StringIO()
760 writer = MimeWriter.MimeWriter(message)
761 tracker_name = self.db.config.TRACKER_NAME
762 writer.addheader('Subject', encode_header(subject))
763 writer.addheader('To', to)
764 writer.addheader('From', roundupdb.straddr((tracker_name,
765 self.db.config.ADMIN_EMAIL)))
766 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
767 time.gmtime()))
768 # add a uniquely Roundup header to help filtering
769 writer.addheader('X-Roundup-Name', tracker_name)
770 # avoid email loops
771 writer.addheader('X-Roundup-Loop', 'hello')
772 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
773 body = writer.startbody('text/plain; charset=utf-8')
775 # message body, encoded quoted-printable
776 content = StringIO.StringIO(content)
777 quopri.encode(content, body, 0)
779 if SENDMAILDEBUG:
780 # don't send - just write to a file
781 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
782 self.db.config.ADMIN_EMAIL,
783 ', '.join(to),message.getvalue()))
784 else:
785 # now try to send the message
786 try:
787 # send the message as admin so bounces are sent there
788 # instead of to roundup
789 smtp = smtplib.SMTP(self.db.config.MAILHOST)
790 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
791 message.getvalue())
792 except socket.error, value:
793 self.error_message.append("Error: couldn't send email: "
794 "mailhost %s"%value)
795 return 0
796 except smtplib.SMTPException, msg:
797 self.error_message.append("Error: couldn't send email: %s"%msg)
798 return 0
799 return 1
801 def registerPermission(self, props):
802 ''' Determine whether the user has permission to register
804 Base behaviour is to check the user has "Web Registration".
805 '''
806 # registration isn't allowed to supply roles
807 if props.has_key('roles'):
808 return 0
809 if self.db.security.hasPermission('Web Registration', self.userid):
810 return 1
811 return 0
813 def confRegoAction(self):
814 ''' Grab the OTK, use it to load up the new user details
815 '''
816 # pull the rego information out of the otk database
817 otk = self.form['otk'].value
818 props = self.db.otks.getall(otk)
819 for propname, proptype in self.db.user.getprops().items():
820 value = props.get(propname, None)
821 if value is None:
822 pass
823 elif isinstance(proptype, hyperdb.Date):
824 props[propname] = date.Date(value)
825 elif isinstance(proptype, hyperdb.Interval):
826 props[propname] = date.Interval(value)
827 elif isinstance(proptype, hyperdb.Password):
828 props[propname] = password.Password()
829 props[propname].unpack(value)
831 # re-open the database as "admin"
832 if self.user != 'admin':
833 self.opendb('admin')
835 # create the new user
836 cl = self.db.user
837 # XXX we need to make the "default" page be able to display errors!
838 try:
839 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
840 del props['__time']
841 self.userid = cl.create(**props)
842 # clear the props from the otk database
843 self.db.otks.destroy(otk)
844 self.db.commit()
845 except (ValueError, KeyError), message:
846 self.error_message.append(str(message))
847 return
849 # log the new user in
850 self.user = cl.get(self.userid, 'username')
851 # re-open the database for real, using the user
852 self.opendb(self.user)
854 # if we have a session, update it
855 if hasattr(self, 'session'):
856 self.db.sessions.set(self.session, user=self.user,
857 last_use=time.time())
858 else:
859 # new session cookie
860 self.set_cookie(self.user)
862 # nice message
863 message = _('You are now registered, welcome!')
865 # redirect to the user's page
866 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
867 self.userid, urllib.quote(message))
869 def passResetAction(self):
870 ''' Handle password reset requests.
872 Presence of either "name" or "address" generate email.
873 Presense of "otk" performs the reset.
874 '''
875 if self.form.has_key('otk'):
876 # pull the rego information out of the otk database
877 otk = self.form['otk'].value
878 uid = self.db.otks.get(otk, 'uid')
879 if uid is None:
880 self.error_message.append('Invalid One Time Key!')
881 return
883 # re-open the database as "admin"
884 if self.user != 'admin':
885 self.opendb('admin')
887 # change the password
888 newpw = ''.join([random.choice(self.chars) for x in range(8)])
890 cl = self.db.user
891 # XXX we need to make the "default" page be able to display errors!
892 try:
893 # set the password
894 cl.set(uid, password=password.Password(newpw))
895 # clear the props from the otk database
896 self.db.otks.destroy(otk)
897 self.db.commit()
898 except (ValueError, KeyError), message:
899 self.error_message.append(str(message))
900 return
902 # user info
903 address = self.db.user.get(uid, 'address')
904 name = self.db.user.get(uid, 'username')
906 # send the email
907 tracker_name = self.db.config.TRACKER_NAME
908 subject = 'Password reset for %s'%tracker_name
909 body = '''
910 The password has been reset for username "%(name)s".
912 Your password is now: %(password)s
913 '''%{'name': name, 'password': newpw}
914 if not self.sendEmail(address, subject, body):
915 return
917 self.ok_message.append('Password reset and email sent to %s'%address)
918 return
920 # no OTK, so now figure the user
921 if self.form.has_key('username'):
922 name = self.form['username'].value
923 try:
924 uid = self.db.user.lookup(name)
925 except KeyError:
926 self.error_message.append('Unknown username')
927 return
928 address = self.db.user.get(uid, 'address')
929 elif self.form.has_key('address'):
930 address = self.form['address'].value
931 uid = uidFromAddress(self.db, ('', address), create=0)
932 if not uid:
933 self.error_message.append('Unknown email address')
934 return
935 name = self.db.user.get(uid, 'username')
936 else:
937 self.error_message.append('You need to specify a username '
938 'or address')
939 return
941 # generate the one-time-key and store the props for later
942 otk = ''.join([random.choice(self.chars) for x in range(32)])
943 self.db.otks.set(otk, uid=uid, __time=time.time())
945 # send the email
946 tracker_name = self.db.config.TRACKER_NAME
947 subject = 'Confirm reset of password for %s'%tracker_name
948 body = '''
949 Someone, perhaps you, has requested that the password be changed for your
950 username, "%(name)s". If you wish to proceed with the change, please follow
951 the link below:
953 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
955 You should then receive another email with the new password.
956 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
957 if not self.sendEmail(address, subject, body):
958 return
960 self.ok_message.append('Email sent to %s'%address)
962 def editItemAction(self):
963 ''' Perform an edit of an item in the database.
965 See parsePropsFromForm and _editnodes for special variables
966 '''
967 # parse the props from the form
968 try:
969 props, links = self.parsePropsFromForm()
970 except (ValueError, KeyError), message:
971 self.error_message.append(_('Error: ') + str(message))
972 return
974 # handle the props
975 try:
976 message = self._editnodes(props, links)
977 except (ValueError, KeyError, IndexError), message:
978 self.error_message.append(_('Error: ') + str(message))
979 return
981 # commit now that all the tricky stuff is done
982 self.db.commit()
984 # redirect to the item's edit page
985 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
986 self.classname, self.nodeid, urllib.quote(message),
987 urllib.quote(self.template))
989 def editItemPermission(self, props):
990 ''' Determine whether the user has permission to edit this item.
992 Base behaviour is to check the user can edit this class. If we're
993 editing the "user" class, users are allowed to edit their own
994 details. Unless it's the "roles" property, which requires the
995 special Permission "Web Roles".
996 '''
997 # if this is a user node and the user is editing their own node, then
998 # we're OK
999 has = self.db.security.hasPermission
1000 if self.classname == 'user':
1001 # reject if someone's trying to edit "roles" and doesn't have the
1002 # right permission.
1003 if props.has_key('roles') and not has('Web Roles', self.userid,
1004 'user'):
1005 return 0
1006 # if the item being edited is the current user, we're ok
1007 if self.nodeid == self.userid:
1008 return 1
1009 if self.db.security.hasPermission('Edit', self.userid, self.classname):
1010 return 1
1011 return 0
1013 def newItemAction(self):
1014 ''' Add a new item to the database.
1016 This follows the same form as the editItemAction, with the same
1017 special form values.
1018 '''
1019 # parse the props from the form
1020 try:
1021 props, links = self.parsePropsFromForm()
1022 except (ValueError, KeyError), message:
1023 self.error_message.append(_('Error: ') + str(message))
1024 return
1026 # handle the props - edit or create
1027 try:
1028 # when it hits the None element, it'll set self.nodeid
1029 messages = self._editnodes(props, links)
1031 except (ValueError, KeyError, IndexError), message:
1032 # these errors might just be indicative of user dumbness
1033 self.error_message.append(_('Error: ') + str(message))
1034 return
1036 # commit now that all the tricky stuff is done
1037 self.db.commit()
1039 # redirect to the new item's page
1040 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1041 self.classname, self.nodeid, urllib.quote(messages),
1042 urllib.quote(self.template))
1044 def newItemPermission(self, props):
1045 ''' Determine whether the user has permission to create (edit) this
1046 item.
1048 Base behaviour is to check the user can edit this class. No
1049 additional property checks are made. Additionally, new user items
1050 may be created if the user has the "Web Registration" Permission.
1051 '''
1052 has = self.db.security.hasPermission
1053 if self.classname == 'user' and has('Web Registration', self.userid,
1054 'user'):
1055 return 1
1056 if has('Edit', self.userid, self.classname):
1057 return 1
1058 return 0
1061 #
1062 # Utility methods for editing
1063 #
1064 def _editnodes(self, all_props, all_links, newids=None):
1065 ''' Use the props in all_props to perform edit and creation, then
1066 use the link specs in all_links to do linking.
1067 '''
1068 # figure dependencies and re-work links
1069 deps = {}
1070 links = {}
1071 for cn, nodeid, propname, vlist in all_links:
1072 if not all_props.has_key((cn, nodeid)):
1073 # link item to link to doesn't (and won't) exist
1074 continue
1075 for value in vlist:
1076 if not all_props.has_key(value):
1077 # link item to link to doesn't (and won't) exist
1078 continue
1079 deps.setdefault((cn, nodeid), []).append(value)
1080 links.setdefault(value, []).append((cn, nodeid, propname))
1082 # figure chained dependencies ordering
1083 order = []
1084 done = {}
1085 # loop detection
1086 change = 0
1087 while len(all_props) != len(done):
1088 for needed in all_props.keys():
1089 if done.has_key(needed):
1090 continue
1091 tlist = deps.get(needed, [])
1092 for target in tlist:
1093 if not done.has_key(target):
1094 break
1095 else:
1096 done[needed] = 1
1097 order.append(needed)
1098 change = 1
1099 if not change:
1100 raise ValueError, 'linking must not loop!'
1102 # now, edit / create
1103 m = []
1104 for needed in order:
1105 props = all_props[needed]
1106 if not props:
1107 # nothing to do
1108 continue
1109 cn, nodeid = needed
1111 if nodeid is not None and int(nodeid) > 0:
1112 # make changes to the node
1113 props = self._changenode(cn, nodeid, props)
1115 # and some nice feedback for the user
1116 if props:
1117 info = ', '.join(props.keys())
1118 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1119 else:
1120 m.append('%s %s - nothing changed'%(cn, nodeid))
1121 else:
1122 assert props
1124 # make a new node
1125 newid = self._createnode(cn, props)
1126 if nodeid is None:
1127 self.nodeid = newid
1128 nodeid = newid
1130 # and some nice feedback for the user
1131 m.append('%s %s created'%(cn, newid))
1133 # fill in new ids in links
1134 if links.has_key(needed):
1135 for linkcn, linkid, linkprop in links[needed]:
1136 props = all_props[(linkcn, linkid)]
1137 cl = self.db.classes[linkcn]
1138 propdef = cl.getprops()[linkprop]
1139 if not props.has_key(linkprop):
1140 if linkid is None or linkid.startswith('-'):
1141 # linking to a new item
1142 if isinstance(propdef, hyperdb.Multilink):
1143 props[linkprop] = [newid]
1144 else:
1145 props[linkprop] = newid
1146 else:
1147 # linking to an existing item
1148 if isinstance(propdef, hyperdb.Multilink):
1149 existing = cl.get(linkid, linkprop)[:]
1150 existing.append(nodeid)
1151 props[linkprop] = existing
1152 else:
1153 props[linkprop] = newid
1155 return '<br>'.join(m)
1157 def _changenode(self, cn, nodeid, props):
1158 ''' change the node based on the contents of the form
1159 '''
1160 # check for permission
1161 if not self.editItemPermission(props):
1162 raise Unauthorised, 'You do not have permission to edit %s'%cn
1164 # make the changes
1165 cl = self.db.classes[cn]
1166 return cl.set(nodeid, **props)
1168 def _createnode(self, cn, props):
1169 ''' create a node based on the contents of the form
1170 '''
1171 # check for permission
1172 if not self.newItemPermission(props):
1173 raise Unauthorised, 'You do not have permission to create %s'%cn
1175 # create the node and return its id
1176 cl = self.db.classes[cn]
1177 return cl.create(**props)
1179 #
1180 # More actions
1181 #
1182 def editCSVAction(self):
1183 ''' Performs an edit of all of a class' items in one go.
1185 The "rows" CGI var defines the CSV-formatted entries for the
1186 class. New nodes are identified by the ID 'X' (or any other
1187 non-existent ID) and removed lines are retired.
1188 '''
1189 # this is per-class only
1190 if not self.editCSVPermission():
1191 self.error_message.append(
1192 _('You do not have permission to edit %s' %self.classname))
1194 # get the CSV module
1195 try:
1196 import csv
1197 except ImportError:
1198 self.error_message.append(_(
1199 'Sorry, you need the csv module to use this function.<br>\n'
1200 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1201 return
1203 cl = self.db.classes[self.classname]
1204 idlessprops = cl.getprops(protected=0).keys()
1205 idlessprops.sort()
1206 props = ['id'] + idlessprops
1208 # do the edit
1209 rows = self.form['rows'].value.splitlines()
1210 p = csv.parser()
1211 found = {}
1212 line = 0
1213 for row in rows[1:]:
1214 line += 1
1215 values = p.parse(row)
1216 # not a complete row, keep going
1217 if not values: continue
1219 # skip property names header
1220 if values == props:
1221 continue
1223 # extract the nodeid
1224 nodeid, values = values[0], values[1:]
1225 found[nodeid] = 1
1227 # see if the node exists
1228 if cl.hasnode(nodeid):
1229 exists = 1
1230 else:
1231 exists = 0
1233 # confirm correct weight
1234 if len(idlessprops) != len(values):
1235 self.error_message.append(
1236 _('Not enough values on line %(line)s')%{'line':line})
1237 return
1239 # extract the new values
1240 d = {}
1241 for name, value in zip(idlessprops, values):
1242 prop = cl.properties[name]
1243 value = value.strip()
1244 # only add the property if it has a value
1245 if value:
1246 # if it's a multilink, split it
1247 if isinstance(prop, hyperdb.Multilink):
1248 value = value.split(':')
1249 d[name] = value
1250 elif exists:
1251 # nuke the existing value
1252 if isinstance(prop, hyperdb.Multilink):
1253 d[name] = []
1254 else:
1255 d[name] = None
1257 # perform the edit
1258 if exists:
1259 # edit existing
1260 cl.set(nodeid, **d)
1261 else:
1262 # new node
1263 found[cl.create(**d)] = 1
1265 # retire the removed entries
1266 for nodeid in cl.list():
1267 if not found.has_key(nodeid):
1268 cl.retire(nodeid)
1270 # all OK
1271 self.db.commit()
1273 self.ok_message.append(_('Items edited OK'))
1275 def editCSVPermission(self):
1276 ''' Determine whether the user has permission to edit this class.
1278 Base behaviour is to check the user can edit this class.
1279 '''
1280 if not self.db.security.hasPermission('Edit', self.userid,
1281 self.classname):
1282 return 0
1283 return 1
1285 def searchAction(self):
1286 ''' Mangle some of the form variables.
1288 Set the form ":filter" variable based on the values of the
1289 filter variables - if they're set to anything other than
1290 "dontcare" then add them to :filter.
1292 Also handle the ":queryname" variable and save off the query to
1293 the user's query list.
1294 '''
1295 # generic edit is per-class only
1296 if not self.searchPermission():
1297 self.error_message.append(
1298 _('You do not have permission to search %s' %self.classname))
1300 # add a faked :filter form variable for each filtering prop
1301 props = self.db.classes[self.classname].getprops()
1302 queryname = ''
1303 for key in self.form.keys():
1304 # special vars
1305 if self.FV_QUERYNAME.match(key):
1306 queryname = self.form[key].value.strip()
1307 continue
1309 if not props.has_key(key):
1310 continue
1311 if isinstance(self.form[key], type([])):
1312 # search for at least one entry which is not empty
1313 for minifield in self.form[key]:
1314 if minifield.value:
1315 break
1316 else:
1317 continue
1318 else:
1319 if not self.form[key].value:
1320 continue
1321 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1323 # handle saving the query params
1324 if queryname:
1325 # parse the environment and figure what the query _is_
1326 req = HTMLRequest(self)
1327 url = req.indexargs_href('', {})
1329 # handle editing an existing query
1330 try:
1331 qid = self.db.query.lookup(queryname)
1332 self.db.query.set(qid, klass=self.classname, url=url)
1333 except KeyError:
1334 # create a query
1335 qid = self.db.query.create(name=queryname,
1336 klass=self.classname, url=url)
1338 # and add it to the user's query multilink
1339 queries = self.db.user.get(self.userid, 'queries')
1340 queries.append(qid)
1341 self.db.user.set(self.userid, queries=queries)
1343 # commit the query change to the database
1344 self.db.commit()
1346 def searchPermission(self):
1347 ''' Determine whether the user has permission to search this class.
1349 Base behaviour is to check the user can view this class.
1350 '''
1351 if not self.db.security.hasPermission('View', self.userid,
1352 self.classname):
1353 return 0
1354 return 1
1357 def retireAction(self):
1358 ''' Retire the context item.
1359 '''
1360 # if we want to view the index template now, then unset the nodeid
1361 # context info (a special-case for retire actions on the index page)
1362 nodeid = self.nodeid
1363 if self.template == 'index':
1364 self.nodeid = None
1366 # generic edit is per-class only
1367 if not self.retirePermission():
1368 self.error_message.append(
1369 _('You do not have permission to retire %s' %self.classname))
1370 return
1372 # make sure we don't try to retire admin or anonymous
1373 if self.classname == 'user' and \
1374 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1375 self.error_message.append(
1376 _('You may not retire the admin or anonymous user'))
1377 return
1379 # do the retire
1380 self.db.getclass(self.classname).retire(nodeid)
1381 self.db.commit()
1383 self.ok_message.append(
1384 _('%(classname)s %(itemid)s has been retired')%{
1385 'classname': self.classname.capitalize(), 'itemid': nodeid})
1387 def retirePermission(self):
1388 ''' Determine whether the user has permission to retire this class.
1390 Base behaviour is to check the user can edit this class.
1391 '''
1392 if not self.db.security.hasPermission('Edit', self.userid,
1393 self.classname):
1394 return 0
1395 return 1
1398 def showAction(self, typere=re.compile('[@:]type'),
1399 numre=re.compile('[@:]number')):
1400 ''' Show a node of a particular class/id
1401 '''
1402 t = n = ''
1403 for key in self.form.keys():
1404 if typere.match(key):
1405 t = self.form[key].value.strip()
1406 elif numre.match(key):
1407 n = self.form[key].value.strip()
1408 if not t:
1409 raise ValueError, 'Invalid %s number'%t
1410 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1411 raise Redirect, url
1413 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1414 ''' Pull properties out of the form.
1416 In the following, <bracketed> values are variable, ":" may be
1417 one of ":" or "@", and other text "required" is fixed.
1419 Properties are specified as form variables:
1421 <propname>
1422 - property on the current context item
1424 <designator>:<propname>
1425 - property on the indicated item
1427 <classname>-<N>:<propname>
1428 - property on the Nth new item of classname
1430 Once we have determined the "propname", we check to see if it
1431 is one of the special form values:
1433 :required
1434 The named property values must be supplied or a ValueError
1435 will be raised.
1437 :remove:<propname>=id(s)
1438 The ids will be removed from the multilink property.
1440 :add:<propname>=id(s)
1441 The ids will be added to the multilink property.
1443 :link:<propname>=<designator>
1444 Used to add a link to new items created during edit.
1445 These are collected up and returned in all_links. This will
1446 result in an additional linking operation (either Link set or
1447 Multilink append) after the edit/create is done using
1448 all_props in _editnodes. The <propname> on the current item
1449 will be set/appended the id of the newly created item of
1450 class <designator> (where <designator> must be
1451 <classname>-<N>).
1453 Any of the form variables may be prefixed with a classname or
1454 designator.
1456 The return from this method is a dict of
1457 (classname, id): properties
1458 ... this dict _always_ has an entry for the current context,
1459 even if it's empty (ie. a submission for an existing issue that
1460 doesn't result in any changes would return {('issue','123'): {}})
1461 The id may be None, which indicates that an item should be
1462 created.
1464 If a String property's form value is a file upload, then we
1465 try to set additional properties "filename" and "type" (if
1466 they are valid for the class).
1468 Two special form values are supported for backwards
1469 compatibility:
1470 :note - create a message (with content, author and date), link
1471 to the context item. This is ALWAYS desginated "msg-1".
1472 :file - create a file, attach to the current item and any
1473 message created by :note. This is ALWAYS designated
1474 "file-1".
1476 We also check that FileClass items have a "content" property with
1477 actual content, otherwise we remove them from all_props before
1478 returning.
1479 '''
1480 # some very useful variables
1481 db = self.db
1482 form = self.form
1484 if not hasattr(self, 'FV_SPECIAL'):
1485 # generate the regexp for handling special form values
1486 classes = '|'.join(db.classes.keys())
1487 # specials for parsePropsFromForm
1488 # handle the various forms (see unit tests)
1489 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1490 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1492 # these indicate the default class / item
1493 default_cn = self.classname
1494 default_cl = self.db.classes[default_cn]
1495 default_nodeid = self.nodeid
1497 # we'll store info about the individual class/item edit in these
1498 all_required = {} # one entry per class/item
1499 all_props = {} # one entry per class/item
1500 all_propdef = {} # note - only one entry per class
1501 all_links = [] # as many as are required
1503 # we should always return something, even empty, for the context
1504 all_props[(default_cn, default_nodeid)] = {}
1506 keys = form.keys()
1507 timezone = db.getUserTimezone()
1509 # sentinels for the :note and :file props
1510 have_note = have_file = 0
1512 # extract the usable form labels from the form
1513 matches = []
1514 for key in keys:
1515 m = self.FV_SPECIAL.match(key)
1516 if m:
1517 matches.append((key, m.groupdict()))
1519 # now handle the matches
1520 for key, d in matches:
1521 if d['classname']:
1522 # we got a designator
1523 cn = d['classname']
1524 cl = self.db.classes[cn]
1525 nodeid = d['id']
1526 propname = d['propname']
1527 elif d['note']:
1528 # the special note field
1529 cn = 'msg'
1530 cl = self.db.classes[cn]
1531 nodeid = '-1'
1532 propname = 'content'
1533 all_links.append((default_cn, default_nodeid, 'messages',
1534 [('msg', '-1')]))
1535 have_note = 1
1536 elif d['file']:
1537 # the special file field
1538 cn = 'file'
1539 cl = self.db.classes[cn]
1540 nodeid = '-1'
1541 propname = 'content'
1542 all_links.append((default_cn, default_nodeid, 'files',
1543 [('file', '-1')]))
1544 have_file = 1
1545 else:
1546 # default
1547 cn = default_cn
1548 cl = default_cl
1549 nodeid = default_nodeid
1550 propname = d['propname']
1552 # the thing this value relates to is...
1553 this = (cn, nodeid)
1555 # get more info about the class, and the current set of
1556 # form props for it
1557 if not all_propdef.has_key(cn):
1558 all_propdef[cn] = cl.getprops()
1559 propdef = all_propdef[cn]
1560 if not all_props.has_key(this):
1561 all_props[this] = {}
1562 props = all_props[this]
1564 # is this a link command?
1565 if d['link']:
1566 value = []
1567 for entry in extractFormList(form[key]):
1568 m = self.FV_DESIGNATOR.match(entry)
1569 if not m:
1570 raise ValueError, \
1571 'link "%s" value "%s" not a designator'%(key, entry)
1572 value.append((m.group(1), m.group(2)))
1574 # make sure the link property is valid
1575 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1576 not isinstance(propdef[propname], hyperdb.Link)):
1577 raise ValueError, '%s %s is not a link or '\
1578 'multilink property'%(cn, propname)
1580 all_links.append((cn, nodeid, propname, value))
1581 continue
1583 # detect the special ":required" variable
1584 if d['required']:
1585 all_required[this] = extractFormList(form[key])
1586 continue
1588 # get the required values list
1589 if not all_required.has_key(this):
1590 all_required[this] = []
1591 required = all_required[this]
1593 # see if we're performing a special multilink action
1594 mlaction = 'set'
1595 if d['remove']:
1596 mlaction = 'remove'
1597 elif d['add']:
1598 mlaction = 'add'
1600 # does the property exist?
1601 if not propdef.has_key(propname):
1602 if mlaction != 'set':
1603 raise ValueError, 'You have submitted a %s action for'\
1604 ' the property "%s" which doesn\'t exist'%(mlaction,
1605 propname)
1606 # the form element is probably just something we don't care
1607 # about - ignore it
1608 continue
1609 proptype = propdef[propname]
1611 # Get the form value. This value may be a MiniFieldStorage or a list
1612 # of MiniFieldStorages.
1613 value = form[key]
1615 # handle unpacking of the MiniFieldStorage / list form value
1616 if isinstance(proptype, hyperdb.Multilink):
1617 value = extractFormList(value)
1618 else:
1619 # multiple values are not OK
1620 if isinstance(value, type([])):
1621 raise ValueError, 'You have submitted more than one value'\
1622 ' for the %s property'%propname
1623 # value might be a file upload...
1624 if not hasattr(value, 'filename') or value.filename is None:
1625 # nope, pull out the value and strip it
1626 value = value.value.strip()
1628 # now that we have the props field, we need a teensy little
1629 # extra bit of help for the old :note field...
1630 if d['note'] and value:
1631 props['author'] = self.db.getuid()
1632 props['date'] = date.Date()
1634 # handle by type now
1635 if isinstance(proptype, hyperdb.Password):
1636 if not value:
1637 # ignore empty password values
1638 continue
1639 for key, d in matches:
1640 if d['confirm'] and d['propname'] == propname:
1641 confirm = form[key]
1642 break
1643 else:
1644 raise ValueError, 'Password and confirmation text do '\
1645 'not match'
1646 if isinstance(confirm, type([])):
1647 raise ValueError, 'You have submitted more than one value'\
1648 ' for the %s property'%propname
1649 if value != confirm.value:
1650 raise ValueError, 'Password and confirmation text do '\
1651 'not match'
1652 value = password.Password(value)
1654 elif isinstance(proptype, hyperdb.Link):
1655 # see if it's the "no selection" choice
1656 if value == '-1' or not value:
1657 # if we're creating, just don't include this property
1658 if not nodeid or nodeid.startswith('-'):
1659 continue
1660 value = None
1661 else:
1662 # handle key values
1663 link = proptype.classname
1664 if not num_re.match(value):
1665 try:
1666 value = db.classes[link].lookup(value)
1667 except KeyError:
1668 raise ValueError, _('property "%(propname)s": '
1669 '%(value)s not a %(classname)s')%{
1670 'propname': propname, 'value': value,
1671 'classname': link}
1672 except TypeError, message:
1673 raise ValueError, _('you may only enter ID values '
1674 'for property "%(propname)s": %(message)s')%{
1675 'propname': propname, 'message': message}
1676 elif isinstance(proptype, hyperdb.Multilink):
1677 # perform link class key value lookup if necessary
1678 link = proptype.classname
1679 link_cl = db.classes[link]
1680 l = []
1681 for entry in value:
1682 if not entry: continue
1683 if not num_re.match(entry):
1684 try:
1685 entry = link_cl.lookup(entry)
1686 except KeyError:
1687 raise ValueError, _('property "%(propname)s": '
1688 '"%(value)s" not an entry of %(classname)s')%{
1689 'propname': propname, 'value': entry,
1690 'classname': link}
1691 except TypeError, message:
1692 raise ValueError, _('you may only enter ID values '
1693 'for property "%(propname)s": %(message)s')%{
1694 'propname': propname, 'message': message}
1695 l.append(entry)
1696 l.sort()
1698 # now use that list of ids to modify the multilink
1699 if mlaction == 'set':
1700 value = l
1701 else:
1702 # we're modifying the list - get the current list of ids
1703 if props.has_key(propname):
1704 existing = props[propname]
1705 elif nodeid and not nodeid.startswith('-'):
1706 existing = cl.get(nodeid, propname, [])
1707 else:
1708 existing = []
1710 # now either remove or add
1711 if mlaction == 'remove':
1712 # remove - handle situation where the id isn't in
1713 # the list
1714 for entry in l:
1715 try:
1716 existing.remove(entry)
1717 except ValueError:
1718 raise ValueError, _('property "%(propname)s": '
1719 '"%(value)s" not currently in list')%{
1720 'propname': propname, 'value': entry}
1721 else:
1722 # add - easy, just don't dupe
1723 for entry in l:
1724 if entry not in existing:
1725 existing.append(entry)
1726 value = existing
1727 value.sort()
1729 elif value == '':
1730 # if we're creating, just don't include this property
1731 if not nodeid or nodeid.startswith('-'):
1732 continue
1733 # other types should be None'd if there's no value
1734 value = None
1735 else:
1736 # handle ValueErrors for all these in a similar fashion
1737 try:
1738 if isinstance(proptype, hyperdb.String):
1739 if (hasattr(value, 'filename') and
1740 value.filename is not None):
1741 # skip if the upload is empty
1742 if not value.filename:
1743 continue
1744 # this String is actually a _file_
1745 # try to determine the file content-type
1746 fn = value.filename.split('\\')[-1]
1747 if propdef.has_key('name'):
1748 props['name'] = fn
1749 # use this info as the type/filename properties
1750 if propdef.has_key('type'):
1751 props['type'] = mimetypes.guess_type(fn)[0]
1752 if not props['type']:
1753 props['type'] = "application/octet-stream"
1754 # finally, read the content
1755 value = value.value
1756 else:
1757 # normal String fix the CRLF/CR -> LF stuff
1758 value = fixNewlines(value)
1760 elif isinstance(proptype, hyperdb.Date):
1761 value = date.Date(value, offset=timezone)
1762 elif isinstance(proptype, hyperdb.Interval):
1763 value = date.Interval(value)
1764 elif isinstance(proptype, hyperdb.Boolean):
1765 value = value.lower() in ('yes', 'true', 'on', '1')
1766 elif isinstance(proptype, hyperdb.Number):
1767 value = float(value)
1768 except ValueError, msg:
1769 raise ValueError, _('Error with %s property: %s')%(
1770 propname, msg)
1772 # get the old value
1773 if nodeid and not nodeid.startswith('-'):
1774 try:
1775 existing = cl.get(nodeid, propname)
1776 except KeyError:
1777 # this might be a new property for which there is
1778 # no existing value
1779 if not propdef.has_key(propname):
1780 raise
1782 # make sure the existing multilink is sorted
1783 if isinstance(proptype, hyperdb.Multilink):
1784 existing.sort()
1786 # "missing" existing values may not be None
1787 if not existing:
1788 if isinstance(proptype, hyperdb.String) and not existing:
1789 # some backends store "missing" Strings as empty strings
1790 existing = None
1791 elif isinstance(proptype, hyperdb.Number) and not existing:
1792 # some backends store "missing" Numbers as 0 :(
1793 existing = 0
1794 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1795 # likewise Booleans
1796 existing = 0
1798 # if changed, set it
1799 if value != existing:
1800 props[propname] = value
1801 else:
1802 # don't bother setting empty/unset values
1803 if value is None:
1804 continue
1805 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1806 continue
1807 elif isinstance(proptype, hyperdb.String) and value == '':
1808 continue
1810 props[propname] = value
1812 # register this as received if required?
1813 if propname in required and value is not None:
1814 required.remove(propname)
1816 # check to see if we need to specially link a file to the note
1817 if have_note and have_file:
1818 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1820 # see if all the required properties have been supplied
1821 s = []
1822 for thing, required in all_required.items():
1823 if not required:
1824 continue
1825 if len(required) > 1:
1826 p = 'properties'
1827 else:
1828 p = 'property'
1829 s.append('Required %s %s %s not supplied'%(thing[0], p,
1830 ', '.join(required)))
1831 if s:
1832 raise ValueError, '\n'.join(s)
1834 # check that FileClass entries have a "content" property with
1835 # content, otherwise remove them
1836 for (cn, id), props in all_props.items():
1837 cl = self.db.classes[cn]
1838 if not isinstance(cl, hyperdb.FileClass):
1839 continue
1840 # we also don't want to create FileClass items with no content
1841 if not props.get('content', ''):
1842 del all_props[(cn, id)]
1843 return all_props, all_links
1845 def fixNewlines(text):
1846 ''' Homogenise line endings.
1848 Different web clients send different line ending values, but
1849 other systems (eg. email) don't necessarily handle those line
1850 endings. Our solution is to convert all line endings to LF.
1851 '''
1852 text = text.replace('\r\n', '\n')
1853 return text.replace('\r', '\n')
1855 def extractFormList(value):
1856 ''' Extract a list of values from the form value.
1858 It may be one of:
1859 [MiniFieldStorage, MiniFieldStorage, ...]
1860 MiniFieldStorage('value,value,...')
1861 MiniFieldStorage('value')
1862 '''
1863 # multiple values are OK
1864 if isinstance(value, type([])):
1865 # it's a list of MiniFieldStorages
1866 value = [i.value.strip() for i in value]
1867 else:
1868 # it's a MiniFieldStorage, but may be a comma-separated list
1869 # of values
1870 value = [i.strip() for i in value.value.split(',')]
1872 # filter out the empty bits
1873 return filter(None, value)