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