Premiere version : mise en route du suivi.
[auf_roundup.git] / roundup / cgi / .svn / text-base / actions.py.svn-base
1 import re, cgi, time, random, csv, codecs
2
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_
10
11 __all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
12            'EditCSVAction', 'EditItemAction', 'PassResetAction',
13            'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction',
14            'NewItemAction', 'ExportCSVAction']
15
16 # used by a couple of routines
17 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
18
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)
31
32     def handle(self):
33         """Action handler procedure"""
34         raise NotImplementedError
35
36     def execute(self):
37         """Execute the action specified by this object."""
38         self.permission()
39         return self.handle()
40
41     name = ''
42     permissionType = None
43     def permission(self):
44         """Check whether the user has permission to execute this action.
45
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.
50
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)
60
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)
68
69     def gettext(self, msgid):
70         """Return the localized translation of msgid"""
71         return self.client.translator.gettext(msgid)
72
73     _ = gettext
74
75 class ShowAction(Action):
76
77     typere=re.compile('[@:]type')
78     numre=re.compile('[@:]number')
79
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)
100
101 class RetireAction(Action):
102     name = 'retire'
103     permissionType = 'Edit'
104
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'))
110
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
116
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'))
122
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})
129
130         # do the retire
131         self.db.getclass(self.classname).retire(itemid)
132         self.db.commit()
133
134         self.client.ok_message.append(
135             self._('%(classname)s %(itemid)s has been retired')%{
136                 'classname': self.classname.capitalize(), 'itemid': itemid})
137
138
139 class SearchAction(Action):
140     name = 'search'
141     permissionType = 'View'
142
143     def handle(self):
144         """Mangle some of the form variables.
145
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.
149
150         Handle the ":queryname" variable and save off the query to the user's
151         query list.
152
153         Split any String query values on whitespace and comma.
154
155         """
156         self.fakeFilterVars()
157         queryname = self.getQueryName()
158
159         # editing existing query name?
160         old_queryname = self.getFromForm('old-queryname')
161
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)
166
167             url = self.getCurrentURL(req)
168
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})
196
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)
214
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)
220
221             # commit the query change to the database
222             self.db.commit()
223
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))
249
250             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
251
252     def getCurrentURL(self, req):
253         """Get current URL for storing as a query.
254
255         Note: We are removing the first character from the current URL,
256         because the leading '?' is not part of the query string.
257
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:]
265
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 ''
271
272     def getQueryName(self):
273         return self.getFromForm('queryname')
274
275 class EditCSVAction(Action):
276     name = 'edit'
277     permissionType = 'Edit'
278
279     def handle(self):
280         """Performs an edit of all of a class' items in one go.
281
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'))
289
290         # figure the properties list for the class
291         cl = self.db.classes[self.classname]
292         props_without_id = list(cl.getprops(protected=0))
293
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
298
299         # do the edit
300         rows = io_.StringIO(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
310
311             # extract the itemid
312             itemid, values = values[0], values[1:]
313             found[itemid] = 1
314
315             # see if the node exists
316             if itemid in ('x', 'X') or not cl.hasnode(itemid):
317                 exists = 0
318
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
331
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
337
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})
347
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)
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
372
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
380
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)
391
392         # all OK
393         self.db.commit()
394
395         self.client.ok_message.append(self._('Items edited OK'))
396
397 class EditCommon(Action):
398     '''Utility methods for editing.'''
399
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
412
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))
419
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!')
439
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)
449
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
461
462                     # make a new node
463                     newid = self._createnode(cn, props)
464                     if nodeid is None:
465                         self.nodeid = newid
466                     nodeid = newid
467
468                     # and some nice feedback for the user
469                     m.append(self._('%(class)s %(id)s created')
470                         % {'class':cn, 'id':newid})
471
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
493
494         return '<br>'.join(m)
495
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})
503
504         # make the changes
505         cl = self.db.classes[cn]
506         return cl.set(nodeid, **props)
507
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})
515
516         # create the node and return its id
517         cl = self.db.classes[cn]
518         return cl.create(**props)
519
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')
524
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
541
542     def newItemPermission(self, props, classname=None):
543         """Determine whether the user has permission to create this item.
544
545         Base behaviour is to check the user can edit this class. No additional
546         property checks are made.
547         """
548
549         if not classname :
550             classname = self.client.classname
551         
552         if not self.hasPermission('Create', classname=classname):
553             return 0
554
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
562
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
573
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
579
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 []
590
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
598
599     def handle(self):
600         """Perform an edit of an item in the database.
601
602         See parsePropsFromForm and _editnodes for special variables.
603
604         """
605         # ensure modification comes via POST
606         if self.client.env['REQUEST_METHOD'] != 'POST':
607             raise roundup.exceptions.Reject(self._('Invalid request'))
608
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
615
616         props, links = self.client.parsePropsFromForm()
617
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
626
627         # commit now that all the tricky stuff is done
628         self.db.commit()
629
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)
643
644 class NewItemAction(EditCommon):
645     def handle(self):
646         ''' Add a new item to the database.
647
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'))
654
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
662
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
672
673         # commit now that all the tricky stuff is done
674         self.db.commit()
675
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)))
680
681 class PassResetAction(Action):
682     def handle(self):
683         """Handle password reset requests.
684
685         Presence of either "name" or "address" generates email. Presence of
686         "otk" performs the reset.
687
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
700
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()
706
707             # change the password
708             newpw = password.generatePassword()
709
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))
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
721
722             # user info
723             address = self.db.user.get(uid, 'address')
724             name = self.db.user.get(uid, 'username')
725
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".
731
732 Your password is now: %(password)s
733 '''%{'name': name, 'password': newpw}
734             if not self.client.standard_message([address], subject, body):
735                 return
736
737             self.client.ok_message.append(
738                 self._('Password reset and email sent to %s') % address)
739             return
740
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
762
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()
769
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:
777
778   %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
779
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
784
785         self.client.ok_message.append(self._('Email sent to %s') % address)
786
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)
794
795         # update session data
796         self.client.session_api.set(user=user)
797
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))
802
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)
810
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()
821
822 class RegisterAction(RegoCommon, EditCommon):
823     name = 'register'
824     permissionType = 'Register'
825
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.
829
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'))
835
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
843
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
855
856             # fix up the initial roles
857             self.db.user.set(self.nodeid,
858                 roles=self.db.config['NEW_WEB_USER_ROLES'])
859
860             # commit now that all the tricky stuff is done
861             self.db.commit()
862
863             # finish off by logging the user in
864             self.userid = self.nodeid
865             return self.finishRego()
866
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)
884
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:
893
894 - send a reply to %(tracker_email)s and maintain the subject line as is (the
895 reply's additional "Re:" is ok),
896
897 - or visit the following URL:
898
899 %(url)s?@action=confrego&otk=%(otk)s
900
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:
907
908 %(url)s?@action=confrego&otk=%(otk)s
909
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
915
916         # commit changes to the database
917         self.db.commit()
918
919         # redirect to the "you're almost there" page
920         raise exceptions.Redirect('%suser?@template=rego_progress'%self.base)
921
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."))
929
930         # technically already checked, but here for clarity
931         return self.hasPermission('Register', classname=classname)
932
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()
939
940         # Let the user know what's going on
941         self.client.ok_message.append(self._('You are logged out'))
942
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
948
949 class LoginAction(Action):
950     def handle(self):
951         """Attempt to log a user in.
952
953         Sets up a session for the user which contains the login credentials.
954
955         """
956         # ensure modification comes via POST
957         if self.client.env['REQUEST_METHOD'] != 'POST':
958             raise roundup.exceptions.Reject(self._('Invalid request'))
959
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
964
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 = ''
971
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
978
979         # now we're OK, re-open the database for real, using the user
980         self.client.opendb(self.client.user)
981
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)
986
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)
990
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'))
997
998         # verify the password
999         if not self.verifyPassword(self.client.userid, password):
1000             raise exceptions.LoginError(self._('Invalid login'))
1001
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"))
1007
1008     def verifyPassword(self, userid, password):
1009         '''Verify the password that the user has supplied'''
1010         stored = self.db.user.get(userid, 'password')
1011         if password == stored:
1012             return 1
1013         if not password and not stored:
1014             return 1
1015         return 0
1016
1017 class ExportCSVAction(Action):
1018     name = 'export'
1019     permissionType = 'View'
1020
1021     def handle(self):
1022         ''' Export the specified search query as CSV. '''
1023         # figure the request
1024         request = templating.HTMLRequest(self.client)
1025         filterspec = request.filterspec
1026         sort = request.sort
1027         group = request.group
1028         columns = request.columns
1029         klass = self.db.getclass(request.classname)
1030
1031         # full-text search
1032         if request.search_text:
1033             matches = self.db.indexer.search(
1034                 re.findall(r'\b\w{2,25}\b', request.search_text), klass)
1035         else:
1036             matches = None
1037
1038         h = self.client.additional_headers
1039         h['Content-Type'] = 'text/csv; charset=%s' % self.client.charset
1040         # some browsers will honor the filename here...
1041         h['Content-Disposition'] = 'inline; filename=query.csv'
1042
1043         self.client.header()
1044
1045         if self.client.env['REQUEST_METHOD'] == 'HEAD':
1046             # all done, return a dummy string
1047             return 'dummy'
1048
1049         wfile = self.client.request.wfile
1050         if self.client.charset != self.client.STORAGE_CHARSET:
1051             wfile = codecs.EncodedFile(wfile,
1052                 self.client.STORAGE_CHARSET, self.client.charset, 'replace')
1053
1054         writer = csv.writer(wfile)
1055         self.client._socket_op(writer.writerow, columns)
1056
1057         # and search
1058         for itemid in klass.filter(matches, filterspec, sort, group):
1059             row = []
1060             for name in columns:
1061                 # check permission to view this property on this item
1062                 if not self.hasPermission('View', itemid=itemid,
1063                         classname=request.classname, property=name):
1064                     raise exceptions.Unauthorised(self._(
1065                         'You do not have permission to view %(class)s'
1066                     ) % {'class': request.classname})
1067                 row.append(str(klass.get(itemid, name)))
1068             self.client._socket_op(writer.writerow, row)
1069
1070         return '\n'
1071
1072
1073 class Bridge(BaseAction):
1074     """Make roundup.actions.Action executable via CGI request.
1075
1076     Using this allows users to write actions executable from multiple frontends.
1077     CGI Form content is translated into a dictionary, which then is passed as
1078     argument to 'handle()'. XMLRPC requests have to pass this dictionary
1079     directly.
1080     """
1081
1082     def __init__(self, *args):
1083
1084         # As this constructor is callable from multiple frontends, each with
1085         # different Action interfaces, we have to look at the arguments to
1086         # figure out how to complete construction.
1087         if (len(args) == 1 and
1088             hasattr(args[0], '__class__') and
1089             args[0].__class__.__name__ == 'Client'):
1090             self.cgi = True
1091             self.execute = self.execute_cgi
1092             self.client = args[0]
1093             self.form = self.client.form
1094         else:
1095             self.cgi = False
1096
1097     def execute_cgi(self):
1098         args = {}
1099         for key in self.form:
1100             args[key] = self.form.getvalue(key)
1101         self.permission(args)
1102         return self.handle(args)
1103
1104     def permission(self, args):
1105         """Raise Unauthorised if the current user is not allowed to execute
1106         this action. Users may override this method."""
1107
1108         pass
1109
1110     def handle(self, args):
1111
1112         raise NotImplementedError
1113
1114 # vim: set filetype=python sts=4 sw=4 et si :