Code

2976814cc710ee1e7fbb68b82d6fa400de9856e7
[roundup.git] / roundup / cgi_client.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: cgi_client.py,v 1.64 2001-11-26 22:56:35 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
25 import binascii, Cookie, time
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
30 class Unauthorised(ValueError):
31     pass
33 class NotFound(ValueError):
34     pass
36 class Client:
37     '''
38     A note about login
39     ------------------
41     If the user has no login cookie, then they are anonymous. There
42     are two levels of anonymous use. If there is no 'anonymous' user, there
43     is no login at all and the database is opened in read-only mode. If the
44     'anonymous' user exists, the user is logged in using that user (though
45     there is no cookie). This allows them to modify the database, and all
46     modifications are attributed to the 'anonymous' user.
49     Customisation
50     -------------
51       FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
52       ANONYMOUS_ACCESS - one of 'deny', 'allow'
53       ANONYMOUS_REGISTER - one of 'deny', 'allow'
55     from the roundup class:
56       INSTANCE_NAME - defaults to 'Roundup issue tracker'
58     '''
59     FILTER_POSITION = 'bottom'       # one of 'top', 'bottom', 'top and bottom'
60     ANONYMOUS_ACCESS = 'deny'        # one of 'deny', 'allow'
61     ANONYMOUS_REGISTER = 'deny'      # one of 'deny', 'allow'
63     def __init__(self, instance, request, env):
64         self.instance = instance
65         self.request = request
66         self.env = env
67         self.path = env['PATH_INFO']
68         self.split_path = self.path.split('/')
70         self.form = cgi.FieldStorage(environ=env)
71         self.headers_done = 0
72         try:
73             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
74         except ValueError:
75             # someone gave us a non-int debug level, turn it off
76             self.debug = 0
78     def getuid(self):
79         return self.db.user.lookup(self.user)
81     def header(self, headers={'Content-Type':'text/html'}):
82         '''Put up the appropriate header.
83         '''
84         if not headers.has_key('Content-Type'):
85             headers['Content-Type'] = 'text/html'
86         self.request.send_response(200)
87         for entry in headers.items():
88             self.request.send_header(*entry)
89         self.request.end_headers()
90         self.headers_done = 1
91         if self.debug:
92             self.headers_sent = headers
94     def pagehead(self, title, message=None):
95         url = self.env['SCRIPT_NAME'] + '/'
96         machine = self.env['SERVER_NAME']
97         port = self.env['SERVER_PORT']
98         if port != '80': machine = machine + ':' + port
99         base = urlparse.urlunparse(('http', machine, url, None, None, None))
100         if message is not None:
101             message = '<div class="system-msg">%s</div>'%message
102         else:
103             message = ''
104         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
105         user_name = self.user or ''
106         if self.user == 'admin':
107             admin_links = ' | <a href="list_classes">Class List</a>'
108         else:
109             admin_links = ''
110         if self.user not in (None, 'anonymous'):
111             userid = self.db.user.lookup(self.user)
112             user_info = '''
113 <a href="issue?assignedto=%s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
114 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
115 '''%(userid, userid)
116         else:
117             user_info = _('<a href="login">Login</a>')
118         if self.user is not None:
119             add_links = '''
120 | Add
121 <a href="newissue">Issue</a>,
122 <a href="newuser">User</a>
123 '''
124         else:
125             add_links = ''
126         self.write('''<html><head>
127 <title>%s</title>
128 <style type="text/css">%s</style>
129 </head>
130 <body bgcolor=#ffffff>
131 %s
132 <table width=100%% border=0 cellspacing=0 cellpadding=2>
133 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
134 <td align=right valign=bottom>%s</td></tr>
135 <tr class="location-bar">
136 <td align=left>All
137 <a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
138 | Unassigned
139 <a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
140 %s
141 %s</td>
142 <td align=right>%s</td>
143 </table>
144 '''%(title, style, message, title, user_name, add_links, admin_links,
145     user_info))
147     def pagefoot(self):
148         if self.debug:
149             self.write('<hr><small><dl>')
150             self.write('<dt><b>Path</b></dt>')
151             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
152             keys = self.form.keys()
153             keys.sort()
154             if keys:
155                 self.write('<dt><b>Form entries</b></dt>')
156                 for k in self.form.keys():
157                     v = self.form.getvalue(k, "<empty>")
158                     if type(v) is type([]):
159                         # Multiple username fields specified
160                         v = "|".join(v)
161                     self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
162             keys = self.headers_sent.keys()
163             keys.sort()
164             self.write('<dt><b>Sent these HTTP headers</b></dt>')
165             for k in keys:
166                 v = self.headers_sent[k]
167                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
168             keys = self.env.keys()
169             keys.sort()
170             self.write('<dt><b>CGI environment</b></dt>')
171             for k in keys:
172                 v = self.env[k]
173                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
174             self.write('</dl></small>')
175         self.write('</body></html>')
177     def write(self, content):
178         if not self.headers_done:
179             self.header()
180         self.request.wfile.write(content)
182     def index_arg(self, arg):
183         ''' handle the args to index - they might be a list from the form
184             (ie. submitted from a form) or they might be a command-separated
185             single string (ie. manually constructed GET args)
186         '''
187         if self.form.has_key(arg):
188             arg =  self.form[arg]
189             if type(arg) == type([]):
190                 return [arg.value for arg in arg]
191             return arg.value.split(',')
192         return []
194     def index_filterspec(self, filter):
195         ''' pull the index filter spec from the form
197         Links and multilinks want to be lists - the rest are straight
198         strings.
199         '''
200         props = self.db.classes[self.classname].getprops()
201         # all the form args not starting with ':' are filters
202         filterspec = {}
203         for key in self.form.keys():
204             if key[0] == ':': continue
205             if not props.has_key(key): continue
206             if key not in filter: continue
207             prop = props[key]
208             value = self.form[key]
209             if (isinstance(prop, hyperdb.Link) or
210                     isinstance(prop, hyperdb.Multilink)):
211                 if type(value) == type([]):
212                     value = [arg.value for arg in value]
213                 else:
214                     value = value.value.split(',')
215                 l = filterspec.get(key, [])
216                 l = l + value
217                 filterspec[key] = l
218             else:
219                 filterspec[key] = value.value
220         return filterspec
222     def customization_widget(self):
223         ''' The customization widget is visible by default. The widget
224             visibility is remembered by show_customization.  Visibility
225             is not toggled if the action value is "Redisplay"
226         '''
227         if not self.form.has_key('show_customization'):
228             visible = 1
229         else:
230             visible = int(self.form['show_customization'].value)
231             if self.form.has_key('action'):
232                 if self.form['action'].value != 'Redisplay':
233                     visible = self.form['action'].value == '+'
234             
235         return visible
237     default_index_sort = ['-activity']
238     default_index_group = ['priority']
239     default_index_filter = ['status']
240     default_index_columns = ['id','activity','title','status','assignedto']
241     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
242     def index(self):
243         ''' put up an index
244         '''
245         self.classname = 'issue'
246         # see if the web has supplied us with any customisation info
247         defaults = 1
248         for key in ':sort', ':group', ':filter', ':columns':
249             if self.form.has_key(key):
250                 defaults = 0
251                 break
252         if defaults:
253             # no info supplied - use the defaults
254             sort = self.default_index_sort
255             group = self.default_index_group
256             filter = self.default_index_filter
257             columns = self.default_index_columns
258             filterspec = self.default_index_filterspec
259         else:
260             sort = self.index_arg(':sort')
261             group = self.index_arg(':group')
262             filter = self.index_arg(':filter')
263             columns = self.index_arg(':columns')
264             filterspec = self.index_filterspec(filter)
265         return self.list(columns=columns, filter=filter, group=group,
266             sort=sort, filterspec=filterspec)
268     # XXX deviates from spec - loses the '+' (that's a reserved character
269     # in URLS
270     def list(self, sort=None, group=None, filter=None, columns=None,
271             filterspec=None, show_customization=None):
272         ''' call the template index with the args
274             :sort    - sort by prop name, optionally preceeded with '-'
275                      to give descending or nothing for ascending sorting.
276             :group   - group by prop name, optionally preceeded with '-' or
277                      to sort in descending or nothing for ascending order.
278             :filter  - selects which props should be displayed in the filter
279                      section. Default is all.
280             :columns - selects the columns that should be displayed.
281                      Default is all.
283         '''
284         cn = self.classname
285         cl = self.db.classes[cn]
286         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
287             'classname': cn, 'instancename': cl.INSTANCE_NAME})
288         if sort is None: sort = self.index_arg(':sort')
289         if group is None: group = self.index_arg(':group')
290         if filter is None: filter = self.index_arg(':filter')
291         if columns is None: columns = self.index_arg(':columns')
292         if filterspec is None: filterspec = self.index_filterspec(filter)
293         if show_customization is None:
294             show_customization = self.customization_widget()
296         index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
297         index.render(filterspec, filter, columns, sort, group,
298             show_customization=show_customization)
299         self.pagefoot()
301     def shownode(self, message=None):
302         ''' display an item
303         '''
304         cn = self.classname
305         cl = self.db.classes[cn]
307         # possibly perform an edit
308         keys = self.form.keys()
309         num_re = re.compile('^\d+$')
310         # don't try to set properties if the user has just logged in
311         if keys and not self.form.has_key('__login_name'):
312             try:
313                 props, changed = parsePropsFromForm(self.db, cl, self.form,
314                     self.nodeid)
316                 # set status to chatting if 'unread' or 'resolved'
317                 if 'status' not in changed:
318                     try:
319                         # determine the id of 'unread','resolved' and 'chatting'
320                         unread_id = self.db.status.lookup('unread')
321                         resolved_id = self.db.status.lookup('resolved')
322                         chatting_id = self.db.status.lookup('chatting')
323                     except KeyError:
324                         pass
325                     else:
326                         if (not props.has_key('status') or
327                                 props['status'] == unread_id or
328                                 props['status'] == resolved_id):
329                             props['status'] = chatting_id
330                             changed.append('status')
331                 note = None
332                 if self.form.has_key('__note'):
333                     note = self.form['__note']
334                     note = note.value
335                 if changed or note:
336                     cl.set(self.nodeid, **props)
337                     self._post_editnode(self.nodeid, changed)
338                     # and some nice feedback for the user
339                     message = '%s edited ok'%', '.join(changed)
340                 else:
341                     message = 'nothing changed'
342             except:
343                 s = StringIO.StringIO()
344                 traceback.print_exc(None, s)
345                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
347         # now the display
348         id = self.nodeid
349         if cl.getkey():
350             id = cl.get(id, cl.getkey())
351         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
353         nodeid = self.nodeid
355         # use the template to display the item
356         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
357         item.render(nodeid)
359         self.pagefoot()
360     showissue = shownode
361     showmsg = shownode
363     def showuser(self, message=None):
364         '''Display a user page for editing. Make sure the user is allowed
365             to edit this node, and also check for password changes.
366         '''
367         if self.user == 'anonymous':
368             raise Unauthorised
370         user = self.db.user
372         # get the username of the node being edited
373         node_user = user.get(self.nodeid, 'username')
375         if self.user not in ('admin', node_user):
376             raise Unauthorised
378         #
379         # perform any editing
380         #
381         keys = self.form.keys()
382         num_re = re.compile('^\d+$')
383         if keys:
384             try:
385                 props, changed = parsePropsFromForm(self.db, user, self.form,
386                     self.nodeid)
387                 set_cookie = 0
388                 if self.nodeid == self.getuid() and 'password' in changed:
389                     password = self.form['password'].value.strip()
390                     if password:
391                         set_cookie = password
392                     else:
393                         del props['password']
394                         del changed[changed.index('password')]
395                 user.set(self.nodeid, **props)
396                 self._post_editnode(self.nodeid, changed)
397                 # and some feedback for the user
398                 message = '%s edited ok'%', '.join(changed)
399             except:
400                 s = StringIO.StringIO()
401                 traceback.print_exc(None, s)
402                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
403         else:
404             set_cookie = 0
406         # fix the cookie if the password has changed
407         if set_cookie:
408             self.set_cookie(self.user, set_cookie)
410         #
411         # now the display
412         #
413         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
415         # use the template to display the item
416         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
417         item.render(self.nodeid)
418         self.pagefoot()
420     def showfile(self):
421         ''' display a file
422         '''
423         nodeid = self.nodeid
424         cl = self.db.file
425         mime_type = cl.get(nodeid, 'type')
426         if mime_type == 'message/rfc822':
427             mime_type = 'text/plain'
428         self.header(headers={'Content-Type': mime_type})
429         self.write(cl.get(nodeid, 'content'))
431     def _createnode(self):
432         ''' create a node based on the contents of the form
433         '''
434         cl = self.db.classes[self.classname]
435         props, dummy = parsePropsFromForm(self.db, cl, self.form)
436         return cl.create(**props)
438     def _post_editnode(self, nid, changes=None):
439         ''' do the linking and message sending part of the node creation
440         '''
441         cn = self.classname
442         cl = self.db.classes[cn]
443         # link if necessary
444         keys = self.form.keys()
445         for key in keys:
446             if key == ':multilink':
447                 value = self.form[key].value
448                 if type(value) != type([]): value = [value]
449                 for value in value:
450                     designator, property = value.split(':')
451                     link, nodeid = roundupdb.splitDesignator(designator)
452                     link = self.db.classes[link]
453                     value = link.get(nodeid, property)
454                     value.append(nid)
455                     link.set(nodeid, **{property: value})
456             elif key == ':link':
457                 value = self.form[key].value
458                 if type(value) != type([]): value = [value]
459                 for value in value:
460                     designator, property = value.split(':')
461                     link, nodeid = roundupdb.splitDesignator(designator)
462                     link = self.db.classes[link]
463                     link.set(nodeid, **{property: nid})
465         # handle file attachments
466         files = []
467         if self.form.has_key('__file'):
468             file = self.form['__file']
469             if file.filename:
470                 mime_type = mimetypes.guess_type(file.filename)[0]
471                 if not mime_type:
472                     mime_type = "application/octet-stream"
473                 # create the new file entry
474                 files.append(self.db.file.create(type=mime_type,
475                     name=file.filename, content=file.file.read()))
477         # generate an edit message
478         # don't bother if there's no messages or nosy list 
479         props = cl.getprops()
480         note = None
481         if self.form.has_key('__note'):
482             note = self.form['__note']
483             note = note.value
484         send = len(cl.get(nid, 'nosy', [])) or note
485         if (send and props.has_key('messages') and
486                 isinstance(props['messages'], hyperdb.Multilink) and
487                 props['messages'].classname == 'msg'):
489             # handle the note
490             edit_msg = 'This %s has been edited through the web.\n'%cn
491             if note:
492                 if '\n' in note:
493                     summary = re.split(r'\n\r?', note)[0]
494                 else:
495                     summary = note
496                 m = [edit_msg + '%s\n'%note]
497             else:
498                 m = [edit_msg]
500             first = 1
501             for name, prop in props.items():
502                 if changes is not None and name not in changes: continue
503                 if first:
504                     m.append('\n-------')
505                     first = 0
506                 value = cl.get(nid, name, None)
507                 if isinstance(prop, hyperdb.Link):
508                     link = self.db.classes[prop.classname]
509                     key = link.labelprop(default_to_id=1)
510                     if value is not None and key:
511                         value = link.get(value, key)
512                     else:
513                         value = '-'
514                 elif isinstance(prop, hyperdb.Multilink):
515                     if value is None: value = []
516                     l = []
517                     link = self.db.classes[prop.classname]
518                     key = link.labelprop(default_to_id=1)
519                     for entry in value:
520                         if key:
521                             l.append(link.get(entry, key))
522                         else:
523                             l.append(entry)
524                     value = ', '.join(l)
525                 m.append('%s: %s'%(name, value))
527             # now create the message
528             content = '\n'.join(m)
529             message_id = self.db.msg.create(author=self.getuid(),
530                 recipients=[], date=date.Date('.'), summary=summary,
531                 content=content, files=files)
532             messages = cl.get(nid, 'messages')
533             messages.append(message_id)
534             props = {'messages': messages, 'files': files}
535             cl.set(nid, **props)
537     def newnode(self, message=None):
538         ''' Add a new node to the database.
539         
540         The form works in two modes: blank form and submission (that is,
541         the submission goes to the same URL). **Eventually this means that
542         the form will have previously entered information in it if
543         submission fails.
545         The new node will be created with the properties specified in the
546         form submission. For multilinks, multiple form entries are handled,
547         as are prop=value,value,value. You can't mix them though.
549         If the new node is to be referenced from somewhere else immediately
550         (ie. the new node is a file that is to be attached to a support
551         issue) then supply one of these arguments in addition to the usual
552         form entries:
553             :link=designator:property
554             :multilink=designator:property
555         ... which means that once the new node is created, the "property"
556         on the node given by "designator" should now reference the new
557         node's id. The node id will be appended to the multilink.
558         '''
559         cn = self.classname
560         cl = self.db.classes[cn]
562         # possibly perform a create
563         keys = self.form.keys()
564         if [i for i in keys if i[0] != ':']:
565             props = {}
566             try:
567                 nid = self._createnode()
568                 self._post_editnode(nid)
569                 # and some nice feedback for the user
570                 message = '%s created ok'%cn
571             except:
572                 s = StringIO.StringIO()
573                 traceback.print_exc(None, s)
574                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
575         self.pagehead('New %s'%self.classname.capitalize(), message)
577         # call the template
578         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
579             self.classname)
580         newitem.render(self.form)
582         self.pagefoot()
583     newissue = newnode
584     newuser = newnode
586     def newfile(self, message=None):
587         ''' Add a new file to the database.
588         
589         This form works very much the same way as newnode - it just has a
590         file upload.
591         '''
592         cn = self.classname
593         cl = self.db.classes[cn]
595         # possibly perform a create
596         keys = self.form.keys()
597         if [i for i in keys if i[0] != ':']:
598             try:
599                 file = self.form['content']
600                 mime_type = mimetypes.guess_type(file.filename)[0]
601                 if not mime_type:
602                     mime_type = "application/octet-stream"
603                 self._post_editnode(cl.create(content=file.file.read(),
604                     type=mime_type, name=file.filename))
605                 # and some nice feedback for the user
606                 message = '%s created ok'%cn
607             except:
608                 s = StringIO.StringIO()
609                 traceback.print_exc(None, s)
610                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
612         self.pagehead('New %s'%self.classname.capitalize(), message)
613         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
614             self.classname)
615         newitem.render(self.form)
616         self.pagefoot()
618     def classes(self, message=None):
619         ''' display a list of all the classes in the database
620         '''
621         if self.user == 'admin':
622             self.pagehead(_('Table of classes'), message)
623             classnames = self.db.classes.keys()
624             classnames.sort()
625             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
626             for cn in classnames:
627                 cl = self.db.getclass(cn)
628                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
629                 for key, value in cl.properties.items():
630                     if value is None: value = ''
631                     else: value = str(value)
632                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
633                         key, cgi.escape(value)))
634             self.write('</table>')
635             self.pagefoot()
636         else:
637             raise Unauthorised
639     def login(self, message=None, newuser_form=None, action='index'):
640         self.pagehead(_('Login to roundup'), message)
641         self.write('''
642 <table>
643 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
644 <form action="login_action" method=POST>
645 <input type="hidden" name="__destination_url" value="%s">
646 <tr><td align=right>Login name: </td>
647     <td><input name="__login_name"></td></tr>
648 <tr><td align=right>Password: </td>
649     <td><input type="password" name="__login_password"></td></tr>
650 <tr><td></td>
651     <td><input type="submit" value="Log In"></td></tr>
652 </form>
653 '''%action)
654         if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
655             self.write('</table>')
656             self.pagefoot()
657             return
658         values = {'realname': '', 'organisation': '', 'address': '',
659             'phone': '', 'username': '', 'password': '', 'confirm': ''}
660         if newuser_form is not None:
661             for key in newuser_form.keys():
662                 values[key] = newuser_form[key].value
663         self.write('''
664 <p>
665 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
666 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
667 <form action="newuser_action" method=POST>
668 <tr><td align=right><em>Name: </em></td>
669     <td><input name="realname" value="%(realname)s"></td></tr>
670 <tr><td align=right><em>Organisation: </em></td>
671     <td><input name="organisation" value="%(organisation)s"></td></tr>
672 <tr><td align=right>E-Mail Address: </td>
673     <td><input name="address" value="%(address)s"></td></tr>
674 <tr><td align=right><em>Phone: </em></td>
675     <td><input name="phone" value="%(phone)s"></td></tr>
676 <tr><td align=right>Preferred Login name: </td>
677     <td><input name="username" value="%(username)s"></td></tr>
678 <tr><td align=right>Password: </td>
679     <td><input type="password" name="password" value="%(password)s"></td></tr>
680 <tr><td align=right>Password Again: </td>
681     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
682 <tr><td></td>
683     <td><input type="submit" value="Register"></td></tr>
684 </form>
685 </table>
686 '''%values)
687         self.pagefoot()
689     def login_action(self, message=None):
690         if not self.form.has_key('__login_name'):
691             return self.login(message='Username required')
692         self.user = self.form['__login_name'].value
693         if self.form.has_key('__login_password'):
694             password = self.form['__login_password'].value
695         else:
696             password = ''
697         # make sure the user exists
698         try:
699             uid = self.db.user.lookup(self.user)
700         except KeyError:
701             name = self.user
702             self.make_user_anonymous()
703             return self.login(message=_('No such user "%(name)s"')%locals())
705         # and that the password is correct
706         pw = self.db.user.get(uid, 'password')
707         if password != self.db.user.get(uid, 'password'):
708             self.make_user_anonymous()
709             return self.login(message=_('Incorrect password'))
711         self.set_cookie(self.user, password)
713     def set_cookie(self, user, password):
714         # construct the cookie
715         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
716         if user[-1] == '=':
717           if user[-2] == '=':
718             user = user[:-2]
719           else:
720             user = user[:-1]
721         expire = Cookie._getdate(86400*365)
722         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
723         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
724             user, expire, path)})
726     def make_user_anonymous(self):
727         # make us anonymous if we can
728         try:
729             self.db.user.lookup('anonymous')
730             self.user = 'anonymous'
731         except KeyError:
732             self.user = None
734     def logout(self, message=None):
735         self.make_user_anonymous()
736         # construct the logout cookie
737         now = Cookie._getdate()
738         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
739         self.header({'Set-Cookie':
740             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
741             path)})
742         return self.login()
744     def newuser_action(self, message=None):
745         ''' create a new user based on the contents of the form and then
746         set the cookie
747         '''
748         # re-open the database as "admin"
749         self.db.close()
750         self.db = self.instance.open('admin')
752         # TODO: pre-check the required fields and username key property
753         cl = self.db.user
754         try:
755             props, dummy = parsePropsFromForm(self.db, cl, self.form)
756             uid = cl.create(**props)
757         except ValueError, message:
758             return self.login(message, newuser_form=self.form)
759         self.user = cl.get(uid, 'username')
760         password = cl.get(uid, 'password')
761         self.set_cookie(self.user, self.form['password'].value)
762         return self.index()
764     def main(self):
765         # determine the uid to use
766         self.db = self.instance.open('admin')
767         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
768         user = 'anonymous'
769         if (cookie.has_key('roundup_user') and
770                 cookie['roundup_user'].value != 'deleted'):
771             cookie = cookie['roundup_user'].value
772             if len(cookie)%4:
773               cookie = cookie + '='*(4-len(cookie)%4)
774             try:
775                 user, password = binascii.a2b_base64(cookie).split(':')
776             except (TypeError, binascii.Error, binascii.Incomplete):
777                 # damaged cookie!
778                 user, password = 'anonymous', ''
780             # make sure the user exists
781             try:
782                 uid = self.db.user.lookup(user)
783                 # now validate the password
784                 if password != self.db.user.get(uid, 'password'):
785                     user = 'anonymous'
786             except KeyError:
787                 user = 'anonymous'
789         # make sure the anonymous user is valid if we're using it
790         if user == 'anonymous':
791             self.make_user_anonymous()
792         else:
793             self.user = user
794         self.db.close()
796         # re-open the database for real, using the user
797         self.db = self.instance.open(self.user)
799         # now figure which function to call
800         path = self.split_path
802         # default action to index if the path has no information in it
803         if not path or path[0] in ('', 'index'):
804             action = 'index'
805         else:
806             action = path[0]
808         # Everthing ignores path[1:]
809         #  - The file download link generator actually relies on this - it
810         #    appends the name of the file to the URL so the download file name
811         #    is correct, but doesn't actually use it.
813         # everyone is allowed to try to log in
814         if action == 'login_action':
815             # do the login
816             self.login_action()
817             # figure the resulting page
818             action = self.form['__destination_url'].value
819             if not action:
820                 action = 'index'
821             return self.do_action(action)
823         # allow anonymous people to register
824         if action == 'newuser_action':
825             # if we don't have a login and anonymous people aren't allowed to
826             # register, then spit up the login form
827             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
828                 if action == 'login':
829                     return self.login()         # go to the index after login
830                 else:
831                     return self.login(action=action)
832             # add the user
833             self.newuser_action()
834             # figure the resulting page
835             action = self.form['__destination_url'].value
836             if not action:
837                 action = 'index'
838             return self.do_action(action)
840         # no login or registration, make sure totally anonymous access is OK
841         if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
842             if action == 'login':
843                 return self.login()             # go to the index after login
844             else:
845                 return self.login(action=action)
847         # just a regular action
848         return self.do_action(action)
850     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
851             nre=re.compile(r'new(\w+)')):
852         # here be the "normal" functionality
853         if action == 'index':
854             return self.index()
855         if action == 'list_classes':
856             return self.classes()
857         if action == 'login':
858             return self.login()
859         if action == 'logout':
860             return self.logout()
861         m = dre.match(action)
862         if m:
863             self.classname = m.group(1)
864             self.nodeid = m.group(2)
865             try:
866                 cl = self.db.classes[self.classname]
867             except KeyError:
868                 raise NotFound
869             try:
870                 cl.get(self.nodeid, 'id')
871             except IndexError:
872                 raise NotFound
873             try:
874                 func = getattr(self, 'show%s'%self.classname)
875             except AttributeError:
876                 raise NotFound
877             return func()
878         m = nre.match(action)
879         if m:
880             self.classname = m.group(1)
881             try:
882                 func = getattr(self, 'new%s'%self.classname)
883             except AttributeError:
884                 raise NotFound
885             return func()
886         self.classname = action
887         try:
888             self.db.getclass(self.classname)
889         except KeyError:
890             raise NotFound
891         return self.list()
893     def __del__(self):
894         self.db.close()
897 class ExtendedClient(Client): 
898     '''Includes pages and page heading information that relate to the
899        extended schema.
900     ''' 
901     showsupport = Client.shownode
902     showtimelog = Client.shownode
903     newsupport = Client.newnode
904     newtimelog = Client.newnode
906     default_index_sort = ['-activity']
907     default_index_group = ['priority']
908     default_index_filter = ['status']
909     default_index_columns = ['activity','status','title','assignedto']
910     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
912     def pagehead(self, title, message=None):
913         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
914         machine = self.env['SERVER_NAME']
915         port = self.env['SERVER_PORT']
916         if port != '80': machine = machine + ':' + port
917         base = urlparse.urlunparse(('http', machine, url, None, None, None))
918         if message is not None:
919             message = '<div class="system-msg">%s</div>'%message
920         else:
921             message = ''
922         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
923         user_name = self.user or ''
924         if self.user == 'admin':
925             admin_links = ' | <a href="list_classes">Class List</a>'
926         else:
927             admin_links = ''
928         if self.user not in (None, 'anonymous'):
929             userid = self.db.user.lookup(self.user)
930             user_info = '''
931 <a href="issue?assignedto=%s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
932 <a href="support?assignedto=%s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=activity&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">My Support</a> |
933 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
934 '''%(userid, userid, userid)
935         else:
936             user_info = '<a href="login">Login</a>'
937         if self.user is not None:
938             add_links = '''
939 | Add
940 <a href="newissue">Issue</a>,
941 <a href="newsupport">Support</a>,
942 <a href="newuser">User</a>
943 '''
944         else:
945             add_links = ''
946         self.write('''<html><head>
947 <title>%s</title>
948 <style type="text/css">%s</style>
949 </head>
950 <body bgcolor=#ffffff>
951 %s
952 <table width=100%% border=0 cellspacing=0 cellpadding=2>
953 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
954 <td align=right valign=bottom>%s</td></tr>
955 <tr class="location-bar">
956 <td align=left>All
957 <a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>,
958 <a href="support?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
959 | Unassigned
960 <a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>,
961 <a href="support?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
962 %s
963 %s</td>
964 <td align=right>%s</td>
965 </table>
966 '''%(title, style, message, title, user_name, add_links, admin_links,
967     user_info))
969 def parsePropsFromForm(db, cl, form, nodeid=0):
970     '''Pull properties for the given class out of the form.
971     '''
972     props = {}
973     changed = []
974     keys = form.keys()
975     num_re = re.compile('^\d+$')
976     for key in keys:
977         if not cl.properties.has_key(key):
978             continue
979         proptype = cl.properties[key]
980         if isinstance(proptype, hyperdb.String):
981             value = form[key].value.strip()
982         elif isinstance(proptype, hyperdb.Password):
983             value = password.Password(form[key].value.strip())
984         elif isinstance(proptype, hyperdb.Date):
985             value = date.Date(form[key].value.strip())
986         elif isinstance(proptype, hyperdb.Interval):
987             value = date.Interval(form[key].value.strip())
988         elif isinstance(proptype, hyperdb.Link):
989             value = form[key].value.strip()
990             # see if it's the "no selection" choice
991             if value == '-1':
992                 # don't set this property
993                 continue
994             else:
995                 # handle key values
996                 link = cl.properties[key].classname
997                 if not num_re.match(value):
998                     try:
999                         value = db.classes[link].lookup(value)
1000                     except KeyError:
1001                         raise ValueError, 'property "%s": %s not a %s'%(
1002                             key, value, link)
1003         elif isinstance(proptype, hyperdb.Multilink):
1004             value = form[key]
1005             if type(value) != type([]):
1006                 value = [i.strip() for i in value.value.split(',')]
1007             else:
1008                 value = [i.value.strip() for i in value]
1009             link = cl.properties[key].classname
1010             l = []
1011             for entry in map(str, value):
1012                 if not num_re.match(entry):
1013                     try:
1014                         entry = db.classes[link].lookup(entry)
1015                     except KeyError:
1016                         raise ValueError, \
1017                             'property "%s": "%s" not an entry of %s'%(key,
1018                             entry, link.capitalize())
1019                 l.append(entry)
1020             l.sort()
1021             value = l
1022         props[key] = value
1024         # get the old value
1025         if nodeid:
1026             try:
1027                 existing = cl.get(nodeid, key)
1028             except KeyError:
1029                 # this might be a new property for which there is no existing
1030                 # value
1031                 if not cl.properties.has_key(key): raise
1033         # if changed, set it
1034         if nodeid and value != existing:
1035             changed.append(key)
1036             props[key] = value
1037     return props, changed
1040 # $Log: not supported by cvs2svn $
1041 # Revision 1.63  2001/11/26 22:55:56  richard
1042 # Feature:
1043 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1044 #    the instance.
1045 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1046 #    signature info in e-mails.
1047 #  . Some more flexibility in the mail gateway and more error handling.
1048 #  . Login now takes you to the page you back to the were denied access to.
1050 # Fixed:
1051 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1053 # Revision 1.62  2001/11/24 00:45:42  jhermann
1054 # typeof() instead of type(): avoid clash with database field(?) "type"
1056 # Fixes this traceback:
1058 # Traceback (most recent call last):
1059 #   File "roundup\cgi_client.py", line 535, in newnode
1060 #     self._post_editnode(nid)
1061 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1062 #     if type(value) != type([]): value = [value]
1063 # UnboundLocalError: local variable 'type' referenced before assignment
1065 # Revision 1.61  2001/11/22 15:46:42  jhermann
1066 # Added module docstrings to all modules.
1068 # Revision 1.60  2001/11/21 22:57:28  jhermann
1069 # Added dummy hooks for I18N and some preliminary (test) markup of
1070 # translatable messages
1072 # Revision 1.59  2001/11/21 03:21:13  richard
1073 # oops
1075 # Revision 1.58  2001/11/21 03:11:28  richard
1076 # Better handling of new properties.
1078 # Revision 1.57  2001/11/15 10:24:27  richard
1079 # handle the case where there is no file attached
1081 # Revision 1.56  2001/11/14 21:35:21  richard
1082 #  . users may attach files to issues (and support in ext) through the web now
1084 # Revision 1.55  2001/11/07 02:34:06  jhermann
1085 # Handling of damaged login cookies
1087 # Revision 1.54  2001/11/07 01:16:12  richard
1088 # Remove the '=' padding from cookie value so quoting isn't an issue.
1090 # Revision 1.53  2001/11/06 23:22:05  jhermann
1091 # More IE fixes: it does not like quotes around cookie values; in the
1092 # hope this does not break anything for other browser; if it does, we
1093 # need to check HTTP_USER_AGENT
1095 # Revision 1.52  2001/11/06 23:11:22  jhermann
1096 # Fixed debug output in page footer; added expiry date to the login cookie
1097 # (expires 1 year in the future) to prevent probs with certain versions
1098 # of IE
1100 # Revision 1.51  2001/11/06 22:00:34  jhermann
1101 # Get debug level from ROUNDUP_DEBUG env var
1103 # Revision 1.50  2001/11/05 23:45:40  richard
1104 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1105 # Also made it present nicer error messages (not tracebacks).
1107 # Revision 1.49  2001/11/04 03:07:12  richard
1108 # Fixed various cookie-related bugs:
1109 #  . bug #477685 ] base64.decodestring breaks
1110 #  . bug #477837 ] lynx does not like the cookie
1111 #  . bug #477892 ] Password edit doesn't fix login cookie
1112 # Also closed a security hole - a logged-in user could edit another user's
1113 # details.
1115 # Revision 1.48  2001/11/03 01:30:18  richard
1116 # Oops. uses pagefoot now.
1118 # Revision 1.47  2001/11/03 01:29:28  richard
1119 # Login page didn't have all close tags.
1121 # Revision 1.46  2001/11/03 01:26:55  richard
1122 # possibly fix truncated base64'ed user:pass
1124 # Revision 1.45  2001/11/01 22:04:37  richard
1125 # Started work on supporting a pop3-fetching server
1126 # Fixed bugs:
1127 #  . bug #477104 ] HTML tag error in roundup-server
1128 #  . bug #477107 ] HTTP header problem
1130 # Revision 1.44  2001/10/28 23:03:08  richard
1131 # Added more useful header to the classic schema.
1133 # Revision 1.43  2001/10/24 00:01:42  richard
1134 # More fixes to lockout logic.
1136 # Revision 1.42  2001/10/23 23:56:03  richard
1137 # HTML typo
1139 # Revision 1.41  2001/10/23 23:52:35  richard
1140 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1142 # Revision 1.40  2001/10/23 23:06:39  richard
1143 # Some cleanup.
1145 # Revision 1.39  2001/10/23 01:00:18  richard
1146 # Re-enabled login and registration access after lopping them off via
1147 # disabling access for anonymous users.
1148 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1149 # a couple of bugs while I was there. Probably introduced a couple, but
1150 # things seem to work OK at the moment.
1152 # Revision 1.38  2001/10/22 03:25:01  richard
1153 # Added configuration for:
1154 #  . anonymous user access and registration (deny/allow)
1155 #  . filter "widget" location on index page (top, bottom, both)
1156 # Updated some documentation.
1158 # Revision 1.37  2001/10/21 07:26:35  richard
1159 # feature #473127: Filenames. I modified the file.index and htmltemplate
1160 #  source so that the filename is used in the link and the creation
1161 #  information is displayed.
1163 # Revision 1.36  2001/10/21 04:44:50  richard
1164 # bug #473124: UI inconsistency with Link fields.
1165 #    This also prompted me to fix a fairly long-standing usability issue -
1166 #    that of being able to turn off certain filters.
1168 # Revision 1.35  2001/10/21 00:17:54  richard
1169 # CGI interface view customisation section may now be hidden (patch from
1170 #  Roch'e Compaan.)
1172 # Revision 1.34  2001/10/20 11:58:48  richard
1173 # Catch errors in login - no username or password supplied.
1174 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1176 # Revision 1.33  2001/10/17 00:18:41  richard
1177 # Manually constructing cookie headers now.
1179 # Revision 1.32  2001/10/16 03:36:21  richard
1180 # CGI interface wasn't handling checkboxes at all.
1182 # Revision 1.31  2001/10/14 10:55:00  richard
1183 # Handle empty strings in HTML template Link function
1185 # Revision 1.30  2001/10/09 07:38:58  richard
1186 # Pushed the base code for the extended schema CGI interface back into the
1187 # code cgi_client module so that future updates will be less painful.
1188 # Also removed a debugging print statement from cgi_client.
1190 # Revision 1.29  2001/10/09 07:25:59  richard
1191 # Added the Password property type. See "pydoc roundup.password" for
1192 # implementation details. Have updated some of the documentation too.
1194 # Revision 1.28  2001/10/08 00:34:31  richard
1195 # Change message was stuffing up for multilinks with no key property.
1197 # Revision 1.27  2001/10/05 02:23:24  richard
1198 #  . roundup-admin create now prompts for property info if none is supplied
1199 #    on the command-line.
1200 #  . hyperdb Class getprops() method may now return only the mutable
1201 #    properties.
1202 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1203 #    now support anonymous user access (read-only, unless there's an
1204 #    "anonymous" user, in which case write access is permitted). Login
1205 #    handling has been moved into cgi_client.Client.main()
1206 #  . The "extended" schema is now the default in roundup init.
1207 #  . The schemas have had their page headings modified to cope with the new
1208 #    login handling. Existing installations should copy the interfaces.py
1209 #    file from the roundup lib directory to their instance home.
1210 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1211 #    Ping - has been removed.
1212 #  . Fixed a whole bunch of places in the CGI interface where we should have
1213 #    been returning Not Found instead of throwing an exception.
1214 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1215 #    an item now throws an exception.
1217 # Revision 1.26  2001/09/12 08:31:42  richard
1218 # handle cases where mime type is not guessable
1220 # Revision 1.25  2001/08/29 05:30:49  richard
1221 # change messages weren't being saved when there was no-one on the nosy list.
1223 # Revision 1.24  2001/08/29 04:49:39  richard
1224 # didn't clean up fully after debugging :(
1226 # Revision 1.23  2001/08/29 04:47:18  richard
1227 # Fixed CGI client change messages so they actually include the properties
1228 # changed (again).
1230 # Revision 1.22  2001/08/17 00:08:10  richard
1231 # reverted back to sending messages always regardless of who is doing the web
1232 # edit. change notes weren't being saved. bleah. hackish.
1234 # Revision 1.21  2001/08/15 23:43:18  richard
1235 # Fixed some isFooTypes that I missed.
1236 # Refactored some code in the CGI code.
1238 # Revision 1.20  2001/08/12 06:32:36  richard
1239 # using isinstance(blah, Foo) now instead of isFooType
1241 # Revision 1.19  2001/08/07 00:24:42  richard
1242 # stupid typo
1244 # Revision 1.18  2001/08/07 00:15:51  richard
1245 # Added the copyright/license notice to (nearly) all files at request of
1246 # Bizar Software.
1248 # Revision 1.17  2001/08/02 06:38:17  richard
1249 # Roundupdb now appends "mailing list" information to its messages which
1250 # include the e-mail address and web interface address. Templates may
1251 # override this in their db classes to include specific information (support
1252 # instructions, etc).
1254 # Revision 1.16  2001/08/02 05:55:25  richard
1255 # Web edit messages aren't sent to the person who did the edit any more. No
1256 # message is generated if they are the only person on the nosy list.
1258 # Revision 1.15  2001/08/02 00:34:10  richard
1259 # bleah syntax error
1261 # Revision 1.14  2001/08/02 00:26:16  richard
1262 # Changed the order of the information in the message generated by web edits.
1264 # Revision 1.13  2001/07/30 08:12:17  richard
1265 # Added time logging and file uploading to the templates.
1267 # Revision 1.12  2001/07/30 06:26:31  richard
1268 # Added some documentation on how the newblah works.
1270 # Revision 1.11  2001/07/30 06:17:45  richard
1271 # Features:
1272 #  . Added ability for cgi newblah forms to indicate that the new node
1273 #    should be linked somewhere.
1274 # Fixed:
1275 #  . Fixed the agument handling for the roundup-admin find command.
1276 #  . Fixed handling of summary when no note supplied for newblah. Again.
1277 #  . Fixed detection of no form in htmltemplate Field display.
1279 # Revision 1.10  2001/07/30 02:37:34  richard
1280 # Temporary measure until we have decent schema migration...
1282 # Revision 1.9  2001/07/30 01:25:07  richard
1283 # Default implementation is now "classic" rather than "extended" as one would
1284 # expect.
1286 # Revision 1.8  2001/07/29 08:27:40  richard
1287 # Fixed handling of passed-in values in form elements (ie. during a
1288 # drill-down)
1290 # Revision 1.7  2001/07/29 07:01:39  richard
1291 # Added vim command to all source so that we don't get no steenkin' tabs :)
1293 # Revision 1.6  2001/07/29 04:04:00  richard
1294 # Moved some code around allowing for subclassing to change behaviour.
1296 # Revision 1.5  2001/07/28 08:16:52  richard
1297 # New issue form handles lack of note better now.
1299 # Revision 1.4  2001/07/28 00:34:34  richard
1300 # Fixed some non-string node ids.
1302 # Revision 1.3  2001/07/23 03:56:30  richard
1303 # oops, missed a config removal
1305 # Revision 1.2  2001/07/22 12:09:32  richard
1306 # Final commit of Grande Splite
1308 # Revision 1.1  2001/07/22 11:58:35  richard
1309 # More Grande Splite
1312 # vim: set filetype=python ts=4 sw=4 et si