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 lastUserActivity(self):
439 if self.form.has_key(':lastactivity'):
440 return date.Date(self.form[':lastactivity'].value)
441 elif self.form.has_key('@lastactivity'):
442 return date.Date(self.form['@lastactivity'].value)
443 else:
444 return None
446 def lastNodeActivity(self):
447 cl = getattr(self.client.db, self.classname)
448 return cl.get(self.nodeid, 'activity')
450 def detectCollision(self, userActivity, nodeActivity):
451 # Result from lastUserActivity may be None. If it is, assume there's no
452 # conflict, or at least not one we can detect.
453 if userActivity:
454 return userActivity < nodeActivity
456 def handleCollision(self):
457 self.client.template = 'collision'
459 def handle(self):
460 """Perform an edit of an item in the database.
462 See parsePropsFromForm and _editnodes for special variables.
464 """
465 if self.detectCollision(self.lastUserActivity(), self.lastNodeActivity()):
466 self.handleCollision()
467 return
469 props, links = self.client.parsePropsFromForm()
471 # handle the props
472 try:
473 message = self._editnodes(props, links)
474 except (ValueError, KeyError, IndexError), message:
475 self.client.error_message.append(_('Apply Error: ') + str(message))
476 return
478 # commit now that all the tricky stuff is done
479 self.db.commit()
481 # redirect to the item's edit page
482 # redirect to finish off
483 url = self.base + self.classname
484 # note that this action might have been called by an index page, so
485 # we will want to include index-page args in this URL too
486 if self.nodeid is not None:
487 url += self.nodeid
488 url += '?@ok_message=%s&@template=%s'%(urllib.quote(message),
489 urllib.quote(self.template))
490 if self.nodeid is None:
491 req = templating.HTMLRequest(self)
492 url += '&' + req.indexargs_href('', {})[1:]
493 raise Redirect, url
495 class NewItemAction(_EditAction):
496 def handle(self):
497 ''' Add a new item to the database.
499 This follows the same form as the EditItemAction, with the same
500 special form values.
501 '''
502 # parse the props from the form
503 try:
504 props, links = self.client.parsePropsFromForm(create=True)
505 except (ValueError, KeyError), message:
506 self.error_message.append(_('Error: ') + str(message))
507 return
509 # handle the props - edit or create
510 try:
511 # when it hits the None element, it'll set self.nodeid
512 messages = self._editnodes(props, links)
514 except (ValueError, KeyError, IndexError), message:
515 # these errors might just be indicative of user dumbness
516 self.error_message.append(_('Error: ') + str(message))
517 return
519 # commit now that all the tricky stuff is done
520 self.db.commit()
522 # redirect to the new item's page
523 raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
524 self.classname, self.nodeid, urllib.quote(messages),
525 urllib.quote(self.template))
527 class PassResetAction(Action):
528 def handle(self):
529 """Handle password reset requests.
531 Presence of either "name" or "address" generates email. Presence of
532 "otk" performs the reset.
534 """
535 if self.form.has_key('otk'):
536 # pull the rego information out of the otk database
537 otk = self.form['otk'].value
538 uid = self.db.otks.get(otk, 'uid')
539 if uid is None:
540 self.client.error_message.append("""Invalid One Time Key!
541 (a Mozilla bug may cause this message to show up erroneously,
542 please check your email)""")
543 return
545 # re-open the database as "admin"
546 if self.user != 'admin':
547 self.client.opendb('admin')
548 self.db = self.client.db
550 # change the password
551 newpw = password.generatePassword()
553 cl = self.db.user
554 # XXX we need to make the "default" page be able to display errors!
555 try:
556 # set the password
557 cl.set(uid, password=password.Password(newpw))
558 # clear the props from the otk database
559 self.db.otks.destroy(otk)
560 self.db.commit()
561 except (ValueError, KeyError), message:
562 self.client.error_message.append(str(message))
563 return
565 # user info
566 address = self.db.user.get(uid, 'address')
567 name = self.db.user.get(uid, 'username')
569 # send the email
570 tracker_name = self.db.config.TRACKER_NAME
571 subject = 'Password reset for %s'%tracker_name
572 body = '''
573 The password has been reset for username "%(name)s".
575 Your password is now: %(password)s
576 '''%{'name': name, 'password': newpw}
577 if not self.client.standard_message([address], subject, body):
578 return
580 self.client.ok_message.append('Password reset and email sent to %s' %
581 address)
582 return
584 # no OTK, so now figure the user
585 if self.form.has_key('username'):
586 name = self.form['username'].value
587 try:
588 uid = self.db.user.lookup(name)
589 except KeyError:
590 self.client.error_message.append('Unknown username')
591 return
592 address = self.db.user.get(uid, 'address')
593 elif self.form.has_key('address'):
594 address = self.form['address'].value
595 uid = uidFromAddress(self.db, ('', address), create=0)
596 if not uid:
597 self.client.error_message.append('Unknown email address')
598 return
599 name = self.db.user.get(uid, 'username')
600 else:
601 self.client.error_message.append('You need to specify a username '
602 'or address')
603 return
605 # generate the one-time-key and store the props for later
606 otk = ''.join([random.choice(chars) for x in range(32)])
607 self.db.otks.set(otk, uid=uid, __time=time.time())
609 # send the email
610 tracker_name = self.db.config.TRACKER_NAME
611 subject = 'Confirm reset of password for %s'%tracker_name
612 body = '''
613 Someone, perhaps you, has requested that the password be changed for your
614 username, "%(name)s". If you wish to proceed with the change, please follow
615 the link below:
617 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
619 You should then receive another email with the new password.
620 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
621 if not self.client.standard_message([address], subject, body):
622 return
624 self.client.ok_message.append('Email sent to %s'%address)
626 class ConfRegoAction(Action):
627 def handle(self):
628 """Grab the OTK, use it to load up the new user details."""
629 try:
630 # pull the rego information out of the otk database
631 self.userid = self.db.confirm_registration(self.form['otk'].value)
632 except (ValueError, KeyError), message:
633 # XXX: we need to make the "default" page be able to display errors!
634 self.client.error_message.append(str(message))
635 return
637 # log the new user in
638 self.client.user = self.db.user.get(self.userid, 'username')
639 # re-open the database for real, using the user
640 self.client.opendb(self.client.user)
641 self.db = client.db
643 # if we have a session, update it
644 if hasattr(self, 'session'):
645 self.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!')
654 # redirect to the user's page
655 raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
656 self.userid, urllib.quote(message))
658 class RegisterAction(Action):
659 def handle(self):
660 """Attempt to create a new user based on the contents of the form
661 and then set the cookie.
663 Return 1 on successful login.
664 """
665 props = self.client.parsePropsFromForm()[0][('user', None)]
667 # make sure we're allowed to register
668 if not self.permission(props):
669 raise Unauthorised, _("You do not have permission to register")
671 try:
672 self.db.user.lookup(props['username'])
673 self.client.error_message.append('Error: A user with the username "%s" '
674 'already exists'%props['username'])
675 return
676 except KeyError:
677 pass
679 # generate the one-time-key and store the props for later
680 otk = ''.join([random.choice(chars) for x in range(32)])
681 for propname, proptype in self.db.user.getprops().items():
682 value = props.get(propname, None)
683 if value is None:
684 pass
685 elif isinstance(proptype, hyperdb.Date):
686 props[propname] = str(value)
687 elif isinstance(proptype, hyperdb.Interval):
688 props[propname] = str(value)
689 elif isinstance(proptype, hyperdb.Password):
690 props[propname] = str(value)
691 props['__time'] = time.time()
692 self.db.otks.set(otk, **props)
694 # send the email
695 tracker_name = self.db.config.TRACKER_NAME
696 tracker_email = self.db.config.TRACKER_EMAIL
697 subject = 'Complete your registration to %s -- key %s' % (tracker_name,
698 otk)
699 body = """To complete your registration of the user "%(name)s" with
700 %(tracker)s, please do one of the following:
702 - send a reply to %(tracker_email)s and maintain the subject line as is (the
703 reply's additional "Re:" is ok),
705 - or visit the following URL:
707 %(url)s?@action=confrego&otk=%(otk)s
708 """ % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
709 'otk': otk, 'tracker_email': tracker_email}
710 if not self.client.standard_message([props['address']], subject, body,
711 tracker_email):
712 return
714 # commit changes to the database
715 self.db.commit()
717 # redirect to the "you're almost there" page
718 raise Redirect, '%suser?@template=rego_progress'%self.base
720 def permission(self, props):
721 """Determine whether the user has permission to register
723 Base behaviour is to check the user has "Web Registration".
725 """
726 # registration isn't allowed to supply roles
727 if props.has_key('roles'):
728 return 0
729 if self.db.security.hasPermission('Web Registration', self.userid):
730 return 1
731 return 0
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 # make sure we're allowed to be here
783 if not self.permission():
784 self.client.make_user_anonymous()
785 self.client.error_message.append(_("You do not have permission to login"))
786 return
788 # now we're OK, re-open the database for real, using the user
789 self.client.opendb(self.client.user)
791 # set the session cookie
792 self.client.set_cookie(self.client.user)
794 def verifyPassword(self, userid, password):
795 ''' Verify the password that the user has supplied
796 '''
797 stored = self.db.user.get(self.client.userid, 'password')
798 if password == stored:
799 return 1
800 if not password and not stored:
801 return 1
802 return 0
804 def permission(self):
805 """Determine whether the user has permission to log in.
807 Base behaviour is to check the user has "Web Access".
809 """
810 if not self.db.security.hasPermission('Web Access', self.client.userid):
811 return 0
812 return 1