/grumponado/handlers.py
Python | 348 lines | 336 code | 5 blank | 7 comment | 1 complexity | 3910b01e85e6198cfbaf2bd9e85b2a67 MD5 | raw file
Possible License(s): MPL-2.0-no-copyleft-exception
- # This Source Code Form is subject to the terms of the Mozilla Public
- # License, v. 2.0. If a copy of the MPL was not distributed with this
- # file, You can obtain one at http://mozilla.org/MPL/2.0/.
- """
- Handlers
- ========
- """
- import concurrent.futures
- import json
- import logging
- from sqlalchemy.exc import IntegrityError
- from tornado.escape import json_decode
- from tornado.gen import coroutine
- from tornado.web import authenticated, MissingArgumentError
- from tornado_sqlalchemy import as_future
- from wtforms.fields import StringField
- from wtforms.validators import Email, DataRequired, Length
- from wtforms_tornado import Form
- from . import errors, messages
- from .basehandler import Basehandler
- from .models import Users, Tokens, SelfRatings, CommunityRatings
- from .utils import hash_password, compare_hashes
- EXECUTOR = concurrent.futures.ThreadPoolExecutor(2)
- class SignupForm(Form):
- """Validator for the signup form. There must string "screenname" of at least
- 3 and at max 30 letters or numbers. The email string must be a valid email
- address and there must be a password of at least 8 characters.
- * The name field, a required field of minimum 3 and maximum characters"
- * The email field is required and must be a valid email address
- * Password, any non null characters are allowed. It must be at least 8
- characters long"
- Args:
- wtformms_tornado.Form (the formdata to validate against the before mentioned criteria):
- """
- name = StringField(validators=[DataRequired(), Length(min=3, max=30)])
- email = StringField(validators=[DataRequired(), Email(), Length(max=120)])
- password = StringField(validators=[DataRequired(), Length(min=8)])
- class MainHandler(Basehandler):
- """The main handler is responsible to respond for the base routes. This
- route is used to have an entry point and to provide template rendered views.
- """
- @coroutine
- def get(self):
- """The get request is used to retrieve a description of this api.
- Returns:
- response: either an error message or a success message if everything
- worked correctly
- """
- self.write_json_message(messages.LISTING)
- class UserHandler(Basehandler):
- """The class `.UserHandler` is responsible to mutate users. This also
- includes the creation of the new users. GrumpONado uses the http word
- **POST** to create a new user. Accordingly the words **DELETE** is used to
- delete a user and the verb **PUT** is used to update a user. **DELETE** and
- **PUT** are only allowed with authorized users.
- """
- @coroutine
- def post(self):
- """The post request is the central action of the UserHandler. Requests
- are expected to be a **JSON** file containing a **name**,
- **email address** and a **password**. The validation will check for each
- field to be available. Also the emails are validated against the WTForm
- validator.
- First we validate the request body for the correct json encoding.
- Ideally the data provided by the user are already valid. Therefore it
- is only checked if the validation fails. This simplifies the nesting.
- Also it saves resources when stopping before expensive calculations are
- performed on invalid data.
- Returns:
- response: either an error message or a success message if everything
- worked correctly
- """
- logging.debug('Request to create a new user.')
- try:
- request_data = json_decode(self.request.body)
- form = SignupForm(data=request_data)
- if not form.validate():
- logging.info('%s', form.errors)
- validation_error = errors.VALIDATION_ERROR
- validation_error['result'] = form.errors
- self.write_error(400, error_message=validation_error)
- else:
- password = yield EXECUTOR.submit(hash_password, request_data['password'], 12)
- user = Users(
- user=request_data['name'],
- email=request_data['email'],
- password=password,
- avatar=''
- )
- logging.debug('Attempting to create the new user on the db')
- with self.make_session() as session:
- as_future(session.add(user))
- as_future(session.commit())
- logging.info('Successfully created the new user')
- logging.debug('Returning the success message')
- self.write_json_message(messages.USER_CREATED(user.user_name))
- except json.JSONDecodeError as err:
- logging.warning('The request request was encoded as something other than json, failed to '
- 'parse the request body as json'
- 'This exception happens if developer does not encode the request data as'
- ' json before sending it.')
- logging.warning(err)
- self.write_error(400, error_message=errors.NOT_VALID_JSON)
- except TypeError as err:
- logging.info('The type of the request is not what was expected. '
- 'This exception happens if the user requests a number as user name or as email')
- logging.info(err)
- self.write_error(400, error_message=errors.NOT_STR)
- except IntegrityError as err:
- logging.info('Could not create a user on the database. '
- 'This usually happens if a database check fails. Most commonly if an email or user name '
- 'exists already on the database')
- logging.info(err)
- self.write_error(400, error_message=errors.USER_ALREADY_EXISTS)
- class UserProfileHandler(Basehandler):
- """The user profile handler is the visualisation of either single users or
- the users as search result. It does not need any authorization. However if a
- user is authorized he may see additional information.
- """
- @coroutine
- def get(self, user_name):
- """Looks up users from the database. To look up a single user, the
- corresponding profile has to be requested with a **GET** request with
- the parameter user_name. If the user is found in the database the
- response will contain the public profile. If an authorized requests a
- profile he will also receive the public profile. The private profile is
- for the /me route.
- Args:
- user_name (str): The username to update
- Returns:
- response: The user if it was found. Otherwise a 404 not found
- message is sent.
- """
- logging.debug('Looking up user %s', user_name)
- with self.make_session() as session:
- user_from_db = yield as_future(session.query(Users).filter(Users.user_name == user_name).first)
- user_dict = user_from_db.__repr__()
- if user_from_db:
- msg = messages.USER_FOUND(user_dict)
- self.write_json_message(msg)
- else:
- self.write_error(404, errors.USER_NOT_FOUND({'user': user_name}))
- @coroutine
- @authenticated
- def put(self, user_name):
- """By sending a PUT request to a username resource, the rating is
- updated. The update requires both, an active and authorized user and an
- action.
- The **action** is either **increase** or **decrease** and it must be
- provided.
- If the requested user is identical to the acrive user, the self
- rating is updated otherwise the community rating receives an update.
- Args:
- user_name (str): The username to update
- Returns:
- response: On success the rating is returned with the update
- """
- logging.debug('Changing the grumpiness of user %s by %s', user_name, self.get_current_user())
- # Set the default action to decrease
- increase = False
- # Extract the action from the form actions of the http request
- try:
- action = self.get_body_argument('action')
- if action == 'increase':
- increase = True
- # Handling the missing http parameter
- except MissingArgumentError:
- self.write_error(403, errors.NOT_ALLOWED)
- self.finish()
- # Look up user on the database
- with self.make_session() as session:
- user_from_db = yield as_future(session.query(Users).filter(Users.user_name == user_name).first)
- # Extract the user id to use as the parent ID for the ratings
- user_id = user_from_db.id
- # The user data as dict to return the formatted results
- user_dict = user_from_db.__repr__()
- if user_id == self.current_user['id']:
- new_self = user_from_db.get_new_self_rating({'increase': increase})
- as_future(session.add(new_self))
- user_dict['self_rating'] = new_self.current_value()
- else:
- new_community = user_from_db.get_new_community_rating({'increase': increase})
- as_future(session.add(new_community))
- user_dict['community_rating'] = new_community.current_value()
- as_future(session.commit())
- if user_dict:
- msg = messages.RATING_SUCCESSFUL(user_dict)
- self.write_json_message(msg)
- self.write_error(403, errors.NOT_ALLOWED)
- class LoginHandler(Basehandler):
- """The LoginHandler provides a convenient way to log users on by sending
- their credentials in a post request. The post request may either contain the
- credentials as json encoded body or as fallback encoded form data.
- Careful attention is given to the design, so the standard get_current
- method is used wherever possible.
- """
- def _prepare_json(self, message):
- """
- Args:
- message:
- """
- if isinstance(message, dict):
- return {**message, **{'auth': self.get_current_user_token()}}
- return None
- @coroutine
- def _check_credentials(self, email, password):
- """Checks if the user credentials are valid.
- Args:
- email (string): the email address of the user to login
- password (string): the password
- Returns:
- AuthenticatedUser: the authenticated user id and name are going to
- be returned if the password and email match to a real user
- """
- with self.make_session() as session:
- user_from_db = yield as_future(session.query(Users)
- .filter(Users.email == email)
- .first)
- if user_from_db is not None:
- match = yield EXECUTOR.submit(compare_hashes, password, user_from_db.password)
- if match:
- return {'id': user_from_db.id,
- 'name': user_from_db.user_name}
- return None
- @coroutine
- def prepare(self):
- """The prepare method is always run before the post method is called.
- Tornado also allows it to be a coroutine. This way, the credentials can
- be checked against the database and still be able to use the
- get_current_user() method on the login.
- """
- request_data = self._get_credentials()
- checked_identity = yield self._check_credentials(request_data['email'], request_data['password'])
- if checked_identity:
- ip_address = self.request.remote_ip or self.request.headers.get('X-Real-IP')
- agent = self.request.headers.get('User-Agent')
- refresh_token = yield self._store_refresh_token(checked_identity['id'], ip_address, agent)
- self.current_user = {**checked_identity, **{'refresh': refresh_token}}
- @coroutine
- def post(self):
- """Posting user credentials to the login handler logs a user on. The
- post router just checks if a "current_user" is available. Therefore is
- the post route rather simple. The logic to verify credentials is
- delegated to the preparation.
- """
- if self.current_user:
- self.write_json_message(messages.LOGIN_OK({'user_name': self.current_user['name']}))
- else:
- self.write_error(400, errors.PASSWORD_OR_NAME_INVALID)
- class UserMeHandler(Basehandler):
- """The me handler provides access to the 'self' of logged in users. It
- basically returns the current user. However, this needs to be isolated from
- the user profile and especially from the login, since these handlers use
- overwrites. Accessing the current user only works properly without the
- overwrites.
- """
- @coroutine
- @authenticated
- def get(self):
- """GET"""
- with self.make_session() as session:
- found_user = yield as_future(
- session.query(Users).filter(Users.user_name == self.current_user['name']).first)
- if found_user:
- user_data = {**found_user.__repr__(), **{'email': found_user.email}}
- self.write_json_message(messages.USER_SELF(user_data))
- else:
- self.write_error(401, errors.NOT_ALLOWED)
- @coroutine
- @authenticated
- def delete(self):
- """To delete a user, the user must be logged in and request the delete route without a body.
- All Tokens and ratings are deleted along with the user itself.
- """
- with self.make_session() as session:
- user_from_db = yield as_future(
- session.query(Users).filter(Users.user_name == self.current_user['name']).first)
- if user_from_db:
- as_future(session.query(Tokens).filter(Tokens.user_id == user_from_db.id). \
- delete(synchronize_session=False))
- as_future(session.query(SelfRatings).filter(SelfRatings.parent_id == user_from_db.id). \
- delete(synchronize_session=False))
- as_future(session.query(CommunityRatings).filter(CommunityRatings.parent_id == user_from_db.id). \
- delete(synchronize_session=False))
- session.delete(user_from_db)
- self.write_json_message(messages.USER_DELETED)
- class LogoutHandler(Basehandler):
- """The logout handler is only used to log out users. It is its own handler,
- to allow the route /logout with a GET request.
- """
- def _prepare_json(self, message):
- """
- Args:
- message:
- """
- return message
- @coroutine
- def get(self):
- """Logs out a user with a simple get. This method basically deletes all
- cookies with the login and refresh tokens.
- """
- if self.current_user:
- with self.make_session() as session:
- as_future(session.query(Tokens).filter(Tokens.token == self.current_user['refresh']).delete())
- as_future(session.commit())
- self.write_json_message(messages.LOGOUT)