Code

Proper handling of 'Create' permissions in both mail gateway (earlier
[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             elif cl.hasnode(itemid) and cl.is_retired(itemid):
324                 # If a CSV line just mentions an id and the corresponding
325                 # item is retired, then the item is restored.
326                 cl.restore(itemid)
327                 continue
328             else:
329                 exists = 1
331             # confirm correct weight
332             if len(props_without_id) != len(values):
333                 self.client.error_message.append(
334                     self._('Not enough values on line %(line)s')%{'line':line})
335                 return
337             # extract the new values
338             d = {}
339             for name, value in zip(props_without_id, values):
340                 # check permission to edit this property on this item
341                 if exists and not self.hasPermission('Edit', itemid=itemid,
342                         classname=self.classname, property=name):
343                     raise exceptions.Unauthorised, self._(
344                         'You do not have permission to edit %(class)s'
345                     ) % {'class': self.classname}
347                 prop = cl.properties[name]
348                 value = value.strip()
349                 # only add the property if it has a value
350                 if value:
351                     # if it's a multilink, split it
352                     if isinstance(prop, hyperdb.Multilink):
353                         value = value.split(':')
354                     elif isinstance(prop, hyperdb.Password):
355                         value = password.Password(value)
356                     elif isinstance(prop, hyperdb.Interval):
357                         value = date.Interval(value)
358                     elif isinstance(prop, hyperdb.Date):
359                         value = date.Date(value)
360                     elif isinstance(prop, hyperdb.Boolean):
361                         value = value.lower() in ('yes', 'true', 'on', '1')
362                     elif isinstance(prop, hyperdb.Number):
363                         value = float(value)
364                     d[name] = value
365                 elif exists:
366                     # nuke the existing value
367                     if isinstance(prop, hyperdb.Multilink):
368                         d[name] = []
369                     else:
370                         d[name] = None
372             # perform the edit
373             if exists:
374                 # edit existing
375                 cl.set(itemid, **d)
376             else:
377                 # new node
378                 found[cl.create(**d)] = 1
380         # retire the removed entries
381         for itemid in cl.list():
382             if not found.has_key(itemid):
383                 # check permission to retire this item
384                 if not self.hasPermission('Retire', itemid=itemid,
385                         classname=self.classname):
386                     raise exceptions.Unauthorised, self._(
387                         'You do not have permission to retire %(class)s'
388                     ) % {'class': self.classname}
389                 cl.retire(itemid)
391         # all OK
392         self.db.commit()
394         self.client.ok_message.append(self._('Items edited OK'))
396 class EditCommon(Action):
397     '''Utility methods for editing.'''
399     def _editnodes(self, all_props, all_links):
400         ''' Use the props in all_props to perform edit and creation, then
401             use the link specs in all_links to do linking.
402         '''
403         # figure dependencies and re-work links
404         deps = {}
405         links = {}
406         for cn, nodeid, propname, vlist in all_links:
407             numeric_id = int (nodeid or 0)
408             if not (numeric_id > 0 or all_props.has_key((cn, nodeid))):
409                 # link item to link to doesn't (and won't) exist
410                 continue
412             for value in vlist:
413                 if not all_props.has_key(value):
414                     # link item to link to doesn't (and won't) exist
415                     continue
416                 deps.setdefault((cn, nodeid), []).append(value)
417                 links.setdefault(value, []).append((cn, nodeid, propname))
419         # figure chained dependencies ordering
420         order = []
421         done = {}
422         # loop detection
423         change = 0
424         while len(all_props) != len(done):
425             for needed in all_props.keys():
426                 if done.has_key(needed):
427                     continue
428                 tlist = deps.get(needed, [])
429                 for target in tlist:
430                     if not done.has_key(target):
431                         break
432                 else:
433                     done[needed] = 1
434                     order.append(needed)
435                     change = 1
436             if not change:
437                 raise ValueError, 'linking must not loop!'
439         # now, edit / create
440         m = []
441         for needed in order:
442             props = all_props[needed]
443             cn, nodeid = needed
444             if props:
445                 if nodeid is not None and int(nodeid) > 0:
446                     # make changes to the node
447                     props = self._changenode(cn, nodeid, props)
449                     # and some nice feedback for the user
450                     if props:
451                         info = ', '.join(map(self._, props.keys()))
452                         m.append(
453                             self._('%(class)s %(id)s %(properties)s edited ok')
454                             % {'class':cn, 'id':nodeid, 'properties':info})
455                     else:
456                         m.append(self._('%(class)s %(id)s - nothing changed')
457                             % {'class':cn, 'id':nodeid})
458                 else:
459                     assert props
461                     # make a new node
462                     newid = self._createnode(cn, props)
463                     if nodeid is None:
464                         self.nodeid = newid
465                     nodeid = newid
467                     # and some nice feedback for the user
468                     m.append(self._('%(class)s %(id)s created')
469                         % {'class':cn, 'id':newid})
471             # fill in new ids in links
472             if links.has_key(needed):
473                 for linkcn, linkid, linkprop in links[needed]:
474                     props = all_props[(linkcn, linkid)]
475                     cl = self.db.classes[linkcn]
476                     propdef = cl.getprops()[linkprop]
477                     if not props.has_key(linkprop):
478                         if linkid is None or linkid.startswith('-'):
479                             # linking to a new item
480                             if isinstance(propdef, hyperdb.Multilink):
481                                 props[linkprop] = [nodeid]
482                             else:
483                                 props[linkprop] = nodeid
484                         else:
485                             # linking to an existing item
486                             if isinstance(propdef, hyperdb.Multilink):
487                                 existing = cl.get(linkid, linkprop)[:]
488                                 existing.append(nodeid)
489                                 props[linkprop] = existing
490                             else:
491                                 props[linkprop] = nodeid
493         return '<br>'.join(m)
495     def _changenode(self, cn, nodeid, props):
496         """Change the node based on the contents of the form."""
497         # check for permission
498         if not self.editItemPermission(props, classname=cn, itemid=nodeid):
499             raise exceptions.Unauthorised, self._(
500                 'You do not have permission to edit %(class)s'
501             ) % {'class': cn}
503         # make the changes
504         cl = self.db.classes[cn]
505         return cl.set(nodeid, **props)
507     def _createnode(self, cn, props):
508         """Create a node based on the contents of the form."""
509         # check for permission
510         if not self.newItemPermission(props, classname=cn):
511             raise exceptions.Unauthorised, self._(
512                 'You do not have permission to create %(class)s'
513             ) % {'class': cn}
515         # create the node and return its id
516         cl = self.db.classes[cn]
517         return cl.create(**props)
519     def isEditingSelf(self):
520         """Check whether a user is editing his/her own details."""
521         return (self.nodeid == self.userid
522                 and self.db.user.get(self.nodeid, 'username') != 'anonymous')
524     _cn_marker = []
525     def editItemPermission(self, props, classname=_cn_marker, itemid=None):
526         """Determine whether the user has permission to edit this item."""
527         if itemid is None:
528             itemid = self.nodeid
529         if classname is self._cn_marker:
530             classname = self.classname
531         # The user must have permission to edit each of the properties
532         # being changed.
533         for p in props:
534             if not self.hasPermission('Edit', itemid=itemid,
535                     classname=classname, property=p):
536                 return 0
537         # Since the user has permission to edit all of the properties,
538         # the edit is OK.
539         return 1
541     def newItemPermission(self, props, classname=None):
542         """Determine whether the user has permission to create this item.
544         Base behaviour is to check the user can edit this class. No additional
545         property checks are made.
546         """
548         if not classname :
549             classname = self.client.classname
550         
551         if not self.hasPermission('Create', classname=classname):
552             return 0
554         # Check Create permission for each property, to avoid being able
555         # to set restricted ones on new item creation
556         for key in props:
557             if not self.hasPermission('Create', classname=classname,
558                                       property=key):
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         # handle the props - edit or create
663         try:
664             # when it hits the None element, it'll set self.nodeid
665             messages = self._editnodes(props, links)
666         except (ValueError, KeyError, IndexError,
667                 roundup.exceptions.Reject), message:
668             # these errors might just be indicative of user dumbness
669             self.client.error_message.append(_('Error: %s') % str(message))
670             return
672         # commit now that all the tricky stuff is done
673         self.db.commit()
675         # redirect to the new item's page
676         raise exceptions.Redirect, '%s%s%s?@ok_message=%s&@template=%s' % (
677             self.base, self.classname, self.nodeid, urllib.quote(messages),
678             urllib.quote(self.template))
680 class PassResetAction(Action):
681     def handle(self):
682         """Handle password reset requests.
684         Presence of either "name" or "address" generates email. Presence of
685         "otk" performs the reset.
687         """
688         otks = self.db.getOTKManager()
689         if self.form.has_key('otk'):
690             # pull the rego information out of the otk database
691             otk = self.form['otk'].value
692             uid = otks.get(otk, 'uid', default=None)
693             if uid is None:
694                 self.client.error_message.append(
695                     self._("Invalid One Time Key!\n"
696                         "(a Mozilla bug may cause this message "
697                         "to show up erroneously, please check your email)"))
698                 return
700             # re-open the database as "admin"
701             if self.user != 'admin':
702                 self.client.opendb('admin')
703                 self.db = self.client.db
704                 otks = self.db.getOTKManager()
706             # change the password
707             newpw = password.generatePassword()
709             cl = self.db.user
710             # XXX we need to make the "default" page be able to display errors!
711             try:
712                 # set the password
713                 cl.set(uid, password=password.Password(newpw))
714                 # clear the props from the otk database
715                 otks.destroy(otk)
716                 self.db.commit()
717             except (ValueError, KeyError), message:
718                 self.client.error_message.append(str(message))
719                 return
721             # user info
722             address = self.db.user.get(uid, 'address')
723             name = self.db.user.get(uid, 'username')
725             # send the email
726             tracker_name = self.db.config.TRACKER_NAME
727             subject = 'Password reset for %s'%tracker_name
728             body = '''
729 The password has been reset for username "%(name)s".
731 Your password is now: %(password)s
732 '''%{'name': name, 'password': newpw}
733             if not self.client.standard_message([address], subject, body):
734                 return
736             self.client.ok_message.append(
737                 self._('Password reset and email sent to %s') % address)
738             return
740         # no OTK, so now figure the user
741         if self.form.has_key('username'):
742             name = self.form['username'].value
743             try:
744                 uid = self.db.user.lookup(name)
745             except KeyError:
746                 self.client.error_message.append(self._('Unknown username'))
747                 return
748             address = self.db.user.get(uid, 'address')
749         elif self.form.has_key('address'):
750             address = self.form['address'].value
751             uid = uidFromAddress(self.db, ('', address), create=0)
752             if not uid:
753                 self.client.error_message.append(
754                     self._('Unknown email address'))
755                 return
756             name = self.db.user.get(uid, 'username')
757         else:
758             self.client.error_message.append(
759                 self._('You need to specify a username or address'))
760             return
762         # generate the one-time-key and store the props for later
763         otk = ''.join([random.choice(chars) for x in range(32)])
764         while otks.exists(otk):
765             otk = ''.join([random.choice(chars) for x in range(32)])
766         otks.set(otk, uid=uid)
767         self.db.commit()
769         # send the email
770         tracker_name = self.db.config.TRACKER_NAME
771         subject = 'Confirm reset of password for %s'%tracker_name
772         body = '''
773 Someone, perhaps you, has requested that the password be changed for your
774 username, "%(name)s". If you wish to proceed with the change, please follow
775 the link below:
777   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
779 You should then receive another email with the new password.
780 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
781         if not self.client.standard_message([address], subject, body):
782             return
784         self.client.ok_message.append(self._('Email sent to %s') % address)
786 class RegoCommon(Action):
787     def finishRego(self):
788         # log the new user in
789         self.client.userid = self.userid
790         user = self.client.user = self.db.user.get(self.userid, 'username')
791         # re-open the database for real, using the user
792         self.client.opendb(user)
794         # update session data
795         self.client.session_api.set(user=user)
797         # nice message
798         message = self._('You are now registered, welcome!')
799         url = '%suser%s?@ok_message=%s'%(self.base, self.userid,
800             urllib.quote(message))
802         # redirect to the user's page (but not 302, as some email clients seem
803         # to want to reload the page, or something)
804         return '''<html><head><title>%s</title></head>
805             <body><p><a href="%s">%s</a></p>
806             <script type="text/javascript">
807             window.setTimeout('window.location = "%s"', 1000);
808             </script>'''%(message, url, message, url)
810 class ConfRegoAction(RegoCommon):
811     def handle(self):
812         """Grab the OTK, use it to load up the new user details."""
813         try:
814             # pull the rego information out of the otk database
815             self.userid = self.db.confirm_registration(self.form['otk'].value)
816         except (ValueError, KeyError), message:
817             self.client.error_message.append(str(message))
818             return
819         return self.finishRego()
821 class RegisterAction(RegoCommon, EditCommon):
822     name = 'register'
823     permissionType = 'Register'
825     def handle(self):
826         """Attempt to create a new user based on the contents of the form
827         and then remember it in session.
829         Return 1 on successful login.
830         """
831         # ensure modification comes via POST
832         if self.client.env['REQUEST_METHOD'] != 'POST':
833             raise roundup.exceptions.Reject(self._('Invalid request'))
835         # parse the props from the form
836         try:
837             props, links = self.client.parsePropsFromForm(create=1)
838         except (ValueError, KeyError), message:
839             self.client.error_message.append(self._('Error: %s')
840                 % str(message))
841             return
843         # registration isn't allowed to supply roles
844         user_props = props[('user', None)]
845         if user_props.has_key('roles'):
846             raise exceptions.Unauthorised, self._(
847                 "It is not permitted to supply roles at registration.")
849         # skip the confirmation step?
850         if self.db.config['INSTANT_REGISTRATION']:
851             # handle the create now
852             try:
853                 # when it hits the None element, it'll set self.nodeid
854                 messages = self._editnodes(props, links)
855             except (ValueError, KeyError, IndexError,
856                     roundup.exceptions.Reject), message:
857                 # these errors might just be indicative of user dumbness
858                 self.client.error_message.append(_('Error: %s') % str(message))
859                 return
861             # fix up the initial roles
862             self.db.user.set(self.nodeid,
863                 roles=self.db.config['NEW_WEB_USER_ROLES'])
865             # commit now that all the tricky stuff is done
866             self.db.commit()
868             # finish off by logging the user in
869             self.userid = self.nodeid
870             return self.finishRego()
872         # generate the one-time-key and store the props for later
873         for propname, proptype in self.db.user.getprops().items():
874             value = user_props.get(propname, None)
875             if value is None:
876                 pass
877             elif isinstance(proptype, hyperdb.Date):
878                 user_props[propname] = str(value)
879             elif isinstance(proptype, hyperdb.Interval):
880                 user_props[propname] = str(value)
881             elif isinstance(proptype, hyperdb.Password):
882                 user_props[propname] = str(value)
883         otks = self.db.getOTKManager()
884         otk = ''.join([random.choice(chars) for x in range(32)])
885         while otks.exists(otk):
886             otk = ''.join([random.choice(chars) for x in range(32)])
887         otks.set(otk, **user_props)
889         # send the email
890         tracker_name = self.db.config.TRACKER_NAME
891         tracker_email = self.db.config.TRACKER_EMAIL
892         if self.db.config['EMAIL_REGISTRATION_CONFIRMATION']:
893             subject = 'Complete your registration to %s -- key %s'%(tracker_name,
894                                                                   otk)
895             body = """To complete your registration of the user "%(name)s" with
896 %(tracker)s, please do one of the following:
898 - send a reply to %(tracker_email)s and maintain the subject line as is (the
899 reply's additional "Re:" is ok),
901 - or visit the following URL:
903 %(url)s?@action=confrego&otk=%(otk)s
905 """ % {'name': user_props['username'], 'tracker': tracker_name,
906         'url': self.base, 'otk': otk, 'tracker_email': tracker_email}
907         else:
908             subject = 'Complete your registration to %s'%(tracker_name)
909             body = """To complete your registration of the user "%(name)s" with
910 %(tracker)s, please visit the following URL:
912 %(url)s?@action=confrego&otk=%(otk)s
914 """ % {'name': user_props['username'], 'tracker': tracker_name,
915         'url': self.base, 'otk': otk}
916         if not self.client.standard_message([user_props['address']], subject,
917                 body, (tracker_name, tracker_email)):
918             return
920         # commit changes to the database
921         self.db.commit()
923         # redirect to the "you're almost there" page
924         raise exceptions.Redirect, '%suser?@template=rego_progress'%self.base
926 class LogoutAction(Action):
927     def handle(self):
928         """Make us really anonymous - nuke the session too."""
929         # log us out
930         self.client.make_user_anonymous()
931         self.client.session_api.destroy()
933         # Let the user know what's going on
934         self.client.ok_message.append(self._('You are logged out'))
936         # reset client context to render tracker home page
937         # instead of last viewed page (may be inaccessibe for anonymous)
938         self.client.classname = None
939         self.client.nodeid = None
940         self.client.template = None
942 class LoginAction(Action):
943     def handle(self):
944         """Attempt to log a user in.
946         Sets up a session for the user which contains the login credentials.
948         """
949         # ensure modification comes via POST
950         if self.client.env['REQUEST_METHOD'] != 'POST':
951             raise roundup.exceptions.Reject(self._('Invalid request'))
953         # we need the username at a minimum
954         if not self.form.has_key('__login_name'):
955             self.client.error_message.append(self._('Username required'))
956             return
958         # get the login info
959         self.client.user = self.form['__login_name'].value
960         if self.form.has_key('__login_password'):
961             password = self.form['__login_password'].value
962         else:
963             password = ''
965         try:
966             self.verifyLogin(self.client.user, password)
967         except exceptions.LoginError, err:
968             self.client.make_user_anonymous()
969             self.client.error_message.extend(list(err.args))
970             return
972         # now we're OK, re-open the database for real, using the user
973         self.client.opendb(self.client.user)
975         # save user in session
976         self.client.session_api.set(user=self.client.user)
977         if self.form.has_key('remember'):
978             self.client.session_api.update(set_cookie=True, expire=24*3600*365)
980         # If we came from someplace, go back there
981         if self.form.has_key('__came_from'):
982             raise exceptions.Redirect, self.form['__came_from'].value
984     def verifyLogin(self, username, password):
985         # make sure the user exists
986         try:
987             self.client.userid = self.db.user.lookup(username)
988         except KeyError:
989             raise exceptions.LoginError, self._('Invalid login')
991         # verify the password
992         if not self.verifyPassword(self.client.userid, password):
993             raise exceptions.LoginError, self._('Invalid login')
995         # Determine whether the user has permission to log in.
996         # Base behaviour is to check the user has "Web Access".
997         if not self.hasPermission("Web Access"):
998             raise exceptions.LoginError, self._(
999                 "You do not have permission to login")
1001     def verifyPassword(self, userid, password):
1002         '''Verify the password that the user has supplied'''
1003         stored = self.db.user.get(userid, 'password')
1004         if password == stored:
1005             return 1
1006         if not password and not stored:
1007             return 1
1008         return 0
1010 class ExportCSVAction(Action):
1011     name = 'export'
1012     permissionType = 'View'
1014     def handle(self):
1015         ''' Export the specified search query as CSV. '''
1016         # figure the request
1017         request = templating.HTMLRequest(self.client)
1018         filterspec = request.filterspec
1019         sort = request.sort
1020         group = request.group
1021         columns = request.columns
1022         klass = self.db.getclass(request.classname)
1024         # full-text search
1025         if request.search_text:
1026             matches = self.db.indexer.search(
1027                 re.findall(r'\b\w{2,25}\b', request.search_text), klass)
1028         else:
1029             matches = None
1031         h = self.client.additional_headers
1032         h['Content-Type'] = 'text/csv; charset=%s' % self.client.charset
1033         # some browsers will honor the filename here...
1034         h['Content-Disposition'] = 'inline; filename=query.csv'
1036         self.client.header()
1038         if self.client.env['REQUEST_METHOD'] == 'HEAD':
1039             # all done, return a dummy string
1040             return 'dummy'
1042         wfile = self.client.request.wfile
1043         if self.client.charset != self.client.STORAGE_CHARSET:
1044             wfile = codecs.EncodedFile(wfile,
1045                 self.client.STORAGE_CHARSET, self.client.charset, 'replace')
1047         writer = csv.writer(wfile)
1048         self.client._socket_op(writer.writerow, columns)
1050         # and search
1051         for itemid in klass.filter(matches, filterspec, sort, group):
1052             row = []
1053             for name in columns:
1054                 # check permission to view this property on this item
1055                 if not self.hasPermission('View', itemid=itemid,
1056                         classname=request.classname, property=name):
1057                     raise exceptions.Unauthorised, self._(
1058                         'You do not have permission to view %(class)s'
1059                     ) % {'class': request.classname}
1060                 row.append(str(klass.get(itemid, name)))
1061             self.client._socket_op(writer.writerow, row)
1063         return '\n'
1066 class Bridge(BaseAction):
1067     """Make roundup.actions.Action executable via CGI request.
1069     Using this allows users to write actions executable from multiple frontends.
1070     CGI Form content is translated into a dictionary, which then is passed as
1071     argument to 'handle()'. XMLRPC requests have to pass this dictionary
1072     directly.
1073     """
1075     def __init__(self, *args):
1077         # As this constructor is callable from multiple frontends, each with
1078         # different Action interfaces, we have to look at the arguments to
1079         # figure out how to complete construction.
1080         if (len(args) == 1 and
1081             hasattr(args[0], '__class__') and
1082             args[0].__class__.__name__ == 'Client'):
1083             self.cgi = True
1084             self.execute = self.execute_cgi
1085             self.client = args[0]
1086             self.form = self.client.form
1087         else:
1088             self.cgi = False
1090     def execute_cgi(self):
1091         args = {}
1092         for key in self.form.keys():
1093             args[key] = self.form.getvalue(key)
1094         self.permission(args)
1095         return self.handle(args)
1097     def permission(self, args):
1098         """Raise Unauthorised if the current user is not allowed to execute
1099         this action. Users may override this method."""
1101         pass
1103     def handle(self, args):
1105         raise NotImplementedError
1107 # vim: set filetype=python sts=4 sw=4 et si :