Code

Remove duplication in permission handling:
[roundup.git] / roundup / cgi / actions.py
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
28         
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.
43         
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'
101     
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))
168         
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'
181     
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'))
273     
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')
279     
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'
447     
448     def handle(self):
449         """Perform an edit of an item in the database.
451         See parsePropsFromForm and _editnodes for special variables.
452         
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
483     
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))
515         
516 class PassResetAction(Action):
517     def handle(self):
518         """Handle password reset requests.
519     
520         Presence of either "name" or "address" generates email. Presence of
521         "otk" performs the reset.
522     
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
625         
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'
650     
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