1 # $Id: client.py,v 1.97 2003-02-25 10:19:32 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
10 from roundup import roundupdb, date, hyperdb, password
11 from roundup.i18n import _
12 from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
13 from roundup.cgi import cgitb
14 from roundup.cgi.PageTemplates import PageTemplate
15 from roundup.rfc2822 import encode_header
17 class HTTPException(Exception):
18 pass
19 class Unauthorised(HTTPException):
20 pass
21 class NotFound(HTTPException):
22 pass
23 class Redirect(HTTPException):
24 pass
26 # XXX actually _use_ FormError
27 class FormError(ValueError):
28 ''' An "expected" exception occurred during form parsing.
29 - ie. something we know can go wrong, and don't want to alarm the
30 user with
32 We trap this at the user interface level and feed back a nice error
33 to the user.
34 '''
35 pass
37 class SendFile(Exception):
38 ''' Send a file from the database '''
40 class SendStaticFile(Exception):
41 ''' Send a static file from the instance html directory '''
43 def initialiseSecurity(security):
44 ''' Create some Permissions and Roles on the security object
46 This function is directly invoked by security.Security.__init__()
47 as a part of the Security object instantiation.
48 '''
49 security.addPermission(name="Web Registration",
50 description="User may register through the web")
51 p = security.addPermission(name="Web Access",
52 description="User may access the web interface")
53 security.addPermissionToRole('Admin', p)
55 # doing Role stuff through the web - make sure Admin can
56 p = security.addPermission(name="Web Roles",
57 description="User may manipulate user Roles through the web")
58 security.addPermissionToRole('Admin', p)
60 class Client:
61 ''' Instantiate to handle one CGI request.
63 See inner_main for request processing.
65 Client attributes at instantiation:
66 "path" is the PATH_INFO inside the instance (with no leading '/')
67 "base" is the base URL for the instance
68 "form" is the cgi form, an instance of FieldStorage from the standard
69 cgi module
70 "additional_headers" is a dictionary of additional HTTP headers that
71 should be sent to the client
72 "response_code" is the HTTP response code to send to the client
74 During the processing of a request, the following attributes are used:
75 "error_message" holds a list of error messages
76 "ok_message" holds a list of OK messages
77 "session" is the current user session id
78 "user" is the current user's name
79 "userid" is the current user's id
80 "template" is the current :template context
81 "classname" is the current class context name
82 "nodeid" is the current context item id
84 User Identification:
85 If the user has no login cookie, then they are anonymous and are logged
86 in as that user. This typically gives them all Permissions assigned to the
87 Anonymous Role.
89 Once a user logs in, they are assigned a session. The Client instance
90 keeps the nodeid of the session as the "session" attribute.
93 Special form variables:
94 Note that in various places throughout this code, special form
95 variables of the form :<name> are used. The colon (":") part may
96 actually be one of either ":" or "@".
97 '''
99 #
100 # special form variables
101 #
102 FV_TEMPLATE = re.compile(r'[@:]template')
103 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
104 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
106 FV_QUERYNAME = re.compile(r'[@:]queryname')
108 # edit form variable handling (see unit tests)
109 FV_LABELS = r'''
110 ^(
111 (?P<note>[@:]note)|
112 (?P<file>[@:]file)|
113 (
114 ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
115 ((?P<required>[@:]required$)| # :required
116 (
117 (
118 (?P<add>[@:]add[@:])| # :add:<prop>
119 (?P<remove>[@:]remove[@:])| # :remove:<prop>
120 (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
121 (?P<link>[@:]link[@:])| # :link:<prop>
122 ([@:]) # just a separator
123 )?
124 (?P<propname>[^@:]+) # <prop>
125 )
126 )
127 )
128 )$'''
130 # Note: index page stuff doesn't appear here:
131 # columns, sort, sortdir, filter, group, groupdir, search_text,
132 # pagesize, startwith
134 def __init__(self, instance, request, env, form=None):
135 hyperdb.traceMark()
136 self.instance = instance
137 self.request = request
138 self.env = env
140 # save off the path
141 self.path = env['PATH_INFO']
143 # this is the base URL for this tracker
144 self.base = self.instance.config.TRACKER_WEB
146 # this is the "cookie path" for this tracker (ie. the path part of
147 # the "base" url)
148 self.cookie_path = urlparse.urlparse(self.base)[2]
149 self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
150 self.instance.config.TRACKER_NAME)
152 # see if we need to re-parse the environment for the form (eg Zope)
153 if form is None:
154 self.form = cgi.FieldStorage(environ=env)
155 else:
156 self.form = form
158 # turn debugging on/off
159 try:
160 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
161 except ValueError:
162 # someone gave us a non-int debug level, turn it off
163 self.debug = 0
165 # flag to indicate that the HTTP headers have been sent
166 self.headers_done = 0
168 # additional headers to send with the request - must be registered
169 # before the first write
170 self.additional_headers = {}
171 self.response_code = 200
174 def main(self):
175 ''' Wrap the real main in a try/finally so we always close off the db.
176 '''
177 try:
178 self.inner_main()
179 finally:
180 if hasattr(self, 'db'):
181 self.db.close()
183 def inner_main(self):
184 ''' Process a request.
186 The most common requests are handled like so:
187 1. figure out who we are, defaulting to the "anonymous" user
188 see determine_user
189 2. figure out what the request is for - the context
190 see determine_context
191 3. handle any requested action (item edit, search, ...)
192 see handle_action
193 4. render a template, resulting in HTML output
195 In some situations, exceptions occur:
196 - HTTP Redirect (generally raised by an action)
197 - SendFile (generally raised by determine_context)
198 serve up a FileClass "content" property
199 - SendStaticFile (generally raised by determine_context)
200 serve up a file from the tracker "html" directory
201 - Unauthorised (generally raised by an action)
202 the action is cancelled, the request is rendered and an error
203 message is displayed indicating that permission was not
204 granted for the action to take place
205 - NotFound (raised wherever it needs to be)
206 percolates up to the CGI interface that called the client
207 '''
208 self.ok_message = []
209 self.error_message = []
210 try:
211 # make sure we're identified (even anonymously)
212 self.determine_user()
213 # figure out the context and desired content template
214 self.determine_context()
215 # possibly handle a form submit action (may change self.classname
216 # and self.template, and may also append error/ok_messages)
217 self.handle_action()
218 # now render the page
220 # we don't want clients caching our dynamic pages
221 self.additional_headers['Cache-Control'] = 'no-cache'
222 self.additional_headers['Pragma'] = 'no-cache'
223 self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
225 # render the content
226 self.write(self.renderContext())
227 except Redirect, url:
228 # let's redirect - if the url isn't None, then we need to do
229 # the headers, otherwise the headers have been set before the
230 # exception was raised
231 if url:
232 self.additional_headers['Location'] = url
233 self.response_code = 302
234 self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
235 except SendFile, designator:
236 self.serve_file(designator)
237 except SendStaticFile, file:
238 self.serve_static_file(str(file))
239 except Unauthorised, message:
240 self.classname = None
241 self.template = ''
242 self.error_message.append(message)
243 self.write(self.renderContext())
244 except NotFound:
245 # pass through
246 raise
247 except:
248 # everything else
249 self.write(cgitb.html())
251 def clean_sessions(self):
252 '''age sessions, remove when they haven't been used for a week.
253 Do it only once an hour'''
254 sessions = self.db.sessions
255 last_clean = sessions.get('last_clean', 'last_use') or 0
257 week = 60*60*24*7
258 hour = 60*60
259 now = time.time()
260 if now - last_clean > hour:
261 # remove age sessions
262 for sessid in sessions.list():
263 interval = now - sessions.get(sessid, 'last_use')
264 if interval > week:
265 sessions.destroy(sessid)
266 sessions.set('last_clean', last_use=time.time())
268 def determine_user(self):
269 ''' Determine who the user is
270 '''
271 # determine the uid to use
272 self.opendb('admin')
273 # clean age sessions
274 self.clean_sessions()
275 # make sure we have the session Class
276 sessions = self.db.sessions
278 # look up the user session cookie
279 cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
280 user = 'anonymous'
282 # bump the "revision" of the cookie since the format changed
283 if (cookie.has_key(self.cookie_name) and
284 cookie[self.cookie_name].value != 'deleted'):
286 # get the session key from the cookie
287 self.session = cookie[self.cookie_name].value
288 # get the user from the session
289 try:
290 # update the lifetime datestamp
291 sessions.set(self.session, last_use=time.time())
292 sessions.commit()
293 user = sessions.get(self.session, 'user')
294 except KeyError:
295 user = 'anonymous'
297 # sanity check on the user still being valid, getting the userid
298 # at the same time
299 try:
300 self.userid = self.db.user.lookup(user)
301 except (KeyError, TypeError):
302 user = 'anonymous'
304 # make sure the anonymous user is valid if we're using it
305 if user == 'anonymous':
306 self.make_user_anonymous()
307 else:
308 self.user = user
310 # reopen the database as the correct user
311 self.opendb(self.user)
313 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
314 ''' Determine the context of this page from the URL:
316 The URL path after the instance identifier is examined. The path
317 is generally only one entry long.
319 - if there is no path, then we are in the "home" context.
320 * if the path is "_file", then the additional path entry
321 specifies the filename of a static file we're to serve up
322 from the instance "html" directory. Raises a SendStaticFile
323 exception.
324 - if there is something in the path (eg "issue"), it identifies
325 the tracker class we're to display.
326 - if the path is an item designator (eg "issue123"), then we're
327 to display a specific item.
328 * if the path starts with an item designator and is longer than
329 one entry, then we're assumed to be handling an item of a
330 FileClass, and the extra path information gives the filename
331 that the client is going to label the download with (ie
332 "file123/image.png" is nicer to download than "file123"). This
333 raises a SendFile exception.
335 Both of the "*" types of contexts stop before we bother to
336 determine the template we're going to use. That's because they
337 don't actually use templates.
339 The template used is specified by the :template CGI variable,
340 which defaults to:
342 only classname suplied: "index"
343 full item designator supplied: "item"
345 We set:
346 self.classname - the class to display, can be None
347 self.template - the template to render the current context with
348 self.nodeid - the nodeid of the class we're displaying
349 '''
350 # default the optional variables
351 self.classname = None
352 self.nodeid = None
354 # see if a template or messages are specified
355 template_override = ok_message = error_message = None
356 for key in self.form.keys():
357 if self.FV_TEMPLATE.match(key):
358 template_override = self.form[key].value
359 elif self.FV_OK_MESSAGE.match(key):
360 ok_message = self.form[key].value
361 elif self.FV_ERROR_MESSAGE.match(key):
362 error_message = self.form[key].value
364 # determine the classname and possibly nodeid
365 path = self.path.split('/')
366 if not path or path[0] in ('', 'home', 'index'):
367 if template_override is not None:
368 self.template = template_override
369 else:
370 self.template = ''
371 return
372 elif path[0] == '_file':
373 raise SendStaticFile, os.path.join(*path[1:])
374 else:
375 self.classname = path[0]
376 if len(path) > 1:
377 # send the file identified by the designator in path[0]
378 raise SendFile, path[0]
380 # see if we got a designator
381 m = dre.match(self.classname)
382 if m:
383 self.classname = m.group(1)
384 self.nodeid = m.group(2)
385 if not self.db.getclass(self.classname).hasnode(self.nodeid):
386 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
387 # with a designator, we default to item view
388 self.template = 'item'
389 else:
390 # with only a class, we default to index view
391 self.template = 'index'
393 # make sure the classname is valid
394 try:
395 self.db.getclass(self.classname)
396 except KeyError:
397 raise NotFound, self.classname
399 # see if we have a template override
400 if template_override is not None:
401 self.template = template_override
403 # see if we were passed in a message
404 if ok_message:
405 self.ok_message.append(ok_message)
406 if error_message:
407 self.error_message.append(error_message)
409 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
410 ''' Serve the file from the content property of the designated item.
411 '''
412 m = dre.match(str(designator))
413 if not m:
414 raise NotFound, str(designator)
415 classname, nodeid = m.group(1), m.group(2)
416 if classname != 'file':
417 raise NotFound, designator
419 # we just want to serve up the file named
420 file = self.db.file
421 self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
422 self.write(file.get(nodeid, 'content'))
424 def serve_static_file(self, file):
425 # we just want to serve up the file named
426 mt = mimetypes.guess_type(str(file))[0]
427 self.additional_headers['Content-Type'] = mt
428 self.write(open(os.path.join(self.instance.config.TEMPLATES,
429 file)).read())
431 def renderContext(self):
432 ''' Return a PageTemplate for the named page
433 '''
434 name = self.classname
435 extension = self.template
436 pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
438 # catch errors so we can handle PT rendering errors more nicely
439 args = {
440 'ok_message': self.ok_message,
441 'error_message': self.error_message
442 }
443 try:
444 # let the template render figure stuff out
445 return pt.render(self, None, None, **args)
446 except NoTemplate, message:
447 return '<strong>%s</strong>'%message
448 except:
449 # everything else
450 return cgitb.pt_html()
452 # these are the actions that are available
453 actions = (
454 ('edit', 'editItemAction'),
455 ('editCSV', 'editCSVAction'),
456 ('new', 'newItemAction'),
457 ('register', 'registerAction'),
458 ('confrego', 'confRegoAction'),
459 ('login', 'loginAction'),
460 ('logout', 'logout_action'),
461 ('search', 'searchAction'),
462 ('retire', 'retireAction'),
463 ('show', 'showAction'),
464 )
465 def handle_action(self):
466 ''' Determine whether there should be an Action called.
468 The action is defined by the form variable :action which
469 identifies the method on this object to call. The four basic
470 actions are defined in the "actions" sequence on this class:
471 "edit" -> self.editItemAction
472 "new" -> self.newItemAction
473 "register" -> self.registerAction
474 "confrego" -> self.confRegoAction
475 "login" -> self.loginAction
476 "logout" -> self.logout_action
477 "search" -> self.searchAction
478 "retire" -> self.retireAction
479 '''
480 if self.form.has_key(':action'):
481 action = self.form[':action'].value.lower()
482 elif self.form.has_key('@action'):
483 action = self.form['@action'].value.lower()
484 else:
485 return None
486 try:
487 # get the action, validate it
488 for name, method in self.actions:
489 if name == action:
490 break
491 else:
492 raise ValueError, 'No such action "%s"'%action
493 # call the mapped action
494 getattr(self, method)()
495 except Redirect:
496 raise
497 except Unauthorised:
498 raise
500 def write(self, content):
501 if not self.headers_done:
502 self.header()
503 self.request.wfile.write(content)
505 def header(self, headers=None, response=None):
506 '''Put up the appropriate header.
507 '''
508 if headers is None:
509 headers = {'Content-Type':'text/html'}
510 if response is None:
511 response = self.response_code
513 # update with additional info
514 headers.update(self.additional_headers)
516 if not headers.has_key('Content-Type'):
517 headers['Content-Type'] = 'text/html'
518 self.request.send_response(response)
519 for entry in headers.items():
520 self.request.send_header(*entry)
521 self.request.end_headers()
522 self.headers_done = 1
523 if self.debug:
524 self.headers_sent = headers
526 def set_cookie(self, user):
527 ''' Set up a session cookie for the user and store away the user's
528 login info against the session.
529 '''
530 # TODO generate a much, much stronger session key ;)
531 self.session = binascii.b2a_base64(repr(random.random())).strip()
533 # clean up the base64
534 if self.session[-1] == '=':
535 if self.session[-2] == '=':
536 self.session = self.session[:-2]
537 else:
538 self.session = self.session[:-1]
540 # insert the session in the sessiondb
541 self.db.sessions.set(self.session, user=user, last_use=time.time())
543 # and commit immediately
544 self.db.sessions.commit()
546 # expire us in a long, long time
547 expire = Cookie._getdate(86400*365)
549 # generate the cookie path - make sure it has a trailing '/'
550 self.additional_headers['Set-Cookie'] = \
551 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
552 expire, self.cookie_path)
554 def make_user_anonymous(self):
555 ''' Make us anonymous
557 This method used to handle non-existence of the 'anonymous'
558 user, but that user is mandatory now.
559 '''
560 self.userid = self.db.user.lookup('anonymous')
561 self.user = 'anonymous'
563 def opendb(self, user):
564 ''' Open the database.
565 '''
566 # open the db if the user has changed
567 if not hasattr(self, 'db') or user != self.db.journaltag:
568 if hasattr(self, 'db'):
569 self.db.close()
570 self.db = self.instance.open(user)
572 #
573 # Actions
574 #
575 def loginAction(self):
576 ''' Attempt to log a user in.
578 Sets up a session for the user which contains the login
579 credentials.
580 '''
581 # we need the username at a minimum
582 if not self.form.has_key('__login_name'):
583 self.error_message.append(_('Username required'))
584 return
586 # get the login info
587 self.user = self.form['__login_name'].value
588 if self.form.has_key('__login_password'):
589 password = self.form['__login_password'].value
590 else:
591 password = ''
593 # make sure the user exists
594 try:
595 self.userid = self.db.user.lookup(self.user)
596 except KeyError:
597 name = self.user
598 self.error_message.append(_('No such user "%(name)s"')%locals())
599 self.make_user_anonymous()
600 return
602 # verify the password
603 if not self.verifyPassword(self.userid, password):
604 self.make_user_anonymous()
605 self.error_message.append(_('Incorrect password'))
606 return
608 # make sure we're allowed to be here
609 if not self.loginPermission():
610 self.make_user_anonymous()
611 self.error_message.append(_("You do not have permission to login"))
612 return
614 # now we're OK, re-open the database for real, using the user
615 self.opendb(self.user)
617 # set the session cookie
618 self.set_cookie(self.user)
620 def verifyPassword(self, userid, password):
621 ''' Verify the password that the user has supplied
622 '''
623 stored = self.db.user.get(self.userid, 'password')
624 if password == stored:
625 return 1
626 if not password and not stored:
627 return 1
628 return 0
630 def loginPermission(self):
631 ''' Determine whether the user has permission to log in.
633 Base behaviour is to check the user has "Web Access".
634 '''
635 if not self.db.security.hasPermission('Web Access', self.userid):
636 return 0
637 return 1
639 def logout_action(self):
640 ''' Make us really anonymous - nuke the cookie too
641 '''
642 # log us out
643 self.make_user_anonymous()
645 # construct the logout cookie
646 now = Cookie._getdate()
647 self.additional_headers['Set-Cookie'] = \
648 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
649 now, self.cookie_path)
651 # Let the user know what's going on
652 self.ok_message.append(_('You are logged out'))
654 chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
655 def registerAction(self):
656 '''Attempt to create a new user based on the contents of the form
657 and then set the cookie.
659 return 1 on successful login
660 '''
661 # parse the props from the form
662 try:
663 props = self.parsePropsFromForm()[0][('user', None)]
664 except (ValueError, KeyError), message:
665 self.error_message.append(_('Error: ') + str(message))
666 return
668 # make sure we're allowed to register
669 if not self.registerPermission(props):
670 raise Unauthorised, _("You do not have permission to register")
672 try:
673 self.db.user.lookup(props['username'])
674 self.error_message.append('Error: A user with the username "%s" '
675 'already exists'%props['username'])
676 return
677 except KeyError:
678 pass
680 # generate the one-time-key and store the props for later
681 otk = ''.join([random.choice(self.chars) for x in range(32)])
682 for propname, proptype in self.db.user.getprops().items():
683 value = props.get(propname, None)
684 if value is None:
685 pass
686 elif isinstance(proptype, hyperdb.Date):
687 props[propname] = str(value)
688 elif isinstance(proptype, hyperdb.Interval):
689 props[propname] = str(value)
690 elif isinstance(proptype, hyperdb.Password):
691 props[propname] = str(value)
692 self.db.otks.set(otk, **props)
694 # send email to the user's email address
695 message = StringIO.StringIO()
696 writer = MimeWriter.MimeWriter(message)
697 tracker_name = self.db.config.TRACKER_NAME
698 s = 'Complete your registration to %s'%tracker_name
699 writer.addheader('Subject', encode_header(s))
700 writer.addheader('To', props['address'])
701 writer.addheader('From', roundupdb.straddr((tracker_name,
702 self.db.config.ADMIN_EMAIL)))
703 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
704 time.gmtime()))
705 # add a uniquely Roundup header to help filtering
706 writer.addheader('X-Roundup-Name', tracker_name)
707 # avoid email loops
708 writer.addheader('X-Roundup-Loop', 'hello')
709 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
710 body = writer.startbody('text/plain; charset=utf-8')
712 # message body, encoded quoted-printable
713 content = StringIO.StringIO('''
714 To complete your registration of the user "%(name)s" with %(tracker)s,
715 please visit the following URL:
717 http://localhost:8001/test/?@action=confrego&otk=%(otk)s
718 '''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
719 'otk': otk})
720 quopri.encode(content, body, 0)
722 # now try to send the message
723 try:
724 # send the message as admin so bounces are sent there
725 # instead of to roundup
726 smtp = smtplib.SMTP(self.db.config.MAILHOST)
727 smtp.sendmail(self.db.config.ADMIN_EMAIL, [props['address']],
728 message.getvalue())
729 except socket.error, value:
730 self.error_message.append("Error: couldn't send "
731 "confirmation email: mailhost %s"%value)
732 return
733 except smtplib.SMTPException, value:
734 self.error_message.append("Error: couldn't send "
735 "confirmation email: %s"%value)
736 return
738 # commit changes to the database
739 self.db.commit()
741 # redirect to the "you're almost there" page
742 raise Redirect, '%s?:template=rego_step1_done'%self.base
744 def registerPermission(self, props):
745 ''' Determine whether the user has permission to register
747 Base behaviour is to check the user has "Web Registration".
748 '''
749 # registration isn't allowed to supply roles
750 if props.has_key('roles'):
751 return 0
752 if self.db.security.hasPermission('Web Registration', self.userid):
753 return 1
754 return 0
756 def confRegoAction(self):
757 ''' Grab the OTK, use it to load up the new user details
758 '''
759 # pull the rego information out of the otk database
760 otk = self.form['otk'].value
761 props = self.db.otks.getall(otk)
762 for propname, proptype in self.db.user.getprops().items():
763 value = props.get(propname, None)
764 if value is None:
765 pass
766 elif isinstance(proptype, hyperdb.Date):
767 props[propname] = date.Date(value)
768 elif isinstance(proptype, hyperdb.Interval):
769 props[propname] = date.Interval(value)
770 elif isinstance(proptype, hyperdb.Password):
771 props[propname] = password.Password()
772 props[propname].unpack(value)
774 # re-open the database as "admin"
775 if self.user != 'admin':
776 self.opendb('admin')
778 # create the new user
779 cl = self.db.user
780 # XXX we need to make the "default" page be able to display errors!
781 # try:
782 if 1:
783 props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
784 self.userid = cl.create(**props)
785 # clear the props from the otk database
786 self.db.otks.destroy(otk)
787 self.db.commit()
788 # except (ValueError, KeyError), message:
789 # self.error_message.append(str(message))
790 # return
792 # log the new user in
793 self.user = cl.get(self.userid, 'username')
794 # re-open the database for real, using the user
795 self.opendb(self.user)
797 # if we have a session, update it
798 if hasattr(self, 'session'):
799 self.db.sessions.set(self.session, user=self.user,
800 last_use=time.time())
801 else:
802 # new session cookie
803 self.set_cookie(self.user)
805 # nice message
806 message = _('You are now registered, welcome!')
808 # redirect to the item's edit page
809 raise Redirect, '%suser%s?@ok_message=%s'%(
810 self.base, self.userid, urllib.quote(message))
812 def editItemAction(self):
813 ''' Perform an edit of an item in the database.
815 See parsePropsFromForm and _editnodes for special variables
816 '''
817 # parse the props from the form
818 # XXX reinstate exception handling
819 # try:
820 if 1:
821 props, links = self.parsePropsFromForm()
822 # except (ValueError, KeyError), message:
823 # self.error_message.append(_('Error: ') + str(message))
824 # return
826 # handle the props
827 # XXX reinstate exception handling
828 # try:
829 if 1:
830 message = self._editnodes(props, links)
831 # except (ValueError, KeyError, IndexError), message:
832 # self.error_message.append(_('Error: ') + str(message))
833 # return
835 # commit now that all the tricky stuff is done
836 self.db.commit()
838 # redirect to the item's edit page
839 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
840 self.nodeid, urllib.quote(message))
842 def editItemPermission(self, props):
843 ''' Determine whether the user has permission to edit this item.
845 Base behaviour is to check the user can edit this class. If we're
846 editing the "user" class, users are allowed to edit their own
847 details. Unless it's the "roles" property, which requires the
848 special Permission "Web Roles".
849 '''
850 # if this is a user node and the user is editing their own node, then
851 # we're OK
852 has = self.db.security.hasPermission
853 if self.classname == 'user':
854 # reject if someone's trying to edit "roles" and doesn't have the
855 # right permission.
856 if props.has_key('roles') and not has('Web Roles', self.userid,
857 'user'):
858 return 0
859 # if the item being edited is the current user, we're ok
860 if self.nodeid == self.userid:
861 return 1
862 if self.db.security.hasPermission('Edit', self.userid, self.classname):
863 return 1
864 return 0
866 def newItemAction(self):
867 ''' Add a new item to the database.
869 This follows the same form as the editItemAction, with the same
870 special form values.
871 '''
872 # parse the props from the form
873 # XXX reinstate exception handling
874 # try:
875 if 1:
876 props, links = self.parsePropsFromForm()
877 # except (ValueError, KeyError), message:
878 # self.error_message.append(_('Error: ') + str(message))
879 # return
881 # handle the props - edit or create
882 # XXX reinstate exception handling
883 # try:
884 if 1:
885 # create the context here
886 # cn = self.classname
887 # nid = self._createnode(cn, props[(cn, None)])
888 # del props[(cn, None)]
890 # when it hits the None element, it'll set self.nodeid
891 messages = self._editnodes(props, links) #, {(cn, None): nid})
893 # except (ValueError, KeyError, IndexError), message:
894 # # these errors might just be indicative of user dumbness
895 # self.error_message.append(_('Error: ') + str(message))
896 # return
898 # commit now that all the tricky stuff is done
899 self.db.commit()
901 # redirect to the new item's page
902 raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
903 self.nodeid, urllib.quote(messages))
905 def newItemPermission(self, props):
906 ''' Determine whether the user has permission to create (edit) this
907 item.
909 Base behaviour is to check the user can edit this class. No
910 additional property checks are made. Additionally, new user items
911 may be created if the user has the "Web Registration" Permission.
912 '''
913 has = self.db.security.hasPermission
914 if self.classname == 'user' and has('Web Registration', self.userid,
915 'user'):
916 return 1
917 if has('Edit', self.userid, self.classname):
918 return 1
919 return 0
922 #
923 # Utility methods for editing
924 #
925 def _editnodes(self, all_props, all_links, newids=None):
926 ''' Use the props in all_props to perform edit and creation, then
927 use the link specs in all_links to do linking.
928 '''
929 # figure dependencies and re-work links
930 deps = {}
931 links = {}
932 for cn, nodeid, propname, vlist in all_links:
933 if not all_props.has_key((cn, nodeid)):
934 # link item to link to doesn't (and won't) exist
935 continue
936 for value in vlist:
937 if not all_props.has_key(value):
938 # link item to link to doesn't (and won't) exist
939 continue
940 deps.setdefault((cn, nodeid), []).append(value)
941 links.setdefault(value, []).append((cn, nodeid, propname))
943 # figure chained dependencies ordering
944 order = []
945 done = {}
946 # loop detection
947 change = 0
948 while len(all_props) != len(done):
949 for needed in all_props.keys():
950 if done.has_key(needed):
951 continue
952 tlist = deps.get(needed, [])
953 for target in tlist:
954 if not done.has_key(target):
955 break
956 else:
957 done[needed] = 1
958 order.append(needed)
959 change = 1
960 if not change:
961 raise ValueError, 'linking must not loop!'
963 # now, edit / create
964 m = []
965 for needed in order:
966 props = all_props[needed]
967 if not props:
968 # nothing to do
969 continue
970 cn, nodeid = needed
972 if nodeid is not None and int(nodeid) > 0:
973 # make changes to the node
974 props = self._changenode(cn, nodeid, props)
976 # and some nice feedback for the user
977 if props:
978 info = ', '.join(props.keys())
979 m.append('%s %s %s edited ok'%(cn, nodeid, info))
980 else:
981 m.append('%s %s - nothing changed'%(cn, nodeid))
982 else:
983 assert props
985 # make a new node
986 newid = self._createnode(cn, props)
987 if nodeid is None:
988 self.nodeid = newid
989 nodeid = newid
991 # and some nice feedback for the user
992 m.append('%s %s created'%(cn, newid))
994 # fill in new ids in links
995 if links.has_key(needed):
996 for linkcn, linkid, linkprop in links[needed]:
997 props = all_props[(linkcn, linkid)]
998 cl = self.db.classes[linkcn]
999 propdef = cl.getprops()[linkprop]
1000 if not props.has_key(linkprop):
1001 if linkid is None or linkid.startswith('-'):
1002 # linking to a new item
1003 if isinstance(propdef, hyperdb.Multilink):
1004 props[linkprop] = [newid]
1005 else:
1006 props[linkprop] = newid
1007 else:
1008 # linking to an existing item
1009 if isinstance(propdef, hyperdb.Multilink):
1010 existing = cl.get(linkid, linkprop)[:]
1011 existing.append(nodeid)
1012 props[linkprop] = existing
1013 else:
1014 props[linkprop] = newid
1016 return '<br>'.join(m)
1018 def _changenode(self, cn, nodeid, props):
1019 ''' change the node based on the contents of the form
1020 '''
1021 # check for permission
1022 if not self.editItemPermission(props):
1023 raise PermissionError, 'You do not have permission to edit %s'%cn
1025 # make the changes
1026 cl = self.db.classes[cn]
1027 return cl.set(nodeid, **props)
1029 def _createnode(self, cn, props):
1030 ''' create a node based on the contents of the form
1031 '''
1032 # check for permission
1033 if not self.newItemPermission(props):
1034 raise PermissionError, 'You do not have permission to create %s'%cn
1036 # create the node and return its id
1037 cl = self.db.classes[cn]
1038 return cl.create(**props)
1040 #
1041 # More actions
1042 #
1043 def editCSVAction(self):
1044 ''' Performs an edit of all of a class' items in one go.
1046 The "rows" CGI var defines the CSV-formatted entries for the
1047 class. New nodes are identified by the ID 'X' (or any other
1048 non-existent ID) and removed lines are retired.
1049 '''
1050 # this is per-class only
1051 if not self.editCSVPermission():
1052 self.error_message.append(
1053 _('You do not have permission to edit %s' %self.classname))
1055 # get the CSV module
1056 try:
1057 import csv
1058 except ImportError:
1059 self.error_message.append(_(
1060 'Sorry, you need the csv module to use this function.<br>\n'
1061 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
1062 return
1064 cl = self.db.classes[self.classname]
1065 idlessprops = cl.getprops(protected=0).keys()
1066 idlessprops.sort()
1067 props = ['id'] + idlessprops
1069 # do the edit
1070 rows = self.form['rows'].value.splitlines()
1071 p = csv.parser()
1072 found = {}
1073 line = 0
1074 for row in rows[1:]:
1075 line += 1
1076 values = p.parse(row)
1077 # not a complete row, keep going
1078 if not values: continue
1080 # skip property names header
1081 if values == props:
1082 continue
1084 # extract the nodeid
1085 nodeid, values = values[0], values[1:]
1086 found[nodeid] = 1
1088 # confirm correct weight
1089 if len(idlessprops) != len(values):
1090 self.error_message.append(
1091 _('Not enough values on line %(line)s')%{'line':line})
1092 return
1094 # extract the new values
1095 d = {}
1096 for name, value in zip(idlessprops, values):
1097 value = value.strip()
1098 # only add the property if it has a value
1099 if value:
1100 # if it's a multilink, split it
1101 if isinstance(cl.properties[name], hyperdb.Multilink):
1102 value = value.split(':')
1103 d[name] = value
1105 # perform the edit
1106 if cl.hasnode(nodeid):
1107 # edit existing
1108 cl.set(nodeid, **d)
1109 else:
1110 # new node
1111 found[cl.create(**d)] = 1
1113 # retire the removed entries
1114 for nodeid in cl.list():
1115 if not found.has_key(nodeid):
1116 cl.retire(nodeid)
1118 # all OK
1119 self.db.commit()
1121 self.ok_message.append(_('Items edited OK'))
1123 def editCSVPermission(self):
1124 ''' Determine whether the user has permission to edit this class.
1126 Base behaviour is to check the user can edit this class.
1127 '''
1128 if not self.db.security.hasPermission('Edit', self.userid,
1129 self.classname):
1130 return 0
1131 return 1
1133 def searchAction(self):
1134 ''' Mangle some of the form variables.
1136 Set the form ":filter" variable based on the values of the
1137 filter variables - if they're set to anything other than
1138 "dontcare" then add them to :filter.
1140 Also handle the ":queryname" variable and save off the query to
1141 the user's query list.
1142 '''
1143 # generic edit is per-class only
1144 if not self.searchPermission():
1145 self.error_message.append(
1146 _('You do not have permission to search %s' %self.classname))
1148 # add a faked :filter form variable for each filtering prop
1149 props = self.db.classes[self.classname].getprops()
1150 queryname = ''
1151 for key in self.form.keys():
1152 # special vars
1153 if self.FV_QUERYNAME.match(key):
1154 queryname = self.form[key].value.strip()
1155 continue
1157 if not props.has_key(key):
1158 continue
1159 if isinstance(self.form[key], type([])):
1160 # search for at least one entry which is not empty
1161 for minifield in self.form[key]:
1162 if minifield.value:
1163 break
1164 else:
1165 continue
1166 else:
1167 if not self.form[key].value: continue
1168 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
1170 # handle saving the query params
1171 if queryname:
1172 # parse the environment and figure what the query _is_
1173 req = HTMLRequest(self)
1174 url = req.indexargs_href('', {})
1176 # handle editing an existing query
1177 try:
1178 qid = self.db.query.lookup(queryname)
1179 self.db.query.set(qid, klass=self.classname, url=url)
1180 except KeyError:
1181 # create a query
1182 qid = self.db.query.create(name=queryname,
1183 klass=self.classname, url=url)
1185 # and add it to the user's query multilink
1186 queries = self.db.user.get(self.userid, 'queries')
1187 queries.append(qid)
1188 self.db.user.set(self.userid, queries=queries)
1190 # commit the query change to the database
1191 self.db.commit()
1193 def searchPermission(self):
1194 ''' Determine whether the user has permission to search this class.
1196 Base behaviour is to check the user can view this class.
1197 '''
1198 if not self.db.security.hasPermission('View', self.userid,
1199 self.classname):
1200 return 0
1201 return 1
1204 def retireAction(self):
1205 ''' Retire the context item.
1206 '''
1207 # if we want to view the index template now, then unset the nodeid
1208 # context info (a special-case for retire actions on the index page)
1209 nodeid = self.nodeid
1210 if self.template == 'index':
1211 self.nodeid = None
1213 # generic edit is per-class only
1214 if not self.retirePermission():
1215 self.error_message.append(
1216 _('You do not have permission to retire %s' %self.classname))
1217 return
1219 # make sure we don't try to retire admin or anonymous
1220 if self.classname == 'user' and \
1221 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
1222 self.error_message.append(
1223 _('You may not retire the admin or anonymous user'))
1224 return
1226 # do the retire
1227 self.db.getclass(self.classname).retire(nodeid)
1228 self.db.commit()
1230 self.ok_message.append(
1231 _('%(classname)s %(itemid)s has been retired')%{
1232 'classname': self.classname.capitalize(), 'itemid': nodeid})
1234 def retirePermission(self):
1235 ''' Determine whether the user has permission to retire this class.
1237 Base behaviour is to check the user can edit this class.
1238 '''
1239 if not self.db.security.hasPermission('Edit', self.userid,
1240 self.classname):
1241 return 0
1242 return 1
1245 def showAction(self, typere=re.compile('[@:]type'),
1246 numre=re.compile('[@:]number')):
1247 ''' Show a node of a particular class/id
1248 '''
1249 t = n = ''
1250 for key in self.form.keys():
1251 if typere.match(key):
1252 t = self.form[key].value.strip()
1253 elif numre.match(key):
1254 n = self.form[key].value.strip()
1255 if not t:
1256 raise ValueError, 'Invalid %s number'%t
1257 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
1258 raise Redirect, url
1260 def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
1261 ''' Pull properties out of the form.
1263 In the following, <bracketed> values are variable, ":" may be
1264 one of ":" or "@", and other text "required" is fixed.
1266 Properties are specified as form variables:
1268 <propname>
1269 - property on the current context item
1271 <designator>:<propname>
1272 - property on the indicated item
1274 <classname>-<N>:<propname>
1275 - property on the Nth new item of classname
1277 Once we have determined the "propname", we check to see if it
1278 is one of the special form values:
1280 :required
1281 The named property values must be supplied or a ValueError
1282 will be raised.
1284 :remove:<propname>=id(s)
1285 The ids will be removed from the multilink property.
1287 :add:<propname>=id(s)
1288 The ids will be added to the multilink property.
1290 :link:<propname>=<designator>
1291 Used to add a link to new items created during edit.
1292 These are collected up and returned in all_links. This will
1293 result in an additional linking operation (either Link set or
1294 Multilink append) after the edit/create is done using
1295 all_props in _editnodes. The <propname> on the current item
1296 will be set/appended the id of the newly created item of
1297 class <designator> (where <designator> must be
1298 <classname>-<N>).
1300 Any of the form variables may be prefixed with a classname or
1301 designator.
1303 The return from this method is a dict of
1304 (classname, id): properties
1305 ... this dict _always_ has an entry for the current context,
1306 even if it's empty (ie. a submission for an existing issue that
1307 doesn't result in any changes would return {('issue','123'): {}})
1308 The id may be None, which indicates that an item should be
1309 created.
1311 If a String property's form value is a file upload, then we
1312 try to set additional properties "filename" and "type" (if
1313 they are valid for the class).
1315 Two special form values are supported for backwards
1316 compatibility:
1317 :note - create a message (with content, author and date), link
1318 to the context item. This is ALWAYS desginated "msg-1".
1319 :file - create a file, attach to the current item and any
1320 message created by :note. This is ALWAYS designated
1321 "file-1".
1323 We also check that FileClass items have a "content" property with
1324 actual content, otherwise we remove them from all_props before
1325 returning.
1326 '''
1327 # some very useful variables
1328 db = self.db
1329 form = self.form
1331 if not hasattr(self, 'FV_SPECIAL'):
1332 # generate the regexp for handling special form values
1333 classes = '|'.join(db.classes.keys())
1334 # specials for parsePropsFromForm
1335 # handle the various forms (see unit tests)
1336 self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
1337 self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
1339 # these indicate the default class / item
1340 default_cn = self.classname
1341 default_cl = self.db.classes[default_cn]
1342 default_nodeid = self.nodeid
1344 # we'll store info about the individual class/item edit in these
1345 all_required = {} # one entry per class/item
1346 all_props = {} # one entry per class/item
1347 all_propdef = {} # note - only one entry per class
1348 all_links = [] # as many as are required
1350 # we should always return something, even empty, for the context
1351 all_props[(default_cn, default_nodeid)] = {}
1353 keys = form.keys()
1354 timezone = db.getUserTimezone()
1356 # sentinels for the :note and :file props
1357 have_note = have_file = 0
1359 # extract the usable form labels from the form
1360 matches = []
1361 for key in keys:
1362 m = self.FV_SPECIAL.match(key)
1363 if m:
1364 matches.append((key, m.groupdict()))
1366 # now handle the matches
1367 for key, d in matches:
1368 if d['classname']:
1369 # we got a designator
1370 cn = d['classname']
1371 cl = self.db.classes[cn]
1372 nodeid = d['id']
1373 propname = d['propname']
1374 elif d['note']:
1375 # the special note field
1376 cn = 'msg'
1377 cl = self.db.classes[cn]
1378 nodeid = '-1'
1379 propname = 'content'
1380 all_links.append((default_cn, default_nodeid, 'messages',
1381 [('msg', '-1')]))
1382 have_note = 1
1383 elif d['file']:
1384 # the special file field
1385 cn = 'file'
1386 cl = self.db.classes[cn]
1387 nodeid = '-1'
1388 propname = 'content'
1389 all_links.append((default_cn, default_nodeid, 'files',
1390 [('file', '-1')]))
1391 have_file = 1
1392 else:
1393 # default
1394 cn = default_cn
1395 cl = default_cl
1396 nodeid = default_nodeid
1397 propname = d['propname']
1399 # the thing this value relates to is...
1400 this = (cn, nodeid)
1402 # get more info about the class, and the current set of
1403 # form props for it
1404 if not all_propdef.has_key(cn):
1405 all_propdef[cn] = cl.getprops()
1406 propdef = all_propdef[cn]
1407 if not all_props.has_key(this):
1408 all_props[this] = {}
1409 props = all_props[this]
1411 # is this a link command?
1412 if d['link']:
1413 value = []
1414 for entry in extractFormList(form[key]):
1415 m = self.FV_DESIGNATOR.match(entry)
1416 if not m:
1417 raise ValueError, \
1418 'link "%s" value "%s" not a designator'%(key, entry)
1419 value.append((m.group(1), m.group(2)))
1421 # make sure the link property is valid
1422 if (not isinstance(propdef[propname], hyperdb.Multilink) and
1423 not isinstance(propdef[propname], hyperdb.Link)):
1424 raise ValueError, '%s %s is not a link or '\
1425 'multilink property'%(cn, propname)
1427 all_links.append((cn, nodeid, propname, value))
1428 continue
1430 # detect the special ":required" variable
1431 if d['required']:
1432 all_required[this] = extractFormList(form[key])
1433 continue
1435 # get the required values list
1436 if not all_required.has_key(this):
1437 all_required[this] = []
1438 required = all_required[this]
1440 # see if we're performing a special multilink action
1441 mlaction = 'set'
1442 if d['remove']:
1443 mlaction = 'remove'
1444 elif d['add']:
1445 mlaction = 'add'
1447 # does the property exist?
1448 if not propdef.has_key(propname):
1449 if mlaction != 'set':
1450 raise ValueError, 'You have submitted a %s action for'\
1451 ' the property "%s" which doesn\'t exist'%(mlaction,
1452 propname)
1453 # the form element is probably just something we don't care
1454 # about - ignore it
1455 continue
1456 proptype = propdef[propname]
1458 # Get the form value. This value may be a MiniFieldStorage or a list
1459 # of MiniFieldStorages.
1460 value = form[key]
1462 # handle unpacking of the MiniFieldStorage / list form value
1463 if isinstance(proptype, hyperdb.Multilink):
1464 value = extractFormList(value)
1465 else:
1466 # multiple values are not OK
1467 if isinstance(value, type([])):
1468 raise ValueError, 'You have submitted more than one value'\
1469 ' for the %s property'%propname
1470 # value might be a file upload...
1471 if not hasattr(value, 'filename') or value.filename is None:
1472 # nope, pull out the value and strip it
1473 value = value.value.strip()
1475 # now that we have the props field, we need a teensy little
1476 # extra bit of help for the old :note field...
1477 if d['note'] and value:
1478 props['author'] = self.db.getuid()
1479 props['date'] = date.Date()
1481 # handle by type now
1482 if isinstance(proptype, hyperdb.Password):
1483 if not value:
1484 # ignore empty password values
1485 continue
1486 for key, d in matches:
1487 if d['confirm'] and d['propname'] == propname:
1488 confirm = form[key]
1489 break
1490 else:
1491 raise ValueError, 'Password and confirmation text do '\
1492 'not match'
1493 if isinstance(confirm, type([])):
1494 raise ValueError, 'You have submitted more than one value'\
1495 ' for the %s property'%propname
1496 if value != confirm.value:
1497 raise ValueError, 'Password and confirmation text do '\
1498 'not match'
1499 value = password.Password(value)
1501 elif isinstance(proptype, hyperdb.Link):
1502 # see if it's the "no selection" choice
1503 if value == '-1' or not value:
1504 # if we're creating, just don't include this property
1505 if not nodeid or nodeid.startswith('-'):
1506 continue
1507 value = None
1508 else:
1509 # handle key values
1510 link = proptype.classname
1511 if not num_re.match(value):
1512 try:
1513 value = db.classes[link].lookup(value)
1514 except KeyError:
1515 raise ValueError, _('property "%(propname)s": '
1516 '%(value)s not a %(classname)s')%{
1517 'propname': propname, 'value': value,
1518 'classname': link}
1519 except TypeError, message:
1520 raise ValueError, _('you may only enter ID values '
1521 'for property "%(propname)s": %(message)s')%{
1522 'propname': propname, 'message': message}
1523 elif isinstance(proptype, hyperdb.Multilink):
1524 # perform link class key value lookup if necessary
1525 link = proptype.classname
1526 link_cl = db.classes[link]
1527 l = []
1528 for entry in value:
1529 if not entry: continue
1530 if not num_re.match(entry):
1531 try:
1532 entry = link_cl.lookup(entry)
1533 except KeyError:
1534 raise ValueError, _('property "%(propname)s": '
1535 '"%(value)s" not an entry of %(classname)s')%{
1536 'propname': propname, 'value': entry,
1537 'classname': link}
1538 except TypeError, message:
1539 raise ValueError, _('you may only enter ID values '
1540 'for property "%(propname)s": %(message)s')%{
1541 'propname': propname, 'message': message}
1542 l.append(entry)
1543 l.sort()
1545 # now use that list of ids to modify the multilink
1546 if mlaction == 'set':
1547 value = l
1548 else:
1549 # we're modifying the list - get the current list of ids
1550 if props.has_key(propname):
1551 existing = props[propname]
1552 elif nodeid and not nodeid.startswith('-'):
1553 existing = cl.get(nodeid, propname, [])
1554 else:
1555 existing = []
1557 # now either remove or add
1558 if mlaction == 'remove':
1559 # remove - handle situation where the id isn't in
1560 # the list
1561 for entry in l:
1562 try:
1563 existing.remove(entry)
1564 except ValueError:
1565 raise ValueError, _('property "%(propname)s": '
1566 '"%(value)s" not currently in list')%{
1567 'propname': propname, 'value': entry}
1568 else:
1569 # add - easy, just don't dupe
1570 for entry in l:
1571 if entry not in existing:
1572 existing.append(entry)
1573 value = existing
1574 value.sort()
1576 elif value == '':
1577 # if we're creating, just don't include this property
1578 if not nodeid or nodeid.startswith('-'):
1579 continue
1580 # other types should be None'd if there's no value
1581 value = None
1582 else:
1583 if isinstance(proptype, hyperdb.String):
1584 if (hasattr(value, 'filename') and
1585 value.filename is not None):
1586 # skip if the upload is empty
1587 if not value.filename:
1588 continue
1589 # this String is actually a _file_
1590 # try to determine the file content-type
1591 filename = value.filename.split('\\')[-1]
1592 if propdef.has_key('name'):
1593 props['name'] = filename
1594 # use this info as the type/filename properties
1595 if propdef.has_key('type'):
1596 props['type'] = mimetypes.guess_type(filename)[0]
1597 if not props['type']:
1598 props['type'] = "application/octet-stream"
1599 # finally, read the content
1600 value = value.value
1601 else:
1602 # normal String fix the CRLF/CR -> LF stuff
1603 value = fixNewlines(value)
1605 elif isinstance(proptype, hyperdb.Date):
1606 value = date.Date(value, offset=timezone)
1607 elif isinstance(proptype, hyperdb.Interval):
1608 value = date.Interval(value)
1609 elif isinstance(proptype, hyperdb.Boolean):
1610 value = value.lower() in ('yes', 'true', 'on', '1')
1611 elif isinstance(proptype, hyperdb.Number):
1612 value = float(value)
1614 # get the old value
1615 if nodeid and not nodeid.startswith('-'):
1616 try:
1617 existing = cl.get(nodeid, propname)
1618 except KeyError:
1619 # this might be a new property for which there is
1620 # no existing value
1621 if not propdef.has_key(propname):
1622 raise
1624 # make sure the existing multilink is sorted
1625 if isinstance(proptype, hyperdb.Multilink):
1626 existing.sort()
1628 # "missing" existing values may not be None
1629 if not existing:
1630 if isinstance(proptype, hyperdb.String) and not existing:
1631 # some backends store "missing" Strings as empty strings
1632 existing = None
1633 elif isinstance(proptype, hyperdb.Number) and not existing:
1634 # some backends store "missing" Numbers as 0 :(
1635 existing = 0
1636 elif isinstance(proptype, hyperdb.Boolean) and not existing:
1637 # likewise Booleans
1638 existing = 0
1640 # if changed, set it
1641 if value != existing:
1642 props[propname] = value
1643 else:
1644 # don't bother setting empty/unset values
1645 if value is None:
1646 continue
1647 elif isinstance(proptype, hyperdb.Multilink) and value == []:
1648 continue
1649 elif isinstance(proptype, hyperdb.String) and value == '':
1650 continue
1652 props[propname] = value
1654 # register this as received if required?
1655 if propname in required and value is not None:
1656 required.remove(propname)
1658 # check to see if we need to specially link a file to the note
1659 if have_note and have_file:
1660 all_links.append(('msg', '-1', 'files', [('file', '-1')]))
1662 # see if all the required properties have been supplied
1663 s = []
1664 for thing, required in all_required.items():
1665 if not required:
1666 continue
1667 if len(required) > 1:
1668 p = 'properties'
1669 else:
1670 p = 'property'
1671 s.append('Required %s %s %s not supplied'%(thing[0], p,
1672 ', '.join(required)))
1673 if s:
1674 raise ValueError, '\n'.join(s)
1676 # check that FileClass entries have a "content" property with
1677 # content, otherwise remove them
1678 for (cn, id), props in all_props.items():
1679 cl = self.db.classes[cn]
1680 if not isinstance(cl, hyperdb.FileClass):
1681 continue
1682 # we also don't want to create FileClass items with no content
1683 if not props.get('content', ''):
1684 del all_props[(cn, id)]
1685 return all_props, all_links
1687 def fixNewlines(text):
1688 ''' Homogenise line endings.
1690 Different web clients send different line ending values, but
1691 other systems (eg. email) don't necessarily handle those line
1692 endings. Our solution is to convert all line endings to LF.
1693 '''
1694 text = text.replace('\r\n', '\n')
1695 return text.replace('\r', '\n')
1697 def extractFormList(value):
1698 ''' Extract a list of values from the form value.
1700 It may be one of:
1701 [MiniFieldStorage, MiniFieldStorage, ...]
1702 MiniFieldStorage('value,value,...')
1703 MiniFieldStorage('value')
1704 '''
1705 # multiple values are OK
1706 if isinstance(value, type([])):
1707 # it's a list of MiniFieldStorages
1708 value = [i.value.strip() for i in value]
1709 else:
1710 # it's a MiniFieldStorage, but may be a comma-separated list
1711 # of values
1712 value = [i.strip() for i in value.value.split(',')]
1714 # filter out the empty bits
1715 return filter(None, value)