Code

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