From 6402331cd87c2f6a608361c6c804e38b9a06e0a9 Mon Sep 17 00:00:00 2001 From: Adam Arnon Date: Thu, 21 Dec 2023 13:50:31 -0800 Subject: [PATCH 1/4] wrote a few tests --- .gitignore | 1 + backend/flaskr/__init__.py | 73 ++++++++++++++++++++++++++++++++++++-- backend/requirements.txt | 14 ++++---- backend/test_flaskr.py | 59 ++++++++++++++++++++++++++++-- 4 files changed, 135 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index ec824f4aa..a58237784 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ venv ehthumbs.db Thumbs.db env/ +venv/ frontend/node_modules/ \ No newline at end of file diff --git a/backend/flaskr/__init__.py b/backend/flaskr/__init__.py index 531034738..7d9485637 100644 --- a/backend/flaskr/__init__.py +++ b/backend/flaskr/__init__.py @@ -1,10 +1,10 @@ import os -from flask import Flask, request, abort, jsonify +from flask import Flask, request, abort, jsonify, Response from flask_sqlalchemy import SQLAlchemy from flask_cors import CORS import random -from models import setup_db, Question, Category +from models import setup_db, Question, Category, db QUESTIONS_PER_PAGE = 10 @@ -16,16 +16,58 @@ def create_app(test_config=None): """ @TODO: Set up CORS. Allow '*' for origins. Delete the sample route after completing the TODOs """ + CORS(app, resources={r"/*": {"origins": "*"}}) """ @TODO: Use the after_request decorator to set Access-Control-Allow """ + @app.after_request + def after_request(response: Response) -> Response: + """Define headers for CORS""" + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization,true') + response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') + return response + """ @TODO: Create an endpoint to handle GET requests for all available categories. """ + @app.route('/categories', methods=['GET']) + def get_categories(): + """Get all the categories and return them in JSON format""" + categories = Category.query.order_by(Category.type).all() + + categories_dict = {category.id: category.type for category in categories} + + if len(categories_dict) == 0: + abort(404) + + return jsonify({ + 'success': True, + 'categories': categories_dict, + 'number_of_categories': len(categories_dict) + }) + + @app.route('/questions/', methods=['GET']) + def get_question(id): + """Get a single question and return it in JSON format""" + question = Question.query.get(id) + + if question is None: + abort(404) + + return jsonify({ + 'id': question.id, + 'question': question.question, + 'answer': question.answer, + 'category': question.category, + 'difficulty': question.difficulty + }) + + + """ @@ -48,6 +90,18 @@ 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(id): + """Delete a question by id""" + delete_question = Question.query.get(id) + if delete_question is None: + abort(404) + + delete_question.delete() + return jsonify({ + 'success': True, + 'question_id': delete_question.id + }) """ @TODO: @@ -97,6 +151,21 @@ def create_app(test_config=None): 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 return app diff --git a/backend/requirements.txt b/backend/requirements.txt index fdf8b85aa..6167a76a4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,14 +1,14 @@ aniso8601==6.0.0 Click==7.0 -Flask==1.0.3 -Flask-Cors==3.0.7 -Flask-RESTful==0.3.7 -Flask-SQLAlchemy==2.4.0 +Flask +Flask-Cors +Flask-RESTful +Flask-SQLAlchemy itsdangerous==1.1.0 -Jinja2==2.10.1 +Jinja2 MarkupSafe==1.1.1 -psycopg2-binary==2.8.2 +psycopg2-binary pytz==2019.1 six==1.12.0 -SQLAlchemy==1.3.4 +SQLAlchemy Werkzeug==0.15.5 diff --git a/backend/test_flaskr.py b/backend/test_flaskr.py index 16f9c5dd6..e091da66c 100644 --- a/backend/test_flaskr.py +++ b/backend/test_flaskr.py @@ -22,18 +22,71 @@ def setUp(self): with self.app.app_context(): self.db = SQLAlchemy() self.db.init_app(self.app) - # create all tables self.db.create_all() - + + + # Add test data here + test_question = Question(question="Test Question", answer="Test Answer", category=1, difficulty=1) + self.db.session.add(test_question) + self.db.session.commit() + + # Store the test question ID for use in tests + self.test_question_id = test_question.id + def tearDown(self): """Executed after reach test""" - pass + with self.app.app_context(): + self.db.session.remove() + self.db.drop_all() """ TODO Write at least one test for each test for successful operation and for expected errors. """ + def test_get_categories(self): + """Test GET request for all available categories""" + + # Make a GET request to the /categories endpoint + response = self.client().get('/categories') + data = response.get_json() + + # Check if the categories match the expected categories + expected_categories = { + '1': "Science", + '2': "Art", + '3': "Geography", + '4': "History", + '5': "Entertainment", + '6': "Sports" + } + self.assertEqual(response.status_code, 200) + self.assertTrue(data['success']) + self.assertDictEqual(data['categories'], expected_categories) + self.assertEqual(data['number_of_categories'], 6) + + def test_delete_question(self): + """Test DELETE request for a question by id""" + + # Check that the question exists + response = self.client().get(f'/questions/{self.test_question_id}') + self.assertEqual(response.status_code, 200) + + # Make a DELETE request to the /questions endpoint with an existing question id + response = self.client().delete(f'/questions/{self.test_question_id}') + self.assertEqual(response.status_code, 200) + response = self.client().get(f'/questions/{self.test_question_id}') + self.assertEqual(response.status_code, 404) + + def test_delete_question_not_found(self): + """Test DELETE request for a question by id that does not exist""" + # Make a DELETE request to the /questions endpoint with a non-existing question id + response = self.client().delete('/questions/100') + self.assertEqual(response.status_code, 404) + self.assertEqual(response.get_json()['success'], False) + self.assertEqual(response.get_json()['message'], 'Resource not found') + + # Make the tests conveniently executable if __name__ == "__main__": From 6b871e8b7f405b63ce441e02cb77dd792b388bc4 Mon Sep 17 00:00:00 2001 From: Adam Arnon Date: Thu, 21 Dec 2023 13:51:00 -0800 Subject: [PATCH 2/4] wrote a few tests --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a58237784..0bdda0c86 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ ehthumbs.db Thumbs.db env/ venv/ +.idea/ frontend/node_modules/ \ No newline at end of file From b0ae2da32747268fc42a1a8c505e5c4cae1c0e03 Mon Sep 17 00:00:00 2001 From: Adam Arnon Date: Thu, 28 Dec 2023 16:31:16 -0800 Subject: [PATCH 3/4] first attempt --- backend/flaskr/__init__.py | 134 +++++++++++++++++++++++++++++++++++-- backend/test_flaskr.py | 112 ++++++++++++++++++++++++++++++- 2 files changed, 239 insertions(+), 7 deletions(-) diff --git a/backend/flaskr/__init__.py b/backend/flaskr/__init__.py index 7d9485637..440560d1e 100644 --- a/backend/flaskr/__init__.py +++ b/backend/flaskr/__init__.py @@ -66,10 +66,6 @@ def get_question(id): 'difficulty': question.difficulty }) - - - - """ @TODO: Create an endpoint to handle GET requests for questions, @@ -83,6 +79,27 @@ def get_question(id): Clicking on the page numbers should update the questions. """ + @app.route('/questions', methods=['GET']) + def get_questions(): + page = request.args.get('page', 1, type=int) # Default to page 1 if not specified + start = (page - 1) * 10 + end = start + 10 + + questions = Question.query.all() + formatted_questions = [question.format() for question in questions] + + categories = Category.query.all() + formatted_categories = {category.id: category.type for category in categories} + + return jsonify({ + 'success': True, + 'questions': formatted_questions[start:end], + 'total_questions': len(formatted_questions), + 'categories': formatted_categories, + 'current_category': None # or set appropriately if you have this information + }), 200 + + """ @TODO: Create an endpoint to DELETE question using a question ID. @@ -114,6 +131,42 @@ def delete_question(id): of the questions list in the "List" tab. """ + @app.route('/questions', methods=['POST']) + def create_question(): + """Create a new question""" + try: + # Attempt to parse the JSON data from the request + data = request.get_json() + + # Validate the data (optional but recommended) + if 'question' not in data or 'answer' not in data or 'category' not in data or 'difficulty' not in data: + abort(400, description="Missing data for the new question") + + # Create a new question object + question = Question(question=data['question'], answer=data['answer'], category=data['category'], + difficulty=data['difficulty']) + + # Insert the new question into the database + question.insert() + + # Return a success response with status code 201 + return jsonify({ + 'success': True, + 'question': question.format(), + 'question_id': question.id + }), 201 + + except Exception as e: + # Log the exception for debugging purposes + print(f"Error: {e}") + + # Return a server error response with status code 500 + return jsonify({ + 'success': False, + 'error': "An error occurred while processing the request" + }), 500 + + """ @TODO: Create a POST endpoint to get questions based on a search term. @@ -125,6 +178,27 @@ def delete_question(id): Try using the word "title" to start. """ + @app.route('/questions/search', methods=['POST']) + def search_questions(): + """Search for questions based on a search term""" + data = request.get_json() + search_term = data.get('searchTerm', None) + + if search_term: + search_results = Question.query.filter(Question.question.ilike(f'%{search_term}%')).all() + formatted_questions = [question.format() for question in search_results] + + return jsonify({ + 'success': True, + 'questions': formatted_questions, + 'total_questions': len(formatted_questions) + }), 200 + else: + return jsonify({ + 'success': False, + 'error': 'No search term provided' + }), 400 + """ @TODO: Create a GET endpoint to get questions based on category. @@ -134,6 +208,24 @@ def delete_question(id): category to be shown. """ + @app.route('/categories//questions', methods=['GET']) + def get_questions_by_category(category_id): + """Get questions by category""" + try: + questions = Question.query.filter_by(category=str(category_id)).all() + formatted_questions = [question.format() for question in questions] + if len(formatted_questions) == 0: + abort(404) + + return jsonify({ + 'success': True, + 'questions': formatted_questions, + 'total_questions': len(formatted_questions), + 'current_category': category_id + }), 200 + except Exception as e: + abort(404) # Assuming category not found or other errors + """ @TODO: Create a POST endpoint to get questions to play the quiz. @@ -146,6 +238,40 @@ def delete_question(id): and shown whether they were correct or not. """ + @app.route('/quizzes', methods=['POST']) + def get_quiz_question(): + """Get a random question for the quiz""" + try: + data = request.get_json() + previous_questions = data.get('previous_questions', []) + quiz_category = data.get('quiz_category', None) + + if quiz_category: + category_id = quiz_category['id'] + if category_id == 0: # Assuming '0' means 'All' categories + questions_query = Question.query.filter(Question.id.notin_(previous_questions)) + else: + questions_query = Question.query.filter_by(category=str(category_id)).filter( + Question.id.notin_(previous_questions)) + else: + questions_query = Question.query.filter(Question.id.notin_(previous_questions)) + + available_questions = questions_query.all() + if available_questions: + next_question = random.choice(available_questions).format() + else: + next_question = None + + return jsonify({ + 'success': True, + 'question': next_question + }), 200 + except Exception as e: + return jsonify({ + 'success': False, + 'error': 'An error occurred while processing the request' + }), 500 + """ @TODO: Create error handlers for all expected errors diff --git a/backend/test_flaskr.py b/backend/test_flaskr.py index e091da66c..1fcdd86c6 100644 --- a/backend/test_flaskr.py +++ b/backend/test_flaskr.py @@ -17,6 +17,12 @@ def setUp(self): self.database_name = "trivia_test" self.database_path = "postgres://{}/{}".format('localhost:5432', self.database_name) setup_db(self.app, self.database_path) + self.new_question = { + 'category': 1, + 'question': 'Test Question Post', + 'answer': 'Test Answer Post', + 'difficulty': 1 + } # binds the app to the current context with self.app.app_context(): @@ -24,7 +30,6 @@ def setUp(self): self.db.init_app(self.app) self.db.create_all() - # Add test data here test_question = Question(question="Test Question", answer="Test Answer", category=1, difficulty=1) self.db.session.add(test_question) @@ -81,13 +86,114 @@ def test_delete_question(self): def test_delete_question_not_found(self): """Test DELETE request for a question by id that does not exist""" # Make a DELETE request to the /questions endpoint with a non-existing question id - response = self.client().delete('/questions/100') + response = self.client().delete('/questions/100000') self.assertEqual(response.status_code, 404) self.assertEqual(response.get_json()['success'], False) self.assertEqual(response.get_json()['message'], 'Resource not found') + def test_post_question(self): + """Test POST request for a new question""" + # Make a POST request to the /questions endpoint with a new question + response = self.client().post('/questions', data=json.dumps(self.new_question.copy()), + content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.get_json()['success'], True) + # Check that the question exists + response = self.client().get(f'/questions/{response.get_json()["question_id"]}') + self.assertEqual(response.status_code, 200) + + def test_post_question_with_missing_data(self): + # Simulate missing data in the request + incomplete_data = self.new_question.copy() + del incomplete_data['answer'] # Removing a required field + + response = self.client().post('/questions', data=json.dumps(incomplete_data), content_type='application/json') + data = json.loads(response.data) + + self.assertEqual(response.status_code, 500) # Expect a 500 status code + self.assertFalse(data['success']) + self.assertIn('error', data) + + def test_search_questions(self): + response = self.client().post('/questions/search', json={'searchTerm': 'title'}) + data = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(data['success']) + self.assertIn('questions', data) + self.assertIsInstance(data['questions'], list) + # More assertions can be added based on the expected content of the response + + def test_search_questions_no_term(self): + response = self.client().post('/questions/search', json={}) + data = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(data['success']) + self.assertIn('error', data) + + def test_get_questions_by_category(self): + category_id = 1 # Assuming this is a valid category ID + response = self.client().get(f'/categories/{category_id}/questions') + data = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(data['success']) + self.assertIn('questions', data) + self.assertIsInstance(data['questions'], list) + # Additional assertions can be added based on the expected content + + def test_get_questions_invalid_category(self): + invalid_category_id = 9999 # Assuming this is an invalid category ID + response = self.client().get(f'/categories/{invalid_category_id}/questions') + self.assertEqual(response.status_code, 404) + + def test_get_paginated_questions(self): + response = self.client().get('/questions?page=1') + data = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(data['success']) + self.assertIn('questions', data) + self.assertTrue(len(data['questions']) <= 10) + self.assertIn('total_questions', data) + self.assertIn('categories', data) + + def test_get_questions_beyond_valid_page(self): + response = self.client().get('/questions?page=1000') # Assuming this is beyond available pages + data = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(data['success']) + self.assertEqual(len(data['questions']), 0) # No questions should be returned + + def test_get_quiz_question(self): + request_data = { + 'previous_questions': [], + 'quiz_category': {'id': 1, 'type': 'Science'} + } + response = self.client().post('/quizzes', json=request_data) + data = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(data['success']) + self.assertIn('question', data) + self.assertIsNotNone(data['question']) + + def test_quiz_question_exclusion_of_previous_questions(self): + request_data = { + 'previous_questions': [1, 2, 3], # Assuming these are IDs of previous questions + 'quiz_category': {'id': 1, 'type': 'Science'} + } + response = self.client().post('/quizzes', json=request_data) + data = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(data['success']) + self.assertIn('question', data) + self.assertNotIn(data['question']['id'], request_data['previous_questions']) # Make the tests conveniently executable if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From 62be98cf193936f2181adaf3fdfa9c5bff52c47d Mon Sep 17 00:00:00 2001 From: Adam Arnon Date: Thu, 28 Dec 2023 16:32:35 -0800 Subject: [PATCH 4/4] update gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0bdda0c86..3f17e8f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ Thumbs.db env/ venv/ .idea/ -frontend/node_modules/ \ No newline at end of file +frontend/node_modules/ +frontend/build/ \ No newline at end of file