41f5979467dee8b5dceed43bdc0cd8298dd08fcf
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_.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
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)
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
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))
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 db.user.set(userid, password=password.Password(givenpw))
1017 db.commit()
1018 return 1
1019 if not givenpw and not stored:
1020 return 1
1021 return 0
1023 class ExportCSVAction(Action):
1024 name = 'export'
1025 permissionType = 'View'
1027 def handle(self):
1028 ''' Export the specified search query as CSV. '''
1029 # figure the request
1030 request = templating.HTMLRequest(self.client)
1031 filterspec = request.filterspec
1032 sort = request.sort
1033 group = request.group
1034 columns = request.columns
1035 klass = self.db.getclass(request.classname)
1037 # full-text search
1038 if request.search_text:
1039 matches = self.db.indexer.search(
1040 re.findall(r'\b\w{2,25}\b', request.search_text), klass)
1041 else:
1042 matches = None
1044 h = self.client.additional_headers
1045 h['Content-Type'] = 'text/csv; charset=%s' % self.client.charset
1046 # some browsers will honor the filename here...
1047 h['Content-Disposition'] = 'inline; filename=query.csv'
1049 self.client.header()
1051 if self.client.env['REQUEST_METHOD'] == 'HEAD':
1052 # all done, return a dummy string
1053 return 'dummy'
1055 wfile = self.client.request.wfile
1056 if self.client.charset != self.client.STORAGE_CHARSET:
1057 wfile = codecs.EncodedFile(wfile,
1058 self.client.STORAGE_CHARSET, self.client.charset, 'replace')
1060 writer = csv.writer(wfile)
1061 self.client._socket_op(writer.writerow, columns)
1063 # and search
1064 for itemid in klass.filter(matches, filterspec, sort, group):
1065 row = []
1066 for name in columns:
1067 # check permission to view this property on this item
1068 if not self.hasPermission('View', itemid=itemid,
1069 classname=request.classname, property=name):
1070 raise exceptions.Unauthorised(self._(
1071 'You do not have permission to view %(class)s'
1072 ) % {'class': request.classname})
1073 row.append(str(klass.get(itemid, name)))
1074 self.client._socket_op(writer.writerow, row)
1076 return '\n'
1079 class Bridge(BaseAction):
1080 """Make roundup.actions.Action executable via CGI request.
1082 Using this allows users to write actions executable from multiple frontends.
1083 CGI Form content is translated into a dictionary, which then is passed as
1084 argument to 'handle()'. XMLRPC requests have to pass this dictionary
1085 directly.
1086 """
1088 def __init__(self, *args):
1090 # As this constructor is callable from multiple frontends, each with
1091 # different Action interfaces, we have to look at the arguments to
1092 # figure out how to complete construction.
1093 if (len(args) == 1 and
1094 hasattr(args[0], '__class__') and
1095 args[0].__class__.__name__ == 'Client'):
1096 self.cgi = True
1097 self.execute = self.execute_cgi
1098 self.client = args[0]
1099 self.form = self.client.form
1100 else:
1101 self.cgi = False
1103 def execute_cgi(self):
1104 args = {}
1105 for key in self.form:
1106 args[key] = self.form.getvalue(key)
1107 self.permission(args)
1108 return self.handle(args)
1110 def permission(self, args):
1111 """Raise Unauthorised if the current user is not allowed to execute
1112 this action. Users may override this method."""
1114 pass
1116 def handle(self, args):
1118 raise NotImplementedError
1120 # vim: set filetype=python sts=4 sw=4 et si :