1 # $Id: client.py,v 1.109 2003-03-20 04:02:52 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
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
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', '')
35 # XXX actually _use_ FormError
36 class FormError(ValueError):
37 ''' An "expected" exception occurred during form parsing.
38 - ie. something we know can go wrong, and don't want to alarm the
39 user with
41 We trap this at the user interface level and feed back a nice error
42 to the user.
43 '''
44 pass
46 class SendFile(Exception):
47 ''' Send a file from the database '''
49 class SendStaticFile(Exception):
50 ''' Send a static file from the instance html directory '''
52 def initialiseSecurity(security):
53 ''' Create some Permissions and Roles on the security object
55 This function is directly invoked by security.Security.__init__()
56 as a part of the Security object instantiation.
57 '''
58 security.addPermission(name="Web Registration",
59 description="User may register through the web")
60 p = security.addPermission(name="Web Access",
61 description="User may access the web interface")
62 security.addPermissionToRole('Admin', p)
64 # doing Role stuff through the web - make sure Admin can
65 p = security.addPermission(name="Web Roles",
66 description="User may manipulate user Roles through the web")
67 security.addPermissionToRole('Admin', p)
69 class Client:
70 ''' Instantiate to handle one CGI request.
72 See inner_main for request processing.
74 Client attributes at instantiation:
75 "path" is the PATH_INFO inside the instance (with no leading '/')
76 "base" is the base URL for the instance
77 "form" is the cgi form, an instance of FieldStorage from the standard
78 cgi module
79 "additional_headers" is a dictionary of additional HTTP headers that
80 should be sent to the client
81 "response_code" is the HTTP response code to send to the client
83 During the processing of a request, the following attributes are used:
84 "error_message" holds a list of error messages
85 "ok_message" holds a list of OK messages
86 "session" is the current user session id
87 "user" is the current user's name
88 "userid" is the current user's id
89 "template" is the current :template context
90 "classname" is the current class context name
91 "nodeid" is the current context item id
93 User Identification:
94 If the user has no login cookie, then they are anonymous and are logged
95 in as that user. This typically gives them all Permissions assigned to the
96 Anonymous Role.
98 Once a user logs in, they are assigned a session. The Client instance
99 keeps the nodeid of the session as the "session" attribute.
102 Special form variables:
103 Note that in various places throughout this code, special form
104 variables of the form :<name> are used. The colon (":") part may
105 actually be one of either ":" or "@".
106 '''
108 #
109 # special form variables
110 #
111 FV_TEMPLATE = re.compile(r'[@:]template')
112 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
113 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
115 FV_QUERYNAME = re.compile(r'[@:]queryname')
117 # edit form variable handling (see unit tests)
118 FV_LABELS = r'''
119 ^(
120 (?P<note>[@:]note)|
121 (?P<file>[@:]file)|
122 (
123 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
124 ((?P<required>[@:]required$)| # :required
125 (
126 (
127 (?P<add>[@:]add[@:])| # :add:<prop>
128 (?P<remove>[@:]remove[@:])| # :remove:<prop>
129 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
130 (?P<link>[@:]link[@:])| # :link:<prop>
131 ([@:]) # just a separator
132 )?
133 (?P<propname>[^@:]+) # <prop>
134 )
135 )
136 )
137 )$'''
139 # Note: index page stuff doesn't appear here:
140 # columns, sort, sortdir, filter, group, groupdir, search_text,
141 # pagesize, startwith
143 def __init__(self, instance, request, env, form=None):
144 hyperdb.traceMark()
145 self.instance = instance
146 self.request = request
147 self.env = env
149 # save off the path
150 self.path = env['PATH_INFO']
152 # this is the base URL for this tracker
153 self.base = self.instance.config.TRACKER_WEB
155 # this is the "cookie path" for this tracker (ie. the path part of
156 # the "base" url)
157 self.cookie_path = urlparse.urlparse(self.base)[2]
158 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
159 self.instance.config.TRACKER_NAME)
161 # see if we need to re-parse the environment for the form (eg Zope)
162 if form is None:
163 self.form = cgi.FieldStorage(environ=env)
164 else:
165 self.form = form
167 # turn debugging on/off
168 try:
169 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
170 except ValueError:
171 # someone gave us a non-int debug level, turn it off
172 self.debug = 0
174 # flag to indicate that the HTTP headers have been sent
175 self.headers_done = 0
177 # additional headers to send with the request - must be registered
178 # before the first write
179 self.additional_headers = {}
180 self.response_code = 200
183 def main(self):
184 ''' Wrap the real main in a try/finally so we always close off the db.
185 '''
186 try:
187 self.inner_main()
188 finally:
189 if hasattr(self, 'db'):
190 self.db.close()
192 def inner_main(self):
193 ''' Process a request.
195 The most common requests are handled like so:
196 1. figure out who we are, defaulting to the "anonymous" user
197 see determine_user
198 2. figure out what the request is for - the context
199 see determine_context
200 3. handle any requested action (item edit, search, ...)
201 see handle_action
202 4. render a template, resulting in HTML output
204 In some situations, exceptions occur:
205 - HTTP Redirect (generally raised by an action)
206 - SendFile (generally raised by determine_context)
207 serve up a FileClass "content" property
208 - SendStaticFile (generally raised by determine_context)
209 serve up a file from the tracker "html" directory
210 - Unauthorised (generally raised by an action)
211 the action is cancelled, the request is rendered and an error
212 message is displayed indicating that permission was not
213 granted for the action to take place
214 - NotFound (raised wherever it needs to be)
215 percolates up to the CGI interface that called the client
216 '''
217 self.ok_message = []
218 self.error_message = []
219 try:
220 # make sure we're identified (even anonymously)
221 self.determine_user()
222 # figure out the context and desired content template
223 self.determine_context()
224 # possibly handle a form submit action (may change self.classname
225 # and self.template, and may also append error/ok_messages)
226 self.handle_action()
227 # now render the page
229 # we don't want clients caching our dynamic pages
230 self.additional_headers['Cache-Control'] = 'no-cache'
231 self.additional_headers['Pragma'] = 'no-cache'
232 self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
234 # render the content
235 self.write(self.renderContext())
236 except Redirect, url:
237 # let's redirect - if the url isn't None, then we need to do
238 # the headers, otherwise the headers have been set before the
239 # exception was raised
240 if url:
241 self.additional_headers['Location'] = url
242 self.response_code = 302
243 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
244 except SendFile, designator:
245 self.serve_file(designator)
246 except SendStaticFile, file:
247 try:
248 self.serve_static_file(str(file))
249 except NotModified:
250 # send the 304 response
251 self.request.send_response(304)
252 self.request.end_headers()
253 except Unauthorised, message:
254 self.classname = None
255 self.template = ''
256 self.error_message.append(message)
257 self.write(self.renderContext())
258 except NotFound:
259 # pass through
260 raise
261 except:
262 # everything else
263 self.write(cgitb.html())
265 def clean_sessions(self):
266 ''' Age sessions, remove when they haven't been used for a week.
268 Do it only once an hour.
270 Note: also cleans One Time Keys, and other "session" based
271 stuff.
272 '''
273 sessions = self.db.sessions
274 last_clean = sessions.get('last_clean', 'last_use') or 0
276 week = 60*60*24*7
277 hour = 60*60
278 now = time.time()
279 if now - last_clean > hour:
280 # remove aged sessions
281 for sessid in sessions.list():
282 interval = now - sessions.get(sessid, 'last_use')
283 if interval > week:
284 sessions.destroy(sessid)
285 # remove aged otks
286 otks = self.db.otks
287 for sessid in otks.list():
288 interval = now - otks.get(sessid, '__time')
289 if interval > week:
290 otks.destroy(sessid)
291 sessions.set('last_clean', last_use=time.time())
293 def determine_user(self):
294 ''' Determine who the user is
295 '''
296 # determine the uid to use
297 self.opendb('admin')
298 # clean age sessions
299 self.clean_sessions()
300 # make sure we have the session Class
301 sessions = self.db.sessions
303 # look up the user session cookie
304 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
305 user = 'anonymous'
307 # bump the "revision" of the cookie since the format changed
308 if (cookie.has_key(self.cookie_name) and
309 cookie[self.cookie_name].value != 'deleted'):
311 # get the session key from the cookie
312 self.session = cookie[self.cookie_name].value
313 # get the user from the session
314 try:
315 # update the lifetime datestamp
316 sessions.set(self.session, last_use=time.time())
317 sessions.commit()
318 user = sessions.get(self.session, 'user')
319 except KeyError:
320 user = 'anonymous'
322 # sanity check on the user still being valid, getting the userid
323 # at the same time
324 try:
325 self.userid = self.db.user.lookup(user)
326 except (KeyError, TypeError):
327 user = 'anonymous'
329 # make sure the anonymous user is valid if we're using it
330 if user == 'anonymous':
331 self.make_user_anonymous()
332 else:
333 self.user = user
335 # reopen the database as the correct user
336 self.opendb(self.user)
338 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
339 ''' Determine the context of this page from the URL:
341 The URL path after the instance identifier is examined. The path
342 is generally only one entry long.
344 - if there is no path, then we are in the "home" context.
345 * if the path is "_file", then the additional path entry
346 specifies the filename of a static file we're to serve up
347 from the instance "html" directory. Raises a SendStaticFile
348 exception.
349 - if there is something in the path (eg "issue"), it identifies
350 the tracker class we're to display.
351 - if the path is an item designator (eg "issue123"), then we're
352 to display a specific item.
353 * if the path starts with an item designator and is longer than
354 one entry, then we're assumed to be handling an item of a
355 FileClass, and the extra path information gives the filename
356 that the client is going to label the download with (ie
357 "file123/image.png" is nicer to download than "file123"). This
358 raises a SendFile exception.
360 Both of the "*" types of contexts stop before we bother to
361 determine the template we're going to use. That's because they
362 don't actually use templates.
364 The template used is specified by the :template CGI variable,
365 which defaults to:
367 only classname suplied: "index"
368 full item designator supplied: "item"
370 We set:
371 self.classname - the class to display, can be None
372 self.template - the template to render the current context with
373 self.nodeid - the nodeid of the class we're displaying
374 '''
375 # default the optional variables
376 self.classname = None
377 self.nodeid = None
379 # see if a template or messages are specified
380 template_override = ok_message = error_message = None
381 for key in self.form.keys():
382 if self.FV_TEMPLATE.match(key):
383 template_override = self.form[key].value
384 elif self.FV_OK_MESSAGE.match(key):
385 ok_message = self.form[key].value
386 elif self.FV_ERROR_MESSAGE.match(key):
387 error_message = self.form[key].value
389 # determine the classname and possibly nodeid
390 path = self.path.split('/')
391 if not path or path[0] in ('', 'home', 'index'):
392 if template_override is not None:
393 self.template = template_override
394 else:
395 self.template = ''
396 return
397 elif path[0] == '_file':
398 raise SendStaticFile, os.path.join(*path[1:])
399 else:
400 self.classname = path[0]
401 if len(path) > 1:
402 # send the file identified by the designator in path[0]
403 raise SendFile, path[0]
405 # see if we got a designator
406 m = dre.match(self.classname)
407 if m:
408 self.classname = m.group(1)
409 self.nodeid = m.group(2)
410 if not self.db.getclass(self.classname).hasnode(self.nodeid):
411 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
412 # with a designator, we default to item view
413 self.template = 'item'
414 else:
415 # with only a class, we default to index view
416 self.template = 'index'
418 # make sure the classname is valid
419 try:
420 self.db.getclass(self.classname)
421 except KeyError:
422 raise NotFound, self.classname
424 # see if we have a template override
425 if template_override is not None:
426 self.template = template_override
428 # see if we were passed in a message
429 if ok_message:
430 self.ok_message.append(ok_message)
431 if error_message:
432 self.error_message.append(error_message)
434 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
435 ''' Serve the file from the content property of the designated item.
436 '''
437 m = dre.match(str(designator))
438 if not m:
439 raise NotFound, str(designator)
440 classname, nodeid = m.group(1), m.group(2)
441 if classname != 'file':
442 raise NotFound, designator
444 # we just want to serve up the file named
445 file = self.db.file
446 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
447 self.write(file.get(nodeid, 'content'))
449 def serve_static_file(self, file):
450 ims = None
451 # see if there's an if-modified-since...
452 if hasattr(self.request, 'headers'):
453 ims = self.request.headers.getheader('if-modified-since')
454 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
455 # cgi will put the header in the env var
456 ims = self.env['HTTP_IF_MODIFIED_SINCE']
457 filename = os.path.join(self.instance.config.TEMPLATES, file)
458 lmt = os.stat(filename)[stat.ST_MTIME]
459 if ims:
460 ims = rfc822.parsedate(ims)[:6]
461 lmtt = time.gmtime(lmt)[:6]
462 if lmtt <= ims:
463 raise NotModified
465 # we just want to serve up the file named
466 mt = mimetypes.guess_type(str(file))[0]
467 if not mt:
468 mt = 'text/plain'
469 self.additional_headers['Content-Type'] = mt
470 self.additional_headers['Last-Modifed'] = rfc822.formatdate(lmt)
471 self.write(open(filename, 'rb').read())
473 def renderContext(self):
474 ''' Return a PageTemplate for the named page
475 '''
476 name = self.classname
477 extension = self.template
478 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
480 # catch errors so we can handle PT rendering errors more nicely
481 args = {
482 'ok_message': self.ok_message,
483 'error_message': self.error_message
484 }
485 try:
486 # let the template render figure stuff out
487 return pt.render(self, None, None, **args)
488 except NoTemplate, message:
489 return '<strong>%s</strong>'%message
490 except:
491 # everything else
492 return cgitb.pt_html()
494 # these are the actions that are available
495 actions = (
496 ('edit', 'editItemAction'),
497 ('editcsv', 'editCSVAction'),
498 ('new', 'newItemAction'),
499 ('register', 'registerAction'),
500 ('confrego', 'confRegoAction'),
501 ('passrst', 'passResetAction'),
502 ('login', 'loginAction'),
503 ('logout', 'logout_action'),
504 ('search', 'searchAction'),
505 ('retire', 'retireAction'),
506 ('show', 'showAction'),
507 )
508 def handle_action(self):
509 ''' Determine whether there should be an Action called.
511 The action is defined by the form variable :action which
512 identifies the method on this object to call. The actions
513 are defined in the "actions" sequence on this class.
514 '''
515 if self.form.has_key(':action'):
516 action = self.form[':action'].value.lower()
517 elif self.form.has_key('@action'):
518 action = self.form['@action'].value.lower()
519 else:
520 return None
521 try:
522 # get the action, validate it
523 for name, method in self.actions:
524 if name == action:
525 break
526 else:
527 raise ValueError, 'No such action "%s"'%action
528 # call the mapped action
529 getattr(self, method)()
530 except Redirect:
531 raise
532 except Unauthorised:
533 raise
535 def write(self, content):
536 if not self.headers_done:
537 self.header()
538 self.request.wfile.write(content)
540 def header(self, headers=None, response=None):
541 '''Put up the appropriate header.
542 '''
543 if headers is None:
544 headers = {'Content-Type':'text/html'}
545 if response is None:
546 response = self.response_code
548 # update with additional info
549 headers.update(self.additional_headers)
551 if not headers.has_key('Content-Type'):
552 headers['Content-Type'] = 'text/html'
553 self.request.send_response(response)
554 for entry in headers.items():
555 self.request.send_header(*entry)
556 self.request.end_headers()
557 self.headers_done = 1
558 if self.debug:
559 self.headers_sent = headers
561 def set_cookie(self, user):
562 ''' Set up a session cookie for the user and store away the user's
563 login info against the session.
564 '''
565 # TODO generate a much, much stronger session key ;)
566 self.session = binascii.b2a_base64(repr(random.random())).strip()
568 # clean up the base64
569 if self.session[-1] == '=':
570 if self.session[-2] == '=':
571 self.session = self.session[:-2]
572 else:
573 self.session = self.session[:-1]
575 # insert the session in the sessiondb
576 self.db.sessions.set(self.session, user=user, last_use=time.time())
578 # and commit immediately
579 self.db.sessions.commit()
581 # expire us in a long, long time
582 expire = Cookie._getdate(86400*365)
584 # generate the cookie path - make sure it has a trailing '/'
585 self.additional_headers['Set-Cookie'] = \
586 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
587 expire, self.cookie_path)
589 def make_user_anonymous(self):
590 ''' Make us anonymous
592 This method used to handle non-existence of the 'anonymous'
593 user, but that user is mandatory now.
594 '''
595 self.userid = self.db.user.lookup('anonymous')
596 self.user = 'anonymous'
598 def opendb(self, user):
599 ''' Open the database.
600 '''
601 # open the db if the user has changed
602 if not hasattr(self, 'db') or user != self.db.journaltag:
603 if hasattr(self, 'db'):
604 self.db.close()
605 self.db = self.instance.open(user)
607 #
608 # Actions
609 #
610 def loginAction(self):
611 ''' Attempt to log a user in.
613 Sets up a session for the user which contains the login
614 credentials.
615 '''
616 # we need the username at a minimum
617 if not self.form.has_key('__login_name'):
618 self.error_message.append(_('Username required'))
619 return
621 # get the login info
622 self.user = self.form['__login_name'].value
623 if self.form.has_key('__login_password'):
624 password = self.form['__login_password'].value
625 else:
626 password = ''
628 # make sure the user exists
629 try:
630 self.userid = self.db.user.lookup(self.user)
631 except KeyError:
632 name = self.user
633 self.error_message.append(_('No such user "%(name)s"')%locals())
634 self.make_user_anonymous()
635 return
637 # verify the password
638 if not self.verifyPassword(self.userid, password):
639 self.make_user_anonymous()
640 self.error_message.append(_('Incorrect password'))
641 return
643 # make sure we're allowed to be here
644 if not self.loginPermission():
645 self.make_user_anonymous()
646 self.error_message.append(_("You do not have permission to login"))
647 return
649 # now we're OK, re-open the database for real, using the user
650 self.opendb(self.user)
652 # set the session cookie
653 self.set_cookie(self.user)
655 def verifyPassword(self, userid, password):
656 ''' Verify the password that the user has supplied
657 '''
658 stored = self.db.user.get(self.userid, 'password')
659 if password == stored:
660 return 1
661 if not password and not stored:
662 return 1
663 return 0
665 def loginPermission(self):
666 ''' Determine whether the user has permission to log in.
668 Base behaviour is to check the user has "Web Access".
669 '''
670 if not self.db.security.hasPermission('Web Access', self.userid):
671 return 0
672 return 1
674 def logout_action(self):
675 ''' Make us really anonymous - nuke the cookie too
676 '''
677 # log us out
678 self.make_user_anonymous()
680 # construct the logout cookie
681 now = Cookie._getdate()
682 self.additional_headers['Set-Cookie'] = \
683 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
684 now, self.cookie_path)
686 # Let the user know what's going on
687 self.ok_message.append(_('You are logged out'))
689 chars = string.letters+string.digits
690 def registerAction(self):
691 '''Attempt to create a new user based on the contents of the form
692 and then set the cookie.
694 return 1 on successful login
695 '''
696 # parse the props from the form
697 try:
698 props = self.parsePropsFromForm()[0][('user', None)]
699 except (ValueError, KeyError), message:
700 self.error_message.append(_('Error: ') + str(message))
701 return
703 # make sure we're allowed to register
704 if not self.registerPermission(props):
705 raise Unauthorised, _("You do not have permission to register")
707 try:
708 self.db.user.lookup(props['username'])
709 self.error_message.append('Error: A user with the username "%s" '
710 'already exists'%props['username'])
711 return
712 except KeyError:
713 pass
715 # generate the one-time-key and store the props for later
716 otk = ''.join([random.choice(self.chars) for x in range(32)])
717 for propname, proptype in self.db.user.getprops().items():
718 value = props.get(propname, None)
719 if value is None:
720 pass
721 elif isinstance(proptype, hyperdb.Date):
722 props[propname] = str(value)
723 elif isinstance(proptype, hyperdb.Interval):
724 props[propname] = str(value)
725 elif isinstance(proptype, hyperdb.Password):
726 props[propname] = str(value)
727 props['__time'] = time.time()
728 self.db.otks.set(otk, **props)
730 # send the email
731 tracker_name = self.db.config.TRACKER_NAME
732 subject = 'Complete your registration to %s'%tracker_name
733 body = '''
734 To complete your registration of the user "%(name)s" with %(tracker)s,
735 please visit the following URL:
737 %(url)s?@action=confrego&otk=%(otk)s
738 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
739 'otk': otk}
740 if not self.sendEmail(props['address'], subject, body):
741 return
743 # commit changes to the database
744 self.db.commit()
746 # redirect to the "you're almost there" page
747 raise Redirect, '%suser?@template=rego_progress'%self.base
749 def sendEmail(self, to, subject, content):
750 # send email to the user's email address
751 message = StringIO.StringIO()
752 writer = MimeWriter.MimeWriter(message)
753 tracker_name = self.db.config.TRACKER_NAME
754 writer.addheader('Subject', encode_header(subject))
755 writer.addheader('To', to)
756 writer.addheader('From', roundupdb.straddr((tracker_name,
757 self.db.config.ADMIN_EMAIL)))
758 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
759 time.gmtime()))
760 # add a uniquely Roundup header to help filtering
761 writer.addheader('X-Roundup-Name', tracker_name)
762 # avoid email loops
763 writer.addheader('X-Roundup-Loop', 'hello')
764 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
765 body = writer.startbody('text/plain; charset=utf-8')
767 # message body, encoded quoted-printable
768 content = StringIO.StringIO(content)
769 quopri.encode(content, body, 0)
771 if SENDMAILDEBUG:
772 # don't send - just write to a file
773 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
774 self.db.config.ADMIN_EMAIL,
775 ', '.join(to),message.getvalue()))
776 else:
777 # now try to send the message
778 try:
779 # send the message as admin so bounces are sent there
780 # instead of to roundup
781 smtp = smtplib.SMTP(self.db.config.MAILHOST)
782 smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
783 message.getvalue())
784 except socket.error, value:
785 self.error_message.append("Error: couldn't send email: "
786 "mailhost %s"%value)
787 return 0
788 except smtplib.SMTPException, msg:
789 self.error_message.append("Error: couldn't send email: %s"%msg)
790 return 0
791 return 1
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 # pull the rego information out of the otk database
809 otk = self.form['otk'].value
810 props = self.db.otks.getall(otk)
811 for propname, proptype in self.db.user.getprops().items():
812 value = props.get(propname, None)
813 if value is None:
814 pass
815 elif isinstance(proptype, hyperdb.Date):
816 props[propname] = date.Date(value)
817 elif isinstance(proptype, hyperdb.Interval):
818 props[propname] = date.Interval(value)
819 elif isinstance(proptype, hyperdb.Password):
820 props[propname] = password.Password()
821 props[propname].unpack(value)
823 # re-open the database as "admin"
824 if self.user != 'admin':
825 self.opendb('admin')
827 # create the new user
828 cl = self.db.user
829 # XXX we need to make the "default" page be able to display errors!
830 try:
831 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
832 del props['__time']
833 self.userid = cl.create(**props)
834 # clear the props from the otk database
835 self.db.otks.destroy(otk)
836 self.db.commit()
837 except (ValueError, KeyError), message:
838 self.error_message.append(str(message))
839 return
841 # log the new user in
842 self.user = cl.get(self.userid, 'username')
843 # re-open the database for real, using the user
844 self.opendb(self.user)
846 # if we have a session, update it
847 if hasattr(self, 'session'):
848 self.db.sessions.set(self.session, user=self.user,
849 last_use=time.time())
850 else:
851 # new session cookie
852 self.set_cookie(self.user)
854 # nice message
855 message = _('You are now registered, welcome!')
857 # redirect to the user's page
858 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
859 self.userid, urllib.quote(message))
861 def passResetAction(self):
862 ''' Handle password reset requests.
864 Presence of either "name" or "address" generate email.
865 Presense of "otk" performs the reset.
866 '''
867 if self.form.has_key('otk'):
868 # pull the rego information out of the otk database
869 otk = self.form['otk'].value
870 uid = self.db.otks.get(otk, 'uid')
872 # re-open the database as "admin"
873 if self.user != 'admin':
874 self.opendb('admin')
876 # change the password
877 newpw = ''.join([random.choice(self.chars) for x in range(8)])
879 cl = self.db.user
880 # XXX we need to make the "default" page be able to display errors!
881 try:
882 # set the password
883 cl.set(uid, password=password.Password(newpw))
884 # clear the props from the otk database
885 self.db.otks.destroy(otk)
886 self.db.commit()
887 except (ValueError, KeyError), message:
888 self.error_message.append(str(message))
889 return
891 # user info
892 address = self.db.user.get(uid, 'address')
893 name = self.db.user.get(uid, 'username')
895 # send the email
896 tracker_name = self.db.config.TRACKER_NAME
897 subject = 'Password reset for %s'%tracker_name
898 body = '''
899 The password has been reset for username "%(name)s".
901 Your password is now: %(password)s
902 '''%{'name': name, 'password': newpw}
903 if not self.sendEmail(address, subject, body):
904 return
906 self.ok_message.append('Password reset and email sent to %s'%address)
907 return
909 # no OTK, so now figure the user
910 if self.form.has_key('username'):
911 name = self.form['username'].value
912 try:
913 uid = self.db.user.lookup(name)
914 except KeyError:
915 self.error_message.append('Unknown username')
916 return
917 address = self.db.user.get(uid, 'address')
918 elif self.form.has_key('address'):
919 address = self.form['address'].value
920 uid = uidFromAddress(self.db, ('', address), create=0)
921 if not uid:
922 self.error_message.append('Unknown email address')
923 return
924 name = self.db.user.get(uid, 'username')
925 else:
926 self.error_message.append('You need to specify a username '
927 'or address')
928 return
930 # generate the one-time-key and store the props for later
931 otk = ''.join([random.choice(self.chars) for x in range(32)])
932 self.db.otks.set(otk, uid=uid, __time=time.time())
934 # send the email
935 tracker_name = self.db.config.TRACKER_NAME
936 subject = 'Confirm reset of password for %s'%tracker_name
937 body = '''
938 Someone, perhaps you, has requested that the password be changed for your
939 username, "%(name)s". If you wish to proceed with the change, please follow
940 the link below:
942 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
944 You should then receive another email with the new password.
945 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
946 if not self.sendEmail(address, subject, body):
947 return
949 self.ok_message.append('Email sent to %s'%address)
951 def editItemAction(self):
952 ''' Perform an edit of an item in the database.
954 See parsePropsFromForm and _editnodes for special variables
955 '''
956 # parse the props from the form
957 try:
958 props, links = self.parsePropsFromForm()
959 except (ValueError, KeyError), message:
960 self.error_message.append(_('Error: ') + str(message))
961 return
963 # handle the props
964 try:
965 message = self._editnodes(props, links)
966 except (ValueError, KeyError, IndexError), message:
967 self.error_message.append(_('Error: ') + str(message))
968 return
970 # commit now that all the tricky stuff is done
971 self.db.commit()
973 # redirect to the item's edit page
974 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
975 self.classname, self.nodeid, urllib.quote(message),
976 urllib.quote(self.template))
978 def editItemPermission(self, props):
979 ''' Determine whether the user has permission to edit this item.
981 Base behaviour is to check the user can edit this class. If we're
982 editing the "user" class, users are allowed to edit their own
983 details. Unless it's the "roles" property, which requires the
984 special Permission "Web Roles".
985 '''
986 # if this is a user node and the user is editing their own node, then
987 # we're OK
988 has = self.db.security.hasPermission
989 if self.classname == 'user':
990 # reject if someone's trying to edit "roles" and doesn't have the
991 # right permission.
992 if props.has_key('roles') and not has('Web Roles', self.userid,
993 'user'):
994 return 0
995 # if the item being edited is the current user, we're ok
996 if self.nodeid == self.userid:
997 return 1
998 if self.db.security.hasPermission('Edit', self.userid, self.classname):
999 return 1
1000 return 0
1002 def newItemAction(self):
1003 ''' Add a new item to the database.
1005 This follows the same form as the editItemAction, with the same
1006 special form values.
1007 '''
1008 # parse the props from the form
1009 try:
1010 props, links = self.parsePropsFromForm()
1011 except (ValueError, KeyError), message:
1012 self.error_message.append(_('Error: ') + str(message))
1013 return
1015 # handle the props - edit or create
1016 try:
1017 # when it hits the None element, it'll set self.nodeid
1018 messages = self._editnodes(props, links)
1020 except (ValueError, KeyError, IndexError), message:
1021 # these errors might just be indicative of user dumbness
1022 self.error_message.append(_('Error: ') + str(message))
1023 return
1025 # commit now that all the tricky stuff is done
1026 self.db.commit()
1028 # redirect to the new item's page
1029 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
1030 self.classname, self.nodeid, urllib.quote(messages),
1031 urllib.quote(self.template))
1033 def newItemPermission(self, props):
1034 ''' Determine whether the user has permission to create (edit) this
1035 item.
1037 Base behaviour is to check the user can edit this class. No
1038 additional property checks are made. Additionally, new user items
1039 may be created if the user has the "Web Registration" Permission.
1040 '''
1041 has = self.db.security.hasPermission
1042 if self.classname == 'user' and has('Web Registration', self.userid,
1043 'user'):
1044 return 1
1045 if has('Edit', self.userid, self.classname):
1046 return 1
1047 return 0
1050 #
1051 # Utility methods for editing
1052 #
1053 def _editnodes(self, all_props, all_links, newids=None):
1054 ''' Use the props in all_props to perform edit and creation, then
1055 use the link specs in all_links to do linking.
1056 '''
1057 # figure dependencies and re-work links
1058 deps = {}
1059 links = {}
1060 for cn, nodeid, propname, vlist in all_links:
1061 if not all_props.has_key((cn, nodeid)):
1062 # link item to link to doesn't (and won't) exist
1063 continue
1064 for value in vlist:
1065 if not all_props.has_key(value):
1066 # link item to link to doesn't (and won't) exist
1067 continue
1068 deps.setdefault((cn, nodeid), []).append(value)
1069 links.setdefault(value, []).append((cn, nodeid, propname))
1071 # figure chained dependencies ordering
1072 order = []
1073 done = {}
1074 # loop detection
1075 change = 0
1076 while len(all_props) != len(done):
1077 for needed in all_props.keys():
1078 if done.has_key(needed):
1079 continue
1080 tlist = deps.get(needed, [])
1081 for target in tlist:
1082 if not done.has_key(target):
1083 break
1084 else:
1085 done[needed] = 1
1086 order.append(needed)
1087 change = 1
1088 if not change:
1089 raise ValueError, 'linking must not loop!'
1091 # now, edit / create
1092 m = []
1093 for needed in order:
1094 props = all_props[needed]
1095 if not props:
1096 # nothing to do
1097 continue
1098 cn, nodeid = needed
1100 if nodeid is not None and int(nodeid) > 0:
1101 # make changes to the node
1102 props = self._changenode(cn, nodeid, props)
1104 # and some nice feedback for the user
1105 if props:
1106 info = ', '.join(props.keys())
1107 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1108 else:
1109 m.append('%s %s - nothing changed'%(cn, nodeid))
1110 else:
1111 assert props
1113 # make a new node
1114 newid = self._createnode(cn, props)
1115 if nodeid is None:
1116 self.nodeid = newid
1117 nodeid = newid
1119 # and some nice feedback for the user
1120 m.append('%s %s created'%(cn, newid))
1122 # fill in new ids in links
1123 if links.has_key(needed):
1124 for linkcn, linkid, linkprop in links[needed]:
1125 props = all_props[(linkcn, linkid)]
1126 cl = self.db.classes[linkcn]
1127 propdef = cl.getprops()[linkprop]
1128 if not props.has_key(linkprop):
1129 if linkid is None or linkid.startswith('-'):
1130 # linking to a new item
1131 if isinstance(propdef, hyperdb.Multilink):
1132 props[linkprop] = [newid]
1133 else:
1134 props[linkprop] = newid
1135 else:
1136 # linking to an existing item
1137 if isinstance(propdef, hyperdb.Multilink):
1138 existing = cl.get(linkid, linkprop)[:]
1139 existing.append(nodeid)
1140 props[linkprop] = existing
1141 else:
1142 props[linkprop] = newid
1144 return '<br>'.join(m)
1146 def _changenode(self, cn, nodeid, props):
1147 ''' change the node based on the contents of the form
1148 '''
1149 # check for permission
1150 if not self.editItemPermission(props):
1151 raise Unauthorised, 'You do not have permission to edit %s'%cn
1153 # make the changes
1154 cl = self.db.classes[cn]
1155 return cl.set(nodeid, **props)
1157 def _createnode(self, cn, props):
1158 ''' create a node based on the contents of the form
1159 '''
1160 # check for permission
1161 if not self.newItemPermission(props):
1162 raise Unauthorised, 'You do not have permission to create %s'%cn
1164 # create the node and return its id
1165 cl = self.db.classes[cn]
1166 return cl.create(**props)
1168 #
1169 # More actions
1170 #
1171 def editCSVAction(self):
1172 ''' Performs an edit of all of a class' items in one go.
1174 The "rows" CGI var defines the CSV-formatted entries for the
1175 class. New nodes are identified by the ID 'X' (or any other
1176 non-existent ID) and removed lines are retired.
1177 '''
1178 # this is per-class only
1179 if not self.editCSVPermission():
1180 self.error_message.append(
1181 _('You do not have permission to edit %s' %self.classname))
1183 # get the CSV module
1184 try:
1185 import csv
1186 except ImportError:
1187 self.error_message.append(_(
1188 'Sorry, you need the csv module to use this function.<br>\n'
1189 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1190 return
1192 cl = self.db.classes[self.classname]
1193 idlessprops = cl.getprops(protected=0).keys()
1194 idlessprops.sort()
1195 props = ['id'] + idlessprops
1197 # do the edit
1198 rows = self.form['rows'].value.splitlines()
1199 p = csv.parser()
1200 found = {}
1201 line = 0
1202 for row in rows[1:]:
1203 line += 1
1204 values = p.parse(row)
1205 # not a complete row, keep going
1206 if not values: continue
1208 # skip property names header
1209 if values == props:
1210 continue
1212 # extract the nodeid
1213 nodeid, values = values[0], values[1:]
1214 found[nodeid] = 1
1216 # see if the node exists
1217 if cl.hasnode(nodeid):
1218 exists = 1
1219 else:
1220 exists = 0
1222 # confirm correct weight
1223 if len(idlessprops) != len(values):
1224 self.error_message.append(
1225 _('Not enough values on line %(line)s')%{'line':line})
1226 return
1228 # extract the new values
1229 d = {}
1230 for name, value in zip(idlessprops, values):
1231 prop = cl.properties[name]
1232 value = value.strip()
1233 # only add the property if it has a value
1234 if value:
1235 # if it's a multilink, split it
1236 if isinstance(prop, hyperdb.Multilink):
1237 value = value.split(':')
1238 d[name] = value
1239 elif exists:
1240 # nuke the existing value
1241 if isinstance(prop, hyperdb.Multilink):
1242 d[name] = []
1243 else:
1244 d[name] = None
1246 # perform the edit
1247 if exists:
1248 # edit existing
1249 cl.set(nodeid, **d)
1250 else:
1251 # new node
1252 found[cl.create(**d)] = 1
1254 # retire the removed entries
1255 for nodeid in cl.list():
1256 if not found.has_key(nodeid):
1257 cl.retire(nodeid)
1259 # all OK
1260 self.db.commit()
1262 self.ok_message.append(_('Items edited OK'))
1264 def editCSVPermission(self):
1265 ''' Determine whether the user has permission to edit this class.
1267 Base behaviour is to check the user can edit this class.
1268 '''
1269 if not self.db.security.hasPermission('Edit', self.userid,
1270 self.classname):
1271 return 0
1272 return 1
1274 def searchAction(self):
1275 ''' Mangle some of the form variables.
1277 Set the form ":filter" variable based on the values of the
1278 filter variables - if they're set to anything other than
1279 "dontcare" then add them to :filter.
1281 Also handle the ":queryname" variable and save off the query to
1282 the user's query list.
1283 '''
1284 # generic edit is per-class only
1285 if not self.searchPermission():
1286 self.error_message.append(
1287 _('You do not have permission to search %s' %self.classname))
1289 # add a faked :filter form variable for each filtering prop
1290 props = self.db.classes[self.classname].getprops()
1291 queryname = ''
1292 for key in self.form.keys():
1293 # special vars
1294 if self.FV_QUERYNAME.match(key):
1295 queryname = self.form[key].value.strip()
1296 continue
1298 if not props.has_key(key):
1299 continue
1300 if isinstance(self.form[key], type([])):
1301 # search for at least one entry which is not empty
1302 for minifield in self.form[key]:
1303 if minifield.value:
1304 break
1305 else:
1306 continue
1307 else:
1308 if not self.form[key].value: continue
1309 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1311 # handle saving the query params
1312 if queryname:
1313 # parse the environment and figure what the query _is_
1314 req = HTMLRequest(self)
1315 url = req.indexargs_href('', {})
1317 # handle editing an existing query
1318 try:
1319 qid = self.db.query.lookup(queryname)
1320 self.db.query.set(qid, klass=self.classname, url=url)
1321 except KeyError:
1322 # create a query
1323 qid = self.db.query.create(name=queryname,
1324 klass=self.classname, url=url)
1326 # and add it to the user's query multilink
1327 queries = self.db.user.get(self.userid, 'queries')
1328 queries.append(qid)
1329 self.db.user.set(self.userid, queries=queries)
1331 # commit the query change to the database
1332 self.db.commit()
1334 def searchPermission(self):
1335 ''' Determine whether the user has permission to search this class.
1337 Base behaviour is to check the user can view this class.
1338 '''
1339 if not self.db.security.hasPermission('View', self.userid,
1340 self.classname):
1341 return 0
1342 return 1
1345 def retireAction(self):
1346 ''' Retire the context item.
1347 '''
1348 # if we want to view the index template now, then unset the nodeid
1349 # context info (a special-case for retire actions on the index page)
1350 nodeid = self.nodeid
1351 if self.template == 'index':
1352 self.nodeid = None
1354 # generic edit is per-class only
1355 if not self.retirePermission():
1356 self.error_message.append(
1357 _('You do not have permission to retire %s' %self.classname))
1358 return
1360 # make sure we don't try to retire admin or anonymous
1361 if self.classname == 'user' and \
1362 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1363 self.error_message.append(
1364 _('You may not retire the admin or anonymous user'))
1365 return
1367 # do the retire
1368 self.db.getclass(self.classname).retire(nodeid)
1369 self.db.commit()
1371 self.ok_message.append(
1372 _('%(classname)s %(itemid)s has been retired')%{
1373 'classname': self.classname.capitalize(), 'itemid': nodeid})
1375 def retirePermission(self):
1376 ''' Determine whether the user has permission to retire this class.
1378 Base behaviour is to check the user can edit this class.
1379 '''
1380 if not self.db.security.hasPermission('Edit', self.userid,
1381 self.classname):
1382 return 0
1383 return 1
1386 def showAction(self, typere=re.compile('[@:]type'),
1387 numre=re.compile('[@:]number')):
1388 ''' Show a node of a particular class/id
1389 '''
1390 t = n = ''
1391 for key in self.form.keys():
1392 if typere.match(key):
1393 t = self.form[key].value.strip()
1394 elif numre.match(key):
1395 n = self.form[key].value.strip()
1396 if not t:
1397 raise ValueError, 'Invalid %s number'%t
1398 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1399 raise Redirect, url
1401 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1402 ''' Pull properties out of the form.
1404 In the following, <bracketed> values are variable, ":" may be
1405 one of ":" or "@", and other text "required" is fixed.
1407 Properties are specified as form variables:
1409 <propname>
1410 - property on the current context item
1412 <designator>:<propname>
1413 - property on the indicated item
1415 <classname>-<N>:<propname>
1416 - property on the Nth new item of classname
1418 Once we have determined the "propname", we check to see if it
1419 is one of the special form values:
1421 :required
1422 The named property values must be supplied or a ValueError
1423 will be raised.
1425 :remove:<propname>=id(s)
1426 The ids will be removed from the multilink property.
1428 :add:<propname>=id(s)
1429 The ids will be added to the multilink property.
1431 :link:<propname>=<designator>
1432 Used to add a link to new items created during edit.
1433 These are collected up and returned in all_links. This will
1434 result in an additional linking operation (either Link set or
1435 Multilink append) after the edit/create is done using
1436 all_props in _editnodes. The <propname> on the current item
1437 will be set/appended the id of the newly created item of
1438 class <designator> (where <designator> must be
1439 <classname>-<N>).
1441 Any of the form variables may be prefixed with a classname or
1442 designator.
1444 The return from this method is a dict of
1445 (classname, id): properties
1446 ... this dict _always_ has an entry for the current context,
1447 even if it's empty (ie. a submission for an existing issue that
1448 doesn't result in any changes would return {('issue','123'): {}})
1449 The id may be None, which indicates that an item should be
1450 created.
1452 If a String property's form value is a file upload, then we
1453 try to set additional properties "filename" and "type" (if
1454 they are valid for the class).
1456 Two special form values are supported for backwards
1457 compatibility:
1458 :note - create a message (with content, author and date), link
1459 to the context item. This is ALWAYS desginated "msg-1".
1460 :file - create a file, attach to the current item and any
1461 message created by :note. This is ALWAYS designated
1462 "file-1".
1464 We also check that FileClass items have a "content" property with
1465 actual content, otherwise we remove them from all_props before
1466 returning.
1467 '''
1468 # some very useful variables
1469 db = self.db
1470 form = self.form
1472 if not hasattr(self, 'FV_SPECIAL'):
1473 # generate the regexp for handling special form values
1474 classes = '|'.join(db.classes.keys())
1475 # specials for parsePropsFromForm
1476 # handle the various forms (see unit tests)
1477 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1478 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1480 # these indicate the default class / item
1481 default_cn = self.classname
1482 default_cl = self.db.classes[default_cn]
1483 default_nodeid = self.nodeid
1485 # we'll store info about the individual class/item edit in these
1486 all_required = {} # one entry per class/item
1487 all_props = {} # one entry per class/item
1488 all_propdef = {} # note - only one entry per class
1489 all_links = [] # as many as are required
1491 # we should always return something, even empty, for the context
1492 all_props[(default_cn, default_nodeid)] = {}
1494 keys = form.keys()
1495 timezone = db.getUserTimezone()
1497 # sentinels for the :note and :file props
1498 have_note = have_file = 0
1500 # extract the usable form labels from the form
1501 matches = []
1502 for key in keys:
1503 m = self.FV_SPECIAL.match(key)
1504 if m:
1505 matches.append((key, m.groupdict()))
1507 # now handle the matches
1508 for key, d in matches:
1509 if d['classname']:
1510 # we got a designator
1511 cn = d['classname']
1512 cl = self.db.classes[cn]
1513 nodeid = d['id']
1514 propname = d['propname']
1515 elif d['note']:
1516 # the special note field
1517 cn = 'msg'
1518 cl = self.db.classes[cn]
1519 nodeid = '-1'
1520 propname = 'content'
1521 all_links.append((default_cn, default_nodeid, 'messages',
1522 [('msg', '-1')]))
1523 have_note = 1
1524 elif d['file']:
1525 # the special file field
1526 cn = 'file'
1527 cl = self.db.classes[cn]
1528 nodeid = '-1'
1529 propname = 'content'
1530 all_links.append((default_cn, default_nodeid, 'files',
1531 [('file', '-1')]))
1532 have_file = 1
1533 else:
1534 # default
1535 cn = default_cn
1536 cl = default_cl
1537 nodeid = default_nodeid
1538 propname = d['propname']
1540 # the thing this value relates to is...
1541 this = (cn, nodeid)
1543 # get more info about the class, and the current set of
1544 # form props for it
1545 if not all_propdef.has_key(cn):
1546 all_propdef[cn] = cl.getprops()
1547 propdef = all_propdef[cn]
1548 if not all_props.has_key(this):
1549 all_props[this] = {}
1550 props = all_props[this]
1552 # is this a link command?
1553 if d['link']:
1554 value = []
1555 for entry in extractFormList(form[key]):
1556 m = self.FV_DESIGNATOR.match(entry)
1557 if not m:
1558 raise ValueError, \
1559 'link "%s" value "%s" not a designator'%(key, entry)
1560 value.append((m.group(1), m.group(2)))
1562 # make sure the link property is valid
1563 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1564 not isinstance(propdef[propname], hyperdb.Link)):
1565 raise ValueError, '%s %s is not a link or '\
1566 'multilink property'%(cn, propname)
1568 all_links.append((cn, nodeid, propname, value))
1569 continue
1571 # detect the special ":required" variable
1572 if d['required']:
1573 all_required[this] = extractFormList(form[key])
1574 continue
1576 # get the required values list
1577 if not all_required.has_key(this):
1578 all_required[this] = []
1579 required = all_required[this]
1581 # see if we're performing a special multilink action
1582 mlaction = 'set'
1583 if d['remove']:
1584 mlaction = 'remove'
1585 elif d['add']:
1586 mlaction = 'add'
1588 # does the property exist?
1589 if not propdef.has_key(propname):
1590 if mlaction != 'set':
1591 raise ValueError, 'You have submitted a %s action for'\
1592 ' the property "%s" which doesn\'t exist'%(mlaction,
1593 propname)
1594 # the form element is probably just something we don't care
1595 # about - ignore it
1596 continue
1597 proptype = propdef[propname]
1599 # Get the form value. This value may be a MiniFieldStorage or a list
1600 # of MiniFieldStorages.
1601 value = form[key]
1603 # handle unpacking of the MiniFieldStorage / list form value
1604 if isinstance(proptype, hyperdb.Multilink):
1605 value = extractFormList(value)
1606 else:
1607 # multiple values are not OK
1608 if isinstance(value, type([])):
1609 raise ValueError, 'You have submitted more than one value'\
1610 ' for the %s property'%propname
1611 # value might be a file upload...
1612 if not hasattr(value, 'filename') or value.filename is None:
1613 # nope, pull out the value and strip it
1614 value = value.value.strip()
1616 # now that we have the props field, we need a teensy little
1617 # extra bit of help for the old :note field...
1618 if d['note'] and value:
1619 props['author'] = self.db.getuid()
1620 props['date'] = date.Date()
1622 # handle by type now
1623 if isinstance(proptype, hyperdb.Password):
1624 if not value:
1625 # ignore empty password values
1626 continue
1627 for key, d in matches:
1628 if d['confirm'] and d['propname'] == propname:
1629 confirm = form[key]
1630 break
1631 else:
1632 raise ValueError, 'Password and confirmation text do '\
1633 'not match'
1634 if isinstance(confirm, type([])):
1635 raise ValueError, 'You have submitted more than one value'\
1636 ' for the %s property'%propname
1637 if value != confirm.value:
1638 raise ValueError, 'Password and confirmation text do '\
1639 'not match'
1640 value = password.Password(value)
1642 elif isinstance(proptype, hyperdb.Link):
1643 # see if it's the "no selection" choice
1644 if value == '-1' or not value:
1645 # if we're creating, just don't include this property
1646 if not nodeid or nodeid.startswith('-'):
1647 continue
1648 value = None
1649 else:
1650 # handle key values
1651 link = proptype.classname
1652 if not num_re.match(value):
1653 try:
1654 value = db.classes[link].lookup(value)
1655 except KeyError:
1656 raise ValueError, _('property "%(propname)s": '
1657 '%(value)s not a %(classname)s')%{
1658 'propname': propname, 'value': value,
1659 'classname': link}
1660 except TypeError, message:
1661 raise ValueError, _('you may only enter ID values '
1662 'for property "%(propname)s": %(message)s')%{
1663 'propname': propname, 'message': message}
1664 elif isinstance(proptype, hyperdb.Multilink):
1665 # perform link class key value lookup if necessary
1666 link = proptype.classname
1667 link_cl = db.classes[link]
1668 l = []
1669 for entry in value:
1670 if not entry: continue
1671 if not num_re.match(entry):
1672 try:
1673 entry = link_cl.lookup(entry)
1674 except KeyError:
1675 raise ValueError, _('property "%(propname)s": '
1676 '"%(value)s" not an entry of %(classname)s')%{
1677 'propname': propname, 'value': entry,
1678 'classname': link}
1679 except TypeError, message:
1680 raise ValueError, _('you may only enter ID values '
1681 'for property "%(propname)s": %(message)s')%{
1682 'propname': propname, 'message': message}
1683 l.append(entry)
1684 l.sort()
1686 # now use that list of ids to modify the multilink
1687 if mlaction == 'set':
1688 value = l
1689 else:
1690 # we're modifying the list - get the current list of ids
1691 if props.has_key(propname):
1692 existing = props[propname]
1693 elif nodeid and not nodeid.startswith('-'):
1694 existing = cl.get(nodeid, propname, [])
1695 else:
1696 existing = []
1698 # now either remove or add
1699 if mlaction == 'remove':
1700 # remove - handle situation where the id isn't in
1701 # the list
1702 for entry in l:
1703 try:
1704 existing.remove(entry)
1705 except ValueError:
1706 raise ValueError, _('property "%(propname)s": '
1707 '"%(value)s" not currently in list')%{
1708 'propname': propname, 'value': entry}
1709 else:
1710 # add - easy, just don't dupe
1711 for entry in l:
1712 if entry not in existing:
1713 existing.append(entry)
1714 value = existing
1715 value.sort()
1717 elif value == '':
1718 # if we're creating, just don't include this property
1719 if not nodeid or nodeid.startswith('-'):
1720 continue
1721 # other types should be None'd if there's no value
1722 value = None
1723 else:
1724 if isinstance(proptype, hyperdb.String):
1725 if (hasattr(value, 'filename') and
1726 value.filename is not None):
1727 # skip if the upload is empty
1728 if not value.filename:
1729 continue
1730 # this String is actually a _file_
1731 # try to determine the file content-type
1732 filename = value.filename.split('\\')[-1]
1733 if propdef.has_key('name'):
1734 props['name'] = filename
1735 # use this info as the type/filename properties
1736 if propdef.has_key('type'):
1737 props['type'] = mimetypes.guess_type(filename)[0]
1738 if not props['type']:
1739 props['type'] = "application/octet-stream"
1740 # finally, read the content
1741 value = value.value
1742 else:
1743 # normal String fix the CRLF/CR -> LF stuff
1744 value = fixNewlines(value)
1746 elif isinstance(proptype, hyperdb.Date):
1747 value = date.Date(value, offset=timezone)
1748 elif isinstance(proptype, hyperdb.Interval):
1749 value = date.Interval(value)
1750 elif isinstance(proptype, hyperdb.Boolean):
1751 value = value.lower() in ('yes', 'true', 'on', '1')
1752 elif isinstance(proptype, hyperdb.Number):
1753 value = float(value)
1755 # get the old value
1756 if nodeid and not nodeid.startswith('-'):
1757 try:
1758 existing = cl.get(nodeid, propname)
1759 except KeyError:
1760 # this might be a new property for which there is
1761 # no existing value
1762 if not propdef.has_key(propname):
1763 raise
1765 # make sure the existing multilink is sorted
1766 if isinstance(proptype, hyperdb.Multilink):
1767 existing.sort()
1769 # "missing" existing values may not be None
1770 if not existing:
1771 if isinstance(proptype, hyperdb.String) and not existing:
1772 # some backends store "missing" Strings as empty strings
1773 existing = None
1774 elif isinstance(proptype, hyperdb.Number) and not existing:
1775 # some backends store "missing" Numbers as 0 :(
1776 existing = 0
1777 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1778 # likewise Booleans
1779 existing = 0
1781 # if changed, set it
1782 if value != existing:
1783 props[propname] = value
1784 else:
1785 # don't bother setting empty/unset values
1786 if value is None:
1787 continue
1788 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1789 continue
1790 elif isinstance(proptype, hyperdb.String) and value == '':
1791 continue
1793 props[propname] = value
1795 # register this as received if required?
1796 if propname in required and value is not None:
1797 required.remove(propname)
1799 # check to see if we need to specially link a file to the note
1800 if have_note and have_file:
1801 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1803 # see if all the required properties have been supplied
1804 s = []
1805 for thing, required in all_required.items():
1806 if not required:
1807 continue
1808 if len(required) > 1:
1809 p = 'properties'
1810 else:
1811 p = 'property'
1812 s.append('Required %s %s %s not supplied'%(thing[0], p,
1813 ', '.join(required)))
1814 if s:
1815 raise ValueError, '\n'.join(s)
1817 # check that FileClass entries have a "content" property with
1818 # content, otherwise remove them
1819 for (cn, id), props in all_props.items():
1820 cl = self.db.classes[cn]
1821 if not isinstance(cl, hyperdb.FileClass):
1822 continue
1823 # we also don't want to create FileClass items with no content
1824 if not props.get('content', ''):
1825 del all_props[(cn, id)]
1826 return all_props, all_links
1828 def fixNewlines(text):
1829 ''' Homogenise line endings.
1831 Different web clients send different line ending values, but
1832 other systems (eg. email) don't necessarily handle those line
1833 endings. Our solution is to convert all line endings to LF.
1834 '''
1835 text = text.replace('\r\n', '\n')
1836 return text.replace('\r', '\n')
1838 def extractFormList(value):
1839 ''' Extract a list of values from the form value.
1841 It may be one of:
1842 [MiniFieldStorage, MiniFieldStorage, ...]
1843 MiniFieldStorage('value,value,...')
1844 MiniFieldStorage('value')
1845 '''
1846 # multiple values are OK
1847 if isinstance(value, type([])):
1848 # it's a list of MiniFieldStorages
1849 value = [i.value.strip() for i in value]
1850 else:
1851 # it's a MiniFieldStorage, but may be a comma-separated list
1852 # of values
1853 value = [i.strip() for i in value.value.split(',')]
1855 # filter out the empty bits
1856 return filter(None, value)