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=True)
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 uid = self.db.otks.get(otk, 'uid')
537 if uid is None:
538 self.client.error_message.append("""Invalid One Time Key!
539 (a Mozilla bug may cause this message to show up erroneously,
540 please check your email)""")
541 return
543 # re-open the database as "admin"
544 if self.user != 'admin':
545 self.client.opendb('admin')
546 self.db = self.client.db
548 # change the password
549 newpw = password.generatePassword()
551 cl = self.db.user
552 # XXX we need to make the "default" page be able to display errors!
553 try:
554 # set the password
555 cl.set(uid, password=password.Password(newpw))
556 # clear the props from the otk database
557 self.db.otks.destroy(otk)
558 self.db.commit()
559 except (ValueError, KeyError), message:
560 self.client.error_message.append(str(message))
561 return
563 # user info
564 address = self.db.user.get(uid, 'address')
565 name = self.db.user.get(uid, 'username')
567 # send the email
568 tracker_name = self.db.config.TRACKER_NAME
569 subject = 'Password reset for %s'%tracker_name
570 body = '''
571 The password has been reset for username "%(name)s".
573 Your password is now: %(password)s
574 '''%{'name': name, 'password': newpw}
575 if not self.client.standard_message([address], subject, body):
576 return
578 self.client.ok_message.append('Password reset and email sent to %s' %
579 address)
580 return
582 # no OTK, so now figure the user
583 if self.form.has_key('username'):
584 name = self.form['username'].value
585 try:
586 uid = self.db.user.lookup(name)
587 except KeyError:
588 self.client.error_message.append('Unknown username')
589 return
590 address = self.db.user.get(uid, 'address')
591 elif self.form.has_key('address'):
592 address = self.form['address'].value
593 uid = uidFromAddress(self.db, ('', address), create=0)
594 if not uid:
595 self.client.error_message.append('Unknown email address')
596 return
597 name = self.db.user.get(uid, 'username')
598 else:
599 self.client.error_message.append('You need to specify a username '
600 'or address')
601 return
603 # generate the one-time-key and store the props for later
604 otk = ''.join([random.choice(chars) for x in range(32)])
605 d = {'uid': uid, self.db.otks.timestamp: time.time()}
606 self.db.otks.set(otk, **d)
608 # send the email
609 tracker_name = self.db.config.TRACKER_NAME
610 subject = 'Confirm reset of password for %s'%tracker_name
611 body = '''
612 Someone, perhaps you, has requested that the password be changed for your
613 username, "%(name)s". If you wish to proceed with the change, please follow
614 the link below:
616 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
618 You should then receive another email with the new password.
619 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
620 if not self.client.standard_message([address], subject, body):
621 return
623 self.client.ok_message.append('Email sent to %s'%address)
625 class ConfRegoAction(Action):
626 def handle(self):
627 """Grab the OTK, use it to load up the new user details."""
628 try:
629 # pull the rego information out of the otk database
630 self.userid = self.db.confirm_registration(self.form['otk'].value)
631 except (ValueError, KeyError), message:
632 self.client.error_message.append(str(message))
633 return
635 # log the new user in
636 self.client.user = self.db.user.get(self.userid, 'username')
637 # re-open the database for real, using the user
638 self.client.opendb(self.client.user)
640 # if we have a session, update it
641 if hasattr(self, 'session'):
642 self.client.db.sessions.set(self.session, user=self.user,
643 last_use=time.time())
644 else:
645 # new session cookie
646 self.client.set_cookie(self.user)
648 # nice message
649 message = _('You are now registered, welcome!')
650 url = '%suser%s?@ok_message=%s'%(self.base, self.userid,
651 urllib.quote(message))
653 # redirect to the user's page (but not 302, as some email clients seem
654 # to want to reload the page, or something)
655 return '''<html><head><title>%s</title></head>
656 <body><p><a href="%s">%s</a></p>
657 <script type="text/javascript">
658 window.setTimeout('window.location = "%s"', 1000);
659 </script>'''%(message, url, message, url)
661 class RegisterAction(Action):
662 name = 'register'
663 permissionType = 'Web Registration'
665 def handle(self):
666 """Attempt to create a new user based on the contents of the form
667 and then set the cookie.
669 Return 1 on successful login.
670 """
671 props = self.client.parsePropsFromForm(create=True)[0][('user', None)]
673 # registration isn't allowed to supply roles
674 if props.has_key('roles'):
675 raise Unauthorised, _("It is not permitted to supply roles "
676 "at registration.")
678 username = props['username']
679 try:
680 self.db.user.lookup(username)
681 self.client.error_message.append(_('Error: A user with the '
682 'username "%(username)s" already exists')%props)
683 return
684 except KeyError:
685 pass
687 # generate the one-time-key and store the props for later
688 otk = ''.join([random.choice(chars) for x in range(32)])
689 for propname, proptype in self.db.user.getprops().items():
690 value = props.get(propname, None)
691 if value is None:
692 pass
693 elif isinstance(proptype, hyperdb.Date):
694 props[propname] = str(value)
695 elif isinstance(proptype, hyperdb.Interval):
696 props[propname] = str(value)
697 elif isinstance(proptype, hyperdb.Password):
698 props[propname] = str(value)
699 props[self.db.otks.timestamp] = time.time()
700 self.db.otks.set(otk, **props)
702 # send the email
703 tracker_name = self.db.config.TRACKER_NAME
704 tracker_email = self.db.config.TRACKER_EMAIL
705 subject = 'Complete your registration to %s -- key %s'%(tracker_name,
706 otk)
707 body = """To complete your registration of the user "%(name)s" with
708 %(tracker)s, please do one of the following:
710 - send a reply to %(tracker_email)s and maintain the subject line as is (the
711 reply's additional "Re:" is ok),
713 - or visit the following URL:
715 %(url)s?@action=confrego&otk=%(otk)s
716 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
717 'otk': otk, 'tracker_email': tracker_email}
718 if not self.client.standard_message([props['address']], subject, body,
719 tracker_email):
720 return
722 # commit changes to the database
723 self.db.commit()
725 # redirect to the "you're almost there" page
726 raise Redirect, '%suser?@template=rego_progress'%self.base
728 class LogoutAction(Action):
729 def handle(self):
730 """Make us really anonymous - nuke the cookie too."""
731 # log us out
732 self.client.make_user_anonymous()
734 # construct the logout cookie
735 now = Cookie._getdate()
736 self.client.additional_headers['Set-Cookie'] = \
737 '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.client.cookie_name,
738 now, self.client.cookie_path)
740 # Let the user know what's going on
741 self.client.ok_message.append(_('You are logged out'))
743 class LoginAction(Action):
744 def handle(self):
745 """Attempt to log a user in.
747 Sets up a session for the user which contains the login credentials.
749 """
750 # we need the username at a minimum
751 if not self.form.has_key('__login_name'):
752 self.client.error_message.append(_('Username required'))
753 return
755 # get the login info
756 self.client.user = self.form['__login_name'].value
757 if self.form.has_key('__login_password'):
758 password = self.form['__login_password'].value
759 else:
760 password = ''
762 # make sure the user exists
763 try:
764 self.client.userid = self.db.user.lookup(self.client.user)
765 except KeyError:
766 name = self.client.user
767 self.client.error_message.append(_('No such user "%(name)s"')%locals())
768 self.client.make_user_anonymous()
769 return
771 # verify the password
772 if not self.verifyPassword(self.client.userid, password):
773 self.client.make_user_anonymous()
774 self.client.error_message.append(_('Incorrect password'))
775 return
777 # Determine whether the user has permission to log in.
778 # Base behaviour is to check the user has "Web Access".
779 if not self.hasPermission("Web Access"):
780 self.client.make_user_anonymous()
781 self.client.error_message.append(_("You do not have permission to login"))
782 return
784 # now we're OK, re-open the database for real, using the user
785 self.client.opendb(self.client.user)
787 # set the session cookie
788 self.client.set_cookie(self.client.user)
790 def verifyPassword(self, userid, password):
791 ''' Verify the password that the user has supplied
792 '''
793 stored = self.db.user.get(self.client.userid, 'password')
794 if password == stored:
795 return 1
796 if not password and not stored:
797 return 1
798 return 0