1 # $Id: client.py,v 1.140 2003-09-24 14:53:58 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] == '_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 return
850 # re-open the database as "admin"
851 if self.user != 'admin':
852 self.opendb('admin')
854 # change the password
855 newpw = password.generatePassword()
857 cl = self.db.user
858 # XXX we need to make the "default" page be able to display errors!
859 try:
860 # set the password
861 cl.set(uid, password=password.Password(newpw))
862 # clear the props from the otk database
863 self.db.otks.destroy(otk)
864 self.db.commit()
865 except (ValueError, KeyError), message:
866 self.error_message.append(str(message))
867 return
869 # user info
870 address = self.db.user.get(uid, 'address')
871 name = self.db.user.get(uid, 'username')
873 # send the email
874 tracker_name = self.db.config.TRACKER_NAME
875 subject = 'Password reset for %s'%tracker_name
876 body = '''
877 The password has been reset for username "%(name)s".
879 Your password is now: %(password)s
880 '''%{'name': name, 'password': newpw}
881 if not self.standard_message(address, subject, body):
882 return
884 self.ok_message.append('Password reset and email sent to %s'%address)
885 return
887 # no OTK, so now figure the user
888 if self.form.has_key('username'):
889 name = self.form['username'].value
890 try:
891 uid = self.db.user.lookup(name)
892 except KeyError:
893 self.error_message.append('Unknown username')
894 return
895 address = self.db.user.get(uid, 'address')
896 elif self.form.has_key('address'):
897 address = self.form['address'].value
898 uid = uidFromAddress(self.db, ('', address), create=0)
899 if not uid:
900 self.error_message.append('Unknown email address')
901 return
902 name = self.db.user.get(uid, 'username')
903 else:
904 self.error_message.append('You need to specify a username '
905 'or address')
906 return
908 # generate the one-time-key and store the props for later
909 otk = ''.join([random.choice(chars) for x in range(32)])
910 self.db.otks.set(otk, uid=uid, __time=time.time())
912 # send the email
913 tracker_name = self.db.config.TRACKER_NAME
914 subject = 'Confirm reset of password for %s'%tracker_name
915 body = '''
916 Someone, perhaps you, has requested that the password be changed for your
917 username, "%(name)s". If you wish to proceed with the change, please follow
918 the link below:
920 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
922 You should then receive another email with the new password.
923 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
924 if not self.standard_message(address, subject, body):
925 return
927 self.ok_message.append('Email sent to %s'%address)
929 def editItemAction(self):
930 ''' Perform an edit of an item in the database.
932 See parsePropsFromForm and _editnodes for special variables
933 '''
934 props, links = self.parsePropsFromForm()
936 # handle the props
937 try:
938 message = self._editnodes(props, links)
939 except (ValueError, KeyError, IndexError), message:
940 self.error_message.append(_('Apply Error: ') + str(message))
941 return
943 # commit now that all the tricky stuff is done
944 self.db.commit()
946 # redirect to the item's edit page
947 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
948 self.classname, self.nodeid, urllib.quote(message),
949 urllib.quote(self.template))
951 newItemAction = editItemAction
953 def editItemPermission(self, props):
954 ''' Determine whether the user has permission to edit this item.
956 Base behaviour is to check the user can edit this class. If we're
957 editing the "user" class, users are allowed to edit their own
958 details. Unless it's the "roles" property, which requires the
959 special Permission "Web Roles".
960 '''
961 # if this is a user node and the user is editing their own node, then
962 # we're OK
963 has = self.db.security.hasPermission
964 if self.classname == 'user':
965 # reject if someone's trying to edit "roles" and doesn't have the
966 # right permission.
967 if props.has_key('roles') and not has('Web Roles', self.userid,
968 'user'):
969 return 0
970 # if the item being edited is the current user, we're ok
971 if self.nodeid == self.userid:
972 return 1
973 if self.db.security.hasPermission('Edit', self.userid, self.classname):
974 return 1
975 return 0
977 def newItemPermission(self, props):
978 ''' Determine whether the user has permission to create (edit) this
979 item.
981 Base behaviour is to check the user can edit this class. No
982 additional property checks are made. Additionally, new user items
983 may be created if the user has the "Web Registration" Permission.
984 '''
985 has = self.db.security.hasPermission
986 if self.classname == 'user' and has('Web Registration', self.userid,
987 'user'):
988 return 1
989 if has('Edit', self.userid, self.classname):
990 return 1
991 return 0
994 #
995 # Utility methods for editing
996 #
997 def _editnodes(self, all_props, all_links, newids=None):
998 ''' Use the props in all_props to perform edit and creation, then
999 use the link specs in all_links to do linking.
1000 '''
1001 # figure dependencies and re-work links
1002 deps = {}
1003 links = {}
1004 for cn, nodeid, propname, vlist in all_links:
1005 if not all_props.has_key((cn, nodeid)):
1006 # link item to link to doesn't (and won't) exist
1007 continue
1008 for value in vlist:
1009 if not all_props.has_key(value):
1010 # link item to link to doesn't (and won't) exist
1011 continue
1012 deps.setdefault((cn, nodeid), []).append(value)
1013 links.setdefault(value, []).append((cn, nodeid, propname))
1015 # figure chained dependencies ordering
1016 order = []
1017 done = {}
1018 # loop detection
1019 change = 0
1020 while len(all_props) != len(done):
1021 for needed in all_props.keys():
1022 if done.has_key(needed):
1023 continue
1024 tlist = deps.get(needed, [])
1025 for target in tlist:
1026 if not done.has_key(target):
1027 break
1028 else:
1029 done[needed] = 1
1030 order.append(needed)
1031 change = 1
1032 if not change:
1033 raise ValueError, 'linking must not loop!'
1035 # now, edit / create
1036 m = []
1037 for needed in order:
1038 props = all_props[needed]
1039 if not props:
1040 # nothing to do
1041 continue
1042 cn, nodeid = needed
1044 if nodeid is not None and int(nodeid) > 0:
1045 # make changes to the node
1046 props = self._changenode(cn, nodeid, props)
1048 # and some nice feedback for the user
1049 if props:
1050 info = ', '.join(props.keys())
1051 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1052 else:
1053 m.append('%s %s - nothing changed'%(cn, nodeid))
1054 else:
1055 assert props
1057 # make a new node
1058 newid = self._createnode(cn, props)
1059 if nodeid is None:
1060 self.nodeid = newid
1061 nodeid = newid
1063 # and some nice feedback for the user
1064 m.append('%s %s created'%(cn, newid))
1066 # fill in new ids in links
1067 if links.has_key(needed):
1068 for linkcn, linkid, linkprop in links[needed]:
1069 props = all_props[(linkcn, linkid)]
1070 cl = self.db.classes[linkcn]
1071 propdef = cl.getprops()[linkprop]
1072 if not props.has_key(linkprop):
1073 if linkid is None or linkid.startswith('-'):
1074 # linking to a new item
1075 if isinstance(propdef, hyperdb.Multilink):
1076 props[linkprop] = [newid]
1077 else:
1078 props[linkprop] = newid
1079 else:
1080 # linking to an existing item
1081 if isinstance(propdef, hyperdb.Multilink):
1082 existing = cl.get(linkid, linkprop)[:]
1083 existing.append(nodeid)
1084 props[linkprop] = existing
1085 else:
1086 props[linkprop] = newid
1088 return '<br>'.join(m)
1090 def _changenode(self, cn, nodeid, props):
1091 ''' change the node based on the contents of the form
1092 '''
1093 # check for permission
1094 if not self.editItemPermission(props):
1095 raise Unauthorised, 'You do not have permission to edit %s'%cn
1097 # make the changes
1098 cl = self.db.classes[cn]
1099 return cl.set(nodeid, **props)
1101 def _createnode(self, cn, props):
1102 ''' create a node based on the contents of the form
1103 '''
1104 # check for permission
1105 if not self.newItemPermission(props):
1106 raise Unauthorised, 'You do not have permission to create %s'%cn
1108 # create the node and return its id
1109 cl = self.db.classes[cn]
1110 return cl.create(**props)
1112 #
1113 # More actions
1114 #
1115 def editCSVAction(self):
1116 ''' Performs an edit of all of a class' items in one go.
1118 The "rows" CGI var defines the CSV-formatted entries for the
1119 class. New nodes are identified by the ID 'X' (or any other
1120 non-existent ID) and removed lines are retired.
1121 '''
1122 # this is per-class only
1123 if not self.editCSVPermission():
1124 self.error_message.append(
1125 _('You do not have permission to edit %s' %self.classname))
1127 # get the CSV module
1128 if rcsv.error:
1129 self.error_message.append(_(rcsv.error))
1130 return
1132 cl = self.db.classes[self.classname]
1133 idlessprops = cl.getprops(protected=0).keys()
1134 idlessprops.sort()
1135 props = ['id'] + idlessprops
1137 # do the edit
1138 rows = StringIO.StringIO(self.form['rows'].value)
1139 reader = rcsv.reader(rows, rcsv.comma_separated)
1140 found = {}
1141 line = 0
1142 for values in reader:
1143 line += 1
1144 if line == 1: continue
1145 # skip property names header
1146 if values == props:
1147 continue
1149 # extract the nodeid
1150 nodeid, values = values[0], values[1:]
1151 found[nodeid] = 1
1153 # see if the node exists
1154 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1155 exists = 0
1156 else:
1157 exists = 1
1159 # confirm correct weight
1160 if len(idlessprops) != len(values):
1161 self.error_message.append(
1162 _('Not enough values on line %(line)s')%{'line':line})
1163 return
1165 # extract the new values
1166 d = {}
1167 for name, value in zip(idlessprops, values):
1168 prop = cl.properties[name]
1169 value = value.strip()
1170 # only add the property if it has a value
1171 if value:
1172 # if it's a multilink, split it
1173 if isinstance(prop, hyperdb.Multilink):
1174 value = value.split(':')
1175 elif isinstance(prop, hyperdb.Password):
1176 value = password.Password(value)
1177 elif isinstance(prop, hyperdb.Interval):
1178 value = date.Interval(value)
1179 elif isinstance(prop, hyperdb.Date):
1180 value = date.Date(value)
1181 elif isinstance(prop, hyperdb.Boolean):
1182 value = value.lower() in ('yes', 'true', 'on', '1')
1183 elif isinstance(prop, hyperdb.Number):
1184 value = float(value)
1185 d[name] = value
1186 elif exists:
1187 # nuke the existing value
1188 if isinstance(prop, hyperdb.Multilink):
1189 d[name] = []
1190 else:
1191 d[name] = None
1193 # perform the edit
1194 if exists:
1195 # edit existing
1196 cl.set(nodeid, **d)
1197 else:
1198 # new node
1199 found[cl.create(**d)] = 1
1201 # retire the removed entries
1202 for nodeid in cl.list():
1203 if not found.has_key(nodeid):
1204 cl.retire(nodeid)
1206 # all OK
1207 self.db.commit()
1209 self.ok_message.append(_('Items edited OK'))
1211 def editCSVPermission(self):
1212 ''' Determine whether the user has permission to edit this class.
1214 Base behaviour is to check the user can edit this class.
1215 '''
1216 if not self.db.security.hasPermission('Edit', self.userid,
1217 self.classname):
1218 return 0
1219 return 1
1221 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1222 ''' Mangle some of the form variables.
1224 Set the form ":filter" variable based on the values of the
1225 filter variables - if they're set to anything other than
1226 "dontcare" then add them to :filter.
1228 Handle the ":queryname" variable and save off the query to
1229 the user's query list.
1231 Split any String query values on whitespace and comma.
1232 '''
1233 # generic edit is per-class only
1234 if not self.searchPermission():
1235 self.error_message.append(
1236 _('You do not have permission to search %s' %self.classname))
1238 # add a faked :filter form variable for each filtering prop
1239 props = self.db.classes[self.classname].getprops()
1240 queryname = ''
1241 for key in self.form.keys():
1242 # special vars
1243 if self.FV_QUERYNAME.match(key):
1244 queryname = self.form[key].value.strip()
1245 continue
1247 if not props.has_key(key):
1248 continue
1249 if isinstance(self.form[key], type([])):
1250 # search for at least one entry which is not empty
1251 for minifield in self.form[key]:
1252 if minifield.value:
1253 break
1254 else:
1255 continue
1256 else:
1257 if not self.form[key].value:
1258 continue
1259 if isinstance(props[key], hyperdb.String):
1260 v = self.form[key].value
1261 l = token.token_split(v)
1262 if len(l) > 1 or l[0] != v:
1263 self.form.value.remove(self.form[key])
1264 # replace the single value with the split list
1265 for v in l:
1266 self.form.value.append(cgi.MiniFieldStorage(key, v))
1268 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1270 # handle saving the query params
1271 if queryname:
1272 # parse the environment and figure what the query _is_
1273 req = HTMLRequest(self)
1275 # The [1:] strips off the '?' character, it isn't part of the
1276 # query string.
1277 url = req.indexargs_href('', {})[1:]
1279 # handle editing an existing query
1280 try:
1281 qid = self.db.query.lookup(queryname)
1282 self.db.query.set(qid, klass=self.classname, url=url)
1283 except KeyError:
1284 # create a query
1285 qid = self.db.query.create(name=queryname,
1286 klass=self.classname, url=url)
1288 # and add it to the user's query multilink
1289 queries = self.db.user.get(self.userid, 'queries')
1290 queries.append(qid)
1291 self.db.user.set(self.userid, queries=queries)
1293 # commit the query change to the database
1294 self.db.commit()
1296 def searchPermission(self):
1297 ''' Determine whether the user has permission to search this class.
1299 Base behaviour is to check the user can view this class.
1300 '''
1301 if not self.db.security.hasPermission('View', self.userid,
1302 self.classname):
1303 return 0
1304 return 1
1307 def retireAction(self):
1308 ''' Retire the context item.
1309 '''
1310 # if we want to view the index template now, then unset the nodeid
1311 # context info (a special-case for retire actions on the index page)
1312 nodeid = self.nodeid
1313 if self.template == 'index':
1314 self.nodeid = None
1316 # generic edit is per-class only
1317 if not self.retirePermission():
1318 self.error_message.append(
1319 _('You do not have permission to retire %s' %self.classname))
1320 return
1322 # make sure we don't try to retire admin or anonymous
1323 if self.classname == 'user' and \
1324 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1325 self.error_message.append(
1326 _('You may not retire the admin or anonymous user'))
1327 return
1329 # do the retire
1330 self.db.getclass(self.classname).retire(nodeid)
1331 self.db.commit()
1333 self.ok_message.append(
1334 _('%(classname)s %(itemid)s has been retired')%{
1335 'classname': self.classname.capitalize(), 'itemid': nodeid})
1337 def retirePermission(self):
1338 ''' Determine whether the user has permission to retire this class.
1340 Base behaviour is to check the user can edit this class.
1341 '''
1342 if not self.db.security.hasPermission('Edit', self.userid,
1343 self.classname):
1344 return 0
1345 return 1
1348 def showAction(self, typere=re.compile('[@:]type'),
1349 numre=re.compile('[@:]number')):
1350 ''' Show a node of a particular class/id
1351 '''
1352 t = n = ''
1353 for key in self.form.keys():
1354 if typere.match(key):
1355 t = self.form[key].value.strip()
1356 elif numre.match(key):
1357 n = self.form[key].value.strip()
1358 if not t:
1359 raise ValueError, 'Invalid %s number'%t
1360 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1361 raise Redirect, url
1363 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1364 ''' Item properties and their values are edited with html FORM
1365 variables and their values. You can:
1367 - Change the value of some property of the current item.
1368 - Create a new item of any class, and edit the new item's
1369 properties,
1370 - Attach newly created items to a multilink property of the
1371 current item.
1372 - Remove items from a multilink property of the current item.
1373 - Specify that some properties are required for the edit
1374 operation to be successful.
1376 In the following, <bracketed> values are variable, "@" may be
1377 either ":" or "@", and other text "required" is fixed.
1379 Most properties are specified as form variables:
1381 <propname>
1382 - property on the current context item
1384 <designator>"@"<propname>
1385 - property on the indicated item (for editing related
1386 information)
1388 Designators name a specific item of a class.
1390 <classname><N>
1392 Name an existing item of class <classname>.
1394 <classname>"-"<N>
1396 Name the <N>th new item of class <classname>. If the form
1397 submission is successful, a new item of <classname> is
1398 created. Within the submitted form, a particular
1399 designator of this form always refers to the same new
1400 item.
1402 Once we have determined the "propname", we look at it to see
1403 if it's special:
1405 @required
1406 The associated form value is a comma-separated list of
1407 property names that must be specified when the form is
1408 submitted for the edit operation to succeed.
1410 When the <designator> is missing, the properties are
1411 for the current context item. When <designator> is
1412 present, they are for the item specified by
1413 <designator>.
1415 The "@required" specifier must come before any of the
1416 properties it refers to are assigned in the form.
1418 @remove@<propname>=id(s) or @add@<propname>=id(s)
1419 The "@add@" and "@remove@" edit actions apply only to
1420 Multilink properties. The form value must be a
1421 comma-separate list of keys for the class specified by
1422 the simple form variable. The listed items are added
1423 to (respectively, removed from) the specified
1424 property.
1426 @link@<propname>=<designator>
1427 If the edit action is "@link@", the simple form
1428 variable must specify a Link or Multilink property.
1429 The form value is a comma-separated list of
1430 designators. The item corresponding to each
1431 designator is linked to the property given by simple
1432 form variable. These are collected up and returned in
1433 all_links.
1435 None of the above (ie. just a simple form value)
1436 The value of the form variable is converted
1437 appropriately, depending on the type of the property.
1439 For a Link('klass') property, the form value is a
1440 single key for 'klass', where the key field is
1441 specified in dbinit.py.
1443 For a Multilink('klass') property, the form value is a
1444 comma-separated list of keys for 'klass', where the
1445 key field is specified in dbinit.py.
1447 Note that for simple-form-variables specifiying Link
1448 and Multilink properties, the linked-to class must
1449 have a key field.
1451 For a String() property specifying a filename, the
1452 file named by the form value is uploaded. This means we
1453 try to set additional properties "filename" and "type" (if
1454 they are valid for the class). Otherwise, the property
1455 is set to the form value.
1457 For Date(), Interval(), Boolean(), and Number()
1458 properties, the form value is converted to the
1459 appropriate
1461 Any of the form variables may be prefixed with a classname or
1462 designator.
1464 Two special form values are supported for backwards
1465 compatibility:
1467 @note
1468 This is equivalent to::
1470 @link@messages=msg-1
1471 @msg-1@content=value
1473 except that in addition, the "author" and "date"
1474 properties of "msg-1" are set to the userid of the
1475 submitter, and the current time, respectively.
1477 @file
1478 This is equivalent to::
1480 @link@files=file-1
1481 @file-1@content=value
1483 The String content value is handled as described above for
1484 file uploads.
1486 If both the "@note" and "@file" form variables are
1487 specified, the action::
1489 @link@msg-1@files=file-1
1491 is also performed.
1493 We also check that FileClass items have a "content" property with
1494 actual content, otherwise we remove them from all_props before
1495 returning.
1497 The return from this method is a dict of
1498 (classname, id): properties
1499 ... this dict _always_ has an entry for the current context,
1500 even if it's empty (ie. a submission for an existing issue that
1501 doesn't result in any changes would return {('issue','123'): {}})
1502 The id may be None, which indicates that an item should be
1503 created.
1504 '''
1505 # some very useful variables
1506 db = self.db
1507 form = self.form
1509 if not hasattr(self, 'FV_SPECIAL'):
1510 # generate the regexp for handling special form values
1511 classes = '|'.join(db.classes.keys())
1512 # specials for parsePropsFromForm
1513 # handle the various forms (see unit tests)
1514 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1515 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1517 # these indicate the default class / item
1518 default_cn = self.classname
1519 default_cl = self.db.classes[default_cn]
1520 default_nodeid = self.nodeid
1522 # we'll store info about the individual class/item edit in these
1523 all_required = {} # required props per class/item
1524 all_props = {} # props to set per class/item
1525 got_props = {} # props received per class/item
1526 all_propdef = {} # note - only one entry per class
1527 all_links = [] # as many as are required
1529 # we should always return something, even empty, for the context
1530 all_props[(default_cn, default_nodeid)] = {}
1532 keys = form.keys()
1533 timezone = db.getUserTimezone()
1535 # sentinels for the :note and :file props
1536 have_note = have_file = 0
1538 # extract the usable form labels from the form
1539 matches = []
1540 for key in keys:
1541 m = self.FV_SPECIAL.match(key)
1542 if m:
1543 matches.append((key, m.groupdict()))
1545 # now handle the matches
1546 for key, d in matches:
1547 if d['classname']:
1548 # we got a designator
1549 cn = d['classname']
1550 cl = self.db.classes[cn]
1551 nodeid = d['id']
1552 propname = d['propname']
1553 elif d['note']:
1554 # the special note field
1555 cn = 'msg'
1556 cl = self.db.classes[cn]
1557 nodeid = '-1'
1558 propname = 'content'
1559 all_links.append((default_cn, default_nodeid, 'messages',
1560 [('msg', '-1')]))
1561 have_note = 1
1562 elif d['file']:
1563 # the special file field
1564 cn = 'file'
1565 cl = self.db.classes[cn]
1566 nodeid = '-1'
1567 propname = 'content'
1568 all_links.append((default_cn, default_nodeid, 'files',
1569 [('file', '-1')]))
1570 have_file = 1
1571 else:
1572 # default
1573 cn = default_cn
1574 cl = default_cl
1575 nodeid = default_nodeid
1576 propname = d['propname']
1578 # the thing this value relates to is...
1579 this = (cn, nodeid)
1581 # get more info about the class, and the current set of
1582 # form props for it
1583 if not all_propdef.has_key(cn):
1584 all_propdef[cn] = cl.getprops()
1585 propdef = all_propdef[cn]
1586 if not all_props.has_key(this):
1587 all_props[this] = {}
1588 props = all_props[this]
1589 if not got_props.has_key(this):
1590 got_props[this] = {}
1592 # is this a link command?
1593 if d['link']:
1594 value = []
1595 for entry in extractFormList(form[key]):
1596 m = self.FV_DESIGNATOR.match(entry)
1597 if not m:
1598 raise FormError, \
1599 'link "%s" value "%s" not a designator'%(key, entry)
1600 value.append((m.group(1), m.group(2)))
1602 # make sure the link property is valid
1603 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1604 not isinstance(propdef[propname], hyperdb.Link)):
1605 raise FormError, '%s %s is not a link or '\
1606 'multilink property'%(cn, propname)
1608 all_links.append((cn, nodeid, propname, value))
1609 continue
1611 # detect the special ":required" variable
1612 if d['required']:
1613 all_required[this] = extractFormList(form[key])
1614 continue
1616 # see if we're performing a special multilink action
1617 mlaction = 'set'
1618 if d['remove']:
1619 mlaction = 'remove'
1620 elif d['add']:
1621 mlaction = 'add'
1623 # does the property exist?
1624 if not propdef.has_key(propname):
1625 if mlaction != 'set':
1626 raise FormError, 'You have submitted a %s action for'\
1627 ' the property "%s" which doesn\'t exist'%(mlaction,
1628 propname)
1629 # the form element is probably just something we don't care
1630 # about - ignore it
1631 continue
1632 proptype = propdef[propname]
1634 # Get the form value. This value may be a MiniFieldStorage or a list
1635 # of MiniFieldStorages.
1636 value = form[key]
1638 # handle unpacking of the MiniFieldStorage / list form value
1639 if isinstance(proptype, hyperdb.Multilink):
1640 value = extractFormList(value)
1641 else:
1642 # multiple values are not OK
1643 if isinstance(value, type([])):
1644 raise FormError, 'You have submitted more than one value'\
1645 ' for the %s property'%propname
1646 # value might be a file upload...
1647 if not hasattr(value, 'filename') or value.filename is None:
1648 # nope, pull out the value and strip it
1649 value = value.value.strip()
1651 # now that we have the props field, we need a teensy little
1652 # extra bit of help for the old :note field...
1653 if d['note'] and value:
1654 props['author'] = self.db.getuid()
1655 props['date'] = date.Date()
1657 # handle by type now
1658 if isinstance(proptype, hyperdb.Password):
1659 if not value:
1660 # ignore empty password values
1661 continue
1662 for key, d in matches:
1663 if d['confirm'] and d['propname'] == propname:
1664 confirm = form[key]
1665 break
1666 else:
1667 raise FormError, 'Password and confirmation text do '\
1668 'not match'
1669 if isinstance(confirm, type([])):
1670 raise FormError, 'You have submitted more than one value'\
1671 ' for the %s property'%propname
1672 if value != confirm.value:
1673 raise FormError, 'Password and confirmation text do '\
1674 'not match'
1675 value = password.Password(value)
1677 elif isinstance(proptype, hyperdb.Link):
1678 # see if it's the "no selection" choice
1679 if value == '-1' or not value:
1680 # if we're creating, just don't include this property
1681 if not nodeid or nodeid.startswith('-'):
1682 continue
1683 value = None
1684 else:
1685 # handle key values
1686 link = proptype.classname
1687 if not num_re.match(value):
1688 try:
1689 value = db.classes[link].lookup(value)
1690 except KeyError:
1691 raise FormError, _('property "%(propname)s": '
1692 '%(value)s not a %(classname)s')%{
1693 'propname': propname, 'value': value,
1694 'classname': link}
1695 except TypeError, message:
1696 raise FormError, _('you may only enter ID values '
1697 'for property "%(propname)s": %(message)s')%{
1698 'propname': propname, 'message': message}
1699 elif isinstance(proptype, hyperdb.Multilink):
1700 # perform link class key value lookup if necessary
1701 link = proptype.classname
1702 link_cl = db.classes[link]
1703 l = []
1704 for entry in value:
1705 if not entry: continue
1706 if not num_re.match(entry):
1707 try:
1708 entry = link_cl.lookup(entry)
1709 except KeyError:
1710 raise FormError, _('property "%(propname)s": '
1711 '"%(value)s" not an entry of %(classname)s')%{
1712 'propname': propname, 'value': entry,
1713 'classname': link}
1714 except TypeError, message:
1715 raise FormError, _('you may only enter ID values '
1716 'for property "%(propname)s": %(message)s')%{
1717 'propname': propname, 'message': message}
1718 l.append(entry)
1719 l.sort()
1721 # now use that list of ids to modify the multilink
1722 if mlaction == 'set':
1723 value = l
1724 else:
1725 # we're modifying the list - get the current list of ids
1726 if props.has_key(propname):
1727 existing = props[propname]
1728 elif nodeid and not nodeid.startswith('-'):
1729 existing = cl.get(nodeid, propname, [])
1730 else:
1731 existing = []
1733 # now either remove or add
1734 if mlaction == 'remove':
1735 # remove - handle situation where the id isn't in
1736 # the list
1737 for entry in l:
1738 try:
1739 existing.remove(entry)
1740 except ValueError:
1741 raise FormError, _('property "%(propname)s": '
1742 '"%(value)s" not currently in list')%{
1743 'propname': propname, 'value': entry}
1744 else:
1745 # add - easy, just don't dupe
1746 for entry in l:
1747 if entry not in existing:
1748 existing.append(entry)
1749 value = existing
1750 value.sort()
1752 elif value == '':
1753 # if we're creating, just don't include this property
1754 if not nodeid or nodeid.startswith('-'):
1755 continue
1756 # other types should be None'd if there's no value
1757 value = None
1758 else:
1759 # handle ValueErrors for all these in a similar fashion
1760 try:
1761 if isinstance(proptype, hyperdb.String):
1762 if (hasattr(value, 'filename') and
1763 value.filename is not None):
1764 # skip if the upload is empty
1765 if not value.filename:
1766 continue
1767 # this String is actually a _file_
1768 # try to determine the file content-type
1769 fn = value.filename.split('\\')[-1]
1770 if propdef.has_key('name'):
1771 props['name'] = fn
1772 # use this info as the type/filename properties
1773 if propdef.has_key('type'):
1774 props['type'] = mimetypes.guess_type(fn)[0]
1775 if not props['type']:
1776 props['type'] = "application/octet-stream"
1777 # finally, read the content
1778 value = value.value
1779 else:
1780 # normal String fix the CRLF/CR -> LF stuff
1781 value = fixNewlines(value)
1783 elif isinstance(proptype, hyperdb.Date):
1784 value = date.Date(value, offset=timezone)
1785 elif isinstance(proptype, hyperdb.Interval):
1786 value = date.Interval(value)
1787 elif isinstance(proptype, hyperdb.Boolean):
1788 value = value.lower() in ('yes', 'true', 'on', '1')
1789 elif isinstance(proptype, hyperdb.Number):
1790 value = float(value)
1791 except ValueError, msg:
1792 raise FormError, _('Error with %s property: %s')%(
1793 propname, msg)
1795 # register that we got this property
1796 if value:
1797 got_props[this][propname] = 1
1799 # get the old value
1800 if nodeid and not nodeid.startswith('-'):
1801 try:
1802 existing = cl.get(nodeid, propname)
1803 except KeyError:
1804 # this might be a new property for which there is
1805 # no existing value
1806 if not propdef.has_key(propname):
1807 raise
1808 except IndexError, message:
1809 raise FormError(str(message))
1811 # make sure the existing multilink is sorted
1812 if isinstance(proptype, hyperdb.Multilink):
1813 existing.sort()
1815 # "missing" existing values may not be None
1816 if not existing:
1817 if isinstance(proptype, hyperdb.String) and not existing:
1818 # some backends store "missing" Strings as empty strings
1819 existing = None
1820 elif isinstance(proptype, hyperdb.Number) and not existing:
1821 # some backends store "missing" Numbers as 0 :(
1822 existing = 0
1823 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1824 # likewise Booleans
1825 existing = 0
1827 # if changed, set it
1828 if value != existing:
1829 props[propname] = value
1830 else:
1831 # don't bother setting empty/unset values
1832 if value is None:
1833 continue
1834 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1835 continue
1836 elif isinstance(proptype, hyperdb.String) and value == '':
1837 continue
1839 props[propname] = value
1841 # check to see if we need to specially link a file to the note
1842 if have_note and have_file:
1843 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1845 # see if all the required properties have been supplied
1846 s = []
1847 for thing, required in all_required.items():
1848 # register the values we got
1849 got = got_props.get(thing, {})
1850 for entry in required[:]:
1851 if got.has_key(entry):
1852 required.remove(entry)
1854 # any required values not present?
1855 if not required:
1856 continue
1858 # tell the user to entry the values required
1859 if len(required) > 1:
1860 p = 'properties'
1861 else:
1862 p = 'property'
1863 s.append('Required %s %s %s not supplied'%(thing[0], p,
1864 ', '.join(required)))
1865 if s:
1866 raise FormError, '\n'.join(s)
1868 # When creating a FileClass node, it should have a non-empty content
1869 # property to be created. When editing a FileClass node, it should
1870 # either have a non-empty content property or no property at all. In
1871 # the latter case, nothing will change.
1872 for (cn, id), props in all_props.items():
1873 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1874 if id == '-1':
1875 if not props.get('content', ''):
1876 del all_props[(cn, id)]
1877 elif props.has_key('content') and not props['content']:
1878 raise FormError, _('File is empty')
1879 return all_props, all_links
1881 def fixNewlines(text):
1882 ''' Homogenise line endings.
1884 Different web clients send different line ending values, but
1885 other systems (eg. email) don't necessarily handle those line
1886 endings. Our solution is to convert all line endings to LF.
1887 '''
1888 text = text.replace('\r\n', '\n')
1889 return text.replace('\r', '\n')
1891 def extractFormList(value):
1892 ''' Extract a list of values from the form value.
1894 It may be one of:
1895 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1896 MiniFieldStorage('value,value,...')
1897 MiniFieldStorage('value')
1898 '''
1899 # multiple values are OK
1900 if isinstance(value, type([])):
1901 # it's a list of MiniFieldStorages - join then into
1902 values = ','.join([i.value.strip() for i in value])
1903 else:
1904 # it's a MiniFieldStorage, but may be a comma-separated list
1905 # of values
1906 values = value.value
1908 value = [i.strip() for i in values.split(',')]
1910 # filter out the empty bits
1911 return filter(None, value)