1 import re, cgi, StringIO, urllib, Cookie, time, random
3 from roundup import hyperdb, token, date, password, rcsv
4 from roundup.i18n import _
5 from roundup.cgi import templating
6 from roundup.cgi.exceptions import Redirect, Unauthorised
7 from roundup.mailgw import uidFromAddress
9 __all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
10 'EditCSVAction', 'EditItemAction', 'PassResetAction',
11 'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction']
13 # used by a couple of routines
14 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
16 class Action:
17 def __init__(self, client):
18 self.client = client
19 self.form = client.form
20 self.db = client.db
21 self.nodeid = client.nodeid
22 self.template = client.template
23 self.classname = client.classname
24 self.userid = client.userid
25 self.base = client.base
26 self.user = client.user
28 def handle(self):
29 """Execute the action specified by this object."""
30 raise NotImplementedError
32 def permission(self):
33 """Check whether the user has permission to execute this action.
35 True by default.
36 """
37 return 1
39 class ShowAction(Action):
40 def handle(self, typere=re.compile('[@:]type'),
41 numre=re.compile('[@:]number')):
42 """Show a node of a particular class/id."""
43 t = n = ''
44 for key in self.form.keys():
45 if typere.match(key):
46 t = self.form[key].value.strip()
47 elif numre.match(key):
48 n = self.form[key].value.strip()
49 if not t:
50 raise ValueError, 'Invalid %s number'%t
51 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
52 raise Redirect, url
54 class RetireAction(Action):
55 def handle(self):
56 """Retire the context item."""
57 # if we want to view the index template now, then unset the nodeid
58 # context info (a special-case for retire actions on the index page)
59 nodeid = self.nodeid
60 if self.template == 'index':
61 self.client.nodeid = None
63 # generic edit is per-class only
64 if not self.permission():
65 raise Unauthorised, _('You do not have permission to retire %s' %
66 self.classname)
68 # make sure we don't try to retire admin or anonymous
69 if self.classname == 'user' and \
70 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
71 raise ValueError, _('You may not retire the admin or anonymous user')
73 # do the retire
74 self.db.getclass(self.classname).retire(nodeid)
75 self.db.commit()
77 self.client.ok_message.append(
78 _('%(classname)s %(itemid)s has been retired')%{
79 'classname': self.classname.capitalize(), 'itemid': nodeid})
81 def permission(self):
82 """Determine whether the user has permission to retire this class.
84 Base behaviour is to check the user can edit this class.
85 """
86 return self.db.security.hasPermission('Edit', self.client.userid,
87 self.client.classname)
89 class SearchAction(Action):
90 def handle(self, wcre=re.compile(r'[\s,]+')):
91 """Mangle some of the form variables.
93 Set the form ":filter" variable based on the values of the filter
94 variables - if they're set to anything other than "dontcare" then add
95 them to :filter.
97 Handle the ":queryname" variable and save off the query to the user's
98 query list.
100 Split any String query values on whitespace and comma.
102 """
103 # generic edit is per-class only
104 if not self.permission():
105 raise Unauthorised, _('You do not have permission to search %s' %
106 self.classname)
108 self.fakeFilterVars()
109 queryname = self.getQueryName()
111 # handle saving the query params
112 if queryname:
113 # parse the environment and figure what the query _is_
114 req = templating.HTMLRequest(self.client)
116 # The [1:] strips off the '?' character, it isn't part of the
117 # query string.
118 url = req.indexargs_href('', {})[1:]
120 # handle editing an existing query
121 try:
122 qid = self.db.query.lookup(queryname)
123 self.db.query.set(qid, klass=self.classname, url=url)
124 except KeyError:
125 # create a query
126 qid = self.db.query.create(name=queryname,
127 klass=self.classname, url=url)
129 # and add it to the user's query multilink
130 queries = self.db.user.get(self.userid, 'queries')
131 queries.append(qid)
132 self.db.user.set(self.userid, queries=queries)
134 # commit the query change to the database
135 self.db.commit()
137 def fakeFilterVars(self):
138 """Add a faked :filter form variable for each filtering prop."""
139 props = self.db.classes[self.classname].getprops()
140 for key in self.form.keys():
141 if not props.has_key(key):
142 continue
143 if isinstance(self.form[key], type([])):
144 # search for at least one entry which is not empty
145 for minifield in self.form[key]:
146 if minifield.value:
147 break
148 else:
149 continue
150 else:
151 if not self.form[key].value:
152 continue
153 if isinstance(props[key], hyperdb.String):
154 v = self.form[key].value
155 l = token.token_split(v)
156 if len(l) > 1 or l[0] != v:
157 self.form.value.remove(self.form[key])
158 # replace the single value with the split list
159 for v in l:
160 self.form.value.append(cgi.MiniFieldStorage(key, v))
162 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
164 FV_QUERYNAME = re.compile(r'[@:]queryname')
165 def getQueryName(self):
166 for key in self.form.keys():
167 if self.FV_QUERYNAME.match(key):
168 return self.form[key].value.strip()
169 return ''
171 def permission(self):
172 return self.db.security.hasPermission('View', self.client.userid,
173 self.client.classname)
175 class EditCSVAction(Action):
176 def handle(self):
177 """Performs an edit of all of a class' items in one go.
179 The "rows" CGI var defines the CSV-formatted entries for the class. New
180 nodes are identified by the ID 'X' (or any other non-existent ID) and
181 removed lines are retired.
183 """
184 # this is per-class only
185 if not self.permission():
186 self.client.error_message.append(
187 _('You do not have permission to edit %s' %self.classname))
188 return
190 # get the CSV module
191 if rcsv.error:
192 self.client.error_message.append(_(rcsv.error))
193 return
195 cl = self.db.classes[self.classname]
196 idlessprops = cl.getprops(protected=0).keys()
197 idlessprops.sort()
198 props = ['id'] + idlessprops
200 # do the edit
201 rows = StringIO.StringIO(self.form['rows'].value)
202 reader = rcsv.reader(rows, rcsv.comma_separated)
203 found = {}
204 line = 0
205 for values in reader:
206 line += 1
207 if line == 1: continue
208 # skip property names header
209 if values == props:
210 continue
212 # extract the nodeid
213 nodeid, values = values[0], values[1:]
214 found[nodeid] = 1
216 # see if the node exists
217 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
218 exists = 0
219 else:
220 exists = 1
222 # confirm correct weight
223 if len(idlessprops) != len(values):
224 self.client.error_message.append(
225 _('Not enough values on line %(line)s')%{'line':line})
226 return
228 # extract the new values
229 d = {}
230 for name, value in zip(idlessprops, values):
231 prop = cl.properties[name]
232 value = value.strip()
233 # only add the property if it has a value
234 if value:
235 # if it's a multilink, split it
236 if isinstance(prop, hyperdb.Multilink):
237 value = value.split(':')
238 elif isinstance(prop, hyperdb.Password):
239 value = password.Password(value)
240 elif isinstance(prop, hyperdb.Interval):
241 value = date.Interval(value)
242 elif isinstance(prop, hyperdb.Date):
243 value = date.Date(value)
244 elif isinstance(prop, hyperdb.Boolean):
245 value = value.lower() in ('yes', 'true', 'on', '1')
246 elif isinstance(prop, hyperdb.Number):
247 value = float(value)
248 d[name] = value
249 elif exists:
250 # nuke the existing value
251 if isinstance(prop, hyperdb.Multilink):
252 d[name] = []
253 else:
254 d[name] = None
256 # perform the edit
257 if exists:
258 # edit existing
259 cl.set(nodeid, **d)
260 else:
261 # new node
262 found[cl.create(**d)] = 1
264 # retire the removed entries
265 for nodeid in cl.list():
266 if not found.has_key(nodeid):
267 cl.retire(nodeid)
269 # all OK
270 self.db.commit()
272 self.client.ok_message.append(_('Items edited OK'))
274 def permission(self):
275 return self.db.security.hasPermission('Edit', self.client.userid,
276 self.client.classname)
278 class EditItemAction(Action):
279 def handle(self):
280 """Perform an edit of an item in the database.
282 See parsePropsFromForm and _editnodes for special variables.
284 """
285 props, links = self.client.parsePropsFromForm()
287 # handle the props
288 try:
289 message = self._editnodes(props, links)
290 except (ValueError, KeyError, IndexError), message:
291 self.client.error_message.append(_('Apply Error: ') + str(message))
292 return
294 # commit now that all the tricky stuff is done
295 self.db.commit()
297 # redirect to the item's edit page
298 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
299 self.classname, self.client.nodeid,
300 urllib.quote(message),
301 urllib.quote(self.template))
303 def editItemPermission(self, props):
304 """Determine whether the user has permission to edit this item.
306 Base behaviour is to check the user can edit this class. If we're
307 editing the"user" class, users are allowed to edit their own details.
308 Unless it's the "roles" property, which requires the special Permission
309 "Web Roles".
310 """
311 # if this is a user node and the user is editing their own node, then
312 # we're OK
313 has = self.db.security.hasPermission
314 if self.classname == 'user':
315 # reject if someone's trying to edit "roles" and doesn't have the
316 # right permission.
317 if props.has_key('roles') and not has('Web Roles', self.userid,
318 'user'):
319 return 0
320 # if the item being edited is the current user, we're ok
321 if (self.nodeid == self.userid
322 and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
323 return 1
324 if self.db.security.hasPermission('Edit', self.userid, self.classname):
325 return 1
326 return 0
328 def newItemPermission(self, props):
329 """Determine whether the user has permission to create (edit) this item.
331 Base behaviour is to check the user can edit this class. No additional
332 property checks are made. Additionally, new user items may be created
333 if the user has the "Web Registration" Permission.
335 """
336 has = self.db.security.hasPermission
337 if self.classname == 'user' and has('Web Registration', self.userid,
338 'user'):
339 return 1
340 if has('Edit', self.userid, self.classname):
341 return 1
342 return 0
344 #
345 # Utility methods for editing
346 #
347 def _editnodes(self, all_props, all_links, newids=None):
348 ''' Use the props in all_props to perform edit and creation, then
349 use the link specs in all_links to do linking.
350 '''
351 # figure dependencies and re-work links
352 deps = {}
353 links = {}
354 for cn, nodeid, propname, vlist in all_links:
355 if not all_props.has_key((cn, nodeid)):
356 # link item to link to doesn't (and won't) exist
357 continue
358 for value in vlist:
359 if not all_props.has_key(value):
360 # link item to link to doesn't (and won't) exist
361 continue
362 deps.setdefault((cn, nodeid), []).append(value)
363 links.setdefault(value, []).append((cn, nodeid, propname))
365 # figure chained dependencies ordering
366 order = []
367 done = {}
368 # loop detection
369 change = 0
370 while len(all_props) != len(done):
371 for needed in all_props.keys():
372 if done.has_key(needed):
373 continue
374 tlist = deps.get(needed, [])
375 for target in tlist:
376 if not done.has_key(target):
377 break
378 else:
379 done[needed] = 1
380 order.append(needed)
381 change = 1
382 if not change:
383 raise ValueError, 'linking must not loop!'
385 # now, edit / create
386 m = []
387 for needed in order:
388 props = all_props[needed]
389 if not props:
390 # nothing to do
391 continue
392 cn, nodeid = needed
394 if nodeid is not None and int(nodeid) > 0:
395 # make changes to the node
396 props = self._changenode(cn, nodeid, props)
398 # and some nice feedback for the user
399 if props:
400 info = ', '.join(props.keys())
401 m.append('%s %s %s edited ok'%(cn, nodeid, info))
402 else:
403 m.append('%s %s - nothing changed'%(cn, nodeid))
404 else:
405 assert props
407 # make a new node
408 newid = self._createnode(cn, props)
409 if nodeid is None:
410 self.client.nodeid = newid
411 nodeid = newid
413 # and some nice feedback for the user
414 m.append('%s %s created'%(cn, newid))
416 # fill in new ids in links
417 if links.has_key(needed):
418 for linkcn, linkid, linkprop in links[needed]:
419 props = all_props[(linkcn, linkid)]
420 cl = self.db.classes[linkcn]
421 propdef = cl.getprops()[linkprop]
422 if not props.has_key(linkprop):
423 if linkid is None or linkid.startswith('-'):
424 # linking to a new item
425 if isinstance(propdef, hyperdb.Multilink):
426 props[linkprop] = [newid]
427 else:
428 props[linkprop] = newid
429 else:
430 # linking to an existing item
431 if isinstance(propdef, hyperdb.Multilink):
432 existing = cl.get(linkid, linkprop)[:]
433 existing.append(nodeid)
434 props[linkprop] = existing
435 else:
436 props[linkprop] = newid
438 return '<br>'.join(m)
440 def _changenode(self, cn, nodeid, props):
441 """Change the node based on the contents of the form."""
442 # check for permission
443 if not self.editItemPermission(props):
444 raise Unauthorised, 'You do not have permission to edit %s'%cn
446 # make the changes
447 cl = self.db.classes[cn]
448 return cl.set(nodeid, **props)
450 def _createnode(self, cn, props):
451 """Create a node based on the contents of the form."""
452 # check for permission
453 if not self.newItemPermission(props):
454 raise Unauthorised, 'You do not have permission to create %s'%cn
456 # create the node and return its id
457 cl = self.db.classes[cn]
458 return cl.create(**props)
460 class PassResetAction(Action):
461 def handle(self):
462 """Handle password reset requests.
464 Presence of either "name" or "address" generates email. Presence of
465 "otk" performs the reset.
467 """
468 if self.form.has_key('otk'):
469 # pull the rego information out of the otk database
470 otk = self.form['otk'].value
471 uid = self.db.otks.get(otk, 'uid')
472 if uid is None:
473 self.client.error_message.append("""Invalid One Time Key!
474 (a Mozilla bug may cause this message to show up erroneously,
475 please check your email)""")
476 return
478 # re-open the database as "admin"
479 if self.user != 'admin':
480 self.client.opendb('admin')
481 self.db = self.client.db
483 # change the password
484 newpw = password.generatePassword()
486 cl = self.db.user
487 # XXX we need to make the "default" page be able to display errors!
488 try:
489 # set the password
490 cl.set(uid, password=password.Password(newpw))
491 # clear the props from the otk database
492 self.db.otks.destroy(otk)
493 self.db.commit()
494 except (ValueError, KeyError), message:
495 self.client.error_message.append(str(message))
496 return
498 # user info
499 address = self.db.user.get(uid, 'address')
500 name = self.db.user.get(uid, 'username')
502 # send the email
503 tracker_name = self.db.config.TRACKER_NAME
504 subject = 'Password reset for %s'%tracker_name
505 body = '''
506 The password has been reset for username "%(name)s".
508 Your password is now: %(password)s
509 '''%{'name': name, 'password': newpw}
510 if not self.client.standard_message([address], subject, body):
511 return
513 self.client.ok_message.append('Password reset and email sent to %s' %
514 address)
515 return
517 # no OTK, so now figure the user
518 if self.form.has_key('username'):
519 name = self.form['username'].value
520 try:
521 uid = self.db.user.lookup(name)
522 except KeyError:
523 self.client.error_message.append('Unknown username')
524 return
525 address = self.db.user.get(uid, 'address')
526 elif self.form.has_key('address'):
527 address = self.form['address'].value
528 uid = uidFromAddress(self.db, ('', address), create=0)
529 if not uid:
530 self.client.error_message.append('Unknown email address')
531 return
532 name = self.db.user.get(uid, 'username')
533 else:
534 self.client.error_message.append('You need to specify a username '
535 'or address')
536 return
538 # generate the one-time-key and store the props for later
539 otk = ''.join([random.choice(chars) for x in range(32)])
540 self.db.otks.set(otk, uid=uid, __time=time.time())
542 # send the email
543 tracker_name = self.db.config.TRACKER_NAME
544 subject = 'Confirm reset of password for %s'%tracker_name
545 body = '''
546 Someone, perhaps you, has requested that the password be changed for your
547 username, "%(name)s". If you wish to proceed with the change, please follow
548 the link below:
550 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
552 You should then receive another email with the new password.
553 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
554 if not self.client.standard_message([address], subject, body):
555 return
557 self.client.ok_message.append('Email sent to %s'%address)
559 class ConfRegoAction(Action):
560 def handle(self):
561 """Grab the OTK, use it to load up the new user details."""
562 try:
563 # pull the rego information out of the otk database
564 self.userid = self.db.confirm_registration(self.form['otk'].value)
565 except (ValueError, KeyError), message:
566 # XXX: we need to make the "default" page be able to display errors!
567 self.client.error_message.append(str(message))
568 return
570 # log the new user in
571 self.client.user = self.db.user.get(self.userid, 'username')
572 # re-open the database for real, using the user
573 self.client.opendb(self.client.user)
574 self.db = client.db
576 # if we have a session, update it
577 if hasattr(self, 'session'):
578 self.db.sessions.set(self.session, user=self.user,
579 last_use=time.time())
580 else:
581 # new session cookie
582 self.client.set_cookie(self.user)
584 # nice message
585 message = _('You are now registered, welcome!')
587 # redirect to the user's page
588 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
589 self.userid, urllib.quote(message))
591 class RegisterAction(Action):
592 def handle(self):
593 """Attempt to create a new user based on the contents of the form
594 and then set the cookie.
596 Return 1 on successful login.
597 """
598 props = self.client.parsePropsFromForm()[0][('user', None)]
600 # make sure we're allowed to register
601 if not self.permission(props):
602 raise Unauthorised, _("You do not have permission to register")
604 try:
605 self.db.user.lookup(props['username'])
606 self.client.error_message.append('Error: A user with the username "%s" '
607 'already exists'%props['username'])
608 return
609 except KeyError:
610 pass
612 # generate the one-time-key and store the props for later
613 otk = ''.join([random.choice(chars) for x in range(32)])
614 for propname, proptype in self.db.user.getprops().items():
615 value = props.get(propname, None)
616 if value is None:
617 pass
618 elif isinstance(proptype, hyperdb.Date):
619 props[propname] = str(value)
620 elif isinstance(proptype, hyperdb.Interval):
621 props[propname] = str(value)
622 elif isinstance(proptype, hyperdb.Password):
623 props[propname] = str(value)
624 props['__time'] = time.time()
625 self.db.otks.set(otk, **props)
627 # send the email
628 tracker_name = self.db.config.TRACKER_NAME
629 tracker_email = self.db.config.TRACKER_EMAIL
630 subject = 'Complete your registration to %s -- key %s' % (tracker_name,
631 otk)
632 body = """To complete your registration of the user "%(name)s" with
633 %(tracker)s, please do one of the following:
635 - send a reply to %(tracker_email)s and maintain the subject line as is (the
636 reply's additional "Re:" is ok),
638 - or visit the following URL:
640 %(url)s?@action=confrego&otk=%(otk)s
641 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
642 'otk': otk, 'tracker_email': tracker_email}
643 if not self.client.standard_message([props['address']], subject, body,
644 tracker_email):
645 return
647 # commit changes to the database
648 self.db.commit()
650 # redirect to the "you're almost there" page
651 raise Redirect, '%suser?@template=rego_progress'%self.base
653 def permission(self, props):
654 """Determine whether the user has permission to register
656 Base behaviour is to check the user has "Web Registration".
658 """
659 # registration isn't allowed to supply roles
660 if props.has_key('roles'):
661 return 0
662 if self.db.security.hasPermission('Web Registration', self.userid):
663 return 1
664 return 0
666 class LogoutAction(Action):
667 def handle(self):
668 """Make us really anonymous - nuke the cookie too."""
669 # log us out
670 self.client.make_user_anonymous()
672 # construct the logout cookie
673 now = Cookie._getdate()
674 self.client.additional_headers['Set-Cookie'] = \
675 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.client.cookie_name,
676 now, self.client.cookie_path)
678 # Let the user know what's going on
679 self.client.ok_message.append(_('You are logged out'))
681 class LoginAction(Action):
682 def handle(self):
683 """Attempt to log a user in.
685 Sets up a session for the user which contains the login credentials.
687 """
688 # we need the username at a minimum
689 if not self.form.has_key('__login_name'):
690 self.client.error_message.append(_('Username required'))
691 return
693 # get the login info
694 self.client.user = self.form['__login_name'].value
695 if self.form.has_key('__login_password'):
696 password = self.form['__login_password'].value
697 else:
698 password = ''
700 # make sure the user exists
701 try:
702 self.client.userid = self.db.user.lookup(self.client.user)
703 except KeyError:
704 name = self.client.user
705 self.client.error_message.append(_('No such user "%(name)s"')%locals())
706 self.client.make_user_anonymous()
707 return
709 # verify the password
710 if not self.verifyPassword(self.client.userid, password):
711 self.client.make_user_anonymous()
712 self.client.error_message.append(_('Incorrect password'))
713 return
715 # make sure we're allowed to be here
716 if not self.permission():
717 self.client.make_user_anonymous()
718 self.client.error_message.append(_("You do not have permission to login"))
719 return
721 # now we're OK, re-open the database for real, using the user
722 self.client.opendb(self.client.user)
724 # set the session cookie
725 self.client.set_cookie(self.client.user)
727 def verifyPassword(self, userid, password):
728 ''' Verify the password that the user has supplied
729 '''
730 stored = self.db.user.get(self.client.userid, 'password')
731 if password == stored:
732 return 1
733 if not password and not stored:
734 return 1
735 return 0
737 def permission(self):
738 """Determine whether the user has permission to log in.
740 Base behaviour is to check the user has "Web Access".
742 """
743 if not self.db.security.hasPermission('Web Access', self.client.userid):
744 return 0
745 return 1