Code

can now unset values in CSV editing (sf bug 704788)
[roundup.git] / roundup / cgi / client.py
index e109bafacc7d7a46f296b1aa3a40c5f96f360e7f..a96babfd7e6ccb7af54ad7c4fa2a69d5b1ae18f5 100644 (file)
@@ -1,4 +1,4 @@
-# $Id: client.py,v 1.100 2003-02-26 04:57:49 richard Exp $
+# $Id: client.py,v 1.108 2003-03-19 02:50:40 richard Exp $
 
 __doc__ = """
 WWW request handler (also used in the stand-alone server).
@@ -6,7 +6,7 @@ WWW request handler (also used in the stand-alone server).
 
 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
 import binascii, Cookie, time, random, MimeWriter, smtplib, socket, quopri
-import stat, rfc822
+import stat, rfc822, string
 
 from roundup import roundupdb, date, hyperdb, password
 from roundup.i18n import _
@@ -14,6 +14,7 @@ from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
 from roundup.cgi import cgitb
 from roundup.cgi.PageTemplates import PageTemplate
 from roundup.rfc2822 import encode_header
+from roundup.mailgw import uidFromAddress
 
 class HTTPException(Exception):
       pass
@@ -26,6 +27,11 @@ class  Redirect(HTTPException):
 class  NotModified(HTTPException):
        pass
 
+# set to indicate to roundup not to actually _send_ email
+# this var must contain a file to write the mail to
+SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
+
+
 # XXX actually _use_ FormError
 class FormError(ValueError):
     ''' An "expected" exception occurred during form parsing.
@@ -257,8 +263,13 @@ class Client:
             self.write(cgitb.html())
 
     def clean_sessions(self):
-        '''age sessions, remove when they haven't been used for a week.
-        Do it only once an hour'''
+        ''' Age sessions, remove when they haven't been used for a week.
+        
+            Do it only once an hour.
+
+            Note: also cleans One Time Keys, and other "session" based
+            stuff.
+        '''
         sessions = self.db.sessions
         last_clean = sessions.get('last_clean', 'last_use') or 0
 
@@ -266,11 +277,17 @@ class Client:
         hour = 60*60
         now = time.time()
         if now - last_clean > hour:
-            # remove age sessions
+            # remove aged sessions
             for sessid in sessions.list():
                 interval = now - sessions.get(sessid, 'last_use')
                 if interval > week:
                     sessions.destroy(sessid)
+            # remove aged otks
+            otks = self.db.otks
+            for sessid in otks.list():
+                interval = now - otks.get(sessid, '__time')
+                if interval > week:
+                    otks.destroy(sessid)
             sessions.set('last_clean', last_use=time.time())
 
     def determine_user(self):
@@ -430,10 +447,12 @@ class Client:
         self.write(file.get(nodeid, 'content'))
 
     def serve_static_file(self, file):
+        ims = None
         # see if there's an if-modified-since...
-        ims = self.request.headers.getheader('if-modified-since')
-        # cgi will put the header in the env var
-        if not ims and self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
+        if hasattr(self.request, 'headers'):
+            ims = self.request.headers.getheader('if-modified-since')
+        elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
+            # cgi will put the header in the env var
             ims = self.env['HTTP_IF_MODIFIED_SINCE']
         filename = os.path.join(self.instance.config.TEMPLATES, file)
         lmt = os.stat(filename)[stat.ST_MTIME]
@@ -479,6 +498,7 @@ class Client:
         ('new',      'newItemAction'),
         ('register', 'registerAction'),
         ('confrego', 'confRegoAction'),
+        ('passrst',  'passResetAction'),
         ('login',    'loginAction'),
         ('logout',   'logout_action'),
         ('search',   'searchAction'),
@@ -489,17 +509,8 @@ class Client:
         ''' Determine whether there should be an Action called.
 
             The action is defined by the form variable :action which
-            identifies the method on this object to call. The four basic
-            actions are defined in the "actions" sequence on this class:
-             "edit"      -> self.editItemAction
-             "editcsv"   -> self.editCSVAction
-             "new"       -> self.newItemAction
-             "register"  -> self.registerAction
-             "confrego"  -> self.confRegoAction
-             "login"     -> self.loginAction
-             "logout"    -> self.logout_action
-             "search"    -> self.searchAction
-             "retire"    -> self.retireAction
+            identifies the method on this object to call. The actions
+            are defined in the "actions" sequence on this class.
         '''
         if self.form.has_key(':action'):
             action = self.form[':action'].value.lower()
@@ -675,7 +686,7 @@ class Client:
         # Let the user know what's going on
         self.ok_message.append(_('You are logged out'))
 
-    chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+    chars = string.letters+string.digits
     def registerAction(self):
         '''Attempt to create a new user based on the contents of the form
         and then set the cookie.
@@ -713,15 +724,35 @@ class Client:
                 props[propname] = str(value)
             elif isinstance(proptype, hyperdb.Password):
                 props[propname] = str(value)
+        props['__time'] = time.time()
         self.db.otks.set(otk, **props)
 
+        # send the email
+        tracker_name = self.db.config.TRACKER_NAME
+        subject = 'Complete your registration to %s'%tracker_name
+        body = '''
+To complete your registration of the user "%(name)s" with %(tracker)s,
+please visit the following URL:
+
+   %(url)s?@action=confrego&otk=%(otk)s
+'''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
+                'otk': otk}
+        if not self.sendEmail(props['address'], subject, body):
+            return
+
+        # commit changes to the database
+        self.db.commit()
+
+        # redirect to the "you're almost there" page
+        raise Redirect, '%suser?@template=rego_progress'%self.base
+
+    def sendEmail(self, to, subject, content):
         # send email to the user's email address
         message = StringIO.StringIO()
         writer = MimeWriter.MimeWriter(message)
         tracker_name = self.db.config.TRACKER_NAME
-        s = 'Complete your registration to %s'%tracker_name
-        writer.addheader('Subject', encode_header(s))
-        writer.addheader('To', props['address'])
+        writer.addheader('Subject', encode_header(subject))
+        writer.addheader('To', to)
         writer.addheader('From', roundupdb.straddr((tracker_name,
             self.db.config.ADMIN_EMAIL)))
         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
@@ -734,36 +765,30 @@ class Client:
         body = writer.startbody('text/plain; charset=utf-8')
 
         # message body, encoded quoted-printable
-        content = StringIO.StringIO('''
-To complete your registration of the user "%(name)s" with %(tracker)s,
-please visit the following URL:
-
-   http://localhost:8001/test/?@action=confrego&otk=%(otk)s
-'''%{'name': props['username'], 'tracker': tracker_name, 'url': self.base,
-        'otk': otk})
+        content = StringIO.StringIO(content)
         quopri.encode(content, body, 0)
 
-        # now try to send the message
-        try:
-            # send the message as admin so bounces are sent there
-            # instead of to roundup
-            smtp = smtplib.SMTP(self.db.config.MAILHOST)
-            smtp.sendmail(self.db.config.ADMIN_EMAIL, [props['address']],
-                message.getvalue())
-        except socket.error, value:
-            self.error_message.append("Error: couldn't send "
-                "confirmation email: mailhost %s"%value)
-            return
-        except smtplib.SMTPException, value:
-            self.error_message.append("Error: couldn't send "
-                "confirmation email: %s"%value)
-            return
-
-        # commit changes to the database
-        self.db.commit()
-
-        # redirect to the "you're almost there" page
-        raise Redirect, '%s?:template=rego_step1_done'%self.base
+        if SENDMAILDEBUG:
+            # don't send - just write to a file
+            open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
+                self.db.config.ADMIN_EMAIL,
+                ', '.join(to),message.getvalue()))
+        else:
+            # now try to send the message
+            try:
+                # send the message as admin so bounces are sent there
+                # instead of to roundup
+                smtp = smtplib.SMTP(self.db.config.MAILHOST)
+                smtp.sendmail(self.db.config.ADMIN_EMAIL, [to],
+                    message.getvalue())
+            except socket.error, value:
+                self.error_message.append("Error: couldn't send email: "
+                    "mailhost %s"%value)
+                return 0
+            except smtplib.SMTPException, msg:
+                self.error_message.append("Error: couldn't send email: %s"%msg)
+            return 0
+        return 1
 
     def registerPermission(self, props):
         ''' Determine whether the user has permission to register
@@ -802,16 +827,16 @@ please visit the following URL:
         # create the new user
         cl = self.db.user
 # XXX we need to make the "default" page be able to display errors!
-#        try:
-        if 1:
+        try:
             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
+            del props['__time']
             self.userid = cl.create(**props)
             # clear the props from the otk database
             self.db.otks.destroy(otk)
             self.db.commit()
-#        except (ValueError, KeyError), message:
-#            self.error_message.append(str(message))
-#            return
+        except (ValueError, KeyError), message:
+            self.error_message.append(str(message))
+            return
 
         # log the new user in
         self.user = cl.get(self.userid, 'username')
@@ -829,9 +854,99 @@ please visit the following URL:
         # nice message
         message = _('You are now registered, welcome!')
 
-        # redirect to the item's edit page
-        raise Redirect, '%suser%s?@ok_message=%s'%(
-            self.base, self.userid,  urllib.quote(message))
+        # redirect to the user's page
+        raise Redirect, '%suser%s?@ok_message=%s&@template=%s'%(self.base,
+            self.userid, urllib.quote(message), urllib.quote(self.template))
+
+    def passResetAction(self):
+        ''' Handle password reset requests.
+
+            Presence of either "name" or "address" generate email.
+            Presense of "otk" performs the reset.
+        '''
+        if self.form.has_key('otk'):
+            # pull the rego information out of the otk database
+            otk = self.form['otk'].value
+            uid = self.db.otks.get(otk, 'uid')
+
+            # re-open the database as "admin"
+            if self.user != 'admin':
+                self.opendb('admin')
+
+            # change the password
+            newpw = ''.join([random.choice(self.chars) for x in range(8)])
+
+            cl = self.db.user
+# XXX we need to make the "default" page be able to display errors!
+            try:
+                # set the password
+                cl.set(uid, password=password.Password(newpw))
+                # clear the props from the otk database
+                self.db.otks.destroy(otk)
+                self.db.commit()
+            except (ValueError, KeyError), message:
+                self.error_message.append(str(message))
+                return
+
+            # user info
+            address = self.db.user.get(uid, 'address')
+            name = self.db.user.get(uid, 'username')
+
+            # send the email
+            tracker_name = self.db.config.TRACKER_NAME
+            subject = 'Password reset for %s'%tracker_name
+            body = '''
+The password has been reset for username "%(name)s".
+
+Your password is now: %(password)s
+'''%{'name': name, 'password': newpw}
+            if not self.sendEmail(address, subject, body):
+                return
+
+            self.ok_message.append('Password reset and email sent to %s'%address)
+            return
+
+        # no OTK, so now figure the user
+        if self.form.has_key('username'):
+            name = self.form['username'].value
+            try:
+                uid = self.db.user.lookup(name)
+            except KeyError:
+                self.error_message.append('Unknown username')
+                return
+            address = self.db.user.get(uid, 'address')
+        elif self.form.has_key('address'):
+            address = self.form['address'].value
+            uid = uidFromAddress(self.db, ('', address), create=0)
+            if not uid:
+                self.error_message.append('Unknown email address')
+                return
+            name = self.db.user.get(uid, 'username')
+        else:
+            self.error_message.append('You need to specify a username '
+                'or address')
+            return
+
+        # generate the one-time-key and store the props for later
+        otk = ''.join([random.choice(self.chars) for x in range(32)])
+        self.db.otks.set(otk, uid=uid, __time=time.time())
+
+        # send the email
+        tracker_name = self.db.config.TRACKER_NAME
+        subject = 'Confirm reset of password for %s'%tracker_name
+        body = '''
+Someone, perhaps you, has requested that the password be changed for your
+username, "%(name)s". If you wish to proceed with the change, please follow
+the link below:
+
+  %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
+
+You should then receive another email with the new password.
+'''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
+        if not self.sendEmail(address, subject, body):
+            return
+
+        self.ok_message.append('Email sent to %s'%address)
 
     def editItemAction(self):
         ''' Perform an edit of an item in the database.
@@ -839,29 +954,26 @@ please visit the following URL:
            See parsePropsFromForm and _editnodes for special variables
         '''
         # parse the props from the form
-# XXX reinstate exception handling
-#        try:
-        if 1:
+        try:
             props, links = self.parsePropsFromForm()
-#        except (ValueError, KeyError), message:
-#            self.error_message.append(_('Error: ') + str(message))
-#            return
+        except (ValueError, KeyError), message:
+            self.error_message.append(_('Error: ') + str(message))
+            return
 
         # handle the props
-# XXX reinstate exception handling
-#        try:
-        if 1:
+        try:
             message = self._editnodes(props, links)
-#        except (ValueError, KeyError, IndexError), message:
-#            self.error_message.append(_('Error: ') + str(message))
-#            return
+        except (ValueError, KeyError, IndexError), message:
+            self.error_message.append(_('Error: ') + str(message))
+            return
 
         # commit now that all the tricky stuff is done
         self.db.commit()
 
         # redirect to the item's edit page
-        raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
-            self.nodeid,  urllib.quote(message))
+        raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
+            self.classname, self.nodeid, urllib.quote(message),
+            urllib.quote(self.template))
 
     def editItemPermission(self, props):
         ''' Determine whether the user has permission to edit this item.
@@ -894,37 +1006,29 @@ please visit the following URL:
             special form values.
         '''
         # parse the props from the form
-# XXX reinstate exception handling
-#        try:
-        if 1:
+        try:
             props, links = self.parsePropsFromForm()
-#        except (ValueError, KeyError), message:
-#            self.error_message.append(_('Error: ') + str(message))
-#            return
+        except (ValueError, KeyError), message:
+            self.error_message.append(_('Error: ') + str(message))
+            return
 
         # handle the props - edit or create
-# XXX reinstate exception handling
-#        try:
-        if 1:
-            # create the context here
-#            cn = self.classname
-#            nid = self._createnode(cn, props[(cn, None)])
-#            del props[(cn, None)]
-
+        try:
             # when it hits the None element, it'll set self.nodeid
-            messages = self._editnodes(props, links) #, {(cn, None): nid})
+            messages = self._editnodes(props, links)
 
-#        except (ValueError, KeyError, IndexError), message:
-#            # these errors might just be indicative of user dumbness
-#            self.error_message.append(_('Error: ') + str(message))
-#            return
+        except (ValueError, KeyError, IndexError), message:
+            # these errors might just be indicative of user dumbness
+            self.error_message.append(_('Error: ') + str(message))
+            return
 
         # commit now that all the tricky stuff is done
         self.db.commit()
 
         # redirect to the new item's page
-        raise Redirect, '%s%s%s?@ok_message=%s'%(self.base, self.classname,
-            self.nodeid, urllib.quote(messages))
+        raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
+            self.classname, self.nodeid, urllib.quote(messages),
+            urllib.quote(self.template))
 
     def newItemPermission(self, props):
         ''' Determine whether the user has permission to create (edit) this
@@ -1044,7 +1148,7 @@ please visit the following URL:
         '''
         # check for permission
         if not self.editItemPermission(props):
-            raise PermissionError, 'You do not have permission to edit %s'%cn
+            raise Unauthorised, 'You do not have permission to edit %s'%cn
 
         # make the changes
         cl = self.db.classes[cn]
@@ -1055,7 +1159,7 @@ please visit the following URL:
         '''
         # check for permission
         if not self.newItemPermission(props):
-            raise PermissionError, 'You do not have permission to create %s'%cn
+            raise Unauthorised, 'You do not have permission to create %s'%cn
 
         # create the node and return its id
         cl = self.db.classes[cn]
@@ -1109,6 +1213,12 @@ please visit the following URL:
             nodeid, values = values[0], values[1:]
             found[nodeid] = 1
 
+            # see if the node exists
+            if cl.hasnode(nodeid):
+                exists = 1
+            else:
+                exists = 0
+
             # confirm correct weight
             if len(idlessprops) != len(values):
                 self.error_message.append(
@@ -1118,16 +1228,23 @@ please visit the following URL:
             # extract the new values
             d = {}
             for name, value in zip(idlessprops, values):
+                prop = cl.properties[name]
                 value = value.strip()
                 # only add the property if it has a value
                 if value:
                     # if it's a multilink, split it
-                    if isinstance(cl.properties[name], hyperdb.Multilink):
+                    if isinstance(prop, hyperdb.Multilink):
                         value = value.split(':')
                     d[name] = value
+                elif exists:
+                    # nuke the existing value
+                    if isinstance(prop, hyperdb.Multilink):
+                        d[name] = []
+                    else:
+                        d[name] = None
 
             # perform the edit
-            if cl.hasnode(nodeid):
+            if exists:
                 # edit existing
                 cl.set(nodeid, **d)
             else: