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, SeriousError
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 execute(self):
30 """Execute the action specified by this object."""
31 self.permission()
32 self.handle()
34 name = ''
35 permissionType = None
36 def permission(self):
37 """Check whether the user has permission to execute this action.
39 True by default. If the permissionType attribute is a string containing
40 a simple permission, check whether the user has that permission.
41 Subclasses must also define the name attribute if they define
42 permissionType.
44 Despite having this permission, users may still be unauthorised to
45 perform parts of actions. It is up to the subclasses to detect this.
46 """
47 if (self.permissionType and
48 not self.hasPermission(self.permissionType)):
49 info = {'action': self.name, 'classname': self.classname}
50 raise Unauthorised, _('You do not have permission to '
51 '%(action)s the %(classname)s class.')%info
53 def hasPermission(self, permission):
54 """Check whether the user has 'permission' on the current class."""
55 return self.db.security.hasPermission(permission, self.client.userid,
56 self.client.classname)
58 class ShowAction(Action):
59 def handle(self, typere=re.compile('[@:]type'),
60 numre=re.compile('[@:]number')):
61 """Show a node of a particular class/id."""
62 t = n = ''
63 for key in self.form.keys():
64 if typere.match(key):
65 t = self.form[key].value.strip()
66 elif numre.match(key):
67 n = self.form[key].value.strip()
68 if not t:
69 raise ValueError, 'No type specified'
70 if not n:
71 raise SeriousError, _('No ID entered')
72 try:
73 int(n)
74 except ValueError:
75 d = {'input': n, 'classname': t}
76 raise SeriousError, _(
77 '"%(input)s" is not an ID (%(classname)s ID required)')%d
78 url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
79 raise Redirect, url
81 class RetireAction(Action):
82 name = 'retire'
83 permissionType = 'Edit'
85 def handle(self):
86 """Retire the context item."""
87 # if we want to view the index template now, then unset the nodeid
88 # context info (a special-case for retire actions on the index page)
89 nodeid = self.nodeid
90 if self.template == 'index':
91 self.client.nodeid = None
93 # make sure we don't try to retire admin or anonymous
94 if self.classname == 'user' and \
95 self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
96 raise ValueError, _('You may not retire the admin or anonymous user')
98 # do the retire
99 self.db.getclass(self.classname).retire(nodeid)
100 self.db.commit()
102 self.client.ok_message.append(
103 _('%(classname)s %(itemid)s has been retired')%{
104 'classname': self.classname.capitalize(), 'itemid': nodeid})
106 class SearchAction(Action):
107 name = 'search'
108 permissionType = 'View'
110 def handle(self, wcre=re.compile(r'[\s,]+')):
111 """Mangle some of the form variables.
113 Set the form ":filter" variable based on the values of the filter
114 variables - if they're set to anything other than "dontcare" then add
115 them to :filter.
117 Handle the ":queryname" variable and save off the query to the user's
118 query list.
120 Split any String query values on whitespace and comma.
122 """
123 self.fakeFilterVars()
124 queryname = self.getQueryName()
126 # handle saving the query params
127 if queryname:
128 # parse the environment and figure what the query _is_
129 req = templating.HTMLRequest(self.client)
131 # The [1:] strips off the '?' character, it isn't part of the
132 # query string.
133 url = req.indexargs_href('', {})[1:]
135 # handle editing an existing query
136 try:
137 qid = self.db.query.lookup(queryname)
138 self.db.query.set(qid, klass=self.classname, url=url)
139 except KeyError:
140 # create a query
141 qid = self.db.query.create(name=queryname,
142 klass=self.classname, url=url)
144 # and add it to the user's query multilink
145 queries = self.db.user.get(self.userid, 'queries')
146 if qid not in queries:
147 queries.append(qid)
148 self.db.user.set(self.userid, queries=queries)
150 # commit the query change to the database
151 self.db.commit()
153 def fakeFilterVars(self):
154 """Add a faked :filter form variable for each filtering prop."""
155 props = self.db.classes[self.classname].getprops()
156 for key in self.form.keys():
157 if not props.has_key(key):
158 continue
159 if isinstance(self.form[key], type([])):
160 # search for at least one entry which is not empty
161 for minifield in self.form[key]:
162 if minifield.value:
163 break
164 else:
165 continue
166 else:
167 if not self.form[key].value:
168 continue
169 if isinstance(props[key], hyperdb.String):
170 v = self.form[key].value
171 l = token.token_split(v)
172 if len(l) > 1 or l[0] != v:
173 self.form.value.remove(self.form[key])
174 # replace the single value with the split list
175 for v in l:
176 self.form.value.append(cgi.MiniFieldStorage(key, v))
178 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
180 FV_QUERYNAME = re.compile(r'[@:]queryname')
181 def getQueryName(self):
182 for key in self.form.keys():
183 if self.FV_QUERYNAME.match(key):
184 return self.form[key].value.strip()
185 return ''
187 class EditCSVAction(Action):
188 name = 'edit'
189 permissionType = 'Edit'
191 def handle(self):
192 """Performs an edit of all of a class' items in one go.
194 The "rows" CGI var defines the CSV-formatted entries for the class. New
195 nodes are identified by the ID 'X' (or any other non-existent ID) and
196 removed lines are retired.
198 """
199 # get the CSV module
200 if rcsv.error:
201 self.client.error_message.append(_(rcsv.error))
202 return
204 cl = self.db.classes[self.classname]
205 idlessprops = cl.getprops(protected=0).keys()
206 idlessprops.sort()
207 props = ['id'] + idlessprops
209 # do the edit
210 rows = StringIO.StringIO(self.form['rows'].value)
211 reader = rcsv.reader(rows, rcsv.comma_separated)
212 found = {}
213 line = 0
214 for values in reader:
215 line += 1
216 if line == 1: continue
217 # skip property names header
218 if values == props:
219 continue
221 # extract the nodeid
222 nodeid, values = values[0], values[1:]
223 found[nodeid] = 1
225 # see if the node exists
226 if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
227 exists = 0
228 else:
229 exists = 1
231 # confirm correct weight
232 if len(idlessprops) != len(values):
233 self.client.error_message.append(
234 _('Not enough values on line %(line)s')%{'line':line})
235 return
237 # extract the new values
238 d = {}
239 for name, value in zip(idlessprops, values):
240 prop = cl.properties[name]
241 value = value.strip()
242 # only add the property if it has a value
243 if value:
244 # if it's a multilink, split it
245 if isinstance(prop, hyperdb.Multilink):
246 value = value.split(':')
247 elif isinstance(prop, hyperdb.Password):
248 value = password.Password(value)
249 elif isinstance(prop, hyperdb.Interval):
250 value = date.Interval(value)
251 elif isinstance(prop, hyperdb.Date):
252 value = date.Date(value)
253 elif isinstance(prop, hyperdb.Boolean):
254 value = value.lower() in ('yes', 'true', 'on', '1')
255 elif isinstance(prop, hyperdb.Number):
256 value = float(value)
257 d[name] = value
258 elif exists:
259 # nuke the existing value
260 if isinstance(prop, hyperdb.Multilink):
261 d[name] = []
262 else:
263 d[name] = None
265 # perform the edit
266 if exists:
267 # edit existing
268 cl.set(nodeid, **d)
269 else:
270 # new node
271 found[cl.create(**d)] = 1
273 # retire the removed entries
274 for nodeid in cl.list():
275 if not found.has_key(nodeid):
276 cl.retire(nodeid)
278 # all OK
279 self.db.commit()
281 self.client.ok_message.append(_('Items edited OK'))
283 class _EditAction(Action):
284 def isEditingSelf(self):
285 """Check whether a user is editing his/her own details."""
286 return (self.nodeid == self.userid
287 and self.db.user.get(self.nodeid, 'username') != 'anonymous')
289 def editItemPermission(self, props):
290 """Determine whether the user has permission to edit this item.
292 Base behaviour is to check the user can edit this class. If we're
293 editing the "user" class, users are allowed to edit their own details.
294 Unless it's the "roles" property, which requires the special Permission
295 "Web Roles".
296 """
297 if self.classname == 'user':
298 if props.has_key('roles') and not self.hasPermission('Web Roles'):
299 raise Unauthorised, _("You do not have permission to edit user roles")
300 if self.isEditingSelf():
301 return 1
302 if self.hasPermission('Edit'):
303 return 1
304 return 0
306 def newItemPermission(self, props):
307 """Determine whether the user has permission to create (edit) this item.
309 Base behaviour is to check the user can edit this class. No additional
310 property checks are made. Additionally, new user items may be created
311 if the user has the "Web Registration" Permission.
313 """
314 if (self.classname == 'user' and self.hasPermission('Web Registration')
315 or self.hasPermission('Edit')):
316 return 1
317 return 0
319 #
320 # Utility methods for editing
321 #
322 def _editnodes(self, all_props, all_links, newids=None):
323 ''' Use the props in all_props to perform edit and creation, then
324 use the link specs in all_links to do linking.
325 '''
326 # figure dependencies and re-work links
327 deps = {}
328 links = {}
329 for cn, nodeid, propname, vlist in all_links:
330 if not all_props.has_key((cn, nodeid)):
331 # link item to link to doesn't (and won't) exist
332 continue
333 for value in vlist:
334 if not all_props.has_key(value):
335 # link item to link to doesn't (and won't) exist
336 continue
337 deps.setdefault((cn, nodeid), []).append(value)
338 links.setdefault(value, []).append((cn, nodeid, propname))
340 # figure chained dependencies ordering
341 order = []
342 done = {}
343 # loop detection
344 change = 0
345 while len(all_props) != len(done):
346 for needed in all_props.keys():
347 if done.has_key(needed):
348 continue
349 tlist = deps.get(needed, [])
350 for target in tlist:
351 if not done.has_key(target):
352 break
353 else:
354 done[needed] = 1
355 order.append(needed)
356 change = 1
357 if not change:
358 raise ValueError, 'linking must not loop!'
360 # now, edit / create
361 m = []
362 for needed in order:
363 props = all_props[needed]
364 if not props:
365 # nothing to do
366 continue
367 cn, nodeid = needed
369 if nodeid is not None and int(nodeid) > 0:
370 # make changes to the node
371 props = self._changenode(cn, nodeid, props)
373 # and some nice feedback for the user
374 if props:
375 info = ', '.join(props.keys())
376 m.append('%s %s %s edited ok'%(cn, nodeid, info))
377 else:
378 m.append('%s %s - nothing changed'%(cn, nodeid))
379 else:
380 assert props
382 # make a new node
383 newid = self._createnode(cn, props)
384 if nodeid is None:
385 self.nodeid = newid
386 nodeid = newid
388 # and some nice feedback for the user
389 m.append('%s %s created'%(cn, newid))
391 # fill in new ids in links
392 if links.has_key(needed):
393 for linkcn, linkid, linkprop in links[needed]:
394 props = all_props[(linkcn, linkid)]
395 cl = self.db.classes[linkcn]
396 propdef = cl.getprops()[linkprop]
397 if not props.has_key(linkprop):
398 if linkid is None or linkid.startswith('-'):
399 # linking to a new item
400 if isinstance(propdef, hyperdb.Multilink):
401 props[linkprop] = [newid]
402 else:
403 props[linkprop] = newid
404 else:
405 # linking to an existing item
406 if isinstance(propdef, hyperdb.Multilink):
407 existing = cl.get(linkid, linkprop)[:]
408 existing.append(nodeid)
409 props[linkprop] = existing
410 else:
411 props[linkprop] = newid
413 return '<br>'.join(m)
415 def _changenode(self, cn, nodeid, props):
416 """Change the node based on the contents of the form."""
417 # check for permission
418 if not self.editItemPermission(props):
419 raise Unauthorised, 'You do not have permission to edit %s'%cn
421 # make the changes
422 cl = self.db.classes[cn]
423 return cl.set(nodeid, **props)
425 def _createnode(self, cn, props):
426 """Create a node based on the contents of the form."""
427 # check for permission
428 if not self.newItemPermission(props):
429 raise Unauthorised, 'You do not have permission to create %s'%cn
431 # create the node and return its id
432 cl = self.db.classes[cn]
433 return cl.create(**props)
435 class EditItemAction(_EditAction):
436 def lastUserActivity(self):
437 if self.form.has_key(':lastactivity'):
438 return date.Date(self.form[':lastactivity'].value)
439 elif self.form.has_key('@lastactivity'):
440 return date.Date(self.form['@lastactivity'].value)
441 else:
442 return None
444 def lastNodeActivity(self):
445 cl = getattr(self.client.db, self.classname)
446 return cl.get(self.nodeid, 'activity')
448 def detectCollision(self, userActivity, nodeActivity):
449 # Result from lastUserActivity may be None. If it is, assume there's no
450 # conflict, or at least not one we can detect.
451 if userActivity:
452 return userActivity < nodeActivity
454 def handleCollision(self):
455 self.client.template = 'collision'
457 def handle(self):
458 """Perform an edit of an item in the database.
460 See parsePropsFromForm and _editnodes for special variables.
462 """
463 if self.detectCollision(self.lastUserActivity(), self.lastNodeActivity()):
464 self.handleCollision()
465 return
467 props, links = self.client.parsePropsFromForm()
469 # handle the props
470 try:
471 message = self._editnodes(props, links)
472 except (ValueError, KeyError, IndexError), message:
473 self.client.error_message.append(_('Apply Error: ') + str(message))
474 return
476 # commit now that all the tricky stuff is done
477 self.db.commit()
479 # redirect to the item's edit page
480 # redirect to finish off
481 url = self.base + self.classname
482 # note that this action might have been called by an index page, so
483 # we will want to include index-page args in this URL too
484 if self.nodeid is not None:
485 url += self.nodeid
486 url += '?@ok_message=%s&@template=%s'%(urllib.quote(message),
487 urllib.quote(self.template))
488 if self.nodeid is None:
489 req = templating.HTMLRequest(self)
490 url += '&' + req.indexargs_href('', {})[1:]
491 raise Redirect, url
493 class NewItemAction(_EditAction):
494 def handle(self):
495 ''' Add a new item to the database.
497 This follows the same form as the EditItemAction, with the same
498 special form values.
499 '''
500 # parse the props from the form
501 try:
502 props, links = self.client.parsePropsFromForm(create=1)
503 except (ValueError, KeyError), message:
504 self.client.error_message.append(_('Error: ') + str(message))
505 return
507 # handle the props - edit or create
508 try:
509 # when it hits the None element, it'll set self.nodeid
510 messages = self._editnodes(props, links)
512 except (ValueError, KeyError, IndexError), message:
513 # these errors might just be indicative of user dumbness
514 self.client.error_message.append(_('Error: ') + str(message))
515 return
517 # commit now that all the tricky stuff is done
518 self.db.commit()
520 # redirect to the new item's page
521 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
522 self.classname, self.nodeid, urllib.quote(messages),
523 urllib.quote(self.template))
525 class PassResetAction(Action):
526 def handle(self):
527 """Handle password reset requests.
529 Presence of either "name" or "address" generates email. Presence of
530 "otk" performs the reset.
532 """
533 if self.form.has_key('otk'):
534 # pull the rego information out of the otk database
535 otk = self.form['otk'].value
536 otks = self.db.getOTKManager()
537 uid = otks.get(otk, 'uid')
538 if uid is None:
539 self.client.error_message.append("""Invalid One Time Key!
540 (a Mozilla bug may cause this message to show up erroneously,
541 please check your email)""")
542 return
544 # re-open the database as "admin"
545 if self.user != 'admin':
546 self.client.opendb('admin')
547 self.db = self.client.db
549 # change the password
550 newpw = password.generatePassword()
552 cl = self.db.user
553 # XXX we need to make the "default" page be able to display errors!
554 try:
555 # set the password
556 cl.set(uid, password=password.Password(newpw))
557 # clear the props from the otk database
558 otks.destroy(otk)
559 self.db.commit()
560 except (ValueError, KeyError), message:
561 self.client.error_message.append(str(message))
562 return
564 # user info
565 address = self.db.user.get(uid, 'address')
566 name = self.db.user.get(uid, 'username')
568 # send the email
569 tracker_name = self.db.config.TRACKER_NAME
570 subject = 'Password reset for %s'%tracker_name
571 body = '''
572 The password has been reset for username "%(name)s".
574 Your password is now: %(password)s
575 '''%{'name': name, 'password': newpw}
576 if not self.client.standard_message([address], subject, body):
577 return
579 self.client.ok_message.append(
580 'Password reset and email sent to %s'%address)
581 return
583 # no OTK, so now figure the user
584 if self.form.has_key('username'):
585 name = self.form['username'].value
586 try:
587 uid = self.db.user.lookup(name)
588 except KeyError:
589 self.client.error_message.append('Unknown username')
590 return
591 address = self.db.user.get(uid, 'address')
592 elif self.form.has_key('address'):
593 address = self.form['address'].value
594 uid = uidFromAddress(self.db, ('', address), create=0)
595 if not uid:
596 self.client.error_message.append('Unknown email address')
597 return
598 name = self.db.user.get(uid, 'username')
599 else:
600 self.client.error_message.append('You need to specify a username '
601 'or address')
602 return
604 # generate the one-time-key and store the props for later
605 otk = ''.join([random.choice(chars) for x in range(32)])
606 while otks.exists(otk):
607 otk = ''.join([random.choice(chars) for x in range(32)])
608 otks.set(otk, uid=uid)
609 self.db.commit()
611 # send the email
612 tracker_name = self.db.config.TRACKER_NAME
613 subject = 'Confirm reset of password for %s'%tracker_name
614 body = '''
615 Someone, perhaps you, has requested that the password be changed for your
616 username, "%(name)s". If you wish to proceed with the change, please follow
617 the link below:
619 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
621 You should then receive another email with the new password.
622 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
623 if not self.client.standard_message([address], subject, body):
624 return
626 self.client.ok_message.append('Email sent to %s'%address)
628 class ConfRegoAction(Action):
629 def handle(self):
630 """Grab the OTK, use it to load up the new user details."""
631 try:
632 # pull the rego information out of the otk database
633 self.userid = self.db.confirm_registration(self.form['otk'].value)
634 except (ValueError, KeyError), message:
635 self.client.error_message.append(str(message))
636 return
638 # log the new user in
639 self.client.user = self.db.user.get(self.userid, 'username')
640 # re-open the database for real, using the user
641 self.client.opendb(self.client.user)
643 # if we have a session, update it
644 if hasattr(self, 'session'):
645 self.client.db.sessions.set(self.session, user=self.user,
646 last_use=time.time())
647 else:
648 # new session cookie
649 self.client.set_cookie(self.user)
651 # nice message
652 message = _('You are now registered, welcome!')
653 url = '%suser%s?@ok_message=%s'%(self.base, self.userid,
654 urllib.quote(message))
656 # redirect to the user's page (but not 302, as some email clients seem
657 # to want to reload the page, or something)
658 return '''<html><head><title>%s</title></head>
659 <body><p><a href="%s">%s</a></p>
660 <script type="text/javascript">
661 window.setTimeout('window.location = "%s"', 1000);
662 </script>'''%(message, url, message, url)
664 class RegisterAction(Action):
665 name = 'register'
666 permissionType = 'Web Registration'
668 def handle(self):
669 """Attempt to create a new user based on the contents of the form
670 and then set the cookie.
672 Return 1 on successful login.
673 """
674 props = self.client.parsePropsFromForm(create=1)[0][('user', None)]
676 # registration isn't allowed to supply roles
677 if props.has_key('roles'):
678 raise Unauthorised, _("It is not permitted to supply roles "
679 "at registration.")
681 username = props['username']
682 try:
683 self.db.user.lookup(username)
684 self.client.error_message.append(_('Error: A user with the '
685 'username "%(username)s" already exists')%props)
686 return
687 except KeyError:
688 pass
690 # generate the one-time-key and store the props for later
691 for propname, proptype in self.db.user.getprops().items():
692 value = props.get(propname, None)
693 if value is None:
694 pass
695 elif isinstance(proptype, hyperdb.Date):
696 props[propname] = str(value)
697 elif isinstance(proptype, hyperdb.Interval):
698 props[propname] = str(value)
699 elif isinstance(proptype, hyperdb.Password):
700 props[propname] = str(value)
701 otks = self.db.getOTKManager()
702 while otks.exists(otk):
703 otk = ''.join([random.choice(chars) for x in range(32)])
704 otks.set(otk, **props)
706 # send the email
707 tracker_name = self.db.config.TRACKER_NAME
708 tracker_email = self.db.config.TRACKER_EMAIL
709 subject = 'Complete your registration to %s -- key %s'%(tracker_name,
710 otk)
711 body = """To complete your registration of the user "%(name)s" with
712 %(tracker)s, please do one of the following:
714 - send a reply to %(tracker_email)s and maintain the subject line as is (the
715 reply's additional "Re:" is ok),
717 - or visit the following URL:
719 %(url)s?@action=confrego&otk=%(otk)s
721 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
722 'otk': otk, 'tracker_email': tracker_email}
723 if not self.client.standard_message([props['address']], subject, body,
724 tracker_email):
725 return
727 # commit changes to the database
728 self.db.commit()
730 # redirect to the "you're almost there" page
731 raise Redirect, '%suser?@template=rego_progress'%self.base
733 class LogoutAction(Action):
734 def handle(self):
735 """Make us really anonymous - nuke the cookie too."""
736 # log us out
737 self.client.make_user_anonymous()
739 # construct the logout cookie
740 now = Cookie._getdate()
741 self.client.additional_headers['Set-Cookie'] = \
742 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.client.cookie_name,
743 now, self.client.cookie_path)
745 # Let the user know what's going on
746 self.client.ok_message.append(_('You are logged out'))
748 class LoginAction(Action):
749 def handle(self):
750 """Attempt to log a user in.
752 Sets up a session for the user which contains the login credentials.
754 """
755 # we need the username at a minimum
756 if not self.form.has_key('__login_name'):
757 self.client.error_message.append(_('Username required'))
758 return
760 # get the login info
761 self.client.user = self.form['__login_name'].value
762 if self.form.has_key('__login_password'):
763 password = self.form['__login_password'].value
764 else:
765 password = ''
767 # make sure the user exists
768 try:
769 self.client.userid = self.db.user.lookup(self.client.user)
770 except KeyError:
771 name = self.client.user
772 self.client.error_message.append(_('No such user "%(name)s"')%locals())
773 self.client.make_user_anonymous()
774 return
776 # verify the password
777 if not self.verifyPassword(self.client.userid, password):
778 self.client.make_user_anonymous()
779 self.client.error_message.append(_('Incorrect password'))
780 return
782 # Determine whether the user has permission to log in.
783 # Base behaviour is to check the user has "Web Access".
784 if not self.hasPermission("Web Access"):
785 self.client.make_user_anonymous()
786 self.client.error_message.append(_("You do not have permission to login"))
787 return
789 # now we're OK, re-open the database for real, using the user
790 self.client.opendb(self.client.user)
792 # set the session cookie
793 self.client.set_cookie(self.client.user)
795 def verifyPassword(self, userid, password):
796 ''' Verify the password that the user has supplied
797 '''
798 stored = self.db.user.get(self.client.userid, 'password')
799 if password == stored:
800 return 1
801 if not password and not stored:
802 return 1
803 return 0