Code

92e62d42c31fd1664bbf5564c3457ed98ed58497
[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         # skip the confirmation step?
844         if self.db.config['INSTANT_REGISTRATION']:
845             # handle the create now
846             try:
847                 # when it hits the None element, it'll set self.nodeid
848                 messages = self._editnodes(props, links)
849             except (ValueError, KeyError, IndexError,
850                     roundup.exceptions.Reject), message:
851                 # these errors might just be indicative of user dumbness
852                 self.client.error_message.append(_('Error: %s') % str(message))
853                 return
855             # fix up the initial roles
856             self.db.user.set(self.nodeid,
857                 roles=self.db.config['NEW_WEB_USER_ROLES'])
859             # commit now that all the tricky stuff is done
860             self.db.commit()
862             # finish off by logging the user in
863             self.userid = self.nodeid
864             return self.finishRego()
866         # generate the one-time-key and store the props for later
867         for propname, proptype in self.db.user.getprops().items():
868             value = user_props.get(propname, None)
869             if value is None:
870                 pass
871             elif isinstance(proptype, hyperdb.Date):
872                 user_props[propname] = str(value)
873             elif isinstance(proptype, hyperdb.Interval):
874                 user_props[propname] = str(value)
875             elif isinstance(proptype, hyperdb.Password):
876                 user_props[propname] = str(value)
877         otks = self.db.getOTKManager()
878         otk = ''.join([random.choice(chars) for x in range(32)])
879         while otks.exists(otk):
880             otk = ''.join([random.choice(chars) for x in range(32)])
881         otks.set(otk, **user_props)
883         # send the email
884         tracker_name = self.db.config.TRACKER_NAME
885         tracker_email = self.db.config.TRACKER_EMAIL
886         if self.db.config['EMAIL_REGISTRATION_CONFIRMATION']:
887             subject = 'Complete your registration to %s -- key %s'%(tracker_name,
888                                                                   otk)
889             body = """To complete your registration of the user "%(name)s" with
890 %(tracker)s, please do one of the following:
892 - send a reply to %(tracker_email)s and maintain the subject line as is (the
893 reply's additional "Re:" is ok),
895 - or visit the following URL:
897 %(url)s?@action=confrego&otk=%(otk)s
899 """ % {'name': user_props['username'], 'tracker': tracker_name,
900         'url': self.base, 'otk': otk, 'tracker_email': tracker_email}
901         else:
902             subject = 'Complete your registration to %s'%(tracker_name)
903             body = """To complete your registration of the user "%(name)s" with
904 %(tracker)s, please visit the following URL:
906 %(url)s?@action=confrego&otk=%(otk)s
908 """ % {'name': user_props['username'], 'tracker': tracker_name,
909         'url': self.base, 'otk': otk}
910         if not self.client.standard_message([user_props['address']], subject,
911                 body, (tracker_name, tracker_email)):
912             return
914         # commit changes to the database
915         self.db.commit()
917         # redirect to the "you're almost there" page
918         raise exceptions.Redirect, '%suser?@template=rego_progress'%self.base
920     def newItemPermission(self, props, classname=None):
921         """Just check the "Register" permission.
922         """
923         # registration isn't allowed to supply roles
924         if props.has_key('roles'):
925             raise exceptions.Unauthorised, self._(
926                 "It is not permitted to supply roles at registration.")
928         # technically already checked, but here for clarity
929         return self.hasPermission('Register', classname=classname)
931 class LogoutAction(Action):
932     def handle(self):
933         """Make us really anonymous - nuke the session too."""
934         # log us out
935         self.client.make_user_anonymous()
936         self.client.session_api.destroy()
938         # Let the user know what's going on
939         self.client.ok_message.append(self._('You are logged out'))
941         # reset client context to render tracker home page
942         # instead of last viewed page (may be inaccessibe for anonymous)
943         self.client.classname = None
944         self.client.nodeid = None
945         self.client.template = None
947 class LoginAction(Action):
948     def handle(self):
949         """Attempt to log a user in.
951         Sets up a session for the user which contains the login credentials.
953         """
954         # ensure modification comes via POST
955         if self.client.env['REQUEST_METHOD'] != 'POST':
956             raise roundup.exceptions.Reject(self._('Invalid request'))
958         # we need the username at a minimum
959         if not self.form.has_key('__login_name'):
960             self.client.error_message.append(self._('Username required'))
961             return
963         # get the login info
964         self.client.user = self.form['__login_name'].value
965         if self.form.has_key('__login_password'):
966             password = self.form['__login_password'].value
967         else:
968             password = ''
970         try:
971             self.verifyLogin(self.client.user, password)
972         except exceptions.LoginError, err:
973             self.client.make_user_anonymous()
974             self.client.error_message.extend(list(err.args))
975             return
977         # now we're OK, re-open the database for real, using the user
978         self.client.opendb(self.client.user)
980         # save user in session
981         self.client.session_api.set(user=self.client.user)
982         if self.form.has_key('remember'):
983             self.client.session_api.update(set_cookie=True, expire=24*3600*365)
985         # If we came from someplace, go back there
986         if self.form.has_key('__came_from'):
987             raise exceptions.Redirect, self.form['__came_from'].value
989     def verifyLogin(self, username, password):
990         # make sure the user exists
991         try:
992             self.client.userid = self.db.user.lookup(username)
993         except KeyError:
994             raise exceptions.LoginError, self._('Invalid login')
996         # verify the password
997         if not self.verifyPassword(self.client.userid, password):
998             raise exceptions.LoginError, self._('Invalid login')
1000         # Determine whether the user has permission to log in.
1001         # Base behaviour is to check the user has "Web Access".
1002         if not self.hasPermission("Web Access"):
1003             raise exceptions.LoginError, self._(
1004                 "You do not have permission to login")
1006     def verifyPassword(self, userid, password):
1007         '''Verify the password that the user has supplied'''
1008         stored = self.db.user.get(userid, 'password')
1009         if password == stored:
1010             return 1
1011         if not password and not stored:
1012             return 1
1013         return 0
1015 class ExportCSVAction(Action):
1016     name = 'export'
1017     permissionType = 'View'
1019     def handle(self):
1020         ''' Export the specified search query as CSV. '''
1021         # figure the request
1022         request = templating.HTMLRequest(self.client)
1023         filterspec = request.filterspec
1024         sort = request.sort
1025         group = request.group
1026         columns = request.columns
1027         klass = self.db.getclass(request.classname)
1029         # full-text search
1030         if request.search_text:
1031             matches = self.db.indexer.search(
1032                 re.findall(r'\b\w{2,25}\b', request.search_text), klass)
1033         else:
1034             matches = None
1036         h = self.client.additional_headers
1037         h['Content-Type'] = 'text/csv; charset=%s' % self.client.charset
1038         # some browsers will honor the filename here...
1039         h['Content-Disposition'] = 'inline; filename=query.csv'
1041         self.client.header()
1043         if self.client.env['REQUEST_METHOD'] == 'HEAD':
1044             # all done, return a dummy string
1045             return 'dummy'
1047         wfile = self.client.request.wfile
1048         if self.client.charset != self.client.STORAGE_CHARSET:
1049             wfile = codecs.EncodedFile(wfile,
1050                 self.client.STORAGE_CHARSET, self.client.charset, 'replace')
1052         writer = csv.writer(wfile)
1053         self.client._socket_op(writer.writerow, columns)
1055         # and search
1056         for itemid in klass.filter(matches, filterspec, sort, group):
1057             row = []
1058             for name in columns:
1059                 # check permission to view this property on this item
1060                 if not self.hasPermission('View', itemid=itemid,
1061                         classname=request.classname, property=name):
1062                     raise exceptions.Unauthorised, self._(
1063                         'You do not have permission to view %(class)s'
1064                     ) % {'class': request.classname}
1065                 row.append(str(klass.get(itemid, name)))
1066             self.client._socket_op(writer.writerow, row)
1068         return '\n'
1071 class Bridge(BaseAction):
1072     """Make roundup.actions.Action executable via CGI request.
1074     Using this allows users to write actions executable from multiple frontends.
1075     CGI Form content is translated into a dictionary, which then is passed as
1076     argument to 'handle()'. XMLRPC requests have to pass this dictionary
1077     directly.
1078     """
1080     def __init__(self, *args):
1082         # As this constructor is callable from multiple frontends, each with
1083         # different Action interfaces, we have to look at the arguments to
1084         # figure out how to complete construction.
1085         if (len(args) == 1 and
1086             hasattr(args[0], '__class__') and
1087             args[0].__class__.__name__ == 'Client'):
1088             self.cgi = True
1089             self.execute = self.execute_cgi
1090             self.client = args[0]
1091             self.form = self.client.form
1092         else:
1093             self.cgi = False
1095     def execute_cgi(self):
1096         args = {}
1097         for key in self.form.keys():
1098             args[key] = self.form.getvalue(key)
1099         self.permission(args)
1100         return self.handle(args)
1102     def permission(self, args):
1103         """Raise Unauthorised if the current user is not allowed to execute
1104         this action. Users may override this method."""
1106         pass
1108     def handle(self, args):
1110         raise NotImplementedError
1112 # vim: set filetype=python sts=4 sw=4 et si :