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',
12 'NewItemAction']
14 # used by a couple of routines
15 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
17 class Action:
18 def __init__(self, client):
19 self.client = client
20 self.form = client.form
21 self.db = client.db
22 self.nodeid = client.nodeid
23 self.template = client.template
24 self.classname = client.classname
25 self.userid = client.userid
26 self.base = client.base
27 self.user = client.user
29 def execute(self):
30 """Execute the action specified by this object."""
31 self.permission()
32 self.handle()
34 name = ''
35 permissionType = None
36 def permission(self):
37 """Check whether the user has permission to execute this action.
39 True by default. If the permissionType attribute is a string containing
40 a simple permission, check whether the user has that permission.
41 Subclasses must also define the name attribute if they define
42 permissionType.
44 Despite having this permission, users may still be unauthorised to
45 perform parts of actions. It is up to the subclasses to detect this.
46 """
47 if (self.permissionType and
48 not self.hasPermission(self.permissionType)):
50 raise Unauthorised, _('You do not have permission to %s the %s class.' %
51 (self.name, self.classname))
53 def hasPermission(self, permission):
54 """Check whether the user has 'permission' on the current class."""
55 return self.db.security.hasPermission(permission, self.client.userid,
56 self.client.classname)
58 class ShowAction(Action):
59 def handle(self, typere=re.compile('[@:]type'),
60 numre=re.compile('[@:]number')):
61 """Show a node of a particular class/id."""
62 t = n = ''
63 for key in self.form.keys():
64 if typere.match(key):
65 t = self.form[key].value.strip()
66 elif numre.match(key):
67 n = self.form[key].value.strip()
68 if not t:
69 raise ValueError, 'Invalid %s number'%t
70 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
71 raise Redirect, url
73 class RetireAction(Action):
74 name = 'retire'
75 permissionType = 'Edit'
77 def handle(self):
78 """Retire the context item."""
79 # if we want to view the index template now, then unset the nodeid
80 # context info (a special-case for retire actions on the index page)
81 nodeid = self.nodeid
82 if self.template == 'index':
83 self.client.nodeid = None
85 # make sure we don't try to retire admin or anonymous
86 if self.classname == 'user' and \
87 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
88 raise ValueError, _('You may not retire the admin or anonymous user')
90 # do the retire
91 self.db.getclass(self.classname).retire(nodeid)
92 self.db.commit()
94 self.client.ok_message.append(
95 _('%(classname)s %(itemid)s has been retired')%{
96 'classname': self.classname.capitalize(), 'itemid': nodeid})
98 class SearchAction(Action):
99 name = 'search'
100 permissionType = 'View'
102 def handle(self, wcre=re.compile(r'[\s,]+')):
103 """Mangle some of the form variables.
105 Set the form ":filter" variable based on the values of the filter
106 variables - if they're set to anything other than "dontcare" then add
107 them to :filter.
109 Handle the ":queryname" variable and save off the query to the user's
110 query list.
112 Split any String query values on whitespace and comma.
114 """
115 self.fakeFilterVars()
116 queryname = self.getQueryName()
118 # handle saving the query params
119 if queryname:
120 # parse the environment and figure what the query _is_
121 req = templating.HTMLRequest(self.client)
123 # The [1:] strips off the '?' character, it isn't part of the
124 # query string.
125 url = req.indexargs_href('', {})[1:]
127 # handle editing an existing query
128 try:
129 qid = self.db.query.lookup(queryname)
130 self.db.query.set(qid, klass=self.classname, url=url)
131 except KeyError:
132 # create a query
133 qid = self.db.query.create(name=queryname,
134 klass=self.classname, url=url)
136 # and add it to the user's query multilink
137 queries = self.db.user.get(self.userid, 'queries')
138 queries.append(qid)
139 self.db.user.set(self.userid, queries=queries)
141 # commit the query change to the database
142 self.db.commit()
144 def fakeFilterVars(self):
145 """Add a faked :filter form variable for each filtering prop."""
146 props = self.db.classes[self.classname].getprops()
147 for key in self.form.keys():
148 if not props.has_key(key):
149 continue
150 if isinstance(self.form[key], type([])):
151 # search for at least one entry which is not empty
152 for minifield in self.form[key]:
153 if minifield.value:
154 break
155 else:
156 continue
157 else:
158 if not self.form[key].value:
159 continue
160 if isinstance(props[key], hyperdb.String):
161 v = self.form[key].value
162 l = token.token_split(v)
163 if len(l) > 1 or l[0] != v:
164 self.form.value.remove(self.form[key])
165 # replace the single value with the split list
166 for v in l:
167 self.form.value.append(cgi.MiniFieldStorage(key, v))
169 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
171 FV_QUERYNAME = re.compile(r'[@:]queryname')
172 def getQueryName(self):
173 for key in self.form.keys():
174 if self.FV_QUERYNAME.match(key):
175 return self.form[key].value.strip()
176 return ''
178 class EditCSVAction(Action):
179 name = 'edit'
180 permissionType = 'Edit'
182 def handle(self):
183 """Performs an edit of all of a class' items in one go.
185 The "rows" CGI var defines the CSV-formatted entries for the class. New
186 nodes are identified by the ID 'X' (or any other non-existent ID) and
187 removed lines are retired.
189 """
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 class _EditAction(Action):
275 def isEditingSelf(self):
276 """Check whether a user is editing his/her own details."""
277 return (self.nodeid == self.userid
278 and self.db.user.get(self.nodeid, 'username') != 'anonymous')
280 def editItemPermission(self, props):
281 """Determine whether the user has permission to edit this item.
283 Base behaviour is to check the user can edit this class. If we're
284 editing the "user" class, users are allowed to edit their own details.
285 Unless it's the "roles" property, which requires the special Permission
286 "Web Roles".
287 """
288 if self.classname == 'user':
289 if props.has_key('roles') and not self.hasPermission('Web Roles'):
290 raise Unauthorised, _("You do not have permission to edit user roles")
291 if self.isEditingSelf():
292 return 1
293 if self.hasPermission('Edit'):
294 return 1
295 return 0
297 def newItemPermission(self, props):
298 """Determine whether the user has permission to create (edit) this item.
300 Base behaviour is to check the user can edit this class. No additional
301 property checks are made. Additionally, new user items may be created
302 if the user has the "Web Registration" Permission.
304 """
305 if (self.classname == 'user' and self.hasPermission('Web Registration')
306 or self.hasPermission('Edit')):
307 return 1
308 return 0
310 #
311 # Utility methods for editing
312 #
313 def _editnodes(self, all_props, all_links, newids=None):
314 ''' Use the props in all_props to perform edit and creation, then
315 use the link specs in all_links to do linking.
316 '''
317 # figure dependencies and re-work links
318 deps = {}
319 links = {}
320 for cn, nodeid, propname, vlist in all_links:
321 if not all_props.has_key((cn, nodeid)):
322 # link item to link to doesn't (and won't) exist
323 continue
324 for value in vlist:
325 if not all_props.has_key(value):
326 # link item to link to doesn't (and won't) exist
327 continue
328 deps.setdefault((cn, nodeid), []).append(value)
329 links.setdefault(value, []).append((cn, nodeid, propname))
331 # figure chained dependencies ordering
332 order = []
333 done = {}
334 # loop detection
335 change = 0
336 while len(all_props) != len(done):
337 for needed in all_props.keys():
338 if done.has_key(needed):
339 continue
340 tlist = deps.get(needed, [])
341 for target in tlist:
342 if not done.has_key(target):
343 break
344 else:
345 done[needed] = 1
346 order.append(needed)
347 change = 1
348 if not change:
349 raise ValueError, 'linking must not loop!'
351 # now, edit / create
352 m = []
353 for needed in order:
354 props = all_props[needed]
355 if not props:
356 # nothing to do
357 continue
358 cn, nodeid = needed
360 if nodeid is not None and int(nodeid) > 0:
361 # make changes to the node
362 props = self._changenode(cn, nodeid, props)
364 # and some nice feedback for the user
365 if props:
366 info = ', '.join(props.keys())
367 m.append('%s %s %s edited ok'%(cn, nodeid, info))
368 else:
369 m.append('%s %s - nothing changed'%(cn, nodeid))
370 else:
371 assert props
373 # make a new node
374 newid = self._createnode(cn, props)
375 if nodeid is None:
376 self.nodeid = newid
377 nodeid = newid
379 # and some nice feedback for the user
380 m.append('%s %s created'%(cn, newid))
382 # fill in new ids in links
383 if links.has_key(needed):
384 for linkcn, linkid, linkprop in links[needed]:
385 props = all_props[(linkcn, linkid)]
386 cl = self.db.classes[linkcn]
387 propdef = cl.getprops()[linkprop]
388 if not props.has_key(linkprop):
389 if linkid is None or linkid.startswith('-'):
390 # linking to a new item
391 if isinstance(propdef, hyperdb.Multilink):
392 props[linkprop] = [newid]
393 else:
394 props[linkprop] = newid
395 else:
396 # linking to an existing item
397 if isinstance(propdef, hyperdb.Multilink):
398 existing = cl.get(linkid, linkprop)[:]
399 existing.append(nodeid)
400 props[linkprop] = existing
401 else:
402 props[linkprop] = newid
404 return '<br>'.join(m)
406 def _changenode(self, cn, nodeid, props):
407 """Change the node based on the contents of the form."""
408 # check for permission
409 if not self.editItemPermission(props):
410 raise Unauthorised, 'You do not have permission to edit %s'%cn
412 # make the changes
413 cl = self.db.classes[cn]
414 return cl.set(nodeid, **props)
416 def _createnode(self, cn, props):
417 """Create a node based on the contents of the form."""
418 # check for permission
419 if not self.newItemPermission(props):
420 raise Unauthorised, 'You do not have permission to create %s'%cn
422 # create the node and return its id
423 cl = self.db.classes[cn]
424 return cl.create(**props)
426 class EditItemAction(_EditAction):
427 def lastUserActivity(self):
428 if self.form.has_key(':lastactivity'):
429 return date.Date(self.form[':lastactivity'].value)
430 elif self.form.has_key('@lastactivity'):
431 return date.Date(self.form['@lastactivity'].value)
432 else:
433 return None
435 def lastNodeActivity(self):
436 cl = getattr(self.client.db, self.classname)
437 return cl.get(self.nodeid, 'activity')
439 def detectCollision(self, userActivity, nodeActivity):
440 # Result from lastUserActivity may be None. If it is, assume there's no
441 # conflict, or at least not one we can detect.
442 if userActivity:
443 return userActivity < nodeActivity
445 def handleCollision(self):
446 self.client.template = 'collision'
448 def handle(self):
449 """Perform an edit of an item in the database.
451 See parsePropsFromForm and _editnodes for special variables.
453 """
454 if self.detectCollision(self.lastUserActivity(), self.lastNodeActivity()):
455 self.handleCollision()
456 return
458 props, links = self.client.parsePropsFromForm()
460 # handle the props
461 try:
462 message = self._editnodes(props, links)
463 except (ValueError, KeyError, IndexError), message:
464 self.client.error_message.append(_('Apply Error: ') + str(message))
465 return
467 # commit now that all the tricky stuff is done
468 self.db.commit()
470 # redirect to the item's edit page
471 # redirect to finish off
472 url = self.base + self.classname
473 # note that this action might have been called by an index page, so
474 # we will want to include index-page args in this URL too
475 if self.nodeid is not None:
476 url += self.nodeid
477 url += '?@ok_message=%s&@template=%s'%(urllib.quote(message),
478 urllib.quote(self.template))
479 if self.nodeid is None:
480 req = templating.HTMLRequest(self)
481 url += '&' + req.indexargs_href('', {})[1:]
482 raise Redirect, url
484 class NewItemAction(_EditAction):
485 def handle(self):
486 ''' Add a new item to the database.
488 This follows the same form as the EditItemAction, with the same
489 special form values.
490 '''
491 # parse the props from the form
492 try:
493 props, links = self.client.parsePropsFromForm(create=True)
494 except (ValueError, KeyError), message:
495 self.client.error_message.append(_('Error: ') + str(message))
496 return
498 # handle the props - edit or create
499 try:
500 # when it hits the None element, it'll set self.nodeid
501 messages = self._editnodes(props, links)
503 except (ValueError, KeyError, IndexError), message:
504 # these errors might just be indicative of user dumbness
505 self.client.error_message.append(_('Error: ') + str(message))
506 return
508 # commit now that all the tricky stuff is done
509 self.db.commit()
511 # redirect to the new item's page
512 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
513 self.classname, self.nodeid, urllib.quote(messages),
514 urllib.quote(self.template))
516 class PassResetAction(Action):
517 def handle(self):
518 """Handle password reset requests.
520 Presence of either "name" or "address" generates email. Presence of
521 "otk" performs the reset.
523 """
524 if self.form.has_key('otk'):
525 # pull the rego information out of the otk database
526 otk = self.form['otk'].value
527 uid = self.db.otks.get(otk, 'uid')
528 if uid is None:
529 self.client.error_message.append("""Invalid One Time Key!
530 (a Mozilla bug may cause this message to show up erroneously,
531 please check your email)""")
532 return
534 # re-open the database as "admin"
535 if self.user != 'admin':
536 self.client.opendb('admin')
537 self.db = self.client.db
539 # change the password
540 newpw = password.generatePassword()
542 cl = self.db.user
543 # XXX we need to make the "default" page be able to display errors!
544 try:
545 # set the password
546 cl.set(uid, password=password.Password(newpw))
547 # clear the props from the otk database
548 self.db.otks.destroy(otk)
549 self.db.commit()
550 except (ValueError, KeyError), message:
551 self.client.error_message.append(str(message))
552 return
554 # user info
555 address = self.db.user.get(uid, 'address')
556 name = self.db.user.get(uid, 'username')
558 # send the email
559 tracker_name = self.db.config.TRACKER_NAME
560 subject = 'Password reset for %s'%tracker_name
561 body = '''
562 The password has been reset for username "%(name)s".
564 Your password is now: %(password)s
565 '''%{'name': name, 'password': newpw}
566 if not self.client.standard_message([address], subject, body):
567 return
569 self.client.ok_message.append('Password reset and email sent to %s' %
570 address)
571 return
573 # no OTK, so now figure the user
574 if self.form.has_key('username'):
575 name = self.form['username'].value
576 try:
577 uid = self.db.user.lookup(name)
578 except KeyError:
579 self.client.error_message.append('Unknown username')
580 return
581 address = self.db.user.get(uid, 'address')
582 elif self.form.has_key('address'):
583 address = self.form['address'].value
584 uid = uidFromAddress(self.db, ('', address), create=0)
585 if not uid:
586 self.client.error_message.append('Unknown email address')
587 return
588 name = self.db.user.get(uid, 'username')
589 else:
590 self.client.error_message.append('You need to specify a username '
591 'or address')
592 return
594 # generate the one-time-key and store the props for later
595 otk = ''.join([random.choice(chars) for x in range(32)])
596 self.db.otks.set(otk, uid=uid, __time=time.time())
598 # send the email
599 tracker_name = self.db.config.TRACKER_NAME
600 subject = 'Confirm reset of password for %s'%tracker_name
601 body = '''
602 Someone, perhaps you, has requested that the password be changed for your
603 username, "%(name)s". If you wish to proceed with the change, please follow
604 the link below:
606 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
608 You should then receive another email with the new password.
609 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
610 if not self.client.standard_message([address], subject, body):
611 return
613 self.client.ok_message.append('Email sent to %s'%address)
615 class ConfRegoAction(Action):
616 def handle(self):
617 """Grab the OTK, use it to load up the new user details."""
618 try:
619 # pull the rego information out of the otk database
620 self.userid = self.db.confirm_registration(self.form['otk'].value)
621 except (ValueError, KeyError), message:
622 # XXX: we need to make the "default" page be able to display errors!
623 self.client.error_message.append(str(message))
624 return
626 # log the new user in
627 self.client.user = self.db.user.get(self.userid, 'username')
628 # re-open the database for real, using the user
629 self.client.opendb(self.client.user)
630 self.db = client.db
632 # if we have a session, update it
633 if hasattr(self, 'session'):
634 self.db.sessions.set(self.session, user=self.user,
635 last_use=time.time())
636 else:
637 # new session cookie
638 self.client.set_cookie(self.user)
640 # nice message
641 message = _('You are now registered, welcome!')
643 # redirect to the user's page
644 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
645 self.userid, urllib.quote(message))
647 class RegisterAction(Action):
648 name = 'register'
649 permissionType = 'Web Registration'
651 def handle(self):
652 """Attempt to create a new user based on the contents of the form
653 and then set the cookie.
655 Return 1 on successful login.
656 """
657 props = self.client.parsePropsFromForm()[0][('user', None)]
659 # registration isn't allowed to supply roles
660 if props.has_key('roles'):
661 raise Unauthorised, _("It is not permitted to supply roles at registration.")
663 try:
664 self.db.user.lookup(props['username'])
665 self.client.error_message.append('Error: A user with the username "%s" '
666 'already exists'%props['username'])
667 return
668 except KeyError:
669 pass
671 # generate the one-time-key and store the props for later
672 otk = ''.join([random.choice(chars) for x in range(32)])
673 for propname, proptype in self.db.user.getprops().items():
674 value = props.get(propname, None)
675 if value is None:
676 pass
677 elif isinstance(proptype, hyperdb.Date):
678 props[propname] = str(value)
679 elif isinstance(proptype, hyperdb.Interval):
680 props[propname] = str(value)
681 elif isinstance(proptype, hyperdb.Password):
682 props[propname] = str(value)
683 props['__time'] = time.time()
684 self.db.otks.set(otk, **props)
686 # send the email
687 tracker_name = self.db.config.TRACKER_NAME
688 tracker_email = self.db.config.TRACKER_EMAIL
689 subject = 'Complete your registration to %s -- key %s' % (tracker_name,
690 otk)
691 body = """To complete your registration of the user "%(name)s" with
692 %(tracker)s, please do one of the following:
694 - send a reply to %(tracker_email)s and maintain the subject line as is (the
695 reply's additional "Re:" is ok),
697 - or visit the following URL:
699 %(url)s?@action=confrego&otk=%(otk)s
700 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
701 'otk': otk, 'tracker_email': tracker_email}
702 if not self.client.standard_message([props['address']], subject, body,
703 tracker_email):
704 return
706 # commit changes to the database
707 self.db.commit()
709 # redirect to the "you're almost there" page
710 raise Redirect, '%suser?@template=rego_progress'%self.base
712 class LogoutAction(Action):
713 def handle(self):
714 """Make us really anonymous - nuke the cookie too."""
715 # log us out
716 self.client.make_user_anonymous()
718 # construct the logout cookie
719 now = Cookie._getdate()
720 self.client.additional_headers['Set-Cookie'] = \
721 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.client.cookie_name,
722 now, self.client.cookie_path)
724 # Let the user know what's going on
725 self.client.ok_message.append(_('You are logged out'))
727 class LoginAction(Action):
728 def handle(self):
729 """Attempt to log a user in.
731 Sets up a session for the user which contains the login credentials.
733 """
734 # we need the username at a minimum
735 if not self.form.has_key('__login_name'):
736 self.client.error_message.append(_('Username required'))
737 return
739 # get the login info
740 self.client.user = self.form['__login_name'].value
741 if self.form.has_key('__login_password'):
742 password = self.form['__login_password'].value
743 else:
744 password = ''
746 # make sure the user exists
747 try:
748 self.client.userid = self.db.user.lookup(self.client.user)
749 except KeyError:
750 name = self.client.user
751 self.client.error_message.append(_('No such user "%(name)s"')%locals())
752 self.client.make_user_anonymous()
753 return
755 # verify the password
756 if not self.verifyPassword(self.client.userid, password):
757 self.client.make_user_anonymous()
758 self.client.error_message.append(_('Incorrect password'))
759 return
761 # Determine whether the user has permission to log in.
762 # Base behaviour is to check the user has "Web Access".
763 if not self.hasPermission("Web Access"):
764 self.client.make_user_anonymous()
765 self.client.error_message.append(_("You do not have permission to login"))
766 return
768 # now we're OK, re-open the database for real, using the user
769 self.client.opendb(self.client.user)
771 # set the session cookie
772 self.client.set_cookie(self.client.user)
774 def verifyPassword(self, userid, password):
775 ''' Verify the password that the user has supplied
776 '''
777 stored = self.db.user.get(self.client.userid, 'password')
778 if password == stored:
779 return 1
780 if not password and not stored:
781 return 1
782 return 0