Code

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