de4143d4fa9b1ff07d39f1a487ac3420768ff480
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',
12 'NewItemAction']
14 # used by a couple of routines
15 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
17 class Action:
18 def __init__(self, client):
19 self.client = client
20 self.form = client.form
21 self.db = client.db
22 self.nodeid = client.nodeid
23 self.template = client.template
24 self.classname = client.classname
25 self.userid = client.userid
26 self.base = client.base
27 self.user = client.user
29 def handle(self):
30 """Execute the action specified by this object."""
31 raise NotImplementedError
33 def permission(self):
34 """Check whether the user has permission to execute this action.
36 True by default.
37 """
38 return 1
40 class ShowAction(Action):
41 def handle(self, typere=re.compile('[@:]type'),
42 numre=re.compile('[@:]number')):
43 """Show a node of a particular class/id."""
44 t = n = ''
45 for key in self.form.keys():
46 if typere.match(key):
47 t = self.form[key].value.strip()
48 elif numre.match(key):
49 n = self.form[key].value.strip()
50 if not t:
51 raise ValueError, 'Invalid %s number'%t
52 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
53 raise Redirect, url
55 class RetireAction(Action):
56 def handle(self):
57 """Retire the context item."""
58 # if we want to view the index template now, then unset the nodeid
59 # context info (a special-case for retire actions on the index page)
60 nodeid = self.nodeid
61 if self.template == 'index':
62 self.client.nodeid = None
64 # generic edit is per-class only
65 if not self.permission():
66 raise Unauthorised, _('You do not have permission to retire %s' %
67 self.classname)
69 # make sure we don't try to retire admin or anonymous
70 if self.classname == 'user' and \
71 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
72 raise ValueError, _('You may not retire the admin or anonymous user')
74 # do the retire
75 self.db.getclass(self.classname).retire(nodeid)
76 self.db.commit()
78 self.client.ok_message.append(
79 _('%(classname)s %(itemid)s has been retired')%{
80 'classname': self.classname.capitalize(), 'itemid': nodeid})
82 def permission(self):
83 """Determine whether the user has permission to retire this class.
85 Base behaviour is to check the user can edit this class.
86 """
87 return self.db.security.hasPermission('Edit', self.client.userid,
88 self.client.classname)
90 class SearchAction(Action):
91 def handle(self, wcre=re.compile(r'[\s,]+')):
92 """Mangle some of the form variables.
94 Set the form ":filter" variable based on the values of the filter
95 variables - if they're set to anything other than "dontcare" then add
96 them to :filter.
98 Handle the ":queryname" variable and save off the query to the user's
99 query list.
101 Split any String query values on whitespace and comma.
103 """
104 # generic edit is per-class only
105 if not self.permission():
106 raise Unauthorised, _('You do not have permission to search %s' %
107 self.classname)
109 self.fakeFilterVars()
110 queryname = self.getQueryName()
112 # handle saving the query params
113 if queryname:
114 # parse the environment and figure what the query _is_
115 req = templating.HTMLRequest(self.client)
117 # The [1:] strips off the '?' character, it isn't part of the
118 # query string.
119 url = req.indexargs_href('', {})[1:]
121 # handle editing an existing query
122 try:
123 qid = self.db.query.lookup(queryname)
124 self.db.query.set(qid, klass=self.classname, url=url)
125 except KeyError:
126 # create a query
127 qid = self.db.query.create(name=queryname,
128 klass=self.classname, url=url)
130 # and add it to the user's query multilink
131 queries = self.db.user.get(self.userid, 'queries')
132 queries.append(qid)
133 self.db.user.set(self.userid, queries=queries)
135 # commit the query change to the database
136 self.db.commit()
138 def fakeFilterVars(self):
139 """Add a faked :filter form variable for each filtering prop."""
140 props = self.db.classes[self.classname].getprops()
141 for key in self.form.keys():
142 if not props.has_key(key):
143 continue
144 if isinstance(self.form[key], type([])):
145 # search for at least one entry which is not empty
146 for minifield in self.form[key]:
147 if minifield.value:
148 break
149 else:
150 continue
151 else:
152 if not self.form[key].value:
153 continue
154 if isinstance(props[key], hyperdb.String):
155 v = self.form[key].value
156 l = token.token_split(v)
157 if len(l) > 1 or l[0] != v:
158 self.form.value.remove(self.form[key])
159 # replace the single value with the split list
160 for v in l:
161 self.form.value.append(cgi.MiniFieldStorage(key, v))
163 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
165 FV_QUERYNAME = re.compile(r'[@:]queryname')
166 def getQueryName(self):
167 for key in self.form.keys():
168 if self.FV_QUERYNAME.match(key):
169 return self.form[key].value.strip()
170 return ''
172 def permission(self):
173 return self.db.security.hasPermission('View', self.client.userid,
174 self.client.classname)
176 class EditCSVAction(Action):
177 def handle(self):
178 """Performs an edit of all of a class' items in one go.
180 The "rows" CGI var defines the CSV-formatted entries for the class. New
181 nodes are identified by the ID 'X' (or any other non-existent ID) and
182 removed lines are retired.
184 """
185 # this is per-class only
186 if not self.permission():
187 self.client.error_message.append(
188 _('You do not have permission to edit %s' %self.classname))
189 return
191 # get the CSV module
192 if rcsv.error:
193 self.client.error_message.append(_(rcsv.error))
194 return
196 cl = self.db.classes[self.classname]
197 idlessprops = cl.getprops(protected=0).keys()
198 idlessprops.sort()
199 props = ['id'] + idlessprops
201 # do the edit
202 rows = StringIO.StringIO(self.form['rows'].value)
203 reader = rcsv.reader(rows, rcsv.comma_separated)
204 found = {}
205 line = 0
206 for values in reader:
207 line += 1
208 if line == 1: continue
209 # skip property names header
210 if values == props:
211 continue
213 # extract the nodeid
214 nodeid, values = values[0], values[1:]
215 found[nodeid] = 1
217 # see if the node exists
218 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
219 exists = 0
220 else:
221 exists = 1
223 # confirm correct weight
224 if len(idlessprops) != len(values):
225 self.client.error_message.append(
226 _('Not enough values on line %(line)s')%{'line':line})
227 return
229 # extract the new values
230 d = {}
231 for name, value in zip(idlessprops, values):
232 prop = cl.properties[name]
233 value = value.strip()
234 # only add the property if it has a value
235 if value:
236 # if it's a multilink, split it
237 if isinstance(prop, hyperdb.Multilink):
238 value = value.split(':')
239 elif isinstance(prop, hyperdb.Password):
240 value = password.Password(value)
241 elif isinstance(prop, hyperdb.Interval):
242 value = date.Interval(value)
243 elif isinstance(prop, hyperdb.Date):
244 value = date.Date(value)
245 elif isinstance(prop, hyperdb.Boolean):
246 value = value.lower() in ('yes', 'true', 'on', '1')
247 elif isinstance(prop, hyperdb.Number):
248 value = float(value)
249 d[name] = value
250 elif exists:
251 # nuke the existing value
252 if isinstance(prop, hyperdb.Multilink):
253 d[name] = []
254 else:
255 d[name] = None
257 # perform the edit
258 if exists:
259 # edit existing
260 cl.set(nodeid, **d)
261 else:
262 # new node
263 found[cl.create(**d)] = 1
265 # retire the removed entries
266 for nodeid in cl.list():
267 if not found.has_key(nodeid):
268 cl.retire(nodeid)
270 # all OK
271 self.db.commit()
273 self.client.ok_message.append(_('Items edited OK'))
275 def permission(self):
276 return self.db.security.hasPermission('Edit', self.client.userid,
277 self.client.classname)
279 class _EditAction(Action):
280 def editItemPermission(self, props):
281 """Determine whether the user has permission to edit this item.
283 Base behaviour is to check the user can edit this class. If we're
284 editing the"user" class, users are allowed to edit their own details.
285 Unless it's the "roles" property, which requires the special Permission
286 "Web Roles".
287 """
288 # if this is a user node and the user is editing their own node, then
289 # we're OK
290 has = self.db.security.hasPermission
291 if self.classname == 'user':
292 # reject if someone's trying to edit "roles" and doesn't have the
293 # right permission.
294 if props.has_key('roles') and not has('Web Roles', self.userid,
295 'user'):
296 return 0
297 # if the item being edited is the current user, we're ok
298 if (self.nodeid == self.userid
299 and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
300 return 1
301 if self.db.security.hasPermission('Edit', self.userid, self.classname):
302 return 1
303 return 0
305 def newItemPermission(self, props):
306 """Determine whether the user has permission to create (edit) this item.
308 Base behaviour is to check the user can edit this class. No additional
309 property checks are made. Additionally, new user items may be created
310 if the user has the "Web Registration" Permission.
312 """
313 has = self.db.security.hasPermission
314 if self.classname == 'user' and has('Web Registration', self.userid,
315 'user'):
316 return 1
317 if has('Edit', self.userid, self.classname):
318 return 1
319 return 0
321 #
322 # Utility methods for editing
323 #
324 def _editnodes(self, all_props, all_links, newids=None):
325 ''' Use the props in all_props to perform edit and creation, then
326 use the link specs in all_links to do linking.
327 '''
328 # figure dependencies and re-work links
329 deps = {}
330 links = {}
331 for cn, nodeid, propname, vlist in all_links:
332 if not all_props.has_key((cn, nodeid)):
333 # link item to link to doesn't (and won't) exist
334 continue
335 for value in vlist:
336 if not all_props.has_key(value):
337 # link item to link to doesn't (and won't) exist
338 continue
339 deps.setdefault((cn, nodeid), []).append(value)
340 links.setdefault(value, []).append((cn, nodeid, propname))
342 # figure chained dependencies ordering
343 order = []
344 done = {}
345 # loop detection
346 change = 0
347 while len(all_props) != len(done):
348 for needed in all_props.keys():
349 if done.has_key(needed):
350 continue
351 tlist = deps.get(needed, [])
352 for target in tlist:
353 if not done.has_key(target):
354 break
355 else:
356 done[needed] = 1
357 order.append(needed)
358 change = 1
359 if not change:
360 raise ValueError, 'linking must not loop!'
362 # now, edit / create
363 m = []
364 for needed in order:
365 props = all_props[needed]
366 if not props:
367 # nothing to do
368 continue
369 cn, nodeid = needed
371 if nodeid is not None and int(nodeid) > 0:
372 # make changes to the node
373 props = self._changenode(cn, nodeid, props)
375 # and some nice feedback for the user
376 if props:
377 info = ', '.join(props.keys())
378 m.append('%s %s %s edited ok'%(cn, nodeid, info))
379 else:
380 m.append('%s %s - nothing changed'%(cn, nodeid))
381 else:
382 assert props
384 # make a new node
385 newid = self._createnode(cn, props)
386 if nodeid is None:
387 self.nodeid = newid
388 nodeid = newid
390 # and some nice feedback for the user
391 m.append('%s %s created'%(cn, newid))
393 # fill in new ids in links
394 if links.has_key(needed):
395 for linkcn, linkid, linkprop in links[needed]:
396 props = all_props[(linkcn, linkid)]
397 cl = self.db.classes[linkcn]
398 propdef = cl.getprops()[linkprop]
399 if not props.has_key(linkprop):
400 if linkid is None or linkid.startswith('-'):
401 # linking to a new item
402 if isinstance(propdef, hyperdb.Multilink):
403 props[linkprop] = [newid]
404 else:
405 props[linkprop] = newid
406 else:
407 # linking to an existing item
408 if isinstance(propdef, hyperdb.Multilink):
409 existing = cl.get(linkid, linkprop)[:]
410 existing.append(nodeid)
411 props[linkprop] = existing
412 else:
413 props[linkprop] = newid
415 return '<br>'.join(m)
417 def _changenode(self, cn, nodeid, props):
418 """Change the node based on the contents of the form."""
419 # check for permission
420 if not self.editItemPermission(props):
421 raise Unauthorised, 'You do not have permission to edit %s'%cn
423 # make the changes
424 cl = self.db.classes[cn]
425 return cl.set(nodeid, **props)
427 def _createnode(self, cn, props):
428 """Create a node based on the contents of the form."""
429 # check for permission
430 if not self.newItemPermission(props):
431 raise Unauthorised, 'You do not have permission to create %s'%cn
433 # create the node and return its id
434 cl = self.db.classes[cn]
435 return cl.create(**props)
437 class EditItemAction(_EditAction):
438 def handle(self):
439 """Perform an edit of an item in the database.
441 See parsePropsFromForm and _editnodes for special variables.
443 """
444 props, links = self.client.parsePropsFromForm()
446 # handle the props
447 try:
448 message = self._editnodes(props, links)
449 except (ValueError, KeyError, IndexError), message:
450 self.client.error_message.append(_('Apply Error: ') + str(message))
451 return
453 # commit now that all the tricky stuff is done
454 self.db.commit()
456 # redirect to the item's edit page
457 # redirect to finish off
458 url = self.base + self.classname
459 # note that this action might have been called by an index page, so
460 # we will want to include index-page args in this URL too
461 if self.nodeid is not None:
462 url += self.nodeid
463 url += '?@ok_message=%s&@template=%s'%(urllib.quote(message),
464 urllib.quote(self.template))
465 if self.nodeid is None:
466 req = templating.HTMLRequest(self)
467 url += '&' + req.indexargs_href('', {})[1:]
468 raise Redirect, url
470 class NewItemAction(_EditAction):
471 def handle(self):
472 ''' Add a new item to the database.
474 This follows the same form as the EditItemAction, with the same
475 special form values.
476 '''
477 # parse the props from the form
478 try:
479 props, links = self.client.parsePropsFromForm(create=True)
480 except (ValueError, KeyError), message:
481 self.error_message.append(_('Error: ') + str(message))
482 return
484 # handle the props - edit or create
485 try:
486 # when it hits the None element, it'll set self.nodeid
487 messages = self._editnodes(props, links)
489 except (ValueError, KeyError, IndexError), message:
490 # these errors might just be indicative of user dumbness
491 self.error_message.append(_('Error: ') + str(message))
492 return
494 # commit now that all the tricky stuff is done
495 self.db.commit()
497 # redirect to the new item's page
498 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
499 self.classname, self.nodeid, urllib.quote(messages),
500 urllib.quote(self.template))
502 class PassResetAction(Action):
503 def handle(self):
504 """Handle password reset requests.
506 Presence of either "name" or "address" generates email. Presence of
507 "otk" performs the reset.
509 """
510 if self.form.has_key('otk'):
511 # pull the rego information out of the otk database
512 otk = self.form['otk'].value
513 uid = self.db.otks.get(otk, 'uid')
514 if uid is None:
515 self.client.error_message.append("""Invalid One Time Key!
516 (a Mozilla bug may cause this message to show up erroneously,
517 please check your email)""")
518 return
520 # re-open the database as "admin"
521 if self.user != 'admin':
522 self.client.opendb('admin')
523 self.db = self.client.db
525 # change the password
526 newpw = password.generatePassword()
528 cl = self.db.user
529 # XXX we need to make the "default" page be able to display errors!
530 try:
531 # set the password
532 cl.set(uid, password=password.Password(newpw))
533 # clear the props from the otk database
534 self.db.otks.destroy(otk)
535 self.db.commit()
536 except (ValueError, KeyError), message:
537 self.client.error_message.append(str(message))
538 return
540 # user info
541 address = self.db.user.get(uid, 'address')
542 name = self.db.user.get(uid, 'username')
544 # send the email
545 tracker_name = self.db.config.TRACKER_NAME
546 subject = 'Password reset for %s'%tracker_name
547 body = '''
548 The password has been reset for username "%(name)s".
550 Your password is now: %(password)s
551 '''%{'name': name, 'password': newpw}
552 if not self.client.standard_message([address], subject, body):
553 return
555 self.client.ok_message.append('Password reset and email sent to %s' %
556 address)
557 return
559 # no OTK, so now figure the user
560 if self.form.has_key('username'):
561 name = self.form['username'].value
562 try:
563 uid = self.db.user.lookup(name)
564 except KeyError:
565 self.client.error_message.append('Unknown username')
566 return
567 address = self.db.user.get(uid, 'address')
568 elif self.form.has_key('address'):
569 address = self.form['address'].value
570 uid = uidFromAddress(self.db, ('', address), create=0)
571 if not uid:
572 self.client.error_message.append('Unknown email address')
573 return
574 name = self.db.user.get(uid, 'username')
575 else:
576 self.client.error_message.append('You need to specify a username '
577 'or address')
578 return
580 # generate the one-time-key and store the props for later
581 otk = ''.join([random.choice(chars) for x in range(32)])
582 self.db.otks.set(otk, uid=uid, __time=time.time())
584 # send the email
585 tracker_name = self.db.config.TRACKER_NAME
586 subject = 'Confirm reset of password for %s'%tracker_name
587 body = '''
588 Someone, perhaps you, has requested that the password be changed for your
589 username, "%(name)s". If you wish to proceed with the change, please follow
590 the link below:
592 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
594 You should then receive another email with the new password.
595 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
596 if not self.client.standard_message([address], subject, body):
597 return
599 self.client.ok_message.append('Email sent to %s'%address)
601 class ConfRegoAction(Action):
602 def handle(self):
603 """Grab the OTK, use it to load up the new user details."""
604 try:
605 # pull the rego information out of the otk database
606 self.userid = self.db.confirm_registration(self.form['otk'].value)
607 except (ValueError, KeyError), message:
608 # XXX: we need to make the "default" page be able to display errors!
609 self.client.error_message.append(str(message))
610 return
612 # log the new user in
613 self.client.user = self.db.user.get(self.userid, 'username')
614 # re-open the database for real, using the user
615 self.client.opendb(self.client.user)
616 self.db = client.db
618 # if we have a session, update it
619 if hasattr(self, 'session'):
620 self.db.sessions.set(self.session, user=self.user,
621 last_use=time.time())
622 else:
623 # new session cookie
624 self.client.set_cookie(self.user)
626 # nice message
627 message = _('You are now registered, welcome!')
629 # redirect to the user's page
630 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
631 self.userid, urllib.quote(message))
633 class RegisterAction(Action):
634 def handle(self):
635 """Attempt to create a new user based on the contents of the form
636 and then set the cookie.
638 Return 1 on successful login.
639 """
640 props = self.client.parsePropsFromForm()[0][('user', None)]
642 # make sure we're allowed to register
643 if not self.permission(props):
644 raise Unauthorised, _("You do not have permission to register")
646 try:
647 self.db.user.lookup(props['username'])
648 self.client.error_message.append('Error: A user with the username "%s" '
649 'already exists'%props['username'])
650 return
651 except KeyError:
652 pass
654 # generate the one-time-key and store the props for later
655 otk = ''.join([random.choice(chars) for x in range(32)])
656 for propname, proptype in self.db.user.getprops().items():
657 value = props.get(propname, None)
658 if value is None:
659 pass
660 elif isinstance(proptype, hyperdb.Date):
661 props[propname] = str(value)
662 elif isinstance(proptype, hyperdb.Interval):
663 props[propname] = str(value)
664 elif isinstance(proptype, hyperdb.Password):
665 props[propname] = str(value)
666 props['__time'] = time.time()
667 self.db.otks.set(otk, **props)
669 # send the email
670 tracker_name = self.db.config.TRACKER_NAME
671 tracker_email = self.db.config.TRACKER_EMAIL
672 subject = 'Complete your registration to %s -- key %s' % (tracker_name,
673 otk)
674 body = """To complete your registration of the user "%(name)s" with
675 %(tracker)s, please do one of the following:
677 - send a reply to %(tracker_email)s and maintain the subject line as is (the
678 reply's additional "Re:" is ok),
680 - or visit the following URL:
682 %(url)s?@action=confrego&otk=%(otk)s
683 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
684 'otk': otk, 'tracker_email': tracker_email}
685 if not self.client.standard_message([props['address']], subject, body,
686 tracker_email):
687 return
689 # commit changes to the database
690 self.db.commit()
692 # redirect to the "you're almost there" page
693 raise Redirect, '%suser?@template=rego_progress'%self.base
695 def permission(self, props):
696 """Determine whether the user has permission to register
698 Base behaviour is to check the user has "Web Registration".
700 """
701 # registration isn't allowed to supply roles
702 if props.has_key('roles'):
703 return 0
704 if self.db.security.hasPermission('Web Registration', self.userid):
705 return 1
706 return 0
708 class LogoutAction(Action):
709 def handle(self):
710 """Make us really anonymous - nuke the cookie too."""
711 # log us out
712 self.client.make_user_anonymous()
714 # construct the logout cookie
715 now = Cookie._getdate()
716 self.client.additional_headers['Set-Cookie'] = \
717 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.client.cookie_name,
718 now, self.client.cookie_path)
720 # Let the user know what's going on
721 self.client.ok_message.append(_('You are logged out'))
723 class LoginAction(Action):
724 def handle(self):
725 """Attempt to log a user in.
727 Sets up a session for the user which contains the login credentials.
729 """
730 # we need the username at a minimum
731 if not self.form.has_key('__login_name'):
732 self.client.error_message.append(_('Username required'))
733 return
735 # get the login info
736 self.client.user = self.form['__login_name'].value
737 if self.form.has_key('__login_password'):
738 password = self.form['__login_password'].value
739 else:
740 password = ''
742 # make sure the user exists
743 try:
744 self.client.userid = self.db.user.lookup(self.client.user)
745 except KeyError:
746 name = self.client.user
747 self.client.error_message.append(_('No such user "%(name)s"')%locals())
748 self.client.make_user_anonymous()
749 return
751 # verify the password
752 if not self.verifyPassword(self.client.userid, password):
753 self.client.make_user_anonymous()
754 self.client.error_message.append(_('Incorrect password'))
755 return
757 # make sure we're allowed to be here
758 if not self.permission():
759 self.client.make_user_anonymous()
760 self.client.error_message.append(_("You do not have permission to login"))
761 return
763 # now we're OK, re-open the database for real, using the user
764 self.client.opendb(self.client.user)
766 # set the session cookie
767 self.client.set_cookie(self.client.user)
769 def verifyPassword(self, userid, password):
770 ''' Verify the password that the user has supplied
771 '''
772 stored = self.db.user.get(self.client.userid, 'password')
773 if password == stored:
774 return 1
775 if not password and not stored:
776 return 1
777 return 0
779 def permission(self):
780 """Determine whether the user has permission to log in.
782 Base behaviour is to check the user has "Web Access".
784 """
785 if not self.db.security.hasPermission('Web Access', self.client.userid):
786 return 0
787 return 1