Switch to side-by-side view

--- a
+++ b/qiita_pet/handlers/user_handlers.py
@@ -0,0 +1,451 @@
+# -----------------------------------------------------------------------------
+# Copyright (c) 2014--, The Qiita Development Team.
+#
+# Distributed under the terms of the BSD 3-clause License.
+#
+# The full license is in the file LICENSE, distributed with this software.
+# -----------------------------------------------------------------------------
+
+import re
+from json import dumps
+import warnings
+
+from tornado.web import authenticated, HTTPError
+from wtforms import Form, StringField, BooleanField, validators
+from wtforms.validators import ValidationError
+
+from qiita_pet.handlers.base_handlers import BaseHandler
+from qiita_pet.handlers.api_proxy import user_jobs_get_req
+from qiita_pet.handlers.portal import PortalEditBase
+import qiita_db as qdb
+from qiita_db.util import send_email
+from qiita_db.user import User
+from qiita_db.logger import LogEntry
+from qiita_db.exceptions import QiitaDBUnknownIDError, QiitaDBError
+from qiita_core.exceptions import IncorrectPasswordError
+from qiita_core.util import execute_as_transaction
+from qiita_core.qiita_settings import qiita_config
+
+
+class UserProfile(Form):
+    def validate_general(value: str, infomsg: str, url_prefix: str):
+        """Validate basic user inputs, i.e. check for leading/trailing
+           whitespaces and leading URL prefix, like http://scholar.google.com/
+
+        Parameters
+        ----------
+        value : str
+            The WTform user input string.
+        infomsg : str
+            An error message to inform the user how to extract the correct
+            value.
+        url_prefix : str
+            The URL prefix of the social network
+
+        Returns
+        -------
+        None in case of empty input, otherwise the input value
+
+        Raises
+        ------
+        ValidationError if
+          a) input has leading or trailing whitespaces
+          b) input starts with the given url_prefix
+        """
+        if (value is None) or (value == ""):
+            # nothing to complain, as input is empty
+            return None
+
+        if value != value.strip():
+            raise ValidationError(
+                'Please remove all leading and trailing whitespaces from your '
+                'input.<br/>%s' % infomsg)
+
+        if len(url_prefix) > 0:
+            isPrefix = re.search("^%s" % url_prefix, value)
+            if isPrefix is not None:
+                raise ValidationError(
+                    'Please remove the "%s" part from your input.<br/>%s' % (
+                        isPrefix[0], infomsg))
+
+        # if there is still no error raised, we return the actual value of the
+        # user input
+        return value
+
+    def validator_orcid_id(form: Form, field: StringField):
+        """A WTForm validator to check if user input follows ORCID syntax.
+
+        Parameters
+        ----------
+        form : wtforms.Form
+            The WTform form enclosing the user input field.
+        field : wtforms.StringField
+            The WTform user input field.
+
+        Returns
+        -------
+        True, if user input is OK.
+
+        Raises
+        ------
+        ValidationError if user input is not valid
+        """
+        infomsg = ('Enter only your 16 digit numerical ORCID identifier, where'
+                   ' every four digits are separated with a dash "-". An '
+                   'example is: 0000-0002-0975-9019')
+        value = UserProfile.validate_general(
+            field.data, infomsg, 'https://orcid.org')
+        if value is None:
+            return True
+
+        if re.search(r"^\d{4}-\d{4}-\d{4}-\d{4}$", value) is None:
+            raise ValidationError(
+                "Your input does not follow the required format.<br/>%s" %
+                infomsg)
+
+    def validator_gscholar_id(form, field):
+        """A WTForm validator to check if user input follows google scholar ID
+           syntax.
+
+        Parameters
+        ----------
+        form : wtforms.Form
+            The WTform form enclosing the user input field.
+        field : wtforms.StringField
+            The WTform user input field.
+
+        Returns
+        -------
+        True, if user input is OK.
+
+        Raises
+        ------
+        ValidationError if user input is not valid
+        """
+        infomsg = ('To retrieve your google scholar ID, surf to your profile '
+                   'and copy the URL in your browser. It might read like '
+                   'https://scholar.google.com/citations?user=_e3QL94AAAAJ&'
+                   'hl=en<br/>Ignore everything left of the "?". The right '
+                   'part is a set of key=value pairs, separated by "&" '
+                   'characters. Find the key "user=", the right part up to '
+                   'the next "&" is your google scholar ID, in the example: '
+                   '"_e3QL94AAAAJ"')
+        # we need a regex here, since we don't know the TLD the user is
+        # presenting to us
+        value = UserProfile.validate_general(
+            field.data, infomsg, r'https://scholar.google.\w{1,3}/citations\?')
+        if value is None:
+            return True
+
+        if '&' in value:
+            raise ValidationError(
+                'Your input contains multiple key=value pairs (we found at '
+                'least one "&" character).<br/>%s' % infomsg)
+        if 'user=' in value:
+            raise ValidationError(
+                'Please remove the key "user" and the "=" character from '
+                'your input.<br/>%s' % infomsg)
+        if value.startswith('='):
+            raise ValidationError(
+                'Please remove leading "=" characters from your input.'
+                '<br/>%s' % infomsg)
+
+    def validator_rgate_id(form, field):
+        """A WTForm validator to check if user input follows ResearchGate
+           user names.
+
+        Parameters
+        ----------
+        form : wtforms.Form
+            The WTform form enclosing the user input field.
+        field : wtforms.StringField
+            The WTform user input field.
+
+        Returns
+        -------
+        True, if user input is OK.
+
+        Raises
+        ------
+        ValidationError if user input is not valid
+        """
+        infomsg = ('To retrieve your ResearchGate ID, surf to your profile '
+                   'and copy the URL in your browser. It might read like '
+                   'https://www.researchgate.net/profile/Rob-Knight<br/>'
+                   'Your ID is the part right of the last "/", in the example:'
+                   ' "Rob-Knight"')
+        value = UserProfile.validate_general(
+            field.data, infomsg, 'https://www.researchgate.net/profile/')
+        if value is None:
+            return True
+
+    name = StringField("Name", [validators.required()])
+    affiliation = StringField("Affiliation")
+    address = StringField("Address")
+    phone = StringField("Phone")
+    receive_processing_job_emails = BooleanField(
+        "Receive Processing Job Emails?")
+
+    social_orcid = StringField(
+        "ORCID", [validator_orcid_id], description="")
+    social_googlescholar = StringField(
+        "Google Scholar", [validator_gscholar_id], description="")
+    social_researchgate = StringField(
+        "ResearchGate", [validator_rgate_id], description="")
+
+
+class UserProfileHandler(BaseHandler):
+    """Displays user profile page and handles profile updates"""
+    @authenticated
+    def get(self):
+        profile = UserProfile()
+        profile.process(data=self.current_user.info)
+        self.render("user_profile.html", profile=profile, msg="", passmsg="",
+                    creation_timestamp=self.current_user.info[
+                        'creation_timestamp'])
+
+    @authenticated
+    @execute_as_transaction
+    def post(self):
+        passmsg = ""
+        msg = ""
+        user = self.current_user
+        action = self.get_argument("action")
+        form_data = UserProfile()
+        if action == "profile":
+            # tuple of columns available for profile
+            # FORM INPUT NAMES MUST MATCH DB COLUMN NAMES
+            not_str_fields = ('receive_processing_job_emails')
+            form_data.process(data=self.request.arguments)
+            profile = {name: data[0].decode('ascii')
+                       if name not in not_str_fields else
+                       data
+                       for name, data in form_data.data.items()}
+
+            # Turn default value as list into default strings
+            for field in form_data:
+                if field.name not in not_str_fields:
+                    field.data = field.data[0].decode('ascii')
+            if form_data.validate() is False:
+                msg = ("ERROR: profile could not be updated"
+                       " as some of your above inputs must be corrected.")
+            else:
+                try:
+                    user.info = profile
+                    msg = "Profile updated successfully"
+                except Exception as e:
+                    msg = "ERROR: profile could not be updated"
+                    LogEntry.create('Runtime', "Cound not update profile: %s" %
+                                    str(e), info={'User': user.id})
+
+        elif action == "password":
+            form_data.process(data=user.info)
+            oldpass = self.get_argument("oldpass")
+            newpass = self.get_argument("newpass")
+            try:
+                changed = user.change_password(oldpass, newpass)
+            except Exception as e:
+                passmsg = "ERROR: could not change password"
+                LogEntry.create('Runtime', "Could not change password: %s" %
+                                str(e), info={'User': user.id})
+            else:
+                if changed:
+                    passmsg = "Password changed successfully"
+                else:
+                    passmsg = "Incorrect old password"
+        self.render("user_profile.html", user=user.id, profile=form_data,
+                    msg=msg, passmsg=passmsg,
+                    creation_timestamp=self.current_user.info[
+                        'creation_timestamp'])
+
+
+class ForgotPasswordHandler(BaseHandler):
+    """Displays forgot password page and generates code for lost passwords"""
+    def get(self):
+        self.render("lost_pass.html", user=None, message="", level="")
+
+    @execute_as_transaction
+    def post(self):
+        message = ""
+        level = ""
+        page = "lost_pass.html"
+        user_id = None
+
+        try:
+            user = User(self.get_argument("email"))
+        except QiitaDBUnknownIDError:
+            message = "ERROR: Unknown user."
+            level = "danger"
+        else:
+            user_id = user.id
+            user.generate_reset_code()
+            info = user.info
+            try:
+                # qiita_config.base_url doesn't have a / at the end, but the
+                # qiita_config.portal_dir has it at the beginning but not at
+                # the end. This constructs the correct URL
+                url = qiita_config.base_url + qiita_config.portal_dir
+                send_email(user.id, "Qiita: Password Reset", "Please go to "
+                           "the following URL to reset your password: \n"
+                           "%s/auth/reset/%s  \nYou "
+                           "have 30 minutes from the time you requested a "
+                           "reset to change your password. After this period, "
+                           "you will have to request another reset." %
+                           (url, info["pass_reset_code"]))
+                message = ("Check your email for the reset code.")
+                level = "success"
+                page = "index.html"
+            except Exception as e:
+                message = ("Unable to send email. Error has been registered. "
+                           "Your password has not been reset.")
+                level = "danger"
+                LogEntry.create('Runtime', "Unable to send forgot password "
+                                "email: %s" % str(e), info={'User': user.id})
+
+        self.render(page, user=user_id, message=message, level=level)
+
+
+class ChangeForgotPasswordHandler(BaseHandler):
+    """Displays change password page and handles password reset"""
+    def get(self, code):
+        self.render("change_lost_pass.html", user=None, message="",
+                    level="", code=code)
+
+    @execute_as_transaction
+    def post(self, code):
+        message = ""
+        level = ""
+        page = "change_lost_pass.html"
+        user = None
+
+        try:
+            user = User(self.get_argument("email"))
+        except QiitaDBUnknownIDError:
+            message = "Unable to reset password"
+            level = "danger"
+        else:
+            newpass = self.get_argument("newpass")
+            try:
+                changed = user.change_forgot_password(code, newpass)
+            except IncorrectPasswordError:
+                message = "The new password is not valid. Try again."
+                changed = False
+            except QiitaDBError:
+                message = "Invalid code. Request a new one."
+                changed = False
+
+            if changed:
+                message = ("Password reset successful. Please log in to "
+                           "continue.")
+                level = "success"
+                page = "index.html"
+            else:
+                if message != "":
+                    message = ("Unable to reset password. Most likely your "
+                               "email is incorrect or your reset window has "
+                               "timed out.")
+                level = "danger"
+
+        self.render(page, message=message, level=level, code=code)
+
+
+class UserMessagesHander(BaseHandler):
+    @authenticated
+    def get(self):
+        self.render("user_messages.html",
+                    messages=self.current_user.messages())
+
+    def post(self):
+        action = self.get_argument("action")
+        messages = self.get_arguments("messages")
+        if len(messages) == 0:
+            HTTPError(400, "No messages passed")
+
+        if action == "read":
+            self.current_user.mark_messages(messages, read=True)
+        elif action == "unread":
+            self.current_user.mark_messages(messages, read=False)
+        elif action == "delete":
+            self.current_user.delete_messages(messages)
+        else:
+            raise HTTPError(400, reason="Unknown action: %s" % action)
+
+        self.render("user_messages.html",
+                    messages=self.current_user.messages())
+
+
+class UserJobs(BaseHandler):
+    @authenticated
+    def get(self):
+        response = user_jobs_get_req(self.current_user)
+        self.write(response)
+
+
+class PurgeUsersAJAXHandler(PortalEditBase):
+    # define columns besides email that will be displayed on website
+    FIELDS = ['name', 'affiliation', 'address', 'phone',
+              'creation_timestamp']
+
+    @authenticated
+    @execute_as_transaction
+    def get(self):
+        # retrieving users not yet verified
+        self.check_admin()
+        with qdb.sql_connection.TRN:
+            sql = """SELECT email,{0}
+                     FROM qiita.qiita_user
+                     WHERE (user_level_id=5) AND
+                           (creation_timestamp < (NOW() - INTERVAL '30 DAY'))
+                  """.format(','.join(self.FIELDS))
+            qdb.sql_connection.TRN.add(sql)
+            users = qdb.sql_connection.TRN.execute()[1:]
+
+        # fetching information for each user
+        result = []
+        for list in users:
+            for user in list:
+                usermail = user[0]
+                user_unit = {'email': usermail}
+                user_infos = User(usermail).info
+                for col in self.FIELDS:
+                    user_unit[col] = str(user_infos[col])
+                result.append(user_unit)
+        # returning information as JSON
+        self.write(dumps(result, separators=(',', ':')))
+
+
+class PurgeUsersHandler(PortalEditBase):
+    @authenticated
+    @execute_as_transaction
+    def get(self):
+        # render page and transfer headers to be included for the table
+        self.check_admin()
+        self.render('admin_purge_users.html',
+                    headers=['email'] + PurgeUsersAJAXHandler.FIELDS,
+                    submit_url="/admin/purge_users/")
+
+    def post(self):
+        # check if logged in user is admin and fetch all checked boxes as well
+        # as the action
+        self.check_admin()
+        users = map(str, self.get_arguments('selected'))
+        action = self.get_argument('action')
+
+        # depending on the action delete user from db (remove)
+        num_deleted_user = 0
+        for user in users:
+            try:
+                with warnings.catch_warnings(record=True) as warns:
+                    if action == "Remove":
+                        user_to_delete = User(user)
+                        user_to_delete.delete(user)
+                        num_deleted_user += 1
+                    else:
+                        raise HTTPError(
+                            400, reason="Unknown action: %s" % action)
+            except QiitaDBError as e:
+                self.write(action.upper() + " ERROR:<br/>" + str(e))
+                return
+        msg = '; '.join([str(w.message) for w in warns])
+        self.write(("%i non-validated user(s) successfully removed from "
+                    "database<br/>%s") % (num_deleted_user, msg))