1 # $Id: client.py,v 1.142 2003-10-22 16:47:55 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 (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 return 1
975 if self.db.security.hasPermission('Edit', self.userid, self.classname):
976 return 1
977 return 0
979 def newItemPermission(self, props):
980 ''' Determine whether the user has permission to create (edit) this
981 item.
983 Base behaviour is to check the user can edit this class. No
984 additional property checks are made. Additionally, new user items
985 may be created if the user has the "Web Registration" Permission.
986 '''
987 has = self.db.security.hasPermission
988 if self.classname == 'user' and has('Web Registration', self.userid,
989 'user'):
990 return 1
991 if has('Edit', self.userid, self.classname):
992 return 1
993 return 0
996 #
997 # Utility methods for editing
998 #
999 def _editnodes(self, all_props, all_links, newids=None):
1000 ''' Use the props in all_props to perform edit and creation, then
1001 use the link specs in all_links to do linking.
1002 '''
1003 # figure dependencies and re-work links
1004 deps = {}
1005 links = {}
1006 for cn, nodeid, propname, vlist in all_links:
1007 if not all_props.has_key((cn, nodeid)):
1008 # link item to link to doesn't (and won't) exist
1009 continue
1010 for value in vlist:
1011 if not all_props.has_key(value):
1012 # link item to link to doesn't (and won't) exist
1013 continue
1014 deps.setdefault((cn, nodeid), []).append(value)
1015 links.setdefault(value, []).append((cn, nodeid, propname))
1017 # figure chained dependencies ordering
1018 order = []
1019 done = {}
1020 # loop detection
1021 change = 0
1022 while len(all_props) != len(done):
1023 for needed in all_props.keys():
1024 if done.has_key(needed):
1025 continue
1026 tlist = deps.get(needed, [])
1027 for target in tlist:
1028 if not done.has_key(target):
1029 break
1030 else:
1031 done[needed] = 1
1032 order.append(needed)
1033 change = 1
1034 if not change:
1035 raise ValueError, 'linking must not loop!'
1037 # now, edit / create
1038 m = []
1039 for needed in order:
1040 props = all_props[needed]
1041 if not props:
1042 # nothing to do
1043 continue
1044 cn, nodeid = needed
1046 if nodeid is not None and int(nodeid) > 0:
1047 # make changes to the node
1048 props = self._changenode(cn, nodeid, props)
1050 # and some nice feedback for the user
1051 if props:
1052 info = ', '.join(props.keys())
1053 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1054 else:
1055 m.append('%s %s - nothing changed'%(cn, nodeid))
1056 else:
1057 assert props
1059 # make a new node
1060 newid = self._createnode(cn, props)
1061 if nodeid is None:
1062 self.nodeid = newid
1063 nodeid = newid
1065 # and some nice feedback for the user
1066 m.append('%s %s created'%(cn, newid))
1068 # fill in new ids in links
1069 if links.has_key(needed):
1070 for linkcn, linkid, linkprop in links[needed]:
1071 props = all_props[(linkcn, linkid)]
1072 cl = self.db.classes[linkcn]
1073 propdef = cl.getprops()[linkprop]
1074 if not props.has_key(linkprop):
1075 if linkid is None or linkid.startswith('-'):
1076 # linking to a new item
1077 if isinstance(propdef, hyperdb.Multilink):
1078 props[linkprop] = [newid]
1079 else:
1080 props[linkprop] = newid
1081 else:
1082 # linking to an existing item
1083 if isinstance(propdef, hyperdb.Multilink):
1084 existing = cl.get(linkid, linkprop)[:]
1085 existing.append(nodeid)
1086 props[linkprop] = existing
1087 else:
1088 props[linkprop] = newid
1090 return '<br>'.join(m)
1092 def _changenode(self, cn, nodeid, props):
1093 ''' change the node based on the contents of the form
1094 '''
1095 # check for permission
1096 if not self.editItemPermission(props):
1097 raise Unauthorised, 'You do not have permission to edit %s'%cn
1099 # make the changes
1100 cl = self.db.classes[cn]
1101 return cl.set(nodeid, **props)
1103 def _createnode(self, cn, props):
1104 ''' create a node based on the contents of the form
1105 '''
1106 # check for permission
1107 if not self.newItemPermission(props):
1108 raise Unauthorised, 'You do not have permission to create %s'%cn
1110 # create the node and return its id
1111 cl = self.db.classes[cn]
1112 return cl.create(**props)
1114 #
1115 # More actions
1116 #
1117 def editCSVAction(self):
1118 ''' Performs an edit of all of a class' items in one go.
1120 The "rows" CGI var defines the CSV-formatted entries for the
1121 class. New nodes are identified by the ID 'X' (or any other
1122 non-existent ID) and removed lines are retired.
1123 '''
1124 # this is per-class only
1125 if not self.editCSVPermission():
1126 self.error_message.append(
1127 _('You do not have permission to edit %s' %self.classname))
1129 # get the CSV module
1130 if rcsv.error:
1131 self.error_message.append(_(rcsv.error))
1132 return
1134 cl = self.db.classes[self.classname]
1135 idlessprops = cl.getprops(protected=0).keys()
1136 idlessprops.sort()
1137 props = ['id'] + idlessprops
1139 # do the edit
1140 rows = StringIO.StringIO(self.form['rows'].value)
1141 reader = rcsv.reader(rows, rcsv.comma_separated)
1142 found = {}
1143 line = 0
1144 for values in reader:
1145 line += 1
1146 if line == 1: continue
1147 # skip property names header
1148 if values == props:
1149 continue
1151 # extract the nodeid
1152 nodeid, values = values[0], values[1:]
1153 found[nodeid] = 1
1155 # see if the node exists
1156 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1157 exists = 0
1158 else:
1159 exists = 1
1161 # confirm correct weight
1162 if len(idlessprops) != len(values):
1163 self.error_message.append(
1164 _('Not enough values on line %(line)s')%{'line':line})
1165 return
1167 # extract the new values
1168 d = {}
1169 for name, value in zip(idlessprops, values):
1170 prop = cl.properties[name]
1171 value = value.strip()
1172 # only add the property if it has a value
1173 if value:
1174 # if it's a multilink, split it
1175 if isinstance(prop, hyperdb.Multilink):
1176 value = value.split(':')
1177 elif isinstance(prop, hyperdb.Password):
1178 value = password.Password(value)
1179 elif isinstance(prop, hyperdb.Interval):
1180 value = date.Interval(value)
1181 elif isinstance(prop, hyperdb.Date):
1182 value = date.Date(value)
1183 elif isinstance(prop, hyperdb.Boolean):
1184 value = value.lower() in ('yes', 'true', 'on', '1')
1185 elif isinstance(prop, hyperdb.Number):
1186 value = float(value)
1187 d[name] = value
1188 elif exists:
1189 # nuke the existing value
1190 if isinstance(prop, hyperdb.Multilink):
1191 d[name] = []
1192 else:
1193 d[name] = None
1195 # perform the edit
1196 if exists:
1197 # edit existing
1198 cl.set(nodeid, **d)
1199 else:
1200 # new node
1201 found[cl.create(**d)] = 1
1203 # retire the removed entries
1204 for nodeid in cl.list():
1205 if not found.has_key(nodeid):
1206 cl.retire(nodeid)
1208 # all OK
1209 self.db.commit()
1211 self.ok_message.append(_('Items edited OK'))
1213 def editCSVPermission(self):
1214 ''' Determine whether the user has permission to edit this class.
1216 Base behaviour is to check the user can edit this class.
1217 '''
1218 if not self.db.security.hasPermission('Edit', self.userid,
1219 self.classname):
1220 return 0
1221 return 1
1223 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1224 ''' Mangle some of the form variables.
1226 Set the form ":filter" variable based on the values of the
1227 filter variables - if they're set to anything other than
1228 "dontcare" then add them to :filter.
1230 Handle the ":queryname" variable and save off the query to
1231 the user's query list.
1233 Split any String query values on whitespace and comma.
1234 '''
1235 # generic edit is per-class only
1236 if not self.searchPermission():
1237 self.error_message.append(
1238 _('You do not have permission to search %s' %self.classname))
1240 # add a faked :filter form variable for each filtering prop
1241 props = self.db.classes[self.classname].getprops()
1242 queryname = ''
1243 for key in self.form.keys():
1244 # special vars
1245 if self.FV_QUERYNAME.match(key):
1246 queryname = self.form[key].value.strip()
1247 continue
1249 if not props.has_key(key):
1250 continue
1251 if isinstance(self.form[key], type([])):
1252 # search for at least one entry which is not empty
1253 for minifield in self.form[key]:
1254 if minifield.value:
1255 break
1256 else:
1257 continue
1258 else:
1259 if not self.form[key].value:
1260 continue
1261 if isinstance(props[key], hyperdb.String):
1262 v = self.form[key].value
1263 l = token.token_split(v)
1264 if len(l) > 1 or l[0] != v:
1265 self.form.value.remove(self.form[key])
1266 # replace the single value with the split list
1267 for v in l:
1268 self.form.value.append(cgi.MiniFieldStorage(key, v))
1270 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1272 # handle saving the query params
1273 if queryname:
1274 # parse the environment and figure what the query _is_
1275 req = HTMLRequest(self)
1277 # The [1:] strips off the '?' character, it isn't part of the
1278 # query string.
1279 url = req.indexargs_href('', {})[1:]
1281 # handle editing an existing query
1282 try:
1283 qid = self.db.query.lookup(queryname)
1284 self.db.query.set(qid, klass=self.classname, url=url)
1285 except KeyError:
1286 # create a query
1287 qid = self.db.query.create(name=queryname,
1288 klass=self.classname, url=url)
1290 # and add it to the user's query multilink
1291 queries = self.db.user.get(self.userid, 'queries')
1292 queries.append(qid)
1293 self.db.user.set(self.userid, queries=queries)
1295 # commit the query change to the database
1296 self.db.commit()
1298 def searchPermission(self):
1299 ''' Determine whether the user has permission to search this class.
1301 Base behaviour is to check the user can view this class.
1302 '''
1303 if not self.db.security.hasPermission('View', self.userid,
1304 self.classname):
1305 return 0
1306 return 1
1309 def retireAction(self):
1310 ''' Retire the context item.
1311 '''
1312 # if we want to view the index template now, then unset the nodeid
1313 # context info (a special-case for retire actions on the index page)
1314 nodeid = self.nodeid
1315 if self.template == 'index':
1316 self.nodeid = None
1318 # generic edit is per-class only
1319 if not self.retirePermission():
1320 self.error_message.append(
1321 _('You do not have permission to retire %s' %self.classname))
1322 return
1324 # make sure we don't try to retire admin or anonymous
1325 if self.classname == 'user' and \
1326 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1327 self.error_message.append(
1328 _('You may not retire the admin or anonymous user'))
1329 return
1331 # do the retire
1332 self.db.getclass(self.classname).retire(nodeid)
1333 self.db.commit()
1335 self.ok_message.append(
1336 _('%(classname)s %(itemid)s has been retired')%{
1337 'classname': self.classname.capitalize(), 'itemid': nodeid})
1339 def retirePermission(self):
1340 ''' Determine whether the user has permission to retire this class.
1342 Base behaviour is to check the user can edit this class.
1343 '''
1344 if not self.db.security.hasPermission('Edit', self.userid,
1345 self.classname):
1346 return 0
1347 return 1
1350 def showAction(self, typere=re.compile('[@:]type'),
1351 numre=re.compile('[@:]number')):
1352 ''' Show a node of a particular class/id
1353 '''
1354 t = n = ''
1355 for key in self.form.keys():
1356 if typere.match(key):
1357 t = self.form[key].value.strip()
1358 elif numre.match(key):
1359 n = self.form[key].value.strip()
1360 if not t:
1361 raise ValueError, 'Invalid %s number'%t
1362 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1363 raise Redirect, url
1365 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1366 ''' Item properties and their values are edited with html FORM
1367 variables and their values. You can:
1369 - Change the value of some property of the current item.
1370 - Create a new item of any class, and edit the new item's
1371 properties,
1372 - Attach newly created items to a multilink property of the
1373 current item.
1374 - Remove items from a multilink property of the current item.
1375 - Specify that some properties are required for the edit
1376 operation to be successful.
1378 In the following, <bracketed> values are variable, "@" may be
1379 either ":" or "@", and other text "required" is fixed.
1381 Most properties are specified as form variables:
1383 <propname>
1384 - property on the current context item
1386 <designator>"@"<propname>
1387 - property on the indicated item (for editing related
1388 information)
1390 Designators name a specific item of a class.
1392 <classname><N>
1394 Name an existing item of class <classname>.
1396 <classname>"-"<N>
1398 Name the <N>th new item of class <classname>. If the form
1399 submission is successful, a new item of <classname> is
1400 created. Within the submitted form, a particular
1401 designator of this form always refers to the same new
1402 item.
1404 Once we have determined the "propname", we look at it to see
1405 if it's special:
1407 @required
1408 The associated form value is a comma-separated list of
1409 property names that must be specified when the form is
1410 submitted for the edit operation to succeed.
1412 When the <designator> is missing, the properties are
1413 for the current context item. When <designator> is
1414 present, they are for the item specified by
1415 <designator>.
1417 The "@required" specifier must come before any of the
1418 properties it refers to are assigned in the form.
1420 @remove@<propname>=id(s) or @add@<propname>=id(s)
1421 The "@add@" and "@remove@" edit actions apply only to
1422 Multilink properties. The form value must be a
1423 comma-separate list of keys for the class specified by
1424 the simple form variable. The listed items are added
1425 to (respectively, removed from) the specified
1426 property.
1428 @link@<propname>=<designator>
1429 If the edit action is "@link@", the simple form
1430 variable must specify a Link or Multilink property.
1431 The form value is a comma-separated list of
1432 designators. The item corresponding to each
1433 designator is linked to the property given by simple
1434 form variable. These are collected up and returned in
1435 all_links.
1437 None of the above (ie. just a simple form value)
1438 The value of the form variable is converted
1439 appropriately, depending on the type of the property.
1441 For a Link('klass') property, the form value is a
1442 single key for 'klass', where the key field is
1443 specified in dbinit.py.
1445 For a Multilink('klass') property, the form value is a
1446 comma-separated list of keys for 'klass', where the
1447 key field is specified in dbinit.py.
1449 Note that for simple-form-variables specifiying Link
1450 and Multilink properties, the linked-to class must
1451 have a key field.
1453 For a String() property specifying a filename, the
1454 file named by the form value is uploaded. This means we
1455 try to set additional properties "filename" and "type" (if
1456 they are valid for the class). Otherwise, the property
1457 is set to the form value.
1459 For Date(), Interval(), Boolean(), and Number()
1460 properties, the form value is converted to the
1461 appropriate
1463 Any of the form variables may be prefixed with a classname or
1464 designator.
1466 Two special form values are supported for backwards
1467 compatibility:
1469 @note
1470 This is equivalent to::
1472 @link@messages=msg-1
1473 @msg-1@content=value
1475 except that in addition, the "author" and "date"
1476 properties of "msg-1" are set to the userid of the
1477 submitter, and the current time, respectively.
1479 @file
1480 This is equivalent to::
1482 @link@files=file-1
1483 @file-1@content=value
1485 The String content value is handled as described above for
1486 file uploads.
1488 If both the "@note" and "@file" form variables are
1489 specified, the action::
1491 @link@msg-1@files=file-1
1493 is also performed.
1495 We also check that FileClass items have a "content" property with
1496 actual content, otherwise we remove them from all_props before
1497 returning.
1499 The return from this method is a dict of
1500 (classname, id): properties
1501 ... this dict _always_ has an entry for the current context,
1502 even if it's empty (ie. a submission for an existing issue that
1503 doesn't result in any changes would return {('issue','123'): {}})
1504 The id may be None, which indicates that an item should be
1505 created.
1506 '''
1507 # some very useful variables
1508 db = self.db
1509 form = self.form
1511 if not hasattr(self, 'FV_SPECIAL'):
1512 # generate the regexp for handling special form values
1513 classes = '|'.join(db.classes.keys())
1514 # specials for parsePropsFromForm
1515 # handle the various forms (see unit tests)
1516 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1517 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1519 # these indicate the default class / item
1520 default_cn = self.classname
1521 default_cl = self.db.classes[default_cn]
1522 default_nodeid = self.nodeid
1524 # we'll store info about the individual class/item edit in these
1525 all_required = {} # required props per class/item
1526 all_props = {} # props to set per class/item
1527 got_props = {} # props received per class/item
1528 all_propdef = {} # note - only one entry per class
1529 all_links = [] # as many as are required
1531 # we should always return something, even empty, for the context
1532 all_props[(default_cn, default_nodeid)] = {}
1534 keys = form.keys()
1535 timezone = db.getUserTimezone()
1537 # sentinels for the :note and :file props
1538 have_note = have_file = 0
1540 # extract the usable form labels from the form
1541 matches = []
1542 for key in keys:
1543 m = self.FV_SPECIAL.match(key)
1544 if m:
1545 matches.append((key, m.groupdict()))
1547 # now handle the matches
1548 for key, d in matches:
1549 if d['classname']:
1550 # we got a designator
1551 cn = d['classname']
1552 cl = self.db.classes[cn]
1553 nodeid = d['id']
1554 propname = d['propname']
1555 elif d['note']:
1556 # the special note field
1557 cn = 'msg'
1558 cl = self.db.classes[cn]
1559 nodeid = '-1'
1560 propname = 'content'
1561 all_links.append((default_cn, default_nodeid, 'messages',
1562 [('msg', '-1')]))
1563 have_note = 1
1564 elif d['file']:
1565 # the special file field
1566 cn = 'file'
1567 cl = self.db.classes[cn]
1568 nodeid = '-1'
1569 propname = 'content'
1570 all_links.append((default_cn, default_nodeid, 'files',
1571 [('file', '-1')]))
1572 have_file = 1
1573 else:
1574 # default
1575 cn = default_cn
1576 cl = default_cl
1577 nodeid = default_nodeid
1578 propname = d['propname']
1580 # the thing this value relates to is...
1581 this = (cn, nodeid)
1583 # get more info about the class, and the current set of
1584 # form props for it
1585 if not all_propdef.has_key(cn):
1586 all_propdef[cn] = cl.getprops()
1587 propdef = all_propdef[cn]
1588 if not all_props.has_key(this):
1589 all_props[this] = {}
1590 props = all_props[this]
1591 if not got_props.has_key(this):
1592 got_props[this] = {}
1594 # is this a link command?
1595 if d['link']:
1596 value = []
1597 for entry in extractFormList(form[key]):
1598 m = self.FV_DESIGNATOR.match(entry)
1599 if not m:
1600 raise FormError, \
1601 'link "%s" value "%s" not a designator'%(key, entry)
1602 value.append((m.group(1), m.group(2)))
1604 # make sure the link property is valid
1605 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1606 not isinstance(propdef[propname], hyperdb.Link)):
1607 raise FormError, '%s %s is not a link or '\
1608 'multilink property'%(cn, propname)
1610 all_links.append((cn, nodeid, propname, value))
1611 continue
1613 # detect the special ":required" variable
1614 if d['required']:
1615 all_required[this] = extractFormList(form[key])
1616 continue
1618 # see if we're performing a special multilink action
1619 mlaction = 'set'
1620 if d['remove']:
1621 mlaction = 'remove'
1622 elif d['add']:
1623 mlaction = 'add'
1625 # does the property exist?
1626 if not propdef.has_key(propname):
1627 if mlaction != 'set':
1628 raise FormError, 'You have submitted a %s action for'\
1629 ' the property "%s" which doesn\'t exist'%(mlaction,
1630 propname)
1631 # the form element is probably just something we don't care
1632 # about - ignore it
1633 continue
1634 proptype = propdef[propname]
1636 # Get the form value. This value may be a MiniFieldStorage or a list
1637 # of MiniFieldStorages.
1638 value = form[key]
1640 # handle unpacking of the MiniFieldStorage / list form value
1641 if isinstance(proptype, hyperdb.Multilink):
1642 value = extractFormList(value)
1643 else:
1644 # multiple values are not OK
1645 if isinstance(value, type([])):
1646 raise FormError, 'You have submitted more than one value'\
1647 ' for the %s property'%propname
1648 # value might be a file upload...
1649 if not hasattr(value, 'filename') or value.filename is None:
1650 # nope, pull out the value and strip it
1651 value = value.value.strip()
1653 # now that we have the props field, we need a teensy little
1654 # extra bit of help for the old :note field...
1655 if d['note'] and value:
1656 props['author'] = self.db.getuid()
1657 props['date'] = date.Date()
1659 # handle by type now
1660 if isinstance(proptype, hyperdb.Password):
1661 if not value:
1662 # ignore empty password values
1663 continue
1664 for key, d in matches:
1665 if d['confirm'] and d['propname'] == propname:
1666 confirm = form[key]
1667 break
1668 else:
1669 raise FormError, 'Password and confirmation text do '\
1670 'not match'
1671 if isinstance(confirm, type([])):
1672 raise FormError, 'You have submitted more than one value'\
1673 ' for the %s property'%propname
1674 if value != confirm.value:
1675 raise FormError, 'Password and confirmation text do '\
1676 'not match'
1677 value = password.Password(value)
1679 elif isinstance(proptype, hyperdb.Link):
1680 # see if it's the "no selection" choice
1681 if value == '-1' or not value:
1682 # if we're creating, just don't include this property
1683 if not nodeid or nodeid.startswith('-'):
1684 continue
1685 value = None
1686 else:
1687 # handle key values
1688 link = proptype.classname
1689 if not num_re.match(value):
1690 try:
1691 value = db.classes[link].lookup(value)
1692 except KeyError:
1693 raise FormError, _('property "%(propname)s": '
1694 '%(value)s not a %(classname)s')%{
1695 'propname': propname, 'value': value,
1696 'classname': link}
1697 except TypeError, message:
1698 raise FormError, _('you may only enter ID values '
1699 'for property "%(propname)s": %(message)s')%{
1700 'propname': propname, 'message': message}
1701 elif isinstance(proptype, hyperdb.Multilink):
1702 # perform link class key value lookup if necessary
1703 link = proptype.classname
1704 link_cl = db.classes[link]
1705 l = []
1706 for entry in value:
1707 if not entry: continue
1708 if not num_re.match(entry):
1709 try:
1710 entry = link_cl.lookup(entry)
1711 except KeyError:
1712 raise FormError, _('property "%(propname)s": '
1713 '"%(value)s" not an entry of %(classname)s')%{
1714 'propname': propname, 'value': entry,
1715 'classname': link}
1716 except TypeError, message:
1717 raise FormError, _('you may only enter ID values '
1718 'for property "%(propname)s": %(message)s')%{
1719 'propname': propname, 'message': message}
1720 l.append(entry)
1721 l.sort()
1723 # now use that list of ids to modify the multilink
1724 if mlaction == 'set':
1725 value = l
1726 else:
1727 # we're modifying the list - get the current list of ids
1728 if props.has_key(propname):
1729 existing = props[propname]
1730 elif nodeid and not nodeid.startswith('-'):
1731 existing = cl.get(nodeid, propname, [])
1732 else:
1733 existing = []
1735 # now either remove or add
1736 if mlaction == 'remove':
1737 # remove - handle situation where the id isn't in
1738 # the list
1739 for entry in l:
1740 try:
1741 existing.remove(entry)
1742 except ValueError:
1743 raise FormError, _('property "%(propname)s": '
1744 '"%(value)s" not currently in list')%{
1745 'propname': propname, 'value': entry}
1746 else:
1747 # add - easy, just don't dupe
1748 for entry in l:
1749 if entry not in existing:
1750 existing.append(entry)
1751 value = existing
1752 value.sort()
1754 elif value == '':
1755 # if we're creating, just don't include this property
1756 if not nodeid or nodeid.startswith('-'):
1757 continue
1758 # other types should be None'd if there's no value
1759 value = None
1760 else:
1761 # handle ValueErrors for all these in a similar fashion
1762 try:
1763 if isinstance(proptype, hyperdb.String):
1764 if (hasattr(value, 'filename') and
1765 value.filename is not None):
1766 # skip if the upload is empty
1767 if not value.filename:
1768 continue
1769 # this String is actually a _file_
1770 # try to determine the file content-type
1771 fn = value.filename.split('\\')[-1]
1772 if propdef.has_key('name'):
1773 props['name'] = fn
1774 # use this info as the type/filename properties
1775 if propdef.has_key('type'):
1776 props['type'] = mimetypes.guess_type(fn)[0]
1777 if not props['type']:
1778 props['type'] = "application/octet-stream"
1779 # finally, read the content
1780 value = value.value
1781 else:
1782 # normal String fix the CRLF/CR -> LF stuff
1783 value = fixNewlines(value)
1785 elif isinstance(proptype, hyperdb.Date):
1786 value = date.Date(value, offset=timezone)
1787 elif isinstance(proptype, hyperdb.Interval):
1788 value = date.Interval(value)
1789 elif isinstance(proptype, hyperdb.Boolean):
1790 value = value.lower() in ('yes', 'true', 'on', '1')
1791 elif isinstance(proptype, hyperdb.Number):
1792 value = float(value)
1793 except ValueError, msg:
1794 raise FormError, _('Error with %s property: %s')%(
1795 propname, msg)
1797 # register that we got this property
1798 if value:
1799 got_props[this][propname] = 1
1801 # get the old value
1802 if nodeid and not nodeid.startswith('-'):
1803 try:
1804 existing = cl.get(nodeid, propname)
1805 except KeyError:
1806 # this might be a new property for which there is
1807 # no existing value
1808 if not propdef.has_key(propname):
1809 raise
1810 except IndexError, message:
1811 raise FormError(str(message))
1813 # make sure the existing multilink is sorted
1814 if isinstance(proptype, hyperdb.Multilink):
1815 existing.sort()
1817 # "missing" existing values may not be None
1818 if not existing:
1819 if isinstance(proptype, hyperdb.String) and not existing:
1820 # some backends store "missing" Strings as empty strings
1821 existing = None
1822 elif isinstance(proptype, hyperdb.Number) and not existing:
1823 # some backends store "missing" Numbers as 0 :(
1824 existing = 0
1825 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1826 # likewise Booleans
1827 existing = 0
1829 # if changed, set it
1830 if value != existing:
1831 props[propname] = value
1832 else:
1833 # don't bother setting empty/unset values
1834 if value is None:
1835 continue
1836 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1837 continue
1838 elif isinstance(proptype, hyperdb.String) and value == '':
1839 continue
1841 props[propname] = value
1843 # check to see if we need to specially link a file to the note
1844 if have_note and have_file:
1845 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1847 # see if all the required properties have been supplied
1848 s = []
1849 for thing, required in all_required.items():
1850 # register the values we got
1851 got = got_props.get(thing, {})
1852 for entry in required[:]:
1853 if got.has_key(entry):
1854 required.remove(entry)
1856 # any required values not present?
1857 if not required:
1858 continue
1860 # tell the user to entry the values required
1861 if len(required) > 1:
1862 p = 'properties'
1863 else:
1864 p = 'property'
1865 s.append('Required %s %s %s not supplied'%(thing[0], p,
1866 ', '.join(required)))
1867 if s:
1868 raise FormError, '\n'.join(s)
1870 # When creating a FileClass node, it should have a non-empty content
1871 # property to be created. When editing a FileClass node, it should
1872 # either have a non-empty content property or no property at all. In
1873 # the latter case, nothing will change.
1874 for (cn, id), props in all_props.items():
1875 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1876 if id == '-1':
1877 if not props.get('content', ''):
1878 del all_props[(cn, id)]
1879 elif props.has_key('content') and not props['content']:
1880 raise FormError, _('File is empty')
1881 return all_props, all_links
1883 def fixNewlines(text):
1884 ''' Homogenise line endings.
1886 Different web clients send different line ending values, but
1887 other systems (eg. email) don't necessarily handle those line
1888 endings. Our solution is to convert all line endings to LF.
1889 '''
1890 text = text.replace('\r\n', '\n')
1891 return text.replace('\r', '\n')
1893 def extractFormList(value):
1894 ''' Extract a list of values from the form value.
1896 It may be one of:
1897 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1898 MiniFieldStorage('value,value,...')
1899 MiniFieldStorage('value')
1900 '''
1901 # multiple values are OK
1902 if isinstance(value, type([])):
1903 # it's a list of MiniFieldStorages - join then into
1904 values = ','.join([i.value.strip() for i in value])
1905 else:
1906 # it's a MiniFieldStorage, but may be a comma-separated list
1907 # of values
1908 values = value.value
1910 value = [i.strip() for i in values.split(',')]
1912 # filter out the empty bits
1913 return filter(None, value)