1 # $Id: client.py,v 1.144 2003-11-11 00:35:14 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
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] == '_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))
1130 # get the CSV module
1131 if rcsv.error:
1132 self.error_message.append(_(rcsv.error))
1133 return
1135 cl = self.db.classes[self.classname]
1136 idlessprops = cl.getprops(protected=0).keys()
1137 idlessprops.sort()
1138 props = ['id'] + idlessprops
1140 # do the edit
1141 rows = StringIO.StringIO(self.form['rows'].value)
1142 reader = rcsv.reader(rows, rcsv.comma_separated)
1143 found = {}
1144 line = 0
1145 for values in reader:
1146 line += 1
1147 if line == 1: continue
1148 # skip property names header
1149 if values == props:
1150 continue
1152 # extract the nodeid
1153 nodeid, values = values[0], values[1:]
1154 found[nodeid] = 1
1156 # see if the node exists
1157 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1158 exists = 0
1159 else:
1160 exists = 1
1162 # confirm correct weight
1163 if len(idlessprops) != len(values):
1164 self.error_message.append(
1165 _('Not enough values on line %(line)s')%{'line':line})
1166 return
1168 # extract the new values
1169 d = {}
1170 for name, value in zip(idlessprops, values):
1171 prop = cl.properties[name]
1172 value = value.strip()
1173 # only add the property if it has a value
1174 if value:
1175 # if it's a multilink, split it
1176 if isinstance(prop, hyperdb.Multilink):
1177 value = value.split(':')
1178 elif isinstance(prop, hyperdb.Password):
1179 value = password.Password(value)
1180 elif isinstance(prop, hyperdb.Interval):
1181 value = date.Interval(value)
1182 elif isinstance(prop, hyperdb.Date):
1183 value = date.Date(value)
1184 elif isinstance(prop, hyperdb.Boolean):
1185 value = value.lower() in ('yes', 'true', 'on', '1')
1186 elif isinstance(prop, hyperdb.Number):
1187 value = float(value)
1188 d[name] = value
1189 elif exists:
1190 # nuke the existing value
1191 if isinstance(prop, hyperdb.Multilink):
1192 d[name] = []
1193 else:
1194 d[name] = None
1196 # perform the edit
1197 if exists:
1198 # edit existing
1199 cl.set(nodeid, **d)
1200 else:
1201 # new node
1202 found[cl.create(**d)] = 1
1204 # retire the removed entries
1205 for nodeid in cl.list():
1206 if not found.has_key(nodeid):
1207 cl.retire(nodeid)
1209 # all OK
1210 self.db.commit()
1212 self.ok_message.append(_('Items edited OK'))
1214 def editCSVPermission(self):
1215 ''' Determine whether the user has permission to edit this class.
1217 Base behaviour is to check the user can edit this class.
1218 '''
1219 if not self.db.security.hasPermission('Edit', self.userid,
1220 self.classname):
1221 return 0
1222 return 1
1224 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1225 ''' Mangle some of the form variables.
1227 Set the form ":filter" variable based on the values of the
1228 filter variables - if they're set to anything other than
1229 "dontcare" then add them to :filter.
1231 Handle the ":queryname" variable and save off the query to
1232 the user's query list.
1234 Split any String query values on whitespace and comma.
1235 '''
1236 # generic edit is per-class only
1237 if not self.searchPermission():
1238 self.error_message.append(
1239 _('You do not have permission to search %s' %self.classname))
1241 # add a faked :filter form variable for each filtering prop
1242 props = self.db.classes[self.classname].getprops()
1243 queryname = ''
1244 for key in self.form.keys():
1245 # special vars
1246 if self.FV_QUERYNAME.match(key):
1247 queryname = self.form[key].value.strip()
1248 continue
1250 if not props.has_key(key):
1251 continue
1252 if isinstance(self.form[key], type([])):
1253 # search for at least one entry which is not empty
1254 for minifield in self.form[key]:
1255 if minifield.value:
1256 break
1257 else:
1258 continue
1259 else:
1260 if not self.form[key].value:
1261 continue
1262 if isinstance(props[key], hyperdb.String):
1263 v = self.form[key].value
1264 l = token.token_split(v)
1265 if len(l) > 1 or l[0] != v:
1266 self.form.value.remove(self.form[key])
1267 # replace the single value with the split list
1268 for v in l:
1269 self.form.value.append(cgi.MiniFieldStorage(key, v))
1271 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1273 # handle saving the query params
1274 if queryname:
1275 # parse the environment and figure what the query _is_
1276 req = HTMLRequest(self)
1278 # The [1:] strips off the '?' character, it isn't part of the
1279 # query string.
1280 url = req.indexargs_href('', {})[1:]
1282 # handle editing an existing query
1283 try:
1284 qid = self.db.query.lookup(queryname)
1285 self.db.query.set(qid, klass=self.classname, url=url)
1286 except KeyError:
1287 # create a query
1288 qid = self.db.query.create(name=queryname,
1289 klass=self.classname, url=url)
1291 # and add it to the user's query multilink
1292 queries = self.db.user.get(self.userid, 'queries')
1293 queries.append(qid)
1294 self.db.user.set(self.userid, queries=queries)
1296 # commit the query change to the database
1297 self.db.commit()
1299 def searchPermission(self):
1300 ''' Determine whether the user has permission to search this class.
1302 Base behaviour is to check the user can view this class.
1303 '''
1304 if not self.db.security.hasPermission('View', self.userid,
1305 self.classname):
1306 return 0
1307 return 1
1310 def retireAction(self):
1311 ''' Retire the context item.
1312 '''
1313 # if we want to view the index template now, then unset the nodeid
1314 # context info (a special-case for retire actions on the index page)
1315 nodeid = self.nodeid
1316 if self.template == 'index':
1317 self.nodeid = None
1319 # generic edit is per-class only
1320 if not self.retirePermission():
1321 self.error_message.append(
1322 _('You do not have permission to retire %s' %self.classname))
1323 return
1325 # make sure we don't try to retire admin or anonymous
1326 if self.classname == 'user' and \
1327 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1328 self.error_message.append(
1329 _('You may not retire the admin or anonymous user'))
1330 return
1332 # do the retire
1333 self.db.getclass(self.classname).retire(nodeid)
1334 self.db.commit()
1336 self.ok_message.append(
1337 _('%(classname)s %(itemid)s has been retired')%{
1338 'classname': self.classname.capitalize(), 'itemid': nodeid})
1340 def retirePermission(self):
1341 ''' Determine whether the user has permission to retire this class.
1343 Base behaviour is to check the user can edit this class.
1344 '''
1345 if not self.db.security.hasPermission('Edit', self.userid,
1346 self.classname):
1347 return 0
1348 return 1
1351 def showAction(self, typere=re.compile('[@:]type'),
1352 numre=re.compile('[@:]number')):
1353 ''' Show a node of a particular class/id
1354 '''
1355 t = n = ''
1356 for key in self.form.keys():
1357 if typere.match(key):
1358 t = self.form[key].value.strip()
1359 elif numre.match(key):
1360 n = self.form[key].value.strip()
1361 if not t:
1362 raise ValueError, 'Invalid %s number'%t
1363 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1364 raise Redirect, url
1366 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1367 ''' Item properties and their values are edited with html FORM
1368 variables and their values. You can:
1370 - Change the value of some property of the current item.
1371 - Create a new item of any class, and edit the new item's
1372 properties,
1373 - Attach newly created items to a multilink property of the
1374 current item.
1375 - Remove items from a multilink property of the current item.
1376 - Specify that some properties are required for the edit
1377 operation to be successful.
1379 In the following, <bracketed> values are variable, "@" may be
1380 either ":" or "@", and other text "required" is fixed.
1382 Most properties are specified as form variables:
1384 <propname>
1385 - property on the current context item
1387 <designator>"@"<propname>
1388 - property on the indicated item (for editing related
1389 information)
1391 Designators name a specific item of a class.
1393 <classname><N>
1395 Name an existing item of class <classname>.
1397 <classname>"-"<N>
1399 Name the <N>th new item of class <classname>. If the form
1400 submission is successful, a new item of <classname> is
1401 created. Within the submitted form, a particular
1402 designator of this form always refers to the same new
1403 item.
1405 Once we have determined the "propname", we look at it to see
1406 if it's special:
1408 @required
1409 The associated form value is a comma-separated list of
1410 property names that must be specified when the form is
1411 submitted for the edit operation to succeed.
1413 When the <designator> is missing, the properties are
1414 for the current context item. When <designator> is
1415 present, they are for the item specified by
1416 <designator>.
1418 The "@required" specifier must come before any of the
1419 properties it refers to are assigned in the form.
1421 @remove@<propname>=id(s) or @add@<propname>=id(s)
1422 The "@add@" and "@remove@" edit actions apply only to
1423 Multilink properties. The form value must be a
1424 comma-separate list of keys for the class specified by
1425 the simple form variable. The listed items are added
1426 to (respectively, removed from) the specified
1427 property.
1429 @link@<propname>=<designator>
1430 If the edit action is "@link@", the simple form
1431 variable must specify a Link or Multilink property.
1432 The form value is a comma-separated list of
1433 designators. The item corresponding to each
1434 designator is linked to the property given by simple
1435 form variable. These are collected up and returned in
1436 all_links.
1438 None of the above (ie. just a simple form value)
1439 The value of the form variable is converted
1440 appropriately, depending on the type of the property.
1442 For a Link('klass') property, the form value is a
1443 single key for 'klass', where the key field is
1444 specified in dbinit.py.
1446 For a Multilink('klass') property, the form value is a
1447 comma-separated list of keys for 'klass', where the
1448 key field is specified in dbinit.py.
1450 Note that for simple-form-variables specifiying Link
1451 and Multilink properties, the linked-to class must
1452 have a key field.
1454 For a String() property specifying a filename, the
1455 file named by the form value is uploaded. This means we
1456 try to set additional properties "filename" and "type" (if
1457 they are valid for the class). Otherwise, the property
1458 is set to the form value.
1460 For Date(), Interval(), Boolean(), and Number()
1461 properties, the form value is converted to the
1462 appropriate
1464 Any of the form variables may be prefixed with a classname or
1465 designator.
1467 Two special form values are supported for backwards
1468 compatibility:
1470 @note
1471 This is equivalent to::
1473 @link@messages=msg-1
1474 @msg-1@content=value
1476 except that in addition, the "author" and "date"
1477 properties of "msg-1" are set to the userid of the
1478 submitter, and the current time, respectively.
1480 @file
1481 This is equivalent to::
1483 @link@files=file-1
1484 @file-1@content=value
1486 The String content value is handled as described above for
1487 file uploads.
1489 If both the "@note" and "@file" form variables are
1490 specified, the action::
1492 @link@msg-1@files=file-1
1494 is also performed.
1496 We also check that FileClass items have a "content" property with
1497 actual content, otherwise we remove them from all_props before
1498 returning.
1500 The return from this method is a dict of
1501 (classname, id): properties
1502 ... this dict _always_ has an entry for the current context,
1503 even if it's empty (ie. a submission for an existing issue that
1504 doesn't result in any changes would return {('issue','123'): {}})
1505 The id may be None, which indicates that an item should be
1506 created.
1507 '''
1508 # some very useful variables
1509 db = self.db
1510 form = self.form
1512 if not hasattr(self, 'FV_SPECIAL'):
1513 # generate the regexp for handling special form values
1514 classes = '|'.join(db.classes.keys())
1515 # specials for parsePropsFromForm
1516 # handle the various forms (see unit tests)
1517 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1518 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1520 # these indicate the default class / item
1521 default_cn = self.classname
1522 default_cl = self.db.classes[default_cn]
1523 default_nodeid = self.nodeid
1525 # we'll store info about the individual class/item edit in these
1526 all_required = {} # required props per class/item
1527 all_props = {} # props to set per class/item
1528 got_props = {} # props received per class/item
1529 all_propdef = {} # note - only one entry per class
1530 all_links = [] # as many as are required
1532 # we should always return something, even empty, for the context
1533 all_props[(default_cn, default_nodeid)] = {}
1535 keys = form.keys()
1536 timezone = db.getUserTimezone()
1538 # sentinels for the :note and :file props
1539 have_note = have_file = 0
1541 # extract the usable form labels from the form
1542 matches = []
1543 for key in keys:
1544 m = self.FV_SPECIAL.match(key)
1545 if m:
1546 matches.append((key, m.groupdict()))
1548 # now handle the matches
1549 for key, d in matches:
1550 if d['classname']:
1551 # we got a designator
1552 cn = d['classname']
1553 cl = self.db.classes[cn]
1554 nodeid = d['id']
1555 propname = d['propname']
1556 elif d['note']:
1557 # the special note field
1558 cn = 'msg'
1559 cl = self.db.classes[cn]
1560 nodeid = '-1'
1561 propname = 'content'
1562 all_links.append((default_cn, default_nodeid, 'messages',
1563 [('msg', '-1')]))
1564 have_note = 1
1565 elif d['file']:
1566 # the special file field
1567 cn = 'file'
1568 cl = self.db.classes[cn]
1569 nodeid = '-1'
1570 propname = 'content'
1571 all_links.append((default_cn, default_nodeid, 'files',
1572 [('file', '-1')]))
1573 have_file = 1
1574 else:
1575 # default
1576 cn = default_cn
1577 cl = default_cl
1578 nodeid = default_nodeid
1579 propname = d['propname']
1581 # the thing this value relates to is...
1582 this = (cn, nodeid)
1584 # get more info about the class, and the current set of
1585 # form props for it
1586 if not all_propdef.has_key(cn):
1587 all_propdef[cn] = cl.getprops()
1588 propdef = all_propdef[cn]
1589 if not all_props.has_key(this):
1590 all_props[this] = {}
1591 props = all_props[this]
1592 if not got_props.has_key(this):
1593 got_props[this] = {}
1595 # is this a link command?
1596 if d['link']:
1597 value = []
1598 for entry in extractFormList(form[key]):
1599 m = self.FV_DESIGNATOR.match(entry)
1600 if not m:
1601 raise FormError, \
1602 'link "%s" value "%s" not a designator'%(key, entry)
1603 value.append((m.group(1), m.group(2)))
1605 # make sure the link property is valid
1606 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1607 not isinstance(propdef[propname], hyperdb.Link)):
1608 raise FormError, '%s %s is not a link or '\
1609 'multilink property'%(cn, propname)
1611 all_links.append((cn, nodeid, propname, value))
1612 continue
1614 # detect the special ":required" variable
1615 if d['required']:
1616 all_required[this] = extractFormList(form[key])
1617 continue
1619 # see if we're performing a special multilink action
1620 mlaction = 'set'
1621 if d['remove']:
1622 mlaction = 'remove'
1623 elif d['add']:
1624 mlaction = 'add'
1626 # does the property exist?
1627 if not propdef.has_key(propname):
1628 if mlaction != 'set':
1629 raise FormError, 'You have submitted a %s action for'\
1630 ' the property "%s" which doesn\'t exist'%(mlaction,
1631 propname)
1632 # the form element is probably just something we don't care
1633 # about - ignore it
1634 continue
1635 proptype = propdef[propname]
1637 # Get the form value. This value may be a MiniFieldStorage or a list
1638 # of MiniFieldStorages.
1639 value = form[key]
1641 # handle unpacking of the MiniFieldStorage / list form value
1642 if isinstance(proptype, hyperdb.Multilink):
1643 value = extractFormList(value)
1644 else:
1645 # multiple values are not OK
1646 if isinstance(value, type([])):
1647 raise FormError, 'You have submitted more than one value'\
1648 ' for the %s property'%propname
1649 # value might be a file upload...
1650 if not hasattr(value, 'filename') or value.filename is None:
1651 # nope, pull out the value and strip it
1652 value = value.value.strip()
1654 # now that we have the props field, we need a teensy little
1655 # extra bit of help for the old :note field...
1656 if d['note'] and value:
1657 props['author'] = self.db.getuid()
1658 props['date'] = date.Date()
1660 # handle by type now
1661 if isinstance(proptype, hyperdb.Password):
1662 if not value:
1663 # ignore empty password values
1664 continue
1665 for key, d in matches:
1666 if d['confirm'] and d['propname'] == propname:
1667 confirm = form[key]
1668 break
1669 else:
1670 raise FormError, 'Password and confirmation text do '\
1671 'not match'
1672 if isinstance(confirm, type([])):
1673 raise FormError, 'You have submitted more than one value'\
1674 ' for the %s property'%propname
1675 if value != confirm.value:
1676 raise FormError, 'Password and confirmation text do '\
1677 'not match'
1678 try:
1679 value = password.Password(value)
1680 except hyperdb.HyperdbValueError, msg:
1681 raise FormError, msg
1683 elif isinstance(proptype, hyperdb.Multilink):
1684 # convert input to list of ids
1685 try:
1686 l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1687 propname, value)
1688 except hyperdb.HyperdbValueError, msg:
1689 raise FormError, msg
1691 # now use that list of ids to modify the multilink
1692 if mlaction == 'set':
1693 value = l
1694 else:
1695 # we're modifying the list - get the current list of ids
1696 if props.has_key(propname):
1697 existing = props[propname]
1698 elif nodeid and not nodeid.startswith('-'):
1699 existing = cl.get(nodeid, propname, [])
1700 else:
1701 existing = []
1703 # now either remove or add
1704 if mlaction == 'remove':
1705 # remove - handle situation where the id isn't in
1706 # the list
1707 for entry in l:
1708 try:
1709 existing.remove(entry)
1710 except ValueError:
1711 raise FormError, _('property "%(propname)s": '
1712 '"%(value)s" not currently in list')%{
1713 'propname': propname, 'value': entry}
1714 else:
1715 # add - easy, just don't dupe
1716 for entry in l:
1717 if entry not in existing:
1718 existing.append(entry)
1719 value = existing
1720 value.sort()
1722 elif value == '':
1723 # other types should be None'd if there's no value
1724 value = None
1725 else:
1726 # handle all other types
1727 try:
1728 if isinstance(proptype, hyperdb.String):
1729 if (hasattr(value, 'filename') and
1730 value.filename is not None):
1731 # skip if the upload is empty
1732 if not value.filename:
1733 continue
1734 # this String is actually a _file_
1735 # try to determine the file content-type
1736 fn = value.filename.split('\\')[-1]
1737 if propdef.has_key('name'):
1738 props['name'] = fn
1739 # use this info as the type/filename properties
1740 if propdef.has_key('type'):
1741 props['type'] = mimetypes.guess_type(fn)[0]
1742 if not props['type']:
1743 props['type'] = "application/octet-stream"
1744 # finally, read the content RAW
1745 value = value.value
1746 else:
1747 value = hyperdb.rawToHyperdb(self.db, cl,
1748 nodeid, propname, value)
1750 else:
1751 value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1752 propname, value)
1753 except hyperdb.HyperdbValueError, msg:
1754 raise FormError, msg
1756 # register that we got this property
1757 if value:
1758 got_props[this][propname] = 1
1760 # get the old value
1761 if nodeid and not nodeid.startswith('-'):
1762 try:
1763 existing = cl.get(nodeid, propname)
1764 except KeyError:
1765 # this might be a new property for which there is
1766 # no existing value
1767 if not propdef.has_key(propname):
1768 raise
1769 except IndexError, message:
1770 raise FormError(str(message))
1772 # make sure the existing multilink is sorted
1773 if isinstance(proptype, hyperdb.Multilink):
1774 existing.sort()
1776 # "missing" existing values may not be None
1777 if not existing:
1778 if isinstance(proptype, hyperdb.String) and not existing:
1779 # some backends store "missing" Strings as empty strings
1780 existing = None
1781 elif isinstance(proptype, hyperdb.Number) and not existing:
1782 # some backends store "missing" Numbers as 0 :(
1783 existing = 0
1784 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1785 # likewise Booleans
1786 existing = 0
1788 # if changed, set it
1789 if value != existing:
1790 props[propname] = value
1791 else:
1792 # don't bother setting empty/unset values
1793 if value is None:
1794 continue
1795 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1796 continue
1797 elif isinstance(proptype, hyperdb.String) and value == '':
1798 continue
1800 props[propname] = value
1802 # check to see if we need to specially link a file to the note
1803 if have_note and have_file:
1804 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1806 # see if all the required properties have been supplied
1807 s = []
1808 for thing, required in all_required.items():
1809 # register the values we got
1810 got = got_props.get(thing, {})
1811 for entry in required[:]:
1812 if got.has_key(entry):
1813 required.remove(entry)
1815 # any required values not present?
1816 if not required:
1817 continue
1819 # tell the user to entry the values required
1820 if len(required) > 1:
1821 p = 'properties'
1822 else:
1823 p = 'property'
1824 s.append('Required %s %s %s not supplied'%(thing[0], p,
1825 ', '.join(required)))
1826 if s:
1827 raise FormError, '\n'.join(s)
1829 # When creating a FileClass node, it should have a non-empty content
1830 # property to be created. When editing a FileClass node, it should
1831 # either have a non-empty content property or no property at all. In
1832 # the latter case, nothing will change.
1833 for (cn, id), props in all_props.items():
1834 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1835 if id == '-1':
1836 if not props.get('content', ''):
1837 del all_props[(cn, id)]
1838 elif props.has_key('content') and not props['content']:
1839 raise FormError, _('File is empty')
1840 return all_props, all_links
1842 def extractFormList(value):
1843 ''' Extract a list of values from the form value.
1845 It may be one of:
1846 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1847 MiniFieldStorage('value,value,...')
1848 MiniFieldStorage('value')
1849 '''
1850 # multiple values are OK
1851 if isinstance(value, type([])):
1852 # it's a list of MiniFieldStorages - join then into
1853 values = ','.join([i.value.strip() for i in value])
1854 else:
1855 # it's a MiniFieldStorage, but may be a comma-separated list
1856 # of values
1857 values = value.value
1859 value = [i.strip() for i in values.split(',')]
1861 # filter out the empty bits
1862 return filter(None, value)