PageRenderTime 54ms CodeModel.GetById 14ms RepoModel.GetById 0ms app.codeStats 0ms

/grumponado/handlers.py

https://bitbucket.org/angryshortone/grump-o-nado
Python | 348 lines | 336 code | 5 blank | 7 comment | 1 complexity | 3910b01e85e6198cfbaf2bd9e85b2a67 MD5 | raw file
Possible License(s): MPL-2.0-no-copyleft-exception
  1. # This Source Code Form is subject to the terms of the Mozilla Public
  2. # License, v. 2.0. If a copy of the MPL was not distributed with this
  3. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
  4. """
  5. Handlers
  6. ========
  7. """
  8. import concurrent.futures
  9. import json
  10. import logging
  11. from sqlalchemy.exc import IntegrityError
  12. from tornado.escape import json_decode
  13. from tornado.gen import coroutine
  14. from tornado.web import authenticated, MissingArgumentError
  15. from tornado_sqlalchemy import as_future
  16. from wtforms.fields import StringField
  17. from wtforms.validators import Email, DataRequired, Length
  18. from wtforms_tornado import Form
  19. from . import errors, messages
  20. from .basehandler import Basehandler
  21. from .models import Users, Tokens, SelfRatings, CommunityRatings
  22. from .utils import hash_password, compare_hashes
  23. EXECUTOR = concurrent.futures.ThreadPoolExecutor(2)
  24. class SignupForm(Form):
  25. """Validator for the signup form. There must string "screenname" of at least
  26. 3 and at max 30 letters or numbers. The email string must be a valid email
  27. address and there must be a password of at least 8 characters.
  28. * The name field, a required field of minimum 3 and maximum characters"
  29. * The email field is required and must be a valid email address
  30. * Password, any non null characters are allowed. It must be at least 8
  31. characters long"
  32. Args:
  33. wtformms_tornado.Form (the formdata to validate against the before mentioned criteria):
  34. """
  35. name = StringField(validators=[DataRequired(), Length(min=3, max=30)])
  36. email = StringField(validators=[DataRequired(), Email(), Length(max=120)])
  37. password = StringField(validators=[DataRequired(), Length(min=8)])
  38. class MainHandler(Basehandler):
  39. """The main handler is responsible to respond for the base routes. This
  40. route is used to have an entry point and to provide template rendered views.
  41. """
  42. @coroutine
  43. def get(self):
  44. """The get request is used to retrieve a description of this api.
  45. Returns:
  46. response: either an error message or a success message if everything
  47. worked correctly
  48. """
  49. self.write_json_message(messages.LISTING)
  50. class UserHandler(Basehandler):
  51. """The class `.UserHandler` is responsible to mutate users. This also
  52. includes the creation of the new users. GrumpONado uses the http word
  53. **POST** to create a new user. Accordingly the words **DELETE** is used to
  54. delete a user and the verb **PUT** is used to update a user. **DELETE** and
  55. **PUT** are only allowed with authorized users.
  56. """
  57. @coroutine
  58. def post(self):
  59. """The post request is the central action of the UserHandler. Requests
  60. are expected to be a **JSON** file containing a **name**,
  61. **email address** and a **password**. The validation will check for each
  62. field to be available. Also the emails are validated against the WTForm
  63. validator.
  64. First we validate the request body for the correct json encoding.
  65. Ideally the data provided by the user are already valid. Therefore it
  66. is only checked if the validation fails. This simplifies the nesting.
  67. Also it saves resources when stopping before expensive calculations are
  68. performed on invalid data.
  69. Returns:
  70. response: either an error message or a success message if everything
  71. worked correctly
  72. """
  73. logging.debug('Request to create a new user.')
  74. try:
  75. request_data = json_decode(self.request.body)
  76. form = SignupForm(data=request_data)
  77. if not form.validate():
  78. logging.info('%s', form.errors)
  79. validation_error = errors.VALIDATION_ERROR
  80. validation_error['result'] = form.errors
  81. self.write_error(400, error_message=validation_error)
  82. else:
  83. password = yield EXECUTOR.submit(hash_password, request_data['password'], 12)
  84. user = Users(
  85. user=request_data['name'],
  86. email=request_data['email'],
  87. password=password,
  88. avatar=''
  89. )
  90. logging.debug('Attempting to create the new user on the db')
  91. with self.make_session() as session:
  92. as_future(session.add(user))
  93. as_future(session.commit())
  94. logging.info('Successfully created the new user')
  95. logging.debug('Returning the success message')
  96. self.write_json_message(messages.USER_CREATED(user.user_name))
  97. except json.JSONDecodeError as err:
  98. logging.warning('The request request was encoded as something other than json, failed to '
  99. 'parse the request body as json'
  100. 'This exception happens if developer does not encode the request data as'
  101. ' json before sending it.')
  102. logging.warning(err)
  103. self.write_error(400, error_message=errors.NOT_VALID_JSON)
  104. except TypeError as err:
  105. logging.info('The type of the request is not what was expected. '
  106. 'This exception happens if the user requests a number as user name or as email')
  107. logging.info(err)
  108. self.write_error(400, error_message=errors.NOT_STR)
  109. except IntegrityError as err:
  110. logging.info('Could not create a user on the database. '
  111. 'This usually happens if a database check fails. Most commonly if an email or user name '
  112. 'exists already on the database')
  113. logging.info(err)
  114. self.write_error(400, error_message=errors.USER_ALREADY_EXISTS)
  115. class UserProfileHandler(Basehandler):
  116. """The user profile handler is the visualisation of either single users or
  117. the users as search result. It does not need any authorization. However if a
  118. user is authorized he may see additional information.
  119. """
  120. @coroutine
  121. def get(self, user_name):
  122. """Looks up users from the database. To look up a single user, the
  123. corresponding profile has to be requested with a **GET** request with
  124. the parameter user_name. If the user is found in the database the
  125. response will contain the public profile. If an authorized requests a
  126. profile he will also receive the public profile. The private profile is
  127. for the /me route.
  128. Args:
  129. user_name (str): The username to update
  130. Returns:
  131. response: The user if it was found. Otherwise a 404 not found
  132. message is sent.
  133. """
  134. logging.debug('Looking up user %s', user_name)
  135. with self.make_session() as session:
  136. user_from_db = yield as_future(session.query(Users).filter(Users.user_name == user_name).first)
  137. user_dict = user_from_db.__repr__()
  138. if user_from_db:
  139. msg = messages.USER_FOUND(user_dict)
  140. self.write_json_message(msg)
  141. else:
  142. self.write_error(404, errors.USER_NOT_FOUND({'user': user_name}))
  143. @coroutine
  144. @authenticated
  145. def put(self, user_name):
  146. """By sending a PUT request to a username resource, the rating is
  147. updated. The update requires both, an active and authorized user and an
  148. action.
  149. The **action** is either **increase** or **decrease** and it must be
  150. provided.
  151. If the requested user is identical to the acrive user, the self
  152. rating is updated otherwise the community rating receives an update.
  153. Args:
  154. user_name (str): The username to update
  155. Returns:
  156. response: On success the rating is returned with the update
  157. """
  158. logging.debug('Changing the grumpiness of user %s by %s', user_name, self.get_current_user())
  159. # Set the default action to decrease
  160. increase = False
  161. # Extract the action from the form actions of the http request
  162. try:
  163. action = self.get_body_argument('action')
  164. if action == 'increase':
  165. increase = True
  166. # Handling the missing http parameter
  167. except MissingArgumentError:
  168. self.write_error(403, errors.NOT_ALLOWED)
  169. self.finish()
  170. # Look up user on the database
  171. with self.make_session() as session:
  172. user_from_db = yield as_future(session.query(Users).filter(Users.user_name == user_name).first)
  173. # Extract the user id to use as the parent ID for the ratings
  174. user_id = user_from_db.id
  175. # The user data as dict to return the formatted results
  176. user_dict = user_from_db.__repr__()
  177. if user_id == self.current_user['id']:
  178. new_self = user_from_db.get_new_self_rating({'increase': increase})
  179. as_future(session.add(new_self))
  180. user_dict['self_rating'] = new_self.current_value()
  181. else:
  182. new_community = user_from_db.get_new_community_rating({'increase': increase})
  183. as_future(session.add(new_community))
  184. user_dict['community_rating'] = new_community.current_value()
  185. as_future(session.commit())
  186. if user_dict:
  187. msg = messages.RATING_SUCCESSFUL(user_dict)
  188. self.write_json_message(msg)
  189. self.write_error(403, errors.NOT_ALLOWED)
  190. class LoginHandler(Basehandler):
  191. """The LoginHandler provides a convenient way to log users on by sending
  192. their credentials in a post request. The post request may either contain the
  193. credentials as json encoded body or as fallback encoded form data.
  194. Careful attention is given to the design, so the standard get_current
  195. method is used wherever possible.
  196. """
  197. def _prepare_json(self, message):
  198. """
  199. Args:
  200. message:
  201. """
  202. if isinstance(message, dict):
  203. return {**message, **{'auth': self.get_current_user_token()}}
  204. return None
  205. @coroutine
  206. def _check_credentials(self, email, password):
  207. """Checks if the user credentials are valid.
  208. Args:
  209. email (string): the email address of the user to login
  210. password (string): the password
  211. Returns:
  212. AuthenticatedUser: the authenticated user id and name are going to
  213. be returned if the password and email match to a real user
  214. """
  215. with self.make_session() as session:
  216. user_from_db = yield as_future(session.query(Users)
  217. .filter(Users.email == email)
  218. .first)
  219. if user_from_db is not None:
  220. match = yield EXECUTOR.submit(compare_hashes, password, user_from_db.password)
  221. if match:
  222. return {'id': user_from_db.id,
  223. 'name': user_from_db.user_name}
  224. return None
  225. @coroutine
  226. def prepare(self):
  227. """The prepare method is always run before the post method is called.
  228. Tornado also allows it to be a coroutine. This way, the credentials can
  229. be checked against the database and still be able to use the
  230. get_current_user() method on the login.
  231. """
  232. request_data = self._get_credentials()
  233. checked_identity = yield self._check_credentials(request_data['email'], request_data['password'])
  234. if checked_identity:
  235. ip_address = self.request.remote_ip or self.request.headers.get('X-Real-IP')
  236. agent = self.request.headers.get('User-Agent')
  237. refresh_token = yield self._store_refresh_token(checked_identity['id'], ip_address, agent)
  238. self.current_user = {**checked_identity, **{'refresh': refresh_token}}
  239. @coroutine
  240. def post(self):
  241. """Posting user credentials to the login handler logs a user on. The
  242. post router just checks if a "current_user" is available. Therefore is
  243. the post route rather simple. The logic to verify credentials is
  244. delegated to the preparation.
  245. """
  246. if self.current_user:
  247. self.write_json_message(messages.LOGIN_OK({'user_name': self.current_user['name']}))
  248. else:
  249. self.write_error(400, errors.PASSWORD_OR_NAME_INVALID)
  250. class UserMeHandler(Basehandler):
  251. """The me handler provides access to the 'self' of logged in users. It
  252. basically returns the current user. However, this needs to be isolated from
  253. the user profile and especially from the login, since these handlers use
  254. overwrites. Accessing the current user only works properly without the
  255. overwrites.
  256. """
  257. @coroutine
  258. @authenticated
  259. def get(self):
  260. """GET"""
  261. with self.make_session() as session:
  262. found_user = yield as_future(
  263. session.query(Users).filter(Users.user_name == self.current_user['name']).first)
  264. if found_user:
  265. user_data = {**found_user.__repr__(), **{'email': found_user.email}}
  266. self.write_json_message(messages.USER_SELF(user_data))
  267. else:
  268. self.write_error(401, errors.NOT_ALLOWED)
  269. @coroutine
  270. @authenticated
  271. def delete(self):
  272. """To delete a user, the user must be logged in and request the delete route without a body.
  273. All Tokens and ratings are deleted along with the user itself.
  274. """
  275. with self.make_session() as session:
  276. user_from_db = yield as_future(
  277. session.query(Users).filter(Users.user_name == self.current_user['name']).first)
  278. if user_from_db:
  279. as_future(session.query(Tokens).filter(Tokens.user_id == user_from_db.id). \
  280. delete(synchronize_session=False))
  281. as_future(session.query(SelfRatings).filter(SelfRatings.parent_id == user_from_db.id). \
  282. delete(synchronize_session=False))
  283. as_future(session.query(CommunityRatings).filter(CommunityRatings.parent_id == user_from_db.id). \
  284. delete(synchronize_session=False))
  285. session.delete(user_from_db)
  286. self.write_json_message(messages.USER_DELETED)
  287. class LogoutHandler(Basehandler):
  288. """The logout handler is only used to log out users. It is its own handler,
  289. to allow the route /logout with a GET request.
  290. """
  291. def _prepare_json(self, message):
  292. """
  293. Args:
  294. message:
  295. """
  296. return message
  297. @coroutine
  298. def get(self):
  299. """Logs out a user with a simple get. This method basically deletes all
  300. cookies with the login and refresh tokens.
  301. """
  302. if self.current_user:
  303. with self.make_session() as session:
  304. as_future(session.query(Tokens).filter(Tokens.token == self.current_user['refresh']).delete())
  305. as_future(session.commit())
  306. self.write_json_message(messages.LOGOUT)