fe8831371b8590243b153b644e93d7bd4bef0411
1 # $Id: client.py,v 1.149 2003-12-05 03:28:38 richard Exp $
3 __doc__ = """
4 WWW request handler (also used in the stand-alone server).
5 """
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random, MimeWriter, smtplib, socket, quopri
9 import stat, rfc822
11 from roundup import roundupdb, date, hyperdb, password, token, rcsv
12 from roundup.i18n import _
13 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
14 from roundup.cgi import cgitb
15 from roundup.cgi.PageTemplates import PageTemplate
16 from roundup.rfc2822 import encode_header
17 from roundup.mailgw import uidFromAddress
18 from roundup.mailer import Mailer, MessageSendError
20 class HTTPException(Exception):
21 pass
22 class Unauthorised(HTTPException):
23 pass
24 class NotFound(HTTPException):
25 pass
26 class Redirect(HTTPException):
27 pass
28 class NotModified(HTTPException):
29 pass
31 # used by a couple of routines
32 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
34 class FormError(ValueError):
35 """ An "expected" exception occurred during form parsing.
36 - ie. something we know can go wrong, and don't want to alarm the
37 user with
39 We trap this at the user interface level and feed back a nice error
40 to the user.
41 """
42 pass
44 class SendFile(Exception):
45 ''' Send a file from the database '''
47 class SendStaticFile(Exception):
48 ''' Send a static file from the instance html directory '''
50 def initialiseSecurity(security):
51 ''' Create some Permissions and Roles on the security object
53 This function is directly invoked by security.Security.__init__()
54 as a part of the Security object instantiation.
55 '''
56 security.addPermission(name="Web Registration",
57 description="User may register through the web")
58 p = security.addPermission(name="Web Access",
59 description="User may access the web interface")
60 security.addPermissionToRole('Admin', p)
62 # doing Role stuff through the web - make sure Admin can
63 p = security.addPermission(name="Web Roles",
64 description="User may manipulate user Roles through the web")
65 security.addPermissionToRole('Admin', p)
67 # used to clean messages passed through CGI variables - HTML-escape any tag
68 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
69 # that people can't pass through nasties like <script>, <iframe>, ...
70 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
71 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
72 return mc.sub(clean_message_callback, message)
73 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
74 ''' Strip all non <a>,<i>,<b> and <br> tags from a string
75 '''
76 if ok.has_key(match.group(3).lower()):
77 return match.group(1)
78 return '<%s>'%match.group(2)
80 class Client:
81 ''' Instantiate to handle one CGI request.
83 See inner_main for request processing.
85 Client attributes at instantiation:
86 "path" is the PATH_INFO inside the instance (with no leading '/')
87 "base" is the base URL for the instance
88 "form" is the cgi form, an instance of FieldStorage from the standard
89 cgi module
90 "additional_headers" is a dictionary of additional HTTP headers that
91 should be sent to the client
92 "response_code" is the HTTP response code to send to the client
94 During the processing of a request, the following attributes are used:
95 "error_message" holds a list of error messages
96 "ok_message" holds a list of OK messages
97 "session" is the current user session id
98 "user" is the current user's name
99 "userid" is the current user's id
100 "template" is the current :template context
101 "classname" is the current class context name
102 "nodeid" is the current context item id
104 User Identification:
105 If the user has no login cookie, then they are anonymous and are logged
106 in as that user. This typically gives them all Permissions assigned to the
107 Anonymous Role.
109 Once a user logs in, they are assigned a session. The Client instance
110 keeps the nodeid of the session as the "session" attribute.
113 Special form variables:
114 Note that in various places throughout this code, special form
115 variables of the form :<name> are used. The colon (":") part may
116 actually be one of either ":" or "@".
117 '''
119 #
120 # special form variables
121 #
122 FV_TEMPLATE = re.compile(r'[@:]template')
123 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
124 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
126 FV_QUERYNAME = re.compile(r'[@:]queryname')
128 # edit form variable handling (see unit tests)
129 FV_LABELS = r'''
130 ^(
131 (?P<note>[@:]note)|
132 (?P<file>[@:]file)|
133 (
134 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
135 ((?P<required>[@:]required$)| # :required
136 (
137 (
138 (?P<add>[@:]add[@:])| # :add:<prop>
139 (?P<remove>[@:]remove[@:])| # :remove:<prop>
140 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
141 (?P<link>[@:]link[@:])| # :link:<prop>
142 ([@:]) # just a separator
143 )?
144 (?P<propname>[^@:]+) # <prop>
145 )
146 )
147 )
148 )$'''
150 # Note: index page stuff doesn't appear here:
151 # columns, sort, sortdir, filter, group, groupdir, search_text,
152 # pagesize, startwith
154 def __init__(self, instance, request, env, form=None):
155 hyperdb.traceMark()
156 self.instance = instance
157 self.request = request
158 self.env = env
159 self.mailer = Mailer(instance.config)
161 # save off the path
162 self.path = env['PATH_INFO']
164 # this is the base URL for this tracker
165 self.base = self.instance.config.TRACKER_WEB
167 # this is the "cookie path" for this tracker (ie. the path part of
168 # the "base" url)
169 self.cookie_path = urlparse.urlparse(self.base)[2]
170 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
171 self.instance.config.TRACKER_NAME)
173 # see if we need to re-parse the environment for the form (eg Zope)
174 if form is None:
175 self.form = cgi.FieldStorage(environ=env)
176 else:
177 self.form = form
179 # turn debugging on/off
180 try:
181 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
182 except ValueError:
183 # someone gave us a non-int debug level, turn it off
184 self.debug = 0
186 # flag to indicate that the HTTP headers have been sent
187 self.headers_done = 0
189 # additional headers to send with the request - must be registered
190 # before the first write
191 self.additional_headers = {}
192 self.response_code = 200
195 def main(self):
196 ''' Wrap the real main in a try/finally so we always close off the db.
197 '''
198 try:
199 self.inner_main()
200 finally:
201 if hasattr(self, 'db'):
202 self.db.close()
204 def inner_main(self):
205 ''' Process a request.
207 The most common requests are handled like so:
208 1. figure out who we are, defaulting to the "anonymous" user
209 see determine_user
210 2. figure out what the request is for - the context
211 see determine_context
212 3. handle any requested action (item edit, search, ...)
213 see handle_action
214 4. render a template, resulting in HTML output
216 In some situations, exceptions occur:
217 - HTTP Redirect (generally raised by an action)
218 - SendFile (generally raised by determine_context)
219 serve up a FileClass "content" property
220 - SendStaticFile (generally raised by determine_context)
221 serve up a file from the tracker "html" directory
222 - Unauthorised (generally raised by an action)
223 the action is cancelled, the request is rendered and an error
224 message is displayed indicating that permission was not
225 granted for the action to take place
226 - NotFound (raised wherever it needs to be)
227 percolates up to the CGI interface that called the client
228 '''
229 self.ok_message = []
230 self.error_message = []
231 try:
232 # figure out the context and desired content template
233 # do this first so we don't authenticate for static files
234 # Note: this method opens the database as "admin" in order to
235 # perform context checks
236 self.determine_context()
238 # make sure we're identified (even anonymously)
239 self.determine_user()
241 # possibly handle a form submit action (may change self.classname
242 # and self.template, and may also append error/ok_messages)
243 self.handle_action()
245 # now render the page
246 # we don't want clients caching our dynamic pages
247 self.additional_headers['Cache-Control'] = 'no-cache'
248 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
249 # self.additional_headers['Pragma'] = 'no-cache'
251 # expire this page 5 seconds from now
252 date = rfc822.formatdate(time.time() + 5)
253 self.additional_headers['Expires'] = date
255 # render the content
256 self.write(self.renderContext())
257 except Redirect, url:
258 # let's redirect - if the url isn't None, then we need to do
259 # the headers, otherwise the headers have been set before the
260 # exception was raised
261 if url:
262 self.additional_headers['Location'] = url
263 self.response_code = 302
264 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
265 except SendFile, designator:
266 self.serve_file(designator)
267 except SendStaticFile, file:
268 try:
269 self.serve_static_file(str(file))
270 except NotModified:
271 # send the 304 response
272 self.request.send_response(304)
273 self.request.end_headers()
274 except Unauthorised, message:
275 self.classname = None
276 self.template = ''
277 self.error_message.append(message)
278 self.write(self.renderContext())
279 except NotFound:
280 # pass through
281 raise
282 except FormError, e:
283 self.error_message.append(_('Form Error: ') + str(e))
284 self.write(self.renderContext())
285 except:
286 # everything else
287 self.write(cgitb.html())
289 def clean_sessions(self):
290 """Age sessions, remove when they haven't been used for a week.
292 Do it only once an hour.
294 Note: also cleans One Time Keys, and other "session" based stuff.
295 """
296 sessions = self.db.sessions
297 last_clean = sessions.get('last_clean', 'last_use') or 0
299 week = 60*60*24*7
300 hour = 60*60
301 now = time.time()
302 if now - last_clean > hour:
303 # remove aged sessions
304 for sessid in sessions.list():
305 interval = now - sessions.get(sessid, 'last_use')
306 if interval > week:
307 sessions.destroy(sessid)
308 # remove aged otks
309 otks = self.db.otks
310 for sessid in otks.list():
311 interval = now - otks.get(sessid, '__time')
312 if interval > week:
313 otks.destroy(sessid)
314 sessions.set('last_clean', last_use=time.time())
316 def determine_user(self):
317 '''Determine who the user is.
318 '''
319 # open the database as admin
320 self.opendb('admin')
322 # clean age sessions
323 self.clean_sessions()
325 # make sure we have the session Class
326 sessions = self.db.sessions
328 # look up the user session cookie
329 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
330 user = 'anonymous'
332 # bump the "revision" of the cookie since the format changed
333 if (cookie.has_key(self.cookie_name) and
334 cookie[self.cookie_name].value != 'deleted'):
336 # get the session key from the cookie
337 self.session = cookie[self.cookie_name].value
338 # get the user from the session
339 try:
340 # update the lifetime datestamp
341 sessions.set(self.session, last_use=time.time())
342 sessions.commit()
343 user = sessions.get(self.session, 'user')
344 except KeyError:
345 user = 'anonymous'
347 # sanity check on the user still being valid, getting the userid
348 # at the same time
349 try:
350 self.userid = self.db.user.lookup(user)
351 except (KeyError, TypeError):
352 user = 'anonymous'
354 # make sure the anonymous user is valid if we're using it
355 if user == 'anonymous':
356 self.make_user_anonymous()
357 else:
358 self.user = user
360 # reopen the database as the correct user
361 self.opendb(self.user)
363 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
364 """ Determine the context of this page from the URL:
366 The URL path after the instance identifier is examined. The path
367 is generally only one entry long.
369 - if there is no path, then we are in the "home" context.
370 * if the path is "_file", then the additional path entry
371 specifies the filename of a static file we're to serve up
372 from the instance "html" directory. Raises a SendStaticFile
373 exception.
374 - if there is something in the path (eg "issue"), it identifies
375 the tracker class we're to display.
376 - if the path is an item designator (eg "issue123"), then we're
377 to display a specific item.
378 * if the path starts with an item designator and is longer than
379 one entry, then we're assumed to be handling an item of a
380 FileClass, and the extra path information gives the filename
381 that the client is going to label the download with (ie
382 "file123/image.png" is nicer to download than "file123"). This
383 raises a SendFile exception.
385 Both of the "*" types of contexts stop before we bother to
386 determine the template we're going to use. That's because they
387 don't actually use templates.
389 The template used is specified by the :template CGI variable,
390 which defaults to:
392 only classname suplied: "index"
393 full item designator supplied: "item"
395 We set:
396 self.classname - the class to display, can be None
397 self.template - the template to render the current context with
398 self.nodeid - the nodeid of the class we're displaying
399 """
400 # default the optional variables
401 self.classname = None
402 self.nodeid = None
404 # see if a template or messages are specified
405 template_override = ok_message = error_message = None
406 for key in self.form.keys():
407 if self.FV_TEMPLATE.match(key):
408 template_override = self.form[key].value
409 elif self.FV_OK_MESSAGE.match(key):
410 ok_message = self.form[key].value
411 ok_message = clean_message(ok_message)
412 elif self.FV_ERROR_MESSAGE.match(key):
413 error_message = self.form[key].value
414 error_message = clean_message(error_message)
416 # determine the classname and possibly nodeid
417 path = self.path.split('/')
418 if not path or path[0] in ('', 'home', 'index'):
419 if template_override is not None:
420 self.template = template_override
421 else:
422 self.template = ''
423 return
424 elif path[0] in ('_file', '@@file'):
425 raise SendStaticFile, os.path.join(*path[1:])
426 else:
427 self.classname = path[0]
428 if len(path) > 1:
429 # send the file identified by the designator in path[0]
430 raise SendFile, path[0]
432 # we need the db for further context stuff - open it as admin
433 self.opendb('admin')
435 # see if we got a designator
436 m = dre.match(self.classname)
437 if m:
438 self.classname = m.group(1)
439 self.nodeid = m.group(2)
440 if not self.db.getclass(self.classname).hasnode(self.nodeid):
441 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
442 # with a designator, we default to item view
443 self.template = 'item'
444 else:
445 # with only a class, we default to index view
446 self.template = 'index'
448 # make sure the classname is valid
449 try:
450 self.db.getclass(self.classname)
451 except KeyError:
452 raise NotFound, self.classname
454 # see if we have a template override
455 if template_override is not None:
456 self.template = template_override
458 # see if we were passed in a message
459 if ok_message:
460 self.ok_message.append(ok_message)
461 if error_message:
462 self.error_message.append(error_message)
464 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
465 ''' Serve the file from the content property of the designated item.
466 '''
467 m = dre.match(str(designator))
468 if not m:
469 raise NotFound, str(designator)
470 classname, nodeid = m.group(1), m.group(2)
471 if classname != 'file':
472 raise NotFound, designator
474 self.opendb('admin')
475 file = self.db.file
477 mime_type = file.get(nodeid, 'type')
478 content = file.get(nodeid, 'content')
479 lmt = file.get(nodeid, 'activity').timestamp()
481 self._serve_file(lmt, mime_type, content)
483 def serve_static_file(self, file):
484 ''' Serve up the file named from the templates dir
485 '''
486 filename = os.path.join(self.instance.config.TEMPLATES, file)
488 # last-modified time
489 lmt = os.stat(filename)[stat.ST_MTIME]
491 # detemine meta-type
492 file = str(file)
493 mime_type = mimetypes.guess_type(file)[0]
494 if not mime_type:
495 if file.endswith('.css'):
496 mime_type = 'text/css'
497 else:
498 mime_type = 'text/plain'
500 # snarf the content
501 f = open(filename, 'rb')
502 try:
503 content = f.read()
504 finally:
505 f.close()
507 self._serve_file(lmt, mime_type, content)
509 def _serve_file(self, last_modified, mime_type, content):
510 ''' guts of serve_file() and serve_static_file()
511 '''
512 ims = None
513 # see if there's an if-modified-since...
514 if hasattr(self.request, 'headers'):
515 ims = self.request.headers.getheader('if-modified-since')
516 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
517 # cgi will put the header in the env var
518 ims = self.env['HTTP_IF_MODIFIED_SINCE']
519 if ims:
520 ims = rfc822.parsedate(ims)[:6]
521 lmtt = time.gmtime(lmt)[:6]
522 if lmtt <= ims:
523 raise NotModified
525 # spit out headers
526 self.additional_headers['Content-Type'] = mime_type
527 self.additional_headers['Content-Length'] = len(content)
528 lmt = rfc822.formatdate(last_modified)
529 self.additional_headers['Last-Modifed'] = lmt
530 self.write(content)
532 def renderContext(self):
533 ''' Return a PageTemplate for the named page
534 '''
535 name = self.classname
536 extension = self.template
537 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
539 # catch errors so we can handle PT rendering errors more nicely
540 args = {
541 'ok_message': self.ok_message,
542 'error_message': self.error_message
543 }
544 try:
545 # let the template render figure stuff out
546 return pt.render(self, None, None, **args)
547 except NoTemplate, message:
548 return '<strong>%s</strong>'%message
549 except:
550 # everything else
551 return cgitb.pt_html()
553 # these are the actions that are available
554 actions = (
555 ('edit', 'editItemAction'),
556 ('editcsv', 'editCSVAction'),
557 ('new', 'newItemAction'),
558 ('register', 'registerAction'),
559 ('confrego', 'confRegoAction'),
560 ('passrst', 'passResetAction'),
561 ('login', 'loginAction'),
562 ('logout', 'logout_action'),
563 ('search', 'searchAction'),
564 ('retire', 'retireAction'),
565 ('show', 'showAction'),
566 )
567 def handle_action(self):
568 ''' Determine whether there should be an Action called.
570 The action is defined by the form variable :action which
571 identifies the method on this object to call. The actions
572 are defined in the "actions" sequence on this class.
573 '''
574 if self.form.has_key(':action'):
575 action = self.form[':action'].value.lower()
576 elif self.form.has_key('@action'):
577 action = self.form['@action'].value.lower()
578 else:
579 return None
580 try:
581 # get the action, validate it
582 for name, method in self.actions:
583 if name == action:
584 break
585 else:
586 raise ValueError, 'No such action "%s"'%action
587 # call the mapped action
588 getattr(self, method)()
589 except Redirect:
590 raise
591 except Unauthorised:
592 raise
594 def write(self, content):
595 if not self.headers_done:
596 self.header()
597 self.request.wfile.write(content)
599 def header(self, headers=None, response=None):
600 '''Put up the appropriate header.
601 '''
602 if headers is None:
603 headers = {'Content-Type':'text/html'}
604 if response is None:
605 response = self.response_code
607 # update with additional info
608 headers.update(self.additional_headers)
610 if not headers.has_key('Content-Type'):
611 headers['Content-Type'] = 'text/html'
612 self.request.send_response(response)
613 for entry in headers.items():
614 self.request.send_header(*entry)
615 self.request.end_headers()
616 self.headers_done = 1
617 if self.debug:
618 self.headers_sent = headers
620 def set_cookie(self, user):
621 """Set up a session cookie for the user.
623 Also store away the user's login info against the session.
624 """
625 # TODO generate a much, much stronger session key ;)
626 self.session = binascii.b2a_base64(repr(random.random())).strip()
628 # clean up the base64
629 if self.session[-1] == '=':
630 if self.session[-2] == '=':
631 self.session = self.session[:-2]
632 else:
633 self.session = self.session[:-1]
635 # insert the session in the sessiondb
636 self.db.sessions.set(self.session, user=user, last_use=time.time())
638 # and commit immediately
639 self.db.sessions.commit()
641 # expire us in a long, long time
642 expire = Cookie._getdate(86400*365)
644 # generate the cookie path - make sure it has a trailing '/'
645 self.additional_headers['Set-Cookie'] = \
646 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
647 expire, self.cookie_path)
649 def make_user_anonymous(self):
650 ''' Make us anonymous
652 This method used to handle non-existence of the 'anonymous'
653 user, but that user is mandatory now.
654 '''
655 self.userid = self.db.user.lookup('anonymous')
656 self.user = 'anonymous'
658 def opendb(self, user):
659 ''' Open the database.
660 '''
661 # open the db if the user has changed
662 if not hasattr(self, 'db') or user != self.db.journaltag:
663 if hasattr(self, 'db'):
664 self.db.close()
665 self.db = self.instance.open(user)
667 #
668 # Actions
669 #
670 def loginAction(self):
671 ''' Attempt to log a user in.
673 Sets up a session for the user which contains the login
674 credentials.
675 '''
676 # we need the username at a minimum
677 if not self.form.has_key('__login_name'):
678 self.error_message.append(_('Username required'))
679 return
681 # get the login info
682 self.user = self.form['__login_name'].value
683 if self.form.has_key('__login_password'):
684 password = self.form['__login_password'].value
685 else:
686 password = ''
688 # make sure the user exists
689 try:
690 self.userid = self.db.user.lookup(self.user)
691 except KeyError:
692 name = self.user
693 self.error_message.append(_('No such user "%(name)s"')%locals())
694 self.make_user_anonymous()
695 return
697 # verify the password
698 if not self.verifyPassword(self.userid, password):
699 self.make_user_anonymous()
700 self.error_message.append(_('Incorrect password'))
701 return
703 # make sure we're allowed to be here
704 if not self.loginPermission():
705 self.make_user_anonymous()
706 self.error_message.append(_("You do not have permission to login"))
707 return
709 # now we're OK, re-open the database for real, using the user
710 self.opendb(self.user)
712 # set the session cookie
713 self.set_cookie(self.user)
715 def verifyPassword(self, userid, password):
716 ''' Verify the password that the user has supplied
717 '''
718 stored = self.db.user.get(self.userid, 'password')
719 if password == stored:
720 return 1
721 if not password and not stored:
722 return 1
723 return 0
725 def loginPermission(self):
726 ''' Determine whether the user has permission to log in.
728 Base behaviour is to check the user has "Web Access".
729 '''
730 if not self.db.security.hasPermission('Web Access', self.userid):
731 return 0
732 return 1
734 def logout_action(self):
735 ''' Make us really anonymous - nuke the cookie too
736 '''
737 # log us out
738 self.make_user_anonymous()
740 # construct the logout cookie
741 now = Cookie._getdate()
742 self.additional_headers['Set-Cookie'] = \
743 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
744 now, self.cookie_path)
746 # Let the user know what's going on
747 self.ok_message.append(_('You are logged out'))
749 def registerAction(self):
750 '''Attempt to create a new user based on the contents of the form
751 and then set the cookie.
753 return 1 on successful login
754 '''
755 props = self.parsePropsFromForm()[0][('user', None)]
757 # make sure we're allowed to register
758 if not self.registerPermission(props):
759 raise Unauthorised, _("You do not have permission to register")
761 try:
762 self.db.user.lookup(props['username'])
763 self.error_message.append('Error: A user with the username "%s" '
764 'already exists'%props['username'])
765 return
766 except KeyError:
767 pass
769 # generate the one-time-key and store the props for later
770 otk = ''.join([random.choice(chars) for x in range(32)])
771 for propname, proptype in self.db.user.getprops().items():
772 value = props.get(propname, None)
773 if value is None:
774 pass
775 elif isinstance(proptype, hyperdb.Date):
776 props[propname] = str(value)
777 elif isinstance(proptype, hyperdb.Interval):
778 props[propname] = str(value)
779 elif isinstance(proptype, hyperdb.Password):
780 props[propname] = str(value)
781 props['__time'] = time.time()
782 self.db.otks.set(otk, **props)
784 # send the email
785 tracker_name = self.db.config.TRACKER_NAME
786 tracker_email = self.db.config.TRACKER_EMAIL
787 subject = 'Complete your registration to %s -- key %s' % (tracker_name,
788 otk)
789 body = """To complete your registration of the user "%(name)s" with
790 %(tracker)s, please do one of the following:
792 - send a reply to %(tracker_email)s and maintain the subject line as is (the
793 reply's additional "Re:" is ok),
795 - or visit the following URL:
797 %(url)s?@action=confrego&otk=%(otk)s
798 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
799 'otk': otk, 'tracker_email': tracker_email}
800 if not self.standard_message([props['address']], subject, body,
801 tracker_email):
802 return
804 # commit changes to the database
805 self.db.commit()
807 # redirect to the "you're almost there" page
808 raise Redirect, '%suser?@template=rego_progress'%self.base
810 def standard_message(self, to, subject, body, author=None):
811 try:
812 self.mailer.standard_message(to, subject, body, author)
813 return 1
814 except MessageSendError, e:
815 self.error_message.append(str(e))
817 def registerPermission(self, props):
818 ''' Determine whether the user has permission to register
820 Base behaviour is to check the user has "Web Registration".
821 '''
822 # registration isn't allowed to supply roles
823 if props.has_key('roles'):
824 return 0
825 if self.db.security.hasPermission('Web Registration', self.userid):
826 return 1
827 return 0
829 def confRegoAction(self):
830 ''' Grab the OTK, use it to load up the new user details
831 '''
832 try:
833 # pull the rego information out of the otk database
834 self.userid = self.db.confirm_registration(self.form['otk'].value)
835 except (ValueError, KeyError), message:
836 # XXX: we need to make the "default" page be able to display errors!
837 self.error_message.append(str(message))
838 return
840 # log the new user in
841 self.user = self.db.user.get(self.userid, 'username')
842 # re-open the database for real, using the user
843 self.opendb(self.user)
845 # if we have a session, update it
846 if hasattr(self, 'session'):
847 self.db.sessions.set(self.session, user=self.user,
848 last_use=time.time())
849 else:
850 # new session cookie
851 self.set_cookie(self.user)
853 # nice message
854 message = _('You are now registered, welcome!')
856 # redirect to the user's page
857 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
858 self.userid, urllib.quote(message))
860 def passResetAction(self):
861 ''' Handle password reset requests.
863 Presence of either "name" or "address" generate email.
864 Presense of "otk" performs the reset.
865 '''
866 if self.form.has_key('otk'):
867 # pull the rego information out of the otk database
868 otk = self.form['otk'].value
869 uid = self.db.otks.get(otk, 'uid')
870 if uid is None:
871 self.error_message.append("""Invalid One Time Key!
872 (a Mozilla bug may cause this message to show up erroneously,
873 please check your email)""")
874 return
876 # re-open the database as "admin"
877 if self.user != 'admin':
878 self.opendb('admin')
880 # change the password
881 newpw = password.generatePassword()
883 cl = self.db.user
884 # XXX we need to make the "default" page be able to display errors!
885 try:
886 # set the password
887 cl.set(uid, password=password.Password(newpw))
888 # clear the props from the otk database
889 self.db.otks.destroy(otk)
890 self.db.commit()
891 except (ValueError, KeyError), message:
892 self.error_message.append(str(message))
893 return
895 # user info
896 address = self.db.user.get(uid, 'address')
897 name = self.db.user.get(uid, 'username')
899 # send the email
900 tracker_name = self.db.config.TRACKER_NAME
901 subject = 'Password reset for %s'%tracker_name
902 body = '''
903 The password has been reset for username "%(name)s".
905 Your password is now: %(password)s
906 '''%{'name': name, 'password': newpw}
907 if not self.standard_message([address], subject, body):
908 return
910 self.ok_message.append('Password reset and email sent to %s' %
911 address)
912 return
914 # no OTK, so now figure the user
915 if self.form.has_key('username'):
916 name = self.form['username'].value
917 try:
918 uid = self.db.user.lookup(name)
919 except KeyError:
920 self.error_message.append('Unknown username')
921 return
922 address = self.db.user.get(uid, 'address')
923 elif self.form.has_key('address'):
924 address = self.form['address'].value
925 uid = uidFromAddress(self.db, ('', address), create=0)
926 if not uid:
927 self.error_message.append('Unknown email address')
928 return
929 name = self.db.user.get(uid, 'username')
930 else:
931 self.error_message.append('You need to specify a username '
932 'or address')
933 return
935 # generate the one-time-key and store the props for later
936 otk = ''.join([random.choice(chars) for x in range(32)])
937 self.db.otks.set(otk, uid=uid, __time=time.time())
939 # send the email
940 tracker_name = self.db.config.TRACKER_NAME
941 subject = 'Confirm reset of password for %s'%tracker_name
942 body = '''
943 Someone, perhaps you, has requested that the password be changed for your
944 username, "%(name)s". If you wish to proceed with the change, please follow
945 the link below:
947 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
949 You should then receive another email with the new password.
950 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
951 if not self.standard_message([address], subject, body):
952 return
954 self.ok_message.append('Email sent to %s'%address)
956 def editItemAction(self):
957 ''' Perform an edit of an item in the database.
959 See parsePropsFromForm and _editnodes for special variables
960 '''
961 props, links = self.parsePropsFromForm()
963 # handle the props
964 try:
965 message = self._editnodes(props, links)
966 except (ValueError, KeyError, IndexError), message:
967 self.error_message.append(_('Apply 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 newItemAction = editItemAction
980 def editItemPermission(self, props):
981 """Determine whether the user has permission to edit this item.
983 Base behaviour is to check the user can edit this class. If we're
984 editing the"user" class, users are allowed to edit their own details.
985 Unless it's the "roles" property, which requires the special Permission
986 "Web Roles".
987 """
988 # if this is a user node and the user is editing their own node, then
989 # we're OK
990 has = self.db.security.hasPermission
991 if self.classname == 'user':
992 # reject if someone's trying to edit "roles" and doesn't have the
993 # right permission.
994 if props.has_key('roles') and not has('Web Roles', self.userid,
995 'user'):
996 return 0
997 # if the item being edited is the current user, we're ok
998 if (self.nodeid == self.userid
999 and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
1000 return 1
1001 if self.db.security.hasPermission('Edit', self.userid, self.classname):
1002 return 1
1003 return 0
1005 def newItemPermission(self, props):
1006 ''' Determine whether the user has permission to create (edit) this
1007 item.
1009 Base behaviour is to check the user can edit this class. No
1010 additional property checks are made. Additionally, new user items
1011 may be created if the user has the "Web Registration" Permission.
1012 '''
1013 has = self.db.security.hasPermission
1014 if self.classname == 'user' and has('Web Registration', self.userid,
1015 'user'):
1016 return 1
1017 if has('Edit', self.userid, self.classname):
1018 return 1
1019 return 0
1022 #
1023 # Utility methods for editing
1024 #
1025 def _editnodes(self, all_props, all_links, newids=None):
1026 ''' Use the props in all_props to perform edit and creation, then
1027 use the link specs in all_links to do linking.
1028 '''
1029 # figure dependencies and re-work links
1030 deps = {}
1031 links = {}
1032 for cn, nodeid, propname, vlist in all_links:
1033 if not all_props.has_key((cn, nodeid)):
1034 # link item to link to doesn't (and won't) exist
1035 continue
1036 for value in vlist:
1037 if not all_props.has_key(value):
1038 # link item to link to doesn't (and won't) exist
1039 continue
1040 deps.setdefault((cn, nodeid), []).append(value)
1041 links.setdefault(value, []).append((cn, nodeid, propname))
1043 # figure chained dependencies ordering
1044 order = []
1045 done = {}
1046 # loop detection
1047 change = 0
1048 while len(all_props) != len(done):
1049 for needed in all_props.keys():
1050 if done.has_key(needed):
1051 continue
1052 tlist = deps.get(needed, [])
1053 for target in tlist:
1054 if not done.has_key(target):
1055 break
1056 else:
1057 done[needed] = 1
1058 order.append(needed)
1059 change = 1
1060 if not change:
1061 raise ValueError, 'linking must not loop!'
1063 # now, edit / create
1064 m = []
1065 for needed in order:
1066 props = all_props[needed]
1067 if not props:
1068 # nothing to do
1069 continue
1070 cn, nodeid = needed
1072 if nodeid is not None and int(nodeid) > 0:
1073 # make changes to the node
1074 props = self._changenode(cn, nodeid, props)
1076 # and some nice feedback for the user
1077 if props:
1078 info = ', '.join(props.keys())
1079 m.append('%s %s %s edited ok'%(cn, nodeid, info))
1080 else:
1081 m.append('%s %s - nothing changed'%(cn, nodeid))
1082 else:
1083 assert props
1085 # make a new node
1086 newid = self._createnode(cn, props)
1087 if nodeid is None:
1088 self.nodeid = newid
1089 nodeid = newid
1091 # and some nice feedback for the user
1092 m.append('%s %s created'%(cn, newid))
1094 # fill in new ids in links
1095 if links.has_key(needed):
1096 for linkcn, linkid, linkprop in links[needed]:
1097 props = all_props[(linkcn, linkid)]
1098 cl = self.db.classes[linkcn]
1099 propdef = cl.getprops()[linkprop]
1100 if not props.has_key(linkprop):
1101 if linkid is None or linkid.startswith('-'):
1102 # linking to a new item
1103 if isinstance(propdef, hyperdb.Multilink):
1104 props[linkprop] = [newid]
1105 else:
1106 props[linkprop] = newid
1107 else:
1108 # linking to an existing item
1109 if isinstance(propdef, hyperdb.Multilink):
1110 existing = cl.get(linkid, linkprop)[:]
1111 existing.append(nodeid)
1112 props[linkprop] = existing
1113 else:
1114 props[linkprop] = newid
1116 return '<br>'.join(m)
1118 def _changenode(self, cn, nodeid, props):
1119 ''' change the node based on the contents of the form
1120 '''
1121 # check for permission
1122 if not self.editItemPermission(props):
1123 raise Unauthorised, 'You do not have permission to edit %s'%cn
1125 # make the changes
1126 cl = self.db.classes[cn]
1127 return cl.set(nodeid, **props)
1129 def _createnode(self, cn, props):
1130 ''' create a node based on the contents of the form
1131 '''
1132 # check for permission
1133 if not self.newItemPermission(props):
1134 raise Unauthorised, 'You do not have permission to create %s'%cn
1136 # create the node and return its id
1137 cl = self.db.classes[cn]
1138 return cl.create(**props)
1140 #
1141 # More actions
1142 #
1143 def editCSVAction(self):
1144 """ Performs an edit of all of a class' items in one go.
1146 The "rows" CGI var defines the CSV-formatted entries for the
1147 class. New nodes are identified by the ID 'X' (or any other
1148 non-existent ID) and removed lines are retired.
1149 """
1150 # this is per-class only
1151 if not self.editCSVPermission():
1152 self.error_message.append(
1153 _('You do not have permission to edit %s' %self.classname))
1154 return
1156 # get the CSV module
1157 if rcsv.error:
1158 self.error_message.append(_(rcsv.error))
1159 return
1161 cl = self.db.classes[self.classname]
1162 idlessprops = cl.getprops(protected=0).keys()
1163 idlessprops.sort()
1164 props = ['id'] + idlessprops
1166 # do the edit
1167 rows = StringIO.StringIO(self.form['rows'].value)
1168 reader = rcsv.reader(rows, rcsv.comma_separated)
1169 found = {}
1170 line = 0
1171 for values in reader:
1172 line += 1
1173 if line == 1: continue
1174 # skip property names header
1175 if values == props:
1176 continue
1178 # extract the nodeid
1179 nodeid, values = values[0], values[1:]
1180 found[nodeid] = 1
1182 # see if the node exists
1183 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
1184 exists = 0
1185 else:
1186 exists = 1
1188 # confirm correct weight
1189 if len(idlessprops) != len(values):
1190 self.error_message.append(
1191 _('Not enough values on line %(line)s')%{'line':line})
1192 return
1194 # extract the new values
1195 d = {}
1196 for name, value in zip(idlessprops, values):
1197 prop = cl.properties[name]
1198 value = value.strip()
1199 # only add the property if it has a value
1200 if value:
1201 # if it's a multilink, split it
1202 if isinstance(prop, hyperdb.Multilink):
1203 value = value.split(':')
1204 elif isinstance(prop, hyperdb.Password):
1205 value = password.Password(value)
1206 elif isinstance(prop, hyperdb.Interval):
1207 value = date.Interval(value)
1208 elif isinstance(prop, hyperdb.Date):
1209 value = date.Date(value)
1210 elif isinstance(prop, hyperdb.Boolean):
1211 value = value.lower() in ('yes', 'true', 'on', '1')
1212 elif isinstance(prop, hyperdb.Number):
1213 value = float(value)
1214 d[name] = value
1215 elif exists:
1216 # nuke the existing value
1217 if isinstance(prop, hyperdb.Multilink):
1218 d[name] = []
1219 else:
1220 d[name] = None
1222 # perform the edit
1223 if exists:
1224 # edit existing
1225 cl.set(nodeid, **d)
1226 else:
1227 # new node
1228 found[cl.create(**d)] = 1
1230 # retire the removed entries
1231 for nodeid in cl.list():
1232 if not found.has_key(nodeid):
1233 cl.retire(nodeid)
1235 # all OK
1236 self.db.commit()
1238 self.ok_message.append(_('Items edited OK'))
1240 def editCSVPermission(self):
1241 ''' Determine whether the user has permission to edit this class.
1243 Base behaviour is to check the user can edit this class.
1244 '''
1245 if not self.db.security.hasPermission('Edit', self.userid,
1246 self.classname):
1247 return 0
1248 return 1
1250 def searchAction(self, wcre=re.compile(r'[\s,]+')):
1251 ''' Mangle some of the form variables.
1253 Set the form ":filter" variable based on the values of the
1254 filter variables - if they're set to anything other than
1255 "dontcare" then add them to :filter.
1257 Handle the ":queryname" variable and save off the query to
1258 the user's query list.
1260 Split any String query values on whitespace and comma.
1261 '''
1262 # generic edit is per-class only
1263 if not self.searchPermission():
1264 self.error_message.append(
1265 _('You do not have permission to search %s' %self.classname))
1266 return
1268 # add a faked :filter form variable for each filtering prop
1269 props = self.db.classes[self.classname].getprops()
1270 queryname = ''
1271 for key in self.form.keys():
1272 # special vars
1273 if self.FV_QUERYNAME.match(key):
1274 queryname = self.form[key].value.strip()
1275 continue
1277 if not props.has_key(key):
1278 continue
1279 if isinstance(self.form[key], type([])):
1280 # search for at least one entry which is not empty
1281 for minifield in self.form[key]:
1282 if minifield.value:
1283 break
1284 else:
1285 continue
1286 else:
1287 if not self.form[key].value:
1288 continue
1289 if isinstance(props[key], hyperdb.String):
1290 v = self.form[key].value
1291 l = token.token_split(v)
1292 if len(l) > 1 or l[0] != v:
1293 self.form.value.remove(self.form[key])
1294 # replace the single value with the split list
1295 for v in l:
1296 self.form.value.append(cgi.MiniFieldStorage(key, v))
1298 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1300 # handle saving the query params
1301 if queryname:
1302 # parse the environment and figure what the query _is_
1303 req = HTMLRequest(self)
1305 # The [1:] strips off the '?' character, it isn't part of the
1306 # query string.
1307 url = req.indexargs_href('', {})[1:]
1309 # handle editing an existing query
1310 try:
1311 qid = self.db.query.lookup(queryname)
1312 self.db.query.set(qid, klass=self.classname, url=url)
1313 except KeyError:
1314 # create a query
1315 qid = self.db.query.create(name=queryname,
1316 klass=self.classname, url=url)
1318 # and add it to the user's query multilink
1319 queries = self.db.user.get(self.userid, 'queries')
1320 queries.append(qid)
1321 self.db.user.set(self.userid, queries=queries)
1323 # commit the query change to the database
1324 self.db.commit()
1326 def searchPermission(self):
1327 ''' Determine whether the user has permission to search this class.
1329 Base behaviour is to check the user can view this class.
1330 '''
1331 if not self.db.security.hasPermission('View', self.userid,
1332 self.classname):
1333 return 0
1334 return 1
1337 def retireAction(self):
1338 ''' Retire the context item.
1339 '''
1340 # if we want to view the index template now, then unset the nodeid
1341 # context info (a special-case for retire actions on the index page)
1342 nodeid = self.nodeid
1343 if self.template == 'index':
1344 self.nodeid = None
1346 # generic edit is per-class only
1347 if not self.retirePermission():
1348 self.error_message.append(
1349 _('You do not have permission to retire %s' %self.classname))
1350 return
1352 # make sure we don't try to retire admin or anonymous
1353 if self.classname == 'user' and \
1354 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1355 self.error_message.append(
1356 _('You may not retire the admin or anonymous user'))
1357 return
1359 # do the retire
1360 self.db.getclass(self.classname).retire(nodeid)
1361 self.db.commit()
1363 self.ok_message.append(
1364 _('%(classname)s %(itemid)s has been retired')%{
1365 'classname': self.classname.capitalize(), 'itemid': nodeid})
1367 def retirePermission(self):
1368 ''' Determine whether the user has permission to retire this class.
1370 Base behaviour is to check the user can edit this class.
1371 '''
1372 if not self.db.security.hasPermission('Edit', self.userid,
1373 self.classname):
1374 return 0
1375 return 1
1378 def showAction(self, typere=re.compile('[@:]type'),
1379 numre=re.compile('[@:]number')):
1380 ''' Show a node of a particular class/id
1381 '''
1382 t = n = ''
1383 for key in self.form.keys():
1384 if typere.match(key):
1385 t = self.form[key].value.strip()
1386 elif numre.match(key):
1387 n = self.form[key].value.strip()
1388 if not t:
1389 raise ValueError, 'Invalid %s number'%t
1390 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1391 raise Redirect, url
1393 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1394 """ Item properties and their values are edited with html FORM
1395 variables and their values. You can:
1397 - Change the value of some property of the current item.
1398 - Create a new item of any class, and edit the new item's
1399 properties,
1400 - Attach newly created items to a multilink property of the
1401 current item.
1402 - Remove items from a multilink property of the current item.
1403 - Specify that some properties are required for the edit
1404 operation to be successful.
1406 In the following, <bracketed> values are variable, "@" may be
1407 either ":" or "@", and other text "required" is fixed.
1409 Most properties are specified as form variables:
1411 <propname>
1412 - property on the current context item
1414 <designator>"@"<propname>
1415 - property on the indicated item (for editing related
1416 information)
1418 Designators name a specific item of a class.
1420 <classname><N>
1422 Name an existing item of class <classname>.
1424 <classname>"-"<N>
1426 Name the <N>th new item of class <classname>. If the form
1427 submission is successful, a new item of <classname> is
1428 created. Within the submitted form, a particular
1429 designator of this form always refers to the same new
1430 item.
1432 Once we have determined the "propname", we look at it to see
1433 if it's special:
1435 @required
1436 The associated form value is a comma-separated list of
1437 property names that must be specified when the form is
1438 submitted for the edit operation to succeed.
1440 When the <designator> is missing, the properties are
1441 for the current context item. When <designator> is
1442 present, they are for the item specified by
1443 <designator>.
1445 The "@required" specifier must come before any of the
1446 properties it refers to are assigned in the form.
1448 @remove@<propname>=id(s) or @add@<propname>=id(s)
1449 The "@add@" and "@remove@" edit actions apply only to
1450 Multilink properties. The form value must be a
1451 comma-separate list of keys for the class specified by
1452 the simple form variable. The listed items are added
1453 to (respectively, removed from) the specified
1454 property.
1456 @link@<propname>=<designator>
1457 If the edit action is "@link@", the simple form
1458 variable must specify a Link or Multilink property.
1459 The form value is a comma-separated list of
1460 designators. The item corresponding to each
1461 designator is linked to the property given by simple
1462 form variable. These are collected up and returned in
1463 all_links.
1465 None of the above (ie. just a simple form value)
1466 The value of the form variable is converted
1467 appropriately, depending on the type of the property.
1469 For a Link('klass') property, the form value is a
1470 single key for 'klass', where the key field is
1471 specified in dbinit.py.
1473 For a Multilink('klass') property, the form value is a
1474 comma-separated list of keys for 'klass', where the
1475 key field is specified in dbinit.py.
1477 Note that for simple-form-variables specifiying Link
1478 and Multilink properties, the linked-to class must
1479 have a key field.
1481 For a String() property specifying a filename, the
1482 file named by the form value is uploaded. This means we
1483 try to set additional properties "filename" and "type" (if
1484 they are valid for the class). Otherwise, the property
1485 is set to the form value.
1487 For Date(), Interval(), Boolean(), and Number()
1488 properties, the form value is converted to the
1489 appropriate
1491 Any of the form variables may be prefixed with a classname or
1492 designator.
1494 Two special form values are supported for backwards
1495 compatibility:
1497 @note
1498 This is equivalent to::
1500 @link@messages=msg-1
1501 msg-1@content=value
1503 except that in addition, the "author" and "date"
1504 properties of "msg-1" are set to the userid of the
1505 submitter, and the current time, respectively.
1507 @file
1508 This is equivalent to::
1510 @link@files=file-1
1511 file-1@content=value
1513 The String content value is handled as described above for
1514 file uploads.
1516 If both the "@note" and "@file" form variables are
1517 specified, the action::
1519 @link@msg-1@files=file-1
1521 is also performed.
1523 We also check that FileClass items have a "content" property with
1524 actual content, otherwise we remove them from all_props before
1525 returning.
1527 The return from this method is a dict of
1528 (classname, id): properties
1529 ... this dict _always_ has an entry for the current context,
1530 even if it's empty (ie. a submission for an existing issue that
1531 doesn't result in any changes would return {('issue','123'): {}})
1532 The id may be None, which indicates that an item should be
1533 created.
1534 """
1535 # some very useful variables
1536 db = self.db
1537 form = self.form
1539 if not hasattr(self, 'FV_SPECIAL'):
1540 # generate the regexp for handling special form values
1541 classes = '|'.join(db.classes.keys())
1542 # specials for parsePropsFromForm
1543 # handle the various forms (see unit tests)
1544 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1545 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1547 # these indicate the default class / item
1548 default_cn = self.classname
1549 default_cl = self.db.classes[default_cn]
1550 default_nodeid = self.nodeid
1552 # we'll store info about the individual class/item edit in these
1553 all_required = {} # required props per class/item
1554 all_props = {} # props to set per class/item
1555 got_props = {} # props received per class/item
1556 all_propdef = {} # note - only one entry per class
1557 all_links = [] # as many as are required
1559 # we should always return something, even empty, for the context
1560 all_props[(default_cn, default_nodeid)] = {}
1562 keys = form.keys()
1563 timezone = db.getUserTimezone()
1565 # sentinels for the :note and :file props
1566 have_note = have_file = 0
1568 # extract the usable form labels from the form
1569 matches = []
1570 for key in keys:
1571 m = self.FV_SPECIAL.match(key)
1572 if m:
1573 matches.append((key, m.groupdict()))
1575 # now handle the matches
1576 for key, d in matches:
1577 if d['classname']:
1578 # we got a designator
1579 cn = d['classname']
1580 cl = self.db.classes[cn]
1581 nodeid = d['id']
1582 propname = d['propname']
1583 elif d['note']:
1584 # the special note field
1585 cn = 'msg'
1586 cl = self.db.classes[cn]
1587 nodeid = '-1'
1588 propname = 'content'
1589 all_links.append((default_cn, default_nodeid, 'messages',
1590 [('msg', '-1')]))
1591 have_note = 1
1592 elif d['file']:
1593 # the special file field
1594 cn = 'file'
1595 cl = self.db.classes[cn]
1596 nodeid = '-1'
1597 propname = 'content'
1598 all_links.append((default_cn, default_nodeid, 'files',
1599 [('file', '-1')]))
1600 have_file = 1
1601 else:
1602 # default
1603 cn = default_cn
1604 cl = default_cl
1605 nodeid = default_nodeid
1606 propname = d['propname']
1608 # the thing this value relates to is...
1609 this = (cn, nodeid)
1611 # get more info about the class, and the current set of
1612 # form props for it
1613 if not all_propdef.has_key(cn):
1614 all_propdef[cn] = cl.getprops()
1615 propdef = all_propdef[cn]
1616 if not all_props.has_key(this):
1617 all_props[this] = {}
1618 props = all_props[this]
1619 if not got_props.has_key(this):
1620 got_props[this] = {}
1622 # is this a link command?
1623 if d['link']:
1624 value = []
1625 for entry in extractFormList(form[key]):
1626 m = self.FV_DESIGNATOR.match(entry)
1627 if not m:
1628 raise FormError, \
1629 'link "%s" value "%s" not a designator'%(key, entry)
1630 value.append((m.group(1), m.group(2)))
1632 # make sure the link property is valid
1633 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1634 not isinstance(propdef[propname], hyperdb.Link)):
1635 raise FormError, '%s %s is not a link or '\
1636 'multilink property'%(cn, propname)
1638 all_links.append((cn, nodeid, propname, value))
1639 continue
1641 # detect the special ":required" variable
1642 if d['required']:
1643 all_required[this] = extractFormList(form[key])
1644 continue
1646 # see if we're performing a special multilink action
1647 mlaction = 'set'
1648 if d['remove']:
1649 mlaction = 'remove'
1650 elif d['add']:
1651 mlaction = 'add'
1653 # does the property exist?
1654 if not propdef.has_key(propname):
1655 if mlaction != 'set':
1656 raise FormError, 'You have submitted a %s action for'\
1657 ' the property "%s" which doesn\'t exist'%(mlaction,
1658 propname)
1659 # the form element is probably just something we don't care
1660 # about - ignore it
1661 continue
1662 proptype = propdef[propname]
1664 # Get the form value. This value may be a MiniFieldStorage or a list
1665 # of MiniFieldStorages.
1666 value = form[key]
1668 # handle unpacking of the MiniFieldStorage / list form value
1669 if isinstance(proptype, hyperdb.Multilink):
1670 value = extractFormList(value)
1671 else:
1672 # multiple values are not OK
1673 if isinstance(value, type([])):
1674 raise FormError, 'You have submitted more than one value'\
1675 ' for the %s property'%propname
1676 # value might be a file upload...
1677 if not hasattr(value, 'filename') or value.filename is None:
1678 # nope, pull out the value and strip it
1679 value = value.value.strip()
1681 # now that we have the props field, we need a teensy little
1682 # extra bit of help for the old :note field...
1683 if d['note'] and value:
1684 props['author'] = self.db.getuid()
1685 props['date'] = date.Date()
1687 # handle by type now
1688 if isinstance(proptype, hyperdb.Password):
1689 if not value:
1690 # ignore empty password values
1691 continue
1692 for key, d in matches:
1693 if d['confirm'] and d['propname'] == propname:
1694 confirm = form[key]
1695 break
1696 else:
1697 raise FormError, 'Password and confirmation text do '\
1698 'not match'
1699 if isinstance(confirm, type([])):
1700 raise FormError, 'You have submitted more than one value'\
1701 ' for the %s property'%propname
1702 if value != confirm.value:
1703 raise FormError, 'Password and confirmation text do '\
1704 'not match'
1705 try:
1706 value = password.Password(value)
1707 except hyperdb.HyperdbValueError, msg:
1708 raise FormError, msg
1710 elif isinstance(proptype, hyperdb.Multilink):
1711 # convert input to list of ids
1712 try:
1713 l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1714 propname, value)
1715 except hyperdb.HyperdbValueError, msg:
1716 raise FormError, msg
1718 # now use that list of ids to modify the multilink
1719 if mlaction == 'set':
1720 value = l
1721 else:
1722 # we're modifying the list - get the current list of ids
1723 if props.has_key(propname):
1724 existing = props[propname]
1725 elif nodeid and not nodeid.startswith('-'):
1726 existing = cl.get(nodeid, propname, [])
1727 else:
1728 existing = []
1730 # now either remove or add
1731 if mlaction == 'remove':
1732 # remove - handle situation where the id isn't in
1733 # the list
1734 for entry in l:
1735 try:
1736 existing.remove(entry)
1737 except ValueError:
1738 raise FormError, _('property "%(propname)s": '
1739 '"%(value)s" not currently in list')%{
1740 'propname': propname, 'value': entry}
1741 else:
1742 # add - easy, just don't dupe
1743 for entry in l:
1744 if entry not in existing:
1745 existing.append(entry)
1746 value = existing
1747 value.sort()
1749 elif value == '':
1750 # other types should be None'd if there's no value
1751 value = None
1752 else:
1753 # handle all other types
1754 try:
1755 if isinstance(proptype, hyperdb.String):
1756 if (hasattr(value, 'filename') and
1757 value.filename is not None):
1758 # skip if the upload is empty
1759 if not value.filename:
1760 continue
1761 # this String is actually a _file_
1762 # try to determine the file content-type
1763 fn = value.filename.split('\\')[-1]
1764 if propdef.has_key('name'):
1765 props['name'] = fn
1766 # use this info as the type/filename properties
1767 if propdef.has_key('type'):
1768 props['type'] = mimetypes.guess_type(fn)[0]
1769 if not props['type']:
1770 props['type'] = "application/octet-stream"
1771 # finally, read the content RAW
1772 value = value.value
1773 else:
1774 value = hyperdb.rawToHyperdb(self.db, cl,
1775 nodeid, propname, value)
1777 else:
1778 value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1779 propname, value)
1780 except hyperdb.HyperdbValueError, msg:
1781 raise FormError, msg
1783 # register that we got this property
1784 if value:
1785 got_props[this][propname] = 1
1787 # get the old value
1788 if nodeid and not nodeid.startswith('-'):
1789 try:
1790 existing = cl.get(nodeid, propname)
1791 except KeyError:
1792 # this might be a new property for which there is
1793 # no existing value
1794 if not propdef.has_key(propname):
1795 raise
1796 except IndexError, message:
1797 raise FormError(str(message))
1799 # make sure the existing multilink is sorted
1800 if isinstance(proptype, hyperdb.Multilink):
1801 existing.sort()
1803 # "missing" existing values may not be None
1804 if not existing:
1805 if isinstance(proptype, hyperdb.String) and not existing:
1806 # some backends store "missing" Strings as empty strings
1807 existing = None
1808 elif isinstance(proptype, hyperdb.Number) and not existing:
1809 # some backends store "missing" Numbers as 0 :(
1810 existing = 0
1811 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1812 # likewise Booleans
1813 existing = 0
1815 # if changed, set it
1816 if value != existing:
1817 props[propname] = value
1818 else:
1819 # don't bother setting empty/unset values
1820 if value is None:
1821 continue
1822 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1823 continue
1824 elif isinstance(proptype, hyperdb.String) and value == '':
1825 continue
1827 props[propname] = value
1829 # check to see if we need to specially link a file to the note
1830 if have_note and have_file:
1831 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1833 # see if all the required properties have been supplied
1834 s = []
1835 for thing, required in all_required.items():
1836 # register the values we got
1837 got = got_props.get(thing, {})
1838 for entry in required[:]:
1839 if got.has_key(entry):
1840 required.remove(entry)
1842 # any required values not present?
1843 if not required:
1844 continue
1846 # tell the user to entry the values required
1847 if len(required) > 1:
1848 p = 'properties'
1849 else:
1850 p = 'property'
1851 s.append('Required %s %s %s not supplied'%(thing[0], p,
1852 ', '.join(required)))
1853 if s:
1854 raise FormError, '\n'.join(s)
1856 # When creating a FileClass node, it should have a non-empty content
1857 # property to be created. When editing a FileClass node, it should
1858 # either have a non-empty content property or no property at all. In
1859 # the latter case, nothing will change.
1860 for (cn, id), props in all_props.items():
1861 if isinstance(self.db.classes[cn], hyperdb.FileClass):
1862 if id == '-1':
1863 if not props.get('content', ''):
1864 del all_props[(cn, id)]
1865 elif props.has_key('content') and not props['content']:
1866 raise FormError, _('File is empty')
1867 return all_props, all_links
1869 def extractFormList(value):
1870 ''' Extract a list of values from the form value.
1872 It may be one of:
1873 [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
1874 MiniFieldStorage('value,value,...')
1875 MiniFieldStorage('value')
1876 '''
1877 # multiple values are OK
1878 if isinstance(value, type([])):
1879 # it's a list of MiniFieldStorages - join then into
1880 values = ','.join([i.value.strip() for i in value])
1881 else:
1882 # it's a MiniFieldStorage, but may be a comma-separated list
1883 # of values
1884 values = value.value
1886 value = [i.strip() for i in values.split(',')]
1888 # filter out the empty bits
1889 return filter(None, value)