diff --git a/app/api/dao/user_extension.py b/app/api/dao/user_extension.py index 8488e3b..244dd37 100644 --- a/app/api/dao/user_extension.py +++ b/app/api/dao/user_extension.py @@ -23,9 +23,9 @@ def create_user_additional_info(data): """ try: - user_id = AUTH_COOKIE["user_id"].value - except KeyError: - return messages.USER_ID_IS_NOT_RETRIEVED, HTTPStatus.FORBIDDEN + user_id = int(AUTH_COOKIE["user_id"].value) + except ValueError: + return messages.USER_ID_IS_NOT_RETRIEVED, HTTPStatus.FORBIDDEN timezone_value = data["timezone"] @@ -73,3 +73,42 @@ def get_user_additional_data_info(user_id): "mobile": result.additional_info["mobile"], "personal_website": result.additional_info["personal_website"] } + return + + @staticmethod + def update_user_additional_info(data): + """Updates a user_extension instance. + + Arguments: + data: A list containing user's id, boolean value of whether or not + the user is representing an organization, as well as their timezone + + Returns: + A dictionary containing "message" which indicates whether or not the user_exension was created successfully and "code" for the HTTP response code. + """ + + try: + user_id = int(AUTH_COOKIE["user_id"].value) + except ValueError: + return messages.USER_ID_IS_NOT_RETRIEVED, HTTPStatus.FORBIDDEN + + timezone_value = data["timezone"] + timezone = Timezone(timezone_value).name + additional_info_data = {} + + existing_additional_info = UserExtensionModel.find_by_user_id(user_id) + if not existing_additional_info: + return messages.ADDITIONAL_INFORMATION_DOES_NOT_EXIST, HTTPStatus.NOT_FOUND + try: + existing_additional_info.is_organization_rep = data["is_organization_rep"] + existing_additional_info.timezone = timezone + additional_info_data["phone"] = data["phone"] + additional_info_data["mobile"] = data["mobile"] + additional_info_data["personal_website"] = data["personal_website"] + except KeyError as e: + return e, HTTPStatus.BAD_REQUEST + + existing_additional_info.additional_info = additional_info_data + existing_additional_info.save_to_db() + + return messages.ADDITIONAL_INFO_SUCCESSFULLY_UPDATED, HTTPStatus.OK diff --git a/app/api/request_api_utils.py b/app/api/request_api_utils.py index 6861600..35c6b6b 100644 --- a/app/api/request_api_utils.py +++ b/app/api/request_api_utils.py @@ -36,6 +36,7 @@ def post_request(request_string, data): access_expiry_cookie = response_message.get("access_expiry") AUTH_COOKIE["Authorization"] = f"Bearer {access_token_cookie}" AUTH_COOKIE["Authorization"]["expires"] = access_expiry_cookie + AUTH_COOKIE["user_id"] = None response_message = {"access_token": response_message.get("access_token"), "access_expiry": response_message.get("access_expiry")} logging.fatal(f"{response_message}") @@ -106,7 +107,7 @@ def put_request(request_string, token, data): def validate_token(token): - if not token or not AUTH_COOKIE: + if not token: return messages.AUTHORISATION_TOKEN_IS_MISSING, HTTPStatus.UNAUTHORIZED if AUTH_COOKIE: if token != AUTH_COOKIE["Authorization"].value: diff --git a/app/api/resources/users.py b/app/api/resources/users.py index 3563d11..6cfa03b 100644 --- a/app/api/resources/users.py +++ b/app/api/resources/users.py @@ -184,8 +184,13 @@ def put(cls): return http_response_checker(put_request("/user", token, data)) +@users_ns.doc( + responses={ + HTTPStatus.INTERNAL_SERVER_ERROR: f"{messages.INTERNAL_SERVER_ERROR['message']}" + } +) @users_ns.response( - HTTPStatus.UNAUTHORIZED, + HTTPStatus.UNAUTHORIZED, f"{messages.TOKEN_HAS_EXPIRED}\n" f"{messages.TOKEN_IS_INVALID}\n" f"{messages.AUTHORISATION_TOKEN_IS_MISSING}" @@ -207,8 +212,8 @@ def get(cls): Returns additional information of current user A user with valid access token can use this endpoint to view their additional information details. - The endpoint doesn't take any other input. But the user must get their personal details first - before they can send this request for getting the additional information. + The endpoint doesn't take any other input. This request only accessible once user retrieve their user_id + by sending GET /user/personal_details. """ token = request.headers.environ["HTTP_AUTHORIZATION"] @@ -217,24 +222,19 @@ def get(cls): if not is_wrong_token: try: - user_id = AUTH_COOKIE["user_id"].value - except KeyError: - return messages.USER_ID_IS_NOT_RETRIEVED, HTTPStatus.FORBIDDEN - + user_id = int(AUTH_COOKIE["user_id"].value) + except ValueError: + return messages.USER_ID_IS_NOT_RETRIEVED, HTTPStatus.FORBIDDEN result = UserExtensionDAO.get_user_additional_data_info(user_id) if not result: return messages.ADDITIONAL_INFORMATION_DOES_NOT_EXIST, HTTPStatus.NOT_FOUND return result + return is_wrong_token @classmethod @users_ns.doc("create_user_additional_info") - @users_ns.doc( - responses={ - HTTPStatus.INTERNAL_SERVER_ERROR: f"{messages.INTERNAL_SERVER_ERROR['message']}" - } - ) @users_ns.response( HTTPStatus.CREATED, f"{messages.ADDITIONAL_INFO_SUCCESSFULLY_CREATED}" ) @@ -258,10 +258,9 @@ def post(cls): A user with valid access token can use this endpoint to add additional information to their own data. The endpoint takes any of the given parameters (is_organization_rep (true or false value), timezone - (with value as per Timezone Enum Name) and additional_info (dictionary of phone, mobile and personal_website)). - The response contains a success or error message. This request only accessible once user confirm - their additional information have not already exist in the data through sending GET request for - additional information. + (with value as per Timezone Enum Value) and additional_info (dictionary of phone, mobile and personal_website)). + The response contains a success or error message. This request only accessible once user retrieve their user_id + by sending GET /user/personal_details. """ token = request.headers.environ["HTTP_AUTHORIZATION"] @@ -283,4 +282,56 @@ def post(cls): return UserExtensionDAO.create_user_additional_info(data) return is_wrong_token + + + @classmethod + @users_ns.doc("update_user_additional_info") + @users_ns.response( + HTTPStatus.CREATED, f"{messages.ADDITIONAL_INFO_SUCCESSFULLY_CREATED}" + ) + @users_ns.response( + HTTPStatus.BAD_REQUEST, + f"{messages.USER_ID_IS_NOT_VALID}\n" + f"{messages.IS_ORGANIZATION_REP_FIELD_IS_MISSING}\n" + f"{messages.TIMEZONE_FIELD_IS_MISSING}" + f"{messages.UNEXPECTED_INPUT}" + ) + @users_ns.response( + HTTPStatus.FORBIDDEN, f"{messages.USER_ID_IS_NOT_RETRIEVED}" + ) + @users_ns.response( + HTTPStatus.INTERNAL_SERVER_ERROR, f"{messages.INTERNAL_SERVER_ERROR}" + ) + @users_ns.expect(auth_header_parser, user_extension_request_body_model, validate=True) + def put(cls): + """ + Updates user additional information + + A user with valid access token can use this endpoint to update additional information to their own data. + The endpoint takes any of the given parameters (is_organization_rep (true or false value), timezone + (with value as per Timezone Enum Value) and additional_info (dictionary of phone, mobile and personal_website)). + The response contains a success or error message. This request only accessible once user retrieve their user_id + by sending GET /user/personal_details. + """ + + token = request.headers.environ["HTTP_AUTHORIZATION"] + is_wrong_token = validate_token(token) + + if not is_wrong_token: + data = request.json + if not data: + return messages.NO_DATA_FOR_UPDATING_PROFILE_WAS_SENT + + is_field_valid = expected_fields_validator(data, user_extension_request_body_model) + if not is_field_valid.get("is_field_valid"): + return is_field_valid.get("message"), HTTPStatus.BAD_REQUEST + + is_not_valid = validate_update_additional_info_request(data) + if is_not_valid: + return is_not_valid, HTTPStatus.BAD_REQUEST + + return UserExtensionDAO.update_user_additional_info(data); + + return is_wrong_token + \ No newline at end of file diff --git a/app/api/validations/user.py b/app/api/validations/user.py index 9db48c3..625b9b1 100644 --- a/app/api/validations/user.py +++ b/app/api/validations/user.py @@ -7,6 +7,7 @@ validate_length, get_stripped_string, ) +from app.utils.bitschema_utils import Timezone # Field character limit @@ -249,9 +250,14 @@ def validate_update_additional_info_request(data): if "timezone" not in data: return messages.TIMEZONE_FIELD_IS_MISSING + timezone_value = data.get("timezone") phone = data.get("phone", None) mobile = data.get("mobile", None) + try: + timezone = Timezone(timezone_value).name + except ValueError: + return messages.TIMEZONE_INPUT_IS_INVALID if phone: if not is_phone_valid(phone): return messages.PHONE_OR_MOBILE_IS_NOT_IN_NUMBER_FORMAT diff --git a/app/messages.py b/app/messages.py index b2ed692..9a5ced3 100644 --- a/app/messages.py +++ b/app/messages.py @@ -20,6 +20,9 @@ WEBSITE_URL_IS_INVALID = { "message": "The website url is not in valid format. It must have ""http*" } +TIMEZONE_INPUT_IS_INVALID = { + "messages": "The Timezone input is not within the approved list" +} # Not found MENTORSHIP_RELATION_REQUEST_DOES_NOT_EXIST = { @@ -38,6 +41,7 @@ "message": "Task comment with given task id does not exist." } + # Missing fields MENTOR_ID_FIELD_IS_MISSING = {"message": "Mentor ID field is missing."} MENTEE_ID_FIELD_IS_MISSING = {"message": "Mentee ID field is missing."} @@ -115,6 +119,9 @@ USER_EXTENSION_DATA_HAS_MISSING_FIELD = { "message": "Additional information is missing one of its field." } +ADDITIONAL_INFO_SUCCESSFULLY_UPDATED = { + "message": "Your additional information is successfully updated." +} # Relation constraints diff --git a/app/utils/bitschema_utils.py b/app/utils/bitschema_utils.py index 00f3d49..be3f601 100644 --- a/app/utils/bitschema_utils.py +++ b/app/utils/bitschema_utils.py @@ -1,8 +1,5 @@ from enum import Enum, unique -# from sqlalchemy import Enum -# from sqlalchemy.dialects.postgresql import ENUM - @unique class ProgramStatus(Enum): diff --git a/tests/test_api_update_user_additional_info.py b/tests/test_api_update_user_additional_info.py new file mode 100644 index 0000000..1d725d2 --- /dev/null +++ b/tests/test_api_update_user_additional_info.py @@ -0,0 +1,209 @@ +import unittest +from http import HTTPStatus, cookies +from unittest.mock import patch, Mock +import requests +from requests.exceptions import HTTPError +from flask import json +from flask_restx import marshal +from app import messages +from tests.base_test_case import BaseTestCase +from app.api.request_api_utils import post_request, get_request, BASE_MS_API_URL, AUTH_COOKIE +from app.api.models.user import full_user_api_model, get_user_extension_response_model +from tests.test_data import user1 +from app.database.models.ms_schema.user import UserModel +from app.database.models.bit_schema.user_extension import UserExtensionModel + + +class TestUpdateUserAdditionalInfoApi(BaseTestCase): + + @patch("requests.post") + def setUp(self, mock_login): + super(TestUpdateUserAdditionalInfoApi, self).setUp() + + success_message = {"access_token": "this is fake token", "access_expiry": 1601478236} + success_code = HTTPStatus.OK + + mock_login_response = Mock() + mock_login_response.json.return_value = success_message + mock_login_response.status_code = success_code + mock_login.return_value = mock_login_response + mock_login.raise_for_status = json.dumps(success_code) + + user_login_success = { + "username": user1.get("username"), + "password": user1.get("password") + } + + with self.client: + login_response = self.client.post( + "/login", + data=json.dumps(user_login_success), + follow_redirects=True, + content_type="application/json", + ) + + test_user = UserModel( + name=user1["name"], + username=user1["username"], + password=user1["password"], + email=user1["email"], + terms_and_conditions_checked=user1["terms_and_conditions_checked"] + ) + test_user.need_mentoring = user1["need_mentoring"] + test_user.available_to_mentor = user1["available_to_mentor"] + + test_user.save_to_db() + + self.test_user_data = UserModel.find_by_email(test_user.email) + + AUTH_COOKIE["user_id"] = self.test_user_data.id + + self.correct_payload_update_additional_info = { + "is_organization_rep": True, + "timezone": "UTC-01:00/Cape Verde Time", + "phone": "123-456-789", + "mobile": "", + "personal_website": "" + } + + + @patch("requests.put") + def test_api_dao_update_user_additional_info_successfully(self, mock_update_additional_info): + success_message = messages.ADDITIONAL_INFO_SUCCESSFULLY_UPDATED + success_code = HTTPStatus.OK + + mock_get_response = Mock() + mock_get_response.json.return_value = success_message + mock_get_response.status_code = success_code + + mock_update_additional_info.return_value = mock_get_response + mock_update_additional_info.raise_for_status = json.dumps(success_code) + + # prepare existing additional info + additional_info = { + "phone": "123-456-456", + "mobile": "", + "personal_website": "" + } + + user_extension = UserExtensionModel( + user_id=AUTH_COOKIE["user_id"].value, + timezone="NEWFOUNDLAND_STANDARD_TIME" + ) + user_extension.is_organization_rep = False + user_extension.additional_info = additional_info + + user_extension.save_to_db() + + with self.client: + response = self.client.put( + "/user/additional_info", + headers={"Authorization": AUTH_COOKIE["Authorization"].value}, + data=json.dumps( + dict(self.correct_payload_update_additional_info) + ), + follow_redirects=True, + content_type="application/json", + ) + + test_user_additional_info_data = UserExtensionModel.query.filter_by(user_id=self.test_user_data.id).first() + self.assertEqual(test_user_additional_info_data.is_organization_rep, self.correct_payload_update_additional_info["is_organization_rep"]) + self.assertEqual(test_user_additional_info_data.timezone.value, self.correct_payload_update_additional_info["timezone"]) + self.assertEqual(test_user_additional_info_data.additional_info["phone"], self.correct_payload_update_additional_info["phone"]) + self.assertEqual(test_user_additional_info_data.additional_info["mobile"], self.correct_payload_update_additional_info["mobile"]) + self.assertEqual(test_user_additional_info_data.additional_info["personal_website"], self.correct_payload_update_additional_info["personal_website"]) + self.assertEqual(response.json, success_message) + self.assertEqual(response.status_code, success_code) + + + @patch("requests.put") + def test_api_dao_update_user_additional_info_not_exist(self, mock_update_additional_info): + error_message = messages.ADDITIONAL_INFORMATION_DOES_NOT_EXIST + error_code = HTTPStatus.NOT_FOUND + + mock_response = Mock() + http_error = requests.exceptions.HTTPError() + mock_response.raise_for_status.side_effect = http_error + mock_update_additional_info.return_value = mock_response + + mock_error = Mock() + mock_error.json.return_value = error_message + mock_error.status_code = error_code + mock_update_additional_info.side_effect = requests.exceptions.HTTPError(response=mock_error) + + with self.client: + response = self.client.put( + "/user/additional_info", + headers={"Authorization": AUTH_COOKIE["Authorization"].value}, + data=json.dumps( + dict(self.correct_payload_update_additional_info) + ), + follow_redirects=True, + content_type="application/json", + ) + + test_user_additional_info_data = UserExtensionModel.query.filter_by(user_id=self.test_user_data.id).first() + self.assertEqual(test_user_additional_info_data, None) + self.assertEqual(response.json, error_message) + self.assertEqual(response.status_code, error_code) + + + @patch("requests.put") + def test_api_dao_update_additional_info_with_invalid_timezone(self, mock_update_additional_info): + error_message = messages.TIMEZONE_INPUT_IS_INVALID + error_code = HTTPStatus.BAD_REQUEST + + mock_response = Mock() + http_error = requests.exceptions.HTTPError() + mock_response.raise_for_status.side_effect = http_error + mock_update_additional_info.return_value = mock_response + + mock_error = Mock() + mock_error.json.return_value = error_message + mock_error.status_code = error_code + mock_update_additional_info.side_effect = requests.exceptions.HTTPError(response=mock_error) + + # prepare existing additional info + additional_info = { + "phone": "123-456-456", + "mobile": "", + "personal_website": "" + } + + user_extension = UserExtensionModel( + user_id=AUTH_COOKIE["user_id"].value, + timezone="NEWFOUNDLAND_STANDARD_TIME" + ) + user_extension.is_organization_rep = False + user_extension.additional_info = additional_info + + user_extension.save_to_db() + + invalid_timezone_update_additional_info = { + "is_organization_rep": True, + "timezone": "some random timezone", + "phone": "123-456-789", + "mobile": "", + "personal_website": "" + } + + with self.client: + response = self.client.put( + "/user/additional_info", + headers={"Authorization": AUTH_COOKIE["Authorization"].value}, + data=json.dumps( + dict(invalid_timezone_update_additional_info) + ), + follow_redirects=True, + content_type="application/json", + ) + + mock_update_additional_info.assert_not_called() + self.assertEqual(response.json, error_message) + self.assertEqual(response.status_code, error_code) + + +if __name__ == "__main__": + unittest.main() + + \ No newline at end of file