1 # $Id: client.py,v 1.147 2003-11-21 21:59:05 jlgijsbers 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
11 from roundup import roundupdb, date, hyperdb, password, token, rcsv
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
18 from roundup.mailer import Mailer, MessageSendError
20 class HTTPException(Exception):
21 pass
22 class Unauthorised(HTTPException):
23 pass
24 class NotFound(HTTPException):
25 pass
26 class Redirect(HTTPException):
27 pass
28 class NotModified(HTTPException):
29 pass
31 # used by a couple of routines
32 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
34 class FormError(ValueError):
35 ''' An "expected" exception occurred during form parsing.
36 - ie. something we know can go wrong, and don't want to alarm the
37 user with
39 We trap this at the user interface level and feed back a nice error
40 to the user.
41 '''
42 pass
44 class SendFile(Exception):
45 ''' Send a file from the database '''
47 class SendStaticFile(Exception):
48 ''' Send a static file from the instance html directory '''
50 def initialiseSecurity(security):
51 ''' Create some Permissions and Roles on the security object
53 This function is directly invoked by security.Security.__init__()
54 as a part of the Security object instantiation.
55 '''
56 security.addPermission(name="Web Registration",
57 description="User may register through the web")
58 p = security.addPermission(name="Web Access",
59 description="User may access the web interface")
60 security.addPermissionToRole('Admin', p)
62 # doing Role stuff through the web - make sure Admin can
63 p = security.addPermission(name="Web Roles",
64 description="User may manipulate user Roles through the web")
65 security.addPermissionToRole('Admin', p)
67 # used to clean messages passed through CGI variables - HTML-escape any tag
68 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
69 # that people can't pass through nasties like <script>, <iframe>, ...
70 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
71 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
72 return mc.sub(clean_message_callback, message)
73 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
74 ''' Strip all non <a>,<i>,<b> and <br> tags from a string
75 '''
76 if ok.has_key(match.group(3).lower()):
77 return match.group(1)
78 return '<%s>'%match.group(2)
80 class Client:
81 ''' Instantiate to handle one CGI request.
83 See inner_main for request processing.
85 Client attributes at instantiation:
86 "path" is the PATH_INFO inside the instance (with no leading '/')
87 "base" is the base URL for the instance
88 "form" is the cgi form, an instance of FieldStorage from the standard
89 cgi module
90 "additional_headers" is a dictionary of additional HTTP headers that
91 should be sent to the client
92 "response_code" is the HTTP response code to send to the client
94 During the processing of a request, the following attributes are used:
95 "error_message" holds a list of error messages
96 "ok_message" holds a list of OK messages
97 "session" is the current user session id
98 "user" is the current user's name
99 "userid" is the current user's id
100 "template" is the current :template context
101 "classname" is the current class context name
102 "nodeid" is the current context item id
104 User Identification:
105 If the user has no login cookie, then they are anonymous and are logged
106 in as that user. This typically gives them all Permissions assigned to the
107 Anonymous Role.
109 Once a user logs in, they are assigned a session. The Client instance
110 keeps the nodeid of the session as the "session" attribute.
113 Special form variables:
114 Note that in various places throughout this code, special form
115 variables of the form :<name> are used. The colon (":") part may
116 actually be one of either ":" or "@".
117 '''
119 #
120 # special form variables
121 #
122 FV_TEMPLATE = re.compile(r'[@:]template')
123 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
124 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
126 FV_QUERYNAME = re.compile(r'[@:]queryname')
128 # edit form variable handling (see unit tests)
129 FV_LABELS = r'''
130 ^(
131 (?P<note>[@:]note)|
132 (?P<file>[@:]file)|
133 (
134 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
135 ((?P<required>[@:]required$)| # :required
136 (
137 (
138 (?P<add>[@:]add[@:])| # :add:<prop>
139 (?P<remove>[@:]remove[@:])| # :remove:<prop>
140 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
141 (?P<link>[@:]link[@:])| # :link:<prop>
142 ([@:]) # just a separator
143 )?
144 (?P<propname>[^@:]+) # <prop>
145 )
146 )
147 )
148 )$'''
150 # Note: index page stuff doesn't appear here:
151 # columns, sort, sortdir, filter, group, groupdir, search_text,
152 # pagesize, startwith
154 def __init__(self, instance, request, env, form=None):
155 hyperdb.traceMark()
156 self.instance = instance
157 self.request = request
158 self.env = env
159 self.mailer = Mailer(instance.config)
161 # save off the path
162 self.path = env['PATH_INFO']
164 # this is the base URL for this tracker
165 self.base = self.instance.config.TRACKER_WEB
167 # this is the "cookie path" for this tracker (ie. the path part of
168 # the "base" url)
169 self.cookie_path = urlparse.urlparse(self.base)[2]
170 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
171 self.instance.config.TRACKER_NAME)
173 # see if we need to re-parse the environment for the form (eg Zope)
174 if form is None:
175 self.form = cgi.FieldStorage(environ=env)
176 else:
177 self.form = form
179 # turn debugging on/off
180 try:
181 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
182 except ValueError:
183 # someone gave us a non-int debug level, turn it off
184 self.debug = 0
186 # flag to indicate that the HTTP headers have been sent
187 self.headers_done = 0
189 # additional headers to send with the request - must be registered
190 # before the first write
191 self.additional_headers = {}
192 self.response_code = 200
195 def main(self):
196 ''' Wrap the real main in a try/finally so we always close off the db.
197 '''
198 try:
199 self.inner_main()
200 finally:
201 if hasattr(self, 'db'):
202 self.db.close()
204 def inner_main(self):
205 ''' Process a request.
207 The most common requests are handled like so:
208 1. figure out who we are, defaulting to the "anonymous" user
209 see determine_user
210 2. figure out what the request is for - the context
211 see determine_context
212 3. handle any requested action (item edit, search, ...)
213 see handle_action
214 4. render a template, resulting in HTML output
216 In some situations, exceptions occur:
217 - HTTP Redirect (generally raised by an action)
218 - SendFile (generally raised by determine_context)
219 serve up a FileClass "content" property
220 - SendStaticFile (generally raised by determine_context)
221 serve up a file from the tracker "html" directory
222 - Unauthorised (generally raised by an action)
223 the action is cancelled, the request is rendered and an error
224 message is displayed indicating that permission was not
225 granted for the action to take place
226 - NotFound (raised wherever it needs to be)
227 percolates up to the CGI interface that called the client
228 '''
229 self.ok_message = []
230 self.error_message = []
231 try:
232 # figure out the context and desired content template
233 # do this first so we don't authenticate for static files
234 # Note: this method opens the database as "admin" in order to
235 # perform context checks
236 self.determine_context()
238 # make sure we're identified (even anonymously)
239 self.determine_user()
241 # possibly handle a form submit action (may change self.classname
242 # and self.template, and may also append error/ok_messages)
243 self.handle_action()
245 # now render the page
246 # we don't want clients caching our dynamic pages
247 self.additional_headers['Cache-Control'] = 'no-cache'
248 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
249 # self.additional_headers['Pragma'] = 'no-cache'
251 # expire this page 5 seconds from now
252 date = rfc822.formatdate(time.time() + 5)
253 self.additional_headers['Expires'] = date
255 # render the content
256 self.write(self.renderContext())
257 except Redirect, url:
258 # let's redirect - if the url isn't None, then we need to do
259 # the headers, otherwise the headers have been set before the
260 # exception was raised
261 if url:
262 self.additional_headers['Location'] = url
263 self.response_code = 302
264 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
265 except SendFile, designator:
266 self.serve_file(designator)
267 except SendStaticFile, file:
268 try:
269 self.serve_static_file(str(file))
270 except NotModified:
271 # send the 304 response
272 self.request.send_response(304)
273 self.request.end_headers()
274 except Unauthorised, message:
275 self.classname = None
276 self.template = ''
277 self.error_message.append(message)
278 self.write(self.renderContext())
279 except NotFound:
280 # pass through
281 raise
282 except FormError, e:
283 self.error_message.append(_('Form Error: ') + str(e))
284 self.write(self.renderContext())
285 except:
286 # everything else
287 self.write(cgitb.html())
289 def clean_sessions(self):
290 ''' Age sessions, remove when they haven't been used for a week.
292 Do it only once an hour.
294 Note: also cleans One Time Keys, and other "session" based
295 stuff.
296 '''
297 sessions = self.db.sessions
298 last_clean = sessions.get('last_clean', 'last_use') or 0
300 week = 60*60*24*7
301 hour = 60*60
302 now = time.time()
303 if now - last_clean > hour:
304 # remove aged sessions
305 for sessid in sessions.list():
306 interval = now - sessions.get(sessid, 'last_use')
307 if interval > week:
308 sessions.destroy(sessid)
309 # remove aged otks
310 otks = self.db.otks
311 for sessid in otks.list():
312 interval = now - otks.get(sessid, '__time')
313 if interval > week:
314 otks.destroy(sessid)
315 sessions.set('last_clean', last_use=time.time())
317 def determine_user(self):
318 ''' Determine who the user is
319 '''
320 # open the database as admin
321 self.opendb('admin')
323 # clean age sessions
324 self.clean_sessions()
326 # make sure we have the session Class
327 sessions = self.db.sessions
329 # look up the user session cookie
330 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
331 user = 'anonymous'
333 # bump the "revision" of the cookie since the format changed
334 if (cookie.has_key(self.cookie_name) and
335 cookie[self.cookie_name].value != 'deleted'):
337 # get the session key from the cookie
338 self.session = cookie[self.cookie_name].value
339 # get the user from the session
340 try:
341 # update the lifetime datestamp
342 sessions.set(self.session, last_use=time.time())
343 sessions.commit()
344 user = sessions.get(self.session, 'user')
345 except KeyError:
346 user = 'anonymous'
348 # sanity check on the user still being valid, getting the userid
349 # at the same time
350 try:
351 self.userid = self.db.user.lookup(user)
352 except (KeyError, TypeError):
353 user = 'anonymous'
355 # make sure the anonymous user is valid if we're using it
356 if user == 'anonymous':
357 self.make_user_anonymous()
358 else:
359 self.user = user
361 # reopen the database as the correct user
362 self.opendb(self.user)
364 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
365 ''' Determine the context of this page from the URL:
367 The URL path after the instance identifier is examined. The path
368 is generally only one entry long.
370 - if there is no path, then we are in the "home" context.
371 * if the path is "_file", then the additional path entry
372 specifies the filename of a static file we're to serve up
373 from the instance "html" directory. Raises a SendStaticFile
374 exception.
375 - if there is something in the path (eg "issue"), it identifies
376 the tracker class we're to display.
377 - if the path is an item designator (eg "issue123"), then we're
378 to display a specific item.
379 * if the path starts with an item designator and is longer than
380 one entry, then we're assumed to be handling an item of a
381 FileClass, and the extra path information gives the filename
382 that the client is going to label the download with (ie
383 "file123/image.png" is nicer to download than "file123"). This
384 raises a SendFile exception.
386 Both of the "*" types of contexts stop before we bother to
387 determine the template we're going to use. That's because they
388 don't actually use templates.
390 The template used is specified by the :template CGI variable,
391 which defaults to:
393 only classname suplied: "index"
394 full item designator supplied: "item"
396 We set:
397 self.classname - the class to display, can be None
398 self.template - the template to render the current context with
399 self.nodeid - the nodeid of the class we're displaying
400 '''
401 # default the optional variables
402 self.classname = None
403 self.nodeid = None
405 # see if a template or messages are specified
406 template_override = ok_message = error_message = None
407 for key in self.form.keys():
408 if self.FV_TEMPLATE.match(key):
409 template_override = self.form[key].value
410 elif self.FV_OK_MESSAGE.match(key):
411 ok_message = self.form[key].value
412 ok_message = clean_message(ok_message)
413 elif self.FV_ERROR_MESSAGE.match(key):
414 error_message = self.form[key].value
415 error_message = clean_message(error_message)
417 # determine the classname and possibly nodeid
418 path = self.path.split('/')
419 if not path or path[0] in ('', 'home', 'index'):
420 if template_override is not None:
421 self.template = template_override
422 else:
423 self.template = ''
424 return
425 elif path[0] in ('_file', '@@file'):
426 raise SendStaticFile, os.path.join(*path[1:])
427 else:
428 self.classname = path[0]
429 if len(path) > 1:
430 # send the file identified by the designator in path[0]
431 raise SendFile, path[0]
433 # we need the db for further context stuff - open it as admin
434 self.opendb('admin')
436 # see if we got a designator
437 m = dre.match(self.classname)
438 if m:
439 self.classname = m.group(1)
440 self.nodeid = m.group(2)
441 if not self.db.getclass(self.classname).hasnode(self.nodeid):
442 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
443 # with a designator, we default to item view
444 self.template = 'item'
445 else:
446 # with only a class, we default to index view
447 self.template = 'index'
449 # make sure the classname is valid
450 try:
451 self.db.getclass(self.classname)
452 except KeyError:
453 raise NotFound, self.classname
455 # see if we have a template override
456 if template_override is not None:
457 self.template = template_override
459 # see if we were passed in a message
460 if ok_message:
461 self.ok_message.append(ok_message)
462 if error_message:
463 self.error_message.append(error_message)
465 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
466 ''' Serve the file from the content property of the designated item.
467 '''
468 m = dre.match(str(designator))
469 if not m:
470 raise NotFound, str(designator)
471 classname, nodeid = m.group(1), m.group(2)
472 if classname != 'file':
473 raise NotFound, designator
475 # we just want to serve up the file named
476 self.opendb('admin')
477 file = self.db.file
478 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
479 self.write(file.get(nodeid, 'content'))
481 def serve_static_file(self, file):
482 ims = None
483 # see if there's an if-modified-since...
484 if hasattr(self.request, 'headers'):
485 ims = self.request.headers.getheader('if-modified-since')
486 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
487 # cgi will put the header in the env var
488 ims = self.env['HTTP_IF_MODIFIED_SINCE']
489 filename = os.path.join(self.instance.config.TEMPLATES, file)
490 lmt = os.stat(filename)[stat.ST_MTIME]
491 if ims:
492 ims = rfc822.parsedate(ims)[:6]
493 lmtt = time.gmtime(lmt)[:6]
494 if lmtt <= ims:
495 raise NotModified
497 # we just want to serve up the file named
498 file = str(file)
499 mt = mimetypes.guess_type(file)[0]
500 if not mt:
501 if file.endswith('.css'):
502 mt = 'text/css'
503 else:
504 mt = 'text/plain'
505 self.additional_headers['Content-Type'] = mt
506 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
507 self.write(open(filename, 'rb').read())
509 def renderContext(self):
510 ''' Return a PageTemplate for the named page
511 '''
512 name = self.classname
513 extension = self.template
514 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
516 # catch errors so we can handle PT rendering errors more nicely
517 args = {
518 'ok_message': self.ok_message,
519 'error_message': self.error_message
520 }
521 try:
522 # let the template render figure stuff out
523 return pt.render(self, None, None, **args)
524 except NoTemplate, message:
525 return '<strong>%s</strong>'%message
526 except:
527 # everything else
528 return cgitb.pt_html()
530 # these are the actions that are available
531 actions = (
532 ('edit', 'editItemAction'),
533 ('editcsv', 'editCSVAction'),
534 ('new', 'newItemAction'),
535 ('register', 'registerAction'),
536 ('confrego', 'confRegoAction'),
537 ('passrst', 'passResetAction'),
538 ('login', 'loginAction'),
539 ('logout', 'logout_action'),
540 ('search', 'searchAction'),
541 ('retire', 'retireAction'),
542 ('show', 'showAction'),
543 )
544 def handle_action(self):
545 ''' Determine whether there should be an Action called.
547 The action is defined by the form variable :action which
548 identifies the method on this object to call. The actions
549 are defined in the "actions" sequence on this class.
550 '''
551 if self.form.has_key(':action'):
552 action = self.form[':action'].value.lower()
553 elif self.form.has_key('@action'):
554 action = self.form['@action'].value.lower()
555 else:
556 return None
557 try:
558 # get the action, validate it
559 for name, method in self.actions:
560 if name == action:
561 break
562 else:
563 raise ValueError, 'No such action "%s"'%action
564 # call the mapped action
565 getattr(self, method)()
566 except Redirect:
567 raise
568 except Unauthorised:
569 raise
571 def write(self, content):
572 if not self.headers_done:
573 self.header()
574 self.request.wfile.write(content)
576 def header(self, headers=None, response=None):
577 '''Put up the appropriate header.
578 '''
579 if headers is None:
580 headers = {'Content-Type':'text/html'}
581 if response is None:
582 response = self.response_code
584 # update with additional info
585 headers.update(self.additional_headers)
587 if not headers.has_key('Content-Type'):
588 headers['Content-Type'] = 'text/html'
589 self.request.send_response(response)
590 for entry in headers.items():
591 self.request.send_header(*entry)
592 self.request.end_headers()
593 self.headers_done = 1
594 if self.debug:
595 self.headers_sent = headers
597 def set_cookie(self, user):
598 ''' Set up a session cookie for the user and store away the user's
599 login info against the session.
600 '''
601 # TODO generate a much, much stronger session key ;)
602 self.session = binascii.b2a_base64(repr(random.random())).strip()
604 # clean up the base64
605 if self.session[-1] == '=':
606 if self.session[-2] == '=':
607 self.session = self.session[:-2]
608 else:
609 self.session = self.session[:-1]
611 # insert the session in the sessiondb
612 self.db.sessions.set(self.session, user=user, last_use=time.time())
614 # and commit immediately
615 self.db.sessions.commit()
617 # expire us in a long, long time
618 expire = Cookie._getdate(86400*365)
620 # generate the cookie path - make sure it has a trailing '/'
621 self.additional_headers['Set-Cookie'] = \
622 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
623 expire, self.cookie_path)
625 def make_user_anonymous(self):
626 ''' Make us anonymous
628 This method used to handle non-existence of the 'anonymous'
629 user, but that user is mandatory now.
630 '''
631 self.userid = self.db.user.lookup('anonymous')
632 self.user = 'anonymous'
634 def opendb(self, user):
635 ''' Open the database.
636 '''
637 # open the db if the user has changed
638 if not hasattr(self, 'db') or user != self.db.journaltag:
639 if hasattr(self, 'db'):
640 self.db.close()
641 self.db = self.instance.open(user)
643 #
644 # Actions
645 #
646 def loginAction(self):
647 ''' Attempt to log a user in.
649 Sets up a session for the user which contains the login
650 credentials.
651 '''
652 # we need the username at a minimum
653 if not self.form.has_key('__login_name'):
654 self.error_message.append(_('Username required'))
655 return
657 # get the login info
658 self.user = self.form['__login_name'].value
659 if self.form.has_key('__login_password'):
660 password = self.form['__login_password'].value
661 else:
662 password = ''
664 # make sure the user exists
665 try:
666 self.userid = self.db.user.lookup(self.user)
667 except KeyError:
668 name = self.user
669 self.error_message.append(_('No such user "%(name)s"')%locals())
670 self.make_user_anonymous()
671 return
673 # verify the password
674 if not self.verifyPassword(self.userid, password):
675 self.make_user_anonymous()
676 self.error_message.append(_('Incorrect password'))
677 return
679 # make sure we're allowed to be here
680 if not self.loginPermission():
681 self.make_user_anonymous()
682 self.error_message.append(_("You do not have permission to login"))
683 return
685 # now we're OK, re-open the database for real, using the user
686 self.opendb(self.user)
688 # set the session cookie
689 self.set_cookie(self.user)
691 def verifyPassword(self, userid, password):
692 ''' Verify the password that the user has supplied
693 '''
694 stored = self.db.user.get(self.userid, 'password')
695 if password == stored:
696 return 1
697 if not password and not stored:
698 return 1
699 return 0
701 def loginPermission(self):
702 ''' Determine whether the user has permission to log in.
704 Base behaviour is to check the user has "Web Access".
705 '''
706 if not self.db.security.hasPermission('Web Access', self.userid):
707 return 0
708 return 1
710 def logout_action(self):
711 ''' Make us really anonymous - nuke the cookie too
712 '''
713 # log us out
714 self.make_user_anonymous()
716 # construct the logout cookie
717 now = Cookie._getdate()
718 self.additional_headers['Set-Cookie'] = \
719 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
720 now, self.cookie_path)
722 # Let the user know what's going on
723 self.ok_message.append(_('You are logged out'))
725 def registerAction(self):
726 '''Attempt to create a new user based on the contents of the form
727 and then set the cookie.
729 return 1 on successful login
730 '''
731 props = self.parsePropsFromForm()[0][('user', None)]
733 # make sure we're allowed to register
734 if not self.registerPermission(props):
735 raise Unauthorised, _("You do not have permission to register")
737 try:
738 self.db.user.lookup(props['username'])
739 self.error_message.append('Error: A user with the username "%s" '
740 'already exists'%props['username'])
741 return
742 except KeyError:
743 pass
745 # generate the one-time-key and store the props for later
746 otk = ''.join([random.choice(chars) for x in range(32)])
747 for propname, proptype in self.db.user.getprops().items():
748 value = props.get(propname, None)
749 if value is None:
750 pass
751 elif isinstance(proptype, hyperdb.Date):
752 props[propname] = str(value)
753 elif isinstance(proptype, hyperdb.Interval):
754 props[propname] = str(value)
755 elif isinstance(proptype, hyperdb.Password):
756 props[propname] = str(value)
757 props['__time'] = time.time()
758 self.db.otks.set(otk, **props)
760 # send the email
761 tracker_name = self.db.config.TRACKER_NAME
762 tracker_email = self.db.config.TRACKER_EMAIL
763 subject = 'Complete your registration to %s -- key %s' % (tracker_name,
764 otk)
765 body = """To complete your registration of the user "%(name)s" with
766 %(tracker)s, please do one of the following:
768 - send a reply to %(tracker_email)s and maintain the subject line as is (the
769 reply's additional "Re:" is ok),
771 - or visit the following URL:
773 %(url)s?@action=confrego&otk=%(otk)s
774 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
775 'otk': otk, 'tracker_email': tracker_email}
776 if not self.standard_message([props['address']], subject, body,
777 tracker_email):
778 return
780 # commit changes to the database
781 self.db.commit()
783 # redirect to the "you're almost there" page
784 raise Redirect, '%suser?@template=rego_progress'%self.base
786 def standard_message(self, to, subject, body, author=None):
787 try:
788 self.mailer.standard_message(to, subject, body, author)
789 return 1
790 except MessageSendError, e:
791 self.error_message.append(str(e))
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 try:
809 # pull the rego information out of the otk database
810 self.userid = self.db.confirm_registration(self.form['otk'].value)
811 except (ValueError, KeyError), message:
812 # XXX: we need to make the "default" page be able to display errors!
813 self.error_message.append(str(message))
814 return
816 # log the new user in
817 self.user = self.db.user.get(self.userid, 'username')
818 # re-open the database for real, using the user
819 self.opendb(self.user)
821 # if we have a session, update it
822 if hasattr(self, 'session'):
823 self.db.sessions.set(self.session, user=self.user,
824 last_use=time.time())
825 else:
826 # new session cookie
827 self.set_cookie(self.user)
829 # nice message
830 message = _('You are now registered, welcome!')
832 # redirect to the user's page
833 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
834 self.userid, urllib.quote(message))
836 def passResetAction(self):
837 ''' Handle password reset requests.
839 Presence of either "name" or "address" generate email.
840 Presense of "otk" performs the reset.
841 '''
842 if self.form.has_key('otk'):
843 # pull the rego information out of the otk database
844 otk = self.form['otk'].value
845 uid = self.db.otks.get(otk, 'uid')
846 if uid is None:
847 self.error_message.append("""Invalid One Time Key!
848 (a Mozilla bug may cause this message to show up erroneously,
849 please check your email)""")
850 return
852 # re-open the database as "admin"
853 if self.user != 'admin':
854 self.opendb('admin')
856 # change the password
857 newpw = password.generatePassword()
859 cl = self.db.user
860 # XXX we need to make the "default" page be able to display errors!
861 try:
862 # set the password
863 cl.set(uid, password=password.Password(newpw))
864 # clear the props from the otk database
865 self.db.otks.destroy(otk)
866 self.db.commit()
867 except (ValueError, KeyError), message:
868 self.error_message.append(str(message))
869 return
871 # user info
872 address = self.db.user.get(uid, 'address')
873 name = self.db.user.get(uid, 'username')
875 # send the email
876 tracker_name = self.db.config.TRACKER_NAME
877 subject = 'Password reset for %s'%tracker_name
878 body = '''
879 The password has been reset for username "%(name)s".
881 Your password is now: %(password)s
882 '''%{'name': name, 'password': newpw}
883 if not self.standard_message([address], subject, body):
884 return
886 self.ok_message.append('Password reset and email sent to %s'%address)
887 return
889 # no OTK, so now figure the user
890 if self.form.has_key('username'):
891 name = self.form['username'].value
892 try:
893 uid = self.db.user.lookup(name)
894 except KeyError:
895 self.error_message.append('Unknown username')
896 return
897 address = self.db.user.get(uid, 'address')
898 elif self.form.has_key('address'):
899 address = self.form['address'].value
900 uid = uidFromAddress(self.db, ('', address), create=0)
901 if not uid:
902 self.error_message.append('Unknown email address')
903 return
904 name = self.db.user.get(uid, 'username')
905 else:
906 self.error_message.append('You need to specify a username '
907 'or address')
908 return
910 # generate the one-time-key and store the props for later
911 otk = ''.join([random.choice(chars) for x in range(32)])
912 self.db.otks.set(otk, uid=uid, __time=time.time())
914 # send the email
915 tracker_name = self.db.config.TRACKER_NAME
916 subject = 'Confirm reset of password for %s'%tracker_name
917 body = '''
918 Someone, perhaps you, has requested that the password be changed for your
919 username, "%(name)s". If you wish to proceed with the change, please follow
920 the link below:
922 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
924 You should then receive another email with the new password.
925 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
926 if not self.standard_message([address], subject, body):
927 return
929 self.ok_message.append('Email sent to %s'%address)
931 def editItemAction(self):
932 ''' Perform an edit of an item in the database.
934 See parsePropsFromForm and _editnodes for special variables
935 '''
936 props, links = self.parsePropsFromForm()
938 # handle the props
939 try:
940 message = self._editnodes(props, links)
941 except (ValueError, KeyError, IndexError), message:
942 self.error_message.append(_('Apply Error: ') + str(message))
943 return
945 # commit now that all the tricky stuff is done
946 self.db.commit()
948 # redirect to the item's edit page
949 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
950 self.classname, self.nodeid, urllib.quote(message),
951 urllib.quote(self.template))
953 newItemAction = editItemAction
955 def editItemPermission(self, props):
956 ''' Determine whether the user has permission to edit this item.
958 Base behaviour is to check the user can edit this class. If we're
959 editing the "user" class, users are allowed to edit their own
960 details. Unless it's the "roles" property, which requires the
961 special Permission "Web Roles".
962 '''
963 # if this is a user node and the user is editing their own node, then
964 # we're OK
965 has = self.db.security.hasPermission
966 if self.classname == 'user':
967 # reject if someone's trying to edit "roles" and doesn't have the
968 # right permission.
969 if props.has_key('roles') and not has('Web Roles', self.userid,
970 'user'):
971 return 0
972 # if the item being edited is the current user, we're ok
973 if (self.nodeid == self.userid
974 and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
975 return 1
976 if self.db.security.hasPermission('Edit', self.userid, self.classname):
977 return 1
978 return 0
980 def newItemPermission(self, props):
981 ''' Determine whether the user has permission to create (edit) this
982 item.
984 Base behaviour is to check the user can edit this class. No
985 additional property checks are made. Additionally, new user items
986 may be created if the user has the "Web Registration" Permission.
987 '''
988 has = self.db.security.hasPermission
989 if self.classname == 'user' and has('Web Registration', self.userid,
990 'user'):
991 return 1
992 if has('Edit', self.userid, self.classname):
993 return 1
994 return 0
997 #
998 # Utility methods for editing
999 #
1000 def _editnodes(self, all_props, all_links, newids=None):
1001 ''' Use the props in all_props to perform edit and creation, then
1002 use the link specs in all_links to do linking.
1003 '''
1004 # figure dependencies and re-work links
1005 deps = {}
1006 links = {}
1007 for cn, nodeid, propname, vlist in all_links:
1008 if not all_props.has_key((cn, nodeid)):
1009 # link item to link to doesn't (and won't) exist
1010 continue
1011 for value in vlist:
1012 if not all_props.has_key(value):
1013 # link item to link to doesn't (and won't) exist
1014 continue
1015 deps.setdefault((cn, nodeid), []).append(value)
1016 links.setdefault(value, []).append((cn, nodeid, propname))
1018 # figure chained dependencies ordering
1019 order = []
1020 done = {}
1021 # loop detection
1022 change = 0
1023 while len(all_props) != len(done):
1024 for needed in all_props.keys():
1025 if done.has_key(needed):
1026 continue
1027 tlist = deps.get(needed, [])
1028 for target in tlist:
1029 if not done.has_key(target):
1030 break
1031 else:
1032 done[needed] = 1
1033 order.append(needed)
1034 change = 1
1035 if not change:
1036 raise ValueError, 'linking must not loop!'
1038 # now, edit / create
1039 m = []
1040 for needed in order:
1041 props = all_props[needed]
1042 if not props:
1043 # nothing to do
1044 continue
1045 cn, nodeid = needed
1047 if nodeid is not None and int(nodeid) > 0:
1048 # make changes to the node
1049 props = self._changenode(cn, nodeid, props)
1051 # and some nice feedback for the user
1052 if props:
1053 info = ', '.join(props.keys())
1054 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1055 else:
1056 m.append('%s %s - nothing changed'%(cn, nodeid))
1057 else:
1058 assert props
1060 # make a new node
1061 newid = self._createnode(cn, props)
1062 if nodeid is None:
1063 self.nodeid = newid
1064 nodeid = newid
1066 # and some nice feedback for the user
1067 m.append('%s %s created'%(cn, newid))
1069 # fill in new ids in links
1070 if links.has_key(needed):
1071 for linkcn, linkid, linkprop in links[needed]:
1072 props = all_props[(linkcn, linkid)]
1073 cl = self.db.classes[linkcn]
1074 propdef = cl.getprops()[linkprop]
1075 if not props.has_key(linkprop):
1076 if linkid is None or linkid.startswith('-'):
1077 # linking to a new item
1078 if isinstance(propdef, hyperdb.Multilink):
1079 props[linkprop] = [newid]
1080 else:
1081 props[linkprop] = newid
1082 else:
1083 # linking to an existing item
1084 if isinstance(propdef, hyperdb.Multilink):
1085 existing = cl.get(linkid, linkprop)[:]
1086 existing.append(nodeid)
1087 props[linkprop] = existing
1088 else:
1089 props[linkprop] = newid
1091 return '<br>'.join(m)
1093 def _changenode(self, cn, nodeid, props):
1094 ''' change the node based on the contents of the form
1095 '''
1096 # check for permission
1097 if not self.editItemPermission(props):
1098 raise Unauthorised, 'You do not have permission to edit %s'%cn
1100 # make the changes
1101 cl = self.db.classes[cn]
1102 return cl.set(nodeid, **props)
1104 def _createnode(self, cn, props):
1105 ''' create a node based on the contents of the form
1106 '''
1107 # check for permission
1108 if not self.newItemPermission(props):
1109 raise Unauthorised, 'You do not have permission to create %s'%cn
1111 # create the node and return its id
1112 cl = self.db.classes[cn]
1113 return cl.create(**props)
1115 #
1116 # More actions
1117 #
1118 def editCSVAction(self):
1119 ''' Performs an edit of all of a class' items in one go.
1121 The "rows" CGI var defines the CSV-formatted entries for the
1122 class. New nodes are identified by the ID 'X' (or any other
1123 non-existent ID) and removed lines are retired.
1124 '''
1125 # this is per-class only
1126 if not self.editCSVPermission():
1127 self.error_message.append(
1128 _('You do not have permission to edit %s' %self.classname))
1129 return
1131 # get the CSV module
1132 if rcsv.error:
1133 self.error_message.append(_(rcsv.error))
1134 return
1136 cl = self.db.classes[self.classname]
1137 idlessprops = cl.getprops(protected=0).keys()
1138 idlessprops.sort()
1139 props = ['id'] + idlessprops
1141 # do the edit
1142 rows = StringIO.StringIO(self.form['rows'].value)
1143 reader = rcsv.reader(rows, rcsv.comma_separated)
1144 found = {}
1145 line = 0
1146 for values in reader:
1147 line += 1
1148 if line == 1: continue
1149 # skip property names header
1150 if values == props:
1151 continue
1153 # extract the nodeid
1154 nodeid, values = values[0], values[1:]
1155 found[nodeid] = 1
1157 # see if the node exists
1158 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1159 exists = 0
1160 else:
1161 exists = 1
1163 # confirm correct weight
1164 if len(idlessprops) != len(values):
1165 self.error_message.append(
1166 _('Not enough values on line %(line)s')%{'line':line})
1167 return
1169 # extract the new values
1170 d = {}
1171 for name, value in zip(idlessprops, values):
1172 prop = cl.properties[name]
1173 value = value.strip()
1174 # only add the property if it has a value
1175 if value:
1176 # if it's a multilink, split it
1177 if isinstance(prop, hyperdb.Multilink):
1178 value = value.split(':')
1179 elif isinstance(prop, hyperdb.Password):
1180 value = password.Password(value)
1181 elif isinstance(prop, hyperdb.Interval):
1182 value = date.Interval(value)
1183 elif isinstance(prop, hyperdb.Date):
1184 value = date.Date(value)
1185 elif isinstance(prop, hyperdb.Boolean):
1186 value = value.lower() in ('yes', 'true', 'on', '1')
1187 elif isinstance(prop, hyperdb.Number):
1188 value = float(value)
1189 d[name] = value
1190 elif exists:
1191 # nuke the existing value
1192 if isinstance(prop, hyperdb.Multilink):
1193 d[name] = []
1194 else:
1195 d[name] = None
1197 # perform the edit
1198 if exists:
1199 # edit existing
1200 cl.set(nodeid, **d)
1201 else:
1202 # new node
1203 found[cl.create(**d)] = 1
1205 # retire the removed entries
1206 for nodeid in cl.list():
1207 if not found.has_key(nodeid):
1208 cl.retire(nodeid)
1210 # all OK
1211 self.db.commit()
1213 self.ok_message.append(_('Items edited OK'))
1215 def editCSVPermission(self):
1216 ''' Determine whether the user has permission to edit this class.
1218 Base behaviour is to check the user can edit this class.
1219 '''
1220 if not self.db.security.hasPermission('Edit', self.userid,
1221 self.classname):
1222 return 0
1223 return 1
1225 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1226 ''' Mangle some of the form variables.
1228 Set the form ":filter" variable based on the values of the
1229 filter variables - if they're set to anything other than
1230 "dontcare" then add them to :filter.
1232 Handle the ":queryname" variable and save off the query to
1233 the user's query list.
1235 Split any String query values on whitespace and comma.
1236 '''
1237 # generic edit is per-class only
1238 if not self.searchPermission():
1239 self.error_message.append(
1240 _('You do not have permission to search %s' %self.classname))
1241 return
1243 # add a faked :filter form variable for each filtering prop
1244 props = self.db.classes[self.classname].getprops()
1245 queryname = ''
1246 for key in self.form.keys():
1247 # special vars
1248 if self.FV_QUERYNAME.match(key):
1249 queryname = self.form[key].value.strip()
1250 continue
1252 if not props.has_key(key):
1253 continue
1254 if isinstance(self.form[key], type([])):
1255 # search for at least one entry which is not empty
1256 for minifield in self.form[key]:
1257 if minifield.value:
1258 break
1259 else:
1260 continue
1261 else:
1262 if not self.form[key].value:
1263 continue
1264 if isinstance(props[key], hyperdb.String):
1265 v = self.form[key].value
1266 l = token.token_split(v)
1267 if len(l) > 1 or l[0] != v:
1268 self.form.value.remove(self.form[key])
1269 # replace the single value with the split list
1270 for v in l:
1271 self.form.value.append(cgi.MiniFieldStorage(key, v))
1273 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1275 # handle saving the query params
1276 if queryname:
1277 # parse the environment and figure what the query _is_
1278 req = HTMLRequest(self)
1280 # The [1:] strips off the '?' character, it isn't part of the
1281 # query string.
1282 url = req.indexargs_href('', {})[1:]
1284 # handle editing an existing query
1285 try:
1286 qid = self.db.query.lookup(queryname)
1287 self.db.query.set(qid, klass=self.classname, url=url)
1288 except KeyError:
1289 # create a query
1290 qid = self.db.query.create(name=queryname,
1291 klass=self.classname, url=url)
1293 # and add it to the user's query multilink
1294 queries = self.db.user.get(self.userid, 'queries')
1295 queries.append(qid)
1296 self.db.user.set(self.userid, queries=queries)
1298 # commit the query change to the database
1299 self.db.commit()
1301 def searchPermission(self):
1302 ''' Determine whether the user has permission to search this class.
1304 Base behaviour is to check the user can view this class.
1305 '''
1306 if not self.db.security.hasPermission('View', self.userid,
1307 self.classname):
1308 return 0
1309 return 1
1312 def retireAction(self):
1313 ''' Retire the context item.
1314 '''
1315 # if we want to view the index template now, then unset the nodeid
1316 # context info (a special-case for retire actions on the index page)
1317 nodeid = self.nodeid
1318 if self.template == 'index':
1319 self.nodeid = None
1321 # generic edit is per-class only
1322 if not self.retirePermission():
1323 self.error_message.append(
1324 _('You do not have permission to retire %s' %self.classname))
1325 return
1327 # make sure we don't try to retire admin or anonymous
1328 if self.classname == 'user' and \
1329 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1330 self.error_message.append(
1331 _('You may not retire the admin or anonymous user'))
1332 return
1334 # do the retire
1335 self.db.getclass(self.classname).retire(nodeid)
1336 self.db.commit()
1338 self.ok_message.append(
1339 _('%(classname)s %(itemid)s has been retired')%{
1340 'classname': self.classname.capitalize(), 'itemid': nodeid})
1342 def retirePermission(self):
1343 ''' Determine whether the user has permission to retire this class.
1345 Base behaviour is to check the user can edit this class.
1346 '''
1347 if not self.db.security.hasPermission('Edit', self.userid,
1348 self.classname):
1349 return 0
1350 return 1
1353 def showAction(self, typere=re.compile('[@:]type'),
1354 numre=re.compile('[@:]number')):
1355 ''' Show a node of a particular class/id
1356 '''
1357 t = n = ''
1358 for key in self.form.keys():
1359 if typere.match(key):
1360 t = self.form[key].value.strip()
1361 elif numre.match(key):
1362 n = self.form[key].value.strip()
1363 if not t:
1364 raise ValueError, 'Invalid %s number'%t
1365 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1366 raise Redirect, url
1368 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1369 ''' Item properties and their values are edited with html FORM
1370 variables and their values. You can:
1372 - Change the value of some property of the current item.
1373 - Create a new item of any class, and edit the new item's
1374 properties,
1375 - Attach newly created items to a multilink property of the
1376 current item.
1377 - Remove items from a multilink property of the current item.
1378 - Specify that some properties are required for the edit
1379 operation to be successful.
1381 In the following, <bracketed> values are variable, "@" may be
1382 either ":" or "@", and other text "required" is fixed.
1384 Most properties are specified as form variables:
1386 <propname>
1387 - property on the current context item
1389 <designator>"@"<propname>
1390 - property on the indicated item (for editing related
1391 information)
1393 Designators name a specific item of a class.
1395 <classname><N>
1397 Name an existing item of class <classname>.
1399 <classname>"-"<N>
1401 Name the <N>th new item of class <classname>. If the form
1402 submission is successful, a new item of <classname> is
1403 created. Within the submitted form, a particular
1404 designator of this form always refers to the same new
1405 item.
1407 Once we have determined the "propname", we look at it to see
1408 if it's special:
1410 @required
1411 The associated form value is a comma-separated list of
1412 property names that must be specified when the form is
1413 submitted for the edit operation to succeed.
1415 When the <designator> is missing, the properties are
1416 for the current context item. When <designator> is
1417 present, they are for the item specified by
1418 <designator>.
1420 The "@required" specifier must come before any of the
1421 properties it refers to are assigned in the form.
1423 @remove@<propname>=id(s) or @add@<propname>=id(s)
1424 The "@add@" and "@remove@" edit actions apply only to
1425 Multilink properties. The form value must be a
1426 comma-separate list of keys for the class specified by
1427 the simple form variable. The listed items are added
1428 to (respectively, removed from) the specified
1429 property.
1431 @link@<propname>=<designator>
1432 If the edit action is "@link@", the simple form
1433 variable must specify a Link or Multilink property.
1434 The form value is a comma-separated list of
1435 designators. The item corresponding to each
1436 designator is linked to the property given by simple
1437 form variable. These are collected up and returned in
1438 all_links.
1440 None of the above (ie. just a simple form value)
1441 The value of the form variable is converted
1442 appropriately, depending on the type of the property.
1444 For a Link('klass') property, the form value is a
1445 single key for 'klass', where the key field is
1446 specified in dbinit.py.
1448 For a Multilink('klass') property, the form value is a
1449 comma-separated list of keys for 'klass', where the
1450 key field is specified in dbinit.py.
1452 Note that for simple-form-variables specifiying Link
1453 and Multilink properties, the linked-to class must
1454 have a key field.
1456 For a String() property specifying a filename, the
1457 file named by the form value is uploaded. This means we
1458 try to set additional properties "filename" and "type" (if
1459 they are valid for the class). Otherwise, the property
1460 is set to the form value.
1462 For Date(), Interval(), Boolean(), and Number()
1463 properties, the form value is converted to the
1464 appropriate
1466 Any of the form variables may be prefixed with a classname or
1467 designator.
1469 Two special form values are supported for backwards
1470 compatibility:
1472 @note
1473 This is equivalent to::
1475 @link@messages=msg-1
1476 msg-1@content=value
1478 except that in addition, the "author" and "date"
1479 properties of "msg-1" are set to the userid of the
1480 submitter, and the current time, respectively.
1482 @file
1483 This is equivalent to::
1485 @link@files=file-1
1486 file-1@content=value
1488 The String content value is handled as described above for
1489 file uploads.
1491 If both the "@note" and "@file" form variables are
1492 specified, the action::
1494 @link@msg-1@files=file-1
1496 is also performed.
1498 We also check that FileClass items have a "content" property with
1499 actual content, otherwise we remove them from all_props before
1500 returning.
1502 The return from this method is a dict of
1503 (classname, id): properties
1504 ... this dict _always_ has an entry for the current context,
1505 even if it's empty (ie. a submission for an existing issue that
1506 doesn't result in any changes would return {('issue','123'): {}})
1507 The id may be None, which indicates that an item should be
1508 created.
1509 '''
1510 # some very useful variables
1511 db = self.db
1512 form = self.form
1514 if not hasattr(self, 'FV_SPECIAL'):
1515 # generate the regexp for handling special form values
1516 classes = '|'.join(db.classes.keys())
1517 # specials for parsePropsFromForm
1518 # handle the various forms (see unit tests)
1519 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1520 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1522 # these indicate the default class / item
1523 default_cn = self.classname
1524 default_cl = self.db.classes[default_cn]
1525 default_nodeid = self.nodeid
1527 # we'll store info about the individual class/item edit in these
1528 all_required = {} # required props per class/item
1529 all_props = {} # props to set per class/item
1530 got_props = {} # props received per class/item
1531 all_propdef = {} # note - only one entry per class
1532 all_links = [] # as many as are required
1534 # we should always return something, even empty, for the context
1535 all_props[(default_cn, default_nodeid)] = {}
1537 keys = form.keys()
1538 timezone = db.getUserTimezone()
1540 # sentinels for the :note and :file props
1541 have_note = have_file = 0
1543 # extract the usable form labels from the form
1544 matches = []
1545 for key in keys:
1546 m = self.FV_SPECIAL.match(key)
1547 if m:
1548 matches.append((key, m.groupdict()))
1550 # now handle the matches
1551 for key, d in matches:
1552 if d['classname']:
1553 # we got a designator
1554 cn = d['classname']
1555 cl = self.db.classes[cn]
1556 nodeid = d['id']
1557 propname = d['propname']
1558 elif d['note']:
1559 # the special note field
1560 cn = 'msg'
1561 cl = self.db.classes[cn]
1562 nodeid = '-1'
1563 propname = 'content'
1564 all_links.append((default_cn, default_nodeid, 'messages',
1565 [('msg', '-1')]))
1566 have_note = 1
1567 elif d['file']:
1568 # the special file field
1569 cn = 'file'
1570 cl = self.db.classes[cn]
1571 nodeid = '-1'
1572 propname = 'content'
1573 all_links.append((default_cn, default_nodeid, 'files',
1574 [('file', '-1')]))
1575 have_file = 1
1576 else:
1577 # default
1578 cn = default_cn
1579 cl = default_cl
1580 nodeid = default_nodeid
1581 propname = d['propname']
1583 # the thing this value relates to is...
1584 this = (cn, nodeid)
1586 # get more info about the class, and the current set of
1587 # form props for it
1588 if not all_propdef.has_key(cn):
1589 all_propdef[cn] = cl.getprops()
1590 propdef = all_propdef[cn]
1591 if not all_props.has_key(this):
1592 all_props[this] = {}
1593 props = all_props[this]
1594 if not got_props.has_key(this):
1595 got_props[this] = {}
1597 # is this a link command?
1598 if d['link']:
1599 value = []
1600 for entry in extractFormList(form[key]):
1601 m = self.FV_DESIGNATOR.match(entry)
1602 if not m:
1603 raise FormError, \
1604 'link "%s" value "%s" not a designator'%(key, entry)
1605 value.append((m.group(1), m.group(2)))
1607 # make sure the link property is valid
1608 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1609 not isinstance(propdef[propname], hyperdb.Link)):
1610 raise FormError, '%s %s is not a link or '\
1611 'multilink property'%(cn, propname)
1613 all_links.append((cn, nodeid, propname, value))
1614 continue
1616 # detect the special ":required" variable
1617 if d['required']:
1618 all_required[this] = extractFormList(form[key])
1619 continue
1621 # see if we're performing a special multilink action
1622 mlaction = 'set'
1623 if d['remove']:
1624 mlaction = 'remove'
1625 elif d['add']:
1626 mlaction = 'add'
1628 # does the property exist?
1629 if not propdef.has_key(propname):
1630 if mlaction != 'set':
1631 raise FormError, 'You have submitted a %s action for'\
1632 ' the property "%s" which doesn\'t exist'%(mlaction,
1633 propname)
1634 # the form element is probably just something we don't care
1635 # about - ignore it
1636 continue
1637 proptype = propdef[propname]
1639 # Get the form value. This value may be a MiniFieldStorage or a list
1640 # of MiniFieldStorages.
1641 value = form[key]
1643 # handle unpacking of the MiniFieldStorage / list form value
1644 if isinstance(proptype, hyperdb.Multilink):
1645 value = extractFormList(value)
1646 else:
1647 # multiple values are not OK
1648 if isinstance(value, type([])):
1649 raise FormError, 'You have submitted more than one value'\
1650 ' for the %s property'%propname
1651 # value might be a file upload...
1652 if not hasattr(value, 'filename') or value.filename is None:
1653 # nope, pull out the value and strip it
1654 value = value.value.strip()
1656 # now that we have the props field, we need a teensy little
1657 # extra bit of help for the old :note field...
1658 if d['note'] and value:
1659 props['author'] = self.db.getuid()
1660 props['date'] = date.Date()
1662 # handle by type now
1663 if isinstance(proptype, hyperdb.Password):
1664 if not value:
1665 # ignore empty password values
1666 continue
1667 for key, d in matches:
1668 if d['confirm'] and d['propname'] == propname:
1669 confirm = form[key]
1670 break
1671 else:
1672 raise FormError, 'Password and confirmation text do '\
1673 'not match'
1674 if isinstance(confirm, type([])):
1675 raise FormError, 'You have submitted more than one value'\
1676 ' for the %s property'%propname
1677 if value != confirm.value:
1678 raise FormError, 'Password and confirmation text do '\
1679 'not match'
1680 try:
1681 value = password.Password(value)
1682 except hyperdb.HyperdbValueError, msg:
1683 raise FormError, msg
1685 elif isinstance(proptype, hyperdb.Multilink):
1686 # convert input to list of ids
1687 try:
1688 l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1689 propname, value)
1690 except hyperdb.HyperdbValueError, msg:
1691 raise FormError, msg
1693 # now use that list of ids to modify the multilink
1694 if mlaction == 'set':
1695 value = l
1696 else:
1697 # we're modifying the list - get the current list of ids
1698 if props.has_key(propname):
1699 existing = props[propname]
1700 elif nodeid and not nodeid.startswith('-'):
1701 existing = cl.get(nodeid, propname, [])
1702 else:
1703 existing = []
1705 # now either remove or add
1706 if mlaction == 'remove':
1707 # remove - handle situation where the id isn't in
1708 # the list
1709 for entry in l:
1710 try:
1711 existing.remove(entry)
1712 except ValueError:
1713 raise FormError, _('property "%(propname)s": '
1714 '"%(value)s" not currently in list')%{
1715 'propname': propname, 'value': entry}
1716 else:
1717 # add - easy, just don't dupe
1718 for entry in l:
1719 if entry not in existing:
1720 existing.append(entry)
1721 value = existing
1722 value.sort()
1724 elif value == '':
1725 # other types should be None'd if there's no value
1726 value = None
1727 else:
1728 # handle all other types
1729 try:
1730 if isinstance(proptype, hyperdb.String):
1731 if (hasattr(value, 'filename') and
1732 value.filename is not None):
1733 # skip if the upload is empty
1734 if not value.filename:
1735 continue
1736 # this String is actually a _file_
1737 # try to determine the file content-type
1738 fn = value.filename.split('\\')[-1]
1739 if propdef.has_key('name'):
1740 props['name'] = fn
1741 # use this info as the type/filename properties
1742 if propdef.has_key('type'):
1743 props['type'] = mimetypes.guess_type(fn)[0]
1744 if not props['type']:
1745 props['type'] = "application/octet-stream"
1746 # finally, read the content RAW
1747 value = value.value
1748 else:
1749 value = hyperdb.rawToHyperdb(self.db, cl,
1750 nodeid, propname, value)
1752 else:
1753 value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1754 propname, value)
1755 except hyperdb.HyperdbValueError, msg:
1756 raise FormError, msg
1758 # register that we got this property
1759 if value:
1760 got_props[this][propname] = 1
1762 # get the old value
1763 if nodeid and not nodeid.startswith('-'):
1764 try:
1765 existing = cl.get(nodeid, propname)
1766 except KeyError:
1767 # this might be a new property for which there is
1768 # no existing value
1769 if not propdef.has_key(propname):
1770 raise
1771 except IndexError, message:
1772 raise FormError(str(message))
1774 # make sure the existing multilink is sorted
1775 if isinstance(proptype, hyperdb.Multilink):
1776 existing.sort()
1778 # "missing" existing values may not be None
1779 if not existing:
1780 if isinstance(proptype, hyperdb.String) and not existing:
1781 # some backends store "missing" Strings as empty strings
1782 existing = None
1783 elif isinstance(proptype, hyperdb.Number) and not existing:
1784 # some backends store "missing" Numbers as 0 :(
1785 existing = 0
1786 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1787 # likewise Booleans
1788 existing = 0
1790 # if changed, set it
1791 if value != existing:
1792 props[propname] = value
1793 else:
1794 # don't bother setting empty/unset values
1795 if value is None:
1796 continue
1797 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1798 continue
1799 elif isinstance(proptype, hyperdb.String) and value == '':
1800 continue
1802 props[propname] = value
1804 # check to see if we need to specially link a file to the note
1805 if have_note and have_file:
1806 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1808 # see if all the required properties have been supplied
1809 s = []
1810 for thing, required in all_required.items():
1811 # register the values we got
1812 got = got_props.get(thing, {})
1813 for entry in required[:]:
1814 if got.has_key(entry):
1815 required.remove(entry)
1817 # any required values not present?
1818 if not required:
1819 continue
1821 # tell the user to entry the values required
1822 if len(required) > 1:
1823 p = 'properties'
1824 else:
1825 p = 'property'
1826 s.append('Required %s %s %s not supplied'%(thing[0], p,
1827 ', '.join(required)))
1828 if s:
1829 raise FormError, '\n'.join(s)
1831 # When creating a FileClass node, it should have a non-empty content
1832 # property to be created. When editing a FileClass node, it should
1833 # either have a non-empty content property or no property at all. In
1834 # the latter case, nothing will change.
1835 for (cn, id), props in all_props.items():
1836 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1837 if id == '-1':
1838 if not props.get('content', ''):
1839 del all_props[(cn, id)]
1840 elif props.has_key('content') and not props['content']:
1841 raise FormError, _('File is empty')
1842 return all_props, all_links
1844 def extractFormList(value):
1845 ''' Extract a list of values from the form value.
1847 It may be one of:
1848 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1849 MiniFieldStorage('value,value,...')
1850 MiniFieldStorage('value')
1851 '''
1852 # multiple values are OK
1853 if isinstance(value, type([])):
1854 # it's a list of MiniFieldStorages - join then into
1855 values = ','.join([i.value.strip() for i in value])
1856 else:
1857 # it's a MiniFieldStorage, but may be a comma-separated list
1858 # of values
1859 values = value.value
1861 value = [i.strip() for i in values.split(',')]
1863 # filter out the empty bits
1864 return filter(None, value)