Code

Make sure user has edit permission on all properties when creating items.
[roundup.git] / roundup / cgi / actions.py
1 import re, cgi, StringIO, urllib, time, random, csv, codecs
3 from roundup import hyperdb, token, date, password
4 from roundup.actions import Action as BaseAction
5 from roundup.i18n import _
6 import roundup.exceptions
7 from roundup.cgi import exceptions, templating
8 from roundup.mailgw import uidFromAddress
10 __all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
11            'EditCSVAction', 'EditItemAction', 'PassResetAction',
12            'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction',
13            'NewItemAction', 'ExportCSVAction']
15 # used by a couple of routines
16 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
18 class Action:
19     def __init__(self, client):
20         self.client = client
21         self.form = client.form
22         self.db = client.db
23         self.nodeid = client.nodeid
24         self.template = client.template
25         self.classname = client.classname
26         self.userid = client.userid
27         self.base = client.base
28         self.user = client.user
29         self.context = templating.context(client)
31     def handle(self):
32         """Action handler procedure"""
33         raise NotImplementedError
35     def execute(self):
36         """Execute the action specified by this object."""
37         self.permission()
38         return self.handle()
40     name = ''
41     permissionType = None
42     def permission(self):
43         """Check whether the user has permission to execute this action.
45         True by default. If the permissionType attribute is a string containing
46         a simple permission, check whether the user has that permission.
47         Subclasses must also define the name attribute if they define
48         permissionType.
50         Despite having this permission, users may still be unauthorised to
51         perform parts of actions. It is up to the subclasses to detect this.
52         """
53         if (self.permissionType and
54                 not self.hasPermission(self.permissionType)):
55             info = {'action': self.name, 'classname': self.classname}
56             raise exceptions.Unauthorised, self._(
57                 'You do not have permission to '
58                 '%(action)s the %(classname)s class.')%info
60     _marker = []
61     def hasPermission(self, permission, classname=_marker, itemid=None, property=None):
62         """Check whether the user has 'permission' on the current class."""
63         if classname is self._marker:
64             classname = self.client.classname
65         return self.db.security.hasPermission(permission, self.client.userid,
66             classname=classname, itemid=itemid, property=property)
68     def gettext(self, msgid):
69         """Return the localized translation of msgid"""
70         return self.client.translator.gettext(msgid)
72     _ = gettext
74 class ShowAction(Action):
76     typere=re.compile('[@:]type')
77     numre=re.compile('[@:]number')
79     def handle(self):
80         """Show a node of a particular class/id."""
81         t = n = ''
82         for key in self.form.keys():
83             if self.typere.match(key):
84                 t = self.form[key].value.strip()
85             elif self.numre.match(key):
86                 n = self.form[key].value.strip()
87         if not t:
88             raise ValueError, self._('No type specified')
89         if not n:
90             raise exceptions.SeriousError, self._('No ID entered')
91         try:
92             int(n)
93         except ValueError:
94             d = {'input': n, 'classname': t}
95             raise exceptions.SeriousError, self._(
96                 '"%(input)s" is not an ID (%(classname)s ID required)')%d
97         url = '%s%s%s'%(self.base, t, n)
98         raise exceptions.Redirect, url
100 class RetireAction(Action):
101     name = 'retire'
102     permissionType = 'Edit'
104     def handle(self):
105         """Retire the context item."""
106         # ensure modification comes via POST
107         if self.client.env['REQUEST_METHOD'] != 'POST':
108             raise roundup.exceptions.Reject(self._('Invalid request'))
110         # if we want to view the index template now, then unset the itemid
111         # context info (a special-case for retire actions on the index page)
112         itemid = self.nodeid
113         if self.template == 'index':
114             self.client.nodeid = None
116         # make sure we don't try to retire admin or anonymous
117         if self.classname == 'user' and \
118                 self.db.user.get(itemid, 'username') in ('admin', 'anonymous'):
119             raise ValueError, self._(
120                 'You may not retire the admin or anonymous user')
122         # check permission
123         if not self.hasPermission('Retire', classname=self.classname,
124                 itemid=itemid):
125             raise exceptions.Unauthorised, self._(
126                 'You do not have permission to retire %(class)s'
127             ) % {'class': self.classname}
129         # do the retire
130         self.db.getclass(self.classname).retire(itemid)
131         self.db.commit()
133         self.client.ok_message.append(
134             self._('%(classname)s %(itemid)s has been retired')%{
135                 'classname': self.classname.capitalize(), 'itemid': itemid})
138 class SearchAction(Action):
139     name = 'search'
140     permissionType = 'View'
142     def handle(self):
143         """Mangle some of the form variables.
145         Set the form ":filter" variable based on the values of the filter
146         variables - if they're set to anything other than "dontcare" then add
147         them to :filter.
149         Handle the ":queryname" variable and save off the query to the user's
150         query list.
152         Split any String query values on whitespace and comma.
154         """
155         self.fakeFilterVars()
156         queryname = self.getQueryName()
158         # editing existing query name?
159         old_queryname = self.getFromForm('old-queryname')
161         # handle saving the query params
162         if queryname:
163             # parse the environment and figure what the query _is_
164             req = templating.HTMLRequest(self.client)
166             url = self.getCurrentURL(req)
168             key = self.db.query.getkey()
169             if key:
170                 # edit the old way, only one query per name
171                 try:
172                     qid = self.db.query.lookup(old_queryname)
173                     if not self.hasPermission('Edit', 'query', itemid=qid):
174                         raise exceptions.Unauthorised, self._(
175                             "You do not have permission to edit queries")
176                     self.db.query.set(qid, klass=self.classname, url=url)
177                 except KeyError:
178                     # create a query
179                     if not self.hasPermission('Create', 'query'):
180                         raise exceptions.Unauthorised, self._(
181                             "You do not have permission to store queries")
182                     qid = self.db.query.create(name=queryname,
183                         klass=self.classname, url=url)
184             else:
185                 # edit the new way, query name not a key any more
186                 # see if we match an existing private query
187                 uid = self.db.getuid()
188                 qids = self.db.query.filter(None, {'name': old_queryname,
189                         'private_for': uid})
190                 if not qids:
191                     # ok, so there's not a private query for the current user
192                     # - see if there's one created by them
193                     qids = self.db.query.filter(None, {'name': old_queryname,
194                         'creator': uid})
196                 if qids and old_queryname:
197                     # edit query - make sure we get an exact match on the name
198                     for qid in qids:
199                         if old_queryname != self.db.query.get(qid, 'name'):
200                             continue
201                         if not self.hasPermission('Edit', 'query', itemid=qid):
202                             raise exceptions.Unauthorised, self._(
203                             "You do not have permission to edit queries")
204                         self.db.query.set(qid, klass=self.classname,
205                             url=url, name=queryname)
206                 else:
207                     # create a query
208                     if not self.hasPermission('Create', 'query'):
209                         raise exceptions.Unauthorised, self._(
210                             "You do not have permission to store queries")
211                     qid = self.db.query.create(name=queryname,
212                         klass=self.classname, url=url, private_for=uid)
214             # and add it to the user's query multilink
215             queries = self.db.user.get(self.userid, 'queries')
216             if qid not in queries:
217                 queries.append(qid)
218                 self.db.user.set(self.userid, queries=queries)
220             # commit the query change to the database
221             self.db.commit()
223     def fakeFilterVars(self):
224         """Add a faked :filter form variable for each filtering prop."""
225         cls = self.db.classes[self.classname]
226         for key in self.form.keys():
227             prop = cls.get_transitive_prop(key)
228             if not prop:
229                 continue
230             if isinstance(self.form[key], type([])):
231                 # search for at least one entry which is not empty
232                 for minifield in self.form[key]:
233                     if minifield.value:
234                         break
235                 else:
236                     continue
237             else:
238                 if not self.form[key].value:
239                     continue
240                 if isinstance(prop, hyperdb.String):
241                     v = self.form[key].value
242                     l = token.token_split(v)
243                     if len(l) != 1 or l[0] != v:
244                         self.form.value.remove(self.form[key])
245                         # replace the single value with the split list
246                         for v in l:
247                             self.form.value.append(cgi.MiniFieldStorage(key, v))
249             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
251     def getCurrentURL(self, req):
252         """Get current URL for storing as a query.
254         Note: We are removing the first character from the current URL,
255         because the leading '?' is not part of the query string.
257         Implementation note:
258         But maybe the template should be part of the stored query:
259         template = self.getFromForm('template')
260         if template:
261             return req.indexargs_url('', {'@template' : template})[1:]
262         """
263         return req.indexargs_url('', {})[1:]
265     def getFromForm(self, name):
266         for key in ('@' + name, ':' + name):
267             if self.form.has_key(key):
268                 return self.form[key].value.strip()
269         return ''
271     def getQueryName(self):
272         return self.getFromForm('queryname')
274 class EditCSVAction(Action):
275     name = 'edit'
276     permissionType = 'Edit'
278     def handle(self):
279         """Performs an edit of all of a class' items in one go.
281         The "rows" CGI var defines the CSV-formatted entries for the class. New
282         nodes are identified by the ID 'X' (or any other non-existent ID) and
283         removed lines are retired.
284         """
285         # ensure modification comes via POST
286         if self.client.env['REQUEST_METHOD'] != 'POST':
287             raise roundup.exceptions.Reject(self._('Invalid request'))
289         # figure the properties list for the class
290         cl = self.db.classes[self.classname]
291         props_without_id = cl.getprops(protected=0).keys()
293         # the incoming CSV data will always have the properties in colums
294         # sorted and starting with the "id" column
295         props_without_id.sort()
296         props = ['id'] + props_without_id
298         # do the edit
299         rows = StringIO.StringIO(self.form['rows'].value)
300         reader = csv.reader(rows)
301         found = {}
302         line = 0
303         for values in reader:
304             line += 1
305             if line == 1: continue
306             # skip property names header
307             if values == props:
308                 continue
310             # extract the itemid
311             itemid, values = values[0], values[1:]
312             found[itemid] = 1
314             # see if the node exists
315             if itemid in ('x', 'X') or not cl.hasnode(itemid):
316                 exists = 0
318                 # check permission to create this item
319                 if not self.hasPermission('Create', classname=self.classname):
320                     raise exceptions.Unauthorised, self._(
321                         'You do not have permission to create %(class)s'
322                     ) % {'class': self.classname}
323             else:
324                 exists = 1
326             # confirm correct weight
327             if len(props_without_id) != len(values):
328                 self.client.error_message.append(
329                     self._('Not enough values on line %(line)s')%{'line':line})
330                 return
332             # extract the new values
333             d = {}
334             for name, value in zip(props_without_id, values):
335                 # check permission to edit this property on this item
336                 if exists and not self.hasPermission('Edit', itemid=itemid,
337                         classname=self.classname, property=name):
338                     raise exceptions.Unauthorised, self._(
339                         'You do not have permission to edit %(class)s'
340                     ) % {'class': self.classname}
342                 prop = cl.properties[name]
343                 value = value.strip()
344                 # only add the property if it has a value
345                 if value:
346                     # if it's a multilink, split it
347                     if isinstance(prop, hyperdb.Multilink):
348                         value = value.split(':')
349                     elif isinstance(prop, hyperdb.Password):
350                         value = password.Password(value)
351                     elif isinstance(prop, hyperdb.Interval):
352                         value = date.Interval(value)
353                     elif isinstance(prop, hyperdb.Date):
354                         value = date.Date(value)
355                     elif isinstance(prop, hyperdb.Boolean):
356                         value = value.lower() in ('yes', 'true', 'on', '1')
357                     elif isinstance(prop, hyperdb.Number):
358                         value = float(value)
359                     d[name] = value
360                 elif exists:
361                     # nuke the existing value
362                     if isinstance(prop, hyperdb.Multilink):
363                         d[name] = []
364                     else:
365                         d[name] = None
367             # perform the edit
368             if exists:
369                 # edit existing
370                 cl.set(itemid, **d)
371             else:
372                 # new node
373                 found[cl.create(**d)] = 1
375         # retire the removed entries
376         for itemid in cl.list():
377             if not found.has_key(itemid):
378                 # check permission to retire this item
379                 if not self.hasPermission('Retire', itemid=itemid,
380                         classname=self.classname):
381                     raise exceptions.Unauthorised, self._(
382                         'You do not have permission to retire %(class)s'
383                     ) % {'class': self.classname}
384                 cl.retire(itemid)
386         # all OK
387         self.db.commit()
389         self.client.ok_message.append(self._('Items edited OK'))
391 class EditCommon(Action):
392     '''Utility methods for editing.'''
394     def _editnodes(self, all_props, all_links):
395         ''' Use the props in all_props to perform edit and creation, then
396             use the link specs in all_links to do linking.
397         '''
398         # figure dependencies and re-work links
399         deps = {}
400         links = {}
401         for cn, nodeid, propname, vlist in all_links:
402             numeric_id = int (nodeid or 0)
403             if not (numeric_id > 0 or all_props.has_key((cn, nodeid))):
404                 # link item to link to doesn't (and won't) exist
405                 continue
407             for value in vlist:
408                 if not all_props.has_key(value):
409                     # link item to link to doesn't (and won't) exist
410                     continue
411                 deps.setdefault((cn, nodeid), []).append(value)
412                 links.setdefault(value, []).append((cn, nodeid, propname))
414         # figure chained dependencies ordering
415         order = []
416         done = {}
417         # loop detection
418         change = 0
419         while len(all_props) != len(done):
420             for needed in all_props.keys():
421                 if done.has_key(needed):
422                     continue
423                 tlist = deps.get(needed, [])
424                 for target in tlist:
425                     if not done.has_key(target):
426                         break
427                 else:
428                     done[needed] = 1
429                     order.append(needed)
430                     change = 1
431             if not change:
432                 raise ValueError, 'linking must not loop!'
434         # now, edit / create
435         m = []
436         for needed in order:
437             props = all_props[needed]
438             cn, nodeid = needed
439             if props:
440                 if nodeid is not None and int(nodeid) > 0:
441                     # make changes to the node
442                     props = self._changenode(cn, nodeid, props)
444                     # and some nice feedback for the user
445                     if props:
446                         info = ', '.join(map(self._, props.keys()))
447                         m.append(
448                             self._('%(class)s %(id)s %(properties)s edited ok')
449                             % {'class':cn, 'id':nodeid, 'properties':info})
450                     else:
451                         m.append(self._('%(class)s %(id)s - nothing changed')
452                             % {'class':cn, 'id':nodeid})
453                 else:
454                     assert props
456                     # make a new node
457                     newid = self._createnode(cn, props)
458                     if nodeid is None:
459                         self.nodeid = newid
460                     nodeid = newid
462                     # and some nice feedback for the user
463                     m.append(self._('%(class)s %(id)s created')
464                         % {'class':cn, 'id':newid})
466             # fill in new ids in links
467             if links.has_key(needed):
468                 for linkcn, linkid, linkprop in links[needed]:
469                     props = all_props[(linkcn, linkid)]
470                     cl = self.db.classes[linkcn]
471                     propdef = cl.getprops()[linkprop]
472                     if not props.has_key(linkprop):
473                         if linkid is None or linkid.startswith('-'):
474                             # linking to a new item
475                             if isinstance(propdef, hyperdb.Multilink):
476                                 props[linkprop] = [newid]
477                             else:
478                                 props[linkprop] = newid
479                         else:
480                             # linking to an existing item
481                             if isinstance(propdef, hyperdb.Multilink):
482                                 existing = cl.get(linkid, linkprop)[:]
483                                 existing.append(nodeid)
484                                 props[linkprop] = existing
485                             else:
486                                 props[linkprop] = newid
488         return '<br>'.join(m)
490     def _changenode(self, cn, nodeid, props):
491         """Change the node based on the contents of the form."""
492         # check for permission
493         if not self.editItemPermission(props, classname=cn, itemid=nodeid):
494             raise exceptions.Unauthorised, self._(
495                 'You do not have permission to edit %(class)s'
496             ) % {'class': cn}
498         # make the changes
499         cl = self.db.classes[cn]
500         return cl.set(nodeid, **props)
502     def _createnode(self, cn, props):
503         """Create a node based on the contents of the form."""
504         # check for permission
505         if not self.newItemPermission(props, classname=cn):
506             raise exceptions.Unauthorised, self._(
507                 'You do not have permission to create %(class)s'
508             ) % {'class': cn}
510         # create the node and return its id
511         cl = self.db.classes[cn]
512         return cl.create(**props)
514     def isEditingSelf(self):
515         """Check whether a user is editing his/her own details."""
516         return (self.nodeid == self.userid
517                 and self.db.user.get(self.nodeid, 'username') != 'anonymous')
519     _cn_marker = []
520     def editItemPermission(self, props, classname=_cn_marker, itemid=None):
521         """Determine whether the user has permission to edit this item."""
522         if itemid is None:
523             itemid = self.nodeid
524         if classname is self._cn_marker:
525             classname = self.classname
526         # The user must have permission to edit each of the properties
527         # being changed.
528         for p in props:
529             if not self.hasPermission('Edit', itemid=itemid,
530                     classname=classname, property=p):
531                 return 0
532         # Since the user has permission to edit all of the properties,
533         # the edit is OK.
534         return 1
536     def newItemPermission(self, props, classname=None):
537         """Determine whether the user has permission to create this item.
539         Base behaviour is to check the user can edit this class. No additional
540         property checks are made.
541         """
543         if not classname :
544             classname = self.client.classname
545         
546         if not self.hasPermission('Create', classname=classname):
547             return 0
549         # Check Edit permission for each property, to avoid being able
550         # to set restricted ones on new item creation
551         for key in props:
552             if not self.hasPermission('Edit', classname=classname,
553                                       property=key):
554                 # We restrict by default and special-case allowed properties
555                 if key == 'date' or key == 'content':
556                     continue
557                 elif key == 'author' and props[key] == self.userid:
558                     continue
559                 return 0
560         return 1
562 class EditItemAction(EditCommon):
563     def lastUserActivity(self):
564         if self.form.has_key(':lastactivity'):
565             d = date.Date(self.form[':lastactivity'].value)
566         elif self.form.has_key('@lastactivity'):
567             d = date.Date(self.form['@lastactivity'].value)
568         else:
569             return None
570         d.second = int(d.second)
571         return d
573     def lastNodeActivity(self):
574         cl = getattr(self.client.db, self.classname)
575         activity = cl.get(self.nodeid, 'activity').local(0)
576         activity.second = int(activity.second)
577         return activity
579     def detectCollision(self, user_activity, node_activity):
580         '''Check for a collision and return the list of props we edited
581         that conflict.'''
582         if user_activity and user_activity < node_activity:
583             props, links = self.client.parsePropsFromForm()
584             key = (self.classname, self.nodeid)
585             # we really only collide for direct prop edit conflicts
586             return props[key].keys()
587         else:
588             return []
590     def handleCollision(self, props):
591         message = self._('Edit Error: someone else has edited this %s (%s). '
592             'View <a target="new" href="%s%s">their changes</a> '
593             'in a new window.')%(self.classname, ', '.join(props),
594             self.classname, self.nodeid)
595         self.client.error_message.append(message)
596         return
598     def handle(self):
599         """Perform an edit of an item in the database.
601         See parsePropsFromForm and _editnodes for special variables.
603         """
604         # ensure modification comes via POST
605         if self.client.env['REQUEST_METHOD'] != 'POST':
606             raise roundup.exceptions.Reject(self._('Invalid request'))
608         user_activity = self.lastUserActivity()
609         if user_activity:
610             props = self.detectCollision(user_activity, self.lastNodeActivity())
611             if props:
612                 self.handleCollision(props)
613                 return
615         props, links = self.client.parsePropsFromForm()
617         # handle the props
618         try:
619             message = self._editnodes(props, links)
620         except (ValueError, KeyError, IndexError,
621                 roundup.exceptions.Reject), message:
622             self.client.error_message.append(
623                 self._('Edit Error: %s') % str(message))
624             return
626         # commit now that all the tricky stuff is done
627         self.db.commit()
629         # redirect to the item's edit page
630         # redirect to finish off
631         url = self.base + self.classname
632         # note that this action might have been called by an index page, so
633         # we will want to include index-page args in this URL too
634         if self.nodeid is not None:
635             url += self.nodeid
636         url += '?@ok_message=%s&@template=%s'%(urllib.quote(message),
637             urllib.quote(self.template))
638         if self.nodeid is None:
639             req = templating.HTMLRequest(self.client)
640             url += '&' + req.indexargs_url('', {})[1:]
641         raise exceptions.Redirect, url
643 class NewItemAction(EditCommon):
644     def handle(self):
645         ''' Add a new item to the database.
647             This follows the same form as the EditItemAction, with the same
648             special form values.
649         '''
650         # ensure modification comes via POST
651         if self.client.env['REQUEST_METHOD'] != 'POST':
652             raise roundup.exceptions.Reject(self._('Invalid request'))
654         # parse the props from the form
655         try:
656             props, links = self.client.parsePropsFromForm(create=1)
657         except (ValueError, KeyError), message:
658             self.client.error_message.append(self._('Error: %s')
659                 % str(message))
660             return
662         # guard against new user creation that would bypass security checks
663         for key in props:
664             if 'user' in key:
665                 return
667         # handle the props - edit or create
668         try:
669             # when it hits the None element, it'll set self.nodeid
670             messages = self._editnodes(props, links)
671         except (ValueError, KeyError, IndexError,
672                 roundup.exceptions.Reject), message:
673             # these errors might just be indicative of user dumbness
674             self.client.error_message.append(_('Error: %s') % str(message))
675             return
677         # commit now that all the tricky stuff is done
678         self.db.commit()
680         # redirect to the new item's page
681         raise exceptions.Redirect, '%s%s%s?@ok_message=%s&@template=%s' % (
682             self.base, self.classname, self.nodeid, urllib.quote(messages),
683             urllib.quote(self.template))
685 class PassResetAction(Action):
686     def handle(self):
687         """Handle password reset requests.
689         Presence of either "name" or "address" generates email. Presence of
690         "otk" performs the reset.
692         """
693         otks = self.db.getOTKManager()
694         if self.form.has_key('otk'):
695             # pull the rego information out of the otk database
696             otk = self.form['otk'].value
697             uid = otks.get(otk, 'uid', default=None)
698             if uid is None:
699                 self.client.error_message.append(
700                     self._("Invalid One Time Key!\n"
701                         "(a Mozilla bug may cause this message "
702                         "to show up erroneously, please check your email)"))
703                 return
705             # re-open the database as "admin"
706             if self.user != 'admin':
707                 self.client.opendb('admin')
708                 self.db = self.client.db
709                 otks = self.db.getOTKManager()
711             # change the password
712             newpw = password.generatePassword()
714             cl = self.db.user
715             # XXX we need to make the "default" page be able to display errors!
716             try:
717                 # set the password
718                 cl.set(uid, password=password.Password(newpw))
719                 # clear the props from the otk database
720                 otks.destroy(otk)
721                 self.db.commit()
722             except (ValueError, KeyError), message:
723                 self.client.error_message.append(str(message))
724                 return
726             # user info
727             address = self.db.user.get(uid, 'address')
728             name = self.db.user.get(uid, 'username')
730             # send the email
731             tracker_name = self.db.config.TRACKER_NAME
732             subject = 'Password reset for %s'%tracker_name
733             body = '''
734 The password has been reset for username "%(name)s".
736 Your password is now: %(password)s
737 '''%{'name': name, 'password': newpw}
738             if not self.client.standard_message([address], subject, body):
739                 return
741             self.client.ok_message.append(
742                 self._('Password reset and email sent to %s') % address)
743             return
745         # no OTK, so now figure the user
746         if self.form.has_key('username'):
747             name = self.form['username'].value
748             try:
749                 uid = self.db.user.lookup(name)
750             except KeyError:
751                 self.client.error_message.append(self._('Unknown username'))
752                 return
753             address = self.db.user.get(uid, 'address')
754         elif self.form.has_key('address'):
755             address = self.form['address'].value
756             uid = uidFromAddress(self.db, ('', address), create=0)
757             if not uid:
758                 self.client.error_message.append(
759                     self._('Unknown email address'))
760                 return
761             name = self.db.user.get(uid, 'username')
762         else:
763             self.client.error_message.append(
764                 self._('You need to specify a username or address'))
765             return
767         # generate the one-time-key and store the props for later
768         otk = ''.join([random.choice(chars) for x in range(32)])
769         while otks.exists(otk):
770             otk = ''.join([random.choice(chars) for x in range(32)])
771         otks.set(otk, uid=uid)
772         self.db.commit()
774         # send the email
775         tracker_name = self.db.config.TRACKER_NAME
776         subject = 'Confirm reset of password for %s'%tracker_name
777         body = '''
778 Someone, perhaps you, has requested that the password be changed for your
779 username, "%(name)s". If you wish to proceed with the change, please follow
780 the link below:
782   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
784 You should then receive another email with the new password.
785 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
786         if not self.client.standard_message([address], subject, body):
787             return
789         self.client.ok_message.append(self._('Email sent to %s') % address)
791 class RegoCommon(Action):
792     def finishRego(self):
793         # log the new user in
794         self.client.userid = self.userid
795         user = self.client.user = self.db.user.get(self.userid, 'username')
796         # re-open the database for real, using the user
797         self.client.opendb(user)
799         # update session data
800         self.client.session_api.set(user=user)
802         # nice message
803         message = self._('You are now registered, welcome!')
804         url = '%suser%s?@ok_message=%s'%(self.base, self.userid,
805             urllib.quote(message))
807         # redirect to the user's page (but not 302, as some email clients seem
808         # to want to reload the page, or something)
809         return '''<html><head><title>%s</title></head>
810             <body><p><a href="%s">%s</a></p>
811             <script type="text/javascript">
812             window.setTimeout('window.location = "%s"', 1000);
813             </script>'''%(message, url, message, url)
815 class ConfRegoAction(RegoCommon):
816     def handle(self):
817         """Grab the OTK, use it to load up the new user details."""
818         try:
819             # pull the rego information out of the otk database
820             self.userid = self.db.confirm_registration(self.form['otk'].value)
821         except (ValueError, KeyError), message:
822             self.client.error_message.append(str(message))
823             return
824         return self.finishRego()
826 class RegisterAction(RegoCommon, EditCommon):
827     name = 'register'
828     permissionType = 'Create'
830     def handle(self):
831         """Attempt to create a new user based on the contents of the form
832         and then remember it in session.
834         Return 1 on successful login.
835         """
836         # ensure modification comes via POST
837         if self.client.env['REQUEST_METHOD'] != 'POST':
838             raise roundup.exceptions.Reject(self._('Invalid request'))
840         # parse the props from the form
841         try:
842             props, links = self.client.parsePropsFromForm(create=1)
843         except (ValueError, KeyError), message:
844             self.client.error_message.append(self._('Error: %s')
845                 % str(message))
846             return
848         # registration isn't allowed to supply roles
849         user_props = props[('user', None)]
850         if user_props.has_key('roles'):
851             raise exceptions.Unauthorised, self._(
852                 "It is not permitted to supply roles at registration.")
854         # skip the confirmation step?
855         if self.db.config['INSTANT_REGISTRATION']:
856             # handle the create now
857             try:
858                 # when it hits the None element, it'll set self.nodeid
859                 messages = self._editnodes(props, links)
860             except (ValueError, KeyError, IndexError,
861                     roundup.exceptions.Reject), message:
862                 # these errors might just be indicative of user dumbness
863                 self.client.error_message.append(_('Error: %s') % str(message))
864                 return
866             # fix up the initial roles
867             self.db.user.set(self.nodeid,
868                 roles=self.db.config['NEW_WEB_USER_ROLES'])
870             # commit now that all the tricky stuff is done
871             self.db.commit()
873             # finish off by logging the user in
874             self.userid = self.nodeid
875             return self.finishRego()
877         # generate the one-time-key and store the props for later
878         for propname, proptype in self.db.user.getprops().items():
879             value = user_props.get(propname, None)
880             if value is None:
881                 pass
882             elif isinstance(proptype, hyperdb.Date):
883                 user_props[propname] = str(value)
884             elif isinstance(proptype, hyperdb.Interval):
885                 user_props[propname] = str(value)
886             elif isinstance(proptype, hyperdb.Password):
887                 user_props[propname] = str(value)
888         otks = self.db.getOTKManager()
889         otk = ''.join([random.choice(chars) for x in range(32)])
890         while otks.exists(otk):
891             otk = ''.join([random.choice(chars) for x in range(32)])
892         otks.set(otk, **user_props)
894         # send the email
895         tracker_name = self.db.config.TRACKER_NAME
896         tracker_email = self.db.config.TRACKER_EMAIL
897         if self.db.config['EMAIL_REGISTRATION_CONFIRMATION']:
898             subject = 'Complete your registration to %s -- key %s'%(tracker_name,
899                                                                   otk)
900             body = """To complete your registration of the user "%(name)s" with
901 %(tracker)s, please do one of the following:
903 - send a reply to %(tracker_email)s and maintain the subject line as is (the
904 reply's additional "Re:" is ok),
906 - or visit the following URL:
908 %(url)s?@action=confrego&otk=%(otk)s
910 """ % {'name': user_props['username'], 'tracker': tracker_name,
911         'url': self.base, 'otk': otk, 'tracker_email': tracker_email}
912         else:
913             subject = 'Complete your registration to %s'%(tracker_name)
914             body = """To complete your registration of the user "%(name)s" with
915 %(tracker)s, please visit the following URL:
917 %(url)s?@action=confrego&otk=%(otk)s
919 """ % {'name': user_props['username'], 'tracker': tracker_name,
920         'url': self.base, 'otk': otk}
921         if not self.client.standard_message([user_props['address']], subject,
922                 body, (tracker_name, tracker_email)):
923             return
925         # commit changes to the database
926         self.db.commit()
928         # redirect to the "you're almost there" page
929         raise exceptions.Redirect, '%suser?@template=rego_progress'%self.base
931 class LogoutAction(Action):
932     def handle(self):
933         """Make us really anonymous - nuke the session too."""
934         # log us out
935         self.client.make_user_anonymous()
936         self.client.session_api.destroy()
938         # Let the user know what's going on
939         self.client.ok_message.append(self._('You are logged out'))
941         # reset client context to render tracker home page
942         # instead of last viewed page (may be inaccessibe for anonymous)
943         self.client.classname = None
944         self.client.nodeid = None
945         self.client.template = None
947 class LoginAction(Action):
948     def handle(self):
949         """Attempt to log a user in.
951         Sets up a session for the user which contains the login credentials.
953         """
954         # ensure modification comes via POST
955         if self.client.env['REQUEST_METHOD'] != 'POST':
956             raise roundup.exceptions.Reject(self._('Invalid request'))
958         # we need the username at a minimum
959         if not self.form.has_key('__login_name'):
960             self.client.error_message.append(self._('Username required'))
961             return
963         # get the login info
964         self.client.user = self.form['__login_name'].value
965         if self.form.has_key('__login_password'):
966             password = self.form['__login_password'].value
967         else:
968             password = ''
970         try:
971             self.verifyLogin(self.client.user, password)
972         except exceptions.LoginError, err:
973             self.client.make_user_anonymous()
974             self.client.error_message.extend(list(err.args))
975             return
977         # now we're OK, re-open the database for real, using the user
978         self.client.opendb(self.client.user)
980         # save user in session
981         self.client.session_api.set(user=self.client.user)
982         if self.form.has_key('remember'):
983             self.client.session_api.update(set_cookie=True, expire=24*3600*365)
985         # If we came from someplace, go back there
986         if self.form.has_key('__came_from'):
987             raise exceptions.Redirect, self.form['__came_from'].value
989     def verifyLogin(self, username, password):
990         # make sure the user exists
991         try:
992             self.client.userid = self.db.user.lookup(username)
993         except KeyError:
994             raise exceptions.LoginError, self._('Invalid login')
996         # verify the password
997         if not self.verifyPassword(self.client.userid, password):
998             raise exceptions.LoginError, self._('Invalid login')
1000         # Determine whether the user has permission to log in.
1001         # Base behaviour is to check the user has "Web Access".
1002         if not self.hasPermission("Web Access"):
1003             raise exceptions.LoginError, self._(
1004                 "You do not have permission to login")
1006     def verifyPassword(self, userid, password):
1007         '''Verify the password that the user has supplied'''
1008         stored = self.db.user.get(userid, 'password')
1009         if password == stored:
1010             return 1
1011         if not password and not stored:
1012             return 1
1013         return 0
1015 class ExportCSVAction(Action):
1016     name = 'export'
1017     permissionType = 'View'
1019     def handle(self):
1020         ''' Export the specified search query as CSV. '''
1021         # figure the request
1022         request = templating.HTMLRequest(self.client)
1023         filterspec = request.filterspec
1024         sort = request.sort
1025         group = request.group
1026         columns = request.columns
1027         klass = self.db.getclass(request.classname)
1029         # full-text search
1030         if request.search_text:
1031             matches = self.db.indexer.search(
1032                 re.findall(r'\b\w{2,25}\b', request.search_text), klass)
1033         else:
1034             matches = None
1036         h = self.client.additional_headers
1037         h['Content-Type'] = 'text/csv; charset=%s' % self.client.charset
1038         # some browsers will honor the filename here...
1039         h['Content-Disposition'] = 'inline; filename=query.csv'
1041         self.client.header()
1043         if self.client.env['REQUEST_METHOD'] == 'HEAD':
1044             # all done, return a dummy string
1045             return 'dummy'
1047         wfile = self.client.request.wfile
1048         if self.client.charset != self.client.STORAGE_CHARSET:
1049             wfile = codecs.EncodedFile(wfile,
1050                 self.client.STORAGE_CHARSET, self.client.charset, 'replace')
1052         writer = csv.writer(wfile)
1053         self.client._socket_op(writer.writerow, columns)
1055         # and search
1056         for itemid in klass.filter(matches, filterspec, sort, group):
1057             row = []
1058             for name in columns:
1059                 # check permission to view this property on this item
1060                 if not self.hasPermission('View', itemid=itemid,
1061                         classname=request.classname, property=name):
1062                     raise exceptions.Unauthorised, self._(
1063                         'You do not have permission to view %(class)s'
1064                     ) % {'class': request.classname}
1065                 row.append(str(klass.get(itemid, name)))
1066             self.client._socket_op(writer.writerow, row)
1068         return '\n'
1071 class Bridge(BaseAction):
1072     """Make roundup.actions.Action executable via CGI request.
1074     Using this allows users to write actions executable from multiple frontends.
1075     CGI Form content is translated into a dictionary, which then is passed as
1076     argument to 'handle()'. XMLRPC requests have to pass this dictionary
1077     directly.
1078     """
1080     def __init__(self, *args):
1082         # As this constructor is callable from multiple frontends, each with
1083         # different Action interfaces, we have to look at the arguments to
1084         # figure out how to complete construction.
1085         if (len(args) == 1 and
1086             hasattr(args[0], '__class__') and
1087             args[0].__class__.__name__ == 'Client'):
1088             self.cgi = True
1089             self.execute = self.execute_cgi
1090             self.client = args[0]
1091             self.form = self.client.form
1092         else:
1093             self.cgi = False
1095     def execute_cgi(self):
1096         args = {}
1097         for key in self.form.keys():
1098             args[key] = self.form.getvalue(key)
1099         self.permission(args)
1100         return self.handle(args)
1102     def permission(self, args):
1103         """Raise Unauthorised if the current user is not allowed to execute
1104         this action. Users may override this method."""
1106         pass
1108     def handle(self, args):
1110         raise NotImplementedError
1112 # vim: set filetype=python sts=4 sw=4 et si :