Code

Fix issue2550553.
[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         # 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 :