Premiere version : mise en route du suivi.
[auf_roundup.git] / roundup / cgi / actions.py
1 import re, cgi, time, random, csv, codecs
2
3 from roundup import hyperdb, token, date, password
4 from roundup.actions import Action as BaseAction
5 from roundup.i18n import _
6 import roundup.exceptions
7 from roundup.cgi import exceptions, templating
8 from roundup.mailgw import uidFromAddress
9 from roundup.anypy import io_, urllib_
10
11 __all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
12 'EditCSVAction', 'EditItemAction', 'PassResetAction',
13 'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction',
14 'NewItemAction', 'ExportCSVAction']
15
16 # used by a couple of routines
17 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
18
19 class Action:
20 def __init__(self, client):
21 self.client = client
22 self.form = client.form
23 self.db = client.db
24 self.nodeid = client.nodeid
25 self.template = client.template
26 self.classname = client.classname
27 self.userid = client.userid
28 self.base = client.base
29 self.user = client.user
30 self.context = templating.context(client)
31
32 def handle(self):
33 """Action handler procedure"""
34 raise NotImplementedError
35
36 def execute(self):
37 """Execute the action specified by this object."""
38 self.permission()
39 return self.handle()
40
41 name = ''
42 permissionType = None
43 def permission(self):
44 """Check whether the user has permission to execute this action.
45
46 True by default. If the permissionType attribute is a string containing
47 a simple permission, check whether the user has that permission.
48 Subclasses must also define the name attribute if they define
49 permissionType.
50
51 Despite having this permission, users may still be unauthorised to
52 perform parts of actions. It is up to the subclasses to detect this.
53 """
54 if (self.permissionType and
55 not self.hasPermission(self.permissionType)):
56 info = {'action': self.name, 'classname': self.classname}
57 raise exceptions.Unauthorised(self._(
58 'You do not have permission to '
59 '%(action)s the %(classname)s class.')%info)
60
61 _marker = []
62 def hasPermission(self, permission, classname=_marker, itemid=None, property=None):
63 """Check whether the user has 'permission' on the current class."""
64 if classname is self._marker:
65 classname = self.client.classname
66 return self.db.security.hasPermission(permission, self.client.userid,
67 classname=classname, itemid=itemid, property=property)
68
69 def gettext(self, msgid):
70 """Return the localized translation of msgid"""
71 return self.client.translator.gettext(msgid)
72
73 _ = gettext
74
75 class ShowAction(Action):
76
77 typere=re.compile('[@:]type')
78 numre=re.compile('[@:]number')
79
80 def handle(self):
81 """Show a node of a particular class/id."""
82 t = n = ''
83 for key in self.form:
84 if self.typere.match(key):
85 t = self.form[key].value.strip()
86 elif self.numre.match(key):
87 n = self.form[key].value.strip()
88 if not t:
89 raise ValueError(self._('No type specified'))
90 if not n:
91 raise exceptions.SeriousError(self._('No ID entered'))
92 try:
93 int(n)
94 except ValueError:
95 d = {'input': n, 'classname': t}
96 raise exceptions.SeriousError(self._(
97 '"%(input)s" is not an ID (%(classname)s ID required)')%d)
98 url = '%s%s%s'%(self.base, t, n)
99 raise exceptions.Redirect(url)
100
101 class RetireAction(Action):
102 name = 'retire'
103 permissionType = 'Edit'
104
105 def handle(self):
106 """Retire the context item."""
107 # ensure modification comes via POST
108 if self.client.env['REQUEST_METHOD'] != 'POST':
109 raise roundup.exceptions.Reject(self._('Invalid request'))
110
111 # if we want to view the index template now, then unset the itemid
112 # context info (a special-case for retire actions on the index page)
113 itemid = self.nodeid
114 if self.template == 'index':
115 self.client.nodeid = None
116
117 # make sure we don't try to retire admin or anonymous
118 if self.classname == 'user' and \
119 self.db.user.get(itemid, 'username') in ('admin', 'anonymous'):
120 raise ValueError(self._(
121 'You may not retire the admin or anonymous user'))
122
123 # check permission
124 if not self.hasPermission('Retire', classname=self.classname,
125 itemid=itemid):
126 raise exceptions.Unauthorised(self._(
127 'You do not have permission to retire %(class)s'
128 ) % {'class': self.classname})
129
130 # do the retire
131 self.db.getclass(self.classname).retire(itemid)
132 self.db.commit()
133
134 self.client.ok_message.append(
135 self._('%(classname)s %(itemid)s has been retired')%{
136 'classname': self.classname.capitalize(), 'itemid': itemid})
137
138
139 class SearchAction(Action):
140 name = 'search'
141 permissionType = 'View'
142
143 def handle(self):
144 """Mangle some of the form variables.
145
146 Set the form ":filter" variable based on the values of the filter
147 variables - if they're set to anything other than "dontcare" then add
148 them to :filter.
149
150 Handle the ":queryname" variable and save off the query to the user's
151 query list.
152
153 Split any String query values on whitespace and comma.
154
155 """
156 self.fakeFilterVars()
157 queryname = self.getQueryName()
158
159 # editing existing query name?
160 old_queryname = self.getFromForm('old-queryname')
161
162 # handle saving the query params
163 if queryname:
164 # parse the environment and figure what the query _is_
165 req = templating.HTMLRequest(self.client)
166
167 url = self.getCurrentURL(req)
168
169 key = self.db.query.getkey()
170 if key:
171 # edit the old way, only one query per name
172 try:
173 qid = self.db.query.lookup(old_queryname)
174 if not self.hasPermission('Edit', 'query', itemid=qid):
175 raise exceptions.Unauthorised(self._(
176 "You do not have permission to edit queries"))
177 self.db.query.set(qid, klass=self.classname, url=url)
178 except KeyError:
179 # create a query
180 if not self.hasPermission('Create', 'query'):
181 raise exceptions.Unauthorised(self._(
182 "You do not have permission to store queries"))
183 qid = self.db.query.create(name=queryname,
184 klass=self.classname, url=url)
185 else:
186 # edit the new way, query name not a key any more
187 # see if we match an existing private query
188 uid = self.db.getuid()
189 qids = self.db.query.filter(None, {'name': old_queryname,
190 'private_for': uid})
191 if not qids:
192 # ok, so there's not a private query for the current user
193 # - see if there's one created by them
194 qids = self.db.query.filter(None, {'name': old_queryname,
195 'creator': uid})
196
197 if qids and old_queryname:
198 # edit query - make sure we get an exact match on the name
199 for qid in qids:
200 if old_queryname != self.db.query.get(qid, 'name'):
201 continue
202 if not self.hasPermission('Edit', 'query', itemid=qid):
203 raise exceptions.Unauthorised(self._(
204 "You do not have permission to edit queries"))
205 self.db.query.set(qid, klass=self.classname,
206 url=url, name=queryname)
207 else:
208 # create a query
209 if not self.hasPermission('Create', 'query'):
210 raise exceptions.Unauthorised(self._(
211 "You do not have permission to store queries"))
212 qid = self.db.query.create(name=queryname,
213 klass=self.classname, url=url, private_for=uid)
214
215 # and add it to the user's query multilink
216 queries = self.db.user.get(self.userid, 'queries')
217 if qid not in queries:
218 queries.append(qid)
219 self.db.user.set(self.userid, queries=queries)
220
221 # commit the query change to the database
222 self.db.commit()
223
224 def fakeFilterVars(self):
225 """Add a faked :filter form variable for each filtering prop."""
226 cls = self.db.classes[self.classname]
227 for key in self.form:
228 prop = cls.get_transitive_prop(key)
229 if not prop:
230 continue
231 if isinstance(self.form[key], type([])):
232 # search for at least one entry which is not empty
233 for minifield in self.form[key]:
234 if minifield.value:
235 break
236 else:
237 continue
238 else:
239 if not self.form[key].value:
240 continue
241 if isinstance(prop, hyperdb.String):
242 v = self.form[key].value
243 l = token.token_split(v)
244 if len(l) != 1 or l[0] != v:
245 self.form.value.remove(self.form[key])
246 # replace the single value with the split list
247 for v in l:
248 self.form.value.append(cgi.MiniFieldStorage(key, v))
249
250 self.form.value.append(cgi.MiniFieldStorage('@filter', key))
251
252 def getCurrentURL(self, req):
253 """Get current URL for storing as a query.
254
255 Note: We are removing the first character from the current URL,
256 because the leading '?' is not part of the query string.
257
258 Implementation note:
259 But maybe the template should be part of the stored query:
260 template = self.getFromForm('template')
261 if template:
262 return req.indexargs_url('', {'@template' : template})[1:]
263 """
264 return req.indexargs_url('', {})[1:]
265
266 def getFromForm(self, name):
267 for key in ('@' + name, ':' + name):
268 if key in self.form:
269 return self.form[key].value.strip()
270 return ''
271
272 def getQueryName(self):
273 return self.getFromForm('queryname')
274
275 class EditCSVAction(Action):
276 name = 'edit'
277 permissionType = 'Edit'
278
279 def handle(self):
280 """Performs an edit of all of a class' items in one go.
281
282 The "rows" CGI var defines the CSV-formatted entries for the class. New
283 nodes are identified by the ID 'X' (or any other non-existent ID) and
284 removed lines are retired.
285 """
286 # ensure modification comes via POST
287 if self.client.env['REQUEST_METHOD'] != 'POST':
288 raise roundup.exceptions.Reject(self._('Invalid request'))
289
290 # figure the properties list for the class
291 cl = self.db.classes[self.classname]
292 props_without_id = list(cl.getprops(protected=0))
293
294 # the incoming CSV data will always have the properties in colums
295 # sorted and starting with the "id" column
296 props_without_id.sort()
297 props = ['id'] + props_without_id
298
299 # do the edit
300 rows = io_.StringIO(self.form['rows'].value)
301 reader = csv.reader(rows)
302 found = {}
303 line = 0
304 for values in reader:
305 line += 1
306 if line == 1: continue
307 # skip property names header
308 if values == props:
309 continue
310
311 # extract the itemid
312 itemid, values = values[0], values[1:]
313 found[itemid] = 1
314
315 # see if the node exists
316 if itemid in ('x', 'X') or not cl.hasnode(itemid):
317 exists = 0
318
319 # check permission to create this item
320 if not self.hasPermission('Create', classname=self.classname):
321 raise exceptions.Unauthorised(self._(
322 'You do not have permission to create %(class)s'
323 ) % {'class': self.classname})
324 elif cl.hasnode(itemid) and cl.is_retired(itemid):
325 # If a CSV line just mentions an id and the corresponding
326 # item is retired, then the item is restored.
327 cl.restore(itemid)
328 continue
329 else:
330 exists = 1
331
332 # confirm correct weight
333 if len(props_without_id) != len(values):
334 self.client.error_message.append(
335 self._('Not enough values on line %(line)s')%{'line':line})
336 return
337
338 # extract the new values
339 d = {}
340 for name, value in zip(props_without_id, values):
341 # check permission to edit this property on this item
342 if exists and not self.hasPermission('Edit', itemid=itemid,
343 classname=self.classname, property=name):
344 raise exceptions.Unauthorised(self._(
345 'You do not have permission to edit %(class)s'
346 ) % {'class': self.classname})
347
348 prop = cl.properties[name]
349 value = value.strip()
350 # only add the property if it has a value
351 if value:
352 # if it's a multilink, split it
353 if isinstance(prop, hyperdb.Multilink):
354 value = value.split(':')
355 elif isinstance(prop, hyperdb.Password):
356 value = password.Password(value)
357 elif isinstance(prop, hyperdb.Interval):
358 value = date.Interval(value)
359 elif isinstance(prop, hyperdb.Date):
360 value = date.Date(value)
361 elif isinstance(prop, hyperdb.Boolean):
362 value = value.lower() in ('yes', 'true', 'on', '1')
363 elif isinstance(prop, hyperdb.Number):
364 value = float(value)
365 d[name] = value
366 elif exists:
367 # nuke the existing value
368 if isinstance(prop, hyperdb.Multilink):
369 d[name] = []
370 else:
371 d[name] = None
372
373 # perform the edit
374 if exists:
375 # edit existing
376 cl.set(itemid, **d)
377 else:
378 # new node
379 found[cl.create(**d)] = 1
380
381 # retire the removed entries
382 for itemid in cl.list():
383 if itemid not in found:
384 # check permission to retire this item
385 if not self.hasPermission('Retire', itemid=itemid,
386 classname=self.classname):
387 raise exceptions.Unauthorised(self._(
388 'You do not have permission to retire %(class)s'
389 ) % {'class': self.classname})
390 cl.retire(itemid)
391
392 # all OK
393 self.db.commit()
394
395 self.client.ok_message.append(self._('Items edited OK'))
396
397 class EditCommon(Action):
398 '''Utility methods for editing.'''
399
400 def _editnodes(self, all_props, all_links):
401 ''' Use the props in all_props to perform edit and creation, then
402 use the link specs in all_links to do linking.
403 '''
404 # figure dependencies and re-work links
405 deps = {}
406 links = {}
407 for cn, nodeid, propname, vlist in all_links:
408 numeric_id = int (nodeid or 0)
409 if not (numeric_id > 0 or (cn, nodeid) in all_props):
410 # link item to link to doesn't (and won't) exist
411 continue
412
413 for value in vlist:
414 if value not in all_props:
415 # link item to link to doesn't (and won't) exist
416 continue
417 deps.setdefault((cn, nodeid), []).append(value)
418 links.setdefault(value, []).append((cn, nodeid, propname))
419
420 # figure chained dependencies ordering
421 order = []
422 done = {}
423 # loop detection
424 change = 0
425 while len(all_props) != len(done):
426 for needed in all_props:
427 if needed in done:
428 continue
429 tlist = deps.get(needed, [])
430 for target in tlist:
431 if target not in done:
432 break
433 else:
434 done[needed] = 1
435 order.append(needed)
436 change = 1
437 if not change:
438 raise ValueError('linking must not loop!')
439
440 # now, edit / create
441 m = []
442 for needed in order:
443 props = all_props[needed]
444 cn, nodeid = needed
445 if props:
446 if nodeid is not None and int(nodeid) > 0:
447 # make changes to the node
448 props = self._changenode(cn, nodeid, props)
449
450 # and some nice feedback for the user
451 if props:
452 info = ', '.join(map(self._, props))
453 m.append(
454 self._('%(class)s %(id)s %(properties)s edited ok')
455 % {'class':cn, 'id':nodeid, 'properties':info})
456 else:
457 m.append(self._('%(class)s %(id)s - nothing changed')
458 % {'class':cn, 'id':nodeid})
459 else:
460 assert props
461
462 # make a new node
463 newid = self._createnode(cn, props)
464 if nodeid is None:
465 self.nodeid = newid
466 nodeid = newid
467
468 # and some nice feedback for the user
469 m.append(self._('%(class)s %(id)s created')
470 % {'class':cn, 'id':newid})
471
472 # fill in new ids in links
473 if needed in links:
474 for linkcn, linkid, linkprop in links[needed]:
475 props = all_props[(linkcn, linkid)]
476 cl = self.db.classes[linkcn]
477 propdef = cl.getprops()[linkprop]
478 if linkprop not in props:
479 if linkid is None or linkid.startswith('-'):
480 # linking to a new item
481 if isinstance(propdef, hyperdb.Multilink):
482 props[linkprop] = [nodeid]
483 else:
484 props[linkprop] = nodeid
485 else:
486 # linking to an existing item
487 if isinstance(propdef, hyperdb.Multilink):
488 existing = cl.get(linkid, linkprop)[:]
489 existing.append(nodeid)
490 props[linkprop] = existing
491 else:
492 props[linkprop] = nodeid
493
494 return '<br>'.join(m)
495
496 def _changenode(self, cn, nodeid, props):
497 """Change the node based on the contents of the form."""
498 # check for permission
499 if not self.editItemPermission(props, classname=cn, itemid=nodeid):
500 raise exceptions.Unauthorised(self._(
501 'You do not have permission to edit %(class)s'
502 ) % {'class': cn})
503
504 # make the changes
505 cl = self.db.classes[cn]
506 return cl.set(nodeid, **props)
507
508 def _createnode(self, cn, props):
509 """Create a node based on the contents of the form."""
510 # check for permission
511 if not self.newItemPermission(props, classname=cn):
512 raise exceptions.Unauthorised(self._(
513 'You do not have permission to create %(class)s'
514 ) % {'class': cn})
515
516 # create the node and return its id
517 cl = self.db.classes[cn]
518 return cl.create(**props)
519
520 def isEditingSelf(self):
521 """Check whether a user is editing his/her own details."""
522 return (self.nodeid == self.userid
523 and self.db.user.get(self.nodeid, 'username') != 'anonymous')
524
525 _cn_marker = []
526 def editItemPermission(self, props, classname=_cn_marker, itemid=None):
527 """Determine whether the user has permission to edit this item."""
528 if itemid is None:
529 itemid = self.nodeid
530 if classname is self._cn_marker:
531 classname = self.classname
532 # The user must have permission to edit each of the properties
533 # being changed.
534 for p in props:
535 if not self.hasPermission('Edit', itemid=itemid,
536 classname=classname, property=p):
537 return 0
538 # Since the user has permission to edit all of the properties,
539 # the edit is OK.
540 return 1
541
542 def newItemPermission(self, props, classname=None):
543 """Determine whether the user has permission to create this item.
544
545 Base behaviour is to check the user can edit this class. No additional
546 property checks are made.
547 """
548
549 if not classname :
550 classname = self.client.classname
551
552 if not self.hasPermission('Create', classname=classname):
553 return 0
554
555 # Check Create permission for each property, to avoid being able
556 # to set restricted ones on new item creation
557 for key in props:
558 if not self.hasPermission('Create', classname=classname,
559 property=key):
560 return 0
561 return 1
562
563 class EditItemAction(EditCommon):
564 def lastUserActivity(self):
565 if ':lastactivity' in self.form:
566 d = date.Date(self.form[':lastactivity'].value)
567 elif '@lastactivity' in self.form:
568 d = date.Date(self.form['@lastactivity'].value)
569 else:
570 return None
571 d.second = int(d.second)
572 return d
573
574 def lastNodeActivity(self):
575 cl = getattr(self.client.db, self.classname)
576 activity = cl.get(self.nodeid, 'activity').local(0)
577 activity.second = int(activity.second)
578 return activity
579
580 def detectCollision(self, user_activity, node_activity):
581 '''Check for a collision and return the list of props we edited
582 that conflict.'''
583 if user_activity and user_activity < node_activity:
584 props, links = self.client.parsePropsFromForm()
585 key = (self.classname, self.nodeid)
586 # we really only collide for direct prop edit conflicts
587 return list(props[key])
588 else:
589 return []
590
591 def handleCollision(self, props):
592 message = self._('Edit Error: someone else has edited this %s (%s). '
593 'View <a target="new" href="%s%s">their changes</a> '
594 'in a new window.')%(self.classname, ', '.join(props),
595 self.classname, self.nodeid)
596 self.client.error_message.append(message)
597 return
598
599 def handle(self):
600 """Perform an edit of an item in the database.
601
602 See parsePropsFromForm and _editnodes for special variables.
603
604 """
605 # ensure modification comes via POST
606 if self.client.env['REQUEST_METHOD'] != 'POST':
607 raise roundup.exceptions.Reject(self._('Invalid request'))
608
609 user_activity = self.lastUserActivity()
610 if user_activity:
611 props = self.detectCollision(user_activity, self.lastNodeActivity())
612 if props:
613 self.handleCollision(props)
614 return
615
616 props, links = self.client.parsePropsFromForm()
617
618 # handle the props
619 try:
620 message = self._editnodes(props, links)
621 except (ValueError, KeyError, IndexError,
622 roundup.exceptions.Reject), message:
623 self.client.error_message.append(
624 self._('Edit Error: %s') % str(message))
625 return
626
627 # commit now that all the tricky stuff is done
628 self.db.commit()
629
630 # redirect to the item's edit page
631 # redirect to finish off
632 url = self.base + self.classname
633 # note that this action might have been called by an index page, so
634 # we will want to include index-page args in this URL too
635 if self.nodeid is not None:
636 url += self.nodeid
637 url += '?@ok_message=%s&@template=%s'%(urllib_.quote(message),
638 urllib_.quote(self.template))
639 if self.nodeid is None:
640 req = templating.HTMLRequest(self.client)
641 url += '&' + req.indexargs_url('', {})[1:]
642 raise exceptions.Redirect(url)
643
644 class NewItemAction(EditCommon):
645 def handle(self):
646 ''' Add a new item to the database.
647
648 This follows the same form as the EditItemAction, with the same
649 special form values.
650 '''
651 # ensure modification comes via POST
652 if self.client.env['REQUEST_METHOD'] != 'POST':
653 raise roundup.exceptions.Reject(self._('Invalid request'))
654
655 # parse the props from the form
656 try:
657 props, links = self.client.parsePropsFromForm(create=1)
658 except (ValueError, KeyError), message:
659 self.client.error_message.append(self._('Error: %s')
660 % str(message))
661 return
662
663 # handle the props - edit or create
664 try:
665 # when it hits the None element, it'll set self.nodeid
666 messages = self._editnodes(props, links)
667 except (ValueError, KeyError, IndexError,
668 roundup.exceptions.Reject), message:
669 # these errors might just be indicative of user dumbness
670 self.client.error_message.append(_('Error: %s') % str(message))
671 return
672
673 # commit now that all the tricky stuff is done
674 self.db.commit()
675
676 # redirect to the new item's page
677 raise exceptions.Redirect('%s%s%s?@ok_message=%s&@template=%s' % (
678 self.base, self.classname, self.nodeid, urllib_.quote(messages),
679 urllib_.quote(self.template)))
680
681 class PassResetAction(Action):
682 def handle(self):
683 """Handle password reset requests.
684
685 Presence of either "name" or "address" generates email. Presence of
686 "otk" performs the reset.
687
688 """
689 otks = self.db.getOTKManager()
690 if 'otk' in self.form:
691 # pull the rego information out of the otk database
692 otk = self.form['otk'].value
693 uid = otks.get(otk, 'uid', default=None)
694 if uid is None:
695 self.client.error_message.append(
696 self._("Invalid One Time Key!\n"
697 "(a Mozilla bug may cause this message "
698 "to show up erroneously, please check your email)"))
699 return
700
701 # re-open the database as "admin"
702 if self.user != 'admin':
703 self.client.opendb('admin')
704 self.db = self.client.db
705 otks = self.db.getOTKManager()
706
707 # change the password
708 newpw = password.generatePassword()
709
710 cl = self.db.user
711 # XXX we need to make the "default" page be able to display errors!
712 try:
713 # set the password
714 cl.set(uid, password=password.Password(newpw))
715 # clear the props from the otk database
716 otks.destroy(otk)
717 self.db.commit()
718 except (ValueError, KeyError), message:
719 self.client.error_message.append(str(message))
720 return
721
722 # user info
723 address = self.db.user.get(uid, 'address')
724 name = self.db.user.get(uid, 'username')
725
726 # send the email
727 tracker_name = self.db.config.TRACKER_NAME
728 subject = 'Password reset for %s'%tracker_name
729 body = '''
730 The password has been reset for username "%(name)s".
731
732 Your password is now: %(password)s
733 '''%{'name': name, 'password': newpw}
734 if not self.client.standard_message([address], subject, body):
735 return
736
737 self.client.ok_message.append(
738 self._('Password reset and email sent to %s') % address)
739 return
740
741 # no OTK, so now figure the user
742 if 'username' in self.form:
743 name = self.form['username'].value
744 try:
745 uid = self.db.user.lookup(name)
746 except KeyError:
747 self.client.error_message.append(self._('Unknown username'))
748 return
749 address = self.db.user.get(uid, 'address')
750 elif 'address' in self.form:
751 address = self.form['address'].value
752 uid = uidFromAddress(self.db, ('', address), create=0)
753 if not uid:
754 self.client.error_message.append(
755 self._('Unknown email address'))
756 return
757 name = self.db.user.get(uid, 'username')
758 else:
759 self.client.error_message.append(
760 self._('You need to specify a username or address'))
761 return
762
763 # generate the one-time-key and store the props for later
764 otk = ''.join([random.choice(chars) for x in range(32)])
765 while otks.exists(otk):
766 otk = ''.join([random.choice(chars) for x in range(32)])
767 otks.set(otk, uid=uid)
768 self.db.commit()
769
770 # send the email
771 tracker_name = self.db.config.TRACKER_NAME
772 subject = 'Confirm reset of password for %s'%tracker_name
773 body = '''
774 Someone, perhaps you, has requested that the password be changed for your
775 username, "%(name)s". If you wish to proceed with the change, please follow
776 the link below:
777
778 %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
779
780 You should then receive another email with the new password.
781 '''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
782 if not self.client.standard_message([address], subject, body):
783 return
784
785 self.client.ok_message.append(self._('Email sent to %s') % address)
786
787 class RegoCommon(Action):
788 def finishRego(self):
789 # log the new user in
790 self.client.userid = self.userid
791 user = self.client.user = self.db.user.get(self.userid, 'username')
792 # re-open the database for real, using the user
793 self.client.opendb(user)
794
795 # update session data
796 self.client.session_api.set(user=user)
797
798 # nice message
799 message = self._('You are now registered, welcome!')
800 url = '%suser%s?@ok_message=%s'%(self.base, self.userid,
801 urllib._quote(message))
802
803 # redirect to the user's page (but not 302, as some email clients seem
804 # to want to reload the page, or something)
805 return '''<html><head><title>%s</title></head>
806 <body><p><a href="%s">%s</a></p>
807 <script type="text/javascript">
808 window.setTimeout('window.location = "%s"', 1000);
809 </script>'''%(message, url, message, url)
810
811 class ConfRegoAction(RegoCommon):
812 def handle(self):
813 """Grab the OTK, use it to load up the new user details."""
814 try:
815 # pull the rego information out of the otk database
816 self.userid = self.db.confirm_registration(self.form['otk'].value)
817 except (ValueError, KeyError), message:
818 self.client.error_message.append(str(message))
819 return
820 return self.finishRego()
821
822 class RegisterAction(RegoCommon, EditCommon):
823 name = 'register'
824 permissionType = 'Register'
825
826 def handle(self):
827 """Attempt to create a new user based on the contents of the form
828 and then remember it in session.
829
830 Return 1 on successful login.
831 """
832 # ensure modification comes via POST
833 if self.client.env['REQUEST_METHOD'] != 'POST':
834 raise roundup.exceptions.Reject(self._('Invalid request'))
835
836 # parse the props from the form
837 try:
838 props, links = self.client.parsePropsFromForm(create=1)
839 except (ValueError, KeyError), message:
840 self.client.error_message.append(self._('Error: %s')
841 % str(message))
842 return
843
844 # skip the confirmation step?
845 if self.db.config['INSTANT_REGISTRATION']:
846 # handle the create now
847 try:
848 # when it hits the None element, it'll set self.nodeid
849 messages = self._editnodes(props, links)
850 except (ValueError, KeyError, IndexError,
851 roundup.exceptions.Reject), message:
852 # these errors might just be indicative of user dumbness
853 self.client.error_message.append(_('Error: %s') % str(message))
854 return
855
856 # fix up the initial roles
857 self.db.user.set(self.nodeid,
858 roles=self.db.config['NEW_WEB_USER_ROLES'])
859
860 # commit now that all the tricky stuff is done
861 self.db.commit()
862
863 # finish off by logging the user in
864 self.userid = self.nodeid
865 return self.finishRego()
866
867 # generate the one-time-key and store the props for later
868 user_props = props[('user', None)]
869 for propname, proptype in self.db.user.getprops().iteritems():
870 value = user_props.get(propname, None)
871 if value is None:
872 pass
873 elif isinstance(proptype, hyperdb.Date):
874 user_props[propname] = str(value)
875 elif isinstance(proptype, hyperdb.Interval):
876 user_props[propname] = str(value)
877 elif isinstance(proptype, hyperdb.Password):
878 user_props[propname] = str(value)
879 otks = self.db.getOTKManager()
880 otk = ''.join([random.choice(chars) for x in range(32)])
881 while otks.exists(otk):
882 otk = ''.join([random.choice(chars) for x in range(32)])
883 otks.set(otk, **user_props)
884
885 # send the email
886 tracker_name = self.db.config.TRACKER_NAME
887 tracker_email = self.db.config.TRACKER_EMAIL
888 if self.db.config['EMAIL_REGISTRATION_CONFIRMATION']:
889 subject = 'Complete your registration to %s -- key %s'%(tracker_name,
890 otk)
891 body = """To complete your registration of the user "%(name)s" with
892 %(tracker)s, please do one of the following:
893
894 - send a reply to %(tracker_email)s and maintain the subject line as is (the
895 reply's additional "Re:" is ok),
896
897 - or visit the following URL:
898
899 %(url)s?@action=confrego&otk=%(otk)s
900
901 """ % {'name': user_props['username'], 'tracker': tracker_name,
902 'url': self.base, 'otk': otk, 'tracker_email': tracker_email}
903 else:
904 subject = 'Complete your registration to %s'%(tracker_name)
905 body = """To complete your registration of the user "%(name)s" with
906 %(tracker)s, please visit the following URL:
907
908 %(url)s?@action=confrego&otk=%(otk)s
909
910 """ % {'name': user_props['username'], 'tracker': tracker_name,
911 'url': self.base, 'otk': otk}
912 if not self.client.standard_message([user_props['address']], subject,
913 body, (tracker_name, tracker_email)):
914 return
915
916 # commit changes to the database
917 self.db.commit()
918
919 # redirect to the "you're almost there" page
920 raise exceptions.Redirect('%suser?@template=rego_progress'%self.base)
921
922 def newItemPermission(self, props, classname=None):
923 """Just check the "Register" permission.
924 """
925 # registration isn't allowed to supply roles
926 if 'roles' in props:
927 raise exceptions.Unauthorised(self._(
928 "It is not permitted to supply roles at registration."))
929
930 # technically already checked, but here for clarity
931 return self.hasPermission('Register', classname=classname)
932
933 class LogoutAction(Action):
934 def handle(self):
935 """Make us really anonymous - nuke the session too."""
936 # log us out
937 self.client.make_user_anonymous()
938 self.client.session_api.destroy()
939
940 # Let the user know what's going on
941 self.client.ok_message.append(self._('You are logged out'))
942
943 # reset client context to render tracker home page
944 # instead of last viewed page (may be inaccessibe for anonymous)
945 self.client.classname = None
946 self.client.nodeid = None
947 self.client.template = None
948
949 class LoginAction(Action):
950 def handle(self):
951 """Attempt to log a user in.
952
953 Sets up a session for the user which contains the login credentials.
954
955 """
956 # ensure modification comes via POST
957 if self.client.env['REQUEST_METHOD'] != 'POST':
958 raise roundup.exceptions.Reject(self._('Invalid request'))
959
960 # we need the username at a minimum
961 if '__login_name' not in self.form:
962 self.client.error_message.append(self._('Username required'))
963 return
964
965 # get the login info
966 self.client.user = self.form['__login_name'].value
967 if '__login_password' in self.form:
968 password = self.form['__login_password'].value
969 else:
970 password = ''
971
972 try:
973 self.verifyLogin(self.client.user, password)
974 except exceptions.LoginError, err:
975 self.client.make_user_anonymous()
976 self.client.error_message.extend(list(err.args))
977 return
978
979 # now we're OK, re-open the database for real, using the user
980 self.client.opendb(self.client.user)
981
982 # save user in session
983 self.client.session_api.set(user=self.client.user)
984 if 'remember' in self.form:
985 self.client.session_api.update(set_cookie=True, expire=24*3600*365)
986
987 # If we came from someplace, go back there
988 if '__came_from' in self.form:
989 raise exceptions.Redirect(self.form['__came_from'].value)
990
991 def verifyLogin(self, username, password):
992 # make sure the user exists
993 try:
994 self.client.userid = self.db.user.lookup(username)
995 except KeyError:
996 raise exceptions.LoginError(self._('Invalid login'))
997
998 # verify the password
999 if not self.verifyPassword(self.client.userid, password):
1000 raise exceptions.LoginError(self._('Invalid login'))
1001
1002 # Determine whether the user has permission to log in.
1003 # Base behaviour is to check the user has "Web Access".
1004 if not self.hasPermission("Web Access"):
1005 raise exceptions.LoginError(self._(
1006 "You do not have permission to login"))
1007
1008 def verifyPassword(self, userid, password):
1009 '''Verify the password that the user has supplied'''
1010 stored = self.db.user.get(userid, 'password')
1011 if password == stored:
1012 return 1
1013 if not password and not stored:
1014 return 1
1015 return 0
1016
1017 class ExportCSVAction(Action):
1018 name = 'export'
1019 permissionType = 'View'
1020
1021 def handle(self):
1022 ''' Export the specified search query as CSV. '''
1023 # figure the request
1024 request = templating.HTMLRequest(self.client)
1025 filterspec = request.filterspec
1026 sort = request.sort
1027 group = request.group
1028 columns = request.columns
1029 klass = self.db.getclass(request.classname)
1030
1031 # full-text search
1032 if request.search_text:
1033 matches = self.db.indexer.search(
1034 re.findall(r'\b\w{2,25}\b', request.search_text), klass)
1035 else:
1036 matches = None
1037
1038 h = self.client.additional_headers
1039 h['Content-Type'] = 'text/csv; charset=%s' % self.client.charset
1040 # some browsers will honor the filename here...
1041 h['Content-Disposition'] = 'inline; filename=query.csv'
1042
1043 self.client.header()
1044
1045 if self.client.env['REQUEST_METHOD'] == 'HEAD':
1046 # all done, return a dummy string
1047 return 'dummy'
1048
1049 wfile = self.client.request.wfile
1050 if self.client.charset != self.client.STORAGE_CHARSET:
1051 wfile = codecs.EncodedFile(wfile,
1052 self.client.STORAGE_CHARSET, self.client.charset, 'replace')
1053
1054 writer = csv.writer(wfile)
1055 self.client._socket_op(writer.writerow, columns)
1056
1057 # and search
1058 for itemid in klass.filter(matches, filterspec, sort, group):
1059 row = []
1060 for name in columns:
1061 # check permission to view this property on this item
1062 if not self.hasPermission('View', itemid=itemid,
1063 classname=request.classname, property=name):
1064 raise exceptions.Unauthorised(self._(
1065 'You do not have permission to view %(class)s'
1066 ) % {'class': request.classname})
1067 row.append(str(klass.get(itemid, name)))
1068 self.client._socket_op(writer.writerow, row)
1069
1070 return '\n'
1071
1072
1073 class Bridge(BaseAction):
1074 """Make roundup.actions.Action executable via CGI request.
1075
1076 Using this allows users to write actions executable from multiple frontends.
1077 CGI Form content is translated into a dictionary, which then is passed as
1078 argument to 'handle()'. XMLRPC requests have to pass this dictionary
1079 directly.
1080 """
1081
1082 def __init__(self, *args):
1083
1084 # As this constructor is callable from multiple frontends, each with
1085 # different Action interfaces, we have to look at the arguments to
1086 # figure out how to complete construction.
1087 if (len(args) == 1 and
1088 hasattr(args[0], '__class__') and
1089 args[0].__class__.__name__ == 'Client'):
1090 self.cgi = True
1091 self.execute = self.execute_cgi
1092 self.client = args[0]
1093 self.form = self.client.form
1094 else:
1095 self.cgi = False
1096
1097 def execute_cgi(self):
1098 args = {}
1099 for key in self.form:
1100 args[key] = self.form.getvalue(key)
1101 self.permission(args)
1102 return self.handle(args)
1103
1104 def permission(self, args):
1105 """Raise Unauthorised if the current user is not allowed to execute
1106 this action. Users may override this method."""
1107
1108 pass
1109
1110 def handle(self, args):
1111
1112 raise NotImplementedError
1113
1114 # vim: set filetype=python sts=4 sw=4 et si :