Code

Fix linking of an existing item to a newly created item, e.g. edit
[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 Edit 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('Edit', classname=classname,
558                                       property=key):
559                 # We restrict by default and special-case allowed properties
560                 if key == 'date' or key == 'content':
561                     continue
562                 elif key == 'author' and props[key] == self.userid:
563                     continue
564                 return 0
565         return 1
567 class EditItemAction(EditCommon):
568     def lastUserActivity(self):
569         if self.form.has_key(':lastactivity'):
570             d = date.Date(self.form[':lastactivity'].value)
571         elif self.form.has_key('@lastactivity'):
572             d = date.Date(self.form['@lastactivity'].value)
573         else:
574             return None
575         d.second = int(d.second)
576         return d
578     def lastNodeActivity(self):
579         cl = getattr(self.client.db, self.classname)
580         activity = cl.get(self.nodeid, 'activity').local(0)
581         activity.second = int(activity.second)
582         return activity
584     def detectCollision(self, user_activity, node_activity):
585         '''Check for a collision and return the list of props we edited
586         that conflict.'''
587         if user_activity and user_activity < node_activity:
588             props, links = self.client.parsePropsFromForm()
589             key = (self.classname, self.nodeid)
590             # we really only collide for direct prop edit conflicts
591             return props[key].keys()
592         else:
593             return []
595     def handleCollision(self, props):
596         message = self._('Edit Error: someone else has edited this %s (%s). '
597             'View <a target="new" href="%s%s">their changes</a> '
598             'in a new window.')%(self.classname, ', '.join(props),
599             self.classname, self.nodeid)
600         self.client.error_message.append(message)
601         return
603     def handle(self):
604         """Perform an edit of an item in the database.
606         See parsePropsFromForm and _editnodes for special variables.
608         """
609         # ensure modification comes via POST
610         if self.client.env['REQUEST_METHOD'] != 'POST':
611             raise roundup.exceptions.Reject(self._('Invalid request'))
613         user_activity = self.lastUserActivity()
614         if user_activity:
615             props = self.detectCollision(user_activity, self.lastNodeActivity())
616             if props:
617                 self.handleCollision(props)
618                 return
620         props, links = self.client.parsePropsFromForm()
622         # handle the props
623         try:
624             message = self._editnodes(props, links)
625         except (ValueError, KeyError, IndexError,
626                 roundup.exceptions.Reject), message:
627             self.client.error_message.append(
628                 self._('Edit Error: %s') % str(message))
629             return
631         # commit now that all the tricky stuff is done
632         self.db.commit()
634         # redirect to the item's edit page
635         # redirect to finish off
636         url = self.base + self.classname
637         # note that this action might have been called by an index page, so
638         # we will want to include index-page args in this URL too
639         if self.nodeid is not None:
640             url += self.nodeid
641         url += '?@ok_message=%s&@template=%s'%(urllib.quote(message),
642             urllib.quote(self.template))
643         if self.nodeid is None:
644             req = templating.HTMLRequest(self.client)
645             url += '&' + req.indexargs_url('', {})[1:]
646         raise exceptions.Redirect, url
648 class NewItemAction(EditCommon):
649     def handle(self):
650         ''' Add a new item to the database.
652             This follows the same form as the EditItemAction, with the same
653             special form values.
654         '''
655         # ensure modification comes via POST
656         if self.client.env['REQUEST_METHOD'] != 'POST':
657             raise roundup.exceptions.Reject(self._('Invalid request'))
659         # parse the props from the form
660         try:
661             props, links = self.client.parsePropsFromForm(create=1)
662         except (ValueError, KeyError), message:
663             self.client.error_message.append(self._('Error: %s')
664                 % str(message))
665             return
667         # handle the props - edit or create
668         try:
669             # when it hits the None element, it'll set self.nodeid
670             messages = self._editnodes(props, links)
671         except (ValueError, KeyError, IndexError,
672                 roundup.exceptions.Reject), message:
673             # these errors might just be indicative of user dumbness
674             self.client.error_message.append(_('Error: %s') % str(message))
675             return
677         # commit now that all the tricky stuff is done
678         self.db.commit()
680         # redirect to the new item's page
681         raise exceptions.Redirect, '%s%s%s?@ok_message=%s&@template=%s' % (
682             self.base, self.classname, self.nodeid, urllib.quote(messages),
683             urllib.quote(self.template))
685 class PassResetAction(Action):
686     def handle(self):
687         """Handle password reset requests.
689         Presence of either "name" or "address" generates email. Presence of
690         "otk" performs the reset.
692         """
693         otks = self.db.getOTKManager()
694         if self.form.has_key('otk'):
695             # pull the rego information out of the otk database
696             otk = self.form['otk'].value
697             uid = otks.get(otk, 'uid', default=None)
698             if uid is None:
699                 self.client.error_message.append(
700                     self._("Invalid One Time Key!\n"
701                         "(a Mozilla bug may cause this message "
702                         "to show up erroneously, please check your email)"))
703                 return
705             # re-open the database as "admin"
706             if self.user != 'admin':
707                 self.client.opendb('admin')
708                 self.db = self.client.db
709                 otks = self.db.getOTKManager()
711             # change the password
712             newpw = password.generatePassword()
714             cl = self.db.user
715             # XXX we need to make the "default" page be able to display errors!
716             try:
717                 # set the password
718                 cl.set(uid, password=password.Password(newpw))
719                 # clear the props from the otk database
720                 otks.destroy(otk)
721                 self.db.commit()
722             except (ValueError, KeyError), message:
723                 self.client.error_message.append(str(message))
724                 return
726             # user info
727             address = self.db.user.get(uid, 'address')
728             name = self.db.user.get(uid, 'username')
730             # send the email
731             tracker_name = self.db.config.TRACKER_NAME
732             subject = 'Password reset for %s'%tracker_name
733             body = '''
734 The password has been reset for username "%(name)s".
736 Your password is now: %(password)s
737 '''%{'name': name, 'password': newpw}
738             if not self.client.standard_message([address], subject, body):
739                 return
741             self.client.ok_message.append(
742                 self._('Password reset and email sent to %s') % address)
743             return
745         # no OTK, so now figure the user
746         if self.form.has_key('username'):
747             name = self.form['username'].value
748             try:
749                 uid = self.db.user.lookup(name)
750             except KeyError:
751                 self.client.error_message.append(self._('Unknown username'))
752                 return
753             address = self.db.user.get(uid, 'address')
754         elif self.form.has_key('address'):
755             address = self.form['address'].value
756             uid = uidFromAddress(self.db, ('', address), create=0)
757             if not uid:
758                 self.client.error_message.append(
759                     self._('Unknown email address'))
760                 return
761             name = self.db.user.get(uid, 'username')
762         else:
763             self.client.error_message.append(
764                 self._('You need to specify a username or address'))
765             return
767         # generate the one-time-key and store the props for later
768         otk = ''.join([random.choice(chars) for x in range(32)])
769         while otks.exists(otk):
770             otk = ''.join([random.choice(chars) for x in range(32)])
771         otks.set(otk, uid=uid)
772         self.db.commit()
774         # send the email
775         tracker_name = self.db.config.TRACKER_NAME
776         subject = 'Confirm reset of password for %s'%tracker_name
777         body = '''
778 Someone, perhaps you, has requested that the password be changed for your
779 username, "%(name)s". If you wish to proceed with the change, please follow
780 the link below:
782   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
784 You should then receive another email with the new password.
785 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
786         if not self.client.standard_message([address], subject, body):
787             return
789         self.client.ok_message.append(self._('Email sent to %s') % address)
791 class RegoCommon(Action):
792     def finishRego(self):
793         # log the new user in
794         self.client.userid = self.userid
795         user = self.client.user = self.db.user.get(self.userid, 'username')
796         # re-open the database for real, using the user
797         self.client.opendb(user)
799         # update session data
800         self.client.session_api.set(user=user)
802         # nice message
803         message = self._('You are now registered, welcome!')
804         url = '%suser%s?@ok_message=%s'%(self.base, self.userid,
805             urllib.quote(message))
807         # redirect to the user's page (but not 302, as some email clients seem
808         # to want to reload the page, or something)
809         return '''<html><head><title>%s</title></head>
810             <body><p><a href="%s">%s</a></p>
811             <script type="text/javascript">
812             window.setTimeout('window.location = "%s"', 1000);
813             </script>'''%(message, url, message, url)
815 class ConfRegoAction(RegoCommon):
816     def handle(self):
817         """Grab the OTK, use it to load up the new user details."""
818         try:
819             # pull the rego information out of the otk database
820             self.userid = self.db.confirm_registration(self.form['otk'].value)
821         except (ValueError, KeyError), message:
822             self.client.error_message.append(str(message))
823             return
824         return self.finishRego()
826 class RegisterAction(RegoCommon, EditCommon):
827     name = 'register'
828     permissionType = 'Register'
830     def handle(self):
831         """Attempt to create a new user based on the contents of the form
832         and then remember it in session.
834         Return 1 on successful login.
835         """
836         # ensure modification comes via POST
837         if self.client.env['REQUEST_METHOD'] != 'POST':
838             raise roundup.exceptions.Reject(self._('Invalid request'))
840         # parse the props from the form
841         try:
842             props, links = self.client.parsePropsFromForm(create=1)
843         except (ValueError, KeyError), message:
844             self.client.error_message.append(self._('Error: %s')
845                 % str(message))
846             return
848         # registration isn't allowed to supply roles
849         user_props = props[('user', None)]
850         if user_props.has_key('roles'):
851             raise exceptions.Unauthorised, self._(
852                 "It is not permitted to supply roles at registration.")
854         # skip the confirmation step?
855         if self.db.config['INSTANT_REGISTRATION']:
856             # handle the create now
857             try:
858                 # when it hits the None element, it'll set self.nodeid
859                 messages = self._editnodes(props, links)
860             except (ValueError, KeyError, IndexError,
861                     roundup.exceptions.Reject), message:
862                 # these errors might just be indicative of user dumbness
863                 self.client.error_message.append(_('Error: %s') % str(message))
864                 return
866             # fix up the initial roles
867             self.db.user.set(self.nodeid,
868                 roles=self.db.config['NEW_WEB_USER_ROLES'])
870             # commit now that all the tricky stuff is done
871             self.db.commit()
873             # finish off by logging the user in
874             self.userid = self.nodeid
875             return self.finishRego()
877         # generate the one-time-key and store the props for later
878         for propname, proptype in self.db.user.getprops().items():
879             value = user_props.get(propname, None)
880             if value is None:
881                 pass
882             elif isinstance(proptype, hyperdb.Date):
883                 user_props[propname] = str(value)
884             elif isinstance(proptype, hyperdb.Interval):
885                 user_props[propname] = str(value)
886             elif isinstance(proptype, hyperdb.Password):
887                 user_props[propname] = str(value)
888         otks = self.db.getOTKManager()
889         otk = ''.join([random.choice(chars) for x in range(32)])
890         while otks.exists(otk):
891             otk = ''.join([random.choice(chars) for x in range(32)])
892         otks.set(otk, **user_props)
894         # send the email
895         tracker_name = self.db.config.TRACKER_NAME
896         tracker_email = self.db.config.TRACKER_EMAIL
897         if self.db.config['EMAIL_REGISTRATION_CONFIRMATION']:
898             subject = 'Complete your registration to %s -- key %s'%(tracker_name,
899                                                                   otk)
900             body = """To complete your registration of the user "%(name)s" with
901 %(tracker)s, please do one of the following:
903 - send a reply to %(tracker_email)s and maintain the subject line as is (the
904 reply's additional "Re:" is ok),
906 - or visit the following URL:
908 %(url)s?@action=confrego&otk=%(otk)s
910 """ % {'name': user_props['username'], 'tracker': tracker_name,
911         'url': self.base, 'otk': otk, 'tracker_email': tracker_email}
912         else:
913             subject = 'Complete your registration to %s'%(tracker_name)
914             body = """To complete your registration of the user "%(name)s" with
915 %(tracker)s, please visit the following URL:
917 %(url)s?@action=confrego&otk=%(otk)s
919 """ % {'name': user_props['username'], 'tracker': tracker_name,
920         'url': self.base, 'otk': otk}
921         if not self.client.standard_message([user_props['address']], subject,
922                 body, (tracker_name, tracker_email)):
923             return
925         # commit changes to the database
926         self.db.commit()
928         # redirect to the "you're almost there" page
929         raise exceptions.Redirect, '%suser?@template=rego_progress'%self.base
931 class LogoutAction(Action):
932     def handle(self):
933         """Make us really anonymous - nuke the session too."""
934         # log us out
935         self.client.make_user_anonymous()
936         self.client.session_api.destroy()
938         # Let the user know what's going on
939         self.client.ok_message.append(self._('You are logged out'))
941         # reset client context to render tracker home page
942         # instead of last viewed page (may be inaccessibe for anonymous)
943         self.client.classname = None
944         self.client.nodeid = None
945         self.client.template = None
947 class LoginAction(Action):
948     def handle(self):
949         """Attempt to log a user in.
951         Sets up a session for the user which contains the login credentials.
953         """
954         # ensure modification comes via POST
955         if self.client.env['REQUEST_METHOD'] != 'POST':
956             raise roundup.exceptions.Reject(self._('Invalid request'))
958         # we need the username at a minimum
959         if not self.form.has_key('__login_name'):
960             self.client.error_message.append(self._('Username required'))
961             return
963         # get the login info
964         self.client.user = self.form['__login_name'].value
965         if self.form.has_key('__login_password'):
966             password = self.form['__login_password'].value
967         else:
968             password = ''
970         try:
971             self.verifyLogin(self.client.user, password)
972         except exceptions.LoginError, err:
973             self.client.make_user_anonymous()
974             self.client.error_message.extend(list(err.args))
975             return
977         # now we're OK, re-open the database for real, using the user
978         self.client.opendb(self.client.user)
980         # save user in session
981         self.client.session_api.set(user=self.client.user)
982         if self.form.has_key('remember'):
983             self.client.session_api.update(set_cookie=True, expire=24*3600*365)
985         # If we came from someplace, go back there
986         if self.form.has_key('__came_from'):
987             raise exceptions.Redirect, self.form['__came_from'].value
989     def verifyLogin(self, username, password):
990         # make sure the user exists
991         try:
992             self.client.userid = self.db.user.lookup(username)
993         except KeyError:
994             raise exceptions.LoginError, self._('Invalid login')
996         # verify the password
997         if not self.verifyPassword(self.client.userid, password):
998             raise exceptions.LoginError, self._('Invalid login')
1000         # Determine whether the user has permission to log in.
1001         # Base behaviour is to check the user has "Web Access".
1002         if not self.hasPermission("Web Access"):
1003             raise exceptions.LoginError, self._(
1004                 "You do not have permission to login")
1006     def verifyPassword(self, userid, password):
1007         '''Verify the password that the user has supplied'''
1008         stored = self.db.user.get(userid, 'password')
1009         if password == stored:
1010             return 1
1011         if not password and not stored:
1012             return 1
1013         return 0
1015 class ExportCSVAction(Action):
1016     name = 'export'
1017     permissionType = 'View'
1019     def handle(self):
1020         ''' Export the specified search query as CSV. '''
1021         # figure the request
1022         request = templating.HTMLRequest(self.client)
1023         filterspec = request.filterspec
1024         sort = request.sort
1025         group = request.group
1026         columns = request.columns
1027         klass = self.db.getclass(request.classname)
1029         # full-text search
1030         if request.search_text:
1031             matches = self.db.indexer.search(
1032                 re.findall(r'\b\w{2,25}\b', request.search_text), klass)
1033         else:
1034             matches = None
1036         h = self.client.additional_headers
1037         h['Content-Type'] = 'text/csv; charset=%s' % self.client.charset
1038         # some browsers will honor the filename here...
1039         h['Content-Disposition'] = 'inline; filename=query.csv'
1041         self.client.header()
1043         if self.client.env['REQUEST_METHOD'] == 'HEAD':
1044             # all done, return a dummy string
1045             return 'dummy'
1047         wfile = self.client.request.wfile
1048         if self.client.charset != self.client.STORAGE_CHARSET:
1049             wfile = codecs.EncodedFile(wfile,
1050                 self.client.STORAGE_CHARSET, self.client.charset, 'replace')
1052         writer = csv.writer(wfile)
1053         self.client._socket_op(writer.writerow, columns)
1055         # and search
1056         for itemid in klass.filter(matches, filterspec, sort, group):
1057             row = []
1058             for name in columns:
1059                 # check permission to view this property on this item
1060                 if not self.hasPermission('View', itemid=itemid,
1061                         classname=request.classname, property=name):
1062                     raise exceptions.Unauthorised, self._(
1063                         'You do not have permission to view %(class)s'
1064                     ) % {'class': request.classname}
1065                 row.append(str(klass.get(itemid, name)))
1066             self.client._socket_op(writer.writerow, row)
1068         return '\n'
1071 class Bridge(BaseAction):
1072     """Make roundup.actions.Action executable via CGI request.
1074     Using this allows users to write actions executable from multiple frontends.
1075     CGI Form content is translated into a dictionary, which then is passed as
1076     argument to 'handle()'. XMLRPC requests have to pass this dictionary
1077     directly.
1078     """
1080     def __init__(self, *args):
1082         # As this constructor is callable from multiple frontends, each with
1083         # different Action interfaces, we have to look at the arguments to
1084         # figure out how to complete construction.
1085         if (len(args) == 1 and
1086             hasattr(args[0], '__class__') and
1087             args[0].__class__.__name__ == 'Client'):
1088             self.cgi = True
1089             self.execute = self.execute_cgi
1090             self.client = args[0]
1091             self.form = self.client.form
1092         else:
1093             self.cgi = False
1095     def execute_cgi(self):
1096         args = {}
1097         for key in self.form.keys():
1098             args[key] = self.form.getvalue(key)
1099         self.permission(args)
1100         return self.handle(args)
1102     def permission(self, args):
1103         """Raise Unauthorised if the current user is not allowed to execute
1104         this action. Users may override this method."""
1106         pass
1108     def handle(self, args):
1110         raise NotImplementedError
1112 # vim: set filetype=python sts=4 sw=4 et si :