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