1 import re, cgi, StringIO, urllib, Cookie, time, random
3 from roundup import hyperdb, token, date, password, rcsv
4 from roundup.i18n import _
5 from roundup.cgi import templating
6 from roundup.cgi.exceptions import Redirect, Unauthorised
7 from roundup.mailgw import uidFromAddress
9 __all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
10 'EditCSVAction', 'EditItemAction', 'PassResetAction',
11 'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction']
13 # used by a couple of routines
14 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
16 class Action:
17 def __init__(self, client):
18 self.client = client
19 self.form = client.form
20 self.db = client.db
21 self.nodeid = client.nodeid
22 self.template = client.template
23 self.classname = client.classname
24 self.userid = client.userid
25 self.base = client.base
26 self.user = client.user
28 def handle(self):
29 """Execute the action specified by this object."""
30 raise NotImplementedError
32 def permission(self):
33 """Check whether the user has permission to execute this action.
35 True by default.
36 """
37 return 1
39 class ShowAction(Action):
40 def handle(self, typere=re.compile('[@:]type'),
41 numre=re.compile('[@:]number')):
42 """Show a node of a particular class/id."""
43 t = n = ''
44 for key in self.form.keys():
45 if typere.match(key):
46 t = self.form[key].value.strip()
47 elif numre.match(key):
48 n = self.form[key].value.strip()
49 if not t:
50 raise ValueError, 'Invalid %s number'%t
51 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
52 raise Redirect, url
54 class RetireAction(Action):
55 def handle(self):
56 """Retire the context item."""
57 # if we want to view the index template now, then unset the nodeid
58 # context info (a special-case for retire actions on the index page)
59 nodeid = self.nodeid
60 if self.template == 'index':
61 self.client.nodeid = None
63 # generic edit is per-class only
64 if not self.permission():
65 raise Unauthorised, _('You do not have permission to retire %s' %
66 self.classname)
68 # make sure we don't try to retire admin or anonymous
69 if self.classname == 'user' and \
70 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
71 raise ValueError, _('You may not retire the admin or anonymous user')
73 # do the retire
74 self.db.getclass(self.classname).retire(nodeid)
75 self.db.commit()
77 self.client.ok_message.append(
78 _('%(classname)s %(itemid)s has been retired')%{
79 'classname': self.classname.capitalize(), 'itemid': nodeid})
81 def permission(self):
82 """Determine whether the user has permission to retire this class.
84 Base behaviour is to check the user can edit this class.
85 """
86 return self.db.security.hasPermission('Edit', self.client.userid,
87 self.client.classname)
89 class SearchAction(Action):
90 def handle(self, wcre=re.compile(r'[\s,]+')):
91 """Mangle some of the form variables.
93 Set the form ":filter" variable based on the values of the filter
94 variables - if they're set to anything other than "dontcare" then add
95 them to :filter.
97 Handle the ":queryname" variable and save off the query to the user's
98 query list.
100 Split any String query values on whitespace and comma.
102 """
103 # generic edit is per-class only
104 if not self.permission():
105 raise Unauthorised, _('You do not have permission to search %s' %
106 self.classname)
108 self.fakeFilterVars()
109 queryname = self.getQueryName()
111 # handle saving the query params
112 if queryname:
113 # parse the environment and figure what the query _is_
114 req = templating.HTMLRequest(self.client)
116 # The [1:] strips off the '?' character, it isn't part of the
117 # query string.
118 url = req.indexargs_href('', {})[1:]
120 # handle editing an existing query
121 try:
122 qid = self.db.query.lookup(queryname)
123 self.db.query.set(qid, klass=self.classname, url=url)
124 except KeyError:
125 # create a query
126 qid = self.db.query.create(name=queryname,
127 klass=self.classname, url=url)
129 # and add it to the user's query multilink
130 queries = self.db.user.get(self.userid, 'queries')
131 queries.append(qid)
132 self.db.user.set(self.userid, queries=queries)
134 # commit the query change to the database
135 self.db.commit()
137 def fakeFilterVars(self):
138 """Add a faked :filter form variable for each filtering prop."""
139 props = self.db.classes[self.classname].getprops()
140 for key in self.form.keys():
141 if not props.has_key(key):
142 continue
143 if isinstance(self.form[key], type([])):
144 # search for at least one entry which is not empty
145 for minifield in self.form[key]:
146 if minifield.value:
147 break
148 else:
149 continue
150 else:
151 if not self.form[key].value:
152 continue
153 if isinstance(props[key], hyperdb.String):
154 v = self.form[key].value
155 l = token.token_split(v)
156 if len(l) > 1 or l[0] != v:
157 self.form.value.remove(self.form[key])
158 # replace the single value with the split list
159 for v in l:
160 self.form.value.append(cgi.MiniFieldStorage(key, v))
162 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
164 FV_QUERYNAME = re.compile(r'[@:]queryname')
165 def getQueryName(self):
166 for key in self.form.keys():
167 if self.FV_QUERYNAME.match(key):
168 return self.form[key].value.strip()
169 return ''
171 def permission(self):
172 return self.db.security.hasPermission('View', self.client.userid,
173 self.client.classname)
175 class EditCSVAction(Action):
176 def handle(self):
177 """Performs an edit of all of a class' items in one go.
179 The "rows" CGI var defines the CSV-formatted entries for the class. New
180 nodes are identified by the ID 'X' (or any other non-existent ID) and
181 removed lines are retired.
183 """
184 # this is per-class only
185 if not self.permission():
186 self.client.error_message.append(
187 _('You do not have permission to edit %s' %self.classname))
188 return
190 # get the CSV module
191 if rcsv.error:
192 self.client.error_message.append(_(rcsv.error))
193 return
195 cl = self.db.classes[self.classname]
196 idlessprops = cl.getprops(protected=0).keys()
197 idlessprops.sort()
198 props = ['id'] + idlessprops
200 # do the edit
201 rows = StringIO.StringIO(self.form['rows'].value)
202 reader = rcsv.reader(rows, rcsv.comma_separated)
203 found = {}
204 line = 0
205 for values in reader:
206 line += 1
207 if line == 1: continue
208 # skip property names header
209 if values == props:
210 continue
212 # extract the nodeid
213 nodeid, values = values[0], values[1:]
214 found[nodeid] = 1
216 # see if the node exists
217 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
218 exists = 0
219 else:
220 exists = 1
222 # confirm correct weight
223 if len(idlessprops) != len(values):
224 self.client.error_message.append(
225 _('Not enough values on line %(line)s')%{'line':line})
226 return
228 # extract the new values
229 d = {}
230 for name, value in zip(idlessprops, values):
231 prop = cl.properties[name]
232 value = value.strip()
233 # only add the property if it has a value
234 if value:
235 # if it's a multilink, split it
236 if isinstance(prop, hyperdb.Multilink):
237 value = value.split(':')
238 elif isinstance(prop, hyperdb.Password):
239 value = password.Password(value)
240 elif isinstance(prop, hyperdb.Interval):
241 value = date.Interval(value)
242 elif isinstance(prop, hyperdb.Date):
243 value = date.Date(value)
244 elif isinstance(prop, hyperdb.Boolean):
245 value = value.lower() in ('yes', 'true', 'on', '1')
246 elif isinstance(prop, hyperdb.Number):
247 value = float(value)
248 d[name] = value
249 elif exists:
250 # nuke the existing value
251 if isinstance(prop, hyperdb.Multilink):
252 d[name] = []
253 else:
254 d[name] = None
256 # perform the edit
257 if exists:
258 # edit existing
259 cl.set(nodeid, **d)
260 else:
261 # new node
262 found[cl.create(**d)] = 1
264 # retire the removed entries
265 for nodeid in cl.list():
266 if not found.has_key(nodeid):
267 cl.retire(nodeid)
269 # all OK
270 self.db.commit()
272 self.client.ok_message.append(_('Items edited OK'))
274 def permission(self):
275 return self.db.security.hasPermission('Edit', self.client.userid,
276 self.client.classname)
278 class EditItemAction(Action):
279 def handle(self):
280 """Perform an edit of an item in the database.
282 See parsePropsFromForm and _editnodes for special variables.
284 """
285 props, links = self.client.parsePropsFromForm()
287 # handle the props
288 try:
289 message = self._editnodes(props, links)
290 except (ValueError, KeyError, IndexError), message:
291 self.client.error_message.append(_('Apply Error: ') + str(message))
292 return
294 # commit now that all the tricky stuff is done
295 self.db.commit()
297 # redirect to the item's edit page
298 # redirect to finish off
299 url = self.base + self.classname
300 # note that this action might have been called by an index page, so
301 # we will want to include index-page args in this URL too
302 if self.nodeid is not None:
303 url += self.nodeid
304 url += '?@ok_message=%s&@template=%s'%(urllib.quote(message),
305 urllib.quote(self.template))
306 if self.nodeid is None:
307 req = templating.HTMLRequest(self)
308 url += '&' + req.indexargs_href('', {})[1:]
309 raise Redirect, url
311 def editItemPermission(self, props):
312 """Determine whether the user has permission to edit this item.
314 Base behaviour is to check the user can edit this class. If we're
315 editing the"user" class, users are allowed to edit their own details.
316 Unless it's the "roles" property, which requires the special Permission
317 "Web Roles".
318 """
319 # if this is a user node and the user is editing their own node, then
320 # we're OK
321 has = self.db.security.hasPermission
322 if self.classname == 'user':
323 # reject if someone's trying to edit "roles" and doesn't have the
324 # right permission.
325 if props.has_key('roles') and not has('Web Roles', self.userid,
326 'user'):
327 return 0
328 # if the item being edited is the current user, we're ok
329 if (self.nodeid == self.userid
330 and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
331 return 1
332 if self.db.security.hasPermission('Edit', self.userid, self.classname):
333 return 1
334 return 0
336 def newItemAction(self):
337 ''' Add a new item to the database.
339 This follows the same form as the editItemAction, with the same
340 special form values.
341 '''
342 # parse the props from the form
343 try:
344 props, links = self.parsePropsFromForm(create=True)
345 except (ValueError, KeyError), message:
346 self.error_message.append(_('Error: ') + str(message))
347 return
349 # handle the props - edit or create
350 try:
351 # when it hits the None element, it'll set self.nodeid
352 messages = self._editnodes(props, links)
354 except (ValueError, KeyError, IndexError), message:
355 # these errors might just be indicative of user dumbness
356 self.error_message.append(_('Error: ') + str(message))
357 return
359 # commit now that all the tricky stuff is done
360 self.db.commit()
362 # redirect to the new item's page
363 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
364 self.classname, self.nodeid, urllib.quote(messages),
365 urllib.quote(self.template))
367 def newItemPermission(self, props):
368 """Determine whether the user has permission to create (edit) this item.
370 Base behaviour is to check the user can edit this class. No additional
371 property checks are made. Additionally, new user items may be created
372 if the user has the "Web Registration" Permission.
374 """
375 has = self.db.security.hasPermission
376 if self.classname == 'user' and has('Web Registration', self.userid,
377 'user'):
378 return 1
379 if has('Edit', self.userid, self.classname):
380 return 1
381 return 0
383 #
384 # Utility methods for editing
385 #
386 def _editnodes(self, all_props, all_links, newids=None):
387 ''' Use the props in all_props to perform edit and creation, then
388 use the link specs in all_links to do linking.
389 '''
390 # figure dependencies and re-work links
391 deps = {}
392 links = {}
393 for cn, nodeid, propname, vlist in all_links:
394 if not all_props.has_key((cn, nodeid)):
395 # link item to link to doesn't (and won't) exist
396 continue
397 for value in vlist:
398 if not all_props.has_key(value):
399 # link item to link to doesn't (and won't) exist
400 continue
401 deps.setdefault((cn, nodeid), []).append(value)
402 links.setdefault(value, []).append((cn, nodeid, propname))
404 # figure chained dependencies ordering
405 order = []
406 done = {}
407 # loop detection
408 change = 0
409 while len(all_props) != len(done):
410 for needed in all_props.keys():
411 if done.has_key(needed):
412 continue
413 tlist = deps.get(needed, [])
414 for target in tlist:
415 if not done.has_key(target):
416 break
417 else:
418 done[needed] = 1
419 order.append(needed)
420 change = 1
421 if not change:
422 raise ValueError, 'linking must not loop!'
424 # now, edit / create
425 m = []
426 for needed in order:
427 props = all_props[needed]
428 if not props:
429 # nothing to do
430 continue
431 cn, nodeid = needed
433 if nodeid is not None and int(nodeid) > 0:
434 # make changes to the node
435 props = self._changenode(cn, nodeid, props)
437 # and some nice feedback for the user
438 if props:
439 info = ', '.join(props.keys())
440 m.append('%s %s %s edited ok'%(cn, nodeid, info))
441 else:
442 m.append('%s %s - nothing changed'%(cn, nodeid))
443 else:
444 assert props
446 # make a new node
447 newid = self._createnode(cn, props)
448 if nodeid is None:
449 self.client.nodeid = newid
450 nodeid = newid
452 # and some nice feedback for the user
453 m.append('%s %s created'%(cn, newid))
455 # fill in new ids in links
456 if links.has_key(needed):
457 for linkcn, linkid, linkprop in links[needed]:
458 props = all_props[(linkcn, linkid)]
459 cl = self.db.classes[linkcn]
460 propdef = cl.getprops()[linkprop]
461 if not props.has_key(linkprop):
462 if linkid is None or linkid.startswith('-'):
463 # linking to a new item
464 if isinstance(propdef, hyperdb.Multilink):
465 props[linkprop] = [newid]
466 else:
467 props[linkprop] = newid
468 else:
469 # linking to an existing item
470 if isinstance(propdef, hyperdb.Multilink):
471 existing = cl.get(linkid, linkprop)[:]
472 existing.append(nodeid)
473 props[linkprop] = existing
474 else:
475 props[linkprop] = newid
477 return '<br>'.join(m)
479 def _changenode(self, cn, nodeid, props):
480 """Change the node based on the contents of the form."""
481 # check for permission
482 if not self.editItemPermission(props):
483 raise Unauthorised, 'You do not have permission to edit %s'%cn
485 # make the changes
486 cl = self.db.classes[cn]
487 return cl.set(nodeid, **props)
489 def _createnode(self, cn, props):
490 """Create a node based on the contents of the form."""
491 # check for permission
492 if not self.newItemPermission(props):
493 raise Unauthorised, 'You do not have permission to create %s'%cn
495 # create the node and return its id
496 cl = self.db.classes[cn]
497 return cl.create(**props)
499 class PassResetAction(Action):
500 def handle(self):
501 """Handle password reset requests.
503 Presence of either "name" or "address" generates email. Presence of
504 "otk" performs the reset.
506 """
507 if self.form.has_key('otk'):
508 # pull the rego information out of the otk database
509 otk = self.form['otk'].value
510 uid = self.db.otks.get(otk, 'uid')
511 if uid is None:
512 self.client.error_message.append("""Invalid One Time Key!
513 (a Mozilla bug may cause this message to show up erroneously,
514 please check your email)""")
515 return
517 # re-open the database as "admin"
518 if self.user != 'admin':
519 self.client.opendb('admin')
520 self.db = self.client.db
522 # change the password
523 newpw = password.generatePassword()
525 cl = self.db.user
526 # XXX we need to make the "default" page be able to display errors!
527 try:
528 # set the password
529 cl.set(uid, password=password.Password(newpw))
530 # clear the props from the otk database
531 self.db.otks.destroy(otk)
532 self.db.commit()
533 except (ValueError, KeyError), message:
534 self.client.error_message.append(str(message))
535 return
537 # user info
538 address = self.db.user.get(uid, 'address')
539 name = self.db.user.get(uid, 'username')
541 # send the email
542 tracker_name = self.db.config.TRACKER_NAME
543 subject = 'Password reset for %s'%tracker_name
544 body = '''
545 The password has been reset for username "%(name)s".
547 Your password is now: %(password)s
548 '''%{'name': name, 'password': newpw}
549 if not self.client.standard_message([address], subject, body):
550 return
552 self.client.ok_message.append('Password reset and email sent to %s' %
553 address)
554 return
556 # no OTK, so now figure the user
557 if self.form.has_key('username'):
558 name = self.form['username'].value
559 try:
560 uid = self.db.user.lookup(name)
561 except KeyError:
562 self.client.error_message.append('Unknown username')
563 return
564 address = self.db.user.get(uid, 'address')
565 elif self.form.has_key('address'):
566 address = self.form['address'].value
567 uid = uidFromAddress(self.db, ('', address), create=0)
568 if not uid:
569 self.client.error_message.append('Unknown email address')
570 return
571 name = self.db.user.get(uid, 'username')
572 else:
573 self.client.error_message.append('You need to specify a username '
574 'or address')
575 return
577 # generate the one-time-key and store the props for later
578 otk = ''.join([random.choice(chars) for x in range(32)])
579 self.db.otks.set(otk, uid=uid, __time=time.time())
581 # send the email
582 tracker_name = self.db.config.TRACKER_NAME
583 subject = 'Confirm reset of password for %s'%tracker_name
584 body = '''
585 Someone, perhaps you, has requested that the password be changed for your
586 username, "%(name)s". If you wish to proceed with the change, please follow
587 the link below:
589 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
591 You should then receive another email with the new password.
592 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
593 if not self.client.standard_message([address], subject, body):
594 return
596 self.client.ok_message.append('Email sent to %s'%address)
598 class ConfRegoAction(Action):
599 def handle(self):
600 """Grab the OTK, use it to load up the new user details."""
601 try:
602 # pull the rego information out of the otk database
603 self.userid = self.db.confirm_registration(self.form['otk'].value)
604 except (ValueError, KeyError), message:
605 # XXX: we need to make the "default" page be able to display errors!
606 self.client.error_message.append(str(message))
607 return
609 # log the new user in
610 self.client.user = self.db.user.get(self.userid, 'username')
611 # re-open the database for real, using the user
612 self.client.opendb(self.client.user)
613 self.db = client.db
615 # if we have a session, update it
616 if hasattr(self, 'session'):
617 self.db.sessions.set(self.session, user=self.user,
618 last_use=time.time())
619 else:
620 # new session cookie
621 self.client.set_cookie(self.user)
623 # nice message
624 message = _('You are now registered, welcome!')
626 # redirect to the user's page
627 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
628 self.userid, urllib.quote(message))
630 class RegisterAction(Action):
631 def handle(self):
632 """Attempt to create a new user based on the contents of the form
633 and then set the cookie.
635 Return 1 on successful login.
636 """
637 props = self.client.parsePropsFromForm()[0][('user', None)]
639 # make sure we're allowed to register
640 if not self.permission(props):
641 raise Unauthorised, _("You do not have permission to register")
643 try:
644 self.db.user.lookup(props['username'])
645 self.client.error_message.append('Error: A user with the username "%s" '
646 'already exists'%props['username'])
647 return
648 except KeyError:
649 pass
651 # generate the one-time-key and store the props for later
652 otk = ''.join([random.choice(chars) for x in range(32)])
653 for propname, proptype in self.db.user.getprops().items():
654 value = props.get(propname, None)
655 if value is None:
656 pass
657 elif isinstance(proptype, hyperdb.Date):
658 props[propname] = str(value)
659 elif isinstance(proptype, hyperdb.Interval):
660 props[propname] = str(value)
661 elif isinstance(proptype, hyperdb.Password):
662 props[propname] = str(value)
663 props['__time'] = time.time()
664 self.db.otks.set(otk, **props)
666 # send the email
667 tracker_name = self.db.config.TRACKER_NAME
668 tracker_email = self.db.config.TRACKER_EMAIL
669 subject = 'Complete your registration to %s -- key %s' % (tracker_name,
670 otk)
671 body = """To complete your registration of the user "%(name)s" with
672 %(tracker)s, please do one of the following:
674 - send a reply to %(tracker_email)s and maintain the subject line as is (the
675 reply's additional "Re:" is ok),
677 - or visit the following URL:
679 %(url)s?@action=confrego&otk=%(otk)s
680 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
681 'otk': otk, 'tracker_email': tracker_email}
682 if not self.client.standard_message([props['address']], subject, body,
683 tracker_email):
684 return
686 # commit changes to the database
687 self.db.commit()
689 # redirect to the "you're almost there" page
690 raise Redirect, '%suser?@template=rego_progress'%self.base
692 def permission(self, props):
693 """Determine whether the user has permission to register
695 Base behaviour is to check the user has "Web Registration".
697 """
698 # registration isn't allowed to supply roles
699 if props.has_key('roles'):
700 return 0
701 if self.db.security.hasPermission('Web Registration', self.userid):
702 return 1
703 return 0
705 class LogoutAction(Action):
706 def handle(self):
707 """Make us really anonymous - nuke the cookie too."""
708 # log us out
709 self.client.make_user_anonymous()
711 # construct the logout cookie
712 now = Cookie._getdate()
713 self.client.additional_headers['Set-Cookie'] = \
714 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.client.cookie_name,
715 now, self.client.cookie_path)
717 # Let the user know what's going on
718 self.client.ok_message.append(_('You are logged out'))
720 class LoginAction(Action):
721 def handle(self):
722 """Attempt to log a user in.
724 Sets up a session for the user which contains the login credentials.
726 """
727 # we need the username at a minimum
728 if not self.form.has_key('__login_name'):
729 self.client.error_message.append(_('Username required'))
730 return
732 # get the login info
733 self.client.user = self.form['__login_name'].value
734 if self.form.has_key('__login_password'):
735 password = self.form['__login_password'].value
736 else:
737 password = ''
739 # make sure the user exists
740 try:
741 self.client.userid = self.db.user.lookup(self.client.user)
742 except KeyError:
743 name = self.client.user
744 self.client.error_message.append(_('No such user "%(name)s"')%locals())
745 self.client.make_user_anonymous()
746 return
748 # verify the password
749 if not self.verifyPassword(self.client.userid, password):
750 self.client.make_user_anonymous()
751 self.client.error_message.append(_('Incorrect password'))
752 return
754 # make sure we're allowed to be here
755 if not self.permission():
756 self.client.make_user_anonymous()
757 self.client.error_message.append(_("You do not have permission to login"))
758 return
760 # now we're OK, re-open the database for real, using the user
761 self.client.opendb(self.client.user)
763 # set the session cookie
764 self.client.set_cookie(self.client.user)
766 def verifyPassword(self, userid, password):
767 ''' Verify the password that the user has supplied
768 '''
769 stored = self.db.user.get(self.client.userid, 'password')
770 if password == stored:
771 return 1
772 if not password and not stored:
773 return 1
774 return 0
776 def permission(self):
777 """Determine whether the user has permission to log in.
779 Base behaviour is to check the user has "Web Access".
781 """
782 if not self.db.security.hasPermission('Web Access', self.client.userid):
783 return 0
784 return 1