diff --git a/backend/README.md b/backend/README.md index d5318811d..0e43cf7e7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -67,32 +67,244 @@ One note before you delve into your tasks: for each endpoint, you are expected t 8. Create a `POST` endpoint to get questions to play the quiz. This endpoint should take a category and previous question parameters and return a random questions within the given category, if provided, and that is not one of the previous questions. 9. Create error handlers for all expected errors including 400, 404, 422, and 500. -## Documenting your Endpoints +### API Documentation -You will need to provide detailed documentation of your API endpoints including the URL, request parameters, and the response body. Use the example below as a reference. +**** +GET "\categories" +curl -X GET 'http://127.0.0.1:5000/categories' -### Documentation Example +- Fetches a dictionary of categories in which the keys are the ids and the value is the corresponding string of the category +- Request Parameters: None +- Response Body: -`GET '/api/v1.0/categories'` +categories: A dictionary containing Category ID and Category Type as a key value pair -- Fetches a dictionary of categories in which the keys are the ids and the value is the corresponding string of the category -- Request Arguments: None -- Returns: An object with a single key, `categories`, that contains an object of `id: category_string` key: value pairs. +{ + "categories": { + "1": "Science", + "2": "Art", + "3": "Geography", + "4": "History", + "5": "Entertainment", + "6": "Sports" + }, + "success": true +} + +**** +GET "\questions?page=" +curl -X GET 'http://127.0.0.1:5000/questions' +curl -X GET 'http://127.0.0.1:5000/questions?page=2' + +- Fetches a paginated dictionary of questions of all available categories. A page contains 10 questions. +- Request parameters (optional): page number in integer +- Response Body: + +categories: A dictionary containing Category ID and Category Type as a key value pair +current_category: Null +questions: List of questions +total_questions: Total Number of questions -```json { - "1": "Science", - "2": "Art", - "3": "Geography", - "4": "History", - "5": "Entertainment", - "6": "Sports" + "categories": { + "1": "Science", + "2": "Art", + "3": "Geography", + "4": "History", + "5": "Entertainment", + "6": "Sports" + }, + "current_category": null, + "questions": [ + { + "answer": "Tom Cruise", + "category": 5, + "difficulty": 4, + "id": 4, + "question": "What actor did author Anne Rice first denounce, then praise in the role of her beloved Lestat?" + }, + { + "answer": "Maya Angelou", + "category": 4, + "difficulty": 2, + "id": 5, + "question": "Whose autobiography is entitled 'I Know Why the Caged Bird Sings'?" + }, + { + "answer": "Edward Scissorhands", + "category": 5, + "difficulty": 3, + "id": 6, + "question": "What was the title of the 1990 fantasy directed by Tim Burton about a young man with multi-bladed appendages?" + }, + { + "answer": "Brazil", + "category": 6, + "difficulty": 3, + "id": 10, + "question": "Which is the only team to play in every soccer World Cup tournament?" + }, + { + "answer": "Uruguay", + "category": 6, + "difficulty": 4, + "id": 11, + "question": "Which country won the first ever soccer World Cup in 1930?" + }, + { + "answer": "George Washington Carver", + "category": 4, + "difficulty": 2, + "id": 12, + "question": "Who invented Peanut Butter?" + }, + { + "answer": "Lake Victoria", + "category": 3, + "difficulty": 2, + "id": 13, + "question": "What is the largest lake in Africa?" + }, + { + "answer": "Agra", + "category": 3, + "difficulty": 2, + "id": 15, + "question": "The Taj Mahal is located in which Indian city?" + }, + { + "answer": "Escher", + "category": 2, + "difficulty": 1, + "id": 16, + "question": "Which Dutch graphic artist\u2013initials M C was a creator of optical illusions?" + }, + { + "answer": "Jackson Pollock", + "category": 2, + "difficulty": 2, + "id": 19, + "question": "Which American artist was a pioneer of Abstract Expressionism, and a leading exponent of action painting?" + } + ], + "success": true, + "total_questions": 46 } -``` -## Testing +**** +DELETE "/questions/" +curl -X DELETE 'http://127.0.0.1:5000/questions/2' + +- Delete an existing questions from the available questions based on the question ID +- Request Parameters: question_id in integer that needs to be deleted +- Response Body: + +deleted: Question ID that is deleted +{ + "deleted": "49", + "success": true +} + +**** +POST /questions +curl -X POST -H "Content-Type: application/json" -d '{"question":"What is the capital city of India?", "answer":"New Delhi", "difficulty":2, "category":3}' 'http://127.0.0.1:5000/questions' + + +- Add a new question to the list of available questions +- Request Paremeter: Need to provide the new question and its answer, difficulty level and category ID in the following format +{question:string, answer:string, difficulty:int, category:int} +- Response Body: + +created: Question ID that is created +{ + "created": 91, + "success": true +} + +**** +POST "/questions/search" +curl -X POST -H "Content-Type: application/json" -d '{"searchTerm":"Taj"}' 'http://127.0.0.1:5000/questions/search' + +- Fetches all questions based on the search string provided (not case-sensitive) +- Request body: Need to provide the search string in the following format +{searchTerm:string} +- Response Body: + +current_category: Null +questions: List of questions having the search string (not case sensitive) +total_questions: Total Number of questions having the search string +{ + "current_category": null, + "questions": [ + { + "answer": "Agra", + "category": 3, + "difficulty": 2, + "id": 15, + "question": "The Taj Mahal is located in which Indian city?" + } + ], + "success": true, + "total_questions": 1 +} + +**** +GET "/categories//questions" +curl -X GET 'http://127.0.0.1:5000/categories/2/questions' + +- Fetches a dictionary of questions for the given category ID +- Request Parameter: Category ID for questions should be in integer +- Response Body: -Write at least one test for the success and at least one error behavior of each endpoint using the unittest library. +current_category: Current category ID +questions: List of questions under the given category +total_questions: Total Number of questions under the given category +{ + "current_category": 2, + "questions": [ + { + "answer": "Escher", + "category": 2, + "difficulty": 1, + "id": 16, + "question": "Which Dutch graphic artist\u2013initials M C was a creator of optical illusions?" + }, + { + "answer": "Jackson Pollock", + "category": 2, + "difficulty": 2, + "id": 19, + "question": "Which American artist was a pioneer of Abstract Expressionism, and a leading exponent of action painting?" + } + ], + "success": true, + "total_questions": 2 +} + +**** +POST "/quizzes" +For a particular category : +curl -X POST -H "Content-Type: application/json" -d '{"previous_questions": [], "quiz_category": {"type": "Sports", "id": "6"}}' 'http://127.0.0.1:5000/quizzes' +For All categories: +curl -X POST -H "Content-Type: application/json" -d '{"previous_questions": [], "quiz_category": {"type": "click", "id": 0}}' 'http://127.0.0.1:5000/quizzes' + +- Fetches one random question within a specified category or all categories based on the option chosen. It does not repeat the previous question. +- Request Parameter: The request parameter consists of previous questions and quiz category containing category ID and category type +{previous_questions: arr, quiz_category: {id:int, type:string}} +- Response Body: + +question: Random questions under the given or any category based on the option chosen +{ + "question": { + "answer": "The Liver", + "category": 1, + "difficulty": 4, + "id": 20, + "question": "What is the heaviest organ in the human body?" + }, + "success": true +} +## Testing To deploy the tests, run diff --git a/backend/flaskr/__init__.py b/backend/flaskr/__init__.py index d991c6bd5..f82de6042 100644 --- a/backend/flaskr/__init__.py +++ b/backend/flaskr/__init__.py @@ -6,32 +6,67 @@ QUESTIONS_PER_PAGE = 10 +def paginate_questions(request, selection): + page = request.args.get("page", 1, type=int) + start = (page - 1) * QUESTIONS_PER_PAGE + end = start + QUESTIONS_PER_PAGE + + questions = [question.format() for question in selection] + current_questions = questions[start:end] + + return current_questions + + + def create_app(test_config=None): # create and configure the app app = Flask(__name__) - - if test_config is None: - setup_db(app) - else: - database_path = test_config.get('SQLALCHEMY_DATABASE_URI') - setup_db(app, database_path=database_path) - + setup_db(app) """ @TODO: Set up CORS. Allow '*' for origins. Delete the sample route after completing the TODOs """ - with app.app_context(): - db.create_all() + CORS(app, resources={r'/*': {'origins': '*'}}) """ @TODO: Use the after_request decorator to set Access-Control-Allow """ + # CORS Headers + @app.after_request + def after_request(response): + response.headers.add( + "Access-Control-Allow-Headers", "Content-Type,Authorization,true" + ) + response.headers.add( + "Access-Control-Allow-Methods", "GET,PATCH,POST,DELETE,OPTIONS" + ) + return response + + with app.app_context(): + db.create_all() + + """ @TODO: Create an endpoint to handle GET requests for all available categories. """ + @app.route("/categories", methods=["GET"]) + def retrieve_categories(): + category = Category.query.order_by(Category.type).all() + + if len(category) == 0: + abort(404) + categories = {} + for cat in category: + categories[cat.id] = cat.type + + return jsonify ({ + "success": True, + "categories": categories + + }) """ @TODO: @@ -39,13 +74,34 @@ def create_app(test_config=None): including pagination (every 10 questions). This endpoint should return a list of questions, number of total questions, current category, categories. - + TEST: At this point, when you start the application you should see questions and categories generated, ten questions per page and pagination at the bottom of the screen for three pages. Clicking on the page numbers should update the questions. """ + @app.route("/questions", methods=["GET"]) + def retrieve_questions(): + selection = Question.query.order_by(Question.id).all() + current_questions = paginate_questions(request, selection) + + if len(current_questions) == 0: + abort(404) + category = Category.query.order_by(Category.type).all() + categories = {} + for cat in category: + categories[cat.id] = cat.type + + return jsonify ( + { + "success": True, + "questions": current_questions, + "total_questions": len(selection), + "categories": categories, + "current_category": None + }) + """ @TODO: Create an endpoint to DELETE question using a question ID. @@ -53,7 +109,27 @@ def create_app(test_config=None): TEST: When you click the trash icon next to a question, the question will be removed. This removal will persist in the database and when you refresh the page. """ + @app.route("/questions/", methods=["DELETE"]) + def delete_question(question_id): + try: + question = Question.query.filter(Question.id == question_id).one_or_none() + + if question is None: + abort(404) + + question.delete() + selection = Question.query.order_by(Question.id).all() + current_questions = paginate_questions(request, selection) + return jsonify( + { + "success": True, + "deleted": question_id, + } + ) + + except: + abort(422) """ @TODO: Create an endpoint to POST a new question, @@ -64,6 +140,39 @@ def create_app(test_config=None): the form will clear and the question will appear at the end of the last page of the questions list in the "List" tab. """ + @app.route("/questions", methods=["POST"]) + def create_question(): + body = request.get_json() + + # to check if the body has all the fields + if not ('question' in body and 'answer' in body and + 'difficulty' in body and 'category' in body): + abort(422) + + + new_question = body.get('question') + new_answer = body.get('answer') + new_difficulty = body.get('difficulty') + new_category = body.get('category') + + # to check if all fields are provided by user + if ((new_question is None or new_question == "") or (new_answer is None or new_answer == "") or + (new_difficulty is None or new_difficulty == "") or (new_category is None or new_category == "")): + abort(422) + + try: + create_quest = Question(question=new_question, answer=new_answer, + difficulty=new_difficulty,category=new_category) + create_quest.insert() + + return jsonify( + { + "success": True, + "created": create_quest.id, + } + ) + except: + abort(422) """ @TODO: @@ -75,7 +184,27 @@ def create_app(test_config=None): only question that include that string within their question. Try using the word "title" to start. """ + @app.route("/questions/search", methods=["POST"]) + def search_questions(): + body = request.get_json() + + search = body.get("searchTerm", None) + if search: + selection = Question.query.order_by(Question.id).filter( + Question.question.ilike("%{}%".format(search))).all() + current_questions = paginate_questions(request, selection) + + return jsonify( + { + "success": True, + "questions": current_questions, + "total_questions": len(selection), + "current_category": None + } + ) + abort(404) + """ @TODO: Create a GET endpoint to get questions based on category. @@ -84,7 +213,22 @@ def create_app(test_config=None): categories in the left column will cause only questions of that category to be shown. """ + @app.route('/categories//questions', methods=['GET']) + def retrieve_questions_by_category(category_id): + selection = Question.query.order_by(Question.id).filter(Question.category==category_id).all() + current_questions = paginate_questions(request, selection) + + if len(current_questions) == 0: + abort(404) + return jsonify ( + { + "success": True, + "questions": current_questions, + "total_questions": len(selection), + "current_category": category_id + }) + """ @TODO: Create a POST endpoint to get questions to play the quiz. @@ -96,12 +240,61 @@ def create_app(test_config=None): one question at a time is displayed, the user is allowed to answer and shown whether they were correct or not. """ + @app.route('/quizzes', methods=['POST']) + def get_random_question_for_quiz(): + + body = request.get_json() + if not ('quiz_category' in body and 'previous_questions' in body): + abort(422) + + quiz_category = body.get('quiz_category') + previous_questions = body.get('previous_questions') + + if ((quiz_category is None) or (previous_questions is None)): + abort(400) + + category_id = quiz_category['id'] + + if category_id == 0: + quiz_questions = Question.query.filter(Question.id.notin_((previous_questions))).all() + else: + quiz_questions = Question.query.filter(Question.category==category_id).filter( + Question.id.notin_((previous_questions))).all() + + if len(quiz_questions) > 0: + new_question = random.choice(quiz_questions).format() + else: + new_question = None + + return jsonify({ + 'success': True, + 'question': new_question + }) """ @TODO: Create error handlers for all expected errors including 404 and 422. """ + @app.errorhandler(404) + def not_found(error): + return ( + jsonify({"success": False, "error": 404, "message": "resource not found"}), + 404, + ) + + @app.errorhandler(422) + def unprocessable(error): + return ( + jsonify({"success": False, "error": 422, "message": "unprocessable"}), + 422, + ) + @app.errorhandler(400) + def bad_request(error): + return ( + jsonify({"success": False, "error": 400, "message": "bad request"}), + 400, + ) return app diff --git a/backend/models.py b/backend/models.py index 7647a6fbf..0ac8be9ed 100644 --- a/backend/models.py +++ b/backend/models.py @@ -2,9 +2,8 @@ from flask_sqlalchemy import SQLAlchemy database_name = 'trivia' database_user = 'postgres' -database_password = 'password' database_host = 'localhost:5432' -database_path = f'postgresql://{database_user}:{database_password}@{database_host}/{database_name}' +database_path = f'postgresql://{database_user}@{database_host}/{database_name}' db = SQLAlchemy() diff --git a/backend/test_flaskr.py b/backend/test_flaskr.py index 20a355b90..d43974956 100644 --- a/backend/test_flaskr.py +++ b/backend/test_flaskr.py @@ -1,8 +1,9 @@ import os import unittest +import json from flaskr import create_app -from models import db, Question, Category +from models import db, Question, Category, setup_db class TriviaTestCase(unittest.TestCase): @@ -12,9 +13,8 @@ def setUp(self): """Define test variables and initialize app.""" self.database_name = "trivia_test" self.database_user = "postgres" - self.database_password = "password" self.database_host = "localhost:5432" - self.database_path = f"postgresql://{self.database_user}:{self.database_password}@{self.database_host}/{self.database_name}" + self.database_path = f"postgresql://{self.database_user}@{self.database_host}/{self.database_name}" # Create app with the test configuration self.app = create_app({ @@ -30,15 +30,155 @@ def setUp(self): def tearDown(self): """Executed after each test""" - with self.app.app_context(): - db.session.remove() - db.drop_all() - + pass """ TODO Write at least one test for each test for successful operation and for expected errors. """ + # test cases for getting valid and invalid categories + def test_get_categories(self): + res = self.client.get("/categories") + data = json.loads(res.data) + + self.assertEqual(res.status_code, 200) + self.assertEqual(data["success"], True) + self.assertTrue(len(data["categories"])) + + def test_404_sent_requesting_invalid_categories(self): + res = self.client.get("/categories/1000") + data = json.loads(res.data) + + self.assertEqual(res.status_code, 404) + self.assertEqual(data["success"], False) + self.assertEqual(data["message"], "resource not found") + + # test cases for getting questions for valid and invalid number of pages + def test_get_paginated_questions(self): + res = self.client.get("/questions") + data = json.loads(res.data) + + self.assertEqual(res.status_code, 200) + self.assertEqual(data["success"], True) + self.assertTrue(data["total_questions"]) + self.assertTrue(len(data["questions"])) + self.assertTrue(len(data["categories"])) + + def test_404_sent_requesting_questions_beyond_valid_page(self): + res = self.client.get("/questions?page=10000") + data = json.loads(res.data) + + self.assertEqual(res.status_code, 404) + self.assertEqual(data["success"], False) + self.assertEqual(data["message"], "resource not found") + + # test cases for deleting valid and invalid question id + def test_delete_question(self): + with self.app.app_context(): + sample_question = Question(question="what is the sample question?", answer="this", + difficulty = 1, category = 3) + sample_question.insert() + sample_question_id = sample_question.id + + res = self.client.delete(f'/questions/{sample_question_id}') + data = json.loads(res.data) + + question = Question.query.filter(Question.id == sample_question_id).one_or_none() + + self.assertEqual(res.status_code, 200) + self.assertEqual(data["success"], True) + self.assertEqual(data["deleted"], str(sample_question_id)) + self.assertEqual(question, None) + + def test_422_if_question_does_not_exist(self): + res = self.client.delete("/questions/-1") + data = json.loads(res.data) + + self.assertEqual(res.status_code, 422) + self.assertEqual(data["success"], False) + self.assertEqual(data["message"], "unprocessable") + + # test cases for creating valid and invalid questions + def test_create_new_question(self): + sample_question = {"question": "what is the sample question?", + "answer": "this", + "difficulty": 1, + "category": 3} + res = self.client.post("/questions", json=sample_question) + data = json.loads(res.data) + + self.assertEqual(res.status_code, 200) + self.assertEqual(data["success"], True) + self.assertTrue(data["created"]) + + def test_422_if_question_creation_not_allowed(self): + sample_question = {"question": "what is the sample question?", + "answer": "this", + "difficulty": 1} + res = self.client.post("/questions", json=sample_question) + data = json.loads(res.data) + + self.assertEqual(res.status_code, 422) + self.assertEqual(data["success"], False) + self.assertEqual(data["message"], "unprocessable") + + # test cases for searching valid and invalid keywords in questions + def test_get_question_search_with_results(self): + res = self.client.post("/questions/search", json={"searchTerm": "is"}) + data = json.loads(res.data) + + self.assertEqual(res.status_code, 200) + self.assertEqual(data["success"], True) + self.assertIsNotNone(data['questions']) + self.assertIsNotNone(data['total_questions']) + + def test_get_book_search_without_results(self): + res = self.client.post("/questions/search", json={"searchTerm": {}}) + data = json.loads(res.data) + + self.assertEqual(res.status_code, 404) + self.assertEqual(data["success"], False) + self.assertEqual(data["message"], "resource not found") + + # test cases for getting valid and invalid questions by categories + def test_get_questions_by_category(self): + res = self.client.get('/categories/1/questions') + data = json.loads(res.data) + + self.assertEqual(res.status_code, 200) + self.assertEqual(data['success'], True) + self.assertTrue(len(data['questions'])) + self.assertTrue(data['total_questions']) + self.assertTrue(data['current_category']) + + def test_404_get_questions_by_category(self): + res = self.client.get('/categories/-1/questions') + data = json.loads(res.data) + + self.assertEqual(res.status_code, 404) + self.assertEqual(data["success"], False) + self.assertEqual(data["message"], "resource not found") + + # test cases for playing quiz with valid and invalid inputs sent + def test_play_quiz(self): + quiz = {'previous_questions': [], + 'quiz_category': {'type': 'click', 'id': 0}} + + res = self.client.post('/quizzes', json=quiz) + data = json.loads(res.data) + + self.assertEqual(res.status_code, 200) + self.assertEqual(data['success'], True) + + def test_422_play_quiz_fields_missing(self): + quiz = {'quiz_category': {'type': 'click', 'id': 0}} + res = self.client.post('/quizzes', json=quiz) + data = json.loads(res.data) + + self.assertEqual(res.status_code, 422) + self.assertEqual(data["success"], False) + self.assertEqual(data["message"], "unprocessable") + # Make the tests conveniently executable if __name__ == "__main__": diff --git a/frontend/src/components/QuestionView.js b/frontend/src/components/QuestionView.js index a86b1f8db..74901a329 100755 --- a/frontend/src/components/QuestionView.js +++ b/frontend/src/components/QuestionView.js @@ -84,7 +84,7 @@ class QuestionView extends Component { submitSearch = (searchTerm) => { $.ajax({ - url: `/questions`, //TODO: update request URL + url: `/questions/search`, //TODO: update request URL type: 'POST', dataType: 'json', contentType: 'application/json',