Code

Move out parts of client.py to new modules:
[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']
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
27         
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))
161         
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 ''
170         
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.
283         
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         raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
299                                                               self.classname, self.client.nodeid,
300                                                               urllib.quote(message),
301                                                               urllib.quote(self.template))
302     
303     def editItemPermission(self, props):
304         """Determine whether the user has permission to edit this item.
306         Base behaviour is to check the user can edit this class. If we're
307         editing the"user" class, users are allowed to edit their own details.
308         Unless it's the "roles" property, which requires the special Permission
309         "Web Roles".
310         """
311         # if this is a user node and the user is editing their own node, then
312         # we're OK
313         has = self.db.security.hasPermission
314         if self.classname == 'user':
315             # reject if someone's trying to edit "roles" and doesn't have the
316             # right permission.
317             if props.has_key('roles') and not has('Web Roles', self.userid,
318                     'user'):
319                 return 0
320             # if the item being edited is the current user, we're ok
321             if (self.nodeid == self.userid
322                 and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
323                 return 1
324         if self.db.security.hasPermission('Edit', self.userid, self.classname):
325             return 1
326         return 0
328     def newItemPermission(self, props):
329         """Determine whether the user has permission to create (edit) this item.
331         Base behaviour is to check the user can edit this class. No additional
332         property checks are made. Additionally, new user items may be created
333         if the user has the "Web Registration" Permission.
335         """
336         has = self.db.security.hasPermission
337         if self.classname == 'user' and has('Web Registration', self.userid,
338                 'user'):
339             return 1
340         if has('Edit', self.userid, self.classname):
341             return 1
342         return 0
344     #
345     #  Utility methods for editing
346     #
347     def _editnodes(self, all_props, all_links, newids=None):
348         ''' Use the props in all_props to perform edit and creation, then
349             use the link specs in all_links to do linking.
350         '''
351         # figure dependencies and re-work links
352         deps = {}
353         links = {}
354         for cn, nodeid, propname, vlist in all_links:
355             if not all_props.has_key((cn, nodeid)):
356                 # link item to link to doesn't (and won't) exist
357                 continue
358             for value in vlist:
359                 if not all_props.has_key(value):
360                     # link item to link to doesn't (and won't) exist
361                     continue
362                 deps.setdefault((cn, nodeid), []).append(value)
363                 links.setdefault(value, []).append((cn, nodeid, propname))
365         # figure chained dependencies ordering
366         order = []
367         done = {}
368         # loop detection
369         change = 0
370         while len(all_props) != len(done):
371             for needed in all_props.keys():
372                 if done.has_key(needed):
373                     continue
374                 tlist = deps.get(needed, [])
375                 for target in tlist:
376                     if not done.has_key(target):
377                         break
378                 else:
379                     done[needed] = 1
380                     order.append(needed)
381                     change = 1
382             if not change:
383                 raise ValueError, 'linking must not loop!'
385         # now, edit / create
386         m = []
387         for needed in order:
388             props = all_props[needed]
389             if not props:
390                 # nothing to do
391                 continue
392             cn, nodeid = needed
394             if nodeid is not None and int(nodeid) > 0:
395                 # make changes to the node
396                 props = self._changenode(cn, nodeid, props)
398                 # and some nice feedback for the user
399                 if props:
400                     info = ', '.join(props.keys())
401                     m.append('%s %s %s edited ok'%(cn, nodeid, info))
402                 else:
403                     m.append('%s %s - nothing changed'%(cn, nodeid))
404             else:
405                 assert props
407                 # make a new node
408                 newid = self._createnode(cn, props)
409                 if nodeid is None:
410                     self.client.nodeid = newid
411                 nodeid = newid
413                 # and some nice feedback for the user
414                 m.append('%s %s created'%(cn, newid))
416             # fill in new ids in links
417             if links.has_key(needed):
418                 for linkcn, linkid, linkprop in links[needed]:
419                     props = all_props[(linkcn, linkid)]
420                     cl = self.db.classes[linkcn]
421                     propdef = cl.getprops()[linkprop]
422                     if not props.has_key(linkprop):
423                         if linkid is None or linkid.startswith('-'):
424                             # linking to a new item
425                             if isinstance(propdef, hyperdb.Multilink):
426                                 props[linkprop] = [newid]
427                             else:
428                                 props[linkprop] = newid
429                         else:
430                             # linking to an existing item
431                             if isinstance(propdef, hyperdb.Multilink):
432                                 existing = cl.get(linkid, linkprop)[:]
433                                 existing.append(nodeid)
434                                 props[linkprop] = existing
435                             else:
436                                 props[linkprop] = newid
438         return '<br>'.join(m)
440     def _changenode(self, cn, nodeid, props):
441         """Change the node based on the contents of the form."""
442         # check for permission
443         if not self.editItemPermission(props):
444             raise Unauthorised, 'You do not have permission to edit %s'%cn
446         # make the changes
447         cl = self.db.classes[cn]
448         return cl.set(nodeid, **props)
450     def _createnode(self, cn, props):
451         """Create a node based on the contents of the form."""
452         # check for permission
453         if not self.newItemPermission(props):
454             raise Unauthorised, 'You do not have permission to create %s'%cn
456         # create the node and return its id
457         cl = self.db.classes[cn]
458         return cl.create(**props)
459         
460 class PassResetAction(Action):
461     def handle(self):
462         """Handle password reset requests.
463     
464         Presence of either "name" or "address" generates email. Presence of
465         "otk" performs the reset.
466     
467         """
468         if self.form.has_key('otk'):
469             # pull the rego information out of the otk database
470             otk = self.form['otk'].value
471             uid = self.db.otks.get(otk, 'uid')
472             if uid is None:
473                 self.client.error_message.append("""Invalid One Time Key!
474 (a Mozilla bug may cause this message to show up erroneously,
475  please check your email)""")
476                 return
478             # re-open the database as "admin"
479             if self.user != 'admin':
480                 self.client.opendb('admin')
481                 self.db = self.client.db
483             # change the password
484             newpw = password.generatePassword()
486             cl = self.db.user
487 # XXX we need to make the "default" page be able to display errors!
488             try:
489                 # set the password
490                 cl.set(uid, password=password.Password(newpw))
491                 # clear the props from the otk database
492                 self.db.otks.destroy(otk)
493                 self.db.commit()
494             except (ValueError, KeyError), message:
495                 self.client.error_message.append(str(message))
496                 return
498             # user info
499             address = self.db.user.get(uid, 'address')
500             name = self.db.user.get(uid, 'username')
502             # send the email
503             tracker_name = self.db.config.TRACKER_NAME
504             subject = 'Password reset for %s'%tracker_name
505             body = '''
506 The password has been reset for username "%(name)s".
508 Your password is now: %(password)s
509 '''%{'name': name, 'password': newpw}
510             if not self.client.standard_message([address], subject, body):
511                 return
513             self.client.ok_message.append('Password reset and email sent to %s' %
514                                           address)
515             return
517         # no OTK, so now figure the user
518         if self.form.has_key('username'):
519             name = self.form['username'].value
520             try:
521                 uid = self.db.user.lookup(name)
522             except KeyError:
523                 self.client.error_message.append('Unknown username')
524                 return
525             address = self.db.user.get(uid, 'address')
526         elif self.form.has_key('address'):
527             address = self.form['address'].value
528             uid = uidFromAddress(self.db, ('', address), create=0)
529             if not uid:
530                 self.client.error_message.append('Unknown email address')
531                 return
532             name = self.db.user.get(uid, 'username')
533         else:
534             self.client.error_message.append('You need to specify a username '
535                 'or address')
536             return
538         # generate the one-time-key and store the props for later
539         otk = ''.join([random.choice(chars) for x in range(32)])
540         self.db.otks.set(otk, uid=uid, __time=time.time())
542         # send the email
543         tracker_name = self.db.config.TRACKER_NAME
544         subject = 'Confirm reset of password for %s'%tracker_name
545         body = '''
546 Someone, perhaps you, has requested that the password be changed for your
547 username, "%(name)s". If you wish to proceed with the change, please follow
548 the link below:
550   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
552 You should then receive another email with the new password.
553 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
554         if not self.client.standard_message([address], subject, body):
555             return
557         self.client.ok_message.append('Email sent to %s'%address)
559 class ConfRegoAction(Action):
560     def handle(self):
561         """Grab the OTK, use it to load up the new user details."""
562         try:
563             # pull the rego information out of the otk database
564             self.userid = self.db.confirm_registration(self.form['otk'].value)
565         except (ValueError, KeyError), message:
566             # XXX: we need to make the "default" page be able to display errors!
567             self.client.error_message.append(str(message))
568             return
569         
570         # log the new user in
571         self.client.user = self.db.user.get(self.userid, 'username')
572         # re-open the database for real, using the user
573         self.client.opendb(self.client.user)
574         self.db = client.db
576         # if we have a session, update it
577         if hasattr(self, 'session'):
578             self.db.sessions.set(self.session, user=self.user,
579                 last_use=time.time())
580         else:
581             # new session cookie
582             self.client.set_cookie(self.user)
584         # nice message
585         message = _('You are now registered, welcome!')
587         # redirect to the user's page
588         raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
589                                                    self.userid, urllib.quote(message))
591 class RegisterAction(Action):
592     def handle(self):
593         """Attempt to create a new user based on the contents of the form
594         and then set the cookie.
596         Return 1 on successful login.
597         """
598         props = self.client.parsePropsFromForm()[0][('user', None)]
600         # make sure we're allowed to register
601         if not self.permission(props):
602             raise Unauthorised, _("You do not have permission to register")
604         try:
605             self.db.user.lookup(props['username'])
606             self.client.error_message.append('Error: A user with the username "%s" '
607                 'already exists'%props['username'])
608             return
609         except KeyError:
610             pass
612         # generate the one-time-key and store the props for later
613         otk = ''.join([random.choice(chars) for x in range(32)])
614         for propname, proptype in self.db.user.getprops().items():
615             value = props.get(propname, None)
616             if value is None:
617                 pass
618             elif isinstance(proptype, hyperdb.Date):
619                 props[propname] = str(value)
620             elif isinstance(proptype, hyperdb.Interval):
621                 props[propname] = str(value)
622             elif isinstance(proptype, hyperdb.Password):
623                 props[propname] = str(value)
624         props['__time'] = time.time()
625         self.db.otks.set(otk, **props)
627         # send the email
628         tracker_name = self.db.config.TRACKER_NAME
629         tracker_email = self.db.config.TRACKER_EMAIL
630         subject = 'Complete your registration to %s -- key %s' % (tracker_name,
631                                                                   otk)
632         body = """To complete your registration of the user "%(name)s" with
633 %(tracker)s, please do one of the following:
635 - send a reply to %(tracker_email)s and maintain the subject line as is (the
636 reply's additional "Re:" is ok),
638 - or visit the following URL:
640 %(url)s?@action=confrego&otk=%(otk)s
641 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
642         'otk': otk, 'tracker_email': tracker_email}
643         if not self.client.standard_message([props['address']], subject, body,
644         tracker_email):
645             return
647         # commit changes to the database
648         self.db.commit()
650         # redirect to the "you're almost there" page
651         raise Redirect, '%suser?@template=rego_progress'%self.base
653     def permission(self, props):
654         """Determine whether the user has permission to register
655         
656         Base behaviour is to check the user has "Web Registration".
657         
658         """
659         # registration isn't allowed to supply roles
660         if props.has_key('roles'):
661             return 0
662         if self.db.security.hasPermission('Web Registration', self.userid):
663             return 1
664         return 0
666 class LogoutAction(Action):
667     def handle(self):
668         """Make us really anonymous - nuke the cookie too."""
669         # log us out
670         self.client.make_user_anonymous()
672         # construct the logout cookie
673         now = Cookie._getdate()
674         self.client.additional_headers['Set-Cookie'] = \
675            '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.client.cookie_name,
676             now, self.client.cookie_path)
678         # Let the user know what's going on
679         self.client.ok_message.append(_('You are logged out'))
681 class LoginAction(Action):
682     def handle(self):
683         """Attempt to log a user in.
685         Sets up a session for the user which contains the login credentials.
687         """
688         # we need the username at a minimum
689         if not self.form.has_key('__login_name'):
690             self.client.error_message.append(_('Username required'))
691             return
693         # get the login info
694         self.client.user = self.form['__login_name'].value
695         if self.form.has_key('__login_password'):
696             password = self.form['__login_password'].value
697         else:
698             password = ''
700         # make sure the user exists
701         try:
702             self.client.userid = self.db.user.lookup(self.client.user)
703         except KeyError:
704             name = self.client.user
705             self.client.error_message.append(_('No such user "%(name)s"')%locals())
706             self.client.make_user_anonymous()
707             return
709         # verify the password
710         if not self.verifyPassword(self.client.userid, password):
711             self.client.make_user_anonymous()
712             self.client.error_message.append(_('Incorrect password'))
713             return
715         # make sure we're allowed to be here
716         if not self.permission():
717             self.client.make_user_anonymous()
718             self.client.error_message.append(_("You do not have permission to login"))
719             return
721         # now we're OK, re-open the database for real, using the user
722         self.client.opendb(self.client.user)
724         # set the session cookie
725         self.client.set_cookie(self.client.user)
727     def verifyPassword(self, userid, password):
728         ''' Verify the password that the user has supplied
729         '''
730         stored = self.db.user.get(self.client.userid, 'password')
731         if password == stored:
732             return 1
733         if not password and not stored:
734             return 1
735         return 0
737     def permission(self):
738         """Determine whether the user has permission to log in.
740         Base behaviour is to check the user has "Web Access".
742         """    
743         if not self.db.security.hasPermission('Web Access', self.client.userid):
744             return 0
745         return 1