1 # $Id: client.py,v 1.119 2003-06-10 22:55:30 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random, MimeWriter, smtplib, socket, quopri
9 import stat, rfc822, string
11 from roundup import roundupdb, date, hyperdb, password, token
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, openSMTPConnection
19 class HTTPException(Exception):
20 pass
21 class Unauthorised(HTTPException):
22 pass
23 class NotFound(HTTPException):
24 pass
25 class Redirect(HTTPException):
26 pass
27 class NotModified(HTTPException):
28 pass
30 # set to indicate to roundup not to actually _send_ email
31 # this var must contain a file to write the mail to
32 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
34 # used by a couple of routines
35 chars = string.letters+string.digits
37 # XXX actually _use_ FormError
38 class FormError(ValueError):
39 ''' An "expected" exception occurred during form parsing.
40 - ie. something we know can go wrong, and don't want to alarm the
41 user with
43 We trap this at the user interface level and feed back a nice error
44 to the user.
45 '''
46 pass
48 class SendFile(Exception):
49 ''' Send a file from the database '''
51 class SendStaticFile(Exception):
52 ''' Send a static file from the instance html directory '''
54 def initialiseSecurity(security):
55 ''' Create some Permissions and Roles on the security object
57 This function is directly invoked by security.Security.__init__()
58 as a part of the Security object instantiation.
59 '''
60 security.addPermission(name="Web Registration",
61 description="User may register through the web")
62 p = security.addPermission(name="Web Access",
63 description="User may access the web interface")
64 security.addPermissionToRole('Admin', p)
66 # doing Role stuff through the web - make sure Admin can
67 p = security.addPermission(name="Web Roles",
68 description="User may manipulate user Roles through the web")
69 security.addPermissionToRole('Admin', p)
71 def clean_message(match, ok={'a':1,'i':1,'b':1,'br':1}):
72 ''' Strip all non <a>,<i>,<b> and <br> tags from a string
73 '''
74 if ok.has_key(match.group(2)):
75 return match.group(1)
76 return '<%s>'%match.group(2)
78 class Client:
79 ''' Instantiate to handle one CGI request.
81 See inner_main for request processing.
83 Client attributes at instantiation:
84 "path" is the PATH_INFO inside the instance (with no leading '/')
85 "base" is the base URL for the instance
86 "form" is the cgi form, an instance of FieldStorage from the standard
87 cgi module
88 "additional_headers" is a dictionary of additional HTTP headers that
89 should be sent to the client
90 "response_code" is the HTTP response code to send to the client
92 During the processing of a request, the following attributes are used:
93 "error_message" holds a list of error messages
94 "ok_message" holds a list of OK messages
95 "session" is the current user session id
96 "user" is the current user's name
97 "userid" is the current user's id
98 "template" is the current :template context
99 "classname" is the current class context name
100 "nodeid" is the current context item id
102 User Identification:
103 If the user has no login cookie, then they are anonymous and are logged
104 in as that user. This typically gives them all Permissions assigned to the
105 Anonymous Role.
107 Once a user logs in, they are assigned a session. The Client instance
108 keeps the nodeid of the session as the "session" attribute.
111 Special form variables:
112 Note that in various places throughout this code, special form
113 variables of the form :<name> are used. The colon (":") part may
114 actually be one of either ":" or "@".
115 '''
117 #
118 # special form variables
119 #
120 FV_TEMPLATE = re.compile(r'[@:]template')
121 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
122 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
124 FV_QUERYNAME = re.compile(r'[@:]queryname')
126 # edit form variable handling (see unit tests)
127 FV_LABELS = r'''
128 ^(
129 (?P<note>[@:]note)|
130 (?P<file>[@:]file)|
131 (
132 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
133 ((?P<required>[@:]required$)| # :required
134 (
135 (
136 (?P<add>[@:]add[@:])| # :add:<prop>
137 (?P<remove>[@:]remove[@:])| # :remove:<prop>
138 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
139 (?P<link>[@:]link[@:])| # :link:<prop>
140 ([@:]) # just a separator
141 )?
142 (?P<propname>[^@:]+) # <prop>
143 )
144 )
145 )
146 )$'''
148 # Note: index page stuff doesn't appear here:
149 # columns, sort, sortdir, filter, group, groupdir, search_text,
150 # pagesize, startwith
152 def __init__(self, instance, request, env, form=None):
153 hyperdb.traceMark()
154 self.instance = instance
155 self.request = request
156 self.env = env
158 # save off the path
159 self.path = env['PATH_INFO']
161 # this is the base URL for this tracker
162 self.base = self.instance.config.TRACKER_WEB
164 # this is the "cookie path" for this tracker (ie. the path part of
165 # the "base" url)
166 self.cookie_path = urlparse.urlparse(self.base)[2]
167 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
168 self.instance.config.TRACKER_NAME)
170 # see if we need to re-parse the environment for the form (eg Zope)
171 if form is None:
172 self.form = cgi.FieldStorage(environ=env)
173 else:
174 self.form = form
176 # turn debugging on/off
177 try:
178 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
179 except ValueError:
180 # someone gave us a non-int debug level, turn it off
181 self.debug = 0
183 # flag to indicate that the HTTP headers have been sent
184 self.headers_done = 0
186 # additional headers to send with the request - must be registered
187 # before the first write
188 self.additional_headers = {}
189 self.response_code = 200
192 def main(self):
193 ''' Wrap the real main in a try/finally so we always close off the db.
194 '''
195 try:
196 self.inner_main()
197 finally:
198 if hasattr(self, 'db'):
199 self.db.close()
201 def inner_main(self):
202 ''' Process a request.
204 The most common requests are handled like so:
205 1. figure out who we are, defaulting to the "anonymous" user
206 see determine_user
207 2. figure out what the request is for - the context
208 see determine_context
209 3. handle any requested action (item edit, search, ...)
210 see handle_action
211 4. render a template, resulting in HTML output
213 In some situations, exceptions occur:
214 - HTTP Redirect (generally raised by an action)
215 - SendFile (generally raised by determine_context)
216 serve up a FileClass "content" property
217 - SendStaticFile (generally raised by determine_context)
218 serve up a file from the tracker "html" directory
219 - Unauthorised (generally raised by an action)
220 the action is cancelled, the request is rendered and an error
221 message is displayed indicating that permission was not
222 granted for the action to take place
223 - NotFound (raised wherever it needs to be)
224 percolates up to the CGI interface that called the client
225 '''
226 self.ok_message = []
227 self.error_message = []
228 try:
229 # make sure we're identified (even anonymously)
230 self.determine_user()
231 # figure out the context and desired content template
232 self.determine_context()
233 # possibly handle a form submit action (may change self.classname
234 # and self.template, and may also append error/ok_messages)
235 self.handle_action()
237 # now render the page
238 # we don't want clients caching our dynamic pages
239 self.additional_headers['Cache-Control'] = 'no-cache'
240 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
241 # self.additional_headers['Pragma'] = 'no-cache'
243 # expire this page 5 seconds from now
244 date = rfc822.formatdate(time.time() + 5)
245 self.additional_headers['Expires'] = date
247 # render the content
248 self.write(self.renderContext())
249 except Redirect, url:
250 # let's redirect - if the url isn't None, then we need to do
251 # the headers, otherwise the headers have been set before the
252 # exception was raised
253 if url:
254 self.additional_headers['Location'] = url
255 self.response_code = 302
256 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
257 except SendFile, designator:
258 self.serve_file(designator)
259 except SendStaticFile, file:
260 try:
261 self.serve_static_file(str(file))
262 except NotModified:
263 # send the 304 response
264 self.request.send_response(304)
265 self.request.end_headers()
266 except Unauthorised, message:
267 self.classname = None
268 self.template = ''
269 self.error_message.append(message)
270 self.write(self.renderContext())
271 except NotFound:
272 # pass through
273 raise
274 except:
275 # everything else
276 self.write(cgitb.html())
278 def clean_sessions(self):
279 ''' Age sessions, remove when they haven't been used for a week.
281 Do it only once an hour.
283 Note: also cleans One Time Keys, and other "session" based
284 stuff.
285 '''
286 sessions = self.db.sessions
287 last_clean = sessions.get('last_clean', 'last_use') or 0
289 week = 60*60*24*7
290 hour = 60*60
291 now = time.time()
292 if now - last_clean > hour:
293 # remove aged sessions
294 for sessid in sessions.list():
295 interval = now - sessions.get(sessid, 'last_use')
296 if interval > week:
297 sessions.destroy(sessid)
298 # remove aged otks
299 otks = self.db.otks
300 for sessid in otks.list():
301 interval = now - otks.get(sessid, '__time')
302 if interval > week:
303 otks.destroy(sessid)
304 sessions.set('last_clean', last_use=time.time())
306 def determine_user(self):
307 ''' Determine who the user is
308 '''
309 # determine the uid to use
310 self.opendb('admin')
311 # clean age sessions
312 self.clean_sessions()
313 # make sure we have the session Class
314 sessions = self.db.sessions
316 # look up the user session cookie
317 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
318 user = 'anonymous'
320 # bump the "revision" of the cookie since the format changed
321 if (cookie.has_key(self.cookie_name) and
322 cookie[self.cookie_name].value != 'deleted'):
324 # get the session key from the cookie
325 self.session = cookie[self.cookie_name].value
326 # get the user from the session
327 try:
328 # update the lifetime datestamp
329 sessions.set(self.session, last_use=time.time())
330 sessions.commit()
331 user = sessions.get(self.session, 'user')
332 except KeyError:
333 user = 'anonymous'
335 # sanity check on the user still being valid, getting the userid
336 # at the same time
337 try:
338 self.userid = self.db.user.lookup(user)
339 except (KeyError, TypeError):
340 user = 'anonymous'
342 # make sure the anonymous user is valid if we're using it
343 if user == 'anonymous':
344 self.make_user_anonymous()
345 else:
346 self.user = user
348 # reopen the database as the correct user
349 self.opendb(self.user)
351 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)'),
352 mc=re.compile(r'(</?(.*?)>)')):
353 ''' Determine the context of this page from the URL:
355 The URL path after the instance identifier is examined. The path
356 is generally only one entry long.
358 - if there is no path, then we are in the "home" context.
359 * if the path is "_file", then the additional path entry
360 specifies the filename of a static file we're to serve up
361 from the instance "html" directory. Raises a SendStaticFile
362 exception.
363 - if there is something in the path (eg "issue"), it identifies
364 the tracker class we're to display.
365 - if the path is an item designator (eg "issue123"), then we're
366 to display a specific item.
367 * if the path starts with an item designator and is longer than
368 one entry, then we're assumed to be handling an item of a
369 FileClass, and the extra path information gives the filename
370 that the client is going to label the download with (ie
371 "file123/image.png" is nicer to download than "file123"). This
372 raises a SendFile exception.
374 Both of the "*" types of contexts stop before we bother to
375 determine the template we're going to use. That's because they
376 don't actually use templates.
378 The template used is specified by the :template CGI variable,
379 which defaults to:
381 only classname suplied: "index"
382 full item designator supplied: "item"
384 We set:
385 self.classname - the class to display, can be None
386 self.template - the template to render the current context with
387 self.nodeid - the nodeid of the class we're displaying
388 '''
389 # default the optional variables
390 self.classname = None
391 self.nodeid = None
393 # see if a template or messages are specified
394 template_override = ok_message = error_message = None
395 for key in self.form.keys():
396 if self.FV_TEMPLATE.match(key):
397 template_override = self.form[key].value
398 elif self.FV_OK_MESSAGE.match(key):
399 ok_message = self.form[key].value
400 ok_message = mc.sub(clean_message, ok_message)
401 elif self.FV_ERROR_MESSAGE.match(key):
402 error_message = self.form[key].value
403 error_message = mc.sub(clean_message, error_message)
405 # determine the classname and possibly nodeid
406 path = self.path.split('/')
407 if not path or path[0] in ('', 'home', 'index'):
408 if template_override is not None:
409 self.template = template_override
410 else:
411 self.template = ''
412 return
413 elif path[0] == '_file':
414 raise SendStaticFile, os.path.join(*path[1:])
415 else:
416 self.classname = path[0]
417 if len(path) > 1:
418 # send the file identified by the designator in path[0]
419 raise SendFile, path[0]
421 # see if we got a designator
422 m = dre.match(self.classname)
423 if m:
424 self.classname = m.group(1)
425 self.nodeid = m.group(2)
426 if not self.db.getclass(self.classname).hasnode(self.nodeid):
427 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
428 # with a designator, we default to item view
429 self.template = 'item'
430 else:
431 # with only a class, we default to index view
432 self.template = 'index'
434 # make sure the classname is valid
435 try:
436 self.db.getclass(self.classname)
437 except KeyError:
438 raise NotFound, self.classname
440 # see if we have a template override
441 if template_override is not None:
442 self.template = template_override
444 # see if we were passed in a message
445 if ok_message:
446 self.ok_message.append(ok_message)
447 if error_message:
448 self.error_message.append(error_message)
450 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
451 ''' Serve the file from the content property of the designated item.
452 '''
453 m = dre.match(str(designator))
454 if not m:
455 raise NotFound, str(designator)
456 classname, nodeid = m.group(1), m.group(2)
457 if classname != 'file':
458 raise NotFound, designator
460 # we just want to serve up the file named
461 file = self.db.file
462 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
463 self.write(file.get(nodeid, 'content'))
465 def serve_static_file(self, file):
466 ims = None
467 # see if there's an if-modified-since...
468 if hasattr(self.request, 'headers'):
469 ims = self.request.headers.getheader('if-modified-since')
470 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
471 # cgi will put the header in the env var
472 ims = self.env['HTTP_IF_MODIFIED_SINCE']
473 filename = os.path.join(self.instance.config.TEMPLATES, file)
474 lmt = os.stat(filename)[stat.ST_MTIME]
475 if ims:
476 ims = rfc822.parsedate(ims)[:6]
477 lmtt = time.gmtime(lmt)[:6]
478 if lmtt <= ims:
479 raise NotModified
481 # we just want to serve up the file named
482 file = str(file)
483 mt = mimetypes.guess_type(file)[0]
484 if not mt:
485 if file.endswith('.css'):
486 mt = 'text/css'
487 else:
488 mt = 'text/plain'
489 self.additional_headers['Content-Type'] = mt
490 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
491 self.write(open(filename, 'rb').read())
493 def renderContext(self):
494 ''' Return a PageTemplate for the named page
495 '''
496 name = self.classname
497 extension = self.template
498 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
500 # catch errors so we can handle PT rendering errors more nicely
501 args = {
502 'ok_message': self.ok_message,
503 'error_message': self.error_message
504 }
505 try:
506 # let the template render figure stuff out
507 return pt.render(self, None, None, **args)
508 except NoTemplate, message:
509 return '<strong>%s</strong>'%message
510 except:
511 # everything else
512 return cgitb.pt_html()
514 # these are the actions that are available
515 actions = (
516 ('edit', 'editItemAction'),
517 ('editcsv', 'editCSVAction'),
518 ('new', 'newItemAction'),
519 ('register', 'registerAction'),
520 ('confrego', 'confRegoAction'),
521 ('passrst', 'passResetAction'),
522 ('login', 'loginAction'),
523 ('logout', 'logout_action'),
524 ('search', 'searchAction'),
525 ('retire', 'retireAction'),
526 ('show', 'showAction'),
527 )
528 def handle_action(self):
529 ''' Determine whether there should be an Action called.
531 The action is defined by the form variable :action which
532 identifies the method on this object to call. The actions
533 are defined in the "actions" sequence on this class.
534 '''
535 if self.form.has_key(':action'):
536 action = self.form[':action'].value.lower()
537 elif self.form.has_key('@action'):
538 action = self.form['@action'].value.lower()
539 else:
540 return None
541 try:
542 # get the action, validate it
543 for name, method in self.actions:
544 if name == action:
545 break
546 else:
547 raise ValueError, 'No such action "%s"'%action
548 # call the mapped action
549 getattr(self, method)()
550 except Redirect:
551 raise
552 except Unauthorised:
553 raise
555 def write(self, content):
556 if not self.headers_done:
557 self.header()
558 self.request.wfile.write(content)
560 def header(self, headers=None, response=None):
561 '''Put up the appropriate header.
562 '''
563 if headers is None:
564 headers = {'Content-Type':'text/html'}
565 if response is None:
566 response = self.response_code
568 # update with additional info
569 headers.update(self.additional_headers)
571 if not headers.has_key('Content-Type'):
572 headers['Content-Type'] = 'text/html'
573 self.request.send_response(response)
574 for entry in headers.items():
575 self.request.send_header(*entry)
576 self.request.end_headers()
577 self.headers_done = 1
578 if self.debug:
579 self.headers_sent = headers
581 def set_cookie(self, user):
582 ''' Set up a session cookie for the user and store away the user's
583 login info against the session.
584 '''
585 # TODO generate a much, much stronger session key ;)
586 self.session = binascii.b2a_base64(repr(random.random())).strip()
588 # clean up the base64
589 if self.session[-1] == '=':
590 if self.session[-2] == '=':
591 self.session = self.session[:-2]
592 else:
593 self.session = self.session[:-1]
595 # insert the session in the sessiondb
596 self.db.sessions.set(self.session, user=user, last_use=time.time())
598 # and commit immediately
599 self.db.sessions.commit()
601 # expire us in a long, long time
602 expire = Cookie._getdate(86400*365)
604 # generate the cookie path - make sure it has a trailing '/'
605 self.additional_headers['Set-Cookie'] = \
606 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
607 expire, self.cookie_path)
609 def make_user_anonymous(self):
610 ''' Make us anonymous
612 This method used to handle non-existence of the 'anonymous'
613 user, but that user is mandatory now.
614 '''
615 self.userid = self.db.user.lookup('anonymous')
616 self.user = 'anonymous'
618 def opendb(self, user):
619 ''' Open the database.
620 '''
621 # open the db if the user has changed
622 if not hasattr(self, 'db') or user != self.db.journaltag:
623 if hasattr(self, 'db'):
624 self.db.close()
625 self.db = self.instance.open(user)
627 #
628 # Actions
629 #
630 def loginAction(self):
631 ''' Attempt to log a user in.
633 Sets up a session for the user which contains the login
634 credentials.
635 '''
636 # we need the username at a minimum
637 if not self.form.has_key('__login_name'):
638 self.error_message.append(_('Username required'))
639 return
641 # get the login info
642 self.user = self.form['__login_name'].value
643 if self.form.has_key('__login_password'):
644 password = self.form['__login_password'].value
645 else:
646 password = ''
648 # make sure the user exists
649 try:
650 self.userid = self.db.user.lookup(self.user)
651 except KeyError:
652 name = self.user
653 self.error_message.append(_('No such user "%(name)s"')%locals())
654 self.make_user_anonymous()
655 return
657 # verify the password
658 if not self.verifyPassword(self.userid, password):
659 self.make_user_anonymous()
660 self.error_message.append(_('Incorrect password'))
661 return
663 # make sure we're allowed to be here
664 if not self.loginPermission():
665 self.make_user_anonymous()
666 self.error_message.append(_("You do not have permission to login"))
667 return
669 # now we're OK, re-open the database for real, using the user
670 self.opendb(self.user)
672 # set the session cookie
673 self.set_cookie(self.user)
675 def verifyPassword(self, userid, password):
676 ''' Verify the password that the user has supplied
677 '''
678 stored = self.db.user.get(self.userid, 'password')
679 if password == stored:
680 return 1
681 if not password and not stored:
682 return 1
683 return 0
685 def loginPermission(self):
686 ''' Determine whether the user has permission to log in.
688 Base behaviour is to check the user has "Web Access".
689 '''
690 if not self.db.security.hasPermission('Web Access', self.userid):
691 return 0
692 return 1
694 def logout_action(self):
695 ''' Make us really anonymous - nuke the cookie too
696 '''
697 # log us out
698 self.make_user_anonymous()
700 # construct the logout cookie
701 now = Cookie._getdate()
702 self.additional_headers['Set-Cookie'] = \
703 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
704 now, self.cookie_path)
706 # Let the user know what's going on
707 self.ok_message.append(_('You are logged out'))
709 def registerAction(self):
710 '''Attempt to create a new user based on the contents of the form
711 and then set the cookie.
713 return 1 on successful login
714 '''
715 # parse the props from the form
716 try:
717 props = self.parsePropsFromForm()[0][('user', None)]
718 except (ValueError, KeyError), message:
719 self.error_message.append(_('Error: ') + str(message))
720 return
722 # make sure we're allowed to register
723 if not self.registerPermission(props):
724 raise Unauthorised, _("You do not have permission to register")
726 try:
727 self.db.user.lookup(props['username'])
728 self.error_message.append('Error: A user with the username "%s" '
729 'already exists'%props['username'])
730 return
731 except KeyError:
732 pass
734 # generate the one-time-key and store the props for later
735 otk = ''.join([random.choice(chars) for x in range(32)])
736 for propname, proptype in self.db.user.getprops().items():
737 value = props.get(propname, None)
738 if value is None:
739 pass
740 elif isinstance(proptype, hyperdb.Date):
741 props[propname] = str(value)
742 elif isinstance(proptype, hyperdb.Interval):
743 props[propname] = str(value)
744 elif isinstance(proptype, hyperdb.Password):
745 props[propname] = str(value)
746 props['__time'] = time.time()
747 self.db.otks.set(otk, **props)
749 # send the email
750 tracker_name = self.db.config.TRACKER_NAME
751 subject = 'Complete your registration to %s'%tracker_name
752 body = '''
753 To complete your registration of the user "%(name)s" with %(tracker)s,
754 please visit the following URL:
756 %(url)s?@action=confrego&otk=%(otk)s
757 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
758 'otk': otk}
759 if not self.sendEmail(props['address'], subject, body):
760 return
762 # commit changes to the database
763 self.db.commit()
765 # redirect to the "you're almost there" page
766 raise Redirect, '%suser?@template=rego_progress'%self.base
768 def sendEmail(self, to, subject, content):
769 # send email to the user's email address
770 message = StringIO.StringIO()
771 writer = MimeWriter.MimeWriter(message)
772 tracker_name = self.db.config.TRACKER_NAME
773 writer.addheader('Subject', encode_header(subject))
774 writer.addheader('To', to)
775 writer.addheader('From', roundupdb.straddr((tracker_name,
776 self.db.config.ADMIN_EMAIL)))
777 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
778 time.gmtime()))
779 # add a uniquely Roundup header to help filtering
780 writer.addheader('X-Roundup-Name', tracker_name)
781 # avoid email loops
782 writer.addheader('X-Roundup-Loop', 'hello')
783 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
784 body = writer.startbody('text/plain; charset=utf-8')
786 # message body, encoded quoted-printable
787 content = StringIO.StringIO(content)
788 quopri.encode(content, body, 0)
790 if SENDMAILDEBUG:
791 # don't send - just write to a file
792 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
793 self.db.config.ADMIN_EMAIL,
794 ', '.join(to),message.getvalue()))
795 else:
796 # now try to send the message
797 try:
798 # send the message as admin so bounces are sent there
799 # instead of to roundup
800 smtp = openSMTPConnection(self.db.config)
801 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
802 message.getvalue())
803 except socket.error, value:
804 self.error_message.append("Error: couldn't send email: "
805 "mailhost %s"%value)
806 return 0
807 except smtplib.SMTPException, msg:
808 self.error_message.append("Error: couldn't send email: %s"%msg)
809 return 0
810 return 1
812 def registerPermission(self, props):
813 ''' Determine whether the user has permission to register
815 Base behaviour is to check the user has "Web Registration".
816 '''
817 # registration isn't allowed to supply roles
818 if props.has_key('roles'):
819 return 0
820 if self.db.security.hasPermission('Web Registration', self.userid):
821 return 1
822 return 0
824 def confRegoAction(self):
825 ''' Grab the OTK, use it to load up the new user details
826 '''
827 # pull the rego information out of the otk database
828 otk = self.form['otk'].value
829 props = self.db.otks.getall(otk)
830 for propname, proptype in self.db.user.getprops().items():
831 value = props.get(propname, None)
832 if value is None:
833 pass
834 elif isinstance(proptype, hyperdb.Date):
835 props[propname] = date.Date(value)
836 elif isinstance(proptype, hyperdb.Interval):
837 props[propname] = date.Interval(value)
838 elif isinstance(proptype, hyperdb.Password):
839 props[propname] = password.Password()
840 props[propname].unpack(value)
842 # re-open the database as "admin"
843 if self.user != 'admin':
844 self.opendb('admin')
846 # create the new user
847 cl = self.db.user
848 # XXX we need to make the "default" page be able to display errors!
849 try:
850 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
851 del props['__time']
852 self.userid = cl.create(**props)
853 # clear the props from the otk database
854 self.db.otks.destroy(otk)
855 self.db.commit()
856 except (ValueError, KeyError), message:
857 self.error_message.append(str(message))
858 return
860 # log the new user in
861 self.user = cl.get(self.userid, 'username')
862 # re-open the database for real, using the user
863 self.opendb(self.user)
865 # if we have a session, update it
866 if hasattr(self, 'session'):
867 self.db.sessions.set(self.session, user=self.user,
868 last_use=time.time())
869 else:
870 # new session cookie
871 self.set_cookie(self.user)
873 # nice message
874 message = _('You are now registered, welcome!')
876 # redirect to the user's page
877 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
878 self.userid, urllib.quote(message))
880 def passResetAction(self):
881 ''' Handle password reset requests.
883 Presence of either "name" or "address" generate email.
884 Presense of "otk" performs the reset.
885 '''
886 if self.form.has_key('otk'):
887 # pull the rego information out of the otk database
888 otk = self.form['otk'].value
889 uid = self.db.otks.get(otk, 'uid')
890 if uid is None:
891 self.error_message.append('Invalid One Time Key!')
892 return
894 # re-open the database as "admin"
895 if self.user != 'admin':
896 self.opendb('admin')
898 # change the password
899 newpw = password.generatePassword()
901 cl = self.db.user
902 # XXX we need to make the "default" page be able to display errors!
903 try:
904 # set the password
905 cl.set(uid, password=password.Password(newpw))
906 # clear the props from the otk database
907 self.db.otks.destroy(otk)
908 self.db.commit()
909 except (ValueError, KeyError), message:
910 self.error_message.append(str(message))
911 return
913 # user info
914 address = self.db.user.get(uid, 'address')
915 name = self.db.user.get(uid, 'username')
917 # send the email
918 tracker_name = self.db.config.TRACKER_NAME
919 subject = 'Password reset for %s'%tracker_name
920 body = '''
921 The password has been reset for username "%(name)s".
923 Your password is now: %(password)s
924 '''%{'name': name, 'password': newpw}
925 if not self.sendEmail(address, subject, body):
926 return
928 self.ok_message.append('Password reset and email sent to %s'%address)
929 return
931 # no OTK, so now figure the user
932 if self.form.has_key('username'):
933 name = self.form['username'].value
934 try:
935 uid = self.db.user.lookup(name)
936 except KeyError:
937 self.error_message.append('Unknown username')
938 return
939 address = self.db.user.get(uid, 'address')
940 elif self.form.has_key('address'):
941 address = self.form['address'].value
942 uid = uidFromAddress(self.db, ('', address), create=0)
943 if not uid:
944 self.error_message.append('Unknown email address')
945 return
946 name = self.db.user.get(uid, 'username')
947 else:
948 self.error_message.append('You need to specify a username '
949 'or address')
950 return
952 # generate the one-time-key and store the props for later
953 otk = ''.join([random.choice(chars) for x in range(32)])
954 self.db.otks.set(otk, uid=uid, __time=time.time())
956 # send the email
957 tracker_name = self.db.config.TRACKER_NAME
958 subject = 'Confirm reset of password for %s'%tracker_name
959 body = '''
960 Someone, perhaps you, has requested that the password be changed for your
961 username, "%(name)s". If you wish to proceed with the change, please follow
962 the link below:
964 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
966 You should then receive another email with the new password.
967 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
968 if not self.sendEmail(address, subject, body):
969 return
971 self.ok_message.append('Email sent to %s'%address)
973 def editItemAction(self):
974 ''' Perform an edit of an item in the database.
976 See parsePropsFromForm and _editnodes for special variables
977 '''
978 # parse the props from the form
979 try:
980 props, links = self.parsePropsFromForm()
981 except (ValueError, KeyError), message:
982 self.error_message.append(_('Error: ') + str(message))
983 return
985 # handle the props
986 try:
987 message = self._editnodes(props, links)
988 except (ValueError, KeyError, IndexError), message:
989 self.error_message.append(_('Error: ') + str(message))
990 return
992 # commit now that all the tricky stuff is done
993 self.db.commit()
995 # redirect to the item's edit page
996 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
997 self.classname, self.nodeid, urllib.quote(message),
998 urllib.quote(self.template))
1000 def editItemPermission(self, props):
1001 ''' Determine whether the user has permission to edit this item.
1003 Base behaviour is to check the user can edit this class. If we're
1004 editing the "user" class, users are allowed to edit their own
1005 details. Unless it's the "roles" property, which requires the
1006 special Permission "Web Roles".
1007 '''
1008 # if this is a user node and the user is editing their own node, then
1009 # we're OK
1010 has = self.db.security.hasPermission
1011 if self.classname == 'user':
1012 # reject if someone's trying to edit "roles" and doesn't have the
1013 # right permission.
1014 if props.has_key('roles') and not has('Web Roles', self.userid,
1015 'user'):
1016 return 0
1017 # if the item being edited is the current user, we're ok
1018 if self.nodeid == self.userid:
1019 return 1
1020 if self.db.security.hasPermission('Edit', self.userid, self.classname):
1021 return 1
1022 return 0
1024 def newItemAction(self):
1025 ''' Add a new item to the database.
1027 This follows the same form as the editItemAction, with the same
1028 special form values.
1029 '''
1030 # parse the props from the form
1031 try:
1032 props, links = self.parsePropsFromForm()
1033 except (ValueError, KeyError), message:
1034 self.error_message.append(_('Error: ') + str(message))
1035 return
1037 # handle the props - edit or create
1038 try:
1039 # when it hits the None element, it'll set self.nodeid
1040 messages = self._editnodes(props, links)
1042 except (ValueError, KeyError, IndexError), message:
1043 # these errors might just be indicative of user dumbness
1044 self.error_message.append(_('Error: ') + str(message))
1045 return
1047 # commit now that all the tricky stuff is done
1048 self.db.commit()
1050 # redirect to the new item's page
1051 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1052 self.classname, self.nodeid, urllib.quote(messages),
1053 urllib.quote(self.template))
1055 def newItemPermission(self, props):
1056 ''' Determine whether the user has permission to create (edit) this
1057 item.
1059 Base behaviour is to check the user can edit this class. No
1060 additional property checks are made. Additionally, new user items
1061 may be created if the user has the "Web Registration" Permission.
1062 '''
1063 has = self.db.security.hasPermission
1064 if self.classname == 'user' and has('Web Registration', self.userid,
1065 'user'):
1066 return 1
1067 if has('Edit', self.userid, self.classname):
1068 return 1
1069 return 0
1072 #
1073 # Utility methods for editing
1074 #
1075 def _editnodes(self, all_props, all_links, newids=None):
1076 ''' Use the props in all_props to perform edit and creation, then
1077 use the link specs in all_links to do linking.
1078 '''
1079 # figure dependencies and re-work links
1080 deps = {}
1081 links = {}
1082 for cn, nodeid, propname, vlist in all_links:
1083 if not all_props.has_key((cn, nodeid)):
1084 # link item to link to doesn't (and won't) exist
1085 continue
1086 for value in vlist:
1087 if not all_props.has_key(value):
1088 # link item to link to doesn't (and won't) exist
1089 continue
1090 deps.setdefault((cn, nodeid), []).append(value)
1091 links.setdefault(value, []).append((cn, nodeid, propname))
1093 # figure chained dependencies ordering
1094 order = []
1095 done = {}
1096 # loop detection
1097 change = 0
1098 while len(all_props) != len(done):
1099 for needed in all_props.keys():
1100 if done.has_key(needed):
1101 continue
1102 tlist = deps.get(needed, [])
1103 for target in tlist:
1104 if not done.has_key(target):
1105 break
1106 else:
1107 done[needed] = 1
1108 order.append(needed)
1109 change = 1
1110 if not change:
1111 raise ValueError, 'linking must not loop!'
1113 # now, edit / create
1114 m = []
1115 for needed in order:
1116 props = all_props[needed]
1117 if not props:
1118 # nothing to do
1119 continue
1120 cn, nodeid = needed
1122 if nodeid is not None and int(nodeid) > 0:
1123 # make changes to the node
1124 props = self._changenode(cn, nodeid, props)
1126 # and some nice feedback for the user
1127 if props:
1128 info = ', '.join(props.keys())
1129 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1130 else:
1131 m.append('%s %s - nothing changed'%(cn, nodeid))
1132 else:
1133 assert props
1135 # make a new node
1136 newid = self._createnode(cn, props)
1137 if nodeid is None:
1138 self.nodeid = newid
1139 nodeid = newid
1141 # and some nice feedback for the user
1142 m.append('%s %s created'%(cn, newid))
1144 # fill in new ids in links
1145 if links.has_key(needed):
1146 for linkcn, linkid, linkprop in links[needed]:
1147 props = all_props[(linkcn, linkid)]
1148 cl = self.db.classes[linkcn]
1149 propdef = cl.getprops()[linkprop]
1150 if not props.has_key(linkprop):
1151 if linkid is None or linkid.startswith('-'):
1152 # linking to a new item
1153 if isinstance(propdef, hyperdb.Multilink):
1154 props[linkprop] = [newid]
1155 else:
1156 props[linkprop] = newid
1157 else:
1158 # linking to an existing item
1159 if isinstance(propdef, hyperdb.Multilink):
1160 existing = cl.get(linkid, linkprop)[:]
1161 existing.append(nodeid)
1162 props[linkprop] = existing
1163 else:
1164 props[linkprop] = newid
1166 return '<br>'.join(m)
1168 def _changenode(self, cn, nodeid, props):
1169 ''' change the node based on the contents of the form
1170 '''
1171 # check for permission
1172 if not self.editItemPermission(props):
1173 raise Unauthorised, 'You do not have permission to edit %s'%cn
1175 # make the changes
1176 cl = self.db.classes[cn]
1177 return cl.set(nodeid, **props)
1179 def _createnode(self, cn, props):
1180 ''' create a node based on the contents of the form
1181 '''
1182 # check for permission
1183 if not self.newItemPermission(props):
1184 raise Unauthorised, 'You do not have permission to create %s'%cn
1186 # create the node and return its id
1187 cl = self.db.classes[cn]
1188 return cl.create(**props)
1190 #
1191 # More actions
1192 #
1193 def editCSVAction(self):
1194 ''' Performs an edit of all of a class' items in one go.
1196 The "rows" CGI var defines the CSV-formatted entries for the
1197 class. New nodes are identified by the ID 'X' (or any other
1198 non-existent ID) and removed lines are retired.
1199 '''
1200 # this is per-class only
1201 if not self.editCSVPermission():
1202 self.error_message.append(
1203 _('You do not have permission to edit %s' %self.classname))
1205 # get the CSV module
1206 try:
1207 import csv
1208 except ImportError:
1209 self.error_message.append(_(
1210 'Sorry, you need the csv module to use this function.<br>\n'
1211 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1212 return
1214 cl = self.db.classes[self.classname]
1215 idlessprops = cl.getprops(protected=0).keys()
1216 idlessprops.sort()
1217 props = ['id'] + idlessprops
1219 # do the edit
1220 rows = self.form['rows'].value.splitlines()
1221 p = csv.parser()
1222 found = {}
1223 line = 0
1224 for row in rows[1:]:
1225 line += 1
1226 values = p.parse(row)
1227 # not a complete row, keep going
1228 if not values: continue
1230 # skip property names header
1231 if values == props:
1232 continue
1234 # extract the nodeid
1235 nodeid, values = values[0], values[1:]
1236 found[nodeid] = 1
1238 # see if the node exists
1239 if cl.hasnode(nodeid):
1240 exists = 1
1241 else:
1242 exists = 0
1244 # confirm correct weight
1245 if len(idlessprops) != len(values):
1246 self.error_message.append(
1247 _('Not enough values on line %(line)s')%{'line':line})
1248 return
1250 # extract the new values
1251 d = {}
1252 for name, value in zip(idlessprops, values):
1253 prop = cl.properties[name]
1254 value = value.strip()
1255 # only add the property if it has a value
1256 if value:
1257 # if it's a multilink, split it
1258 if isinstance(prop, hyperdb.Multilink):
1259 value = value.split(':')
1260 d[name] = value
1261 elif exists:
1262 # nuke the existing value
1263 if isinstance(prop, hyperdb.Multilink):
1264 d[name] = []
1265 else:
1266 d[name] = None
1268 # perform the edit
1269 if exists:
1270 # edit existing
1271 cl.set(nodeid, **d)
1272 else:
1273 # new node
1274 found[cl.create(**d)] = 1
1276 # retire the removed entries
1277 for nodeid in cl.list():
1278 if not found.has_key(nodeid):
1279 cl.retire(nodeid)
1281 # all OK
1282 self.db.commit()
1284 self.ok_message.append(_('Items edited OK'))
1286 def editCSVPermission(self):
1287 ''' Determine whether the user has permission to edit this class.
1289 Base behaviour is to check the user can edit this class.
1290 '''
1291 if not self.db.security.hasPermission('Edit', self.userid,
1292 self.classname):
1293 return 0
1294 return 1
1296 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1297 ''' Mangle some of the form variables.
1299 Set the form ":filter" variable based on the values of the
1300 filter variables - if they're set to anything other than
1301 "dontcare" then add them to :filter.
1303 Handle the ":queryname" variable and save off the query to
1304 the user's query list.
1306 Split any String query values on whitespace and comma.
1307 '''
1308 # generic edit is per-class only
1309 if not self.searchPermission():
1310 self.error_message.append(
1311 _('You do not have permission to search %s' %self.classname))
1313 # add a faked :filter form variable for each filtering prop
1314 props = self.db.classes[self.classname].getprops()
1315 queryname = ''
1316 for key in self.form.keys():
1317 # special vars
1318 if self.FV_QUERYNAME.match(key):
1319 queryname = self.form[key].value.strip()
1320 continue
1322 if not props.has_key(key):
1323 continue
1324 if isinstance(self.form[key], type([])):
1325 # search for at least one entry which is not empty
1326 for minifield in self.form[key]:
1327 if minifield.value:
1328 break
1329 else:
1330 continue
1331 else:
1332 if not self.form[key].value:
1333 continue
1334 if isinstance(props[key], hyperdb.String):
1335 v = self.form[key].value
1336 l = token.token_split(v)
1337 if len(l) > 1 or l[0] != v:
1338 self.form.value.remove(self.form[key])
1339 # replace the single value with the split list
1340 for v in l:
1341 self.form.value.append(cgi.MiniFieldStorage(key, v))
1343 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1345 # handle saving the query params
1346 if queryname:
1347 # parse the environment and figure what the query _is_
1348 req = HTMLRequest(self)
1349 url = req.indexargs_href('', {})
1351 # handle editing an existing query
1352 try:
1353 qid = self.db.query.lookup(queryname)
1354 self.db.query.set(qid, klass=self.classname, url=url)
1355 except KeyError:
1356 # create a query
1357 qid = self.db.query.create(name=queryname,
1358 klass=self.classname, url=url)
1360 # and add it to the user's query multilink
1361 queries = self.db.user.get(self.userid, 'queries')
1362 queries.append(qid)
1363 self.db.user.set(self.userid, queries=queries)
1365 # commit the query change to the database
1366 self.db.commit()
1368 def searchPermission(self):
1369 ''' Determine whether the user has permission to search this class.
1371 Base behaviour is to check the user can view this class.
1372 '''
1373 if not self.db.security.hasPermission('View', self.userid,
1374 self.classname):
1375 return 0
1376 return 1
1379 def retireAction(self):
1380 ''' Retire the context item.
1381 '''
1382 # if we want to view the index template now, then unset the nodeid
1383 # context info (a special-case for retire actions on the index page)
1384 nodeid = self.nodeid
1385 if self.template == 'index':
1386 self.nodeid = None
1388 # generic edit is per-class only
1389 if not self.retirePermission():
1390 self.error_message.append(
1391 _('You do not have permission to retire %s' %self.classname))
1392 return
1394 # make sure we don't try to retire admin or anonymous
1395 if self.classname == 'user' and \
1396 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1397 self.error_message.append(
1398 _('You may not retire the admin or anonymous user'))
1399 return
1401 # do the retire
1402 self.db.getclass(self.classname).retire(nodeid)
1403 self.db.commit()
1405 self.ok_message.append(
1406 _('%(classname)s %(itemid)s has been retired')%{
1407 'classname': self.classname.capitalize(), 'itemid': nodeid})
1409 def retirePermission(self):
1410 ''' Determine whether the user has permission to retire this class.
1412 Base behaviour is to check the user can edit this class.
1413 '''
1414 if not self.db.security.hasPermission('Edit', self.userid,
1415 self.classname):
1416 return 0
1417 return 1
1420 def showAction(self, typere=re.compile('[@:]type'),
1421 numre=re.compile('[@:]number')):
1422 ''' Show a node of a particular class/id
1423 '''
1424 t = n = ''
1425 for key in self.form.keys():
1426 if typere.match(key):
1427 t = self.form[key].value.strip()
1428 elif numre.match(key):
1429 n = self.form[key].value.strip()
1430 if not t:
1431 raise ValueError, 'Invalid %s number'%t
1432 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1433 raise Redirect, url
1435 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1436 ''' Item properties and their values are edited with html FORM
1437 variables and their values. You can:
1439 - Change the value of some property of the current item.
1440 - Create a new item of any class, and edit the new item's
1441 properties,
1442 - Attach newly created items to a multilink property of the
1443 current item.
1444 - Remove items from a multilink property of the current item.
1445 - Specify that some properties are required for the edit
1446 operation to be successful.
1448 In the following, <bracketed> values are variable, "@" may be
1449 either ":" or "@", and other text "required" is fixed.
1451 Most properties are specified as form variables:
1453 <propname>
1454 - property on the current context item
1456 <designator>"@"<propname>
1457 - property on the indicated item (for editing related
1458 information)
1460 Designators name a specific item of a class.
1462 <classname><N>
1464 Name an existing item of class <classname>.
1466 <classname>"-"<N>
1468 Name the <N>th new item of class <classname>. If the form
1469 submission is successful, a new item of <classname> is
1470 created. Within the submitted form, a particular
1471 designator of this form always refers to the same new
1472 item.
1474 Once we have determined the "propname", we look at it to see
1475 if it's special:
1477 @required
1478 The associated form value is a comma-separated list of
1479 property names that must be specified when the form is
1480 submitted for the edit operation to succeed.
1482 When the <designator> is missing, the properties are
1483 for the current context item. When <designator> is
1484 present, they are for the item specified by
1485 <designator>.
1487 The "@required" specifier must come before any of the
1488 properties it refers to are assigned in the form.
1490 @remove@<propname>=id(s) or @add@<propname>=id(s)
1491 The "@add@" and "@remove@" edit actions apply only to
1492 Multilink properties. The form value must be a
1493 comma-separate list of keys for the class specified by
1494 the simple form variable. The listed items are added
1495 to (respectively, removed from) the specified
1496 property.
1498 @link@<propname>=<designator>
1499 If the edit action is "@link@", the simple form
1500 variable must specify a Link or Multilink property.
1501 The form value is a comma-separated list of
1502 designators. The item corresponding to each
1503 designator is linked to the property given by simple
1504 form variable.
1506 XXX Used to add a link to new items created during edit.
1507 XXX These are collected up and returned in all_links. This will
1508 XXX result in an additional linking operation (either Link set or
1509 XXX Multilink append) after the edit/create is done using
1510 XXX all_props in _editnodes. The <propname> on the current item
1511 XXX will be set/appended the id of the newly created item of
1512 XXX class <designator> (where <designator> must be
1513 XXX <classname>-<N>).
1515 None of the above (ie. just a simple form value)
1516 The value of the form variable is converted
1517 appropriately, depending on the type of the property.
1519 For a Link('klass') property, the form value is a
1520 single key for 'klass', where the key field is
1521 specified in dbinit.py.
1523 For a Multilink('klass') property, the form value is a
1524 comma-separated list of keys for 'klass', where the
1525 key field is specified in dbinit.py.
1527 Note that for simple-form-variables specifiying Link
1528 and Multilink properties, the linked-to class must
1529 have a key field.
1531 For a String() property specifying a filename, the
1532 file named by the form value is uploaded. This means we
1533 try to set additional properties "filename" and "type" (if
1534 they are valid for the class). Otherwise, the property
1535 is set to the form value.
1537 For Date(), Interval(), Boolean(), and Number()
1538 properties, the form value is converted to the
1539 appropriate
1541 Any of the form variables may be prefixed with a classname or
1542 designator.
1544 Two special form values are supported for backwards
1545 compatibility:
1547 @note
1548 This is equivalent to::
1550 @link@messages=msg-1
1551 @msg-1@content=value
1553 except that in addition, the "author" and "date"
1554 properties of "msg-1" are set to the userid of the
1555 submitter, and the current time, respectively.
1557 @file
1558 This is equivalent to::
1560 @link@files=file-1
1561 @file-1@content=value
1563 The String content value is handled as described above for
1564 file uploads.
1566 If both the "@note" and "@file" form variables are
1567 specified, the action::
1569 @link@msg-1@files=file-1
1571 is also performed.
1573 We also check that FileClass items have a "content" property with
1574 actual content, otherwise we remove them from all_props before
1575 returning.
1577 The return from this method is a dict of
1578 (classname, id): properties
1579 ... this dict _always_ has an entry for the current context,
1580 even if it's empty (ie. a submission for an existing issue that
1581 doesn't result in any changes would return {('issue','123'): {}})
1582 The id may be None, which indicates that an item should be
1583 created.
1584 '''
1585 # some very useful variables
1586 db = self.db
1587 form = self.form
1589 if not hasattr(self, 'FV_SPECIAL'):
1590 # generate the regexp for handling special form values
1591 classes = '|'.join(db.classes.keys())
1592 # specials for parsePropsFromForm
1593 # handle the various forms (see unit tests)
1594 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1595 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1597 # these indicate the default class / item
1598 default_cn = self.classname
1599 default_cl = self.db.classes[default_cn]
1600 default_nodeid = self.nodeid
1602 # we'll store info about the individual class/item edit in these
1603 all_required = {} # one entry per class/item
1604 all_props = {} # one entry per class/item
1605 all_propdef = {} # note - only one entry per class
1606 all_links = [] # as many as are required
1608 # we should always return something, even empty, for the context
1609 all_props[(default_cn, default_nodeid)] = {}
1611 keys = form.keys()
1612 timezone = db.getUserTimezone()
1614 # sentinels for the :note and :file props
1615 have_note = have_file = 0
1617 # extract the usable form labels from the form
1618 matches = []
1619 for key in keys:
1620 m = self.FV_SPECIAL.match(key)
1621 if m:
1622 matches.append((key, m.groupdict()))
1624 # now handle the matches
1625 for key, d in matches:
1626 if d['classname']:
1627 # we got a designator
1628 cn = d['classname']
1629 cl = self.db.classes[cn]
1630 nodeid = d['id']
1631 propname = d['propname']
1632 elif d['note']:
1633 # the special note field
1634 cn = 'msg'
1635 cl = self.db.classes[cn]
1636 nodeid = '-1'
1637 propname = 'content'
1638 all_links.append((default_cn, default_nodeid, 'messages',
1639 [('msg', '-1')]))
1640 have_note = 1
1641 elif d['file']:
1642 # the special file field
1643 cn = 'file'
1644 cl = self.db.classes[cn]
1645 nodeid = '-1'
1646 propname = 'content'
1647 all_links.append((default_cn, default_nodeid, 'files',
1648 [('file', '-1')]))
1649 have_file = 1
1650 else:
1651 # default
1652 cn = default_cn
1653 cl = default_cl
1654 nodeid = default_nodeid
1655 propname = d['propname']
1657 # the thing this value relates to is...
1658 this = (cn, nodeid)
1660 # get more info about the class, and the current set of
1661 # form props for it
1662 if not all_propdef.has_key(cn):
1663 all_propdef[cn] = cl.getprops()
1664 propdef = all_propdef[cn]
1665 if not all_props.has_key(this):
1666 all_props[this] = {}
1667 props = all_props[this]
1669 # is this a link command?
1670 if d['link']:
1671 value = []
1672 for entry in extractFormList(form[key]):
1673 m = self.FV_DESIGNATOR.match(entry)
1674 if not m:
1675 raise ValueError, \
1676 'link "%s" value "%s" not a designator'%(key, entry)
1677 value.append((m.group(1), m.group(2)))
1679 # make sure the link property is valid
1680 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1681 not isinstance(propdef[propname], hyperdb.Link)):
1682 raise ValueError, '%s %s is not a link or '\
1683 'multilink property'%(cn, propname)
1685 all_links.append((cn, nodeid, propname, value))
1686 continue
1688 # detect the special ":required" variable
1689 if d['required']:
1690 all_required[this] = extractFormList(form[key])
1691 continue
1693 # get the required values list
1694 if not all_required.has_key(this):
1695 all_required[this] = []
1696 required = all_required[this]
1698 # see if we're performing a special multilink action
1699 mlaction = 'set'
1700 if d['remove']:
1701 mlaction = 'remove'
1702 elif d['add']:
1703 mlaction = 'add'
1705 # does the property exist?
1706 if not propdef.has_key(propname):
1707 if mlaction != 'set':
1708 raise ValueError, 'You have submitted a %s action for'\
1709 ' the property "%s" which doesn\'t exist'%(mlaction,
1710 propname)
1711 # the form element is probably just something we don't care
1712 # about - ignore it
1713 continue
1714 proptype = propdef[propname]
1716 # Get the form value. This value may be a MiniFieldStorage or a list
1717 # of MiniFieldStorages.
1718 value = form[key]
1720 # handle unpacking of the MiniFieldStorage / list form value
1721 if isinstance(proptype, hyperdb.Multilink):
1722 value = extractFormList(value)
1723 else:
1724 # multiple values are not OK
1725 if isinstance(value, type([])):
1726 raise ValueError, 'You have submitted more than one value'\
1727 ' for the %s property'%propname
1728 # value might be a file upload...
1729 if not hasattr(value, 'filename') or value.filename is None:
1730 # nope, pull out the value and strip it
1731 value = value.value.strip()
1733 # now that we have the props field, we need a teensy little
1734 # extra bit of help for the old :note field...
1735 if d['note'] and value:
1736 props['author'] = self.db.getuid()
1737 props['date'] = date.Date()
1739 # handle by type now
1740 if isinstance(proptype, hyperdb.Password):
1741 if not value:
1742 # ignore empty password values
1743 continue
1744 for key, d in matches:
1745 if d['confirm'] and d['propname'] == propname:
1746 confirm = form[key]
1747 break
1748 else:
1749 raise ValueError, 'Password and confirmation text do '\
1750 'not match'
1751 if isinstance(confirm, type([])):
1752 raise ValueError, 'You have submitted more than one value'\
1753 ' for the %s property'%propname
1754 if value != confirm.value:
1755 raise ValueError, 'Password and confirmation text do '\
1756 'not match'
1757 value = password.Password(value)
1759 elif isinstance(proptype, hyperdb.Link):
1760 # see if it's the "no selection" choice
1761 if value == '-1' or not value:
1762 # if we're creating, just don't include this property
1763 if not nodeid or nodeid.startswith('-'):
1764 continue
1765 value = None
1766 else:
1767 # handle key values
1768 link = proptype.classname
1769 if not num_re.match(value):
1770 try:
1771 value = db.classes[link].lookup(value)
1772 except KeyError:
1773 raise ValueError, _('property "%(propname)s": '
1774 '%(value)s not a %(classname)s')%{
1775 'propname': propname, 'value': value,
1776 'classname': link}
1777 except TypeError, message:
1778 raise ValueError, _('you may only enter ID values '
1779 'for property "%(propname)s": %(message)s')%{
1780 'propname': propname, 'message': message}
1781 elif isinstance(proptype, hyperdb.Multilink):
1782 # perform link class key value lookup if necessary
1783 link = proptype.classname
1784 link_cl = db.classes[link]
1785 l = []
1786 for entry in value:
1787 if not entry: continue
1788 if not num_re.match(entry):
1789 try:
1790 entry = link_cl.lookup(entry)
1791 except KeyError:
1792 raise ValueError, _('property "%(propname)s": '
1793 '"%(value)s" not an entry of %(classname)s')%{
1794 'propname': propname, 'value': entry,
1795 'classname': link}
1796 except TypeError, message:
1797 raise ValueError, _('you may only enter ID values '
1798 'for property "%(propname)s": %(message)s')%{
1799 'propname': propname, 'message': message}
1800 l.append(entry)
1801 l.sort()
1803 # now use that list of ids to modify the multilink
1804 if mlaction == 'set':
1805 value = l
1806 else:
1807 # we're modifying the list - get the current list of ids
1808 if props.has_key(propname):
1809 existing = props[propname]
1810 elif nodeid and not nodeid.startswith('-'):
1811 existing = cl.get(nodeid, propname, [])
1812 else:
1813 existing = []
1815 # now either remove or add
1816 if mlaction == 'remove':
1817 # remove - handle situation where the id isn't in
1818 # the list
1819 for entry in l:
1820 try:
1821 existing.remove(entry)
1822 except ValueError:
1823 raise ValueError, _('property "%(propname)s": '
1824 '"%(value)s" not currently in list')%{
1825 'propname': propname, 'value': entry}
1826 else:
1827 # add - easy, just don't dupe
1828 for entry in l:
1829 if entry not in existing:
1830 existing.append(entry)
1831 value = existing
1832 value.sort()
1834 elif value == '':
1835 # if we're creating, just don't include this property
1836 if not nodeid or nodeid.startswith('-'):
1837 continue
1838 # other types should be None'd if there's no value
1839 value = None
1840 else:
1841 # handle ValueErrors for all these in a similar fashion
1842 try:
1843 if isinstance(proptype, hyperdb.String):
1844 if (hasattr(value, 'filename') and
1845 value.filename is not None):
1846 # skip if the upload is empty
1847 if not value.filename:
1848 continue
1849 # this String is actually a _file_
1850 # try to determine the file content-type
1851 fn = value.filename.split('\\')[-1]
1852 if propdef.has_key('name'):
1853 props['name'] = fn
1854 # use this info as the type/filename properties
1855 if propdef.has_key('type'):
1856 props['type'] = mimetypes.guess_type(fn)[0]
1857 if not props['type']:
1858 props['type'] = "application/octet-stream"
1859 # finally, read the content
1860 value = value.value
1861 else:
1862 # normal String fix the CRLF/CR -> LF stuff
1863 value = fixNewlines(value)
1865 elif isinstance(proptype, hyperdb.Date):
1866 value = date.Date(value, offset=timezone)
1867 elif isinstance(proptype, hyperdb.Interval):
1868 value = date.Interval(value)
1869 elif isinstance(proptype, hyperdb.Boolean):
1870 value = value.lower() in ('yes', 'true', 'on', '1')
1871 elif isinstance(proptype, hyperdb.Number):
1872 value = float(value)
1873 except ValueError, msg:
1874 raise ValueError, _('Error with %s property: %s')%(
1875 propname, msg)
1877 # get the old value
1878 if nodeid and not nodeid.startswith('-'):
1879 try:
1880 existing = cl.get(nodeid, propname)
1881 except KeyError:
1882 # this might be a new property for which there is
1883 # no existing value
1884 if not propdef.has_key(propname):
1885 raise
1887 # make sure the existing multilink is sorted
1888 if isinstance(proptype, hyperdb.Multilink):
1889 existing.sort()
1891 # "missing" existing values may not be None
1892 if not existing:
1893 if isinstance(proptype, hyperdb.String) and not existing:
1894 # some backends store "missing" Strings as empty strings
1895 existing = None
1896 elif isinstance(proptype, hyperdb.Number) and not existing:
1897 # some backends store "missing" Numbers as 0 :(
1898 existing = 0
1899 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1900 # likewise Booleans
1901 existing = 0
1903 # if changed, set it
1904 if value != existing:
1905 props[propname] = value
1906 else:
1907 # don't bother setting empty/unset values
1908 if value is None:
1909 continue
1910 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1911 continue
1912 elif isinstance(proptype, hyperdb.String) and value == '':
1913 continue
1915 props[propname] = value
1917 # register this as received if required?
1918 if propname in required and value is not None:
1919 required.remove(propname)
1921 # check to see if we need to specially link a file to the note
1922 if have_note and have_file:
1923 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1925 # see if all the required properties have been supplied
1926 s = []
1927 for thing, required in all_required.items():
1928 if not required:
1929 continue
1930 if len(required) > 1:
1931 p = 'properties'
1932 else:
1933 p = 'property'
1934 s.append('Required %s %s %s not supplied'%(thing[0], p,
1935 ', '.join(required)))
1936 if s:
1937 raise ValueError, '\n'.join(s)
1939 # check that FileClass entries have a "content" property with
1940 # content, otherwise remove them
1941 for (cn, id), props in all_props.items():
1942 cl = self.db.classes[cn]
1943 if not isinstance(cl, hyperdb.FileClass):
1944 continue
1945 # we also don't want to create FileClass items with no content
1946 if not props.get('content', ''):
1947 del all_props[(cn, id)]
1948 return all_props, all_links
1950 def fixNewlines(text):
1951 ''' Homogenise line endings.
1953 Different web clients send different line ending values, but
1954 other systems (eg. email) don't necessarily handle those line
1955 endings. Our solution is to convert all line endings to LF.
1956 '''
1957 text = text.replace('\r\n', '\n')
1958 return text.replace('\r', '\n')
1960 def extractFormList(value):
1961 ''' Extract a list of values from the form value.
1963 It may be one of:
1964 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1965 MiniFieldStorage('value,value,...')
1966 MiniFieldStorage('value')
1967 '''
1968 # multiple values are OK
1969 if isinstance(value, type([])):
1970 # it's a list of MiniFieldStorages - join then into
1971 values = ','.join([i.value.strip() for i in value])
1972 else:
1973 # it's a MiniFieldStorage, but may be a comma-separated list
1974 # of values
1975 values = value.value
1977 value = [i.strip() for i in values.split(',')]
1979 # filter out the empty bits
1980 return filter(None, value)