Code

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